diff --git a/README.md b/README.md index 4616298..bd3ad4d 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ CodeOps is a specification-first engineering system for building complex softwar It is designed for work where an unstated assumption can become a correctness defect: programming languages and compilers, financial systems, protocols, distributed services, security-sensitive applications, developer tools, and substantial web applications. -> **Release status:** `0.4.0` is the stable release of the current CodeOps workflow surface. Core workflows, deterministic state, project tracking, domain lenses, Codex-native routing, opt-in delegated technical design, strict scope control with user-owned exploration, and a non-negotiable source-documentation gate are present. A retained Claude 3.12.0 requirements-stage ambiguity benchmark passes; it is not a claim of complete product parity. A real complex-project milestone remains the 1.0 release gate. +> **Release status:** `0.4.0` is the stable release of the current CodeOps workflow surface. Core workflows, Markdown-authoritative progress, project tracking, domain lenses, Codex-native routing, opt-in delegated technical design, strict scope control with user-owned exploration, and a non-negotiable source-documentation gate are present. A retained Claude 3.12.0 requirements-stage ambiguity benchmark passes; it is not a claim of complete product parity. A real complex-project milestone remains the 1.0 release gate. ## The workflow @@ -16,7 +16,7 @@ Intent or existing system → specification ambiguity closure → execution plan → plan ambiguity closure - → readiness proof + → direct artifact readiness checks → specification tests → implementation → verification and independent review @@ -40,11 +40,10 @@ The port begins from the proven CodeOps workflow set: - safe artifact upgrades and migration; and - CodeOps project setup. -Codex-native traceability, readiness proofs, recovery, agent routing, and outcome evaluation are governed by the [port program](plans/codex-port/00-index.md). - -Readiness commands are target-scoped: skills resolve the exact graph node and matching lifecycle -gate, while dependency closure supplies diagnostics without implicitly advancing sibling work. -Schema-1 graphs remain compatible and can be atomically upgraded to schema 2. +Requirements own agreed behavior; plan metadata declares RD mapping; and each +`99-execution-plan.md` owns its task progress. Roadmaps and status summaries are derived. The +minimal lifecycle and the rest of the Codex-native workflow are governed by the +[port program](plans/codex-port/00-index.md). The retained [evaluation evidence](docs/evaluation.md) currently passes compiler, financial, and multi-tenant web ambiguity benchmarks against Claude CodeOps 3.12.0. For a first project, follow the [complex-project quick start](docs/tutorial.md). diff --git a/_shared/layout-convention.md b/_shared/layout-convention.md index 73eaf6f..1119a64 100644 --- a/_shared/layout-convention.md +++ b/_shared/layout-convention.md @@ -85,9 +85,10 @@ they intentionally share the full-set or full-plan scope baseline. - **RD ids reset per feature.** Within `codeops/features/billing/requirements/` the ids run `RD-01, RD-02, …` independently of every other feature. (In flat layout there is one global RD sequence, as before.) -- **Cross-feature references are feature-qualified.** A plan's `00-index.md` declares - `> **Implements**: billing/RD-01` (feature-qualified) in nested layout, or `> **Implements**: - RD-01` in flat layout. The roadmap matcher reads this line. +- **Cross-feature references are feature-qualified.** A plan's `00-index.md` declares one or more + requirements on a single line, for example `> **Implements**: billing/RD-01, billing/RD-02` in + nested layout or `> **Implements**: RD-01, RD-02` in flat layout. The roadmap matcher and plan + status parser read this line. - **Tasks use a separate per-feature sequence** `T-01, T-02, …`, so a task id never collides with an RD id in the same feature. See the task-lane spec for the lightweight task model. diff --git a/_shared/scope-expansion-control.md b/_shared/scope-expansion-control.md index cc6114c..70c4240 100644 --- a/_shared/scope-expansion-control.md +++ b/_shared/scope-expansion-control.md @@ -88,11 +88,11 @@ valid appended event determines current state. Never edit, delete, or reorder an Accepted proposals also maintain dependency-oriented authority links: -| SE ID | Derived artifact or graph target | Relation or kind | Current state | Evidence source | +| SE ID | Derived artifact | Relation or kind | Current state | Evidence source | |---|---|---|---|---| -| `SE-001` | Requirement, specification, test, task, implementation evidence, verification, or roadmap item | `authorizes` or `invalidates` | Current, stale, or superseded | Durable artifact or traceability evidence | +| `SE-001` | Requirement, specification, test, task, implementation evidence, verification, or roadmap item | `authorizes` or `invalidates` | Current, stale, or superseded | Durable artifact evidence | -Implementation linkage belongs in traceability records or other artifact evidence and never in source comments. +Implementation linkage belongs in plan or other artifact evidence and never in source comments. Recompute the proposal table's current state from the latest valid event; the event log remains authoritative history. diff --git a/codeops/features/dependency-aware-readiness/traceability.json b/codeops/features/dependency-aware-readiness/traceability.json deleted file mode 100644 index 50c831b..0000000 --- a/codeops/features/dependency-aware-readiness/traceability.json +++ /dev/null @@ -1,174 +0,0 @@ -{ - "feature": "dependency-aware-readiness", - "nodes": [ - { - "edges": [ - { - "relation": "depends-on", - "target": "dependency-aware-readiness/CRIT-READINESS" - }, - { - "relation": "implemented-by", - "target": "dependency-aware-readiness/TASK-PHASE-3" - }, - { - "relation": "implemented-by", - "target": "dependency-aware-readiness/TASK-PHASE-4" - }, - { - "relation": "implemented-by", - "target": "dependency-aware-readiness/TASK-PHASE-5" - } - ], - "evidence": [ - "plans/dependency-aware-readiness/99-execution-plan.md" - ], - "id": "PLAN-READINESS", - "revision": "sha256:dd1738716e4ac1d9efb6933bb98bd563a664c871acf32be3089dd4c14433ac27", - "semanticSources": [ - { - "digest": "sha256", - "normalization": "utf8-lf-trim-trailing-v1", - "path": "plans/dependency-aware-readiness/00-index.md", - "selector": { - "kind": "whole-file" - } - } - ], - "status": "approved", - "title": "Dependency-aware readiness implementation plan", - "type": "plan", - "validations": [ - { - "gate": "execution", - "relation": "depends-on", - "revision": "sha256:3f8fd10d9984c48eb78bafa1d413f688a0fb5d7d4c9f08155809ab1a2be07325", - "upstream": "dependency-aware-readiness/CRIT-READINESS", - "validatedAt": "2026-07-23T15:15:00Z" - }, - { - "gate": "execution", - "relation": "implemented-by", - "revision": "sha256:b6e3270ec379fdbd5aa1cb4cf03018c8190ba4920da82a9428f04e2a7684bce0", - "upstream": "dependency-aware-readiness/TASK-PHASE-3", - "validatedAt": "2026-07-23T15:15:00Z" - }, - { - "gate": "execution", - "relation": "implemented-by", - "revision": "sha256:b6e3270ec379fdbd5aa1cb4cf03018c8190ba4920da82a9428f04e2a7684bce0", - "upstream": "dependency-aware-readiness/TASK-PHASE-4", - "validatedAt": "2026-07-23T15:15:00Z" - }, - { - "gate": "execution", - "relation": "implemented-by", - "revision": "sha256:5f0010ec1a5aaed32e5f4bc32da2c8710ea8c597a369d2443e076c06f7b0f6e1", - "upstream": "dependency-aware-readiness/TASK-PHASE-5", - "validatedAt": "2026-07-23T15:15:00Z" - } - ] - }, - { - "edges": [ - { - "relation": "tested-by", - "target": "dependency-aware-readiness/TEST-READINESS" - } - ], - "id": "CRIT-READINESS", - "revision": "sha256:3f8fd10d9984c48eb78bafa1d413f688a0fb5d7d4c9f08155809ab1a2be07325", - "semanticSources": [ - { - "digest": "sha256", - "normalization": "utf8-lf-trim-trailing-v1", - "path": "plans/dependency-aware-readiness/07-testing-strategy.md", - "selector": { - "kind": "whole-file" - } - } - ], - "status": "approved", - "title": "Dependency-aware readiness acceptance criteria", - "type": "criterion", - "validations": [] - }, - { - "edges": [], - "id": "TEST-READINESS", - "revision": "sha256:3f8fd10d9984c48eb78bafa1d413f688a0fb5d7d4c9f08155809ab1a2be07325", - "semanticSources": [ - { - "digest": "sha256", - "normalization": "utf8-lf-trim-trailing-v1", - "path": "plans/dependency-aware-readiness/07-testing-strategy.md", - "selector": { - "kind": "whole-file" - } - } - ], - "status": "planned", - "title": "Dependency-aware readiness conformance suite", - "type": "test", - "validations": [] - }, - { - "edges": [], - "id": "TASK-PHASE-3", - "revision": "sha256:b6e3270ec379fdbd5aa1cb4cf03018c8190ba4920da82a9428f04e2a7684bce0", - "semanticSources": [ - { - "digest": "sha256", - "normalization": "utf8-lf-trim-trailing-v1", - "path": "plans/dependency-aware-readiness/03-03-migration-and-invalidation.md", - "selector": { - "kind": "whole-file" - } - } - ], - "status": "implemented", - "title": "Revisions and atomic transitions", - "type": "task", - "validations": [] - }, - { - "edges": [], - "id": "TASK-PHASE-4", - "revision": "sha256:b6e3270ec379fdbd5aa1cb4cf03018c8190ba4920da82a9428f04e2a7684bce0", - "semanticSources": [ - { - "digest": "sha256", - "normalization": "utf8-lf-trim-trailing-v1", - "path": "plans/dependency-aware-readiness/03-03-migration-and-invalidation.md", - "selector": { - "kind": "whole-file" - } - } - ], - "status": "implemented", - "title": "Traceability graph migration", - "type": "task", - "validations": [] - }, - { - "edges": [], - "id": "TASK-PHASE-5", - "revision": "sha256:5f0010ec1a5aaed32e5f4bc32da2c8710ea8c597a369d2443e076c06f7b0f6e1", - "semanticSources": [ - { - "digest": "sha256", - "normalization": "utf8-lf-trim-trailing-v1", - "path": "plans/dependency-aware-readiness/03-04-workflow-integration.md", - "selector": { - "kind": "whole-file" - } - } - ], - "status": "pending", - "title": "Workflow integration and pilot closeout", - "type": "task", - "validations": [] - } - ], - "schema": 2 -} diff --git a/docs/concepts.md b/docs/concepts.md index acd3d26..99c8731 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -2,7 +2,7 @@ ## Recursive ambiguity closure -CodeOps does not ask one round of questions and call the result a specification. Requirements, component specifications, testing strategies, and execution plans each receive their own ambiguity pass. A later discovery can reopen an earlier gate and invalidate downstream readiness. +CodeOps does not ask one round of questions and call the result a specification. Requirements, component specifications, testing strategies, and execution plans each receive their own ambiguity pass. A later discovery can reopen an earlier gate and block affected downstream tasks. ## Material ambiguity @@ -10,20 +10,21 @@ An ambiguity is material when plausible answers can change behavior, semantics, ## Durable artifacts -Markdown owns human-readable requirements, decisions, specifications, tests, and plans. `traceability.json` owns stable typed relationships and state. Roadmaps are derived views. Conversations are useful context but never durable workflow state. +Markdown owns requirements, decisions, specifications, tests, plans, and progress. Requirements +documents own agreed behavior and acceptance criteria. A plan's `00-index.md` declares the RD or +RDs it implements. Its `99-execution-plan.md` is the only mutable task-progress authority. +Roadmaps and status output are derived views. Git supplies history and recovery. ## Readiness -The deterministic state tool validates identifiers, paths, relationships, status, and coverage shape. Semantic review validates truth, completeness, consistency, feasibility, and risk. Both must pass. +Readiness is checked directly from artifacts: required documents exist, material ambiguities are +closed, specification tests precede implementation, and critical/major findings are resolved. +Semantic review validates truth, completeness, consistency, feasibility, and risk. -Readiness is target-scoped. A workflow selects one canonical node or group and one gate profile; -the engine computes its dependency closure and shortest blocker paths. Closure is read context, -not permission to edit or advance siblings. Feature and release nodes are explicit aggregates, -and a release contains only declared members. - -Schema 2 binds semantic sources to normalized revisions and stores relationship snapshots. -Changing upstream meaning therefore makes affected downstream claims stale. Legal lifecycle -changes are atomic compare-and-swap transitions with recovery evidence. +A plan has four derived states: `Ready`, `Executing`, `Done`, and `Blocked`. Tasks use `[ ]` for +not started, `[~]` for implemented with verification pending, `[x]` for verified, and `[!]` for +blocked with a visible reason. Resume selects the first `[~]` task, otherwise the first `[ ]`. +Only a passing verification permits `[~]` to become `[x]`. ## Delegated technical design @@ -60,7 +61,9 @@ inside scope that the user already kept, but it cannot choose `Keep` or activate ## Project tracking -Tracking combines lifecycle—discovery through archive—with readiness, task progress, verification, findings, blockers, dependencies, and deferrals. A new thread can reconstruct the next safe action from repository and Git evidence. +Tracking combines lifecycle—discovery through archive—with derived plan progress, findings, +blockers, dependencies, and deferrals. A new thread reconstructs the next safe action from the +execution plan and Git evidence. ## Agents diff --git a/docs/migration.md b/docs/migration.md index 33676df..6f11781 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -10,20 +10,28 @@ Run the setup skill in dry-run mode first. Review all source-relative-link warni Project instructions belong in `AGENTS.md` for Codex. Do not mechanically copy global Claude instructions or model-routing blocks. Preserve repository commands and conventions that remain true, then express routing and quality policy in `codeops/codeops.json` or `.codex/config.toml`. -## Traceability adoption and schema upgrade +## Legacy workflow-state artifacts -Legacy Markdown artifacts and schema-1 graphs remain readable. Upgrade graphs with a deterministic -preview and explicit resolutions: +Re-run `setup-codeops` on the existing project. It automatically detects legacy graphs before its +normal already-configured no-op. Use `--dry-run` for preview only, or `--yes` for an unattended +apply followed by verification. The same one-shot engine can also be invoked directly: ```bash -python3 /path/to/plugin/scripts/codeops_state.py traceability-upgrade --root . \ - --feature my-feature --preview upgrade.json -python3 /path/to/plugin/scripts/codeops_state.py traceability-upgrade --root . \ - --feature my-feature --preview upgrade.json --resolutions resolutions.json --apply -python3 /path/to/plugin/scripts/codeops_state.py validate --root . +python3 /path/to/plugin/scripts/codeops_plan_migrate.py ./codeops +python3 /path/to/plugin/scripts/codeops_plan_migrate.py ./codeops --apply ``` -Review the preview; resolve every classified ambiguity or explicitly omit the link. Apply is -atomic, creates a protected backup, and reports recovery-required state instead of guessing after -an interrupted write. Do not mark legacy work ready until every active node has valid links, -current source revisions and snapshots, and semantic review passes. +The migrator preserves checklist progress, adds or normalizes each plan's single +`> **Implements**:` declaration, creates a minimal index for roadmap-linked lightweight plans, +validates the four task markers, and deletes active and archived feature `traceability.json` +files. It prefers existing declarations and roadmap links, then consumes the legacy graph once +to recover plan-local requirements before deleting it. Explicit index metadata and a +single-plan/single-RD feature are conservative fallbacks. Archived features without a graph are +outside this bounded conversion. Any missing or ambiguous mapping blocks the entire apply without +changing files. Apply requires a clean Git working tree. + +For blocked legacy work that does not already use `[!]`, first use `upgrade-plan` to record a +visible reason. Do not migrate graph state into another state platform. + +After apply, run `python3 /path/to/plugin/scripts/codeops_plan.py --root . --json`, then the +repository's normal verification. Git history is the rollback and recovery mechanism. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index dc60921..29148c6 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -12,28 +12,23 @@ Start a new Codex thread after installation or update. Confirm the installed cac Open `/hooks`. Non-managed plugin hooks are skipped until their exact definitions are reviewed and trusted. A hook change invalidates its prior trust hash. -## Readiness says no traceability graph exists +## Plan status reports missing metadata -Run `setup-codeops`, then create or migrate a feature and its `traceability.json`. A newly scaffolded empty portfolio is configured but cannot be implementation-ready. +Confirm the plan contains `00-index.md`, `99-execution-plan.md`, and one +`> **Implements**:` line with at least one RD, tracker (`T-*`), or plan-local requirement (`REQ-*`) +target. Only RD targets contribute to the derived requirements summary. An empty portfolio is +configured but has no plan status to report. ## A sibling blocks or advances unexpectedly -Confirm the command includes both `--target` and `--gate`. The reported closure may name sibling -or upstream context, but only the selected target may transition. Roadmap sync repairs derived -rows; it must not mutate authoritative graph state. +Confirm each plan declares only the RDs it implements and each task appears once in its execution +plan. Roadmap sync repairs derived rows; it must not mutate requirements or task checkboxes. -## Upgrade or transition requires recovery +## A task is stuck after interruption -Do not delete the journal, backup, or lock metadata. Create a recovery request with the recorded -operation ID and an explicit `roll-forward` or `rollback` action, then run: - -```bash -python3 /path/to/plugin/scripts/codeops_state.py transition-recover --root . \ - --request recovery-request.json -``` - -Inspect the durable images before choosing the action. A second apply is safe only after recovery -completes. +Read `99-execution-plan.md`. Resume the first `[~]` task and re-run its verification; otherwise +start the first `[ ]` task. For `[!]`, resolve the visible blocker before restoring the appropriate +task marker. Use Git history to inspect or recover interrupted edits. ## Generated agents are missing or stale diff --git a/docs/tutorial.md b/docs/tutorial.md index b4fec86..964de7e 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -2,7 +2,7 @@ This walkthrough starts a new project without skipping the ambiguity gates. -## 1. Initialize durable CodeOps state +## 1. Initialize CodeOps artifacts In a new Codex thread, ask: @@ -64,29 +64,25 @@ blocking safety, correctness, or feasibility uncertainties are reported in eithe explicit `Keep` decision turns a proposal into executable work; `--auto-design` cannot make that decision. -## 4. Prove readiness and execute +## 4. Check the plan and execute -Run the deterministic check from the project root: +Inspect derived plan status from the project root: ```bash -python3 /path/to/codeops/scripts/codeops_state.py readiness --root . \ - --gate requirements --target my-feature/RD-01 -python3 /path/to/codeops/scripts/codeops_state.py readiness --root . \ - --gate execution --target my-feature/PLAN-01 +python3 /path/to/codeops/scripts/codeops_plan.py --root . --json ``` -When semantic preflight and deterministic readiness both pass, ask Codex to use -`exec-plan`. Specification tests establish the oracle before production code. -Each completed task records implementation, verification, review, and roadmap -state before moving to the next task. +The plan's `00-index.md` declares one or more implemented RDs. Before execution, directly confirm +required documents exist, material ambiguities are closed, specification tests precede production +code, and critical/major findings are resolved. Then ask Codex to use `exec-plan`. -Use the exact target ID from `traceability.json`. For a completed task, run the -`task-complete` gate for that task and persist its transition; do not use a feature-wide check. +The executor immediately marks implementation `[~]`, runs verification, and promotes the task to +`[x]` only on success. A blocker uses `[!]` and records a short reason on the task line. ## 5. Resume safely In a fresh thread, ask CodeOps for project status or use the `roadmap` skill. -It reconstructs state from artifacts, traceability, plan checkboxes, findings, -and Git drift. If implementation uncovers a missing upstream decision, reopen -the ambiguity, mark linked downstream work stale, resolve it, and re-run the -affected gates before continuing. +It reconstructs status from requirements, plan metadata, execution checkboxes, findings, and Git +drift. If implementation uncovers a missing upstream decision, reopen the ambiguity, block the +affected task visibly, resolve it, update affected artifacts, and continue from the first `[~]` +task or otherwise the first `[ ]` task. diff --git a/references/artifacts/traceability.md b/references/artifacts/traceability.md deleted file mode 100644 index c44305c..0000000 --- a/references/artifacts/traceability.md +++ /dev/null @@ -1,87 +0,0 @@ -# Traceability contract - -CodeOps schema 2 stores one `traceability.json` in each feature directory. It is an index over authoritative Markdown artifacts and implementation evidence, not a replacement for them. - -## Required chain - -For every behavior delivered by a feature, the graph must establish: - -```text -requirement - → specification or invariant - → acceptance criterion - → specification test - → execution task - → implementation evidence - → verification evidence -``` - -Decisions and resolved ambiguities link to every downstream node they affect. Findings link to the reviewed task, implementation, or verification node. Approved deferrals record their risk and link to the affected nodes. - -## Status vocabulary - -| Type | Expected statuses | -|---|---| -| ambiguity | `open`, `resolved`, `deferred-approved` | -| deferral | `proposed`, `approved`, `expired`, `resolved` | -| requirement/specification/criterion/invariant | `draft`, `approved`, `stale`, `superseded` | -| test | `planned`, `red-confirmed`, `passing`, `blocked`, `stale` | -| task | `pending`, `implemented`, `verified`, `blocked`, `stale` | -| implementation | `present`, `stale`, `superseded`, `reverted` | -| verification | `passing`, `failing`, `stale` | -| finding | `open`, `accepted`, `resolved` | - -The validator rejects unknown structure and broken links. Readiness additionally requires zero open material ambiguities, zero unapproved deferrals, zero critical/major open findings, and complete forward coverage for the active gate. - -Every workflow resolves a canonical graph target and supplies its matching gate: -`requirements`, `specifications`, `audit`, `plan`, `execution`, `task-complete`, -`feature-acceptance`, or `release`. For example: - -```bash -codeops_state.py readiness --root . --gate plan --target billing/RD-03 -``` - -Target closure supplies dependencies and blocker paths as read context; it never authorizes -editing or advancing siblings. Feature and release aggregates are explicit nodes. Schema 2 also -records semantic sources, deterministic revisions, and relationship snapshots so changed -upstream meaning makes downstream state stale. Lifecycle changes use public compare-and-swap -`transition` requests. - -An exact transition request is a closed JSON object: - -```json -{ - "schema": 1, - "operationId": "unique-operation-id", - "target": "feature/RD-01", - "expected": {"status": "draft", "revision": "sha256:..."}, - "requested": {"status": "approved"}, - "gate": "requirements", - "sourceUpdates": [], - "validationAdditions": [], - "validationRemovals": [], - "staleReason": null, - "evidence": {"summary": "durable evidence summary"} -} -``` - -Submit it with `codeops_state.py transition --root . --request `. Use the gate -owned by the target type; the engine validates the projected portfolio before committing. - -Schema 1 remains readable. Upgrade it with `traceability-upgrade`: generate a preview, provide -closed-form resolutions for ambiguous links, apply atomically, then validate. Do not hand-convert -graphs or delete recovery journals. - -Node IDs are feature-local because RD and task sequences reset per feature. Links within one graph -use the local node ID. A deliberate cross-feature link uses `/`; an unqualified -link never resolves against a sibling feature by coincidence. - -When a high- or critical-risk ambiguity reopens, every linked downstream -requirement, specification, invariant, criterion, test, task, implementation, -and verification must be marked `stale` (or returned to another non-approved -work state). Readiness reports both the reopened ambiguity and any downstream -artifact that still falsely claims completion. - -## One owner per fact - -The graph stores identity, state, and relationships. Markdown owns semantic content. Generated roadmaps own neither: they summarize the graph and on-disk execution state. diff --git a/schemas/traceability-v2.schema.json b/schemas/traceability-v2.schema.json deleted file mode 100644 index 0d5ae28..0000000 --- a/schemas/traceability-v2.schema.json +++ /dev/null @@ -1,283 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/blendsdk/codex-codeops/schemas/traceability-v2.schema.json", - "title": "CodeOps traceability graph schema 2", - "type": "object", - "required": ["schema", "feature", "nodes"], - "additionalProperties": false, - "properties": { - "schema": {"const": 2}, - "feature": {"type": "string", "pattern": "^(?:[A-Za-z0-9]|_[A-Za-z0-9])[A-Za-z0-9._-]*$"}, - "updated": {"type": "string"}, - "nodes": { - "type": "array", - "items": {"$ref": "#/$defs/node"} - } - }, - "$defs": { - "canonicalIdentity": { - "type": "string", - "pattern": "^(?:[A-Za-z0-9]|_[A-Za-z0-9])[A-Za-z0-9._-]*/[A-Z][A-Z0-9]*-[A-Za-z0-9._-]+$" - }, - "revision": { - "type": "string", - "pattern": "^sha256:[0-9a-f]{64}$" - }, - "selector": { - "oneOf": [ - { - "type": "object", - "required": ["kind"], - "additionalProperties": false, - "properties": {"kind": {"const": "whole-file"}} - }, - { - "type": "object", - "required": ["kind", "value"], - "additionalProperties": false, - "properties": { - "kind": {"const": "heading"}, - "value": {"type": "string", "minLength": 1} - } - } - ] - }, - "semanticSource": { - "type": "object", - "required": ["path", "selector", "normalization", "digest"], - "additionalProperties": false, - "properties": { - "path": {"type": "string", "minLength": 1}, - "selector": {"$ref": "#/$defs/selector"}, - "normalization": {"const": "utf8-lf-trim-trailing-v1"}, - "digest": {"const": "sha256"} - } - }, - "edge": { - "type": "object", - "required": ["relation", "target"], - "additionalProperties": false, - "properties": { - "relation": { - "enum": [ - "specified-by", - "accepted-by", - "tested-by", - "implemented-by", - "verified-by", - "affected-by", - "depends-on", - "consumes-contract", - "related", - "release-coupled" - ] - }, - "target": {"$ref": "#/$defs/canonicalIdentity"}, - "requiredMaturity": {"enum": ["provisional", "stable", "frozen"]} - }, - "allOf": [ - { - "if": { - "properties": {"relation": {"const": "consumes-contract"}}, - "required": ["relation"] - }, - "then": {"required": ["requiredMaturity"]}, - "else": {"not": {"required": ["requiredMaturity"]}} - } - ] - }, - "validation": { - "type": "object", - "required": ["upstream", "relation", "revision", "gate", "validatedAt"], - "additionalProperties": false, - "properties": { - "upstream": {"$ref": "#/$defs/canonicalIdentity"}, - "relation": { - "enum": [ - "specified-by", - "accepted-by", - "tested-by", - "implemented-by", - "verified-by", - "affected-by", - "depends-on", - "consumes-contract", - "release-coupled" - ] - }, - "revision": {"$ref": "#/$defs/revision"}, - "gate": { - "enum": [ - "requirements", - "specifications", - "plan", - "audit", - "execution", - "task-complete", - "feature-acceptance", - "release" - ] - }, - "validatedAt": {"type": "string", "format": "date-time"} - } - }, - "node": { - "type": "object", - "required": [ - "id", - "type", - "title", - "status", - "semanticSources", - "revision", - "edges", - "validations" - ], - "additionalProperties": false, - "properties": { - "id": { - "type": "string", - "pattern": "^[A-Z][A-Z0-9]*-[A-Za-z0-9._-]+$" - }, - "type": { - "enum": [ - "requirement", - "requirement-set", - "ambiguity", - "decision", - "invariant", - "specification", - "criterion", - "contract", - "planning-group", - "plan", - "feature", - "audit-artifact", - "release", - "test", - "task", - "implementation", - "verification", - "finding", - "deferral" - ] - }, - "title": {"type": "string", "minLength": 1}, - "status": {"type": "string", "minLength": 1}, - "semanticSources": { - "type": "array", - "minItems": 1, - "items": {"$ref": "#/$defs/semanticSource"} - }, - "revision": {"$ref": "#/$defs/revision"}, - "edges": { - "type": "array", - "items": {"$ref": "#/$defs/edge"} - }, - "validations": { - "type": "array", - "items": {"$ref": "#/$defs/validation"} - }, - "maturity": {"enum": ["provisional", "stable", "frozen"]}, - "members": { - "type": "array", - "minItems": 1, - "uniqueItems": true, - "items": {"$ref": "#/$defs/canonicalIdentity"} - }, - "memberGates": { - "type": "object", - "additionalProperties": { - "enum": ["task-complete", "feature-acceptance", "release"] - } - }, - "auditStage": { - "enum": [ - "requirements", - "specifications", - "plan", - "execution", - "task-complete", - "feature-acceptance", - "release" - ] - }, - "required": { - "type": "array", - "uniqueItems": true, - "items": {"$ref": "#/$defs/canonicalIdentity"} - }, - "optional": { - "type": "array", - "uniqueItems": true, - "items": {"$ref": "#/$defs/canonicalIdentity"} - }, - "excluded": { - "type": "array", - "uniqueItems": true, - "items": {"$ref": "#/$defs/canonicalIdentity"} - }, - "evidence": { - "type": "array", - "uniqueItems": true, - "items": {"type": "string", "minLength": 1} - }, - "risk": {"enum": ["low", "medium", "high", "critical"]} - }, - "allOf": [ - { - "if": { - "properties": {"type": {"const": "contract"}}, - "required": ["type"] - }, - "then": {"required": ["maturity"]}, - "else": {"not": {"required": ["maturity"]}} - }, - { - "if": { - "properties": { - "type": { - "enum": ["requirement-set", "feature", "planning-group"] - } - }, - "required": ["type"] - }, - "then": {"required": ["members"]}, - "else": {"not": {"required": ["members"]}} - }, - { - "if": { - "properties": {"type": {"const": "feature"}}, - "required": ["type"] - }, - "then": {"required": ["memberGates"]}, - "else": {"not": {"required": ["memberGates"]}} - }, - { - "if": { - "properties": {"type": {"const": "audit-artifact"}}, - "required": ["type"] - }, - "then": {"required": ["auditStage"]}, - "else": {"not": {"required": ["auditStage"]}} - }, - { - "if": { - "properties": {"type": {"const": "release"}}, - "required": ["type"] - }, - "then": {"required": ["required", "optional", "excluded"]}, - "else": { - "not": { - "anyOf": [ - {"required": ["required"]}, - {"required": ["optional"]}, - {"required": ["excluded"]} - ] - } - } - } - ] - } - } -} diff --git a/schemas/traceability.schema.json b/schemas/traceability.schema.json deleted file mode 100644 index 67b40d8..0000000 --- a/schemas/traceability.schema.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/blendsdk/codex-codeops/schemas/traceability.schema.json", - "title": "CodeOps traceability graph", - "type": "object", - "required": ["schema", "feature", "nodes"], - "additionalProperties": false, - "properties": { - "schema": {"const": 1}, - "feature": {"type": "string", "minLength": 1}, - "updated": {"type": "string"}, - "nodes": { - "type": "array", - "items": { - "type": "object", - "required": ["id", "type", "title", "status", "path", "links"], - "additionalProperties": false, - "properties": { - "id": {"type": "string", "pattern": "^[A-Z][A-Z0-9]*-[A-Za-z0-9._-]+$"}, - "type": { - "enum": ["requirement", "ambiguity", "decision", "invariant", "specification", "criterion", "test", "task", "implementation", "verification", "finding", "deferral"] - }, - "title": {"type": "string", "minLength": 1}, - "status": {"type": "string", "minLength": 1}, - "path": {"type": "string", "minLength": 1}, - "links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, - "evidence": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, - "risk": {"enum": ["low", "medium", "high", "critical"]} - } - } - } - } -} diff --git a/scripts/codeops-roadmap-sync.sh b/scripts/codeops-roadmap-sync.sh index b9cfef7..bce66c1 100755 --- a/scripts/codeops-roadmap-sync.sh +++ b/scripts/codeops-roadmap-sync.sh @@ -16,8 +16,8 @@ # trailing ` · …` / ` (…)` annotation is preserved verbatim); a hand-maintained value (e.g. `n/a`, # free text) is left untouched and reported as informational HELD, and that row's Status is not # re-rolled. It never infers or changes a Stage cell, never touches Notes or prose, and never -# executes repo data. When schema-2 traceability exists, it validates the authoritative portfolio -# before touching the derived view; lifecycle skills query each exact target and own stage judgment. +# executes repo data. Execution-plan checklists and plan metadata are the authoritative inputs; +# roadmaps remain derived views. # # Usage: # codeops-roadmap-sync.sh # rewrite the computed values in place @@ -44,14 +44,6 @@ command -v python3 >/dev/null 2>&1 || { exit 3 } -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -if find codeops/features -path '*/traceability.json' -print -quit 2>/dev/null | grep -q .; then - if ! python3 "$SCRIPT_DIR/codeops_state.py" validate --root . >/dev/null; then - printf 'ERROR: authoritative traceability is invalid; roadmap sync refused.\n' >&2 - exit 1 - fi -fi - # Layout detection — the canonical grep from _shared/layout-convention.md. layout="flat" if [[ -f codeops/.codeops.yml ]] && grep -Eq '^codeopsLayout:[[:space:]]*nested[[:space:]]*$' codeops/.codeops.yml; then diff --git a/scripts/codeops_plan.py b/scripts/codeops_plan.py new file mode 100644 index 0000000..48435c4 --- /dev/null +++ b/scripts/codeops_plan.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +"""Derive CodeOps plan progress directly from Markdown artifacts. + +This module is intentionally a read-only parser. Markdown remains authoritative; +the helper has no state store, transition API, revision counter, lock, or journal. +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from dataclasses import asdict, dataclass +from pathlib import Path + + +IMPLEMENTS_RE = re.compile(r"^>[ \t]*\*\*Implements\*\*:[ \t]*(.+?)[ \t]*$", re.MULTILINE) +TARGET_RE = re.compile( + r"(? str: + return {" ": "not-started", "~": "verification-pending", "x": "verified", "!": "blocked"}[ + self.marker.lower() + ] + + +@dataclass(frozen=True) +class PlanStatus: + plan: str + implements: tuple[str, ...] + lifecycle: str + total: int + not_started: int + verification_pending: int + verified: int + blocked: int + next_task: str | None + problems: tuple[str, ...] + + +def parse_implements(index_text: str) -> tuple[str, ...]: + """Return ordered, de-duplicated requirement or tracker targets for a plan.""" + match = IMPLEMENTS_RE.search(index_text) + if not match: + return () + return tuple(dict.fromkeys(TARGET_RE.findall(match.group(1)))) + + +def parse_tasks(execution_text: str) -> tuple[Task, ...]: + """Parse only the four authoritative execution checklist markers.""" + return tuple(Task(marker.lower(), text.strip()) for marker, text in TASK_RE.findall(execution_text)) + + +def next_task(tasks: tuple[Task, ...]) -> Task | None: + """Resume verification first, otherwise start the first untouched task.""" + return next((task for task in tasks if task.marker == "~"), None) or next( + (task for task in tasks if task.marker == " "), None + ) + + +def lifecycle(tasks: tuple[Task, ...]) -> str: + """Derive the plan's Ready/Executing/Done/Blocked lifecycle from its checklist.""" + if any(task.marker == "!" for task in tasks): + return "Blocked" + if tasks and all(task.marker == "x" for task in tasks): + return "Done" + if any(task.marker in {"~", "x"} for task in tasks): + return "Executing" + return "Ready" + + +def inspect_plan(plan_dir: Path, root: Path | None = None) -> PlanStatus: + """Inspect one plan directory without mutating it.""" + index_path = plan_dir / "00-index.md" + execution_path = plan_dir / "99-execution-plan.md" + problems: list[str] = [] + index_text = index_path.read_text(encoding="utf-8") if index_path.is_file() else "" + execution_text = execution_path.read_text(encoding="utf-8") if execution_path.is_file() else "" + if not index_path.is_file(): + problems.append("missing required 00-index.md") + if not execution_path.is_file(): + problems.append("missing required 99-execution-plan.md") + implements = parse_implements(index_text) + if not implements: + problems.append("00-index.md must declare one or more requirement or tracker targets in **Implements**") + tasks = parse_tasks(execution_text) + if not tasks: + problems.append("99-execution-plan.md contains no execution tasks") + for task in tasks: + if task.marker == "!" and not BLOCKED_REASON_RE.search(task.text): + problems.append(f"blocked task lacks a visible 'Blocked: ': {task.text}") + counts = {marker: sum(task.marker == marker for task in tasks) for marker in (" ", "~", "x", "!")} + candidate = next_task(tasks) + display = str(plan_dir.resolve()) + if root is not None: + try: + display = plan_dir.resolve().relative_to(root.resolve()).as_posix() + except ValueError: + pass + return PlanStatus( + plan=display, + implements=implements, + lifecycle=lifecycle(tasks), + total=len(tasks), + not_started=counts[" "], + verification_pending=counts["~"], + verified=counts["x"], + blocked=counts["!"], + next_task=candidate.text if candidate else None, + problems=tuple(problems), + ) + + +def discover_plans(root: Path) -> tuple[Path, ...]: + """Discover flat and nested plan directories by their execution plan.""" + paths = set((root / "plans").glob("*/99-execution-plan.md")) + paths.update((root / "codeops" / "features").glob("*/plans/*/99-execution-plan.md")) + return tuple(sorted(path.parent for path in paths)) + + +def rd_delivery(statuses: tuple[PlanStatus, ...]) -> dict[str, str]: + """Derive each RD's delivery state from its implementing plan or plans.""" + grouped: dict[str, list[str]] = {} + for status in statuses: + for rd_id in status.implements: + if not rd_id.rsplit("/", 1)[-1].startswith("RD-"): + continue + grouped.setdefault(rd_id, []).append(status.lifecycle) + result: dict[str, str] = {} + for rd_id, states in grouped.items(): + if "Blocked" in states: + result[rd_id] = "Blocked" + elif states and all(state == "Done" for state in states): + result[rd_id] = "Done" + elif any(state == "Executing" for state in states): + result[rd_id] = "Executing" + else: + result[rd_id] = "Ready" + return result + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, default=Path.cwd()) + parser.add_argument("--plan", type=Path, help="inspect one plan directory") + parser.add_argument("--json", action="store_true") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + root = args.root.resolve() + plan_dirs = (args.plan if args.plan.is_absolute() else root / args.plan,) if args.plan else discover_plans(root) + statuses = tuple(inspect_plan(path, root) for path in plan_dirs) + payload = {"plans": [asdict(status) for status in statuses], "requirements": rd_delivery(statuses)} + if args.json: + print(json.dumps(payload, indent=2)) + else: + for status in statuses: + progress = f"{status.verified}/{status.total} verified" + print(f"{status.plan}: {status.lifecycle} ({progress})") + if status.next_task: + print(f" next: {status.next_task}") + for problem in status.problems: + print(f" ERROR: {problem}") + return 1 if any(status.problems for status in statuses) else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/codeops_plan_migrate.py b/scripts/codeops_plan_migrate.py new file mode 100644 index 0000000..165d3c4 --- /dev/null +++ b/scripts/codeops_plan_migrate.py @@ -0,0 +1,318 @@ +#!/usr/bin/env python3 +"""One-shot migration from traceability graphs to authoritative Markdown plans. + +The command previews by default. ``--apply`` updates plan ``Implements`` metadata +and deletes obsolete feature ``traceability.json`` files only when every mapping +is unambiguous. It creates no replacement state or compatibility layer. +""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + +try: + from scripts.codeops_plan import BLOCKED_REASON_RE, IMPLEMENTS_RE, parse_implements, parse_tasks +except ModuleNotFoundError: # Direct execution adds scripts/, not the repository root, to sys.path. + from codeops_plan import BLOCKED_REASON_RE, IMPLEMENTS_RE, parse_implements, parse_tasks + + +TARGET_ID_RE = re.compile( + r"^(?:RD-(?:[A-Za-z0-9]+-)*\d+|T-\d+|REQ-[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*)$" +) +RD_FILE_RE = re.compile(r"^(RD-(?:[A-Za-z0-9]+-)*\d+)(?:[-.].*)?$", re.IGNORECASE) +MARKDOWN_LINK_RE = re.compile(r"\[[^]]*\]\(([^)]+)\)") +HEADING_RE = re.compile(r"^#\s+(.+?)\s*$", re.MULTILINE) + + +@dataclass(frozen=True) +class PlanMigration: + index: Path + implements: tuple[str, ...] + source: str + updated_text: str + changed: bool + + +@dataclass(frozen=True) +class Migration: + root: Path + plans: tuple[PlanMigration, ...] + graphs: tuple[Path, ...] + problems: tuple[str, ...] + + +def _qualify(feature: str, rd_id: str) -> str: + return rd_id if "/" in rd_id else f"{feature}/{rd_id}" + + +def _rd_ids(feature_dir: Path) -> tuple[str, ...]: + result: list[str] = [] + requirements = feature_dir / "requirements" + if requirements.is_dir(): + for path in requirements.glob("RD-*.md"): + match = RD_FILE_RE.match(path.name) + if match: + result.append(_qualify(feature_dir.name, match.group(1).upper())) + return tuple(dict.fromkeys(sorted(result))) + + +def _roadmap_links(feature_dir: Path) -> dict[Path, tuple[str, ...]]: + """Map plan directories to requirement or tracker IDs from roadmap rows.""" + roadmap = feature_dir / "00-roadmap.md" + if not roadmap.is_file(): + return {} + found: dict[Path, list[str]] = {} + for line in roadmap.read_text(encoding="utf-8").splitlines(): + if not line.startswith("|"): + continue + cells = [cell.strip() for cell in line.strip().strip("|").split("|")] + if len(cells) < 4 or not TARGET_ID_RE.fullmatch(cells[0]): + continue + for link in MARKDOWN_LINK_RE.findall(cells[3]): + link_path = link.split("#", 1)[0] + if not link_path.endswith(("00-index.md", "99-execution-plan.md")): + continue + plan_dir = (roadmap.parent / link_path).resolve().parent + found.setdefault(plan_dir, []).append(_qualify(feature_dir.name, cells[0])) + return {path: tuple(dict.fromkeys(ids)) for path, ids in found.items()} + + +def _source_plan(source_path: str, feature: str) -> str | None: + parts = Path(source_path.replace("\\", "/")).parts + marker = ("features", feature, "plans") + for offset in range(len(parts) - 3): + if tuple(parts[offset : offset + 3]) == marker: + return parts[offset + 3] + return None + + +def _graph_links(feature_dir: Path) -> tuple[dict[Path, tuple[str, ...]], str | None]: + """Extract the last useful plan mapping before the legacy graph is deleted.""" + graph = feature_dir / "traceability.json" + if not graph.is_file(): + return {}, None + try: + payload = json.loads(graph.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + return {}, f"{graph}: cannot read legacy graph: {error}" + found: dict[Path, list[str]] = {} + requirements_by_plan: dict[str, list[str]] = {} + plan_nodes: dict[str, list[dict[str, object]]] = {} + for node in payload.get("nodes", []): + if not isinstance(node, dict): + continue + source_plans = { + plan + for source in node.get("semanticSources", []) + if isinstance(source, dict) + and isinstance(source.get("path"), str) + and (plan := _source_plan(source["path"], feature_dir.name)) + } + node_id = node.get("id") + if ( + node.get("type") == "requirement" + and isinstance(node_id, str) + and TARGET_ID_RE.fullmatch(node_id) + ): + for plan in source_plans: + requirements_by_plan.setdefault(plan, []).append(_qualify(feature_dir.name, node_id)) + if node.get("type") == "plan": + for plan in source_plans: + plan_nodes.setdefault(plan, []).append(node) + for plan, nodes in plan_nodes.items(): + targets: list[str] = [] + for node in nodes: + for edge in node.get("edges", []): + if not isinstance(edge, dict) or edge.get("relation") != "depends-on": + continue + target = edge.get("target") + if isinstance(target, str) and TARGET_ID_RE.fullmatch(target.rsplit("/", 1)[-1]): + targets.append(_qualify(feature_dir.name, target)) + targets = targets or requirements_by_plan.get(plan, []) + if targets: + found[(feature_dir / "plans" / plan).resolve()] = list(dict.fromkeys(targets)) + for plan, targets in requirements_by_plan.items(): + found.setdefault((feature_dir / "plans" / plan).resolve(), list(dict.fromkeys(targets))) + return {path: tuple(targets) for path, targets in found.items()}, None + + +def _metadata_targets(feature_dir: Path, index_text: str) -> tuple[str, ...]: + """Read explicit targets from the title and index metadata block only.""" + leading = index_text.splitlines()[:20] + metadata = "\n".join(line for line in leading if line.startswith(("# ", ">"))) + return tuple( + dict.fromkeys( + _qualify(feature_dir.name, match.group(0)) + for match in re.finditer( + r"RD-(?:[A-Za-z0-9]+-)*\d+|T-\d+|REQ-[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*", + metadata, + ) + ) + ) + + +def _new_index(execution_text: str, implements: tuple[str, ...]) -> str: + heading = HEADING_RE.search(execution_text) + title = heading.group(1) if heading else "Implementation Plan" + return ( + f"# {title}\n\n> **Implements**: {', '.join(implements)}\n" + "> **CodeOps Artifact Schema**: 1\n" + ) + + +def _replace_implements(index_text: str, implements: tuple[str, ...]) -> str: + line = f"> **Implements**: {', '.join(implements)}" + if IMPLEMENTS_RE.search(index_text): + return IMPLEMENTS_RE.sub(line, index_text, count=1) + lines = index_text.splitlines(keepends=True) + insert_at = 1 if lines and lines[0].lstrip().startswith("#") else 0 + newline = "\r\n" if "\r\n" in index_text else "\n" + insertion = line + newline + if insert_at and len(lines) > insert_at and lines[insert_at].strip(): + insertion = newline + insertion + newline + lines.insert(insert_at, insertion) + return "".join(lines) + + +def inspect_migration(codeops_root: Path) -> Migration: + root = codeops_root.resolve() + problems: list[str] = [] + plans: list[PlanMigration] = [] + if root.name.lower() != "codeops" or not (root / "features").is_dir(): + return Migration(root, (), (), ("target must be a nested CodeOps directory named 'codeops' with features/",)) + + feature_dirs = [path for path in (root / "features").iterdir() if path.is_dir()] + archive = root / "_archive" + if archive.is_dir(): + feature_dirs.extend( + path for path in archive.iterdir() if path.is_dir() and (path / "traceability.json").is_file() + ) + graphs = tuple( + sorted( + feature_dir / "traceability.json" + for feature_dir in feature_dirs + if (feature_dir / "traceability.json").is_file() + ) + ) + for feature_dir in sorted(feature_dirs): + plan_dirs = sorted((feature_dir / "plans").glob("*/99-execution-plan.md")) + roadmap_links = _roadmap_links(feature_dir) + graph_links, graph_problem = _graph_links(feature_dir) + if graph_problem: + problems.append(graph_problem) + feature_rds = _rd_ids(feature_dir) + for execution in plan_dirs: + plan_dir = execution.parent + index = plan_dir / "00-index.md" + index_text = index.read_text(encoding="utf-8") if index.is_file() else "" + declared = tuple(_qualify(feature_dir.name, item) for item in parse_implements(index_text)) + if declared: + implements, source = declared, "existing declaration" + elif plan_dir.resolve() in roadmap_links: + implements, source = roadmap_links[plan_dir.resolve()], "feature roadmap" + elif plan_dir.resolve() in graph_links: + implements, source = graph_links[plan_dir.resolve()], "legacy graph" + elif metadata := _metadata_targets( + feature_dir, index_text or execution.read_text(encoding="utf-8") + ): + implements, source = metadata, "plan metadata" + elif len(plan_dirs) == 1 and len(feature_rds) == 1: + implements, source = feature_rds, "single plan and single RD" + else: + problems.append( + f"{index}: cannot infer implemented targets; add an Implements declaration or roadmap link" + ) + continue + execution_text = execution.read_text(encoding="utf-8") + tasks = parse_tasks(execution_text) + if not tasks: + problems.append(f"{execution}: contains no execution tasks") + for task in tasks: + if task.marker == "!" and not BLOCKED_REASON_RE.search(task.text): + problems.append(f"{execution}: blocked task lacks a visible 'Blocked: ': {task.text}") + updated = ( + _replace_implements(index_text, implements) + if index_text + else _new_index(execution_text, implements) + ) + plans.append(PlanMigration(index, implements, source, updated, updated != index_text)) + return Migration(root, tuple(plans), graphs, tuple(problems)) + + +def _require_clean_git_tree(root: Path) -> str | None: + probe = subprocess.run( + ["git", "-C", str(root), "rev-parse", "--show-toplevel"], + text=True, + capture_output=True, + check=False, + ) + if probe.returncode: + return "--apply requires the codeops directory to be inside a Git repository" + repo = Path(probe.stdout.strip()).resolve() + status = subprocess.run( + ["git", "-C", str(repo), "status", "--porcelain"], + text=True, + capture_output=True, + check=False, + ) + if status.returncode: + return "unable to inspect Git working tree" + if status.stdout.strip(): + return "--apply requires a clean Git working tree" + return None + + +def apply_migration(migration: Migration) -> None: + for plan in migration.plans: + plan.index.write_text(plan.updated_text, encoding="utf-8", newline="") + for graph in migration.graphs: + graph.unlink() + + +def render(migration: Migration, applying: bool) -> None: + mode = "APPLY" if applying else "PREVIEW" + print(f"codeops-plan-migrate: {mode} {migration.root}") + for plan in migration.plans: + relative = plan.index.relative_to(migration.root).as_posix() + values = ", ".join(plan.implements) + action = "UPDATE" if plan.changed else "KEEP" + print(f" {action} {relative}: {values} ({plan.source})") + for graph in migration.graphs: + print(f" DELETE {graph.relative_to(migration.root).as_posix()}") + for problem in migration.problems: + print(f" BLOCKED {problem}") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("codeops", type=Path, help="nested codeops/ directory to migrate") + parser.add_argument("--apply", action="store_true", help="apply the previewed Markdown edits and deletions") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + migration = inspect_migration(args.codeops) + if migration.problems: + render(migration, args.apply) + return 1 + if args.apply: + git_problem = _require_clean_git_tree(migration.root) + if git_problem: + print(f"codeops-plan-migrate: BLOCKED {git_problem}", file=sys.stderr) + return 1 + apply_migration(migration) + render(migration, args.apply) + if not args.apply: + print("Run again with --apply to perform this migration.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/codeops_state.py b/scripts/codeops_state.py deleted file mode 100755 index f7b67c9..0000000 --- a/scripts/codeops_state.py +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env python3 -"""Dispatch CodeOps state commands to the graph schema that owns their semantics.""" - -from __future__ import annotations - -import sys -from pathlib import Path - -from codeops_state_lib import legacy -from codeops_state_lib.v2 import has_schema_two, run as run_v2 - - -def _root(argv: list[str]) -> Path: - if "--root" not in argv: - return Path(".").resolve() - index = argv.index("--root") - if index + 1 >= len(argv): - return Path(".").resolve() - return Path(argv[index + 1]).resolve() - - -def main() -> int: - argv = sys.argv[1:] - root = _root(argv) - use_v2 = ( - (argv and argv[0] in {"transition", "transition-recover", "traceability-upgrade"}) - or "--target" in argv - or "--gate" in argv - or has_schema_two(root) - or (root / "codeops" / "codeops.json").is_file() - or (root / "schemas" / "traceability-v2.schema.json").is_file() - ) - return run_v2(argv) if use_v2 else legacy.main() - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/codeops_state_lib/__init__.py b/scripts/codeops_state_lib/__init__.py deleted file mode 100644 index fad2b85..0000000 --- a/scripts/codeops_state_lib/__init__.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Versioned state models and deterministic readiness operations for CodeOps.""" - -from .models import ( - Edge, - Graph, - Node, - SemanticSource, - SourceSelector, - ValidationSnapshot, -) - -__all__ = [ - "Edge", - "Graph", - "Node", - "SemanticSource", - "SourceSelector", - "ValidationSnapshot", -] diff --git a/scripts/codeops_state_lib/closure.py b/scripts/codeops_state_lib/closure.py deleted file mode 100644 index 3a6d4f7..0000000 --- a/scripts/codeops_state_lib/closure.py +++ /dev/null @@ -1,181 +0,0 @@ -"""Target, trace, dependency, planning-group, and release closure.""" - -from __future__ import annotations - -from collections import deque -from dataclasses import dataclass - -from .models import Node - - -TRACE_BY_GATE = { - "requirements": {"accepted-by", "affected-by"}, - "specifications": {"specified-by", "accepted-by", "affected-by"}, - "plan": {"specified-by", "accepted-by", "affected-by"}, - "execution": {"specified-by", "accepted-by", "implemented-by", "affected-by"}, - "task-complete": {"tested-by", "implemented-by", "verified-by", "affected-by"}, -} - - -@dataclass(frozen=True) -class Closure: - members: tuple[str, ...] - paths: dict[str, tuple[str, ...]] - group_expansions: dict[str, tuple[str, ...]] - excluded: tuple[str, ...] - - -def dependency_paths(target: str, nodes: dict[str, Node]) -> dict[str, tuple[str, ...]]: - """Return real shortest paths, retaining dangling endpoint identities.""" - paths: dict[str, tuple[str, ...]] = {target: (target,)} - pending = deque([target]) - while pending: - identity = pending.popleft() - node = nodes.get(identity) - if node is None: - continue - additions = [ - edge.target - for edge in node.edges - if edge.relation in {"depends-on", "consumes-contract", "release-coupled"} - ] - if node.node_type in {"feature", "planning-group", "requirement-set"}: - additions.extend(node.members) - if node.node_type == "release": - additions.extend(node.required) - for addition in sorted(set(additions)): - if addition in paths: - continue - paths[addition] = paths[identity] + (addition,) - if addition in nodes: - pending.append(addition) - return paths - - -def build_closure(target: str, gate: str, nodes: dict[str, Node]) -> Closure: - groups = { - identity: tuple(node.members) - for identity, node in nodes.items() - if node.node_type == "planning-group" - } - group_by_member = { - member: identity for identity, members in groups.items() for member in members - } - release_coupled: dict[str, set[str]] = {} - if gate == "release": - for identity, node in nodes.items(): - for edge in node.edges: - if edge.relation == "release-coupled": - release_coupled.setdefault(identity, set()).add(edge.target) - release_coupled.setdefault(edge.target, set()).add(identity) - paths: dict[str, tuple[str, ...]] = {target: (target,)} - pending = deque([target]) - active_groups: dict[str, tuple[str, ...]] = {} - while pending: - identity = pending.popleft() - node = nodes[identity] - additions: list[str] = [] - group = group_by_member.get(identity) - if group: - active_groups[group] = groups[group] - additions.extend(groups[group]) - additions.extend( - edge.target - for edge in node.edges - if edge.relation in {"depends-on", "consumes-contract"} - or edge.relation == "affected-by" - or edge.relation in TRACE_BY_GATE.get(gate, set()) - or (gate == "release" and edge.relation == "release-coupled") - ) - if gate == "release": - additions.extend(release_coupled.get(identity, ())) - if node.node_type == "requirement-set": - additions.extend(node.members) - if gate == "feature-acceptance" and identity == target: - additions.extend(node.members) - if gate == "release" and identity == target: - additions.extend(node.required) - for addition in sorted(set(additions)): - if addition in paths: - continue - paths[addition] = paths[identity] + (addition,) - if addition in nodes: - pending.append(addition) - members = tuple(sorted(paths)) - excluded = tuple(sorted(set(nodes) - set(members))) - return Closure(members, paths, active_groups, excluded) - - -def build_scope_closure( - target: str, - gate: str, - nodes: dict[str, Node], - seen: frozenset[tuple[str, str]] = frozenset(), -) -> Closure: - """Build gate-aware structural scope, including recursive aggregate obligations.""" - key = (target, gate) - base = build_closure(target, gate, nodes) - if key in seen or target not in nodes: - return base - requests: list[tuple[str, str]] = [] - node = nodes[target] - if gate == "feature-acceptance": - requests.extend( - (member, node.member_gates[member]) - for member in node.members - if member in nodes - ) - elif gate == "release": - terminal = set(node.required) - for identity in base.members: - current = nodes.get(identity) - if current is None: - continue - for edge in current.edges: - if edge.relation == "release-coupled": - terminal.add(identity) - terminal.add(edge.target) - terminal.discard(target) - mapping = { - "feature": "feature-acceptance", - "release": "release", - "task": "task-complete", - "plan": "execution", - "requirement": "specifications", - "requirement-set": "requirements", - "specification": "specifications", - "invariant": "specifications", - "contract": "specifications", - "criterion": "specifications", - "test": "task-complete", - "implementation": "task-complete", - "verification": "task-complete", - "ambiguity": "requirements", - "decision": "requirements", - "deferral": "requirements", - "finding": "requirements", - } - requests.extend( - (member, mapping[nodes[member].node_type]) - for member in sorted(terminal) - if member in nodes and nodes[member].node_type in mapping - ) - members = set(base.members) - paths = dict(base.paths) - groups = dict(base.group_expansions) - for member, member_gate in requests: - child = build_scope_closure(member, member_gate, nodes, seen | {key}) - prefix = paths.get(member, (target, member)) - members.update(child.members) - groups.update(child.group_expansions) - for identity, path in child.paths.items(): - combined = prefix + path[1:] if path and path[0] == member else prefix + path - prior = paths.get(identity) - if prior is None or (len(combined), combined) < (len(prior), prior): - paths[identity] = combined - return Closure( - tuple(sorted(members)), - paths, - groups, - tuple(sorted(set(nodes) - members)), - ) diff --git a/scripts/codeops_state_lib/discovery.py b/scripts/codeops_state_lib/discovery.py deleted file mode 100644 index 49486a1..0000000 --- a/scripts/codeops_state_lib/discovery.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Deterministic discovery of live CodeOps artifact graphs.""" - -from __future__ import annotations - -import json -from dataclasses import dataclass -from pathlib import Path - -from .models import StructuralProblem - - -@dataclass(frozen=True) -class Discovery: - paths: tuple[Path, ...] - problems: tuple[StructuralProblem, ...] - - -def discover_graphs(root: Path) -> list[Path]: - return list(discover_state(root).paths) - - -def discover_state(root: Path) -> Discovery: - problems: list[StructuralProblem] = [] - config = root / "codeops" / "codeops.json" - if config.is_file(): - try: - raw = json.loads(config.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: - return Discovery((), (StructuralProblem("invalid-config", f"cannot parse configuration: {exc}", config),)) - if not isinstance(raw, dict): - return Discovery((), (StructuralProblem("invalid-config", "configuration must be an object", config),)) - artifacts = raw.get("artifacts") - if not isinstance(artifacts, dict): - return Discovery((), (StructuralProblem("invalid-config", "artifacts must be an object", config),)) - artifact_root = artifacts.get("root") - if isinstance(artifact_root, str) and artifact_root: - base = (root / artifact_root).resolve() - if base != root.resolve() and root.resolve() not in base.parents: - return Discovery((), (StructuralProblem("unsafe-config-root", f"artifacts.root escapes project root: {artifact_root}", config),)) - feature_root = base / "features" - search_root = feature_root if feature_root.is_dir() else base - return Discovery(tuple(sorted(search_root.glob("*/traceability.json"))), tuple(problems)) - return Discovery((), (StructuralProblem("invalid-config", "artifacts.root must be a non-empty string", config),)) - conventional = root / "codeops" / "features" - if conventional.is_dir(): - return Discovery(tuple(sorted(conventional.glob("*/traceability.json"))), ()) - flat = root / "traceability.json" - if flat.is_file(): - return Discovery((flat,), ()) - return Discovery((), ()) diff --git a/scripts/codeops_state_lib/gates.py b/scripts/codeops_state_lib/gates.py deleted file mode 100644 index 7f55968..0000000 --- a/scripts/codeops_state_lib/gates.py +++ /dev/null @@ -1,361 +0,0 @@ -"""Normative target compatibility, gate predicates, and lifecycle summaries.""" - -from __future__ import annotations - -from pathlib import Path - -from .closure import Closure, build_closure -from .models import CONTRACT_MATURITIES, Node, StructuralProblem -from .revisions import snapshot_problems - - -TARGET_TYPES = { - "requirements": {"requirement", "requirement-set"}, - "specifications": {"requirement", "specification", "invariant", "contract", "planning-group"}, - "plan": {"requirement", "task", "planning-group", "plan"}, - "audit": set(), - "execution": {"plan"}, - "task-complete": {"task"}, - "feature-acceptance": {"feature"}, - "release": {"release"}, -} - -AUDIT_MAPPING = { - "requirement": "requirements", - "requirement-set": "requirements", - "specification": "specifications", - "invariant": "specifications", - "contract": "specifications", - "planning-group": "specifications", - "criterion": "specifications", - "plan": "execution", - "test": "task-complete", - "implementation": "task-complete", - "verification": "task-complete", - "ambiguity": "requirements", - "decision": "requirements", - "deferral": "requirements", - "finding": "requirements", - "feature": "feature-acceptance", - "release": "release", -} - - -def audit_gate(node: Node) -> str: - if node.node_type == "audit-artifact": - return node.audit_stage or "requirements" - if node.node_type == "task": - return "plan" if node.status == "pending" else "task-complete" - return AUDIT_MAPPING[node.node_type] - - -def compatibility_problem(gate: str, target: Node, source: Path) -> StructuralProblem | None: - allowed = TARGET_TYPES.get(gate) - if allowed is None: - return StructuralProblem("unknown-gate", f"unknown gate {gate!r}", source, target.canonical_id) - if gate == "audit" and target.node_type not in AUDIT_MAPPING and target.node_type not in {"task", "audit-artifact"}: - return StructuralProblem( - "incompatible-target", - f"audit has no normative mapping for {target.node_type}", - source, - target.canonical_id, - ) - if gate != "audit" and target.node_type not in allowed: - return StructuralProblem( - "incompatible-target", - f"{gate} accepts target types {sorted(allowed)}; got {target.node_type}", - source, - target.canonical_id, - ) - return None - - -def evaluate( - gate: str, - closure: Closure, - nodes: dict[str, Node], - sources: dict[str, Path], -) -> list[StructuralProblem]: - problems: list[StructuralProblem] = [] - maturity = {value: index for index, value in enumerate(CONTRACT_MATURITIES)} - for identity in closure.members: - node = nodes[identity] - required: set[str] = set() - if node.status == "superseded": - problems.append( - StructuralProblem( - "superseded-evidence", - f"{identity} is superseded and cannot satisfy {gate}", - sources[identity], - identity, - {"path": closure.paths[identity]}, - ) - ) - if gate == "requirements": - if node.node_type in {"requirement", "requirement-set", "criterion", "audit-artifact"}: - required = {"approved"} - elif gate == "specifications": - if node.node_type in {"requirement", "specification", "invariant", "contract", "criterion", "planning-group", "audit-artifact"}: - required = {"approved"} - elif gate == "plan": - if node.node_type in {"requirement", "specification", "invariant", "contract", "criterion", "planning-group", "plan", "audit-artifact"}: - required = {"approved"} - elif node.node_type == "task": - required = {"pending"} - elif gate == "execution": - if node.node_type in {"plan", "specification", "invariant", "contract", "criterion"}: - required = {"approved"} - elif node.node_type == "task": - required = {"pending", "implemented", "verified"} - elif node.node_type == "test": - required = {"planned", "red-confirmed", "passing"} - elif gate == "task-complete": - required = { - "task": {"verified"}, - "test": {"passing"}, - "implementation": {"present", "verified"}, - "verification": {"passing"}, - }.get(node.node_type, set()) - elif gate in {"feature-acceptance", "release"}: - if gate == "feature-acceptance" and node.node_type == "feature": - required = {"approved"} - elif gate == "release" and node.node_type == "release": - required = {"approved"} - if required and node.status not in required: - problems.append( - StructuralProblem( - "status-not-ready", - f"{identity} status {node.status!r} does not satisfy {gate}; expected {sorted(required)}", - sources[identity], - identity, - {"path": closure.paths[identity]}, - ) - ) - if node.node_type == "ambiguity" and (node.risk or "high") in {"high", "critical"} and node.status not in {"resolved", "deferred-approved"}: - problems.append(StructuralProblem("material-ambiguity", f"{identity} is unresolved", sources[identity], identity, {"path": closure.paths[identity]})) - if node.node_type == "finding" and (node.risk or "high") in {"high", "critical"} and node.status == "open": - problems.append(StructuralProblem("blocking-finding", f"{identity} is open", sources[identity], identity, {"path": closure.paths[identity]})) - if node.node_type == "deferral" and node.status != "approved": - problems.append(StructuralProblem("unapproved-deferral", f"{identity} is {node.status}", sources[identity], identity, {"path": closure.paths[identity]})) - if node.node_type == "decision" and node.status != "approved": - problems.append(StructuralProblem("decision-not-current", f"{identity} is {node.status}", sources[identity], identity, {"path": closure.paths[identity]})) - for edge in node.edges: - if edge.relation == "consumes-contract" and edge.target in nodes: - actual = nodes[edge.target].maturity or "" - if maturity.get(actual, -1) < maturity.get(edge.required_maturity or "", 0): - problems.append(StructuralProblem("contract-maturity", f"{identity} requires {edge.required_maturity}; {edge.target} is {actual}", sources[identity], identity, {"path": closure.paths[identity] + (edge.target,)})) - return problems - - -def evaluate_target( - target: str, - gate: str, - nodes: dict[str, Node], - sources: dict[str, Path], - seen: frozenset[tuple[str, str]] = frozenset(), -) -> tuple[Closure, list[StructuralProblem]]: - key = (target, gate) - if key in seen: - empty = build_closure(target, gate, nodes) - return empty, [ - StructuralProblem( - "composite-gate-cycle", - f"recursive composite gate at {target} ({gate})", - sources[target], - target, - {"path": (target,)}, - ) - ] - closure = build_closure(target, gate, nodes) - augmented_members = set(closure.members) - augmented_paths = dict(closure.paths) - evidence_additions: set[str] = set() - if gate == "execution": - for identity in closure.members: - for edge in nodes[identity].edges: - if edge.relation == "tested-by": - evidence_additions.add(edge.target) - if gate == "task-complete": - for owner_identity, owner in nodes.items(): - if any( - edge.relation == "implemented-by" and edge.target == target - for edge in owner.edges - ): - for edge in owner.edges: - if edge.relation in {"tested-by", "verified-by"} or ( - edge.relation == "implemented-by" - and nodes.get(edge.target, nodes[target]).node_type == "implementation" - ): - evidence_additions.add(edge.target) - for edge in nodes[target].edges: - if edge.relation == "verified-by": - evidence_additions.add(edge.target) - for identity in sorted(evidence_additions): - if identity in nodes: - augmented_members.add(identity) - augmented_paths.setdefault(identity, (target, identity)) - if augmented_members != set(closure.members): - closure = Closure( - tuple(sorted(augmented_members)), - augmented_paths, - closure.group_expansions, - tuple(sorted(set(nodes) - augmented_members)), - ) - problems = evaluate(gate, closure, nodes, sources) - problems.extend( - snapshot_problems(gate, closure.members, closure.paths, nodes, sources) - ) - member_requests: list[tuple[str, str]] = [] - target_node = nodes[target] - if gate == "feature-acceptance": - member_requests.extend( - (member, target_node.member_gates[member]) - for member in target_node.members - ) - elif gate == "release": - terminal_members = set(target_node.required) - for identity in closure.members: - for edge in nodes[identity].edges: - if edge.relation == "release-coupled": - terminal_members.add(edge.target) - terminal_members.add(identity) - terminal_members.discard(target) - for member in sorted(terminal_members): - member_type = nodes[member].node_type - member_gate = { - "feature": "feature-acceptance", - "release": "release", - "task": "task-complete", - "plan": "execution", - "requirement": "specifications", - "requirement-set": "requirements", - "specification": "specifications", - "invariant": "specifications", - "contract": "specifications", - }.get(member_type, audit_gate(nodes[member])) - member_requests.append((member, member_gate)) - merged_members = set(closure.members) - merged_paths = dict(closure.paths) - merged_groups = dict(closure.group_expansions) - for member, member_gate in member_requests: - member_node = nodes[member] - internal_release_evidence = ( - gate == "release" - and member_gate == "task-complete" - and member_node.node_type in {"test", "implementation", "verification"} - ) - incompatible = ( - None - if internal_release_evidence - else compatibility_problem(member_gate, member_node, sources[member]) - ) - if incompatible is not None: - prefix = closure.paths.get(member, (target, member)) - problems.append( - StructuralProblem( - incompatible.code, - incompatible.message, - incompatible.source, - incompatible.identity, - {"path": prefix}, - ) - ) - continue - child, child_problems = evaluate_target( - member, member_gate, nodes, sources, seen | {key} - ) - if internal_release_evidence: - terminal_status = { - "test": "passing", - "implementation": "verified", - "verification": "passing", - }[member_node.node_type] - if member_node.status != terminal_status: - child_problems.append( - StructuralProblem( - "release-terminal-evidence", - f"{member} must be {terminal_status} for release readiness", - sources[member], - member, - {"path": (member,)}, - ) - ) - prefix = closure.paths.get(member, (target, member)) - for identity, path in child.paths.items(): - combined = prefix + path[1:] if path[0] == member else prefix + path - prior = merged_paths.get(identity) - if prior is None or (len(combined), combined) < (len(prior), prior): - merged_paths[identity] = combined - merged_members.update(child.members) - merged_groups.update(child.group_expansions) - for problem in child_problems: - path = problem.details.get("path") - details = dict(problem.details) - if path: - details["path"] = prefix + tuple(path)[1:] - problems.append( - StructuralProblem( - problem.code, - problem.message, - problem.source, - problem.identity, - details, - ) - ) - merged = Closure( - tuple(sorted(merged_members)), - merged_paths, - merged_groups, - tuple(sorted(set(nodes) - merged_members)), - ) - if gate == "plan" and target_node.node_type in {"requirement", "planning-group"}: - owners = [ - node for node in nodes.values() - if node.node_type == "plan" - and any( - edge.relation == "depends-on" - and ( - edge.target == target - or ( - target_node.node_type == "planning-group" - and edge.target in target_node.members - ) - ) - for edge in node.edges - ) - ] - if not any(node.status == "approved" for node in owners): - problems.append(StructuralProblem("approved-plan-required", f"{target} has no approved owning plan", sources[target], target, {"path": (target,)})) - if gate == "execution": - if not any(nodes[item].node_type == "test" and nodes[item].status in {"planned", "red-confirmed", "passing"} for item in merged.members): - problems.append(StructuralProblem("planned-test-required", f"{target} has no planned test evidence", sources[target], target, {"path": (target,)})) - if not target_node.evidence: - problems.append(StructuralProblem("entry-evidence-required", f"{target} has no repository entry evidence", sources[target], target, {"path": (target,)})) - return merged, problems - - -def lifecycle(node: Node) -> str: - if node.node_type in {"requirement", "requirement-set"}: - return "requirements" - if node.node_type in {"specification", "invariant", "contract", "planning-group"}: - return "specifications" - if node.node_type == "plan": - return "execution" if node.status == "approved" else "planning" - if node.node_type == "task": - return "complete" if node.status == "verified" else "execution" - if node.node_type == "feature": - return "acceptance" - if node.node_type == "release": - return "release" - return "audit" - - -def valid_transitions(node: Node) -> list[str]: - return { - "draft": ["approved", "superseded"], - "approved": ["stale", "superseded"], - "stale": ["draft", "approved", "superseded"], - "pending": ["implemented", "blocked"], - "implemented": ["verified", "blocked", "stale"], - "verified": ["stale", "superseded"], - }.get(node.status, []) diff --git a/scripts/codeops_state_lib/legacy.py b/scripts/codeops_state_lib/legacy.py deleted file mode 100755 index b668052..0000000 --- a/scripts/codeops_state_lib/legacy.py +++ /dev/null @@ -1,466 +0,0 @@ -#!/usr/bin/env python3 -"""Validate and summarize durable CodeOps project state using only the standard library.""" - -from __future__ import annotations - -import argparse -import json -import re -import subprocess -import sys -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any, Iterable - - -NODE_TYPES = { - "requirement", "ambiguity", "decision", "invariant", "specification", - "criterion", "test", "task", "implementation", "verification", "finding", - "deferral", -} -RISK = {"low", "medium", "high", "critical"} -TERMINAL_AMBIGUITY = {"resolved", "deferred-approved"} -APPROVED_CONTENT = {"approved", "superseded"} -BLOCKING_FINDINGS = {"critical", "major"} -TASK_RE = re.compile(r"^\s*- \[(?P[ x~])\]\s+(?P.+)$", re.MULTILINE) -FEATURE_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}") -STATUS_BY_TYPE = { - "ambiguity": {"open", "resolved", "deferred-approved", "superseded"}, - "decision": {"approved", "superseded"}, - "deferral": {"proposed", "approved", "expired", "resolved", "rejected"}, - "requirement": {"draft", "approved", "stale", "superseded"}, - "specification": {"draft", "approved", "stale", "superseded"}, - "criterion": {"draft", "approved", "stale", "superseded"}, - "invariant": {"draft", "approved", "stale", "superseded"}, - "test": {"planned", "red-confirmed", "passing", "blocked", "stale", "superseded"}, - "task": {"pending", "implemented", "verified", "blocked", "stale", "superseded"}, - "implementation": {"present", "verified", "stale", "superseded", "reverted"}, - "verification": {"planned", "passing", "failing", "blocked", "stale", "superseded"}, - "finding": {"open", "accepted", "resolved", "superseded"}, -} - - -@dataclass -class Problem: - level: str - source: Path - message: str - - def render(self, root: Path) -> str: - try: - source = self.source.relative_to(root) - except ValueError: - source = self.source - return f"{self.level}: {source}: {self.message}" - - -@dataclass -class Graph: - source: Path - feature: str - nodes: dict[str, dict[str, Any]] = field(default_factory=dict) - - -def load_json(path: Path, problems: list[Problem]) -> dict[str, Any] | None: - try: - value = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: - problems.append(Problem("ERROR", path, f"cannot parse JSON: {exc}")) - return None - if not isinstance(value, dict): - problems.append(Problem("ERROR", path, "root must be a JSON object")) - return None - return value - - -def discover_graphs(root: Path) -> list[Path]: - ignored = {".git", "node_modules", ".codex", ".agents", "_archive"} - return sorted( - path for path in root.rglob("traceability.json") - if not any(part in ignored for part in path.relative_to(root).parts) - ) - - -def validate_config(root: Path, problems: list[Problem]) -> dict[str, Any] | None: - path = root / "codeops" / "codeops.json" - if not path.exists(): - return None - data = load_json(path, problems) - if data is None: - return None - allowed = {"schema", "mode", "artifacts", "quality", "routing", "metrics"} - unknown = set(data) - allowed - if unknown: - problems.append(Problem("ERROR", path, f"unknown fields {sorted(unknown)}")) - if data.get("schema") != 1: - problems.append(Problem("ERROR", path, "schema must equal 1")) - if data.get("mode") not in {"strict", "adaptive"}: - problems.append(Problem("ERROR", path, "mode must be strict or adaptive")) - artifacts = data.get("artifacts") - if not isinstance(artifacts, dict): - problems.append(Problem("ERROR", path, "artifacts must be an object")) - else: - if artifacts.get("layout") not in {"nested", "flat"}: - problems.append(Problem("ERROR", path, "artifacts.layout must be nested or flat")) - artifact_root = artifacts.get("root") - if not isinstance(artifact_root, str) or not artifact_root: - problems.append(Problem("ERROR", path, "artifacts.root must be a non-empty string")) - quality = data.get("quality", {}) - if not isinstance(quality, dict): - problems.append(Problem("ERROR", path, "quality must be an object")) - elif data.get("mode") == "strict" and quality.get("independentReview") is False: - problems.append(Problem("ERROR", path, "strict mode cannot disable independent review")) - metrics = data.get("metrics", {}) - if not isinstance(metrics, dict) or not isinstance(metrics.get("enabled", False), bool): - problems.append(Problem("ERROR", path, "metrics.enabled must be boolean")) - return data - - -def validate_node_shape(node: Any, source: Path, index: int, problems: list[Problem]) -> bool: - if not isinstance(node, dict): - problems.append(Problem("ERROR", source, f"nodes[{index}] must be an object")) - return False - required = {"id", "type", "title", "status", "path", "links"} - allowed = required | {"evidence", "risk"} - missing = required - set(node) - unknown = set(node) - allowed - if missing: - problems.append(Problem("ERROR", source, f"nodes[{index}] missing {sorted(missing)}")) - if unknown: - problems.append(Problem("ERROR", source, f"nodes[{index}] has unknown fields {sorted(unknown)}")) - if missing or unknown: - return False - if not isinstance(node["id"], str) or not re.fullmatch(r"[A-Z][A-Z0-9]*-[A-Za-z0-9._-]+", node["id"]): - problems.append(Problem("ERROR", source, f"nodes[{index}].id is invalid")) - return False - if node["type"] not in NODE_TYPES: - problems.append(Problem("ERROR", source, f"{node['id']} has unknown type {node['type']!r}")) - for key in ("title", "status", "path"): - if not isinstance(node[key], str) or not node[key].strip(): - problems.append(Problem("ERROR", source, f"{node['id']}.{key} must be non-empty")) - for key in ("links", "evidence"): - value = node.get(key, []) - if not isinstance(value, list) or not all(isinstance(item, str) and item for item in value): - problems.append(Problem("ERROR", source, f"{node['id']}.{key} must be an array of strings")) - if "risk" in node and node["risk"] not in RISK: - problems.append(Problem("ERROR", source, f"{node['id']}.risk is invalid")) - allowed_statuses = STATUS_BY_TYPE.get(node["type"], set()) - if node["status"] not in allowed_statuses: - problems.append(Problem( - "ERROR", source, - f"{node['id']}.status {node['status']!r} is invalid for {node['type']}; expected {sorted(allowed_statuses)}", - )) - return True - - -def load_graph(path: Path, project_root: Path, problems: list[Problem]) -> Graph | None: - data = load_json(path, problems) - if data is None: - return None - unknown = set(data) - {"schema", "feature", "updated", "nodes"} - if unknown: - problems.append(Problem("ERROR", path, f"unknown root fields {sorted(unknown)}")) - if data.get("schema") != 1: - problems.append(Problem("ERROR", path, "schema must equal 1")) - feature = data.get("feature") - if not isinstance(feature, str) or not feature.strip(): - problems.append(Problem("ERROR", path, "feature must be a non-empty string")) - feature = path.parent.name - nodes = data.get("nodes") - if not isinstance(nodes, list): - problems.append(Problem("ERROR", path, "nodes must be an array")) - return Graph(path, feature) - graph = Graph(path, feature) - for index, node in enumerate(nodes): - if not validate_node_shape(node, path, index, problems): - continue - node_id = node["id"] - if node_id in graph.nodes: - problems.append(Problem("ERROR", path, f"duplicate node id {node_id}")) - continue - graph.nodes[node_id] = node - artifact = (path.parent / node["path"]).resolve() - if project_root not in (artifact, *artifact.parents): - problems.append(Problem("ERROR", path, f"{node_id}.path escapes the project root")) - elif not artifact.exists(): - problems.append(Problem("ERROR", path, f"{node_id}.path does not exist: {node['path']}")) - return graph - - -def validate_relationships(graphs: list[Graph], root: Path, problems: list[Problem]) -> dict[str, dict[str, Any]]: - all_nodes: dict[str, dict[str, Any]] = {} - graphs_by_feature: dict[str, Graph] = {} - for graph in graphs: - if graph.feature in graphs_by_feature: - owner = graphs_by_feature[graph.feature].source - problems.append(Problem( - "ERROR", - graph.source, - f"feature {graph.feature!r} also exists in {owner}", - )) - continue - graphs_by_feature[graph.feature] = graph - for node_id, node in graph.nodes.items(): - all_nodes[f"{graph.feature}/{node_id}"] = node - for graph in graphs: - for node_id, node in graph.nodes.items(): - for target in node.get("links", []): - if target in graph.nodes: - continue - if "/" in target: - target_feature, target_node = target.split("/", 1) - target_graph = graphs_by_feature.get(target_feature) - if target_graph is not None and target_node in target_graph.nodes: - continue - if target not in graph.nodes: - problems.append(Problem("ERROR", graph.source, f"{node_id} links to missing node {target}")) - for evidence in node.get("evidence", []): - path = (graph.source.parent / evidence).resolve() - if root not in (path, *path.parents): - problems.append(Problem("ERROR", graph.source, f"{node_id} evidence escapes project root: {evidence}")) - elif not path.exists(): - problems.append(Problem("ERROR", graph.source, f"{node_id} evidence is missing: {evidence}")) - return all_nodes - - -def incoming(nodes: dict[str, dict[str, Any]]) -> dict[str, set[str]]: - result = {node_id: set() for node_id in nodes} - for source, node in nodes.items(): - for target in node.get("links", []): - if target in result: - result[target].add(source) - return result - - -def coverage_problems(nodes: dict[str, dict[str, Any]], source: Path) -> list[Problem]: - problems: list[Problem] = [] - reverse = incoming(nodes) - - def linked_type(node_id: str, allowed: set[str]) -> bool: - references = set(nodes[node_id].get("links", [])) | reverse[node_id] - return any(nodes[ref]["type"] in allowed for ref in references if ref in nodes) - - expectations = { - "requirement": {"specification", "invariant", "criterion"}, - "specification": {"requirement", "criterion", "task"}, - "criterion": {"requirement", "specification", "test", "task", "verification"}, - "test": {"criterion"}, - "task": {"specification", "criterion", "implementation"}, - "implementation": {"task", "verification"}, - "verification": {"criterion", "implementation"}, - } - for node_id, node in nodes.items(): - expected = expectations.get(node["type"]) - if expected and node["status"] != "superseded" and not linked_type(node_id, expected): - problems.append(Problem("ERROR", source, f"{node_id} ({node['type']}) has no trace to {sorted(expected)}")) - return problems - - -def readiness(nodes: dict[str, dict[str, Any]], source: Path) -> list[Problem]: - problems = coverage_problems(nodes, source) - for node_id, node in nodes.items(): - node_type = node["type"] - status = node["status"] - risk = node.get("risk", "high") - if node_type == "ambiguity" and status not in TERMINAL_AMBIGUITY and risk in {"high", "critical"}: - problems.append(Problem("BLOCK", source, f"material ambiguity {node_id} is {status}")) - elif node_type == "deferral" and status != "approved": - problems.append(Problem("BLOCK", source, f"deferral {node_id} is not approved")) - elif node_type == "finding" and status == "open" and risk in BLOCKING_FINDINGS: - problems.append(Problem("BLOCK", source, f"blocking finding {node_id} is open")) - elif node_type in {"requirement", "specification", "criterion", "invariant"} and status not in APPROVED_CONTENT: - problems.append(Problem("BLOCK", source, f"{node_type} {node_id} is not approved")) - problems.extend(invalidation_problems(nodes, source)) - return problems - - -def invalidation_problems(nodes: dict[str, dict[str, Any]], source: Path) -> list[Problem]: - """Require approved downstream work to become stale when an ambiguity reopens.""" - problems: list[Problem] = [] - stale_statuses = {"stale", "draft", "planned", "blocked", "superseded"} - downstream_types = { - "requirement", "specification", "invariant", "criterion", "test", - "task", "implementation", "verification", - } - for ambiguity_id, ambiguity in nodes.items(): - if ambiguity["type"] != "ambiguity": - continue - if ambiguity["status"] in TERMINAL_AMBIGUITY or ambiguity.get("risk", "high") not in {"high", "critical"}: - continue - pending = list(ambiguity.get("links", [])) - visited: set[str] = set() - while pending: - node_id = pending.pop() - if node_id in visited or node_id not in nodes: - continue - visited.add(node_id) - node = nodes[node_id] - if node["type"] in downstream_types and node["status"] not in stale_statuses: - problems.append(Problem( - "BLOCK", source, - f"{node_id} must be marked stale because material ambiguity {ambiguity_id} is open", - )) - pending.extend(node.get("links", [])) - return problems - - -def feature_lifecycle(graph: Graph, ready: bool) -> str: - feature_nodes = list(graph.nodes.values()) - types = {node["type"] for node in feature_nodes} - tasks = [node for node in feature_nodes if node["type"] == "task"] - findings = [node for node in feature_nodes if node["type"] == "finding" and node["status"] == "open"] - if "requirement" not in types: - return "discovery" - if "specification" not in types and "invariant" not in types: - return "requirements" - if not tasks: - return "planning" - if any(node["status"] in {"implemented", "verified", "blocked"} for node in tasks): - if all(node["status"] == "verified" for node in tasks): - completion_types = { - "test": {"passing", "superseded"}, - "implementation": {"present", "verified", "superseded"}, - "verification": {"passing", "superseded"}, - } - complete = all( - node["status"] in completion_types[node["type"]] - for node in feature_nodes if node["type"] in completion_types - ) - return "complete" if complete and not findings else "reviewing" - return "executing" - return "ready" if ready else "planning" - - -def git_drift(root: Path) -> list[str]: - result = subprocess.run( - ["git", "-C", str(root), "status", "--porcelain"], - text=True, - capture_output=True, - check=False, - ) - if result.returncode != 0: - return [] - return [line for line in result.stdout.splitlines() if line] - - -def execution_progress(root: Path) -> tuple[int, int, int]: - pending = implemented = verified = 0 - for path in root.rglob("99-execution-plan.md"): - relative_parts = path.relative_to(root).parts - if ".git" in relative_parts or "_archive" in relative_parts: - continue - for match in TASK_RE.finditer(path.read_text(encoding="utf-8")): - mark = match.group("mark") - if mark == "x": - verified += 1 - elif mark == "~": - implemented += 1 - else: - pending += 1 - return pending, implemented, verified - - -def select_feature_graph( - graphs: list[Graph], - feature: str | None, - root: Path, - problems: list[Problem], -) -> Graph | None: - """Resolve an optional exact feature selector without guessing between artifacts.""" - if feature is None: - return None - if not FEATURE_RE.fullmatch(feature): - problems.append(Problem("ERROR", root, f"feature selector is invalid: {feature!r}")) - return None - matches = [graph for graph in graphs if graph.feature == feature] - if not matches: - problems.append(Problem("ERROR", root, f"feature not found: {feature}")) - return None - if len(matches) > 1: - sources = ", ".join(str(graph.source.relative_to(root)) for graph in matches) - problems.append(Problem("ERROR", root, f"feature {feature!r} is ambiguous across {sources}")) - return None - return matches[0] - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("command", choices=("validate", "readiness", "status")) - parser.add_argument("--root", default=".", help="project root (default: current directory)") - parser.add_argument( - "--feature", - help="limit readiness/status gating to the exact traceability feature name", - ) - parser.add_argument("--json", action="store_true", dest="as_json") - return parser.parse_args() - - -def main() -> int: - args = parse_args() - root = Path(args.root).resolve() - problems: list[Problem] = [] - config = validate_config(root, problems) - paths = discover_graphs(root) - if not paths: - problems.append(Problem("ERROR", root, "no traceability.json files found")) - graphs = [graph for path in paths if (graph := load_graph(path, root, problems)) is not None] - nodes = validate_relationships(graphs, root, problems) - if args.command == "validate" and args.feature is not None: - problems.append(Problem("ERROR", root, "--feature is valid only for readiness or status")) - selected_graph = select_feature_graph(graphs, args.feature, root, problems) - if args.command in {"readiness", "status"} and nodes: - if args.feature is None: - for graph in graphs: - problems.extend(readiness(graph.nodes, graph.source)) - elif selected_graph is not None: - problems.extend(readiness(selected_graph.nodes, selected_graph.source)) - pending, implemented, verified = execution_progress(root) - blocking = sum(problem.level in {"ERROR", "BLOCK"} for problem in problems) - graph_status = [] - for graph in graphs: - graph_ids = set(graph.nodes) - graph_problems = [problem for problem in readiness(graph.nodes, graph.source)] - graph_ready = not any(problem.level in {"ERROR", "BLOCK"} for problem in graph_problems) - graph_status.append({ - "feature": graph.feature, - "lifecycle": feature_lifecycle(graph, graph_ready), - "ready": graph_ready, - "nodes": len(graph_ids), - }) - drift = git_drift(root) - result = { - "ready": blocking == 0, - "selected_feature": selected_graph.feature if selected_graph is not None else None, - "configured": config is not None, - "graphs": len(graphs), - "nodes": len(nodes), - "features": graph_status, - "git_drift": drift, - "problems": [problem.render(root) for problem in problems], - "tasks": {"pending": pending, "implemented": implemented, "verified": verified}, - } - if args.as_json: - print(json.dumps(result, indent=2, sort_keys=True)) - else: - print(f"CodeOps graphs: {result['graphs']} | nodes: {result['nodes']}") - if result["selected_feature"] is not None: - print(f"Readiness scope: {result['selected_feature']}") - print(f"Tasks: {pending} pending | {implemented} implemented | {verified} verified") - for feature in graph_status: - print(f"Feature {feature['feature']}: {feature['lifecycle']} | {'ready' if feature['ready'] else 'blocked'}") - if drift: - print(f"Git drift: {len(drift)} path(s)") - for problem in problems: - print(problem.render(root)) - print("READY" if result["ready"] else "NOT READY") - # `status` is an observation command, not a gate. A well-formed project with - # draft requirements or open work is a successful status read even though it - # is not ready for execution. Keep non-zero exits for malformed state, while - # `validate` and `readiness` retain their gate semantics. - if args.command == "status": - return 0 if not any(problem.level == "ERROR" for problem in problems) else 1 - return 0 if result["ready"] else 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/codeops_state_lib/migration.py b/scripts/codeops_state_lib/migration.py deleted file mode 100644 index ced5d91..0000000 --- a/scripts/codeops_state_lib/migration.py +++ /dev/null @@ -1,545 +0,0 @@ -"""Explicit schema-one to schema-two traceability migration.""" - -from __future__ import annotations - -import hashlib -import json -import os -import re -import uuid -from dataclasses import replace -from pathlib import Path -from typing import Any - -from . import legacy -from .discovery import discover_state -from .revisions import compute_revision -from .schema import RELATION_MATRIX, parse_graph_v2, validate_portfolio_v2 -from .models import SemanticSource, SourceSelector -from .transitions import _atomic_write, _hash, replace_graph_atomically - - -PREVIEW_KIND = "codeops-traceability-upgrade-preview" -RESOLUTION_KIND = "codeops-traceability-upgrade-resolutions" - - -def _canonical_json(value: Any) -> bytes: - return (json.dumps(value, indent=2, sort_keys=True) + "\n").encode("utf-8") - - -def _preview_hash(value: dict[str, Any]) -> str: - unhashed = {key: item for key, item in value.items() if key != "previewHash"} - return _hash(_canonical_json(unhashed)) - - -def _safe_output(root: Path, path: Path) -> bool: - resolved = path.resolve() - project = root.resolve() - return resolved == project or project in resolved.parents - - -def _find_schema_one(root: Path, feature: str) -> tuple[Path | None, dict[str, Any] | None, str | None]: - matches: list[tuple[Path, dict[str, Any]]] = [] - for path in discover_state(root).paths: - try: - raw = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - continue - if ( - isinstance(raw, dict) - and raw.get("schema") == 1 - and raw.get("feature") == feature - ): - matches.append((path, raw)) - if len(matches) != 1: - return None, None, f"expected one schema-1 graph for {feature}; found {len(matches)}" - return matches[0][0], matches[0][1], None - - -def _relation_choices(source_type: str, target_type: str) -> list[str]: - choices = [ - relation - for relation, (sources, targets) in RELATION_MATRIX.items() - if source_type in sources and target_type in targets - ] - return sorted(set(choices) | {"omit"}) - - -def _build_preview( - root: Path, - feature: str, - source: Path, - raw: dict[str, Any], - *, - logical_source: Path | None = None, -) -> tuple[dict[str, Any] | None, list[dict[str, str]]]: - problems: list[legacy.Problem] = [] - graph = legacy.load_graph(source, root, problems) - if graph is None or problems: - return None, [ - {"code": "invalid-schema1", "message": problem.message} - for problem in problems - ] - nodes = {item["id"]: item for item in raw["nodes"]} - portfolio_types: dict[str, str] = {} - for graph_path in discover_state(root).paths: - graph_raw, _ = _read_object(graph_path) - if graph_raw is None or not isinstance(graph_raw.get("feature"), str): - continue - for item in graph_raw.get("nodes", []): - if ( - isinstance(item, dict) - and isinstance(item.get("id"), str) - and isinstance(item.get("type"), str) - ): - portfolio_types[ - f"{graph_raw['feature']}/{item['id']}" - ] = item["type"] - unresolved: list[dict[str, Any]] = [] - for node_id, node in sorted(nodes.items()): - unresolved.append({ - "id": f"source:{node_id}", - "kind": "source-selector", - "node": node_id, - "path": str( - (source.parent / node["path"]).resolve().relative_to(root.resolve()) - ), - "choices": ["whole-file", "heading"], - }) - for linked in sorted(node["links"]): - canonical_target = ( - linked if "/" in linked else f"{feature}/{linked}" - ) - target_type = portfolio_types.get(canonical_target) - choices = ( - _relation_choices(node["type"], target_type) - if target_type is not None - else ["omit"] - ) - unresolved.append({ - "id": f"edge:{node_id}:{linked}", - "kind": "relationship", - "source": node_id, - "target": canonical_target, - "choices": choices, - }) - logical = logical_source or source - preview: dict[str, Any] = { - "schema": 1, - "kind": PREVIEW_KIND, - "feature": feature, - "source": { - "path": str(logical.relative_to(root)), - "hash": _hash(source.read_bytes()), - "schema": 1, - }, - "destination": str(logical.relative_to(root)), - "preservedNodes": [ - { - key: node[key] - for key in ("id", "type", "title", "status", "path") - } - | { - "evidence": node.get("evidence", []), - "risk": node.get("risk"), - } - for node in sorted(raw["nodes"], key=lambda item: item["id"]) - ], - "unresolved": unresolved, - "blockers": ["resolution-required"] if unresolved else [], - } - preview["previewHash"] = _preview_hash(preview) - return preview, [] - - -def _protected_preview_paths(root: Path) -> set[Path]: - protected = {path.resolve() for path in discover_state(root).paths} - config = root / "codeops" / "codeops.json" - if config.exists(): - protected.add(config.resolve()) - for graph_path in discover_state(root).paths: - raw, _ = _read_object(graph_path) - if raw is None: - continue - if raw.get("schema") == 1: - for node in raw.get("nodes", []): - if isinstance(node, dict) and isinstance(node.get("path"), str): - protected.add((graph_path.parent / node["path"]).resolve()) - elif raw.get("schema") == 2: - for node in raw.get("nodes", []): - if not isinstance(node, dict): - continue - for source in node.get("semanticSources", []): - if isinstance(source, dict) and isinstance(source.get("path"), str): - protected.add((root / source["path"]).resolve()) - protected.add(graph_path.with_name("traceability.schema1.backup.json").resolve()) - return protected - - -def make_preview(root: Path, feature: str, preview_path: Path) -> tuple[int, dict[str, Any]]: - if not _safe_output(root, preview_path): - return 1, { - "result": "refused", - "blockers": [{"code": "unsafe-preview-path", "message": "preview path escapes root"}], - } - if ( - preview_path.resolve() in _protected_preview_paths(root) - or (root / "codeops" / ".state-transactions").resolve() - in preview_path.resolve().parents - ): - return 1, { - "result": "refused", - "blockers": [{"code": "preview-path-collision", "message": "preview path is a protected project artifact"}], - } - source, raw, error = _find_schema_one(root, feature) - if source is None or raw is None: - existing = root / "codeops" / "features" / feature / "traceability.json" - if existing.is_file(): - try: - value = json.loads(existing.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - value = None - if isinstance(value, dict) and value.get("schema") == 2: - return 0, { - "result": "already-upgraded", - "feature": feature, - "destination": str(existing.relative_to(root)), - "blockers": [], - } - return 1, { - "result": "refused", - "blockers": [{"code": "schema1-source", "message": error or "source not found"}], - } - preview, blockers = _build_preview(root, feature, source, raw) - if preview is None: - return 1, {"result": "refused", "blockers": blockers} - preview_bytes = _canonical_json(preview) - if preview_path.exists() and preview_path.read_bytes() != preview_bytes: - return 1, { - "result": "refused", - "blockers": [{"code": "preview-collision", "message": "non-identical preview exists"}], - } - preview_path.parent.mkdir(parents=True, exist_ok=True) - if not preview_path.exists(): - descriptor = os.open( - preview_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600 - ) - with os.fdopen(descriptor, "wb") as handle: - handle.write(preview_bytes) - handle.flush() - os.fsync(handle.fileno()) - return 0, { - "result": "preview", - "feature": feature, - "preview": str(preview_path.relative_to(root)), - "previewHash": preview["previewHash"], - "unresolved": preview["unresolved"], - "blockers": preview["blockers"], - } - - -def _read_object(path: Path) -> tuple[dict[str, Any] | None, str | None]: - try: - value = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: - return None, str(exc) - return (value, None) if isinstance(value, dict) else (None, "root must be an object") - - -def _portfolio_projection_problems( - root: Path, - source: Path, - projected: Any, -) -> list[Any]: - graphs = [replace(projected, source=source)] - problems: list[Any] = [] - for path in discover_state(root).paths: - if path.resolve() == source.resolve(): - continue - raw, _ = _read_object(path) - if raw is None or raw.get("schema") != 2: - continue - graph, graph_problems = parse_graph_v2(path, root) - problems.extend(graph_problems) - if graph is not None: - graphs.append(graph) - problems.extend(validate_portfolio_v2(graphs)) - return problems - - -def _resolved_graph( - root: Path, - preview: dict[str, Any], - resolutions: dict[str, Any], -) -> tuple[dict[str, Any] | None, list[dict[str, str]]]: - blockers: list[dict[str, str]] = [] - expected_ids = {item["id"] for item in preview["unresolved"]} - decisions = resolutions.get("decisions") - if not isinstance(decisions, dict) or set(decisions) != expected_ids: - return None, [{ - "code": "incomplete-resolutions", - "message": "resolutions must contain every unresolved id and no unknown id", - }] - preserved = {item["id"]: item for item in preview["preservedNodes"]} - edges: dict[str, list[dict[str, str]]] = {identity: [] for identity in preserved} - sources: dict[str, dict[str, Any]] = {} - for unresolved in preview["unresolved"]: - decision = decisions[unresolved["id"]] - if not isinstance(decision, dict): - blockers.append({"code": "invalid-resolution", "message": unresolved["id"]}) - continue - if unresolved["kind"] == "relationship": - if set(decision) != {"relation"}: - blockers.append({"code": "invalid-resolution", "message": unresolved["id"]}) - continue - relation = decision.get("relation") - if relation not in unresolved["choices"]: - blockers.append({"code": "invalid-resolution", "message": unresolved["id"]}) - continue - if relation != "omit": - target = unresolved["target"] - canonical = ( - target - if "/" in target - else f"{preview['feature']}/{target}" - ) - edges[unresolved["source"]].append({ - "relation": relation, - "target": canonical, - }) - else: - selector = decision.get("selector") - if ( - set(decision) != {"selector"} - or - not isinstance(selector, dict) - or selector.get("kind") not in unresolved["choices"] - or set(selector) - {"kind", "value"} - or ( - selector.get("kind") == "heading" - and not isinstance(selector.get("value"), str) - ) - ): - blockers.append({"code": "invalid-resolution", "message": unresolved["id"]}) - continue - sources[unresolved["node"]] = { - "path": unresolved["path"], - "selector": selector, - "normalization": "utf8-lf-trim-trailing-v1", - "digest": "sha256", - } - if blockers: - return None, blockers - nodes: list[dict[str, Any]] = [] - for identity, item in sorted(preserved.items()): - source = sources[identity] - semantic = SemanticSource( - source["path"], - SourceSelector(source["selector"]["kind"], source["selector"].get("value")), - source["normalization"], - source["digest"], - ) - node = { - "id": identity, - "type": item["type"], - "title": item["title"], - "status": item["status"], - "semanticSources": [source], - "revision": compute_revision(root, (semantic,)), - "edges": sorted(edges[identity], key=lambda edge: (edge["relation"], edge["target"])), - "validations": [], - } - if item["evidence"]: - node["evidence"] = item["evidence"] - if item["risk"] is not None: - node["risk"] = item["risk"] - nodes.append(node) - return { - "schema": 2, - "feature": preview["feature"], - "nodes": nodes, - }, [] - - -def apply_upgrade( - root: Path, - feature: str, - preview_path: Path, - resolutions_path: Path, -) -> tuple[int, dict[str, Any]]: - if not _safe_output(root, preview_path) or not _safe_output(root, resolutions_path): - return 1, { - "result": "refused", - "blockers": [{"code": "unsafe-upgrade-path", "message": "input path escapes root"}], - } - preview, preview_error = _read_object(preview_path) - resolutions, resolution_error = _read_object(resolutions_path) - if preview is None or resolutions is None: - return 1, { - "result": "refused", - "blockers": [{ - "code": "invalid-upgrade-input", - "message": preview_error or resolution_error or "invalid input", - }], - } - if ( - set(preview) - != { - "schema", - "kind", - "feature", - "source", - "destination", - "preservedNodes", - "unresolved", - "blockers", - "previewHash", - } - or set(resolutions) != {"schema", "previewHash", "decisions"} - or - preview.get("schema") != 1 - or preview.get("kind") != PREVIEW_KIND - or preview.get("feature") != feature - or preview.get("previewHash") != _preview_hash(preview) - or resolutions.get("schema") != 1 - or resolutions.get("previewHash") != preview.get("previewHash") - ): - return 1, { - "result": "refused", - "blockers": [{"code": "changed-preview", "message": "preview/resolution identity mismatch"}], - } - source_value = preview.get("source") - if ( - not isinstance(source_value, dict) - or set(source_value) != {"path", "hash", "schema"} - or not isinstance(source_value.get("path"), str) - or not isinstance(preview.get("destination"), str) - ): - return 1, { - "result": "refused", - "blockers": [{"code": "invalid-preview", "message": "preview source shape is invalid"}], - } - source = (root / source_value["path"]).resolve() - if not _safe_output(root, source): - return 1, { - "result": "refused", - "blockers": [{"code": "unsafe-upgrade-path", "message": "source path escapes root"}], - } - backup = source.with_name("traceability.schema1.backup.json") - if not source.is_file(): - return 1, { - "result": "refused", - "blockers": [{"code": "source-missing", "message": "schema-1 source is missing"}], - } - current = source.read_bytes() - try: - current_raw = json.loads(current.decode("utf-8")) - current_schema = current_raw.get("schema") - except (UnicodeError, json.JSONDecodeError, AttributeError): - current_schema = None - canonical_preview: dict[str, Any] | None = None - canonical_blockers: list[dict[str, str]] = [] - if current_schema == 1: - canonical_preview, canonical_blockers = _build_preview( - root, feature, source, current_raw - ) - elif current_schema == 2 and backup.is_file(): - backup_raw, backup_error = _read_object(backup) - if backup_raw is None or backup_raw.get("schema") != 1: - return 1, { - "result": "refused", - "blockers": [{"code": "invalid-backup", "message": backup_error or "backup is not schema 1"}], - } - canonical_preview, canonical_blockers = _build_preview( - root, - feature, - backup, - backup_raw, - logical_source=source, - ) - if canonical_preview is None or canonical_blockers: - return 1, { - "result": "refused", - "blockers": canonical_blockers or [{"code": "schema1-source", "message": "no canonical schema-1 source"}], - } - if canonical_preview != preview: - return 1, { - "result": "refused", - "blockers": [{"code": "changed-preview", "message": "preview does not match regenerated canonical source preview"}], - } - try: - graph, blockers = _resolved_graph(root, preview, resolutions) - except (KeyError, TypeError, ValueError, OSError, UnicodeError) as exc: - return 1, { - "result": "refused", - "blockers": [{"code": "invalid-resolution", "message": str(exc)}], - } - if graph is None: - return 1, {"result": "refused", "blockers": blockers} - after = _canonical_json(graph) - if current_schema == 2: - parsed, parse_problems = parse_graph_v2(source, root) - portfolio_problems = ( - _portfolio_projection_problems(root, source, parsed) - if parsed is not None - else [] - ) - if current == after and parsed is not None and not parse_problems and not portfolio_problems: - return 0, { - "result": "no-change", - "feature": feature, - "destination": preview["destination"], - "backup": str(backup.relative_to(root)), - "destinationHash": _hash(after), - "blockers": [], - } - return 1, { - "result": "refused", - "blockers": [{"code": "divergent-destination", "message": "current schema-2 graph differs from resolved projection"}], - } - temporary = source.with_name(f".{source.name}.upgrade-check.json") - try: - _atomic_write(temporary, after) - parsed, parse_problems = parse_graph_v2(temporary, root) - except OSError as exc: - return 1, { - "result": "refused", - "blockers": [{"code": "projection-write", "message": str(exc)}], - } - finally: - temporary.unlink(missing_ok=True) - portfolio_problems = ( - _portfolio_projection_problems(root, source, parsed) - if parsed is not None - else [] - ) - if parsed is None or parse_problems or portfolio_problems: - return 1, { - "result": "refused", - "blockers": [{"code": "invalid-projection", "message": "resolved graph is invalid"}], - } - operation = ( - "upgrade-" - + re.sub(r"[^A-Za-z0-9._-]", "-", feature) - + "-" - + preview["previewHash"].split(":", 1)[-1][:16] - + "-" - + uuid.uuid4().hex[:12] - ) - code, result = replace_graph_atomically( - root, - source, - after, - operation, - expected_hash=preview["source"]["hash"], - backup=backup, - ) - if code != 0: - return code, result - result.update({ - "result": "committed", - "feature": feature, - "destination": preview["destination"], - "backup": str(backup.relative_to(root)), - }) - return 0, result diff --git a/scripts/codeops_state_lib/models.py b/scripts/codeops_state_lib/models.py deleted file mode 100644 index 21586d7..0000000 --- a/scripts/codeops_state_lib/models.py +++ /dev/null @@ -1,172 +0,0 @@ -"""Immutable domain models shared by CodeOps state commands.""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from pathlib import Path -from types import MappingProxyType -from typing import Any, Mapping - - -GATES = frozenset( - { - "requirements", - "specifications", - "plan", - "audit", - "execution", - "task-complete", - "feature-acceptance", - "release", - } -) - -NODE_STATUSES: Mapping[str, frozenset[str]] = { - node_type: frozenset({"draft", "approved", "stale", "superseded"}) - for node_type in ( - "requirement", - "requirement-set", - "specification", - "criterion", - "invariant", - "contract", - "planning-group", - "plan", - "feature", - "audit-artifact", - "release", - ) -} -NODE_STATUSES = { - **NODE_STATUSES, - "ambiguity": frozenset({"open", "resolved", "deferred-approved", "superseded"}), - "decision": frozenset({"approved", "stale", "superseded"}), - "deferral": frozenset( - {"proposed", "approved", "expired", "resolved", "rejected"} - ), - "test": frozenset( - {"planned", "red-confirmed", "passing", "blocked", "stale", "superseded"} - ), - "task": frozenset( - {"pending", "implemented", "verified", "blocked", "stale", "superseded"} - ), - "implementation": frozenset( - {"present", "verified", "stale", "superseded", "reverted"} - ), - "verification": frozenset( - {"planned", "passing", "failing", "blocked", "stale", "superseded"} - ), - "finding": frozenset({"open", "accepted", "resolved", "superseded"}), -} - -RELATIONS = frozenset( - { - "specified-by", - "accepted-by", - "tested-by", - "implemented-by", - "verified-by", - "affected-by", - "depends-on", - "consumes-contract", - "related", - "release-coupled", - } -) - -CONTRACT_MATURITIES = ("provisional", "stable", "frozen") -AGGREGATE_TYPES = frozenset({"requirement-set", "feature", "planning-group"}) - - -@dataclass(frozen=True, slots=True) -class SourceSelector: - kind: str - value: str | None = None - - -@dataclass(frozen=True, slots=True) -class SemanticSource: - path: str - selector: SourceSelector - normalization: str - digest: str - - -@dataclass(frozen=True, slots=True) -class Edge: - relation: str - target: str - required_maturity: str | None = None - - -@dataclass(frozen=True, slots=True) -class ValidationSnapshot: - upstream: str - relation: str - revision: str - gate: str - validated_at: str - - @property - def key(self) -> tuple[str, str, str]: - return self.upstream, self.relation, self.gate - - -@dataclass(frozen=True, slots=True) -class Node: - feature: str - node_id: str - node_type: str - title: str - status: str - semantic_sources: tuple[SemanticSource, ...] - revision: str - edges: tuple[Edge, ...] - validations: tuple[ValidationSnapshot, ...] - maturity: str | None = None - members: tuple[str, ...] = () - member_gates: Mapping[str, str] = field( - default_factory=lambda: MappingProxyType({}) - ) - audit_stage: str | None = None - required: tuple[str, ...] = () - optional: tuple[str, ...] = () - excluded: tuple[str, ...] = () - evidence: tuple[str, ...] = () - risk: str | None = None - - def __post_init__(self) -> None: - object.__setattr__( - self, - "member_gates", - MappingProxyType(dict(sorted(self.member_gates.items()))), - ) - - @property - def canonical_id(self) -> str: - return f"{self.feature}/{self.node_id}" - - -@dataclass(frozen=True, slots=True) -class Graph: - schema: int - feature: str - nodes: tuple[Node, ...] - source: Path - updated: str | None = None - - @property - def identities(self) -> tuple[str, ...]: - return tuple(node.canonical_id for node in self.nodes) - - def by_identity(self) -> dict[str, Node]: - return {node.canonical_id: node for node in self.nodes} - - -@dataclass(frozen=True, slots=True) -class StructuralProblem: - code: str - message: str - source: Path - identity: str | None = None - details: Mapping[str, Any] = field(default_factory=dict) diff --git a/scripts/codeops_state_lib/rendering.py b/scripts/codeops_state_lib/rendering.py deleted file mode 100644 index 6504df7..0000000 --- a/scripts/codeops_state_lib/rendering.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Stable JSON-ready rendering for schema-2 state results.""" - -from __future__ import annotations - -from pathlib import Path -from typing import Any - -from .models import StructuralProblem - - -def problem_json(problem: StructuralProblem, root: Path) -> dict[str, Any]: - try: - source = str(problem.source.relative_to(root)) - except ValueError: - source = str(problem.source) - return { - "code": problem.code, - "message": problem.message, - "source": source, - "identity": problem.identity, - **dict(problem.details), - } - - -def problem_text(problem: StructuralProblem, root: Path) -> str: - item = problem_json(problem, root) - path = item.get("path") - suffix = f" | path: {' -> '.join(path)}" if path else "" - return f"ERROR [{item['code']}]: {item['source']}: {item['message']}{suffix}" diff --git a/scripts/codeops_state_lib/revisions.py b/scripts/codeops_state_lib/revisions.py deleted file mode 100644 index c64a789..0000000 --- a/scripts/codeops_state_lib/revisions.py +++ /dev/null @@ -1,172 +0,0 @@ -"""Canonical semantic revisions and relationship-specific snapshot validation.""" - -from __future__ import annotations - -import hashlib -import re -from pathlib import Path - -from .closure import TRACE_BY_GATE -from .models import Node, SemanticSource, StructuralProblem - - -def normalize_utf8(data: bytes) -> str: - text = data.decode("utf-8") - if text.startswith("\ufeff"): - text = text[1:] - text = text.replace("\r\n", "\n").replace("\r", "\n") - return "\n".join(line.rstrip(" \t") for line in text.split("\n")).rstrip("\n") + "\n" - - -def heading_section(text: str, heading: str) -> str | None: - lines = text.splitlines() - heading_re = re.compile(r"^[ ]{0,3}(#{1,6})[ \t]+(.+?)[ \t]*$") - fence_re = re.compile(r"^[ ]{0,3}(`{3,}|~{3,})(.*)$") - matches: list[tuple[int, int]] = [] - fence: tuple[str, int] | None = None - for index, line in enumerate(lines): - marker = fence_re.match(line) - if marker is not None: - token = marker.group(1) - if fence is None: - fence = (token[0], len(token)) - elif token[0] == fence[0] and len(token) >= fence[1] and not marker.group(2).strip(): - fence = None - continue - if fence is not None: - continue - match = heading_re.fullmatch(line) - if match is not None: - title = re.sub(r"[ \t]+#+[ \t]*$", "", match.group(2)) - if title == heading: - matches.append((index, len(match.group(1)))) - if len(matches) != 1: - return None - start, level = matches[0] - end = len(lines) - fence = None - for index in range(start + 1, len(lines)): - marker = fence_re.match(lines[index]) - if marker is not None: - token = marker.group(1) - if fence is None: - fence = (token[0], len(token)) - elif token[0] == fence[0] and len(token) >= fence[1] and not marker.group(2).strip(): - fence = None - continue - if fence is not None: - continue - match = heading_re.fullmatch(lines[index]) - if match is not None and len(match.group(1)) <= level: - end = index - break - return "\n".join(lines[start:end]).rstrip("\n") + "\n" - - -def compute_revision(root: Path, sources: tuple[SemanticSource, ...]) -> str: - selected: list[tuple[tuple[str, str, str], str]] = [] - for source in sources: - path = (root / source.path).resolve() - if path != root.resolve() and root.resolve() not in path.parents: - raise ValueError(f"source escapes project root: {source.path}") - text = normalize_utf8(path.read_bytes()) - if source.selector.kind == "heading": - section = heading_section(text, source.selector.value or "") - if section is None: - raise ValueError( - f"heading selector must match exactly once: {source.selector.value}" - ) - text = section - selected.append( - ((source.path, source.selector.kind, source.selector.value or ""), text) - ) - payload = "".join(text for _, text in sorted(selected)) - return "sha256:" + hashlib.sha256(payload.encode("utf-8")).hexdigest() - - -def snapshot_problems( - gate: str, - members: tuple[str, ...], - paths: dict[str, tuple[str, ...]], - nodes: dict[str, Node], - sources: dict[str, Path], -) -> list[StructuralProblem]: - problems: list[StructuralProblem] = [] - required_relations = { - "depends-on", - "consumes-contract", - *(TRACE_BY_GATE.get(gate, set()) - {"affected-by"}), - } - if gate == "release": - required_relations.add("release-coupled") - for identity in members: - node = nodes[identity] - required = { - (edge.target, edge.relation, gate) - for edge in node.edges - if edge.relation in required_relations - and edge.relation != "related" - and edge.target in members - } - snapshots = { - snapshot.key: snapshot - for snapshot in node.validations - if snapshot.gate == gate - } - for key in sorted(required - set(snapshots)): - upstream, relation, snapshot_gate = key - problems.append( - StructuralProblem( - "missing-snapshot", - f"{identity} lacks a {snapshot_gate} snapshot for {relation} {upstream}", - sources[identity], - identity, - { - "path": paths.get(identity, (identity,)) + (upstream,), - "upstream": upstream, - "relation": relation, - "gate": snapshot_gate, - }, - ) - ) - for key in sorted(set(snapshots) - required): - snapshot = snapshots[key] - problems.append( - StructuralProblem( - "extraneous-snapshot", - f"{identity} snapshot has no persisted {snapshot.relation} relationship to {snapshot.upstream}", - sources[identity], - identity, - { - "path": paths.get(identity, (identity,)), - "upstream": snapshot.upstream, - "relation": snapshot.relation, - "gate": snapshot.gate, - }, - ) - ) - for key in sorted(required & set(snapshots)): - snapshot = snapshots[key] - upstream = nodes.get(snapshot.upstream) - actual = upstream.revision if upstream is not None else "missing" - if actual == snapshot.revision: - continue - problems.append( - StructuralProblem( - "stale-snapshot", - f"{identity} snapshot for {snapshot.upstream} expected {snapshot.revision} but found {actual}", - sources[identity], - identity, - { - "path": paths.get(identity, (identity,)) + ( - (() if snapshot.upstream == identity else (snapshot.upstream,)) - ), - "upstream": snapshot.upstream, - "expected": snapshot.revision, - "actual": actual, - "relation": snapshot.relation, - "gate": snapshot.gate, - }, - ) - ) - return problems diff --git a/scripts/codeops_state_lib/schema.py b/scripts/codeops_state_lib/schema.py deleted file mode 100644 index 001de33..0000000 --- a/scripts/codeops_state_lib/schema.py +++ /dev/null @@ -1,752 +0,0 @@ -"""Schema-2 parsing and structural graph validation.""" - -from __future__ import annotations - -import json -import hashlib -import re -from collections import defaultdict -from datetime import datetime -from pathlib import Path -from types import MappingProxyType -from typing import Any, Iterable - -from .models import ( - AGGREGATE_TYPES, - CONTRACT_MATURITIES, - GATES, - NODE_STATUSES, - RELATIONS, - Edge, - Graph, - Node, - SemanticSource, - SourceSelector, - StructuralProblem, - ValidationSnapshot, -) - - -FEATURE_RE = re.compile(r"^(?:[A-Za-z0-9]|_[A-Za-z0-9])[A-Za-z0-9._-]*$") -NODE_ID_RE = re.compile(r"^[A-Z][A-Z0-9]*-[A-Za-z0-9._-]+$") -REVISION_RE = re.compile(r"^sha256:[0-9a-f]{64}$") -CANONICAL_ID_RE = re.compile( - r"^(?:[A-Za-z0-9]|_[A-Za-z0-9])[A-Za-z0-9._-]*/[A-Z][A-Z0-9]*-[A-Za-z0-9._-]+$" -) -NORMALIZATION = "utf8-lf-trim-trailing-v1" - -GATEABLE_TYPES = frozenset( - { - "requirement", - "requirement-set", - "specification", - "criterion", - "invariant", - "contract", - "planning-group", - "plan", - "feature", - "audit-artifact", - "release", - "task", - } -) - -RELATION_MATRIX: dict[str, tuple[frozenset[str], frozenset[str]]] = { - "specified-by": ( - frozenset({"requirement"}), - frozenset({"specification", "invariant"}), - ), - "accepted-by": ( - frozenset({"requirement", "specification", "invariant", "contract"}), - frozenset({"criterion"}), - ), - "tested-by": (frozenset({"criterion"}), frozenset({"test"})), - "implemented-by": ( - frozenset({"specification", "criterion", "plan"}), - frozenset({"task", "implementation"}), - ), - "verified-by": ( - frozenset({"criterion", "task", "implementation"}), - frozenset({"verification"}), - ), - "affected-by": ( - frozenset(NODE_STATUSES) - frozenset({"ambiguity", "decision", "finding"}), - frozenset({"ambiguity", "decision", "deferral", "finding"}), - ), - "depends-on": (GATEABLE_TYPES, GATEABLE_TYPES), - "consumes-contract": ( - frozenset({"requirement", "specification", "plan", "task", "feature", "release"}), - frozenset({"contract"}), - ), - "related": (frozenset(NODE_STATUSES), frozenset(NODE_STATUSES)), - "release-coupled": ( - frozenset({"feature", "requirement", "contract", "release"}), - frozenset({"feature", "requirement", "contract", "release"}), - ), -} - - -def _problem(source: Path, code: str, message: str, identity: str | None = None) -> StructuralProblem: - return StructuralProblem(code, message, source, identity) - - -def _safe_project_path(project_root: Path, value: str) -> Path | None: - candidate = (project_root / value).resolve() - root = project_root.resolve() - return candidate if candidate == root or root in candidate.parents else None - - -def _normalized_text(path: Path) -> str: - text = path.read_text(encoding="utf-8") - if text.startswith("\ufeff"): - text = text[1:] - text = text.replace("\r\n", "\n").replace("\r", "\n") - lines = [line.rstrip(" \t") for line in text.split("\n")] - return "\n".join(lines).rstrip("\n") + "\n" - - -def _heading_section(text: str, heading: str) -> str | None: - matches: list[tuple[int, int]] = [] - lines = text.splitlines() - heading_re = re.compile(r"^[ ]{0,3}(#{1,6})[ \t]+(.+?)[ \t]*$") - fence_re = re.compile(r"^[ ]{0,3}(`{3,}|~{3,})(.*)$") - fence_character: str | None = None - fence_length = 0 - for index, line in enumerate(lines): - fence = fence_re.match(line) - if fence is not None: - marker = fence.group(1) - if fence_character is None: - fence_character = marker[0] - fence_length = len(marker) - elif ( - marker[0] == fence_character - and len(marker) >= fence_length - and not fence.group(2).strip() - ): - fence_character = None - fence_length = 0 - continue - if fence_character is not None: - continue - match = heading_re.fullmatch(line) - if match is None: - continue - title = re.sub(r"[ \t]+#+[ \t]*$", "", match.group(2)) - if title == heading: - matches.append((index, len(match.group(1)))) - if len(matches) != 1: - return None - start, level = matches[0] - end = len(lines) - fence_character = None - fence_length = 0 - for index in range(start + 1, len(lines)): - fence = fence_re.match(lines[index]) - if fence is not None: - marker = fence.group(1) - if fence_character is None: - fence_character = marker[0] - fence_length = len(marker) - elif ( - marker[0] == fence_character - and len(marker) >= fence_length - and not fence.group(2).strip() - ): - fence_character = None - fence_length = 0 - continue - if fence_character is not None: - continue - match = heading_re.fullmatch(lines[index]) - if match is not None and len(match.group(1)) <= level: - end = index - break - return "\n".join(lines[start:end]).rstrip("\n") + "\n" - - -def _source_revision( - sources: tuple[SemanticSource, ...], - graph_path: Path, - project_root: Path, -) -> tuple[str | None, str | None]: - selected: list[tuple[tuple[str, str, str], str]] = [] - for source in sources: - resolved = _safe_project_path(project_root, source.path) - if resolved is None or not resolved.is_file(): - return None, "source-selection" - try: - text = _normalized_text(resolved) - except UnicodeDecodeError: - return None, "source-not-utf8" - if source.selector.kind == "heading": - text = _heading_section(text, source.selector.value or "") - if text is None: - return None, "source-selection" - selected.append( - ( - (source.path, source.selector.kind, source.selector.value or ""), - text, - ) - ) - payload = "".join(text for _, text in sorted(selected)) - return "sha256:" + hashlib.sha256(payload.encode("utf-8")).hexdigest(), None - - -def _is_rfc3339_utc(value: Any) -> bool: - if not isinstance(value, str) or not value.endswith("Z"): - return False - try: - datetime.fromisoformat(value[:-1] + "+00:00") - except ValueError: - return False - return True - - -def _strings(value: Any) -> tuple[str, ...] | None: - if not isinstance(value, list) or not all(isinstance(item, str) for item in value): - return None - return tuple(value) - - -def parse_graph_v2(path: Path, project_root: Path) -> tuple[Graph | None, list[StructuralProblem]]: - problems: list[StructuralProblem] = [] - try: - raw = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: - return None, [_problem(path, "invalid-json", f"cannot parse JSON: {exc}")] - if not isinstance(raw, dict): - return None, [_problem(path, "invalid-root", "root must be a JSON object")] - allowed_root = {"schema", "feature", "updated", "nodes"} - unknown_root = set(raw) - allowed_root - if unknown_root: - problems.append(_problem(path, "unknown-field", f"unknown root fields {sorted(unknown_root)}")) - if raw.get("schema") != 2: - problems.append(_problem(path, "schema-version", "schema must equal 2")) - return None, problems - feature = raw.get("feature") - if not isinstance(feature, str) or not FEATURE_RE.fullmatch(feature): - problems.append(_problem(path, "invalid-feature", "feature is invalid")) - return None, problems - if "updated" in raw and not isinstance(raw["updated"], str): - problems.append(_problem(path, "invalid-updated", "updated must be a string")) - raw_nodes = raw.get("nodes") - if not isinstance(raw_nodes, list): - problems.append(_problem(path, "invalid-nodes", "nodes must be an array")) - return Graph(2, feature, (), path, raw.get("updated")), problems - - nodes: list[Node] = [] - seen_ids: set[str] = set() - for index, value in enumerate(raw_nodes): - node, node_problems = _parse_node(value, feature, path, project_root, index) - problems.extend(node_problems) - if node is None: - continue - if node.node_id in seen_ids: - problems.append( - _problem( - path, - "duplicate-identity", - f"duplicate canonical identity {node.canonical_id}", - node.canonical_id, - ) - ) - continue - seen_ids.add(node.node_id) - nodes.append(node) - return Graph(2, feature, tuple(nodes), path, raw.get("updated")), problems - - -def _parse_node( - raw: Any, - feature: str, - path: Path, - project_root: Path, - index: int, -) -> tuple[Node | None, list[StructuralProblem]]: - problems: list[StructuralProblem] = [] - prefix = f"nodes[{index}]" - if not isinstance(raw, dict): - return None, [_problem(path, "invalid-node", f"{prefix} must be an object")] - required = { - "id", - "type", - "title", - "status", - "semanticSources", - "revision", - "edges", - "validations", - } - optional = { - "maturity", - "members", - "memberGates", - "auditStage", - "required", - "optional", - "excluded", - "evidence", - "risk", - } - missing = required - set(raw) - unknown = set(raw) - required - optional - if missing: - problems.append(_problem(path, "missing-field", f"{prefix} missing {sorted(missing)}")) - if unknown: - problems.append(_problem(path, "unknown-field", f"{prefix} has unknown fields {sorted(unknown)}")) - if missing or unknown: - return None, problems - node_id = raw["id"] - node_type = raw["type"] - identity = f"{feature}/{node_id}" if isinstance(node_id, str) else None - if not isinstance(node_id, str) or not NODE_ID_RE.fullmatch(node_id): - problems.append(_problem(path, "invalid-node-id", f"{prefix}.id is invalid", identity)) - if node_type not in NODE_STATUSES: - problems.append(_problem(path, "invalid-node-type", f"{prefix}.type is invalid", identity)) - if not isinstance(raw["title"], str) or not raw["title"].strip(): - problems.append(_problem(path, "invalid-title", f"{prefix}.title must be non-empty", identity)) - if ( - node_type in NODE_STATUSES - and ( - not isinstance(raw["status"], str) - or raw["status"] not in NODE_STATUSES[node_type] - ) - ): - problems.append( - _problem( - path, - "invalid-status", - f"{identity}.status {raw['status']!r} is invalid for {node_type}", - identity, - ) - ) - if not isinstance(raw["revision"], str) or not REVISION_RE.fullmatch(raw["revision"]): - problems.append(_problem(path, "invalid-revision", f"{identity}.revision is invalid", identity)) - - sources = _parse_sources(raw["semanticSources"], path, project_root, identity, problems) - edges = _parse_edges(raw["edges"], path, identity, problems) - validations = _parse_validations(raw["validations"], path, identity, problems) - members = _strings(raw.get("members", [])) - required_members = _strings(raw.get("required", [])) - optional_members = _strings(raw.get("optional", [])) - excluded_members = _strings(raw.get("excluded", [])) - evidence = _strings(raw.get("evidence", [])) - for field_name, parsed in ( - ("members", members), - ("required", required_members), - ("optional", optional_members), - ("excluded", excluded_members), - ("evidence", evidence), - ): - if parsed is None: - problems.append(_problem(path, "invalid-field", f"{identity}.{field_name} must be an array of strings", identity)) - if problems or not isinstance(node_id, str) or node_type not in NODE_STATUSES: - return None, problems - member_gates = raw.get("memberGates", {}) - if not isinstance(member_gates, dict) or not all( - isinstance(key, str) and value in {"task-complete", "feature-acceptance", "release"} - for key, value in member_gates.items() - ): - problems.append(_problem(path, "invalid-member-gates", f"{identity}.memberGates is invalid", identity)) - member_gates = {} - audit_stage = raw.get("auditStage") - if node_type == "audit-artifact": - allowed_stages = GATES - {"audit"} - if audit_stage not in allowed_stages: - problems.append(_problem(path, "invalid-audit-stage", f"{identity}.auditStage is required and must name a non-audit gate", identity)) - elif audit_stage is not None: - problems.append(_problem(path, "invalid-audit-stage", f"{identity} may not carry auditStage", identity)) - if node_type == "release": - missing_release = {"required", "optional", "excluded"} - set(raw) - if missing_release: - problems.append(_problem(path, "missing-release-members", f"{identity} missing {sorted(missing_release)}", identity)) - elif {"required", "optional", "excluded"} & set(raw): - problems.append(_problem(path, "invalid-release-members", f"{identity} may not carry release membership", identity)) - if raw.get("risk") is not None and raw["risk"] not in {"low", "medium", "high", "critical"}: - problems.append(_problem(path, "invalid-risk", f"{identity}.risk is invalid", identity)) - if evidence is not None and ( - any(not item for item in evidence) or len(set(evidence)) != len(evidence) - ): - problems.append(_problem(path, "invalid-evidence", f"{identity}.evidence must contain unique non-empty strings", identity)) - for field_name, values in ( - ("members", members), - ("required", required_members), - ("optional", optional_members), - ("excluded", excluded_members), - ): - if values is not None and any(not CANONICAL_ID_RE.fullmatch(item) for item in values): - problems.append(_problem(path, "invalid-identity", f"{identity}.{field_name} contains a non-canonical identity", identity)) - if problems: - return None, problems - computed_revision, source_error = _source_revision(sources, path, project_root) - if computed_revision is None: - if source_error == "source-not-utf8": - problems.append(_problem(path, "source-not-utf8", f"{identity} semantic source is not valid UTF-8", identity)) - else: - problems.append(_problem(path, "source-selection", f"{identity} source selector must match exactly one normalized ATX heading", identity)) - return None, problems - if raw["revision"] != computed_revision: - problems.append(_problem(path, "revision-mismatch", f"{identity}.revision does not match semantic sources", identity)) - return None, problems - return ( - Node( - feature=feature, - node_id=node_id, - node_type=node_type, - title=raw["title"], - status=raw["status"], - semantic_sources=sources, - revision=raw["revision"], - edges=edges, - validations=validations, - maturity=raw.get("maturity"), - members=members or (), - member_gates=MappingProxyType(dict(sorted(member_gates.items()))), - audit_stage=audit_stage, - required=required_members or (), - optional=optional_members or (), - excluded=excluded_members or (), - evidence=evidence or (), - risk=raw.get("risk"), - ), - problems, - ) - - -def _parse_sources( - raw: Any, - graph_path: Path, - project_root: Path, - identity: str | None, - problems: list[StructuralProblem], -) -> tuple[SemanticSource, ...]: - if not isinstance(raw, list) or not raw: - problems.append(_problem(graph_path, "invalid-sources", f"{identity}.semanticSources must be non-empty", identity)) - return () - result: list[SemanticSource] = [] - for index, value in enumerate(raw): - if not isinstance(value, dict): - problems.append(_problem(graph_path, "invalid-source", f"{identity}.semanticSources[{index}] is invalid", identity)) - continue - selector = value.get("selector") - selector_kind = selector.get("kind") if isinstance(selector, dict) else None - selector_value = selector.get("value") if isinstance(selector, dict) else None - valid_selector = selector_kind == "whole-file" and set(selector) == {"kind"} - valid_selector |= ( - selector_kind == "heading" - and isinstance(selector_value, str) - and bool(selector_value) - and set(selector) == {"kind", "value"} - ) - source_path = value.get("path") - if ( - set(value) != {"path", "selector", "normalization", "digest"} - or not isinstance(source_path, str) - or not source_path - or not valid_selector - or value.get("normalization") != NORMALIZATION - or value.get("digest") != "sha256" - ): - problems.append(_problem(graph_path, "invalid-source", f"{identity}.semanticSources[{index}] is invalid", identity)) - continue - resolved = _safe_project_path(project_root, source_path) - if resolved is None: - problems.append(_problem(graph_path, "path-escape", f"{identity} source escapes project root: {source_path}", identity)) - elif not resolved.is_file(): - problems.append(_problem(graph_path, "missing-source", f"{identity} source is missing: {source_path}", identity)) - result.append( - SemanticSource( - source_path, - SourceSelector(selector_kind, selector_value), - NORMALIZATION, - "sha256", - ) - ) - return tuple(result) - - -def _parse_edges( - raw: Any, - path: Path, - identity: str | None, - problems: list[StructuralProblem], -) -> tuple[Edge, ...]: - if not isinstance(raw, list): - problems.append(_problem(path, "invalid-edges", f"{identity}.edges must be an array", identity)) - return () - result: list[Edge] = [] - seen: set[tuple[str, str]] = set() - inverse = {"blocks": "depends-on", "provides-contract": "consumes-contract"} - for index, value in enumerate(raw): - if not isinstance(value, dict): - problems.append(_problem(path, "invalid-edge", f"{identity}.edges[{index}] is invalid", identity)) - continue - relation = value.get("relation") - target = value.get("target") - if relation in inverse: - problems.append( - _problem( - path, - "persisted-inverse", - f"{identity} persists {relation}; store canonical relation {inverse[relation]}", - identity, - ) - ) - continue - allowed_fields = {"relation", "target", "requiredMaturity"} - if ( - relation not in RELATIONS - or not isinstance(target, str) - or not CANONICAL_ID_RE.fullmatch(target) - or set(value) - allowed_fields - ): - problems.append(_problem(path, "invalid-edge", f"{identity}.edges[{index}] is invalid", identity)) - continue - maturity = value.get("requiredMaturity") - if relation == "consumes-contract": - if maturity not in CONTRACT_MATURITIES: - problems.append(_problem(path, "invalid-maturity", f"{identity} consumes-contract requires valid requiredMaturity", identity)) - elif maturity is not None: - problems.append(_problem(path, "invalid-maturity", f"{identity} may set requiredMaturity only on consumes-contract", identity)) - key = (relation, target) - if key in seen: - problems.append(_problem(path, "duplicate-edge", f"{identity} has duplicate {relation} edge to {target}", identity)) - continue - seen.add(key) - result.append(Edge(relation, target, maturity)) - return tuple(result) - - -def _parse_validations( - raw: Any, - path: Path, - identity: str | None, - problems: list[StructuralProblem], -) -> tuple[ValidationSnapshot, ...]: - if not isinstance(raw, list): - problems.append(_problem(path, "invalid-validations", f"{identity}.validations must be an array", identity)) - return () - result: list[ValidationSnapshot] = [] - seen: set[tuple[str, str, str]] = set() - for index, value in enumerate(raw): - required = {"upstream", "relation", "revision", "gate", "validatedAt"} - if not isinstance(value, dict) or set(value) != required: - problems.append(_problem(path, "invalid-validation", f"{identity}.validations[{index}] is invalid", identity)) - continue - snapshot = ValidationSnapshot( - value["upstream"], - value["relation"], - value["revision"], - value["gate"], - value["validatedAt"], - ) - if ( - not isinstance(snapshot.upstream, str) - or not CANONICAL_ID_RE.fullmatch(snapshot.upstream) - or snapshot.relation not in { - "specified-by", - "accepted-by", - "tested-by", - "implemented-by", - "verified-by", - "affected-by", - "depends-on", - "consumes-contract", - "release-coupled", - } - or not isinstance(snapshot.revision, str) - or not REVISION_RE.fullmatch(snapshot.revision) - or snapshot.gate not in GATES - or not _is_rfc3339_utc(snapshot.validated_at) - ): - problems.append(_problem(path, "invalid-validation", f"{identity}.validations[{index}] is invalid", identity)) - continue - if snapshot.key in seen: - problems.append(_problem(path, "duplicate-validation", f"{identity} has duplicate validation {snapshot.key}", identity)) - continue - seen.add(snapshot.key) - result.append(snapshot) - return tuple(sorted(result, key=lambda item: item.key)) - - -def validate_portfolio_v2(graphs: Iterable[Graph]) -> list[StructuralProblem]: - graph_list = list(graphs) - problems: list[StructuralProblem] = [] - nodes: dict[str, Node] = {} - owners: dict[str, Path] = {} - for graph in graph_list: - for node in graph.nodes: - if node.canonical_id in nodes: - problems.append( - _problem( - graph.source, - "duplicate-identity", - f"duplicate canonical identity {node.canonical_id}; first declared in {owners[node.canonical_id]}", - node.canonical_id, - ) - ) - else: - nodes[node.canonical_id] = node - owners[node.canonical_id] = graph.source - group_owner: dict[tuple[str, str], str] = {} - for node in nodes.values(): - problems.extend(_validate_node_semantics(node, nodes, owners[node.canonical_id], group_owner)) - problems.extend(_relationship_pair_problems(nodes, owners)) - problems.extend(_cycle_problems(nodes, owners)) - return problems - - -def _relationship_pair_problems( - nodes: dict[str, Node], - owners: dict[str, Path], -) -> list[StructuralProblem]: - problems: list[StructuralProblem] = [] - symmetric_seen: dict[tuple[str, str, str], str] = {} - for identity in sorted(nodes): - for edge in nodes[identity].edges: - if edge.relation not in {"related", "release-coupled"} or edge.target not in nodes: - continue - left, right = sorted((identity, edge.target)) - key = (edge.relation, left, right) - prior = symmetric_seen.get(key) - if prior is not None: - problems.append( - _problem( - owners[identity], - "redundant-relationship", - f"{identity} redundantly stores reverse {edge.relation} relationship already owned by {prior}; store it once", - identity, - ) - ) - else: - symmetric_seen[key] = identity - return problems - - -def _validate_node_semantics( - node: Node, - nodes: dict[str, Node], - source: Path, - group_owner: dict[tuple[str, str], str], -) -> list[StructuralProblem]: - problems: list[StructuralProblem] = [] - identity = node.canonical_id - if node.node_type == "contract": - if node.maturity not in CONTRACT_MATURITIES: - problems.append(_problem(source, "invalid-maturity", f"{identity} requires contract maturity", identity)) - elif node.maturity is not None: - problems.append(_problem(source, "invalid-maturity", f"{identity} may not carry maturity", identity)) - if node.node_type in AGGREGATE_TYPES: - if not node.members or tuple(sorted(set(node.members))) != node.members: - problems.append(_problem(source, "invalid-members", f"{identity}.members must be sorted, unique, and non-empty", identity)) - elif node.members: - problems.append(_problem(source, "invalid-members", f"{identity} may not carry members", identity)) - if node.node_type == "feature": - if set(node.member_gates) != set(node.members): - problems.append(_problem(source, "invalid-member-gates", f"{identity}.memberGates must exactly cover members", identity)) - elif node.member_gates: - problems.append(_problem(source, "invalid-member-gates", f"{identity} may not carry memberGates", identity)) - if node.node_type == "planning-group": - for member in node.members: - key = ("all", member) - if key in group_owner: - problems.append(_problem(source, "multiple-groups", f"{member} belongs to both {group_owner[key]} and {identity}", identity)) - group_owner[key] = identity - release_sets = [set(node.required), set(node.optional), set(node.excluded)] - if node.node_type == "release": - for field_name, values in ( - ("required", node.required), - ("optional", node.optional), - ("excluded", node.excluded), - ): - if len(values) != len(set(values)): - problems.append( - _problem( - source, - "duplicate-release-member", - f"{identity}.{field_name} must contain unique members", - identity, - ) - ) - overlap = (release_sets[0] & release_sets[1]) | (release_sets[0] & release_sets[2]) | (release_sets[1] & release_sets[2]) - if overlap: - problems.append(_problem(source, "release-membership-conflict", f"{identity} has conflicting release members {sorted(overlap)}", identity)) - elif any(release_sets): - problems.append(_problem(source, "invalid-release-members", f"{identity} may not carry release membership", identity)) - for member in (*node.members, *node.required, *node.optional, *node.excluded): - if member not in nodes: - problems.append(_problem(source, "missing-member", f"{identity} names missing member {member}", identity)) - for edge in node.edges: - target = nodes.get(edge.target) - if target is None: - problems.append(_problem(source, "dangling-edge", f"{identity} {edge.relation} targets missing node {edge.target}", identity)) - continue - if edge.target == identity: - problems.append(_problem(source, "self-edge", f"{identity} has self {edge.relation} edge", identity)) - continue - allowed_sources, allowed_targets = RELATION_MATRIX[edge.relation] - if node.node_type not in allowed_sources or target.node_type not in allowed_targets: - problems.append( - _problem( - source, - "illegal-edge", - f"{identity} ({node.node_type}) {edge.relation} {edge.target} ({target.node_type}) is not allowed", - identity, - ) - ) - return problems - - -def _cycle_problems(nodes: dict[str, Node], owners: dict[str, Path]) -> list[StructuralProblem]: - group_by_member: dict[str, str] = {} - for node in nodes.values(): - if node.node_type == "planning-group": - for member in node.members: - group_by_member[member] = node.canonical_id - - def vertex(identity: str) -> str: - return group_by_member.get(identity, identity) - - adjacency: dict[str, set[str]] = defaultdict(set) - for node in nodes.values(): - source_vertex = vertex(node.canonical_id) - for edge in node.edges: - if edge.relation not in {"depends-on", "consumes-contract"} or edge.target not in nodes: - continue - target_vertex = vertex(edge.target) - if source_vertex != target_vertex: - adjacency[source_vertex].add(target_vertex) - state: dict[str, int] = {} - stack: list[str] = [] - problems: list[StructuralProblem] = [] - - def visit(current: str) -> bool: - state[current] = 1 - stack.append(current) - for target in sorted(adjacency[current]): - if state.get(target, 0) == 0: - if visit(target): - return True - elif state.get(target) == 1: - start = stack.index(target) - cycle = stack[start:] + [target] - owner_identity = cycle[0] - problems.append( - _problem( - owners.get(owner_identity, next(iter(owners.values()))), - "dependency-cycle", - "blocking dependency cycle: " + " -> ".join(cycle), - owner_identity, - ) - ) - return True - stack.pop() - state[current] = 2 - return False - - for identity in sorted(set(nodes) | set(adjacency)): - if state.get(identity, 0) == 0 and visit(identity): - break - return problems diff --git a/scripts/codeops_state_lib/transitions.py b/scripts/codeops_state_lib/transitions.py deleted file mode 100644 index 0952b74..0000000 --- a/scripts/codeops_state_lib/transitions.py +++ /dev/null @@ -1,1722 +0,0 @@ -"""Lock-safe compare-and-swap transitions and explicit recovery.""" - -from __future__ import annotations - -import hashlib -import json -import os -import re -import tempfile -import uuid -from dataclasses import replace -from pathlib import Path -from typing import Any - -from .discovery import discover_state -from .gates import compatibility_problem, evaluate_target -from .models import ( - Graph, - Node, - SemanticSource, - SourceSelector, - StructuralProblem, -) -from .rendering import problem_json -from .revisions import compute_revision -from .schema import parse_graph_v2, validate_portfolio_v2 - - -CONTENT_TYPES = { - "requirement", - "requirement-set", - "specification", - "criterion", - "invariant", - "contract", - "planning-group", - "plan", - "feature", - "audit-artifact", - "release", -} -SAFE_OPERATION = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") -REQUEST_FIELDS = { - "schema", - "operationId", - "target", - "expected", - "requested", - "gate", - "sourceUpdates", - "validationAdditions", - "validationRemovals", - "staleReason", - "evidence", -} - - -def _hash(data: bytes) -> str: - return "sha256:" + hashlib.sha256(data).hexdigest() - - -def _read_json(path: Path) -> tuple[dict[str, Any] | None, str | None]: - try: - value = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: - return None, f"cannot parse JSON: {exc}" - if not isinstance(value, dict): - return None, "JSON root must be an object" - return value, None - - -def _load_v2( - root: Path, refresh_target: str | None = None -) -> tuple[list[Graph], list[StructuralProblem]]: - discovery = discover_state(root) - graphs: list[Graph] = [] - problems: list[StructuralProblem] = list(discovery.problems) - for path in discovery.paths: - try: - raw = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: - problems.append(StructuralProblem("invalid-json", str(exc), path)) - continue - if not isinstance(raw, dict) or raw.get("schema") != 2: - continue - parse_path = path - temporary_path: Path | None = None - feature = raw.get("feature") - if ( - refresh_target is not None - and isinstance(feature, str) - and refresh_target.startswith(f"{feature}/") - and isinstance(raw.get("nodes"), list) - ): - node_id = refresh_target.split("/", 1)[1] - raw_node = next( - ( - item - for item in raw["nodes"] - if isinstance(item, dict) and item.get("id") == node_id - ), - None, - ) - if raw_node is not None and isinstance( - raw_node.get("semanticSources"), list - ): - try: - semantic_sources = tuple( - SemanticSource( - item["path"], - SourceSelector( - item["selector"]["kind"], - item["selector"].get("value"), - ), - item["normalization"], - item["digest"], - ) - for item in raw_node["semanticSources"] - ) - raw_node["revision"] = compute_revision( - root, semantic_sources - ) - descriptor, temporary = tempfile.mkstemp( - prefix=f".{path.name}.refresh.", - suffix=".json", - dir=path.parent, - ) - temporary_path = Path(temporary) - with os.fdopen(descriptor, "w", encoding="utf-8") as handle: - json.dump(raw, handle) - parse_path = temporary_path - except (KeyError, TypeError, ValueError, OSError, UnicodeError): - pass - graph, graph_problems = parse_graph_v2(parse_path, root) - if graph is not None and parse_path != path: - graph = replace(graph, source=path) - if temporary_path is not None: - temporary_path.unlink(missing_ok=True) - problems.extend(graph_problems) - if graph is not None: - graphs.append(graph) - problems.extend(validate_portfolio_v2(graphs)) - return graphs, problems - - -def _allowed(node: Node, requested: str) -> bool: - current = node.status - if node.node_type in CONTENT_TYPES: - return requested in { - "draft": {"approved", "superseded"}, - "approved": {"stale", "superseded"}, - "stale": {"draft", "approved", "superseded"}, - }.get(current, set()) - return requested in { - "test": { - "planned": {"red-confirmed", "blocked"}, - "red-confirmed": {"passing", "blocked"}, - "passing": {"stale", "blocked"}, - }, - "task": { - "pending": {"implemented", "blocked"}, - "implemented": {"verified", "blocked", "stale"}, - "verified": {"stale"}, - }, - "implementation": { - "present": {"verified", "stale", "reverted"}, - "verified": {"stale", "reverted"}, - }, - "verification": { - "planned": {"passing", "failing", "blocked"}, - "passing": {"stale"}, - }, - "ambiguity": { - "open": {"resolved", "deferred-approved", "superseded"}, - "resolved": {"superseded"}, - "deferred-approved": {"resolved", "superseded"}, - }, - "finding": { - "open": {"accepted", "resolved", "superseded"}, - "accepted": {"resolved", "superseded"}, - "resolved": {"superseded"}, - }, - "deferral": { - "proposed": {"approved", "rejected"}, - "approved": {"expired", "resolved"}, - "expired": {"resolved"}, - }, - }.get(node.node_type, {}).get(current, set()) - - -def _required_gate(node: Node, requested: str) -> str: - if node.node_type in {"requirement", "requirement-set"}: - return "requirements" - if node.node_type in { - "specification", - "criterion", - "invariant", - "contract", - "planning-group", - }: - return "specifications" - if node.node_type == "plan": - return "plan" if requested == "approved" else "execution" - if node.node_type == "feature": - return "feature-acceptance" - if node.node_type == "release": - return "release" - if node.node_type == "task": - return "plan" if node.status == "pending" and requested == "implemented" else "task-complete" - if node.node_type in {"test", "implementation", "verification"}: - return "task-complete" - if node.node_type == "audit-artifact": - return node.audit_stage or "requirements" - return "requirements" - - -def _evidence_problem(node: Node, requested: str, request: dict[str, Any]) -> str | None: - evidence = request["evidence"] - if not isinstance(evidence, dict) or not all( - isinstance(key, str) and isinstance(value, str) and value.strip() - for key, value in evidence.items() - ): - return "evidence must be an object of non-empty string values" - required: str | None = None - if requested == "superseded": - required = "replacement" - elif requested == "blocked": - required = "blocker" - elif requested == "stale": - required = "invalidation" - elif node.status == "stale" and requested == "draft": - required = "reopenDecision" - elif node.node_type == "test" and requested == "red-confirmed": - required = "redEvidence" - elif node.node_type == "test" and requested == "passing": - required = "greenEvidence" - elif node.node_type == "task" and requested == "verified": - required = "verificationEvidence" - elif node.node_type == "implementation" and requested == "verified": - required = "verificationEvidence" - elif node.node_type == "implementation" and requested == "reverted": - required = "rollbackEvidence" - elif node.node_type == "verification" and requested in {"passing", "failing"}: - required = "commandEvidence" - elif node.node_type in {"ambiguity", "finding", "deferral"}: - required = "userRuling" - if required is not None and required not in evidence: - return f"{node.status} -> {requested} requires evidence.{required}" - if requested == "stale" and not request["staleReason"]: - return "a stale transition requires staleReason" - return None - - -def _atomic_write(path: Path, data: bytes) -> None: - descriptor, temporary = tempfile.mkstemp( - prefix=f".{path.name}.", suffix=".tmp", dir=path.parent - ) - try: - with os.fdopen(descriptor, "wb") as handle: - handle.write(data) - handle.flush() - os.fsync(handle.fileno()) - os.replace(temporary, path) - try: - directory = os.open(path.parent, os.O_RDONLY) - try: - os.fsync(directory) - finally: - os.close(directory) - except OSError: - pass - finally: - try: - Path(temporary).unlink() - except FileNotFoundError: - pass - - -def _write_json(path: Path, value: dict[str, Any]) -> None: - _atomic_write( - path, - (json.dumps(value, indent=2, sort_keys=True) + "\n").encode("utf-8"), - ) - - -def _sync_directory(path: Path) -> None: - try: - descriptor = os.open(path, os.O_RDONLY) - try: - os.fsync(descriptor) - finally: - os.close(descriptor) - except OSError: - pass - - -def _process_identity(pid: int) -> dict[str, Any] | None: - stat = Path(f"/proc/{pid}/stat") - boot = Path("/proc/sys/kernel/random/boot_id") - try: - fields = stat.read_text(encoding="utf-8").split() - return { - "pid": pid, - "startTicks": fields[21], - "bootId": boot.read_text(encoding="utf-8").strip(), - } - except (OSError, IndexError): - return None - - -def _owner_is_absent(owner: Any) -> bool | None: - if not isinstance(owner, dict) or set(owner) != {"pid", "startTicks", "bootId"}: - return None - if ( - not isinstance(owner["pid"], int) - or not isinstance(owner["startTicks"], str) - or not isinstance(owner["bootId"], str) - ): - return None - proc_root = Path("/proc") - if not proc_root.is_dir(): - return None - try: - boot_id = (proc_root / "sys/kernel/random/boot_id").read_text( - encoding="utf-8" - ).strip() - except OSError: - return None - if boot_id != owner["bootId"]: - return True - stat = proc_root / str(owner["pid"]) / "stat" - try: - fields = stat.read_text(encoding="utf-8").split() - except FileNotFoundError: - return True - except OSError: - return None - try: - return fields[21] != owner["startTicks"] - except IndexError: - return None - - -def _failure( - root: Path, - code: str, - message: str, - target: str | None = None, - *, - result: str = "refused", - exit_code: int = 1, - operation: str | None = None, -) -> tuple[int, dict[str, Any]]: - problem = StructuralProblem(code, message, root, target) - payload: dict[str, Any] = { - "result": result, - "target": target, - "blockers": [problem_json(problem, root)], - } - if operation is not None: - payload["operationId"] = operation - return exit_code, payload - - -def _state_paths(root: Path, operation: str) -> tuple[Path, Path, Path, Path, Path]: - state = root / "codeops" / ".state-transactions" - return ( - state, - state / f"{operation}.lock", - state / f"{operation}.journal.json", - state / f"{operation}.before", - state / f"{operation}.after", - ) - - -def _validate_request( - root: Path, request: dict[str, Any] -) -> tuple[str | None, str | None, str | None]: - target = request.get("target") - operation = request.get("operationId") - if set(request) != REQUEST_FIELDS or request.get("schema") != 1: - return None, target if isinstance(target, str) else None, "request fields or schema are invalid" - if not isinstance(operation, str) or SAFE_OPERATION.fullmatch(operation) is None: - return None, target if isinstance(target, str) else None, "operationId is not path-safe" - expected = request.get("expected") - requested = request.get("requested") - if ( - not isinstance(target, str) - or not isinstance(expected, dict) - or set(expected) != {"status", "revision"} - or not all(isinstance(expected.get(key), str) for key in ("status", "revision")) - or not isinstance(requested, dict) - or set(requested) != {"status"} - or not isinstance(requested.get("status"), str) - or not isinstance(request.get("gate"), str) - or not isinstance(request.get("sourceUpdates"), list) - or not isinstance(request.get("validationAdditions"), list) - or not isinstance(request.get("validationRemovals"), list) - or not isinstance(request.get("evidence"), dict) - or (request.get("staleReason") is not None and not isinstance(request.get("staleReason"), str)) - ): - return None, target if isinstance(target, str) else None, "request preconditions are incomplete" - return operation, target, None - - -def _persisted_target_state( - root: Path, target: str -) -> tuple[str, str] | None: - feature, node_id = target.split("/", 1) - for path in discover_state(root).paths: - raw, _ = _read_json(path) - if raw is None or raw.get("schema") != 2 or raw.get("feature") != feature: - continue - for item in raw.get("nodes", []): - if ( - isinstance(item, dict) - and item.get("id") == node_id - and isinstance(item.get("status"), str) - and isinstance(item.get("revision"), str) - ): - return item["status"], item["revision"] - return None - - -def _project_raw( - root: Path, raw: dict[str, Any], node: Node, request: dict[str, Any] -) -> bytes: - raw_node = next(item for item in raw["nodes"] if item["id"] == node.node_id) - raw_node["status"] = request["requested"]["status"] - remove_keys = { - (item.get("upstream"), item.get("relation"), item.get("gate")) - for item in request["validationRemovals"] - if isinstance(item, dict) - } - raw_node["validations"] = [ - item - for item in raw_node["validations"] - if (item.get("upstream"), item.get("relation"), item.get("gate")) - not in remove_keys - ] + request["validationAdditions"] - for update in request["sourceUpdates"]: - if not isinstance(update, dict) or set(update) != {"path"}: - raise ValueError("sourceUpdates entries require only path") - if not isinstance(update["path"], str): - raise ValueError("sourceUpdates path must be a string") - if update["path"] not in {item["path"] for item in raw_node["semanticSources"]}: - raise ValueError(f"source update is not owned by target: {update['path']}") - if request["sourceUpdates"]: - raw_node["revision"] = compute_revision(root, node.semantic_sources) - durable_evidence = [ - f"{key}:{value}" for key, value in sorted(request["evidence"].items()) - ] - if durable_evidence: - raw_node["evidence"] = sorted( - set(raw_node.get("evidence", [])) | set(durable_evidence) - ) - return (json.dumps(raw, indent=2, sort_keys=True) + "\n").encode("utf-8") - - -def _parse_projected( - path: Path, data: bytes, root: Path -) -> tuple[Graph | None, list[StructuralProblem]]: - descriptor, temporary = tempfile.mkstemp( - prefix=f".{path.name}.projection.", suffix=".json", dir=path.parent - ) - temporary_path = Path(temporary) - try: - with os.fdopen(descriptor, "wb") as handle: - handle.write(data) - graph, problems = parse_graph_v2(temporary_path, root) - return (replace(graph, source=path) if graph is not None else None), problems - finally: - temporary_path.unlink(missing_ok=True) - - -def _project_portfolio( - root: Path, - graphs: list[Graph], - target: str, - request: dict[str, Any], -) -> tuple[ - list[Graph], - dict[Path, bytes], - dict[Path, bytes], - list[StructuralProblem], -]: - nodes = {node.canonical_id: node for graph in graphs for node in graph.nodes} - target_node = nodes[target] - graph_by_identity = { - node.canonical_id: graph for graph in graphs for node in graph.nodes - } - raw_by_path = { - graph.source: json.loads(graph.source.read_text(encoding="utf-8")) - for graph in graphs - } - target_graph = graph_by_identity[target] - target_after = _project_raw( - root, raw_by_path[target_graph.source], target_node, request - ) - raw_by_path[target_graph.source] = json.loads(target_after.decode("utf-8")) - - invalidating = { - "depends-on", - "consumes-contract", - "specified-by", - "accepted-by", - "tested-by", - "implemented-by", - "verified-by", - "affected-by", - } - target_changed = ( - request["requested"]["status"] == "stale" - or bool(request["sourceUpdates"]) - ) - if target_changed: - pending = [target] - invalidated = {target} - while pending: - changed = pending.pop(0) - for identity, current in sorted(nodes.items()): - owner_graph = graph_by_identity[identity] - raw_owner = next( - item - for item in raw_by_path[owner_graph.source]["nodes"] - if item["id"] == current.node_id - ) - release_edges = [ - edge - for edge in current.edges - if edge.target == changed and edge.relation == "release-coupled" - ] - if release_edges: - raw_owner["validations"] = [ - item - for item in raw_owner["validations"] - if not ( - item.get("upstream") == changed - and item.get("relation") == "release-coupled" - and item.get("gate") == "release" - ) - ] - depends = any( - edge.target == changed and edge.relation in invalidating - for edge in current.edges - ) - group_depends = ( - current.node_type == "planning-group" - and changed in current.members - ) - if not (depends or group_depends) or identity in invalidated: - continue - if current.status not in { - "approved", - "implemented", - "verified", - "passing", - }: - continue - if "stale" not in { - "approved": {"stale"}, - "implemented": {"stale"}, - "verified": {"stale"}, - "passing": {"stale"}, - }[current.status]: - continue - raw_owner["status"] = "stale" - invalidated.add(identity) - pending.append(identity) - - before = {graph.source: graph.source.read_bytes() for graph in graphs} - after = { - path: (json.dumps(raw, indent=2, sort_keys=True) + "\n").encode("utf-8") - for path, raw in raw_by_path.items() - if (json.dumps(raw, indent=2, sort_keys=True) + "\n").encode("utf-8") - != before[path] - } - projected: list[Graph] = [] - problems: list[StructuralProblem] = [] - for graph in graphs: - data = after.get(graph.source, before[graph.source]) - parsed, parse_problems = _parse_projected(graph.source, data, root) - problems.extend(parse_problems) - if parsed is not None: - projected.append(parsed) - problems.extend(validate_portfolio_v2(projected)) - return projected, before, after, problems - - -def _rollback( - committed: list[Path], before: dict[Path, bytes] -) -> bool: - try: - for path in reversed(committed): - _atomic_write(path, before[path]) - return all(path.read_bytes() == before[path] for path in committed) - except OSError: - return False - - -def _evidence_transition_problems( - target: str, - node: Node, - requested_status: str, - nodes: dict[str, Node], - sources: dict[str, Path], -) -> list[StructuralProblem]: - terminal = { - "test": "passing", - "task": "verified", - "implementation": "verified", - "verification": "passing", - }.get(node.node_type) - if requested_status != terminal: - return [] - if node.node_type == "task": - owners = [target] - else: - relation = { - "test": "tested-by", - "implementation": "implemented-by", - "verification": "verified-by", - }[node.node_type] - owners = sorted( - identity - for identity, owner in nodes.items() - if any( - edge.relation == relation and edge.target == target - for edge in owner.edges - ) - ) - if not owners: - return [ - StructuralProblem( - "transition-evidence", - f"{target} has no owning task-complete closure", - sources[target], - target, - {"path": (target,)}, - ) - ] - if node.node_type != "task": - return [] - problems: list[StructuralProblem] = [] - for owner in owners: - incompatible = compatibility_problem( - "task-complete", nodes[owner], sources[owner] - ) - if incompatible is not None: - problems.append(incompatible) - continue - _, owner_problems = evaluate_target( - owner, "task-complete", nodes, sources - ) - problems.extend(owner_problems) - return problems - - -def transition(root: Path, request_path: Path) -> tuple[int, dict[str, Any]]: - request, error = _read_json(request_path) - if request is None: - return _failure(root, "invalid-request", error or "invalid request") - operation, target, error = _validate_request(root, request) - if operation is None or target is None: - return _failure(root, "invalid-request", error or "invalid request", target) - - state, lock, journal, before_path, after_path = _state_paths(root, operation) - state.mkdir(parents=True, exist_ok=True) - active_lock = state / "active.lock" - completed = state / f"{operation}.completed.json" - if completed.exists(): - return _failure( - root, - "operation-id-reused", - f"operationId {operation} has a completed recovery record", - target, - operation=operation, - ) - if journal.exists() or before_path.exists() or after_path.exists(): - return _failure( - root, - "recovery-required", - f"operation {operation} has retained recovery state", - target, - result="recovery-required", - exit_code=2, - operation=operation, - ) - nonce = uuid.uuid4().hex - owner = _process_identity(os.getpid()) - if owner is None: - return _failure( - root, - "owner-proof-unavailable", - "this platform cannot record process start identity", - target, - operation=operation, - ) - try: - descriptor = os.open( - lock, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600 - ) - except FileExistsError: - return _failure( - root, - "transition-locked", - f"transition lock exists: {lock.name}", - target, - operation=operation, - ) - try: - active_descriptor = os.open( - active_lock, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600 - ) - except FileExistsError: - os.close(descriptor) - lock.unlink() - return _failure( - root, - "transition-locked", - "another transition owns the active writer lock", - target, - operation=operation, - ) - - preserve_recovery = False - try: - with os.fdopen(descriptor, "w", encoding="utf-8") as handle: - json.dump( - { - "schema": 1, - "operationId": operation, - "nonce": nonce, - "owner": owner, - }, - handle, - ) - handle.flush() - os.fsync(handle.fileno()) - _sync_directory(state) - with os.fdopen(active_descriptor, "w", encoding="utf-8") as handle: - json.dump( - { - "schema": 1, - "operationId": operation, - "owner": owner, - "nonce": nonce, - }, - handle, - sort_keys=True, - ) - handle.flush() - os.fsync(handle.fileno()) - _sync_directory(state) - - persisted_state = _persisted_target_state(root, target) - graphs, structural = _load_v2( - root, target if request["sourceUpdates"] else None - ) - if structural: - return 1, { - "result": "refused", - "target": target, - "operationId": operation, - "blockers": [problem_json(problem, root) for problem in structural], - } - nodes = {node.canonical_id: node for graph in graphs for node in graph.nodes} - sources = { - node.canonical_id: graph.source for graph in graphs for node in graph.nodes - } - node = nodes.get(target) - if node is None: - return _failure(root, "target-not-found", f"target not found: {target}", target) - graph = next(graph for graph in graphs if graph.feature == node.feature) - expected = request["expected"] - cas_status, cas_revision = ( - persisted_state - if persisted_state is not None - else (node.status, node.revision) - ) - if cas_status != expected["status"] or cas_revision != expected["revision"]: - return _failure( - root, - "compare-and-swap", - "expected status or revision does not match", - target, - operation=operation, - ) - requested_status = request["requested"]["status"] - snapshot_refresh = ( - requested_status == node.status - and bool(request["validationAdditions"] or request["validationRemovals"]) - and not request["sourceUpdates"] - ) - if not _allowed(node, requested_status) and not snapshot_refresh: - return _failure( - root, - "invalid-transition", - f"{node.status} -> {requested_status} is not allowed for {node.node_type}", - target, - operation=operation, - ) - evidence_error = ( - None - if snapshot_refresh - else _evidence_problem(node, requested_status, request) - ) - if evidence_error is not None: - return _failure( - root, - "transition-evidence", - evidence_error, - target, - operation=operation, - ) - replacement = request["evidence"].get("replacement") - if ( - requested_status == "superseded" - and ( - replacement not in nodes - or replacement == target - or nodes[replacement].node_type != node.node_type - or nodes[replacement].status - not in {"approved", "verified", "passing"} - ) - ): - return _failure( - root, - "transition-evidence", - "replacement must name a distinct current canonical target", - target, - operation=operation, - ) - required_gate = ( - request["gate"] - if snapshot_refresh - else _required_gate(node, requested_status) - ) - if snapshot_refresh and any( - not isinstance(item, dict) or item.get("gate") != required_gate - for item in request["validationAdditions"] + request["validationRemovals"] - ): - return _failure( - root, - "invalid-transition-gate", - "snapshot refresh entries must all use the requested governing gate", - target, - operation=operation, - ) - if request["gate"] != required_gate: - return _failure( - root, - "invalid-transition-gate", - f"{node.node_type} {node.status} -> {requested_status} requires gate {required_gate}", - target, - operation=operation, - ) - try: - projected_graphs, before, after, projected_problems = _project_portfolio( - root, graphs, target, request - ) - except (KeyError, TypeError, ValueError, UnicodeError) as exc: - return _failure( - root, "invalid-request", str(exc), target, operation=operation - ) - if projected_problems: - return 1, { - "result": "refused", - "target": target, - "operationId": operation, - "blockers": [ - problem_json(problem, root) for problem in projected_problems - ], - } - projected_nodes = { - item.canonical_id: item - for projected_graph in projected_graphs - for item in projected_graph.nodes - } - projected_sources = { - item.canonical_id: projected_graph.source - for projected_graph in projected_graphs - for item in projected_graph.nodes - } - approval = node.status in {"draft", "stale"} and requested_status == "approved" - gate_nodes = projected_nodes if approval or snapshot_refresh else nodes - gate_sources = projected_sources if approval or snapshot_refresh else sources - gate_node = gate_nodes[target] - evidence_routed = ( - node.node_type in {"test", "implementation", "verification"} - or ( - node.node_type == "task" - and not ( - node.status == "pending" - and requested_status == "implemented" - ) - ) - ) - if evidence_routed: - gate_problems = _evidence_transition_problems( - target, - node, - requested_status, - projected_nodes, - projected_sources, - ) - else: - incompatible = compatibility_problem( - required_gate, gate_node, graph.source - ) - gate_problems = ( - [incompatible] - if incompatible is not None - else evaluate_target( - target, required_gate, gate_nodes, gate_sources - )[1] - ) - if gate_problems: - return 1, { - "result": "refused", - "target": target, - "operationId": operation, - "blockers": [ - problem_json(problem, root) for problem in gate_problems - ], - } - - ordered_paths = sorted(after, key=lambda path: str(path.relative_to(root))) - image_pairs: dict[Path, tuple[Path, Path]] = {} - for index, path in enumerate(ordered_paths): - before_image = state / f"{operation}.{index}.before" - after_image = state / f"{operation}.{index}.after" - _atomic_write(before_image, before[path]) - _atomic_write(after_image, after[path]) - image_pairs[path] = before_image, after_image - journal_value = { - "schema": 1, - "operationId": operation, - "lockNonce": nonce, - "direction": None, - "owner": owner, - "graphs": [{ - "path": str(path.relative_to(root)), - "beforeHash": _hash(before[path]), - "afterHash": _hash(after[path]), - "beforeImage": image_pairs[path][0].name, - "afterImage": image_pairs[path][1].name, - "committed": False, - } for path in ordered_paths], - } - _write_json(journal, journal_value) - committed_paths: list[Path] = [] - try: - for index, path in enumerate(ordered_paths): - _atomic_write(path, after[path]) - committed_paths.append(path) - journal_value["graphs"][index]["committed"] = True - _write_json(journal, journal_value) - post_graphs, post_problems = _load_v2(root) - if post_problems: - raise ValueError("post-write portfolio validation failed") - post_nodes = { - item.canonical_id: item - for post_graph in post_graphs - for item in post_graph.nodes - } - post_sources = { - item.canonical_id: post_graph.source - for post_graph in post_graphs - for item in post_graph.nodes - } - if approval or snapshot_refresh: - _, post_gate_problems = evaluate_target( - target, required_gate, post_nodes, post_sources - ) - if post_gate_problems: - raise ValueError("post-write governing gate validation failed") - elif evidence_routed: - post_gate_problems = _evidence_transition_problems( - target, - node, - requested_status, - post_nodes, - post_sources, - ) - if post_gate_problems: - raise ValueError("post-write evidence gate validation failed") - except Exception as exc: - if _rollback(committed_paths, before): - return _failure( - root, - "post-write-validation", - f"write was restored after failure: {exc}", - target, - operation=operation, - ) - preserve_recovery = True - return _failure( - root, - "post-write-validation", - f"rollback could not be proven: {exc}", - target, - result="recovery-required", - exit_code=2, - operation=operation, - ) - return 0, { - "result": "committed", - "operationId": operation, - "target": target, - "graphs": [ - { - "path": str(path.relative_to(root)), - "beforeHash": _hash(before[path]), - "afterHash": _hash(after[path]), - } - for path in ordered_paths - ], - "postWriteValidation": "passed", - "blockers": [], - } - finally: - if not preserve_recovery: - recovery_images = list(state.glob(f"{operation}.*.before")) + list( - state.glob(f"{operation}.*.after") - ) - for path in ( - journal, - before_path, - after_path, - lock, - active_lock, - *recovery_images, - ): - try: - path.unlink() - except FileNotFoundError: - pass - - -def _safe_graph_path(root: Path, value: Any) -> Path | None: - if not isinstance(value, str): - return None - candidate = (root / value).resolve() - if candidate == root or root not in candidate.parents: - return None - return candidate - - -def replace_graph_atomically( - root: Path, - path: Path, - after: bytes, - operation: str, - *, - expected_hash: str | None = None, - backup: Path | None = None, -) -> tuple[int, dict[str, Any]]: - """Replace one graph through the same durable journal protocol as transitions.""" - if SAFE_OPERATION.fullmatch(operation) is None: - return _failure(root, "invalid-operation", "operationId is not path-safe") - state, lock, journal, _, _ = _state_paths(root, operation) - state.mkdir(parents=True, exist_ok=True) - active = state / "active.lock" - if (state / f"{operation}.completed.json").exists(): - return _failure(root, "operation-id-reused", "completed operationId cannot be reused") - owner = _process_identity(os.getpid()) - if owner is None: - return _failure(root, "owner-proof-unavailable", "process identity is unavailable") - nonce = uuid.uuid4().hex - try: - lock_descriptor = os.open(lock, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) - except FileExistsError: - return _failure(root, "transition-locked", "operation lock exists") - try: - active_descriptor = os.open(active, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) - except FileExistsError: - os.close(lock_descriptor) - lock.unlink(missing_ok=True) - return _failure(root, "transition-locked", "another writer owns the active lock") - record = { - "schema": 1, - "operationId": operation, - "nonce": nonce, - "owner": owner, - } - pending_descriptors = [lock_descriptor, active_descriptor] - try: - while pending_descriptors: - descriptor = pending_descriptors.pop(0) - with os.fdopen(descriptor, "w", encoding="utf-8") as handle: - json.dump(record, handle, sort_keys=True) - handle.flush() - os.fsync(handle.fileno()) - _sync_directory(state) - before = path.read_bytes() - except (OSError, TypeError, ValueError) as exc: - for descriptor in pending_descriptors: - try: - os.close(descriptor) - except OSError: - pass - lock.unlink(missing_ok=True) - active.unlink(missing_ok=True) - return _failure( - root, - "transaction-preparation", - f"cannot initialize transaction ownership: {exc}", - operation=operation, - ) - if expected_hash is not None and _hash(before) != expected_hash: - lock.unlink(missing_ok=True) - active.unlink(missing_ok=True) - return _failure( - root, - "compare-and-swap", - "source changed after preview", - operation=operation, - ) - before_image = state / f"{operation}.0.before" - after_image = state / f"{operation}.0.after" - preserve = False - backup_created = False - try: - if backup is not None: - try: - descriptor = os.open( - backup, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600 - ) - except FileExistsError: - if backup.read_bytes() != before: - return _failure( - root, - "backup-collision", - "non-identical backup exists", - operation=operation, - ) - except OSError as exc: - return _failure( - root, - "backup-write", - f"cannot create backup: {exc}", - operation=operation, - ) - else: - backup_created = True - try: - with os.fdopen(descriptor, "wb") as handle: - handle.write(before) - handle.flush() - os.fsync(handle.fileno()) - _sync_directory(backup.parent) - verified_backup = backup.read_bytes() - except OSError as exc: - backup.unlink(missing_ok=True) - return _failure( - root, - "backup-write", - f"backup preparation failed: {exc}", - operation=operation, - ) - if verified_backup != before: - backup.unlink(missing_ok=True) - return _failure( - root, - "backup-write", - "backup verification failed", - operation=operation, - ) - try: - _atomic_write(before_image, before) - _atomic_write(after_image, after) - except OSError as exc: - if backup_created and backup is not None: - backup.unlink(missing_ok=True) - return _failure( - root, - "transaction-preparation", - f"cannot prepare recovery images: {exc}", - operation=operation, - ) - journal_value = { - "schema": 1, - "operationId": operation, - "lockNonce": nonce, - "direction": None, - "owner": owner, - "graphs": [{ - "path": str(path.relative_to(root)), - "beforeHash": _hash(before), - "afterHash": _hash(after), - "beforeImage": before_image.name, - "afterImage": after_image.name, - "committed": False, - }], - } - try: - _write_json(journal, journal_value) - except OSError as exc: - if backup_created and backup is not None: - backup.unlink(missing_ok=True) - return _failure( - root, - "transaction-preparation", - f"cannot publish transition journal: {exc}", - operation=operation, - ) - try: - _atomic_write(path, after) - journal_value["graphs"][0]["committed"] = True - _write_json(journal, journal_value) - graphs, problems = _load_v2(root) - if problems or not graphs: - raise ValueError("post-write portfolio validation failed") - except Exception as exc: - if _rollback([path], {path: before}): - return _failure( - root, - "post-write-validation", - f"write was restored after failure: {exc}", - ) - preserve = True - return _failure( - root, - "post-write-validation", - f"rollback could not be proven: {exc}", - result="recovery-required", - exit_code=2, - operation=operation, - ) - return 0, { - "result": "committed", - "operationId": operation, - "graphs": [{ - "path": str(path.relative_to(root)), - "beforeHash": _hash(before), - "afterHash": _hash(after), - }], - "postWriteValidation": "passed", - "blockers": [], - } - finally: - if not preserve: - for cleanup in (journal, before_image, after_image, lock, active): - cleanup.unlink(missing_ok=True) - - -def _exclusive_recovery_lock( - path: Path, - operation: str, - nonce: str, -) -> tuple[dict[str, Any] | None, str | None]: - owner = _process_identity(os.getpid()) - if owner is None: - return None, "recovery process identity is unavailable" - try: - descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) - except FileExistsError: - existing, error = _read_json(path) - if ( - existing is None - or existing.get("operationId") != operation - or existing.get("nonce") != nonce - ): - return None, error or "recovery lock belongs to another operation" - if _owner_is_absent(existing.get("owner")) is not True: - return None, "existing recovery owner absence is unproven" - stale = path.with_name(f"{path.name}.stale-{uuid.uuid4().hex}") - try: - os.replace(path, stale) - descriptor = os.open( - path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600 - ) - except OSError as exc: - return None, f"cannot take over stale recovery lock: {exc}" - with os.fdopen(descriptor, "w", encoding="utf-8") as handle: - json.dump( - { - "schema": 1, - "operationId": operation, - "nonce": nonce, - "owner": owner, - }, - handle, - sort_keys=True, - ) - handle.flush() - os.fsync(handle.fileno()) - _sync_directory(path.parent) - return owner, None - - -def _claim_active_lock( - path: Path, - operation: str, - nonce: str, - prior_owner: dict[str, Any], - recovery_owner: dict[str, Any], -) -> tuple[Path | None, str | None]: - stale_claim: Path | None = None - existing, _ = _read_json(path) - if existing is not None: - if ( - existing.get("operationId") != operation - or existing.get("nonce") != nonce - or existing.get("owner") != prior_owner - ): - return None, "active writer lock belongs to another operation" - stale_claim = path.with_name(f"{path.name}.stale-{uuid.uuid4().hex}") - try: - os.replace(path, stale_claim) - except OSError as exc: - return None, f"cannot quarantine stale active lock: {exc}" - try: - descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) - except FileExistsError: - return stale_claim, "a new writer acquired the active lock" - with os.fdopen(descriptor, "w", encoding="utf-8") as handle: - json.dump( - { - "schema": 1, - "mode": "recovery", - "operationId": operation, - "nonce": nonce, - "owner": recovery_owner, - }, - handle, - sort_keys=True, - ) - handle.flush() - os.fsync(handle.fileno()) - _sync_directory(path.parent) - return stale_claim, None - - -def _release_owned_lock( - path: Path, - operation: str, - nonce: str, - owner: dict[str, Any], -) -> None: - value, _ = _read_json(path) - if ( - value is not None - and value.get("operationId") == operation - and value.get("nonce") == nonce - and value.get("owner") == owner - ): - path.unlink(missing_ok=True) - - -def recover(root: Path, request_path: Path) -> tuple[int, dict[str, Any]]: - request, error = _read_json(request_path) - if request is None: - return _failure(root, "invalid-request", error or "invalid request") - operation = request.get("operationId") - direction = request.get("direction") - if ( - request.get("schema") != 1 - or not isinstance(operation, str) - or SAFE_OPERATION.fullmatch(operation) is None - or direction not in {"roll-forward", "rollback"} - or not isinstance(request.get("expectedLock"), str) - or not isinstance(request.get("expectedOwner"), dict) - or not isinstance(request.get("graphs"), list) - ): - return _failure(root, "invalid-request", "recovery request is invalid") - state, lock, journal, before_path, after_path = _state_paths(root, operation) - completed = state / f"{operation}.completed.json" - completed_value, _ = _read_json(completed) - if completed_value is not None: - if ( - completed_value.get("direction") == direction - and completed_value.get("graphs") == request["graphs"] - ): - return 0, { - "result": "already-recovered", - "operationId": operation, - "direction": direction, - "blockers": [], - } - return _failure( - root, - "recovery-direction-conflict", - "completed recovery does not match this request", - operation=operation, - ) - lock_value, lock_error = _read_json(lock) - if lock_value is None: - return _failure( - root, - "recovery-state-not-found", - lock_error or f"no lock for {operation}", - operation=operation, - ) - if lock_value.get("nonce") != request["expectedLock"]: - return _failure( - root, - "recovery-lock-mismatch", - "expected lock nonce does not match", - operation=operation, - ) - if lock_value.get("owner") != request["expectedOwner"]: - return _failure( - root, - "recovery-owner-mismatch", - "expected owner identity does not match the lock", - operation=operation, - ) - absent = _owner_is_absent(lock_value.get("owner")) - if absent is not True: - return _failure( - root, - "owner-absence-unproven", - "recorded process absence cannot be proven", - result="refused", - operation=operation, - ) - active_lock = state / "active.lock" - active_value, _ = _read_json(active_lock) - prior_active_owner = request["expectedOwner"] - if active_value is not None: - same_operation = ( - active_value.get("operationId") == operation - and active_value.get("nonce") == request["expectedLock"] - ) - recovery_resume = ( - same_operation - and active_value.get("mode") == "recovery" - and _owner_is_absent(active_value.get("owner")) is True - ) - original_owner = ( - same_operation - and active_value.get("owner") == request["expectedOwner"] - ) - if not (recovery_resume or original_owner): - return _failure( - root, - "active-lock-mismatch", - "active writer lock belongs to another operation or live recovery", - operation=operation, - ) - prior_active_owner = active_value["owner"] - recovery_lock = state / f"{operation}.recovery.lock" - recovery_owner, recovery_error = _exclusive_recovery_lock( - recovery_lock, operation, request["expectedLock"] - ) - if recovery_owner is None: - return _failure( - root, - "recovery-locked", - recovery_error or "another recovery owns this operation", - operation=operation, - ) - stale_active, active_error = _claim_active_lock( - active_lock, - operation, - request["expectedLock"], - prior_active_owner, - recovery_owner, - ) - if active_error is not None: - _release_owned_lock( - recovery_lock, - operation, - request["expectedLock"], - recovery_owner, - ) - return _failure( - root, - "active-lock-mismatch", - active_error, - operation=operation, - ) - - def release_claim() -> None: - _release_owned_lock( - active_lock, - operation, - request["expectedLock"], - recovery_owner, - ) - _release_owned_lock( - recovery_lock, - operation, - request["expectedLock"], - recovery_owner, - ) - if stale_active is not None: - stale_active.unlink(missing_ok=True) - - journal_value, journal_error = _read_json(journal) - if journal_value is None: - if not request["graphs"] and not journal.exists(): - completed_payload = { - "schema": 1, - "operationId": operation, - "direction": direction, - "graphs": [], - } - _write_json(completed, completed_payload) - lock.unlink(missing_ok=True) - release_claim() - return 0, { - "result": "recovered", - "operationId": operation, - "direction": direction, - "blockers": [], - } - release_claim() - return _failure( - root, - "recovery-state-not-found", - journal_error or f"no journal for {operation}", - operation=operation, - ) - if ( - journal_value.get("operationId") != operation - or journal_value.get("lockNonce") != request["expectedLock"] - or journal_value.get("owner") != request["expectedOwner"] - or journal_value.get("schema") != 1 - ): - release_claim() - return _failure( - root, - "recovery-journal-mismatch", - "journal identity does not match the recovery lock", - operation=operation, - ) - prior_direction = journal_value.get("direction") - if prior_direction not in {None, direction}: - release_claim() - return _failure( - root, - "recovery-direction-conflict", - "recovery direction cannot change after recovery starts", - operation=operation, - ) - journal_graphs = journal_value.get("graphs") - if not isinstance(journal_graphs, list) or request["graphs"] != [ - { - "path": item.get("path"), - "beforeHash": item.get("beforeHash"), - "afterHash": item.get("afterHash"), - } - for item in journal_graphs - if isinstance(item, dict) - ]: - release_claim() - return _failure( - root, - "recovery-hash-mismatch", - "recovery graph hashes do not match the journal", - operation=operation, - ) - paths = [item.get("path") for item in journal_graphs if isinstance(item, dict)] - images = [ - item.get(key) - for item in journal_graphs - if isinstance(item, dict) - for key in ("beforeImage", "afterImage") - ] - valid_hash = re.compile(r"^sha256:[0-9a-f]{64}$") - if ( - len(paths) != len(journal_graphs) - or len(set(paths)) != len(paths) - or len(set(images)) != len(images) - or any( - not isinstance(item, dict) - or not isinstance(item.get("committed"), bool) - or valid_hash.fullmatch(str(item.get("beforeHash"))) is None - or valid_hash.fullmatch(str(item.get("afterHash"))) is None - or any( - not isinstance(item.get(key), str) - or Path(item[key]).name != item[key] - for key in ("beforeImage", "afterImage") - ) - for item in journal_graphs - ) - ): - release_claim() - return _failure( - root, - "invalid-recovery-journal", - "journal graph records are not closed, unique, and hash-valid", - operation=operation, - ) - - journal_value["direction"] = direction - try: - _write_json(journal, journal_value) - except OSError as exc: - release_claim() - return _failure( - root, - "recovery-journal-write", - f"cannot persist recovery direction: {exc}", - operation=operation, - ) - ordered = list(reversed(journal_graphs)) if direction == "rollback" else journal_graphs - desired_key = "beforeHash" if direction == "rollback" else "afterHash" - allowed_key = "afterHash" if direction == "rollback" else "beforeHash" - image_key = "beforeImage" if direction == "rollback" else "afterImage" - changed = False - for item in ordered: - graph_path = _safe_graph_path(root, item.get("path")) - if graph_path is None or not graph_path.is_file(): - if not changed: - release_claim() - return _failure( - root, - "unsafe-recovery-path", - "journal graph path is unsafe or missing", - result="recovery-required" if changed else "refused", - exit_code=2 if changed else 1, - operation=operation, - ) - image_name = item.get(image_key) - if not isinstance(image_name, str) or Path(image_name).name != image_name: - if not changed: - release_claim() - return _failure( - root, - "unsafe-recovery-path", - "journal recovery image path is unsafe", - result="recovery-required" if changed else "refused", - exit_code=2 if changed else 1, - operation=operation, - ) - image_path = state / image_name - if not image_path.is_file(): - if not changed: - release_claim() - return _failure( - root, - "recovery-image-missing", - "journal recovery image is missing", - result="recovery-required" if changed else "refused", - exit_code=2 if changed else 1, - operation=operation, - ) - desired = image_path.read_bytes() - current_hash = _hash(graph_path.read_bytes()) - if current_hash == item.get(desired_key): - continue - if current_hash != item.get(allowed_key) or _hash(desired) != item.get(desired_key): - if not changed: - release_claim() - return _failure( - root, - "recovery-hash-mismatch", - "current or recovery image hash is unexpected", - result="recovery-required" if changed else "refused", - exit_code=2 if changed else 1, - operation=operation, - ) - try: - _atomic_write(graph_path, desired) - except OSError as exc: - return _failure( - root, - "recovery-write-failed", - f"recovery write failed with retained state: {exc}", - result="recovery-required", - exit_code=2, - operation=operation, - ) - changed = True - - graphs, problems = _load_v2(root) - if problems or not graphs: - return _failure( - root, - "post-recovery-validation", - "recovered portfolio did not validate", - result="recovery-required", - exit_code=2, - operation=operation, - ) - try: - _write_json( - completed, - { - "schema": 1, - "operationId": operation, - "direction": direction, - "graphs": request["graphs"], - }, - ) - except OSError as exc: - return _failure( - root, - "recovery-completion-write", - f"recovery completed but its durable record failed: {exc}", - result="recovery-required", - exit_code=2, - operation=operation, - ) - image_paths = { - state / item[key] - for item in journal_graphs - for key in ("beforeImage", "afterImage") - if isinstance(item, dict) - and isinstance(item.get(key), str) - and Path(item[key]).name == item[key] - } - release_claim() - for path in ( - journal, - lock, - *image_paths, - ): - try: - path.unlink() - except FileNotFoundError: - pass - return 0, { - "result": "recovered" if changed else "already-recovered", - "operationId": operation, - "direction": direction, - "blockers": [], - } diff --git a/scripts/codeops_state_lib/v2.py b/scripts/codeops_state_lib/v2.py deleted file mode 100644 index 4ea022a..0000000 --- a/scripts/codeops_state_lib/v2.py +++ /dev/null @@ -1,514 +0,0 @@ -"""Version-aware command orchestration for CodeOps state.""" - -from __future__ import annotations - -import argparse -import json -import re -import sys -from dataclasses import dataclass -from pathlib import Path -from typing import Any - -from . import legacy -from .closure import build_scope_closure -from .discovery import discover_graphs, discover_state -from .gates import ( - TARGET_TYPES, - audit_gate, - compatibility_problem, - evaluate_target, - lifecycle, - valid_transitions, -) -from .models import Graph, Node, StructuralProblem -from .migration import apply_upgrade, make_preview -from .rendering import problem_json, problem_text -from .schema import parse_graph_v2, validate_portfolio_v2 -from .transitions import recover, transition - - -GLOBAL_CODES = { - "invalid-config", - "unsafe-config-root", - "duplicate-identity", - "ambiguous-target", - "target-feature-mismatch", -} - - -@dataclass -class Loaded: - graphs: list[Graph] - problems: list[StructuralProblem] - schema1_features: dict[str, set[str]] - schema1_objects: dict[str, legacy.Graph] - schema1_graphs: int - schema1_nodes: int - - -def discover_v2_graphs(root: Path) -> list[Path]: - return discover_graphs(root) - - -def has_schema_two(root: Path) -> bool: - for path in discover_v2_graphs(root): - try: - value = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - continue - if isinstance(value, dict) and value.get("schema") == 2: - return True - return False - - -def _load(root: Path) -> Loaded: - discovery = discover_state(root) - graphs: list[Graph] = [] - problems = list(discovery.problems) - schema1_features: dict[str, set[str]] = {} - schema1_graphs: list[legacy.Graph] = [] - source_features: dict[Path, str] = {} - legacy_problems: list[legacy.Problem] = [] - if (root / "codeops" / "codeops.json").is_file(): - config_problems: list[legacy.Problem] = [] - legacy.validate_config(root, config_problems) - problems.extend( - StructuralProblem("invalid-config", problem.message, problem.source) - for problem in config_problems - ) - for path in discovery.paths: - try: - raw = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: - problems.append(StructuralProblem("invalid-json", f"cannot parse JSON: {exc}", path)) - continue - if not isinstance(raw, dict): - problems.append(StructuralProblem("invalid-root", "root must be an object", path)) - continue - feature = raw.get("feature") if isinstance(raw.get("feature"), str) else path.parent.name - source_features[path] = feature - if raw.get("schema") == 1: - graph = legacy.load_graph(path, root, legacy_problems) - if graph is not None: - schema1_graphs.append(graph) - schema1_features[graph.feature] = set(graph.nodes) - continue - if raw.get("schema") != 2: - problems.append( - StructuralProblem( - "schema-version", - f"unsupported graph schema {raw.get('schema')!r}", - path, - details={"feature": feature}, - ) - ) - continue - graph, graph_problems = parse_graph_v2(path, root) - problems.extend( - StructuralProblem( - problem.code, - problem.message, - problem.source, - problem.identity, - {**dict(problem.details), "feature": feature}, - ) - for problem in graph_problems - ) - if graph is not None: - graphs.append(graph) - if schema1_graphs: - legacy.validate_relationships(schema1_graphs, root, legacy_problems) - for problem in legacy_problems: - feature = source_features.get(problem.source) - problems.append( - StructuralProblem( - "schema1-invalid", - problem.message, - problem.source, - details={"feature": feature, "schema": 1}, - ) - ) - for problem in validate_portfolio_v2(graphs): - problems.append( - StructuralProblem( - problem.code, - problem.message, - problem.source, - problem.identity, - { - **dict(problem.details), - "feature": source_features.get(problem.source), - }, - ) - ) - for graph in graphs: - legacy_ids = schema1_features.get(graph.feature, set()) - for node in graph.nodes: - if node.node_id in legacy_ids: - problems.append( - StructuralProblem( - "duplicate-identity", - f"canonical identity {node.canonical_id} exists in both schema 1 and schema 2", - graph.source, - node.canonical_id, - {"feature": graph.feature}, - ) - ) - if not graphs and not schema1_graphs and not problems: - problems.append(StructuralProblem("no-graphs", "no live traceability graphs found", root)) - return Loaded( - graphs, - problems, - schema1_features, - {graph.feature: graph for graph in schema1_graphs}, - len(schema1_graphs), - sum(len(graph.nodes) for graph in schema1_graphs), - ) - - -def _canonical_target( - target: str | None, - feature: str | None, - root: Path, -) -> tuple[str | None, list[StructuralProblem]]: - if target is None: - return None, [] - if "/" not in target: - if feature is None: - return None, [ - StructuralProblem( - "ambiguous-target", - f"bare target {target!r} is not canonical; use --target FEATURE/{target} or add --feature FEATURE", - root, - ) - ] - return f"{feature}/{target}", [] - if feature is not None and target.split("/", 1)[0] != feature: - return None, [ - StructuralProblem( - "target-feature-mismatch", - f"--feature {feature!r} conflicts with canonical target {target!r}", - root, - ) - ] - return target, [] - - -def _scope_problems( - problems: list[StructuralProblem], - target: str, - paths: dict[str, tuple[str, ...]], - root: Path, -) -> tuple[list[StructuralProblem], list[dict[str, Any]]]: - entered_features = {identity.split("/", 1)[0] for identity in paths} - blocking: list[StructuralProblem] = [] - diagnostics: list[dict[str, Any]] = [] - for problem in problems: - feature = problem.details.get("feature") - is_global = problem.code in GLOBAL_CODES or feature is None - if not is_global and feature not in entered_features: - diagnostics.append(problem_json(problem, root)) - continue - details = dict(problem.details) - if "path" not in details: - if problem.identity in paths: - details["path"] = paths[problem.identity] - elif isinstance(feature, str): - candidates = [ - path for identity, path in paths.items() - if identity.split("/", 1)[0] == feature - ] - if candidates: - details["path"] = min(candidates, key=lambda item: (len(item), item)) - if problem.code == "dangling-edge": - match = re.search(r"missing node (\S+)$", problem.message) - if match is not None and match.group(1) in paths: - details["path"] = paths[match.group(1)] - blocking.append( - StructuralProblem( - problem.code, - problem.message, - problem.source, - problem.identity, - details, - ) - ) - return blocking, diagnostics - - -def _stale_snapshots(target: Node, nodes: dict[str, Node]) -> list[dict[str, str]]: - stale: list[dict[str, str]] = [] - for snapshot in target.validations: - upstream = nodes.get(snapshot.upstream) - actual = upstream.revision if upstream is not None else "missing" - if actual != snapshot.revision: - stale.append( - { - "upstream": snapshot.upstream, - "relation": snapshot.relation, - "gate": snapshot.gate, - "expected": snapshot.revision, - "actual": actual, - } - ) - return stale - - -def _base_result(loaded: Loaded, problems: list[StructuralProblem], root: Path) -> dict[str, Any]: - versions = ({2} if loaded.graphs else set()) | ({1} if loaded.schema1_graphs else set()) - return { - "ready": not problems, - "schema_versions": sorted(versions), - "graphs": len(loaded.graphs) + loaded.schema1_graphs, - "nodes": sum(len(graph.nodes) for graph in loaded.graphs) + loaded.schema1_nodes, - "problems": [problem_text(problem, root) for problem in problems], - } - - -def run(argv: list[str]) -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - "command", - choices=( - "validate", - "readiness", - "status", - "transition", - "transition-recover", - "traceability-upgrade", - ), - ) - parser.add_argument("--root", default=".") - parser.add_argument("--feature") - parser.add_argument("--target") - parser.add_argument("--gate") - parser.add_argument("--request") - parser.add_argument("--preview") - parser.add_argument("--apply", action="store_true") - parser.add_argument("--resolutions") - parser.add_argument("--json", action="store_true", dest="as_json") - args = parser.parse_args(argv) - root = Path(args.root).resolve() - if args.command == "traceability-upgrade": - if args.feature is None or args.preview is None: - parser.error("traceability-upgrade requires --feature and --preview") - preview_path = Path(args.preview) - if not preview_path.is_absolute(): - preview_path = (Path.cwd() / preview_path).resolve() - if args.apply: - if args.resolutions is None: - parser.error("traceability-upgrade --apply requires --resolutions") - resolutions_path = Path(args.resolutions) - if not resolutions_path.is_absolute(): - resolutions_path = (Path.cwd() / resolutions_path).resolve() - code, result = apply_upgrade( - root, args.feature, preview_path, resolutions_path - ) - else: - code, result = make_preview(root, args.feature, preview_path) - if args.as_json: - print(json.dumps(result, indent=2, sort_keys=True)) - else: - print(f"{result['result']}: {result.get('feature', args.feature)}") - for blocker in result.get("blockers", []): - if isinstance(blocker, dict): - print(f"{blocker['code']}: {blocker['message']}") - else: - print(blocker) - return code - if args.command in {"transition", "transition-recover"}: - if args.request is None: - parser.error(f"{args.command} requires --request") - request_path = Path(args.request) - if not request_path.is_absolute(): - request_path = (Path.cwd() / request_path).resolve() - code, result = ( - transition(root, request_path) - if args.command == "transition" - else recover(root, request_path) - ) - if args.as_json: - print(json.dumps(result, indent=2, sort_keys=True)) - else: - print(f"{result['result']}: {result.get('target') or result.get('operationId', '')}") - for blocker in result.get("blockers", []): - print(f"{blocker['code']}: {blocker['message']}") - return code - loaded = _load(root) - nodes = {node.canonical_id: node for graph in loaded.graphs for node in graph.nodes} - sources = {node.canonical_id: graph.source for graph in loaded.graphs for node in graph.nodes} - target, target_problems = _canonical_target(args.target, args.feature, root) - problems = list(loaded.problems) + target_problems - diagnostics: list[dict[str, Any]] = [] - closure_members: list[str] = [] - groups: dict[str, list[str]] = {} - blockers: list[dict[str, Any]] = [] - status_gate_results: dict[str, dict[str, Any]] = {} - - if target is None and args.feature in loaded.schema1_features and args.gate is None: - graph = loaded.schema1_objects[args.feature] - legacy_gate_problems = legacy.readiness(graph.nodes, graph.source) - converted = [ - StructuralProblem("schema1-readiness", problem.message, problem.source) - for problem in legacy_gate_problems - ] - result = _base_result(loaded, converted, root) - result.update( - { - "selected_feature": args.feature, - "features": [{ - "feature": graph.feature, - "lifecycle": legacy.feature_lifecycle(graph, not converted), - "ready": not converted, - "nodes": len(graph.nodes), - }], - "blockers": [problem_json(problem, root) for problem in converted], - "diagnostics": [], - } - ) - if args.as_json: - print(json.dumps(result, indent=2, sort_keys=True)) - else: - print("READY" if result["ready"] else "NOT READY") - return 0 if args.command == "status" or result["ready"] else 1 - if target is not None: - target_feature, target_id = target.split("/", 1) - if target_id in loaded.schema1_features.get(target_feature, set()): - problems = [ - StructuralProblem( - "upgrade-required", - f"typed target readiness requires schema 2; run traceability-upgrade preview for feature {target_feature}", - root, - target, - {"feature": target_feature, "schema": 1}, - ) - ] - else: - paths: dict[str, tuple[str, ...]] = {target: (target,)} - if target in nodes: - target_node = nodes[target] - scope_gates: list[str] = [] - if args.command == "readiness" and args.gate is not None: - if compatibility_problem(args.gate, target_node, sources[target]) is None: - scope_gates.append( - audit_gate(target_node) if args.gate == "audit" else args.gate - ) - elif args.command == "status": - for gate, accepted in TARGET_TYPES.items(): - if gate == "audit" or target_node.node_type in accepted: - scope_gates.append( - audit_gate(target_node) if gate == "audit" else gate - ) - for scope_gate in sorted(set(scope_gates)): - scoped = build_scope_closure(target, scope_gate, nodes) - for identity, path in scoped.paths.items(): - prior = paths.get(identity) - if prior is None or (len(path), path) < (len(prior), prior): - paths[identity] = path - problems, diagnostics = _scope_problems(problems, target, paths, root) - for identity, path in paths.items(): - feature_name, node_id = identity.split("/", 1) - if node_id in loaded.schema1_features.get(feature_name, set()): - problems.append( - StructuralProblem( - "upgrade-required", - f"dependency {identity} uses schema 1 and must be upgraded before typed readiness", - root, - identity, - {"feature": feature_name, "schema": 1, "path": path}, - ) - ) - if target not in nodes and not any( - problem.details.get("feature") == target_feature for problem in problems - ): - problems.append( - StructuralProblem("target-not-found", f"target not found: {target}", root) - ) - if args.command == "readiness": - if target is not None and args.gate is None: - problems.append(StructuralProblem("gate-required", "--gate is required with a schema-2 target", root)) - if args.gate is not None and target is None and not target_problems: - problems.append(StructuralProblem("target-required", "--target is required with a schema-2 gate", root)) - - gate_problems: list[StructuralProblem] = [] - if target is not None and target in nodes and not problems: - target_node = nodes[target] - if args.command == "readiness" and args.gate is not None: - incompatible = compatibility_problem(args.gate, target_node, sources[target]) - if incompatible is not None: - gate_problems.append(incompatible) - else: - effective = audit_gate(target_node) if args.gate == "audit" else args.gate - closure, gate_problems = evaluate_target(target, effective, nodes, sources) - closure_members = list(closure.members) - groups = {key: list(value) for key, value in closure.group_expansions.items()} - elif args.command == "status": - for gate, accepted in TARGET_TYPES.items(): - if gate != "audit" and target_node.node_type not in accepted: - continue - effective = audit_gate(target_node) if gate == "audit" else gate - closure, verdict_problems = evaluate_target(target, effective, nodes, sources) - status_gate_results[gate] = { - "ready": not verdict_problems, - "blockers": [problem_json(problem, root) for problem in verdict_problems], - } - closure_members = sorted(set(closure_members) | set(closure.members)) - groups.update({key: list(value) for key, value in closure.group_expansions.items()}) - gate_problems = [ - problem - for result in status_gate_results.values() - for problem in () - ] - problems.extend(gate_problems) - blockers = [problem_json(problem, root) for problem in problems] - result = _base_result(loaded, problems, root) - result.update( - { - "gate": args.gate, - "target": target, - "closure": closure_members, - "group_expansions": groups, - "blockers": blockers, - "diagnostics": diagnostics, - } - ) - if target is not None and target in nodes and args.command == "status": - target_node = nodes[target] - all_gate_blockers = [ - blocker - for summary in status_gate_results.values() - for blocker in summary["blockers"] - ] - result.update( - { - "ready": bool(status_gate_results) and all( - summary["ready"] for summary in status_gate_results.values() - ), - "status": target_node.status, - "lifecycle": lifecycle(target_node), - "revision": target_node.revision, - "stale_snapshots": _stale_snapshots(target_node, nodes), - "valid_transitions": valid_transitions(target_node), - "gates": status_gate_results, - "blockers": all_gate_blockers, - } - ) - if args.as_json: - print(json.dumps(result, indent=2, sort_keys=True)) - else: - print(f"CodeOps graphs: {result['graphs']} | nodes: {result['nodes']}") - if target is not None: - print(f"Target: {target}") - if args.gate is not None: - print(f"Gate: {args.gate}") - for problem in result["problems"]: - print(problem) - print("READY" if result["ready"] else "NOT READY") - if args.command == "status": - return 1 if problems else 0 - return 0 if result["ready"] else 1 - - -if __name__ == "__main__": - raise SystemExit(run(sys.argv[1:])) diff --git a/scripts/validate-codex.sh b/scripts/validate-codex.sh index 6794518..5f55e3e 100755 --- a/scripts/validate-codex.sh +++ b/scripts/validate-codex.sh @@ -82,7 +82,7 @@ assert '00-scope-expansion-register-.md' in layout assert 'scope-expansion-register-.md' in layout assert '| Ad-hoc directory | `scope-expansion-register.md` inside the governed directory |' in layout assert '| Event ID | SE ID | Timestamp | From state | Decision | Authority and evidence |' in policy -assert '| SE ID | Derived artifact or graph target | Relation or kind | Current state |' in policy +assert '| SE ID | Derived artifact | Relation or kind | Current state |' in policy assert 'choose `Keep`' in Path('_shared/auto-design.md').read_text(encoding='utf-8') assert 'scope mode (`strict` or `explore`)' in Path('_shared/quality-profile.md').read_text(encoding='utf-8') PY @@ -191,10 +191,10 @@ assert 'exact expanded modification set' in make_plan assert 'explicitly approved `⏸ Deferred`' in make_plan assert 'after two post-gate ambiguity batches' in make_plan assert 'except the incrementally persisted Ambiguity' in make_plan -assert '--gate plan --target ' in make_plan.replace(' \\\n ', ' ') -assert '--gate execution --target ' in exec_plan.replace(' \\\n ', ' ') -assert '--request ' in exec_plan.replace(' \\\n ', ' ') -assert '`task-complete`' in exec_plan +assert '`99-execution-plan.md` is the sole' in make_plan +assert '`99-execution-plan.md` is the only mutable' in exec_plan +assert '`[!]` is blocked' in exec_plan +assert 'mark `[x]` only on' in exec_plan assert 'codeops_worktree_snapshot.py\" snapshot' in protocol assert 'codeops_worktree_snapshot.py\" diff' in protocol assert 'three consecutive failures with the same failure signature' in protocol @@ -302,7 +302,7 @@ run_check "preflight scope and convergence contract" validate_preflight_contract run_check "plan and execution scope contracts" validate_plan_execution_contracts run_check "auto-design authority contract" validate_auto_design_contract run_check "scope-expansion authority contract" validate_scope_expansion_contract -run_check "state conformance" python3 -m unittest discover -s tests/conformance -p 'test_*.py' +run_check "workflow conformance" python3 -m unittest discover -s tests/conformance -p 'test_*.py' run_check "retained adversarial parity evidence" validate_scenarios run_check "release evidence provenance" validate_release_evidence diff --git a/skills/exec-plan/SKILL.md b/skills/exec-plan/SKILL.md index 0afc022..198804e 100644 --- a/skills/exec-plan/SKILL.md +++ b/skills/exec-plan/SKILL.md @@ -41,37 +41,26 @@ argument is the feature name; an optional flag selects the commit mode. ## Execution-entry gate -When CodeOps traceability exists, run the readiness check before modifying implementation files: - -```bash -python3 "${PLUGIN_ROOT}/scripts/codeops_state.py" readiness --root . \ - --gate execution --target -``` - -Use the exact plan target from the selected graph. At task completion create a compare-and-swap -request for `` with its expected status/revision, requested status `verified`, gate -`task-complete`, lifecycle evidence, and validation additions, then run: - -```bash -python3 "${PLUGIN_ROOT}/scripts/codeops_state.py" transition --root . \ - --request -``` - -The transition evaluates `task-complete` against the projected verified state atomically; do not -run the gate against the still-implemented task first. `[ ]`, `[~]`, and `[x]` correspond to -`pending`, `implemented`, and `verified`. Never advance sibling -tasks. Use the exact `feature` value from the selected feature's `traceability.json`; never guess from a -directory name. Do not execute while the selected feature reports a blocker. During execution, -keep task, implementation, and verification nodes synchronized with the Markdown plan's `[ ]` → -`[~]` → `[x]` transitions. - -A runtime ambiguity invalidates the selected feature's readiness: record it and mark affected -links stale. In normal mode, present options and obtain the user's explicit decision. With active +Before modifying implementation files, directly confirm the plan has its required documents, the +ambiguity register has no open material item, specification tests precede implementation, and no +critical/major preflight finding remains unresolved. `99-execution-plan.md` is the only mutable +task-progress authority: + +- `[ ]` is not started; +- `[~]` is implemented with verification pending; +- `[x]` is verified; and +- `[!]` is blocked and includes `Blocked: ` on the task line. + +Never advance sibling tasks. Implement, immediately mark `[~]`, verify, then mark `[x]` only on +success. The primary agent updates the checklist after every delegated result. + +A runtime ambiguity blocks affected plan tasks: record it in the ambiguity register and mark each +affected task `[!]` with a short visible reason. In normal mode, present options and obtain the user's explicit decision. With active auto-design, resolve and record an eligible technical ambiguity under the shared policy; reserved authority still pauses for the user. If resolution requires changing an upstream artifact outside the selected plan's documents, present the exact expanded modification set and obtain the user's -approval before editing because auto-design does not expand scope. Resolve the ambiguity, rerun -feature-scoped readiness, and only then resume. +approval before editing because auto-design does not expand scope. Resolve the ambiguity, update +the affected plan artifacts, and only then resume. Before treating a runtime discovery or reviewer remediation as executable, classify it under the shared scope-expansion protocol. A necessary correction retains the ordinary ambiguity/finding @@ -139,9 +128,9 @@ summary template. The essentials: 4. If the plan is missing/empty/already complete, **STOP** — see the load table in [execution-protocol.md](execution-protocol.md). Generally suggest the make-plan skill. -**Schema check:** schema 1 plans require valid traceability and readiness. A legacy -`CodeOps Skills Version` stamp or missing schema triggers a read-only upgrade assessment; ask -before migration or execution with recorded compatibility risk. Never silently upgrade. +**Schema check:** a legacy `CodeOps Skills Version` stamp or missing schema triggers a read-only +upgrade assessment; ask before migration or execution with recorded compatibility risk. Never +silently upgrade. ### Step 2 — Execute tasks (per-task loop) @@ -174,7 +163,7 @@ For each task, in order: > `00-ambiguity-register.md`, STOP and record it. In normal mode, present options and wait for an > explicit user decision. With active auto-design, resolve an eligible technical choice under the > shared policy or escalate a reserved choice. Record the authorized resolution in -> `00-ambiguity-register.md` (tag `(runtime)`), rerun affected readiness gates, then resume. +> `00-ambiguity-register.md` (tag `(runtime)`), update affected plan documents, then resume. > Never guess. > **Grounded Options & Recommendations (coding standards → Working style) apply here.** Before presenting options/findings/recommendations: filter out non-viable ones (no strawmen; ≥2 only when ≥2 are genuinely viable, else present the single viable path and name what was rejected), second-guess each, verify any code-modifying option against the actual current code (cite `file:line`), and lead with a recommendation backed by grounded reasoning. Match ceremony to stakes. In normal mode, the user decides; active auto-design resolves eligible technical decisions and escalates reserved ones. Apply the recommendation-hardening protocol (`_shared/recommendation-hardening.md`) to consequential recommendations; escalate to an independent challenger only when the decision is genuinely high-stakes. @@ -202,7 +191,7 @@ links to them, never restates them. The flow: the protocol records a phase-start ref when the phase begins; after the phase's last task verifies, the correctness reviewer and any active auditors are dispatched **in parallel** on the phase diff, their findings are merged and presented in severity-grouped batches, and each -ruling is recorded in durable finding and traceability artifacts. +ruling is recorded in the durable finding artifact. > **🚨 Finding gate (load-bearing).** In normal mode, 🔴 CRITICAL and 🟠 MAJOR findings PAUSE > execution for the user's ruling in ALL commit modes. With active auto-design, select and record diff --git a/skills/exec-plan/execution-protocol.md b/skills/exec-plan/execution-protocol.md index a882eb4..7d80491 100644 --- a/skills/exec-plan/execution-protocol.md +++ b/skills/exec-plan/execution-protocol.md @@ -32,17 +32,15 @@ If the execution plan can't be loaded cleanly, **STOP** and handle as follows: ### Artifact schema check -Read `00-index.md` or `99-execution-plan.md`. Schema 1 plans must also have valid traceability and -pass the execution-entry readiness gate. A legacy `CodeOps Skills Version` stamp or no schema -stamp triggers a read-only upgrade assessment. Do not execute a legacy plan merely because its -task checkbox shape can be parsed; the user must approve migration or explicitly accept the -recorded compatibility risk. - -For schema-2 traceability, resolve the exact plan target and run `readiness --gate execution ---target ` before implementation. Before promoting a task to `[x]`, submit a public -compare-and-swap `transition --request ` from `implemented` to `verified` -with gate `task-complete`; the transition evaluates the projected state atomically. A task -transition never advances siblings; dependency closure is context, not a modification set. +Read `00-index.md` and `99-execution-plan.md`. A current plan declares one or more RDs on its +`> **Implements**:` line and uses `[ ]`, `[~]`, `[x]`, and `[!]` as its complete task-state +vocabulary. A legacy `CodeOps Skills Version` stamp or no schema stamp triggers a read-only +upgrade assessment. Do not execute a legacy plan merely because its task checkbox shape can be +parsed; the user must approve migration or explicitly accept the recorded compatibility risk. + +Before implementation, directly check required documents, closed material ambiguities, +specification-first ordering, and unresolved critical/major findings. Before promoting a task to +`[x]`, run its verification. A task update never advances siblings. Suggestion only — the user may proceed without upgrading. @@ -170,12 +168,12 @@ whole-task diff. Activation rules, packets, supersession, and caps are defined i Before merging, apply `_shared/scope-expansion-control.md`: a necessary correction or blocking uncertainty remains a finding, while an optional remediation is omitted in strict scope or moved to a separate `SE-*` proposal in exploration mode. A finding ruling never chooses `Keep`. -4. **Record decisions durably** in the finding and traceability artifacts after each ruling batch. +4. **Record decisions durably** in the finding artifact after each ruling batch. 5. **Accepted fixes:** implement → verify → follow-up commit per the commit mode. If any 🔴/🟠 fix was applied, dispatch ONE re-review scoped to the fix diff — never a third pass. A fix the re-review still rejects is reported. Normal mode returns the ruling to the user; active auto-design may select one eligible technical correction, but cannot waive the finding. -6. **Attach review evidence** to the task/verification nodes, optionally record a content-free +6. **Record review evidence** in the plan or its review report, optionally record a content-free review outcome, then proceed to the next phase. A dispatch that fails or dies mid-loop is reported — the phase completes UNreviewed only on the @@ -216,7 +214,7 @@ unchanged — and the temp log is read-only evidence, never executed. If you encounter any implementation detail, behavioral question, edge case, or design choice not covered by the plan documents or `00-ambiguity-register.md`, first record it and mark affected -downstream traceability stale. +tasks `[!]` with `Blocked: ` until the owning artifact is resolved. First determine whether the discovery is an ambiguity inside authorized scope, a necessary correction, or an optional expansion. Optional expansions do not enter the ambiguity register: @@ -231,8 +229,8 @@ they are silent in strict scope or use the Scope Expansion Register during explo 4. **Record** it in `00-ambiguity-register.md` with the next sequential AR number, tagged `(runtime)` in the Category column. Update the register header to note items added during execution. -5. **Only then** rerun affected readiness gates and resume implementation using the authorized - decision. +5. **Only then** update affected plan artifacts, confirm direct entry checks still pass, and + resume implementation using the authorized decision. This applies to ALL ambiguities — architectural, behavioral, naming, formatting, UX, error handling. Never fill gaps by guessing. diff --git a/skills/make-plan/SKILL.md b/skills/make-plan/SKILL.md index cac4b20..589a85f 100644 --- a/skills/make-plan/SKILL.md +++ b/skills/make-plan/SKILL.md @@ -24,21 +24,14 @@ If `$ARGUMENTS` contains exactly one exact standalone `--explore-scope` token be do not report or plan optional additions. Exploration may propose `SE-*` items but never accepts them; only the user may choose `Keep`. -## Codex readiness proof +## Plan readiness proof -Maintain the feature's typed requirement → specification/invariant → acceptance criterion → specification test → task chain in `traceability.json`; follow [../../references/artifacts/traceability.md](../../references/artifacts/traceability.md). A plan is not ready merely because its documents exist. Before presenting it as executable, run: - -```bash -python3 "${PLUGIN_ROOT}/scripts/codeops_state.py" readiness --root . \ - --gate plan --target -``` - -Use the exact RD, task, or planning-group `` from `traceability.json`; never broaden the -planning modification set to its feature or siblings. Use the exact `feature` value from the selected feature's `traceability.json`; never substitute a -directory guess. This keeps unrelated draft features from blocking the selected plan. Resolve every -in-scope structural or readiness blocker, then perform the semantic Zero-Ambiguity Gate. -Record approval only through the exact compare-and-swap request in the traceability reference: -`transition --request ` with gate `plan` and the selected target. +A plan is not ready merely because its documents exist. Before presenting it as executable, +directly confirm that its required documents exist, its `00-index.md` declares every implemented +RD on one `> **Implements**:` line, its ambiguity register has no open material item, and its +specification tests are ordered before implementation tasks. Run the semantic Zero-Ambiguity Gate. +Do not create a graph, readiness record, or transition request. `99-execution-plan.md` is the sole +mutable task-progress authority. ## Planning scope contract @@ -51,7 +44,7 @@ Record three boundaries before discovery: | **Modification set** | The requirement/specification artifacts the user has authorized make-plan to change | Reading an artifact does not authorize changing it. If planning exposes an upstream defect, reopen -the owning requirement or specification and mark affected downstream traceability stale, but do +the owning requirement or specification and mark affected downstream plan content stale, but do not edit it until the user confirms the exact expanded modification set. If the correction would redesign sibling RDs or the requirement set, pause and offer a separate requirements revision instead of silently absorbing it into plan creation. @@ -141,7 +134,7 @@ When a `requirements/` directory exists with `RD-XX-*.md` files (produced by the ### 1.1 Ask clarifying questions -> **ZERO-AMBIGUITY RULE — active from the first question.** Applies to every decision with semantic weight: design, architecture, behavior, scope, edge cases, error messages, naming, file structure. Behavior/scope/data/security decisions ALWAYS gate; cosmetic choices with zero semantic impact are exempt (per the shared gate's traceability exemptions), and low-stakes cosmetic items may be batched. In normal mode, if there is more than one semantically distinct option, the **user decides**. With active auto-design, resolve and record eligible technical decisions under the shared policy; reserved decisions still require the user. Demand concrete, specific answers. Do not fill gaps with assumptions, infer intent, or apply "reasonable defaults" outside that delegated policy. If an answer is vague, ask again with sharper options. +> **ZERO-AMBIGUITY RULE — active from the first question.** Applies to every decision with semantic weight: design, architecture, behavior, scope, edge cases, error messages, naming, file structure. Behavior/scope/data/security decisions ALWAYS gate; cosmetic choices with zero semantic impact are exempt (per the shared gate's semantic-impact exemptions), and low-stakes cosmetic items may be batched. In normal mode, if there is more than one semantically distinct option, the **user decides**. With active auto-design, resolve and record eligible technical decisions under the shared policy; reserved decisions still require the user. Demand concrete, specific answers. Do not fill gaps with assumptions, infer intent, or apply "reasonable defaults" outside that delegated policy. If an answer is vague, ask again with sharper options. Cover at minimum: **Feature scope** (what it does / does NOT do, boundaries), **Technical context** (affected code, existing patterns, constraints), **Dependencies** (prerequisites, external deps), and **Success criteria** (definition of done, required tests, required docs). diff --git a/skills/make-plan/templates.md b/skills/make-plan/templates.md index 4eef9e7..28a3ca9 100644 --- a/skills/make-plan/templates.md +++ b/skills/make-plan/templates.md @@ -45,7 +45,7 @@ excerpt at dispatch time — excerpting for a handoff packet is not restatement) > **Feature**: [Brief description] > **Status**: Planning Complete > **Created**: [Date] -> **Implements**: RD-NN (only if based on a requirements document; omit otherwise) +> **Implements**: RD-NN, RD-NN (one or more RD identifiers; feature-qualify in nested layout) > **CodeOps Artifact Schema**: 1 ## Overview @@ -425,6 +425,8 @@ task-size criteria in [quality-checklist.md](quality-checklist.md)) > stamp after EVERY task — never batch updates. Only `[x]` counts as complete. > 4. **Resume** by scanning the phase sections top-to-bottom: the first `[~]` task is resumed > first, else the first `[ ]` task. +> 5. **On blocker:** mark the task `[!]` and append `Blocked: ` on the same line. +> The plan lifecycle is `Ready`, `Executing`, `Done`, or `Blocked`, derived from these markers. > > Timestamps come from `date '+%Y-%m-%d %H:%M'` — never invented. Failure to keep the marks > current means progress is invisible after crashes, context resets, or session handoffs. diff --git a/skills/make-requirements/SKILL.md b/skills/make-requirements/SKILL.md index efb5dda..21ff396 100644 --- a/skills/make-requirements/SKILL.md +++ b/skills/make-requirements/SKILL.md @@ -31,22 +31,15 @@ Transform a rough project idea into a structured, complete set of formal **requirement documents (RDs)**. This skill is upstream of, and independent from, the make-plan skill — neither requires the other. -## Codex traceability contract +## Requirements authority contract -For every accepted requirement and material ambiguity, maintain the feature's `traceability.json` according to [../../references/artifacts/traceability.md](../../references/artifacts/traceability.md). Requirements use stable `RD-*` identifiers; ambiguities and decisions use stable `AR-*` identifiers. Link each resolved ambiguity to every requirement or specification it affects. Before declaring requirements complete, run: - -```bash -python3 "${PLUGIN_ROOT}/scripts/codeops_state.py" readiness --root . \ - --gate requirements --target -``` - -Resolve `` from the graph (an RD, or an explicitly selected requirement-set/group); -never infer it from a directory or widen it to sibling RDs. Closure is read context, not an -authorized modification set. The requirements gate remains closed while a material ambiguity is -open, a requirement is not approved, a referenced artifact is missing, or the traceability graph -is broken. The script validates structure; this skill remains responsible for semantic completeness. -Advance a draft target only with the exact compare-and-swap request defined in the traceability -reference and `transition --request ` using gate `requirements`. +Requirement documents own agreed behavior and acceptance criteria. Use stable `RD-*` identifiers; +ambiguities and decisions use stable `AR-*` identifiers. Link each resolved ambiguity to every +requirement or specification it affects. Before declaring requirements complete, directly confirm +that every material ambiguity is resolved, every requirement is approved, and every referenced +artifact exists. Do not create a workflow-state file. When a plan is later created, its +`00-index.md` declares the RD or RDs it implements; RD delivery is derived from that plan rather +than stored as a second mutable status. ## Core Principle: Proactive Domain Consultant @@ -149,7 +142,7 @@ This rule applies to **every decision with semantic weight** — feature specs, behavioral definitions, scope boundaries, edge-case handling, technical choices, data models, naming, document organization. Behavior/scope/data/security decisions ALWAYS gate; cosmetic choices with zero semantic impact are exempt -(per the shared gate's traceability exemptions), and low-stakes cosmetic items +(per the shared gate's semantic-impact exemptions), and low-stakes cosmetic items may be batched. If you must choose between two or more semantically distinct options. **In normal mode, the user decides.** With active auto-design, resolve only eligible technical decisions under the shared policy; reserved decisions still require the user. diff --git a/skills/make-requirements/templates.md b/skills/make-requirements/templates.md index 9152104..fd4250d 100644 --- a/skills/make-requirements/templates.md +++ b/skills/make-requirements/templates.md @@ -278,10 +278,3 @@ Before finalizing, run through commonly forgotten requirements: > project. See your project's security coding standards (AGENTS.md) for the full > standard. Acceptance criteria for these map to your project's testing standards > (AGENTS.md). - -## Schema-2 traceability seed - -New requirement sets create schema-2 nodes with feature-local IDs, semantic `sources`, current -`revision`, and relationship `snapshots`. Link requirements to explicit specification or planning -group targets. Never copy semantic requirement text into the graph and never add sibling -membership merely because files share a directory. diff --git a/skills/preflight/SKILL.md b/skills/preflight/SKILL.md index ffbffea..89d303a 100644 --- a/skills/preflight/SKILL.md +++ b/skills/preflight/SKILL.md @@ -38,18 +38,14 @@ codebase**. Find every issue, ambiguity, contradiction, gap, and risk; verify ev assumption against the real code; present each finding with options + a recommendation; iterate until the artifact passes clean. -Begin with deterministic evidence when the project has CodeOps traceability: - -```bash -python3 "${PLUGIN_ROOT}/scripts/codeops_state.py" readiness --root . \ - --gate audit --target -``` - -Resolve `` from the graph-owned artifact named by the user. Closure may supply review -context, but the declared target is the modification set: a sibling issue is contextual unless -the user explicitly expands that set. Graphless ad-hoc artifacts receive semantic audit only. - -Report its surviving structural/readiness failures alongside, but never as substitutes for, the semantic audit. Deterministic checks own identifiers, links, statuses, and coverage shape; reviewers own truth, completeness, consistency, feasibility, and risk. +Begin with direct artifact checks: resolve the exact artifact named by the user, confirm required +documents and links exist, confirm material ambiguities are closed, and for a plan confirm its +specification-first ordering. Related artifacts may supply review context, but the declared target +is the modification set: a sibling issue is contextual unless the user explicitly expands that +set. Report structural failures alongside, but never as substitutes for, the semantic audit. +Deterministic checks own identifiers, links, statuses, and coverage shape; reviewers own truth, +completeness, consistency, feasibility, and risk. A pass still requires every critical/major +finding resolved and every remaining minor finding explicitly accepted. Read [../../references/domains/selection.md](../../references/domains/selection.md), verify the artifact selected all applicable domain lenses, and add one audit cluster per selected lens. A generic security or feasibility pass does not substitute for compiler semantics, financial integrity, concurrency, or migration analysis. diff --git a/skills/preflight/dimensions.md b/skills/preflight/dimensions.md index 2ecfc1c..1a5147b 100644 --- a/skills/preflight/dimensions.md +++ b/skills/preflight/dimensions.md @@ -18,8 +18,8 @@ Before scanning, you MUST: `plans//00-ambiguity-register.md`. Understand what decisions were already made and why. 5. **Freeze the scope** — record the exact audit target, context documents, and authorized modification set. Reading a related document does not add it to the target. -6. **Resolve graph identity** — when traceability exists, use the exact node/group target with - the `audit` gate. Closure is context; findings do not silently expand the modification set. +6. **Resolve artifact identity** — use the exact user-selected file or directory as the audit + target. Related artifacts are context; findings do not silently expand the modification set. 7. **Freeze product scope** — load [../../_shared/scope-expansion-control.md](../../_shared/scope-expansion-control.md), record strict or exploration mode, and separate defects in the authorized target from optional new diff --git a/skills/roadmap/SKILL.md b/skills/roadmap/SKILL.md index 2d2bab7..97d8b2e 100644 --- a/skills/roadmap/SKILL.md +++ b/skills/roadmap/SKILL.md @@ -11,7 +11,7 @@ description: >- progress, stages, and next steps), archive_roadmap (move a completed feature to the archive), and compact_roadmap (slim a bloated roadmap: strip the legacy Notes log and trim fat cells). Detects the action from the user's phrasing or arguments and branches. The roadmap is the cross-session - source of truth at the RD/plan altitude, above any single execution plan. + derived cross-session summary at the RD/plan altitude. --- # roadmap — Live Feature-Set Roadmap Keeper @@ -20,25 +20,18 @@ description: >- ## Codex derived-status rule -When a feature has `traceability.json`, treat the graph plus on-disk execution plans as the status evidence. Run this as a standalone command (never in an `&&` chain with roadmap reads): +Execution-plan checklists and plan metadata are authoritative. Derive status without writing a +second state store. For a read-only summary, run: ```bash -python3 "${PLUGIN_ROOT}/scripts/codeops_state.py" status --root . --target --json +python3 "${PLUGIN_ROOT}/scripts/codeops_plan.py" --root . --json ``` -Resolve and query each canonical target independently; updating one row must never advance its -siblings. Feature and release aggregation is explicit: - -```bash -python3 "${PLUGIN_ROOT}/scripts/codeops_state.py" readiness --root . \ - --gate feature-acceptance --target -python3 "${PLUGIN_ROOT}/scripts/codeops_state.py" readiness --root . \ - --gate release --target -``` - -A release includes only its declared members. - -`status` exits zero when valid project state is read, even when its JSON says `"ready": false`; not-ready is normal status data for draft or in-progress features. A nonzero exit means structurally invalid or unreadable state. Roadmaps summarize lifecycle, readiness, tasks, verification, findings, blockers, and deferrals; they never become an independent owner of those facts. If roadmap text conflicts with derived evidence, report drift and repair the derived view without silently changing authoritative artifacts. +The helper reads every `> **Implements**:` declaration and each `[ ]`/`[~]`/`[x]`/`[!]` task. +Its output is derived and read-only; a nonzero exit means a required plan artifact, RD mapping, or +blocked reason is missing. Updating one row must never advance its siblings. Roadmaps summarize +lifecycle, tasks, blockers, and RD delivery; they never become an independent owner of those +facts. If roadmap text conflicts with the plan, report drift and repair only the derived view. ## Resolve paths first (layout-aware) @@ -158,10 +151,10 @@ Plan folders are named by feature (e.g. `plans/billing/`) and carry **no encoded id**, and the repo can hold multiple unrelated feature-sets at once, so "everything under `plans/`" is **not** a valid membership rule. Link deterministically instead: -- Every plan declares the requirement it implements as a `> **Implements**: RD-NN` - line in its `00-index.md` (feature-qualified `> **Implements**: /RD-NN` in nested - layout — see the ID rules in the convention doc). The `Plan Created` hook reads this line and - links the plan to the matching RD row in that feature's roadmap. +- Every plan declares every requirement it implements on one `> **Implements**: RD-NN, RD-NN` + line in its `00-index.md` (feature-qualified identifiers in nested layout — see the ID rules in + the convention doc). The `Plan Created` hook reads this line and links the plan to each matching + RD row in that feature's roadmap. - A plan with **no declared RD** is linked only when the user explicitly states which RD (or `DEF-n`) it belongs to. Unrelated plans are never silently swept in. diff --git a/skills/roadmap/stage-hooks.md b/skills/roadmap/stage-hooks.md index 5385672..a847927 100644 --- a/skills/roadmap/stage-hooks.md +++ b/skills/roadmap/stage-hooks.md @@ -69,7 +69,8 @@ complete the per-feature roadmap transition (codeops/features//00-roadmap.md) ## Source-of-truth rule (stated directly here) -The roadmap is the **cross-session source of truth** at the RD/plan altitude: +The roadmap is a **cross-session derived view** at the RD/plan altitude. Requirements own agreed +behavior; plan metadata owns RD mapping; `99-execution-plan.md` owns task progress: - **Read-if-exists** — when a roadmap exists, read it at the start of relevant work to see what is done, in flight, blocked, or in the backlog. diff --git a/skills/setup-codeops/SKILL.md b/skills/setup-codeops/SKILL.md index c95a20f..672694a 100644 --- a/skills/setup-codeops/SKILL.md +++ b/skills/setup-codeops/SKILL.md @@ -1,7 +1,7 @@ --- name: setup-codeops description: >- - Sets up the CodeOps nested codeops/ layout in the current git repo — scaffolds a fresh skeleton or auto-migrates an existing flat-layout repo (requirements/ + plans/) into it. Use when the user says "setup-codeops", "/setup-codeops", "set up codeops", "initialize codeops", "migrate to the nested layout", "convert my plans/requirements to codeops/", or "scaffold the codeops structure". Detects repo state and dispatches: a marker (codeops/.codeops.yml) already present → no-op status report; a flat layout → migration (deterministic preview via scripts/codeops-migrate.sh, one confirmation, then git mv); neither → minimal fresh scaffold. Supports --dry-run (preview only) and --yes (apply without the prompt). Migration is git-mv-only, refuses a dirty tree, rejects path-traversal slugs, and is idempotent. setup-codeops is the SOLE writer of the layout marker. + Sets up or upgrades CodeOps in the current git repo. It scaffolds a fresh nested codeops/ layout, auto-migrates a flat requirements/ + plans/ layout, and detects obsolete traceability.json workflow-state graphs in an existing nested project. Use when the user says "setup-codeops", "/setup-codeops", "set up codeops", "initialize codeops", "upgrade existing codeops", "migrate to the nested layout", or "scaffold the codeops structure". Supports --dry-run and unattended --yes. Every migration previews deterministically, refuses unsafe or ambiguous apply, requires clean Git state for writes, and is idempotent. setup-codeops is the SOLE writer of the layout marker. --- # CodeOps Layout Setup (`setup-codeops`) @@ -26,17 +26,23 @@ it is the single source of truth for the layout. Do not re-encode paths here. Run inside the repo and detect, in this order: ``` -1. codeops/.codeops.yml present +1. Any codeops/features/*/traceability.json or codeops/_archive/*/traceability.json present + → LEGACY WORKFLOW-STATE UPGRADE, even when the layout marker is already present. Follow the + nested-project flow in migration.md. Preview with codeops_plan_migrate.py; with --yes, + apply only when the preview has no BLOCKED entry, then verify with codeops_plan.py. + This check intentionally precedes the marker no-op so re-running setup upgrades an + existing project without requiring a separate upgrade prompt. +2. codeops/.codeops.yml present → already set up. NO-OP for the layout: print a short status report (layout = nested, where things live). BUT if the marker is **missing `integrationBranch`**, BACKFILL it — add that one line (resolved to the repo's integration branch: `origin/HEAD`, else the current branch, else `main`/`master`) without touching any other key; if it is already present, leave it. Never re-scaffold or re-migrate. (Idempotent — a marker that is present and complete → no change; this is the existing-project entry point for parallel-agents support.) -2. Flat layout detected (requirements/ OR plans/00-roadmap.md OR any plans//) +3. Flat layout detected (requirements/ OR plans/00-roadmap.md OR any plans//) → MIGRATE. Follow migration.md: run the engine --dry-run, render the preview, take ONE confirmation, then apply. The engine (scripts/codeops-migrate.sh) owns the algorithm. -3. Neither +4. Neither → fresh SCAFFOLD. Follow scaffold.md: create the minimal codeops/ skeleton. ``` @@ -47,26 +53,36 @@ fresh scaffold should live in version control) and suggest `git init` first. | Flag | Effect | |------|--------| -| *(none)* | Interactive: scaffold creates the skeleton; migration previews then asks for one confirmation before applying. | -| `--dry-run` | Preview only — compute and show what would happen; change **nothing**. For migration, pass straight through to the engine. | -| `--yes` | Apply without the confirmation prompt (unattended). For migration, the engine applies directly. | +| *(none)* | Interactive: scaffold creates the skeleton; either migration previews then asks for one confirmation before applying. | +| `--dry-run` | Preview only — compute and show what would happen; change **nothing**. | +| `--yes` | Apply an unblocked migration without confirmation, then verify it. Safety refusals still apply. | -## The migration engine (delegation — do not re-implement) +## Migration engines (delegation — do not re-implement) -All migration path arithmetic, the slug derivation, the hazard scan, the dirty-tree refusal, the -path-traversal slug guard, idempotency, and the `git mv` apply live in the deterministic helper -**`scripts/codeops-migrate.sh`** (see [migration.md](migration.md)). This skill **delegates** to it -so there is one source of truth and no prose-vs-script drift: +Flat-to-nested path arithmetic, slug derivation, hazard scanning, dirty-tree refusal, +path-traversal protection, idempotency, and `git mv` apply live in +**`scripts/codeops-migrate.sh`** (see [migration.md](migration.md)): - Preview: `scripts/codeops-migrate.sh --dry-run` - Apply: `scripts/codeops-migrate.sh --yes` Never re-derive the move map in prose — read it from the engine's output and present it. +Existing nested-project graph removal and Markdown ownership inference live in +**`scripts/codeops_plan_migrate.py`**. Do not inspect or rewrite graph semantics in the skill: + +- Preview: `python3 "${PLUGIN_ROOT}/scripts/codeops_plan_migrate.py" ./codeops` +- Apply: `python3 "${PLUGIN_ROOT}/scripts/codeops_plan_migrate.py" ./codeops --apply` +- Verify: `python3 "${PLUGIN_ROOT}/scripts/codeops_plan.py" --root . --json` + +On `--yes`, run preview first. Apply only when it exits successfully with no `BLOCKED` entry, then +run verification. The migrator itself owns clean-tree refusal, all-or-nothing writes, graph +deletion, and idempotency. + ## Reference files - [scaffold.md](scaffold.md) — the minimal fresh-repo skeleton. -- [migration.md](migration.md) — the flat→nested migration UX (invoke the engine, preview, confirm, report). +- [migration.md](migration.md) — flat-layout and legacy workflow-state migration UX. - [_shared/layout-convention.md](../../_shared/layout-convention.md) — the layout/path/ID/marker source of truth. ## Grounded Options & Recommendations diff --git a/skills/setup-codeops/migration.md b/skills/setup-codeops/migration.md index 1f84dbd..f89f4d7 100644 --- a/skills/setup-codeops/migration.md +++ b/skills/setup-codeops/migration.md @@ -1,4 +1,37 @@ -# Flat → Nested Migration (UX) +# CodeOps Migration UX + +## Existing nested project: remove legacy workflow state + +Read this section when `setup-codeops` finds `traceability.json` in an active or archived feature, +including when `codeops/.codeops.yml` already exists. This detection precedes the normal +already-configured no-op. + +1. Preview with: + + ```text + python3 "${PLUGIN_ROOT}/scripts/codeops_plan_migrate.py" ./codeops + ``` + +2. Render every `UPDATE`, `KEEP`, `DELETE`, and `BLOCKED` line. A non-zero preview or any + `BLOCKED` entry stops the migration without asking to apply. +3. Unless `--yes` was passed, ask once whether to apply the displayed Markdown updates and graph + deletions. +4. Apply with `codeops_plan_migrate.py ./codeops --apply`. The engine refuses dirty Git state and + performs no writes unless every mapping is unambiguous. +5. Verify with: + + ```text + python3 "${PLUGIN_ROOT}/scripts/codeops_plan.py" --root . --json + ``` + + Then run the repository's normal verification command. Report plan coverage, preserved task + progress, deleted graphs, and any residual risk. Do not advance roadmap lifecycle state. + +Re-running after success must report no graph migration and continue to the normal configured +project status. `--yes` skips only the confirmation; it never bypasses ambiguity or Git-safety +checks. + +## Flat → nested layout > Read this when `setup-codeops` detects an existing **flat layout** (a `requirements/` dir, a > `plans/00-roadmap.md`, or any `plans//`). Resolve paths via diff --git a/skills/setup-codeops/scaffold.md b/skills/setup-codeops/scaffold.md index 5be265e..13de6d9 100644 --- a/skills/setup-codeops/scaffold.md +++ b/skills/setup-codeops/scaffold.md @@ -26,8 +26,8 @@ front. Those appear lazily when the first RD, plan, or task is authored (AR #5). 4. Write `codeops/codeops.json` with the strict defaults below. 5. Write `codeops/00-roadmap.md` (empty portfolio — see the portfolio template in the `roadmap` skill; seed it with zero features and an empty Archived section). -6. Run `codeops_state.py status`; no feature graph exists yet, so report setup state rather than - claiming project readiness. +6. Confirm the marker, policy, portfolio, and empty feature directory exist; report setup state + rather than claiming project readiness. 7. Report what was created and what to do next (`make-requirements` / `make-plan` for the first feature; the feature folder is created lazily then). diff --git a/skills/setup-routing/SKILL.md b/skills/setup-routing/SKILL.md index f9364a6..845a489 100644 --- a/skills/setup-routing/SKILL.md +++ b/skills/setup-routing/SKILL.md @@ -5,7 +5,9 @@ description: Configure risk- and capability-based Codex subagent routing for a p # Configure CodeOps routing for Codex -Routing is an optimization and isolation mechanism, not a source of correctness. Requirements, ambiguity, readiness, verification, and review gates remain identical whether work runs inline, through a named custom agent, or through a dynamically prompted generic subagent. +Routing is an optimization and isolation mechanism, not a source of correctness. Requirements, +ambiguity, direct artifact checks, verification, and review gates remain identical whether work +runs inline, through a named custom agent, or through a dynamically prompted generic subagent. ## Inputs @@ -57,7 +59,7 @@ Model names are implementation choices, not policy names. Default to the current ## Structured policy -Store CodeOps policy in `codeops/codeops.json`, not in `AGENTS.md`. `AGENTS.md` receives only a concise instruction that CodeOps routing is configured and that material ambiguity/readiness gates may not be bypassed. +Store CodeOps policy in `codeops/codeops.json`, not in `AGENTS.md`. `AGENTS.md` receives only a concise instruction that CodeOps routing is configured and that material ambiguity and verification gates may not be bypassed. Example policy fields are documented in [routing.md](routing.md). @@ -95,7 +97,6 @@ After setup: ```bash python3 "${PLUGIN_ROOT}/scripts/install_agents.py" --project . --check -python3 "${PLUGIN_ROOT}/scripts/codeops_state.py" status --root . ``` Report configured roles, model pins if any, read-only roles, fallbacks, and unresolved capability gaps. diff --git a/skills/upgrade-plan/SKILL.md b/skills/upgrade-plan/SKILL.md index 5bc72fb..5e4e6cc 100644 --- a/skills/upgrade-plan/SKILL.md +++ b/skills/upgrade-plan/SKILL.md @@ -1,6 +1,6 @@ --- name: upgrade-plan -description: Upgrade an existing CodeOps requirements set, specification, plan, or project from a legacy artifact format to the current schema and quality standards. Use for upgrade my plan, upgrade requirements, migrate CodeOps artifacts, add traceability, or bring project artifacts up to date. Assesses and previews changes, closes content ambiguities before structural migration, preserves user-authored semantics and progress, and verifies the result without advancing the roadmap. +description: Upgrade an existing CodeOps requirements set, specification, plan, or project from a legacy artifact format to the current schema and quality standards. Use for upgrade my plan, upgrade requirements, migrate CodeOps artifacts, or bring project artifacts up to date. Assesses and previews changes, closes content ambiguities before structural migration, preserves user-authored semantics and progress, and verifies the result without advancing the roadmap. --- # Upgrade CodeOps artifacts @@ -16,24 +16,26 @@ Targets may be a requirements set, one feature plan, one feature, or the whole C ## Phase 1 — Read-only assessment 1. Read every target artifact and its links. -2. Detect `CodeOps Artifact Schema: 1`, legacy `CodeOps Skills Version`, partial migrations, missing traceability, and contradictory stamps. +2. Detect `CodeOps Artifact Schema: 1`, legacy `CodeOps Skills Version`, partial migrations, + obsolete `traceability.json` files, missing RD-to-plan declarations, and contradictory stamps. 3. Run current requirement, specification, plan, domain-lens, and content-quality checks. 4. Inventory user-owned semantics, completed/in-progress task marks, custom notes, identifiers, and links that must survive byte-for-byte or meaning-for-meaning. 5. Produce an upgrade report listing additions, structural changes, semantic gaps, preserved content, risks, and rollback/recovery method. -If every graph is schema 2, traceability validates, and current semantic gates pass, report no -upgrade needed. For schema 1, use the public preview, resolution, apply, and validate protocol: +If current semantic gates pass, every plan declares its implemented RDs, and every execution plan +uses the four checklist markers, report no upgrade needed. Treat obsolete traceability files as +deletion candidates after confirming no external consumer depends on them; do not migrate their +graph state into a replacement platform. + +For a whole nested `codeops/` project, preview the deterministic structural portion with: ```bash -python3 "${PLUGIN_ROOT}/scripts/codeops_state.py" traceability-upgrade --root . \ - --feature --preview -python3 "${PLUGIN_ROOT}/scripts/codeops_state.py" traceability-upgrade --root . \ - --feature --preview --resolutions --apply -python3 "${PLUGIN_ROOT}/scripts/codeops_state.py" validate --root . +python3 "${PLUGIN_ROOT}/scripts/codeops_plan_migrate.py" ./codeops ``` -The preview and closed-form resolutions are reviewable artifacts. Apply is atomic and may require -`transition-recover`; never hand-edit around a recovery-required result. +If the preview has no `BLOCKED` entries and the user approves it, rerun with `--apply`. The +migrator never resolves content ambiguities: return blocked mappings or legacy blocker semantics +to this skill before applying. ## Phase 2 — Approval and content-quality gate @@ -46,24 +48,28 @@ After approval, run [content-quality-gate.md](content-quality-gate.md). Structur Follow [upgrade-checklists.md](upgrade-checklists.md): - add `> **CodeOps Artifact Schema**: 1` where artifact stamps belong; -- create/update feature `traceability.json` with stable typed nodes; +- add or update each plan's single `> **Implements**:` declaration; - preserve completed `[x]` and implemented `[~]` task states; +- convert a blocked legacy task to `[!]` with a short visible reason; - preserve technical decisions, requirements, criteria, rationale, and notes; - update renamed skill/project-guidance references; -- add missing readiness, recovery, domain, security, verification, and project-tracking sections; and +- add missing ambiguity, domain, security, verification, and project-tracking sections; and - never silently renumber identifiers that external artifacts reference. -Use small recoverable edits. If interrupted, schema stamps and graph validation identify remaining work. +Use small recoverable edits. Git history is the rollback and recovery mechanism. ## Phase 4 — Verification -Run: +Run the plan parser and the project's verification commands: ```bash -python3 "${PLUGIN_ROOT}/scripts/codeops_state.py" validate --root . -python3 "${PLUGIN_ROOT}/scripts/codeops_state.py" readiness --root . +python3 "${PLUGIN_ROOT}/scripts/codeops_plan.py" --root . --json ``` -Then verify document/task/requirement counts and user semantics are preserved; every migrated node has valid relationships; material ambiguities are resolved or explicitly approved deferrals; active content is approved; tests, tasks, implementation, and verification are traced; roadmap lifecycle state is unchanged except for approved drift repair; and the Git diff contains only the approved migration. +Then verify document/task/requirement counts and user semantics are preserved; material +ambiguities are resolved or explicitly approved deferrals; tests precede implementation; no task +is marked `[x]` without passing verification; roadmap lifecycle state is unchanged except for +approved drift repair; and the Git diff contains only the approved migration. -Report old formats, new schema, files changed, ambiguities resolved, traceability coverage, preserved progress, and residual risk. Do not auto-advance lifecycle stages. +Report old formats, new schema, files changed, ambiguities resolved, RD-to-plan coverage, preserved +progress, and residual risk. Do not auto-advance lifecycle stages. diff --git a/skills/upgrade-plan/upgrade-checklists.md b/skills/upgrade-plan/upgrade-checklists.md index 16e3f01..157ac38 100644 --- a/skills/upgrade-plan/upgrade-checklists.md +++ b/skills/upgrade-plan/upgrade-checklists.md @@ -15,6 +15,10 @@ SKILL.md — never destroy user work. ## Plan upgrade — re-evaluation checklists +For a whole nested project, use `codeops_plan_migrate.py ` to preview the mechanical +RD mapping and obsolete-graph deletion after these content checks pass. Apply only from a clean +Git tree and only when the preview has no blocked plan. + Re-evaluate each plan document against the make-plan skill's current standards. ### `00-index.md` diff --git a/tests/conformance/test_plan_migration.py b/tests/conformance/test_plan_migration.py new file mode 100644 index 0000000..39be12a --- /dev/null +++ b/tests/conformance/test_plan_migration.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +from scripts.codeops_plan import inspect_plan + + +ROOT = Path(__file__).resolve().parents[2] +SCRIPT = ROOT / "scripts" / "codeops_plan_migrate.py" + + +class PlanMigrationTests(unittest.TestCase): + def make_feature( + self, + root: Path, + plans: tuple[str, ...] = ("billing-plan",), + rds: tuple[str, ...] = ("RD-01", "RD-02"), + roadmap: bool = True, + ) -> Path: + codeops = root / "codeops" + feature = codeops / "features" / "billing" + requirements = feature / "requirements" + requirements.mkdir(parents=True) + for rd_id in rds: + (requirements / f"{rd_id}-sample.md").write_text(f"# {rd_id}\n", encoding="utf-8") + for plan_name in plans: + plan = feature / "plans" / plan_name + plan.mkdir(parents=True) + (plan / "00-index.md").write_text(f"# {plan_name}\n\n> **Status**: Ready\n", encoding="utf-8") + (plan / "99-execution-plan.md").write_text("# Execution\n\n- [ ] T-1 Build\n", encoding="utf-8") + (feature / "traceability.json").write_text('{"schema": 2}\n', encoding="utf-8") + if roadmap: + rows = "\n".join( + f"| {rd_id} | Item | [RD] | [plan](plans/{plans[0]}/00-index.md) | Plan Created | | | |" + for rd_id in rds + ) + (feature / "00-roadmap.md").write_text( + "# Roadmap\n\n| ID | Title | RD | Plan | Stage | Status | Updated | Blocker |\n" + "|---|---|---|---|---|---|---|---|\n" + f"{rows}\n", + encoding="utf-8", + ) + return codeops + + def commit_fixture(self, root: Path) -> None: + subprocess.run(["git", "-C", str(root), "init", "-q"], check=True) + subprocess.run(["git", "-C", str(root), "config", "user.email", "test@example.com"], check=True) + subprocess.run(["git", "-C", str(root), "config", "user.name", "Test"], check=True) + subprocess.run(["git", "-C", str(root), "add", "-A"], check=True) + subprocess.run(["git", "-C", str(root), "commit", "-q", "-m", "fixture"], check=True) + + def run_migrator(self, codeops: Path, *args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, str(SCRIPT), str(codeops), *args], + text=True, + capture_output=True, + check=False, + ) + + def test_preview_infers_multiple_rds_from_roadmap_without_mutating(self) -> None: + with tempfile.TemporaryDirectory() as directory: + codeops = self.make_feature(Path(directory)) + index = codeops / "features" / "billing" / "plans" / "billing-plan" / "00-index.md" + before = index.read_text(encoding="utf-8") + result = self.run_migrator(codeops) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("billing/RD-01, billing/RD-02 (feature roadmap)", result.stdout) + self.assertIn("DELETE features/billing/traceability.json", result.stdout) + self.assertEqual(index.read_text(encoding="utf-8"), before) + + def test_apply_updates_markdown_then_deletes_graph(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + codeops = self.make_feature(root) + self.commit_fixture(root) + result = self.run_migrator(codeops, "--apply") + self.assertEqual(result.returncode, 0, result.stderr) + plan = codeops / "features" / "billing" / "plans" / "billing-plan" + self.assertEqual(inspect_plan(plan).implements, ("billing/RD-01", "billing/RD-02")) + self.assertFalse((codeops / "features" / "billing" / "traceability.json").exists()) + second_preview = self.run_migrator(codeops) + self.assertEqual(second_preview.returncode, 0, second_preview.stdout + second_preview.stderr) + self.assertNotIn("UPDATE", second_preview.stdout) + + def test_ambiguous_mapping_blocks_every_write_and_deletion(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + codeops = self.make_feature(root, plans=("first", "second"), roadmap=False) + self.commit_fixture(root) + result = self.run_migrator(codeops, "--apply") + self.assertEqual(result.returncode, 1) + self.assertIn("cannot infer implemented targets", result.stdout) + self.assertTrue((codeops / "features" / "billing" / "traceability.json").is_file()) + for plan_name in ("first", "second"): + index = codeops / "features" / "billing" / "plans" / plan_name / "00-index.md" + self.assertNotIn("**Implements**", index.read_text(encoding="utf-8")) + + def test_single_plan_single_rd_is_an_unambiguous_fallback(self) -> None: + with tempfile.TemporaryDirectory() as directory: + codeops = self.make_feature(Path(directory), rds=("RD-AP-001",), roadmap=False) + result = self.run_migrator(codeops) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("billing/RD-AP-001 (single plan and single RD)", result.stdout) + + def test_archived_features_are_included(self) -> None: + with tempfile.TemporaryDirectory() as directory: + codeops = self.make_feature(Path(directory), rds=("RD-01",), roadmap=False) + archive = codeops / "_archive" + archive.mkdir() + (codeops / "features" / "billing").rename(archive / "billing") + result = self.run_migrator(codeops) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("DELETE _archive/billing/traceability.json", result.stdout) + + def test_roadmap_tracker_creates_missing_index_from_execution_plan(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + codeops = self.make_feature(root, rds=(), roadmap=False) + feature = codeops / "features" / "billing" + plan = feature / "plans" / "billing-plan" + (plan / "00-index.md").unlink() + (feature / "00-roadmap.md").write_text( + "# Roadmap\n\n| ID | Title | RD | Plan |\n|---|---|---|---|\n" + "| T-01 | Cleanup | — | [plan](plans/billing-plan/99-execution-plan.md) |\n", + encoding="utf-8", + ) + self.commit_fixture(root) + result = self.run_migrator(codeops, "--apply") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + index = plan / "00-index.md" + self.assertIn("> **Implements**: billing/T-01", index.read_text(encoding="utf-8")) + self.assertEqual(inspect_plan(plan).implements, ("billing/T-01",)) + + def test_plan_local_requirement_is_recovered_from_legacy_graph(self) -> None: + with tempfile.TemporaryDirectory() as directory: + codeops = self.make_feature(Path(directory), rds=(), roadmap=False) + graph = codeops / "features" / "billing" / "traceability.json" + graph.write_text( + '{"nodes": [' + '{"id":"REQ-IMPORT","type":"requirement","semanticSources":[' + '{"path":"codeops/features/billing/plans/billing-plan/01-requirements.md"}]},' + '{"id":"PLAN-IMPORT","type":"plan","semanticSources":[' + '{"path":"codeops/features/billing/plans/billing-plan/00-index.md"}],"edges":[]}' + ']}\n', + encoding="utf-8", + ) + result = self.run_migrator(codeops) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("billing/REQ-IMPORT (legacy graph)", result.stdout) + + def test_archived_feature_without_graph_is_out_of_scope(self) -> None: + with tempfile.TemporaryDirectory() as directory: + codeops = self.make_feature(Path(directory), rds=("RD-01",), roadmap=False) + archive_feature = codeops / "_archive" / "unrelated" + plan = archive_feature / "plans" / "ambiguous" + plan.mkdir(parents=True) + (plan / "99-execution-plan.md").write_text("# Execution\n\n- [ ] T-1 Build\n", encoding="utf-8") + result = self.run_migrator(codeops) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertNotIn("unrelated", result.stdout) + + def test_rd_reference_in_index_metadata_is_an_explicit_fallback(self) -> None: + with tempfile.TemporaryDirectory() as directory: + codeops = self.make_feature(Path(directory), plans=("first", "second"), roadmap=False) + index = codeops / "features" / "billing" / "plans" / "first" / "00-index.md" + index.write_text("# First\n\n> **Type**: Remediation follow-up to RD-02\n", encoding="utf-8") + second = codeops / "features" / "billing" / "plans" / "second" / "00-index.md" + second.write_text("# Second\n\n> **Implements**: billing/RD-01\n", encoding="utf-8") + result = self.run_migrator(codeops) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("billing/RD-02 (plan metadata)", result.stdout) + + def test_lightweight_task_id_in_execution_title_is_an_explicit_fallback(self) -> None: + with tempfile.TemporaryDirectory() as directory: + codeops = self.make_feature(Path(directory), rds=(), roadmap=False) + plan = codeops / "features" / "billing" / "plans" / "billing-plan" + (plan / "00-index.md").unlink() + (plan / "99-execution-plan.md").write_text( + "# Task T-07: Repair release\n\n- [x] T-07.1 Verified\n", encoding="utf-8" + ) + result = self.run_migrator(codeops) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("billing/T-07 (plan metadata)", result.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/conformance/test_plan_state_impl.py b/tests/conformance/test_plan_state_impl.py new file mode 100644 index 0000000..ea6e6ae --- /dev/null +++ b/tests/conformance/test_plan_state_impl.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +from scripts.codeops_plan import lifecycle, parse_tasks + + +ROOT = Path(__file__).resolve().parents[2] +SCRIPT = ROOT / "scripts" / "codeops_plan.py" + + +class PlanStateImplementationTests(unittest.TestCase): + def test_lifecycle_has_four_states(self) -> None: + cases = { + "- [ ] T-1 Start\n": "Ready", + "- [~] T-1 Await verification\n": "Executing", + "- [x] T-1 Verified\n": "Done", + "- [!] T-1 Blocked: dependency unavailable\n": "Blocked", + } + for checklist, expected in cases.items(): + with self.subTest(expected=expected): + self.assertEqual(lifecycle(parse_tasks(checklist)), expected) + + def test_cli_needs_no_traceability_file(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + plan = root / "plans" / "sample" + plan.mkdir(parents=True) + (plan / "00-index.md").write_text( + "# Sample\n\n> **Implements**: RD-01\n", encoding="utf-8" + ) + (plan / "99-execution-plan.md").write_text( + "# Execution Plan\n\n- [x] T-1 Verified\n", encoding="utf-8" + ) + result = subprocess.run( + [sys.executable, str(SCRIPT), "--root", str(root), "--json"], + text=True, + capture_output=True, + check=False, + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn('"lifecycle": "Done"', result.stdout) + self.assertFalse((root / "traceability.json").exists()) + + def test_invalid_markers_are_not_silently_treated_as_progress(self) -> None: + self.assertEqual(parse_tasks("- [?] T-1 Unknown\n"), ()) + + def test_normal_workflows_do_not_invoke_removed_state_interface(self) -> None: + workflows = ( + "make-requirements", + "make-plan", + "preflight", + "exec-plan", + "roadmap", + "setup-codeops", + "setup-routing", + "upgrade-plan", + ) + removed_tokens = ("codeops_state.py", "transition-request", "readiness --") + for workflow in workflows: + for path in (ROOT / "skills" / workflow).glob("*.md"): + text = path.read_text(encoding="utf-8") + for token in removed_tokens: + self.assertNotIn(token, text, f"{path} retains {token}") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/conformance/test_plan_state_spec.py b/tests/conformance/test_plan_state_spec.py new file mode 100644 index 0000000..6073bf0 --- /dev/null +++ b/tests/conformance/test_plan_state_spec.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from scripts.codeops_plan import inspect_plan, rd_delivery + + +class PlanStateSpecificationTests(unittest.TestCase): + def make_plan(self, root: Path, implements: str, tasks: str, name: str = "sample") -> Path: + plan = root / "plans" / name + plan.mkdir(parents=True) + (plan / "00-index.md").write_text(f"# Plan\n\n> **Implements**: {implements}\n", encoding="utf-8") + (plan / "99-execution-plan.md").write_text(f"# Execution Plan\n\n{tasks}", encoding="utf-8") + return plan + + def test_plan_can_implement_multiple_requirements(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + status = inspect_plan(self.make_plan(root, "RD-01, RD-02", "- [ ] T-1 Build\n"), root) + self.assertEqual(status.implements, ("RD-01", "RD-02")) + + def test_resume_prefers_verification_pending_then_not_started(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + status = inspect_plan( + self.make_plan(root, "RD-01", "- [ ] T-1 Later\n- [~] T-2 Verify this first\n"), root + ) + self.assertEqual(status.next_task, "T-2 Verify this first") + + def test_blocked_task_requires_a_visible_reason(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + status = inspect_plan(self.make_plan(root, "RD-01", "- [!] T-1 Blocked: waiting for API\n"), root) + self.assertEqual(status.lifecycle, "Blocked") + self.assertFalse(status.problems) + + def test_requirement_delivery_is_derived_from_its_plan(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + done = inspect_plan(self.make_plan(root, "RD-01, RD-02", "- [x] T-1 Verified\n"), root) + self.assertEqual(rd_delivery((done,)), {"RD-01": "Done", "RD-02": "Done"}) + + def test_tracker_and_plan_local_requirement_targets_are_supported(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + tracked = inspect_plan( + self.make_plan(root, "billing/T-01", "- [x] T-1 Verified\n", "tracked"), root + ) + local = inspect_plan( + self.make_plan(root, "billing/REQ-IMPORT", "- [x] T-1 Verified\n", "local"), root + ) + self.assertFalse(tracked.problems) + self.assertFalse(local.problems) + self.assertEqual(rd_delivery((tracked, local)), {}) + + def test_internal_feature_qualifier_is_preserved(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + status = inspect_plan( + self.make_plan(root, "_maintenance/T-01", "- [x] T-1 Verified\n"), root + ) + self.assertEqual(status.implements, ("_maintenance/T-01",)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/conformance/test_scope_expansion_control_impl.py b/tests/conformance/test_scope_expansion_control_impl.py index f7caf9c..d6f0f30 100644 --- a/tests/conformance/test_scope_expansion_control_impl.py +++ b/tests/conformance/test_scope_expansion_control_impl.py @@ -189,7 +189,7 @@ def test_register_schema_separates_state_events_and_authority_links(self) -> Non for header in ( "| ID | Proposed addition | Origin | Why it is outside scope | Impact | Recommendation | Current state |", "| Event ID | SE ID | Timestamp | From state | Decision | Authority and evidence | Owner | Revisit trigger | Replacement or reversal |", - "| SE ID | Derived artifact or graph target | Relation or kind | Current state | Evidence source |", + "| SE ID | Derived artifact | Relation or kind | Current state | Evidence source |", ): self.assertIn(header, text) self.assertIn("normalized project-relative path", text) diff --git a/tests/conformance/test_scope_expansion_control_spec.py b/tests/conformance/test_scope_expansion_control_spec.py index bfbca90..e12f9e9 100644 --- a/tests/conformance/test_scope_expansion_control_spec.py +++ b/tests/conformance/test_scope_expansion_control_spec.py @@ -153,7 +153,7 @@ def test_register_identity_is_monotonic_and_history_preserving(self) -> None: "append-only history", "Decision", "Authority and evidence", - "Derived artifact or graph target", + "Derived artifact", ): self.assertIn(token, text) diff --git a/tests/conformance/test_setup_codeops_migration.py b/tests/conformance/test_setup_codeops_migration.py new file mode 100644 index 0000000..81f032f --- /dev/null +++ b/tests/conformance/test_setup_codeops_migration.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +SKILL = ROOT / "skills" / "setup-codeops" / "SKILL.md" +MIGRATION = ROOT / "skills" / "setup-codeops" / "migration.md" + + +class SetupCodeOpsMigrationTests(unittest.TestCase): + def test_legacy_graph_detection_precedes_configured_noop(self) -> None: + text = SKILL.read_text(encoding="utf-8") + graph_detection = text.index("codeops/features/*/traceability.json") + marker_noop = text.index("codeops/.codeops.yml present") + self.assertLess(graph_detection, marker_noop) + + def test_yes_uses_preview_apply_and_verification_without_bypassing_safety(self) -> None: + text = (SKILL.read_text(encoding="utf-8") + MIGRATION.read_text(encoding="utf-8")).lower() + for token in ( + "codeops_plan_migrate.py", + "--apply", + "codeops_plan.py", + "--root . --json", + "--yes", + "blocked", + "clean", + "idempot", + ): + self.assertIn(token, text) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/conformance/test_state.py b/tests/conformance/test_state.py deleted file mode 100755 index 16baf42..0000000 --- a/tests/conformance/test_state.py +++ /dev/null @@ -1,282 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import json -import subprocess -import sys -import tempfile -import unittest -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[2] -SCRIPT = ROOT / "scripts" / "codeops_state.py" -FIXTURES = ROOT / "tests" / "fixtures" - - -class StateConformanceTests(unittest.TestCase): - def run_state( - self, - fixture: str, - command: str = "readiness", - feature: str | None = None, - ) -> tuple[int, dict]: - command_args = [ - sys.executable, - str(SCRIPT), - command, - "--root", - str(FIXTURES / fixture), - "--json", - ] - if feature is not None: - command_args.extend(["--feature", feature]) - result = subprocess.run( - command_args, - text=True, - capture_output=True, - check=False, - ) - return result.returncode, json.loads(result.stdout) - - def test_complete_graph_is_ready(self) -> None: - code, payload = self.run_state("state-valid") - self.assertEqual(code, 0, payload) - self.assertTrue(payload["ready"]) - self.assertEqual(payload["nodes"], 8) - self.assertEqual(payload["problems"], []) - self.assertEqual(payload["features"][0]["lifecycle"], "complete") - - def test_material_ambiguity_and_broken_link_block_readiness(self) -> None: - code, payload = self.run_state("state-invalid") - self.assertEqual(code, 1) - self.assertFalse(payload["ready"]) - joined = "\n".join(payload["problems"]) - self.assertIn("links to missing node SPEC-MISSING", joined) - self.assertIn("material ambiguity AR-001 is open", joined) - self.assertIn("requirement RD-001 is not approved", joined) - - def test_invalid_strict_configuration_is_rejected(self) -> None: - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - codeops = root / "codeops" - codeops.mkdir() - (codeops / "codeops.json").write_text( - '{"schema":1,"mode":"strict","artifacts":{"layout":"nested","root":"codeops"},"quality":{"independentReview":false},"metrics":{"enabled":false}}', - encoding="utf-8", - ) - result = subprocess.run( - [sys.executable, str(SCRIPT), "validate", "--root", str(root), "--json"], - text=True, - capture_output=True, - check=False, - ) - payload = json.loads(result.stdout) - self.assertEqual(result.returncode, 1) - self.assertIn("strict mode cannot disable independent review", "\n".join(payload["problems"])) - - def test_reopened_ambiguity_invalidates_approved_downstream_work(self) -> None: - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - feature = root / "codeops/features/example" - feature.mkdir(parents=True) - (feature / "artifact.md").write_text("# Evidence\n", encoding="utf-8") - nodes = [ - {"id": "AR-001", "type": "ambiguity", "title": "Reopened", "status": "open", "path": "artifact.md", "links": ["RD-001"], "risk": "critical"}, - {"id": "RD-001", "type": "requirement", "title": "Affected", "status": "approved", "path": "artifact.md", "links": ["SPEC-001"]}, - {"id": "SPEC-001", "type": "specification", "title": "Affected spec", "status": "approved", "path": "artifact.md", "links": ["RD-001", "TASK-001"]}, - {"id": "TASK-001", "type": "task", "title": "Affected task", "status": "verified", "path": "artifact.md", "links": ["SPEC-001"]}, - ] - (feature / "traceability.json").write_text( - json.dumps({"schema": 1, "feature": "example", "updated": "2026-07-23", "nodes": nodes}), - encoding="utf-8", - ) - result = subprocess.run( - [sys.executable, str(SCRIPT), "readiness", "--root", str(root), "--json"], - text=True, capture_output=True, check=False, - ) - payload = json.loads(result.stdout) - joined = "\n".join(payload["problems"]) - self.assertEqual(result.returncode, 1) - self.assertIn("RD-001 must be marked stale", joined) - self.assertIn("SPEC-001 must be marked stale", joined) - self.assertIn("TASK-001 must be marked stale", joined) - - def test_unknown_status_cannot_produce_false_readiness(self) -> None: - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - feature = root / "codeops/features/example" - feature.mkdir(parents=True) - (feature / "artifact.md").write_text("# Evidence\n", encoding="utf-8") - nodes = [ - {"id": "RD-001", "type": "requirement", "title": "Bad state", "status": "finished-ish", "path": "artifact.md", "links": ["SPEC-001"]}, - {"id": "SPEC-001", "type": "specification", "title": "Spec", "status": "approved", "path": "artifact.md", "links": ["RD-001"]}, - ] - (feature / "traceability.json").write_text( - json.dumps({"schema": 1, "feature": "example", "nodes": nodes}), encoding="utf-8", - ) - result = subprocess.run( - [sys.executable, str(SCRIPT), "readiness", "--root", str(root), "--json"], - text=True, capture_output=True, check=False, - ) - payload = json.loads(result.stdout) - self.assertEqual(result.returncode, 1) - self.assertIn("finished-ish", "\n".join(payload["problems"])) - - def test_archived_graphs_and_tasks_do_not_pollute_live_state(self) -> None: - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - active = root / "codeops/features/live" - archived = root / "codeops/_archive/old" - active.mkdir(parents=True) - archived.mkdir(parents=True) - artifact = "# Artifact\n" - (active / "artifact.md").write_text(artifact, encoding="utf-8") - (archived / "artifact.md").write_text(artifact, encoding="utf-8") - active_nodes = [ - {"id": "RD-LIVE", "type": "requirement", "title": "Live", "status": "approved", "path": "artifact.md", "links": ["SPEC-LIVE"]}, - {"id": "SPEC-LIVE", "type": "specification", "title": "Live spec", "status": "approved", "path": "artifact.md", "links": ["RD-LIVE"]}, - ] - archived_nodes = [ - {"id": "RD-OLD", "type": "requirement", "title": "Old", "status": "draft", "path": "artifact.md", "links": []}, - ] - (active / "traceability.json").write_text( - json.dumps({"schema": 1, "feature": "live", "nodes": active_nodes}), encoding="utf-8", - ) - (archived / "traceability.json").write_text( - json.dumps({"schema": 1, "feature": "old", "nodes": archived_nodes}), encoding="utf-8", - ) - (active / "99-execution-plan.md").write_text("- [ ] live task\n", encoding="utf-8") - (archived / "99-execution-plan.md").write_text("- [x] 999 archived tasks\n", encoding="utf-8") - result = subprocess.run( - [sys.executable, str(SCRIPT), "status", "--root", str(root), "--json"], - text=True, capture_output=True, check=False, - ) - payload = json.loads(result.stdout) - self.assertEqual(result.returncode, 0, payload) - self.assertEqual(payload["graphs"], 1) - self.assertEqual(payload["nodes"], 2) - self.assertEqual(payload["tasks"], {"pending": 1, "implemented": 0, "verified": 0}) - - def test_status_succeeds_for_valid_but_not_ready_project(self) -> None: - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - feature = root / "codeops/features/draft-feature" - feature.mkdir(parents=True) - (feature / "artifact.md").write_text("# Draft\n", encoding="utf-8") - nodes = [ - { - "id": "RD-DRAFT", - "type": "requirement", - "title": "Draft requirement", - "status": "draft", - "path": "artifact.md", - "links": ["SPEC-DRAFT"], - }, - { - "id": "SPEC-DRAFT", - "type": "specification", - "title": "Draft specification", - "status": "draft", - "path": "artifact.md", - "links": ["RD-DRAFT"], - }, - ] - (feature / "traceability.json").write_text( - json.dumps({"schema": 1, "feature": "draft-feature", "nodes": nodes}), - encoding="utf-8", - ) - result = subprocess.run( - [sys.executable, str(SCRIPT), "status", "--root", str(root), "--json"], - text=True, - capture_output=True, - check=False, - ) - payload = json.loads(result.stdout) - self.assertEqual(result.returncode, 0, payload) - self.assertFalse(payload["ready"]) - self.assertIn("requirement RD-DRAFT is not approved", "\n".join(payload["problems"])) - - def test_status_still_fails_for_structurally_invalid_project(self) -> None: - code, payload = self.run_state("state-invalid", command="status") - self.assertEqual(code, 1, payload) - self.assertIn("links to missing node SPEC-MISSING", "\n".join(payload["problems"])) - - def test_feature_readiness_ignores_unrelated_draft_content(self) -> None: - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - for feature_name, status in ( - ("ready-feature", "approved"), - ("draft-feature", "draft"), - ): - feature = root / "codeops/features" / feature_name - feature.mkdir(parents=True) - (feature / "artifact.md").write_text("# Artifact\n", encoding="utf-8") - nodes = [ - { - "id": "RD-001", - "type": "requirement", - "title": "Requirement", - "status": status, - "path": "artifact.md", - "links": ["SPEC-001"], - }, - { - "id": "SPEC-001", - "type": "specification", - "title": "Specification", - "status": status, - "path": "artifact.md", - "links": ["RD-001"], - }, - ] - (feature / "traceability.json").write_text( - json.dumps({"schema": 1, "feature": feature_name, "nodes": nodes}), - encoding="utf-8", - ) - - global_result = subprocess.run( - [sys.executable, str(SCRIPT), "readiness", "--root", str(root), "--json"], - text=True, - capture_output=True, - check=False, - ) - scoped_result = subprocess.run( - [ - sys.executable, - str(SCRIPT), - "readiness", - "--root", - str(root), - "--feature", - "ready-feature", - "--json", - ], - text=True, - capture_output=True, - check=False, - ) - global_payload = json.loads(global_result.stdout) - scoped_payload = json.loads(scoped_result.stdout) - - self.assertEqual(global_result.returncode, 1, global_payload) - self.assertEqual(scoped_result.returncode, 0, scoped_payload) - self.assertEqual(scoped_payload["selected_feature"], "ready-feature") - self.assertEqual(scoped_payload["problems"], []) - self.assertNotIn("also exists", "\n".join(global_payload["problems"])) - - def test_unknown_feature_selector_fails_without_fallback(self) -> None: - code, payload = self.run_state("state-valid", feature="missing") - self.assertEqual(code, 1, payload) - self.assertEqual(payload["selected_feature"], None) - self.assertIn("feature not found: missing", "\n".join(payload["problems"])) - - def test_feature_selector_rejects_path_like_input(self) -> None: - code, payload = self.run_state("state-valid", feature="../ledger") - self.assertEqual(code, 1, payload) - self.assertEqual(payload["selected_feature"], None) - self.assertIn("feature selector is invalid", "\n".join(payload["problems"])) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/conformance/test_state_migration_impl.py b/tests/conformance/test_state_migration_impl.py deleted file mode 100644 index afa8803..0000000 --- a/tests/conformance/test_state_migration_impl.py +++ /dev/null @@ -1,660 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import hashlib -import json -import subprocess -import sys -import tempfile -import unittest -import shutil -from pathlib import Path -from unittest import mock - -from scripts.codeops_state_lib import migration, transitions -from scripts.codeops_state_lib.models import StructuralProblem -from scripts.codeops_state_lib.revisions import normalize_utf8 - - -ROOT = Path(__file__).resolve().parents[2] -SCRIPT = ROOT / "scripts" / "codeops_state.py" -ARTIFACT = b"# Artifact\n" -REVISION = "sha256:" + hashlib.sha256(ARTIFACT).hexdigest() -ABSENT_OWNER = { - "pid": 999999, - "startTicks": "1", - "bootId": Path("/proc/sys/kernel/random/boot_id").read_text().strip(), -} - - -def graph(status: str = "draft") -> dict[str, object]: - return { - "schema": 2, - "feature": "sample", - "nodes": [{ - "id": "RD-001", - "type": "requirement", - "title": "Requirement", - "status": status, - "semanticSources": [{ - "path": "artifact.md", - "selector": {"kind": "whole-file"}, - "normalization": "utf8-lf-trim-trailing-v1", - "digest": "sha256", - }], - "revision": REVISION, - "edges": [], - "validations": [], - }], - } - - -class TransitionImplementationTests(unittest.TestCase): - def make_root(self, raw: str) -> tuple[Path, Path]: - root = Path(raw) - (root / "artifact.md").write_bytes(ARTIFACT) - graph_path = root / "codeops" / "features" / "sample" / "traceability.json" - graph_path.parent.mkdir(parents=True) - graph_path.write_text(json.dumps(graph(), indent=2) + "\n", encoding="utf-8") - return root, graph_path - - def write_transition(self, root: Path, operation: str) -> Path: - request = root / f"{operation}.json" - request.write_text(json.dumps({ - "schema": 1, - "operationId": operation, - "target": "sample/RD-001", - "expected": {"status": "draft", "revision": REVISION}, - "requested": {"status": "approved"}, - "gate": "requirements", - "sourceUpdates": [], - "validationAdditions": [], - "validationRemovals": [], - "staleReason": None, - "evidence": {}, - }), encoding="utf-8") - return request - - def command(self, command: str, root: Path, request: Path) -> list[str]: - return [ - sys.executable, - str(SCRIPT), - command, - "--root", - str(root), - "--request", - str(request), - "--json", - ] - - def make_upgrade(self, raw: str) -> tuple[Path, Path, Path]: - root = Path(raw) - fixture = ROOT / "tests" / "fixtures" / "state-v1-upgrade" / "ambiguous" - shutil.copytree(fixture, root, dirs_exist_ok=True) - preview = root / "preview.json" - code, payload = migration.make_preview(root, "sample", preview) - self.assertEqual(code, 0, payload) - template = json.loads( - ( - ROOT - / "tests" - / "fixtures" - / "state-v1-upgrade" - / "resolutions-template.json" - ).read_text(encoding="utf-8") - ) - template["previewHash"] = payload["previewHash"] - resolutions = root / "resolutions.json" - resolutions.write_text(json.dumps(template), encoding="utf-8") - return root, preview, resolutions - - def test_st_37_concurrent_compare_and_swap_has_one_winner(self) -> None: - with tempfile.TemporaryDirectory() as raw: - root, graph_path = self.make_root(raw) - requests = [self.write_transition(root, f"op-{index}") for index in (1, 2)] - processes = [ - subprocess.Popen(self.command("transition", root, request), stdout=subprocess.PIPE, text=True) - for request in requests - ] - results = [] - for process in processes: - stdout, _ = process.communicate() - results.append((process.returncode, json.loads(stdout))) - committed = json.loads(graph_path.read_text(encoding="utf-8")) - self.assertEqual([code for code, _ in results].count(0), 1, results) - self.assertEqual(committed["nodes"][0]["status"], "approved") - - def test_existing_lock_is_never_silently_removed(self) -> None: - with tempfile.TemporaryDirectory() as raw: - root, _ = self.make_root(raw) - state = root / "codeops" / ".state-transactions" - state.mkdir() - active = state / "active.lock" - active.write_text('{"operationId":"prior"}', encoding="utf-8") - request = self.write_transition(root, "op-1") - result = subprocess.run(self.command("transition", root, request), capture_output=True, text=True) - payload = json.loads(result.stdout) - retained = active.read_bytes() - self.assertEqual(result.returncode, 1) - self.assertEqual(payload["blockers"][0]["code"], "transition-locked") - self.assertEqual(retained, b'{"operationId":"prior"}') - - def test_st_48_recovery_repeat_is_idempotent(self) -> None: - with tempfile.TemporaryDirectory() as raw: - root, graph_path = self.make_root(raw) - before = graph_path.read_bytes() - after = (json.dumps(graph("approved"), indent=2) + "\n").encode() - graph_path.write_bytes(after) - state = root / "codeops" / ".state-transactions" - state.mkdir() - (state / "op-1.before").write_bytes(before) - (state / "op-1.after").write_bytes(after) - (state / "op-1.lock").write_text( - json.dumps({"operationId": "op-1", "owner": ABSENT_OWNER, "nonce": "n-1"}), - encoding="utf-8", - ) - record = { - "path": str(graph_path.relative_to(root)), - "beforeHash": "sha256:" + hashlib.sha256(before).hexdigest(), - "afterHash": "sha256:" + hashlib.sha256(after).hexdigest(), - } - (state / "op-1.journal.json").write_text(json.dumps({ - "schema": 1, - "operationId": "op-1", - "lockNonce": "n-1", - "owner": ABSENT_OWNER, - "direction": None, - "graphs": [{ - **record, - "beforeImage": "op-1.before", - "afterImage": "op-1.after", - "committed": True, - }], - }), encoding="utf-8") - request = root / "recovery.json" - request.write_text(json.dumps({ - "schema": 1, - "operationId": "op-1", - "direction": "rollback", - "expectedLock": "n-1", - "expectedOwner": ABSENT_OWNER, - "graphs": [record], - }), encoding="utf-8") - first = subprocess.run(self.command("transition-recover", root, request), capture_output=True, text=True) - second = subprocess.run(self.command("transition-recover", root, request), capture_output=True, text=True) - restored = graph_path.read_bytes() - self.assertEqual(first.returncode, 0, first.stdout) - self.assertEqual(json.loads(first.stdout)["result"], "recovered") - self.assertEqual(second.returncode, 0, second.stdout) - self.assertEqual(json.loads(second.stdout)["result"], "already-recovered") - self.assertEqual(restored, before) - - def test_st_46_interrupted_journal_rolls_forward(self) -> None: - with tempfile.TemporaryDirectory() as raw: - root, graph_path = self.make_root(raw) - before = graph_path.read_bytes() - after = (json.dumps(graph("approved"), indent=2) + "\n").encode() - state = root / "codeops" / ".state-transactions" - state.mkdir() - (state / "op-1.before").write_bytes(before) - (state / "op-1.after").write_bytes(after) - (state / "op-1.lock").write_text( - json.dumps({"operationId": "op-1", "owner": ABSENT_OWNER, "nonce": "n-1"}), - encoding="utf-8", - ) - record = { - "path": str(graph_path.relative_to(root)), - "beforeHash": "sha256:" + hashlib.sha256(before).hexdigest(), - "afterHash": "sha256:" + hashlib.sha256(after).hexdigest(), - } - (state / "op-1.journal.json").write_text(json.dumps({ - "schema": 1, - "operationId": "op-1", - "lockNonce": "n-1", - "owner": ABSENT_OWNER, - "direction": None, - "graphs": [{ - **record, - "beforeImage": "op-1.before", - "afterImage": "op-1.after", - "committed": False, - }], - }), encoding="utf-8") - request = root / "recovery.json" - request.write_text(json.dumps({ - "schema": 1, - "operationId": "op-1", - "direction": "roll-forward", - "expectedLock": "n-1", - "expectedOwner": ABSENT_OWNER, - "graphs": [record], - }), encoding="utf-8") - result = subprocess.run( - self.command("transition-recover", root, request), - capture_output=True, - text=True, - ) - committed = graph_path.read_bytes() - self.assertEqual(result.returncode, 0, result.stdout) - self.assertEqual(json.loads(result.stdout)["result"], "recovered") - self.assertEqual(committed, after) - - def test_st_45_post_write_validation_restores_before_image(self) -> None: - with tempfile.TemporaryDirectory() as raw: - root, graph_path = self.make_root(raw) - before = graph_path.read_bytes() - request = self.write_transition(root, "op-1") - real_load = transitions._load_v2 - calls = 0 - - def fail_post_write( - project_root: Path, refresh_target: str | None = None - ): - nonlocal calls - calls += 1 - if calls == 1: - return real_load(project_root, refresh_target) - return [], [ - StructuralProblem( - "injected-post-write-failure", - "post-write validation failed", - graph_path, - ) - ] - - with mock.patch.object( - transitions, "_load_v2", side_effect=fail_post_write - ): - code, payload = transitions.transition(root, request) - restored = graph_path.read_bytes() - self.assertEqual(code, 1, payload) - self.assertEqual(payload["blockers"][0]["code"], "post-write-validation") - self.assertEqual(restored, before) - - def test_recovery_refuses_moved_or_escaping_graph(self) -> None: - with tempfile.TemporaryDirectory() as raw: - root, _ = self.make_root(raw) - state = root / "codeops" / ".state-transactions" - state.mkdir() - (state / "op-1.lock").write_text( - json.dumps({"operationId": "op-1", "owner": ABSENT_OWNER, "nonce": "n-1"}), encoding="utf-8" - ) - record = { - "path": "../outside.json", - "beforeHash": "sha256:" + ("0" * 64), - "afterHash": "sha256:" + ("1" * 64), - } - (state / "op-1.journal.json").write_text(json.dumps({ - "schema": 1, - "operationId": "op-1", - "lockNonce": "n-1", - "owner": ABSENT_OWNER, - "direction": None, - "graphs": [{ - **record, - "beforeImage": "op-1.before", - "afterImage": "op-1.after", - "committed": False, - }], - }), encoding="utf-8") - request = root / "recovery.json" - request.write_text(json.dumps({ - "schema": 1, - "operationId": "op-1", - "direction": "rollback", - "expectedLock": "n-1", - "expectedOwner": ABSENT_OWNER, - "graphs": [record], - }), encoding="utf-8") - result = subprocess.run(self.command("transition-recover", root, request), capture_output=True, text=True) - self.assertEqual(result.returncode, 1) - self.assertEqual(json.loads(result.stdout)["blockers"][0]["code"], "unsafe-recovery-path") - - def test_cross_platform_normalization_is_stable(self) -> None: - variants = (b"\xef\xbb\xbfline \r\nnext\t\r\n", b"line\nnext\n") - normalized = [normalize_utf8(value).encode() for value in variants] - self.assertEqual(normalized[0], normalized[1]) - self.assertEqual( - hashlib.sha256(normalized[0]).hexdigest(), - hashlib.sha256(normalized[1]).hexdigest(), - ) - - def test_operation_id_cannot_escape_transaction_directory(self) -> None: - with tempfile.TemporaryDirectory() as raw: - root, _ = self.make_root(raw) - request = self.write_transition(root, "safe") - payload = json.loads(request.read_text(encoding="utf-8")) - payload["operationId"] = "../escape" - request.write_text(json.dumps(payload), encoding="utf-8") - result = subprocess.run(self.command("transition", root, request), capture_output=True, text=True) - self.assertEqual(result.returncode, 1) - self.assertEqual(json.loads(result.stdout)["blockers"][0]["code"], "invalid-request") - - def test_revision_change_invalidates_downstream_graph_in_same_journal(self) -> None: - with tempfile.TemporaryDirectory() as raw: - root, upstream_path = self.make_root(raw) - upstream = json.loads(upstream_path.read_text(encoding="utf-8")) - upstream["nodes"][0]["status"] = "approved" - upstream_path.write_text(json.dumps(upstream, indent=2) + "\n", encoding="utf-8") - consumer_path = root / "codeops" / "features" / "consumer" / "traceability.json" - consumer_path.parent.mkdir(parents=True) - consumer = graph("approved") - consumer["feature"] = "consumer" - consumer["nodes"][0]["edges"] = [ - {"relation": "depends-on", "target": "sample/RD-001"} - ] - consumer["nodes"][0]["validations"] = [{ - "upstream": "sample/RD-001", - "relation": "depends-on", - "revision": REVISION, - "gate": "requirements", - "validatedAt": "2026-07-23T00:00:00Z", - }] - consumer_path.write_text(json.dumps(consumer, indent=2) + "\n", encoding="utf-8") - request = self.write_transition(root, "op-1") - payload = json.loads(request.read_text(encoding="utf-8")) - payload["expected"]["status"] = "approved" - payload["requested"]["status"] = "stale" - payload["staleReason"] = "semantic source changed" - payload["evidence"] = {"invalidation": "source digest changed"} - request.write_text(json.dumps(payload), encoding="utf-8") - result = subprocess.run( - self.command("transition", root, request), - capture_output=True, - text=True, - ) - response = json.loads(result.stdout) - downstream = json.loads(consumer_path.read_text(encoding="utf-8")) - self.assertEqual(result.returncode, 0, response) - self.assertEqual(len(response["graphs"]), 2) - self.assertEqual(downstream["nodes"][0]["status"], "stale") - - def test_transitive_invalidation_reaches_cross_graph_fixed_point(self) -> None: - with tempfile.TemporaryDirectory() as raw: - root, upstream_path = self.make_root(raw) - upstream = json.loads(upstream_path.read_text(encoding="utf-8")) - upstream["nodes"][0]["status"] = "approved" - upstream_path.write_text(json.dumps(upstream, indent=2) + "\n", encoding="utf-8") - prior = "sample/RD-001" - paths: list[Path] = [] - for feature in ("middle", "downstream"): - path = root / "codeops" / "features" / feature / "traceability.json" - path.parent.mkdir(parents=True) - value = graph("approved") - value["feature"] = feature - value["nodes"][0]["edges"] = [ - {"relation": "depends-on", "target": prior} - ] - path.write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8") - paths.append(path) - prior = f"{feature}/RD-001" - request = self.write_transition(root, "op-transitive") - payload = json.loads(request.read_text(encoding="utf-8")) - payload["expected"]["status"] = "approved" - payload["requested"]["status"] = "stale" - payload["staleReason"] = "upstream invalidated" - payload["evidence"] = {"invalidation": "upstream invalidated"} - request.write_text(json.dumps(payload), encoding="utf-8") - result = subprocess.run( - self.command("transition", root, request), - capture_output=True, - text=True, - ) - response = json.loads(result.stdout) - statuses = [ - json.loads(path.read_text(encoding="utf-8"))["nodes"][0]["status"] - for path in paths - ] - self.assertEqual(result.returncode, 0, response) - self.assertEqual(len(response["graphs"]), 3) - self.assertEqual(statuses, ["stale", "stale"]) - - def test_revision_refresh_repairs_only_targeted_mismatch(self) -> None: - with tempfile.TemporaryDirectory() as raw: - root, graph_path = self.make_root(raw) - value = json.loads(graph_path.read_text(encoding="utf-8")) - value["nodes"][0]["status"] = "approved" - graph_path.write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8") - (root / "artifact.md").write_text("# Changed\n", encoding="utf-8") - request = self.write_transition(root, "op-refresh") - payload = json.loads(request.read_text(encoding="utf-8")) - payload["expected"]["status"] = "approved" - payload["requested"]["status"] = "stale" - payload["sourceUpdates"] = [{"path": "artifact.md"}] - payload["staleReason"] = "semantic source changed" - payload["evidence"] = {"invalidation": "semantic source changed"} - request.write_text(json.dumps(payload), encoding="utf-8") - result = subprocess.run( - self.command("transition", root, request), - capture_output=True, - text=True, - ) - response = json.loads(result.stdout) - changed = json.loads(graph_path.read_text(encoding="utf-8")) - self.assertEqual(result.returncode, 0, response) - self.assertEqual(changed["nodes"][0]["status"], "stale") - self.assertEqual( - changed["nodes"][0]["revision"], - "sha256:" + hashlib.sha256(b"# Changed\n").hexdigest(), - ) - - def test_completed_operation_id_cannot_be_reused(self) -> None: - with tempfile.TemporaryDirectory() as raw: - root, graph_path = self.make_root(raw) - state = root / "codeops" / ".state-transactions" - state.mkdir() - (state / "op-1.completed.json").write_text( - json.dumps({ - "schema": 1, - "operationId": "op-1", - "direction": "rollback", - "graphs": [], - }), - encoding="utf-8", - ) - request = self.write_transition(root, "op-1") - before = graph_path.read_bytes() - result = subprocess.run( - self.command("transition", root, request), - capture_output=True, - text=True, - ) - response = json.loads(result.stdout) - after = graph_path.read_bytes() - self.assertEqual(result.returncode, 1) - self.assertEqual(response["blockers"][0]["code"], "operation-id-reused") - self.assertEqual(after, before) - - def test_upgrade_rejects_malformed_and_changed_resolutions(self) -> None: - with tempfile.TemporaryDirectory() as raw: - root, preview, resolutions = self.make_upgrade(raw) - graph = root / "codeops" / "features" / "sample" / "traceability.json" - before = graph.read_bytes() - malformed = json.loads(resolutions.read_text(encoding="utf-8")) - malformed["decisions"]["unknown:item"] = {"relation": "related"} - resolutions.write_text(json.dumps(malformed), encoding="utf-8") - code, payload = migration.apply_upgrade( - root, "sample", preview, resolutions - ) - after = graph.read_bytes() - self.assertEqual(code, 1, payload) - self.assertEqual(payload["blockers"][0]["code"], "incomplete-resolutions") - self.assertEqual(after, before) - - def test_upgrade_refuses_preview_source_path_escape(self) -> None: - with tempfile.TemporaryDirectory() as raw: - root, preview, resolutions = self.make_upgrade(raw) - graph = root / "codeops" / "features" / "sample" / "traceability.json" - value = json.loads(preview.read_text(encoding="utf-8")) - value["source"]["path"] = "../outside.json" - value["previewHash"] = migration._preview_hash(value) - preview.write_text(json.dumps(value), encoding="utf-8") - resolution_value = json.loads(resolutions.read_text(encoding="utf-8")) - resolution_value["previewHash"] = value["previewHash"] - resolutions.write_text(json.dumps(resolution_value), encoding="utf-8") - before = graph.read_bytes() - code, payload = migration.apply_upgrade( - root, "sample", preview, resolutions - ) - after = graph.read_bytes() - self.assertEqual(code, 1, payload) - self.assertEqual(payload["blockers"][0]["code"], "unsafe-upgrade-path") - self.assertEqual(after, before) - - def test_upgrade_backup_permission_failure_is_byte_identical(self) -> None: - with tempfile.TemporaryDirectory() as raw: - root, preview, resolutions = self.make_upgrade(raw) - graph = root / "codeops" / "features" / "sample" / "traceability.json" - before = graph.read_bytes() - real_open = transitions.os.open - - def fail_backup(path: str | Path, flags: int, mode: int = 0o777) -> int: - if Path(path).name == "traceability.schema1.backup.json": - raise PermissionError("backup denied") - return real_open(path, flags, mode) - - with mock.patch.object( - transitions.os, "open", side_effect=fail_backup - ): - code, payload = migration.apply_upgrade( - root, "sample", preview, resolutions - ) - after = graph.read_bytes() - self.assertEqual(code, 1, payload) - self.assertEqual(payload["blockers"][0]["code"], "backup-write") - self.assertEqual(after, before) - - def test_upgrade_rejects_self_rehashed_preview_tampering(self) -> None: - with tempfile.TemporaryDirectory() as raw: - root, preview, resolutions = self.make_upgrade(raw) - graph = root / "codeops" / "features" / "sample" / "traceability.json" - value = json.loads(preview.read_text(encoding="utf-8")) - value["preservedNodes"][0]["status"] = "draft" - value["previewHash"] = migration._preview_hash(value) - preview.write_text(json.dumps(value), encoding="utf-8") - resolution_value = json.loads(resolutions.read_text(encoding="utf-8")) - resolution_value["previewHash"] = value["previewHash"] - resolutions.write_text(json.dumps(resolution_value), encoding="utf-8") - before = graph.read_bytes() - code, payload = migration.apply_upgrade( - root, "sample", preview, resolutions - ) - after = graph.read_bytes() - self.assertEqual(code, 1, payload) - self.assertEqual(payload["blockers"][0]["code"], "changed-preview") - self.assertEqual(after, before) - - def test_preview_cannot_alias_live_graph_or_semantic_source(self) -> None: - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - fixture = ROOT / "tests" / "fixtures" / "state-v1-upgrade" / "ambiguous" - shutil.copytree(fixture, root, dirs_exist_ok=True) - graph = root / "codeops" / "features" / "sample" / "traceability.json" - artifact = root / "codeops" / "features" / "sample" / "artifact.md" - graph_before = graph.read_bytes() - artifact_before = artifact.read_bytes() - graph_code, graph_payload = migration.make_preview( - root, "sample", graph - ) - source_code, source_payload = migration.make_preview( - root, "sample", artifact - ) - graph_after = graph.read_bytes() - artifact_after = artifact.read_bytes() - self.assertEqual(graph_code, 1, graph_payload) - self.assertEqual(source_code, 1, source_payload) - self.assertEqual(graph_before, graph_after) - self.assertEqual(artifact_before, artifact_after) - - def test_idempotence_refuses_divergent_schema_two_destination(self) -> None: - with tempfile.TemporaryDirectory() as raw: - root, preview, resolutions = self.make_upgrade(raw) - code, payload = migration.apply_upgrade( - root, "sample", preview, resolutions - ) - self.assertEqual(code, 0, payload) - graph = root / "codeops" / "features" / "sample" / "traceability.json" - value = json.loads(graph.read_text(encoding="utf-8")) - value["nodes"][0]["title"] = "Diverged" - graph.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8") - code, payload = migration.apply_upgrade( - root, "sample", preview, resolutions - ) - self.assertEqual(code, 1, payload) - self.assertEqual(payload["blockers"][0]["code"], "divergent-destination") - - def test_upgrade_preserves_explicit_cross_feature_relation(self) -> None: - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - fixture = ROOT / "tests" / "fixtures" / "state-v1-upgrade" / "ambiguous" - shutil.copytree(fixture, root, dirs_exist_ok=True) - sample_graph = root / "codeops" / "features" / "sample" / "traceability.json" - sample = json.loads(sample_graph.read_text(encoding="utf-8")) - sample["nodes"][0]["links"].append("identity/RD-001") - sample_graph.write_text(json.dumps(sample, indent=2), encoding="utf-8") - (root / "artifact.md").write_bytes(ARTIFACT) - identity_path = root / "codeops" / "features" / "identity" / "traceability.json" - identity_path.parent.mkdir(parents=True) - identity = graph("approved") - identity["feature"] = "identity" - identity_path.write_text( - json.dumps(identity, indent=2) + "\n", encoding="utf-8" - ) - preview = root / "preview.json" - code, preview_payload = migration.make_preview( - root, "sample", preview - ) - self.assertEqual(code, 0, preview_payload) - template = json.loads( - ( - ROOT - / "tests" - / "fixtures" - / "state-v1-upgrade" - / "resolutions-template.json" - ).read_text(encoding="utf-8") - ) - template["previewHash"] = preview_payload["previewHash"] - template["decisions"]["edge:RD-001:identity/RD-001"] = { - "relation": "depends-on" - } - resolutions = root / "resolutions.json" - resolutions.write_text(json.dumps(template), encoding="utf-8") - code, payload = migration.apply_upgrade( - root, "sample", preview, resolutions - ) - migrated = json.loads(sample_graph.read_text(encoding="utf-8")) - self.assertEqual(code, 0, payload) - edges = migrated["nodes"][0]["edges"] - self.assertIn( - {"relation": "depends-on", "target": "identity/RD-001"}, edges - ) - - def test_prior_rollback_generation_does_not_block_new_apply_attempt(self) -> None: - with tempfile.TemporaryDirectory() as raw: - root, preview, resolutions = self.make_upgrade(raw) - preview_value = json.loads(preview.read_text(encoding="utf-8")) - state = root / "codeops" / ".state-transactions" - state.mkdir() - old_operation = ( - "upgrade-sample-" - + preview_value["previewHash"].split(":", 1)[-1][:16] - ) - (state / f"{old_operation}.completed.json").write_text( - json.dumps({ - "schema": 1, - "operationId": old_operation, - "direction": "rollback", - "graphs": [], - }), - encoding="utf-8", - ) - code, payload = migration.apply_upgrade( - root, "sample", preview, resolutions - ) - self.assertEqual(code, 0, payload) - self.assertEqual(payload["result"], "committed") - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/conformance/test_state_migration_spec.py b/tests/conformance/test_state_migration_spec.py deleted file mode 100644 index 2410b7d..0000000 --- a/tests/conformance/test_state_migration_spec.py +++ /dev/null @@ -1,468 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import hashlib -import json -import os -import subprocess -import sys -import tempfile -import unittest -import shutil -from pathlib import Path -from typing import Any - - -ROOT = Path(__file__).resolve().parents[2] -SCRIPT = ROOT / "scripts" / "codeops_state.py" -REVISION = "sha256:" + hashlib.sha256(b"# Artifact\n").hexdigest() - - -def requirement(status: str = "draft", *, edges: list[dict[str, Any]] | None = None) -> dict[str, Any]: - return { - "id": "RD-001", - "type": "requirement", - "title": "Requirement", - "status": status, - "semanticSources": [{ - "path": "artifact.md", - "selector": {"kind": "whole-file"}, - "normalization": "utf8-lf-trim-trailing-v1", - "digest": "sha256", - }], - "revision": REVISION, - "edges": edges or [], - "validations": [], - } - - -class TransitionSpecificationTests(unittest.TestCase): - def make_root(self, raw: str, nodes: list[dict[str, Any]]) -> tuple[Path, Path]: - root = Path(raw) - (root / "artifact.md").write_text("# Artifact\n", encoding="utf-8") - graph_root = root / "codeops" / "features" / "sample" - graph_root.mkdir(parents=True) - graph = graph_root / "traceability.json" - graph.write_text( - json.dumps({"schema": 2, "feature": "sample", "nodes": nodes}, indent=2) + "\n", - encoding="utf-8", - ) - return root, graph - - def request( - self, - root: Path, - *, - requested_status: str, - expected_status: str = "draft", - expected_revision: str = REVISION, - operation_id: str = "op-1", - ) -> Path: - path = root / "request.json" - path.write_text( - json.dumps({ - "schema": 1, - "operationId": operation_id, - "target": "sample/RD-001", - "expected": { - "status": expected_status, - "revision": expected_revision, - }, - "requested": {"status": requested_status}, - "gate": "requirements", - "sourceUpdates": [], - "validationAdditions": [], - "validationRemovals": [], - "staleReason": None, - "evidence": {}, - }), - encoding="utf-8", - ) - return path - - def run_command( - self, - command: str, - root: Path, - request: Path, - ) -> subprocess.CompletedProcess[str]: - return subprocess.run( - [ - sys.executable, - str(SCRIPT), - command, - "--root", - str(root), - "--request", - str(request), - "--json", - ], - text=True, - capture_output=True, - check=False, - ) - - def payload(self, result: subprocess.CompletedProcess[str]) -> dict[str, Any]: - self.assertTrue(result.stdout, result.stderr) - return json.loads(result.stdout) - - def run_upgrade( - self, - root: Path, - preview: Path, - *, - apply: bool = False, - resolutions: Path | None = None, - ) -> subprocess.CompletedProcess[str]: - command = [ - sys.executable, - str(SCRIPT), - "traceability-upgrade", - "--root", - str(root), - "--feature", - "sample", - "--preview", - str(preview), - "--json", - ] - if apply: - command.append("--apply") - if resolutions is not None: - command.extend(["--resolutions", str(resolutions)]) - return subprocess.run(command, text=True, capture_output=True, check=False) - - def upgrade_root(self, raw: str) -> Path: - source = ROOT / "tests" / "fixtures" / "state-v1-upgrade" / "ambiguous" - root = Path(raw) - shutil.copytree(source, root, dirs_exist_ok=True) - return root - - def test_legal_projected_approval_commits_atomically(self) -> None: - with tempfile.TemporaryDirectory() as raw: - root, graph = self.make_root(raw, [requirement()]) - request = self.request(root, requested_status="approved") - result = self.run_command("transition", root, request) - payload = self.payload(result) - after = json.loads(graph.read_text(encoding="utf-8")) - self.assertEqual(result.returncode, 0, payload) - self.assertEqual(payload["result"], "committed") - self.assertEqual(payload["target"], "sample/RD-001") - self.assertEqual(after["nodes"][0]["status"], "approved") - - def test_st_43_illegal_jump_is_byte_identical(self) -> None: - with tempfile.TemporaryDirectory() as raw: - root, graph = self.make_root(raw, [requirement()]) - before = graph.read_bytes() - request = self.request(root, requested_status="stale") - result = self.run_command("transition", root, request) - payload = self.payload(result) - after = graph.read_bytes() - self.assertEqual(result.returncode, 1) - self.assertEqual(before, after) - self.assertIn("invalid-transition", {item["code"] for item in payload["blockers"]}) - - def test_compare_and_swap_mismatch_does_not_write(self) -> None: - with tempfile.TemporaryDirectory() as raw: - root, graph = self.make_root(raw, [requirement()]) - before = graph.read_bytes() - request = self.request( - root, - requested_status="approved", - expected_revision="sha256:" + ("1" * 64), - ) - result = self.run_command("transition", root, request) - payload = self.payload(result) - after = graph.read_bytes() - self.assertEqual(result.returncode, 1) - self.assertEqual(before, after) - self.assertIn("compare-and-swap", {item["code"] for item in payload["blockers"]}) - - def test_st_49_invalid_projected_dependency_does_not_write(self) -> None: - dependency = requirement(status="draft") - dependency["id"] = "RD-UP" - target = requirement(edges=[{"relation": "depends-on", "target": "sample/RD-UP"}]) - with tempfile.TemporaryDirectory() as raw: - root, graph = self.make_root(raw, [target, dependency]) - before = graph.read_bytes() - request = self.request(root, requested_status="approved") - result = self.run_command("transition", root, request) - payload = self.payload(result) - after = graph.read_bytes() - self.assertEqual(result.returncode, 1) - self.assertEqual(before, after) - self.assertIn("status-not-ready", {item["code"] for item in payload["blockers"]}) - - def test_caller_cannot_replace_governing_gate_with_audit(self) -> None: - with tempfile.TemporaryDirectory() as raw: - root, graph = self.make_root(raw, [requirement()]) - request = self.request(root, requested_status="approved") - payload = json.loads(request.read_text(encoding="utf-8")) - payload["gate"] = "audit" - request.write_text(json.dumps(payload), encoding="utf-8") - before = graph.read_bytes() - result = self.run_command("transition", root, request) - response = self.payload(result) - after = graph.read_bytes() - self.assertEqual(result.returncode, 1) - self.assertEqual(response["blockers"][0]["code"], "invalid-transition-gate") - self.assertEqual(after, before) - - def test_evidence_transitions_support_test_and_task_lifecycle(self) -> None: - test_node = requirement(status="planned") - test_node.update({"id": "TEST-1", "type": "test"}) - implementation = requirement(status="present") - implementation.update({"id": "IMPL-1", "type": "implementation"}) - verification = requirement(status="planned") - verification.update({"id": "VERIFY-1", "type": "verification"}) - task = requirement(status="pending") - task.update({ - "id": "TASK-1", - "type": "task", - }) - criterion = requirement(status="approved") - criterion.update({ - "id": "CRIT-1", - "type": "criterion", - "edges": [ - {"relation": "tested-by", "target": "sample/TEST-1"}, - {"relation": "implemented-by", "target": "sample/TASK-1"}, - {"relation": "implemented-by", "target": "sample/IMPL-1"}, - {"relation": "verified-by", "target": "sample/VERIFY-1"}, - ], - "validations": [{ - "upstream": "sample/TEST-1", - "relation": "tested-by", - "revision": REVISION, - "gate": "task-complete", - "validatedAt": "2026-07-23T00:00:00Z", - }, { - "upstream": "sample/TASK-1", - "relation": "implemented-by", - "revision": REVISION, - "gate": "task-complete", - "validatedAt": "2026-07-23T00:00:00Z", - }] - }) - with tempfile.TemporaryDirectory() as raw: - root, graph = self.make_root( - raw, [criterion, task, test_node, implementation, verification] - ) - - def advance( - operation: str, - target: str, - expected_status: str, - requested_status: str, - gate: str, - evidence: dict[str, str], - ) -> subprocess.CompletedProcess[str]: - request = self.request( - root, - requested_status="approved", - operation_id=operation, - ) - payload = json.loads(request.read_text(encoding="utf-8")) - payload["target"] = target - payload["expected"]["status"] = expected_status - payload["requested"]["status"] = requested_status - payload["gate"] = gate - payload["evidence"] = evidence - request.write_text(json.dumps(payload), encoding="utf-8") - return self.run_command("transition", root, request) - - results = [ - advance( - "op-red", - "sample/TEST-1", - "planned", - "red-confirmed", - "task-complete", - {"redEvidence": "expected failing test"}, - ), - advance( - "op-green", - "sample/TEST-1", - "red-confirmed", - "passing", - "task-complete", - {"greenEvidence": "passing test command"}, - ), - advance( - "op-impl-verified", - "sample/IMPL-1", - "present", - "verified", - "task-complete", - {"verificationEvidence": "implementation verification"}, - ), - advance( - "op-verification-passing", - "sample/VERIFY-1", - "planned", - "passing", - "task-complete", - {"commandEvidence": "verification command passed"}, - ), - advance( - "op-implemented", - "sample/TASK-1", - "pending", - "implemented", - "plan", - {}, - ), - advance( - "op-verified", - "sample/TASK-1", - "implemented", - "verified", - "task-complete", - {"verificationEvidence": "verified test closure"}, - ), - ] - final = json.loads(graph.read_text(encoding="utf-8")) - self.assertEqual( - [result.returncode for result in results], - [0, 0, 0, 0, 0, 0], - ) - statuses = {item["id"]: item["status"] for item in final["nodes"]} - self.assertEqual(statuses["TASK-1"], "verified") - self.assertEqual(statuses["TEST-1"], "passing") - self.assertEqual(statuses["IMPL-1"], "verified") - self.assertEqual(statuses["VERIFY-1"], "passing") - - def test_st_27_preview_requires_complete_explicit_resolutions(self) -> None: - with tempfile.TemporaryDirectory() as raw: - root = self.upgrade_root(raw) - preview = root / "preview.json" - before = ( - root / "codeops" / "features" / "sample" / "traceability.json" - ).read_bytes() - preview_result = self.run_upgrade(root, preview) - preview_payload = self.payload(preview_result) - after_preview = ( - root / "codeops" / "features" / "sample" / "traceability.json" - ).read_bytes() - incomplete = root / "incomplete.json" - incomplete.write_text( - json.dumps({ - "schema": 1, - "previewHash": preview_payload["previewHash"], - "decisions": {}, - }), - encoding="utf-8", - ) - apply_result = self.run_upgrade( - root, preview, apply=True, resolutions=incomplete - ) - self.assertEqual(preview_result.returncode, 0, preview_result.stderr) - self.assertEqual(before, after_preview) - self.assertEqual(apply_result.returncode, 1) - - def test_st_39_resolved_apply_is_valid_and_idempotent(self) -> None: - with tempfile.TemporaryDirectory() as raw: - root = self.upgrade_root(raw) - preview = root / "preview.json" - preview_result = self.run_upgrade(root, preview) - preview_payload = self.payload(preview_result) - template = json.loads( - ( - ROOT - / "tests" - / "fixtures" - / "state-v1-upgrade" - / "resolutions-template.json" - ).read_text(encoding="utf-8") - ) - template["previewHash"] = preview_payload["previewHash"] - resolutions = root / "resolutions.json" - resolutions.write_text(json.dumps(template), encoding="utf-8") - first = self.run_upgrade( - root, preview, apply=True, resolutions=resolutions - ) - second = self.run_upgrade( - root, preview, apply=True, resolutions=resolutions - ) - graph = json.loads( - ( - root - / "codeops" - / "features" - / "sample" - / "traceability.json" - ).read_text(encoding="utf-8") - ) - self.assertEqual(first.returncode, 0, first.stderr) - self.assertEqual(second.returncode, 0, second.stderr) - self.assertEqual(self.payload(second)["result"], "no-change") - self.assertEqual(graph["schema"], 2) - - def test_st_47_non_identical_backup_collision_is_refused(self) -> None: - with tempfile.TemporaryDirectory() as raw: - root = self.upgrade_root(raw) - graph = ( - root / "codeops" / "features" / "sample" / "traceability.json" - ) - backup = graph.with_name("traceability.schema1.backup.json") - backup.write_text('{"different":true}\n', encoding="utf-8") - preview = root / "preview.json" - preview_result = self.run_upgrade(root, preview) - preview_payload = self.payload(preview_result) - template = json.loads( - ( - ROOT - / "tests" - / "fixtures" - / "state-v1-upgrade" - / "resolutions-template.json" - ).read_text(encoding="utf-8") - ) - template["previewHash"] = preview_payload["previewHash"] - resolutions = root / "resolutions.json" - resolutions.write_text(json.dumps(template), encoding="utf-8") - before = graph.read_bytes() - result = self.run_upgrade( - root, preview, apply=True, resolutions=resolutions - ) - after = graph.read_bytes() - self.assertEqual(result.returncode, 1) - self.assertEqual(after, before) - - def test_st_44_recovery_refuses_when_owner_absence_is_unproven(self) -> None: - with tempfile.TemporaryDirectory() as raw: - root, graph = self.make_root(raw, [requirement()]) - state = root / "codeops" / ".state-transactions" - state.mkdir() - owner = { - "pid": os.getpid(), - "startTicks": Path(f"/proc/{os.getpid()}/stat").read_text().split()[21], - "bootId": Path("/proc/sys/kernel/random/boot_id").read_text().strip(), - } - (state / "op-1.lock").write_text( - json.dumps({"operationId": "op-1", "owner": owner, "nonce": "lock-1"}), - encoding="utf-8", - ) - recovery = root / "recovery.json" - recovery.write_text( - json.dumps({ - "schema": 1, - "operationId": "op-1", - "direction": "rollback", - "expectedLock": "lock-1", - "expectedOwner": owner, - "graphs": [], - }), - encoding="utf-8", - ) - before = graph.read_bytes() - result = self.run_command("transition-recover", root, recovery) - payload = self.payload(result) - after = graph.read_bytes() - self.assertEqual(result.returncode, 1) - self.assertEqual(payload["result"], "refused") - self.assertEqual(before, after) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/conformance/test_state_test_collection.py b/tests/conformance/test_state_test_collection.py deleted file mode 100755 index 85fc349..0000000 --- a/tests/conformance/test_state_test_collection.py +++ /dev/null @@ -1,54 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import importlib -import unittest - - -DECLARED_MODULES = ( - "tests.conformance.test_state_v1_compat_spec", - "tests.conformance.test_state_v2_spec", - "tests.conformance.test_state_v2_impl", - "tests.conformance.test_state_migration_spec", - "tests.conformance.test_state_migration_impl", - "tests.conformance.test_targeted_workflows_spec", - "tests.conformance.test_targeted_workflows_impl", - "tests.conformance.test_auto_design_spec", - "tests.conformance.test_auto_design_impl", -) - -IMPLEMENTED_ST_CASES = set(range(1, 50)) - - -def flatten(suite: unittest.TestSuite): - for item in suite: - if isinstance(item, unittest.TestSuite): - yield from flatten(item) - else: - yield item - - -class StateTestCollectionGuard(unittest.TestCase): - def test_every_declared_module_imports_and_collects(self) -> None: - loader = unittest.defaultTestLoader - for module_name in DECLARED_MODULES: - module = importlib.import_module(module_name) - tests = list(flatten(loader.loadTestsFromModule(module))) - self.assertTrue(tests, f"{module_name} collected no tests") - - def test_implemented_st_cases_have_exactly_one_specification_test(self) -> None: - names = [] - for module_name in DECLARED_MODULES: - module = importlib.import_module(module_name) - names.extend( - test._testMethodName - for test in flatten(unittest.defaultTestLoader.loadTestsFromModule(module)) - ) - for case in IMPLEMENTED_ST_CASES: - matches = [name for name in names if name.startswith(f"test_st_{case}_")] - self.assertEqual(matches, [matches[0]] if matches else [], f"ST-{case} ownership") - self.assertEqual(len(matches), 1, f"ST-{case} must have exactly one implementation") - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/conformance/test_state_v1_compat_spec.py b/tests/conformance/test_state_v1_compat_spec.py deleted file mode 100644 index 025422a..0000000 --- a/tests/conformance/test_state_v1_compat_spec.py +++ /dev/null @@ -1,140 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import json -import shutil -import subprocess -import sys -import tempfile -import unittest -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[2] -SCRIPT = ROOT / "scripts" / "codeops_state.py" -VALID_FIXTURE = ROOT / "tests" / "fixtures" / "state-valid" - - -class SchemaOneCompatibilityTests(unittest.TestCase): - def run_state( - self, - root: Path, - command: str, - *, - feature: str | None = None, - as_json: bool = True, - ) -> subprocess.CompletedProcess[str]: - args = [sys.executable, str(SCRIPT), command, "--root", str(root)] - if feature is not None: - args.extend(["--feature", feature]) - if as_json: - args.append("--json") - return subprocess.run(args, text=True, capture_output=True, check=False) - - def test_st_25_ready_graph_json_contract(self) -> None: - result = self.run_state(VALID_FIXTURE, "readiness") - payload = json.loads(result.stdout) - - self.assertEqual(result.returncode, 0, result.stderr) - self.assertTrue(payload["ready"]) - self.assertIsNone(payload["selected_feature"]) - self.assertEqual(payload["graphs"], 1) - self.assertEqual(payload["nodes"], 8) - self.assertEqual(payload["problems"], []) - self.assertEqual(payload["tasks"], {"pending": 0, "implemented": 0, "verified": 0}) - self.assertEqual( - payload["features"], - [{"feature": "ledger", "lifecycle": "complete", "nodes": 8, "ready": True}], - ) - - def test_feature_scoped_readiness_contract(self) -> None: - result = self.run_state(VALID_FIXTURE, "readiness", feature="ledger") - payload = json.loads(result.stdout) - - self.assertEqual(result.returncode, 0, result.stderr) - self.assertTrue(payload["ready"]) - self.assertEqual(payload["selected_feature"], "ledger") - self.assertEqual(payload["problems"], []) - - def test_ready_graph_human_output_contract(self) -> None: - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) / "state-valid" - shutil.copytree(VALID_FIXTURE, root) - result = self.run_state(root, "readiness", feature="ledger", as_json=False) - - self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual( - result.stdout.splitlines(), - [ - "CodeOps graphs: 1 | nodes: 8", - "Readiness scope: ledger", - "Tasks: 0 pending | 0 implemented | 0 verified", - "Feature ledger: complete | ready", - "READY", - ], - ) - - def test_status_observes_valid_but_not_ready_state(self) -> None: - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - feature = root / "codeops" / "features" / "draft" - feature.mkdir(parents=True) - (feature / "requirement.md").write_text("# Draft\n", encoding="utf-8") - graph = { - "schema": 1, - "feature": "draft", - "nodes": [ - { - "id": "RD-001", - "type": "requirement", - "title": "Draft requirement", - "status": "draft", - "path": "requirement.md", - "links": ["SPEC-001"], - }, - { - "id": "SPEC-001", - "type": "specification", - "title": "Draft specification", - "status": "draft", - "path": "requirement.md", - "links": ["RD-001"], - }, - ], - } - (feature / "traceability.json").write_text( - json.dumps(graph), - encoding="utf-8", - ) - - result = self.run_state(root, "status", feature="draft") - payload = json.loads(result.stdout) - - self.assertEqual(result.returncode, 0, result.stderr) - self.assertFalse(payload["ready"]) - self.assertEqual(payload["selected_feature"], "draft") - self.assertIn("requirement RD-001 is not approved", "\n".join(payload["problems"])) - self.assertIn("specification SPEC-001 is not approved", "\n".join(payload["problems"])) - - def test_unknown_feature_fails_without_fallback(self) -> None: - result = self.run_state(VALID_FIXTURE, "readiness", feature="missing") - payload = json.loads(result.stdout) - - self.assertEqual(result.returncode, 1) - self.assertFalse(payload["ready"]) - self.assertIsNone(payload["selected_feature"]) - self.assertIn("feature not found: missing", "\n".join(payload["problems"])) - - def test_validate_rejects_feature_selector(self) -> None: - result = self.run_state(VALID_FIXTURE, "validate", feature="ledger") - payload = json.loads(result.stdout) - - self.assertEqual(result.returncode, 1) - self.assertIn( - "--feature is valid only for readiness or status", - "\n".join(payload["problems"]), - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/conformance/test_state_v2_impl.py b/tests/conformance/test_state_v2_impl.py deleted file mode 100755 index 82e45d2..0000000 --- a/tests/conformance/test_state_v2_impl.py +++ /dev/null @@ -1,887 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import json -import hashlib -import subprocess -import sys -import shutil -import tempfile -import unittest -from pathlib import Path -from typing import Any - -from scripts.codeops_state_lib.models import Edge, Node -from scripts.codeops_state_lib.closure import build_closure -from scripts.codeops_state_lib.discovery import discover_graphs -from scripts.codeops_state_lib.schema import parse_graph_v2, validate_portfolio_v2 - - -ROOT = Path(__file__).resolve().parents[2] -SCRIPT = ROOT / "scripts" / "codeops_state.py" -REVISION = "sha256:" + hashlib.sha256(b"# Artifact\n").hexdigest() - - -def base_node(node_id: str, node_type: str, **extra: Any) -> dict[str, Any]: - value = { - "id": node_id, - "type": node_type, - "title": node_id, - "status": "approved", - "semanticSources": [ - { - "path": "artifact.md", - "selector": {"kind": "whole-file"}, - "normalization": "utf8-lf-trim-trailing-v1", - "digest": "sha256", - } - ], - "revision": REVISION, - "edges": [], - "validations": [], - } - value.update(extra) - return value - - -class SchemaTwoImplementationTests(unittest.TestCase): - def run_cli( - self, - root: Path, - command: str, - *args: str, - ) -> tuple[int, dict[str, Any]]: - result = subprocess.run( - [sys.executable, str(SCRIPT), command, "--root", str(root), *args, "--json"], - text=True, - capture_output=True, - check=False, - ) - return result.returncode, json.loads(result.stdout) - - def parse( - self, - nodes: list[dict[str, Any]], - *, - artifact: str = "# Artifact\n", - ) -> list[str]: - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - (root / "artifact.md").write_text(artifact, encoding="utf-8") - feature = root / "codeops" / "features" / "sample" - feature.mkdir(parents=True) - path = feature / "traceability.json" - path.write_text( - json.dumps({"schema": 2, "feature": "sample", "nodes": nodes}), - encoding="utf-8", - ) - graph, problems = parse_graph_v2(path, root) - if graph is not None: - problems.extend(validate_portfolio_v2([graph])) - return [f"{problem.code}: {problem.message}" for problem in problems] - - def test_illegal_relation_direction_is_rejected(self) -> None: - problems = self.parse( - [ - base_node( - "SPEC-001", - "specification", - edges=[{"relation": "specified-by", "target": "sample/RD-001"}], - ), - base_node("RD-001", "requirement"), - ] - ) - self.assertIn("illegal-edge", "\n".join(problems)) - - def test_self_and_duplicate_edges_are_rejected(self) -> None: - edge = {"relation": "depends-on", "target": "sample/RD-001"} - duplicate = self.parse([base_node("RD-001", "requirement", edges=[edge, edge])]) - self.assertIn("duplicate-edge", "\n".join(duplicate)) - self_edge = self.parse([base_node("RD-001", "requirement", edges=[edge])]) - self.assertIn("self-edge", "\n".join(self_edge)) - - def test_duplicate_identity_is_canonical(self) -> None: - problems = self.parse( - [base_node("RD-001", "requirement"), base_node("RD-001", "requirement")] - ) - self.assertIn("duplicate canonical identity sample/RD-001", "\n".join(problems)) - - def test_aggregate_members_are_sorted_unique_and_resolved(self) -> None: - problems = self.parse( - [ - base_node( - "SET-001", - "requirement-set", - members=["sample/RD-002", "sample/RD-001", "sample/RD-001"], - ), - base_node("RD-001", "requirement"), - ] - ) - rendered = "\n".join(problems) - self.assertIn("invalid-members", rendered) - self.assertIn("missing member sample/RD-002", rendered) - - def test_only_contract_may_carry_maturity(self) -> None: - problems = self.parse([base_node("RD-001", "requirement", maturity="stable")]) - self.assertIn("may not carry maturity", "\n".join(problems)) - - def test_cross_group_cycle_has_stable_path(self) -> None: - nodes = [ - base_node( - "RD-A", - "requirement", - edges=[{"relation": "depends-on", "target": "sample/RD-B"}], - ), - base_node( - "RD-B", - "requirement", - edges=[{"relation": "depends-on", "target": "sample/RD-A"}], - ), - ] - first = self.parse(nodes) - second = self.parse(nodes) - self.assertEqual(first, second) - self.assertIn( - "sample/RD-A -> sample/RD-B -> sample/RD-A", - "\n".join(first), - ) - - def test_heading_selector_requires_a_value(self) -> None: - item = base_node("RD-001", "requirement") - item["semanticSources"][0]["selector"] = {"kind": "heading"} - problems = self.parse([item]) - self.assertIn("invalid-source", "\n".join(problems)) - - def test_heading_selector_must_match_exactly_once(self) -> None: - item = base_node( - "RD-001", - "requirement", - revision="sha256:" + hashlib.sha256(b"## Unique\nBody\n").hexdigest(), - ) - item["semanticSources"][0]["selector"] = {"kind": "heading", "value": "Unique"} - missing = self.parse([item], artifact="# Different\n") - duplicate = self.parse( - [item], - artifact="# Root\n## Unique\nOne\n## Unique\nTwo\n", - ) - valid = self.parse([item], artifact="# Root\n## Unique\nBody\n## Next\n") - self.assertIn("source-selection", "\n".join(missing)) - self.assertIn("source-selection", "\n".join(duplicate)) - self.assertEqual(valid, []) - - def test_heading_selector_ignores_backtick_and_tilde_fences(self) -> None: - item = base_node( - "RD-001", - "requirement", - revision="sha256:" + hashlib.sha256(b"## Unique\nBody\n").hexdigest(), - ) - item["semanticSources"][0]["selector"] = {"kind": "heading", "value": "Unique"} - for marker in ("```", "~~~"): - with self.subTest(marker=marker): - artifact = ( - f"# Root\n{marker}text\n## Unique\nfake\n{marker}\n" - "## Unique\nBody\n## Next\n" - ) - self.assertEqual(self.parse([item], artifact=artifact), []) - - def test_heading_selector_accepts_up_to_three_spaces_of_indent(self) -> None: - item = base_node( - "RD-001", - "requirement", - revision="sha256:" + hashlib.sha256(b" ## Unique\nBody\n").hexdigest(), - ) - item["semanticSources"][0]["selector"] = {"kind": "heading", "value": "Unique"} - self.assertEqual( - self.parse([item], artifact="# Root\n ## Unique\nBody\n## Next\n"), - [], - ) - - def test_fence_marker_with_text_does_not_close_the_fence(self) -> None: - item = base_node( - "RD-001", - "requirement", - revision="sha256:" + hashlib.sha256(b"## Unique\nReal\n").hexdigest(), - ) - item["semanticSources"][0]["selector"] = {"kind": "heading", "value": "Unique"} - for marker in ("```", "~~~"): - with self.subTest(marker=marker): - artifact = ( - f"# Root\n{marker}text\n{marker}not-a-close\n" - f"## Unique\nFake\n{marker}\n## Unique\nReal\n## Next\n" - ) - self.assertEqual(self.parse([item], artifact=artifact), []) - - def test_source_path_cannot_escape_project_root(self) -> None: - item = base_node("RD-001", "requirement") - item["semanticSources"][0]["path"] = "../../../../outside.md" - problems = self.parse([item]) - self.assertIn("path-escape", "\n".join(problems)) - - def test_release_and_audit_conditional_fields_are_required(self) -> None: - release = base_node("RELEASE-1", "release") - audit = base_node("AUDIT-1", "audit-artifact") - problems = self.parse([release, audit]) - rendered = "\n".join(problems) - self.assertIn("missing-release-members", rendered) - self.assertIn("invalid-audit-stage", rendered) - - def test_release_membership_arrays_reject_duplicates(self) -> None: - for field_name in ("required", "optional", "excluded"): - with self.subTest(field=field_name): - member = base_node("RD-001", "requirement") - release = base_node( - "RELEASE-1", - "release", - required=[], - optional=[], - excluded=[], - ) - release[field_name] = ["sample/RD-001", "sample/RD-001"] - problems = self.parse([member, release]) - self.assertIn("duplicate-release-member", "\n".join(problems)) - - def test_contract_vocabularies_and_uniqueness_are_enforced(self) -> None: - item = base_node( - "RD-001", - "requirement", - risk="urgent", - evidence=["proof.txt", "proof.txt"], - ) - rendered = "\n".join(self.parse([item])) - self.assertIn("invalid-risk", rendered) - self.assertIn("invalid-evidence", rendered) - - def test_reverse_symmetric_relationship_is_rejected(self) -> None: - problems = self.parse( - [ - base_node( - "RD-A", - "requirement", - edges=[{"relation": "related", "target": "sample/RD-B"}], - ), - base_node( - "RD-B", - "requirement", - edges=[{"relation": "related", "target": "sample/RD-A"}], - ), - ] - ) - self.assertIn("redundant-relationship", "\n".join(problems)) - - def test_member_gates_are_immutable_after_parsing(self) -> None: - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - (root / "artifact.md").write_text("# Artifact\n", encoding="utf-8") - feature = root / "codeops" / "features" / "sample" - feature.mkdir(parents=True) - member = base_node("TASK-1", "task", status="verified") - aggregate = base_node( - "FEATURE-1", - "feature", - members=["sample/TASK-1"], - memberGates={"sample/TASK-1": "task-complete"}, - ) - path = feature / "traceability.json" - path.write_text( - json.dumps({"schema": 2, "feature": "sample", "nodes": [member, aggregate]}), - encoding="utf-8", - ) - graph, problems = parse_graph_v2(path, root) - self.assertEqual(problems, []) - assert graph is not None - parsed = graph.by_identity()["sample/FEATURE-1"] - with self.assertRaises(TypeError): - parsed.member_gates["sample/TASK-1"] = "release" # type: ignore[index] - - def test_direct_node_construction_defensively_freezes_member_gates(self) -> None: - source = {"sample/TASK-1": "task-complete"} - parsed = Node( - "sample", - "FEATURE-1", - "feature", - "Feature", - "approved", - (), - REVISION, - (), - (), - members=("sample/TASK-1",), - member_gates=source, - ) - source["sample/TASK-1"] = "release" - self.assertEqual(parsed.member_gates["sample/TASK-1"], "task-complete") - with self.assertRaises(TypeError): - parsed.member_gates["sample/TASK-1"] = "release" # type: ignore[index] - - def test_invalid_utf8_source_is_a_structural_problem(self) -> None: - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - (root / "artifact.md").write_bytes(b"\xff\xfe") - feature = root / "codeops" / "features" / "sample" - feature.mkdir(parents=True) - path = feature / "traceability.json" - path.write_text( - json.dumps( - { - "schema": 2, - "feature": "sample", - "nodes": [base_node("RD-001", "requirement")], - } - ), - encoding="utf-8", - ) - graph, problems = parse_graph_v2(path, root) - self.assertIsNotNone(graph) - self.assertIn("source-not-utf8", "\n".join(problem.code for problem in problems)) - - def test_dependency_closure_is_sorted_with_shortest_paths(self) -> None: - nodes = { - identity: Node( - "sample", - identity.split("/", 1)[1], - "requirement", - identity, - "approved", - (), - REVISION, - tuple(edges), - (), - ) - for identity, edges in { - "sample/RD-A": ( - Edge("depends-on", "sample/RD-C"), - Edge("depends-on", "sample/RD-B"), - ), - "sample/RD-B": ( - Edge("depends-on", "sample/RD-C"), - ), - "sample/RD-C": (), - }.items() - } - closure = build_closure("sample/RD-A", "requirements", nodes) - self.assertEqual( - closure.members, - ("sample/RD-A", "sample/RD-B", "sample/RD-C"), - ) - self.assertEqual( - closure.paths["sample/RD-C"], - ("sample/RD-A", "sample/RD-C"), - ) - - def test_repository_discovery_excludes_fixture_graphs(self) -> None: - discovered = discover_graphs(ROOT) - self.assertEqual(len(discovered), 1) - self.assertEqual( - discovered[0].relative_to(ROOT).as_posix(), - "codeops/features/dependency-aware-readiness/traceability.json", - ) - fixture = ROOT / "tests" / "fixtures" / "state-v2-cross-feature" - self.assertEqual(len(discover_graphs(fixture)), 2) - - def test_unrelated_semantic_error_is_diagnostic_not_blocker(self) -> None: - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - (root / "artifact.md").write_text("# Artifact\n", encoding="utf-8") - for feature in ("valid", "unrelated"): - (root / "codeops" / "features" / feature).mkdir(parents=True) - valid = {"schema": 2, "feature": "valid", "nodes": [base_node("RD-001", "requirement")]} - unrelated_node = base_node( - "RD-002", - "requirement", - edges=[{"relation": "depends-on", "target": "unrelated/RD-MISSING"}], - ) - unrelated = {"schema": 2, "feature": "unrelated", "nodes": [unrelated_node]} - for feature, graph in (("valid", valid), ("unrelated", unrelated)): - (root / "codeops" / "features" / feature / "traceability.json").write_text( - json.dumps(graph), encoding="utf-8" - ) - code, payload = self.run_cli( - root, "readiness", "--gate", "requirements", "--target", "valid/RD-001" - ) - self.assertEqual(code, 0, payload) - self.assertEqual(payload["blockers"], []) - self.assertIn("dangling-edge", {item["code"] for item in payload["diagnostics"]}) - - def test_entered_invalid_graph_is_blocking_with_real_path(self) -> None: - fixture = ROOT / "tests" / "fixtures" / "state-v2-cross-feature" - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - shutil.copytree(fixture, root, dirs_exist_ok=True) - identity = root / "codeops" / "features" / "identity" / "traceability.json" - identity.write_text('{"schema":2,"feature":"identity","nodes":"invalid"}', encoding="utf-8") - code, payload = self.run_cli( - root, "readiness", "--gate", "requirements", "--target", "accounting/RD-001" - ) - self.assertEqual(code, 1) - self.assertIn("invalid-nodes", {item["code"] for item in payload["blockers"]}) - self.assertIn( - ["accounting/RD-001", "identity/RD-001"], - [item.get("path") for item in payload["blockers"]], - ) - - def test_st_26_mixed_schema_is_reported_and_schema1_target_requires_upgrade(self) -> None: - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - (root / "artifact.md").write_text("# Artifact\n", encoding="utf-8") - v2_root = root / "codeops" / "features" / "modern" - v1_root = root / "codeops" / "features" / "legacy" - v2_root.mkdir(parents=True) - v1_root.mkdir(parents=True) - (v2_root / "traceability.json").write_text( - json.dumps({"schema": 2, "feature": "modern", "nodes": [base_node("RD-001", "requirement")]}), - encoding="utf-8", - ) - (v1_root / "legacy.md").write_text("# Legacy\n", encoding="utf-8") - (v1_root / "traceability.json").write_text( - json.dumps({ - "schema": 1, - "feature": "legacy", - "nodes": [{ - "id": "RD-001", "type": "requirement", "title": "Legacy", - "status": "approved", "path": "legacy.md", "links": [], - }], - }), - encoding="utf-8", - ) - validate_code, validate = self.run_cli(root, "validate") - target_code, target = self.run_cli( - root, "readiness", "--gate", "requirements", "--target", "legacy/RD-001" - ) - self.assertEqual(validate_code, 0, validate) - self.assertEqual(validate["schema_versions"], [1, 2]) - self.assertEqual(validate["graphs"], 2) - self.assertEqual(target_code, 1) - self.assertIn("upgrade-required", {item["code"] for item in target["blockers"]}) - - def test_unsafe_configured_root_is_globally_blocking(self) -> None: - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - config = root / "codeops" - config.mkdir() - (config / "codeops.json").write_text( - json.dumps({"artifacts": {"root": "../outside"}}), - encoding="utf-8", - ) - code, payload = self.run_cli(root, "validate") - self.assertEqual(code, 1) - self.assertIn("unsafe-config-root", {item["code"] for item in payload["blockers"]}) - - def test_feature_member_gate_is_evaluated_recursively(self) -> None: - feature = base_node( - "FEATURE-1", - "feature", - members=["sample/TASK-1"], - memberGates={"sample/TASK-1": "task-complete"}, - ) - task = base_node("TASK-1", "task", status="implemented") - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - (root / "artifact.md").write_text("# Artifact\n", encoding="utf-8") - graph_root = root / "codeops" / "features" / "sample" - graph_root.mkdir(parents=True) - (graph_root / "traceability.json").write_text( - json.dumps({"schema": 2, "feature": "sample", "nodes": [feature, task]}), - encoding="utf-8", - ) - code, payload = self.run_cli( - root, "readiness", "--gate", "feature-acceptance", "--target", "sample/FEATURE-1" - ) - self.assertEqual(code, 1) - self.assertIn("sample/TASK-1", "\n".join(payload["problems"])) - - def test_plan_execution_and_superseded_evidence_predicates(self) -> None: - plan = base_node("PLAN-1", "plan", evidence=[]) - task = base_node( - "TASK-1", - "task", - status="verified", - edges=[{"relation": "verified-by", "target": "sample/VERIFY-1"}], - ) - verification = base_node("VERIFY-1", "verification", status="superseded") - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - (root / "artifact.md").write_text("# Artifact\n", encoding="utf-8") - graph_root = root / "codeops" / "features" / "sample" - graph_root.mkdir(parents=True) - (graph_root / "traceability.json").write_text( - json.dumps({"schema": 2, "feature": "sample", "nodes": [plan, task, verification]}), - encoding="utf-8", - ) - execution_code, execution = self.run_cli( - root, "readiness", "--gate", "execution", "--target", "sample/PLAN-1" - ) - complete_code, complete = self.run_cli( - root, "readiness", "--gate", "task-complete", "--target", "sample/TASK-1" - ) - self.assertEqual(execution_code, 1) - self.assertIn("planned-test-required", {item["code"] for item in execution["blockers"]}) - self.assertIn("entry-evidence-required", {item["code"] for item in execution["blockers"]}) - self.assertEqual(complete_code, 1) - self.assertIn("status-not-ready", {item["code"] for item in complete["blockers"]}) - - def test_requirement_plan_gate_requires_explicit_approved_plan(self) -> None: - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - (root / "artifact.md").write_text("# Artifact\n", encoding="utf-8") - graph_root = root / "codeops" / "features" / "sample" - graph_root.mkdir(parents=True) - (graph_root / "traceability.json").write_text( - json.dumps({ - "schema": 2, - "feature": "sample", - "nodes": [base_node("RD-001", "requirement")], - }), - encoding="utf-8", - ) - code, payload = self.run_cli( - root, "readiness", "--gate", "plan", "--target", "sample/RD-001" - ) - self.assertEqual(code, 1) - self.assertIn("approved-plan-required", {item["code"] for item in payload["blockers"]}) - - def test_shared_decision_deferral_and_major_finding_blockers(self) -> None: - requirement = base_node( - "RD-001", - "requirement", - edges=[ - {"relation": "affected-by", "target": "sample/DECISION-1"}, - {"relation": "affected-by", "target": "sample/DEFERRAL-1"}, - {"relation": "affected-by", "target": "sample/FINDING-1"}, - ], - ) - decision = base_node("DECISION-1", "decision", status="stale") - deferral = base_node("DEFERRAL-1", "deferral", status="proposed") - finding = base_node("FINDING-1", "finding", status="open", risk="high") - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - (root / "artifact.md").write_text("# Artifact\n", encoding="utf-8") - graph_root = root / "codeops" / "features" / "sample" - graph_root.mkdir(parents=True) - (graph_root / "traceability.json").write_text( - json.dumps({ - "schema": 2, - "feature": "sample", - "nodes": [requirement, decision, deferral, finding], - }), - encoding="utf-8", - ) - code, payload = self.run_cli( - root, "readiness", "--gate", "requirements", "--target", "sample/RD-001" - ) - self.assertEqual(code, 1) - codes = {item["code"] for item in payload["blockers"]} - self.assertTrue( - {"decision-not-current", "unapproved-deferral", "blocking-finding"} <= codes - ) - - def test_release_recursively_evaluates_required_requirement(self) -> None: - release = base_node( - "RELEASE-1", - "release", - required=["sample/RD-001"], - optional=[], - excluded=[], - ) - requirement = base_node("RD-001", "requirement", status="draft") - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - (root / "artifact.md").write_text("# Artifact\n", encoding="utf-8") - graph_root = root / "codeops" / "features" / "sample" - graph_root.mkdir(parents=True) - (graph_root / "traceability.json").write_text( - json.dumps({ - "schema": 2, - "feature": "sample", - "nodes": [release, requirement], - }), - encoding="utf-8", - ) - code, payload = self.run_cli( - root, "readiness", "--gate", "release", "--target", "sample/RELEASE-1" - ) - self.assertEqual(code, 1) - self.assertIn("sample/RD-001", "\n".join(payload["problems"])) - - def test_requirement_set_expands_and_evaluates_members(self) -> None: - aggregate = base_node( - "SET-1", - "requirement-set", - members=["sample/RD-001"], - ) - requirement = base_node("RD-001", "requirement", status="draft") - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - (root / "artifact.md").write_text("# Artifact\n", encoding="utf-8") - graph_root = root / "codeops" / "features" / "sample" - graph_root.mkdir(parents=True) - (graph_root / "traceability.json").write_text( - json.dumps({"schema": 2, "feature": "sample", "nodes": [aggregate, requirement]}), - encoding="utf-8", - ) - code, payload = self.run_cli( - root, "readiness", "--gate", "requirements", "--target", "sample/SET-1" - ) - self.assertEqual(code, 1) - self.assertIn("sample/RD-001", payload["closure"]) - - def test_release_coupling_is_symmetric_for_closure(self) -> None: - release = base_node( - "RELEASE-1", - "release", - required=["sample/RD-B"], - optional=[], - excluded=[], - ) - left = base_node( - "RD-A", - "requirement", - edges=[{"relation": "release-coupled", "target": "sample/RD-B"}], - validations=[{ - "upstream": "sample/RD-B", - "relation": "release-coupled", - "revision": REVISION, - "gate": "release", - "validatedAt": "2026-07-23T00:00:00Z", - }], - ) - right = base_node("RD-B", "requirement") - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - (root / "artifact.md").write_text("# Artifact\n", encoding="utf-8") - graph_root = root / "codeops" / "features" / "sample" - graph_root.mkdir(parents=True) - (graph_root / "traceability.json").write_text( - json.dumps({"schema": 2, "feature": "sample", "nodes": [release, left, right]}), - encoding="utf-8", - ) - code, payload = self.run_cli( - root, "readiness", "--gate", "release", "--target", "sample/RELEASE-1" - ) - self.assertEqual(code, 0, payload) - self.assertIn("sample/RD-A", payload["closure"]) - - def test_feature_member_gate_type_is_validated_before_recursion(self) -> None: - feature = base_node( - "FEATURE-1", - "feature", - members=["sample/TASK-1"], - memberGates={"sample/TASK-1": "feature-acceptance"}, - ) - task = base_node("TASK-1", "task", status="verified") - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - (root / "artifact.md").write_text("# Artifact\n", encoding="utf-8") - graph_root = root / "codeops" / "features" / "sample" - graph_root.mkdir(parents=True) - (graph_root / "traceability.json").write_text( - json.dumps({"schema": 2, "feature": "sample", "nodes": [feature, task]}), - encoding="utf-8", - ) - code, payload = self.run_cli( - root, "readiness", "--gate", "feature-acceptance", "--target", "sample/FEATURE-1" - ) - self.assertEqual(code, 1) - self.assertIn("incompatible-target", {item["code"] for item in payload["blockers"]}) - - def test_planning_group_plan_gate_requires_owning_plan(self) -> None: - group = base_node( - "GROUP-1", - "planning-group", - members=["sample/RD-001"], - ) - requirement = base_node("RD-001", "requirement") - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - (root / "artifact.md").write_text("# Artifact\n", encoding="utf-8") - graph_root = root / "codeops" / "features" / "sample" - graph_root.mkdir(parents=True) - (graph_root / "traceability.json").write_text( - json.dumps({"schema": 2, "feature": "sample", "nodes": [group, requirement]}), - encoding="utf-8", - ) - code, payload = self.run_cli( - root, "readiness", "--gate", "plan", "--target", "sample/GROUP-1" - ) - self.assertEqual(code, 1) - self.assertIn("approved-plan-required", {item["code"] for item in payload["blockers"]}) - - def test_audit_mapping_evaluates_verification_state(self) -> None: - verification = base_node("VERIFY-1", "verification", status="failing") - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - (root / "artifact.md").write_text("# Artifact\n", encoding="utf-8") - graph_root = root / "codeops" / "features" / "sample" - graph_root.mkdir(parents=True) - (graph_root / "traceability.json").write_text( - json.dumps({"schema": 2, "feature": "sample", "nodes": [verification]}), - encoding="utf-8", - ) - code, payload = self.run_cli( - root, "readiness", "--gate", "audit", "--target", "sample/VERIFY-1" - ) - self.assertEqual(code, 1) - self.assertIn("status-not-ready", {item["code"] for item in payload["blockers"]}) - - def test_high_finding_blocks_but_low_finding_does_not(self) -> None: - for risk, expected in (("high", 1), ("low", 0)): - with self.subTest(risk=risk), tempfile.TemporaryDirectory() as raw: - root = Path(raw) - (root / "artifact.md").write_text("# Artifact\n", encoding="utf-8") - graph_root = root / "codeops" / "features" / "sample" - graph_root.mkdir(parents=True) - requirement = base_node( - "RD-001", - "requirement", - edges=[{"relation": "affected-by", "target": "sample/FINDING-1"}], - ) - finding = base_node("FINDING-1", "finding", status="open", risk=risk) - (graph_root / "traceability.json").write_text( - json.dumps({"schema": 2, "feature": "sample", "nodes": [requirement, finding]}), - encoding="utf-8", - ) - code, payload = self.run_cli( - root, "readiness", "--gate", "requirements", "--target", "sample/RD-001" - ) - self.assertEqual(code, expected, payload) - - def test_cross_version_identity_collision_is_global(self) -> None: - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - (root / "artifact.md").write_text("# Artifact\n", encoding="utf-8") - config = root / "codeops" - artifacts = config / "artifacts" - (artifacts / "v1").mkdir(parents=True) - (artifacts / "v2").mkdir(parents=True) - (config / "codeops.json").write_text( - json.dumps({ - "schema": 1, - "mode": "strict", - "artifacts": {"layout": "nested", "root": "codeops/artifacts"}, - "quality": {"independentReview": True}, - "metrics": {"enabled": False}, - }), - encoding="utf-8", - ) - (artifacts / "v1" / "legacy.md").write_text("# Legacy\n", encoding="utf-8") - (artifacts / "v1" / "traceability.json").write_text( - json.dumps({ - "schema": 1, - "feature": "same", - "nodes": [{ - "id": "RD-001", "type": "requirement", "title": "Legacy", - "status": "approved", "path": "legacy.md", "links": [], - }], - }), - encoding="utf-8", - ) - (artifacts / "v2" / "traceability.json").write_text( - json.dumps({"schema": 2, "feature": "same", "nodes": [base_node("RD-001", "requirement")]}), - encoding="utf-8", - ) - code, payload = self.run_cli(root, "validate") - self.assertEqual(code, 1) - self.assertIn("duplicate-identity", {item["code"] for item in payload["blockers"]}) - - def test_structural_scope_follows_only_selected_gate_relations(self) -> None: - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - (root / "artifact.md").write_text("# Artifact\n", encoding="utf-8") - target_root = root / "codeops" / "features" / "target" - evidence_root = root / "codeops" / "features" / "evidence" - coupled_root = root / "codeops" / "features" / "coupled" - for path in (target_root, evidence_root, coupled_root): - path.mkdir(parents=True) - target = base_node( - "RD-001", - "requirement", - edges=[ - {"relation": "specified-by", "target": "evidence/SPEC-001"}, - {"relation": "release-coupled", "target": "coupled/RD-001"}, - ], - ) - specification = base_node( - "SPEC-001", - "specification", - edges=[{"relation": "depends-on", "target": "evidence/SPEC-001"}], - ) - coupled = base_node( - "RD-001", - "requirement", - edges=[{"relation": "depends-on", "target": "coupled/RD-MISSING"}], - ) - for path, feature, nodes in ( - (target_root, "target", [target]), - (evidence_root, "evidence", [specification]), - (coupled_root, "coupled", [coupled]), - ): - (path / "traceability.json").write_text( - json.dumps({"schema": 2, "feature": feature, "nodes": nodes}), - encoding="utf-8", - ) - requirements_code, requirements = self.run_cli( - root, "readiness", "--gate", "requirements", "--target", "target/RD-001" - ) - specifications_code, specifications = self.run_cli( - root, "readiness", "--gate", "specifications", "--target", "target/RD-001" - ) - self.assertEqual(requirements_code, 0, requirements) - self.assertEqual( - {"evidence", "coupled"}, - {item["feature"] for item in requirements["diagnostics"]}, - ) - self.assertEqual(specifications_code, 1) - self.assertIn("evidence", {item.get("feature") for item in specifications["blockers"]}) - self.assertNotIn("coupled", {item.get("feature") for item in specifications["blockers"]}) - - def test_release_accepts_and_rejects_terminal_evidence_members(self) -> None: - cases = ( - ("test", "passing", "planned"), - ("implementation", "verified", "present"), - ("verification", "passing", "failing"), - ) - for node_type, passing_status, failing_status in cases: - for status, expected_code in ((passing_status, 0), (failing_status, 1)): - with self.subTest(node_type=node_type, status=status), tempfile.TemporaryDirectory() as raw: - root = Path(raw) - (root / "artifact.md").write_text("# Artifact\n", encoding="utf-8") - graph_root = root / "codeops" / "features" / "sample" - graph_root.mkdir(parents=True) - evidence_id = { - "test": "TEST-1", - "implementation": "IMPL-1", - "verification": "VERIFY-1", - }[node_type] - release = base_node( - "RELEASE-1", - "release", - required=[f"sample/{evidence_id}"], - optional=[], - excluded=[], - ) - evidence = base_node(evidence_id, node_type, status=status) - (graph_root / "traceability.json").write_text( - json.dumps({ - "schema": 2, - "feature": "sample", - "nodes": [release, evidence], - }), - encoding="utf-8", - ) - code, payload = self.run_cli( - root, - "readiness", - "--gate", - "release", - "--target", - "sample/RELEASE-1", - ) - self.assertEqual(code, expected_code, payload) - if expected_code: - self.assertNotIn( - "incompatible-target", - {item["code"] for item in payload["blockers"]}, - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/conformance/test_state_v2_spec.py b/tests/conformance/test_state_v2_spec.py deleted file mode 100755 index 720168a..0000000 --- a/tests/conformance/test_state_v2_spec.py +++ /dev/null @@ -1,951 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import json -import hashlib -import subprocess -import sys -import tempfile -import unittest -from pathlib import Path -from typing import Any - - -ROOT = Path(__file__).resolve().parents[2] -SCRIPT = ROOT / "scripts" / "codeops_state.py" -REVISION = "sha256:" + hashlib.sha256(b"# Artifact\n").hexdigest() - - -def node( - node_id: str, - node_type: str, - *, - status: str = "approved", - edges: list[dict[str, Any]] | None = None, - **extra: Any, -) -> dict[str, Any]: - value = { - "id": node_id, - "type": node_type, - "title": node_id, - "status": status, - "semanticSources": [ - { - "path": "artifact.md", - "selector": {"kind": "whole-file"}, - "normalization": "utf8-lf-trim-trailing-v1", - "digest": "sha256", - } - ], - "revision": REVISION, - "edges": edges or [], - "validations": [], - } - value.update(extra) - return value - - -def snapshot(upstream: str, relation: str, gate: str) -> dict[str, str]: - return { - "upstream": upstream, - "relation": relation, - "revision": REVISION, - "gate": gate, - "validatedAt": "2026-07-23T00:00:00Z", - } - - -class SchemaTwoSpecificationTests(unittest.TestCase): - maxDiff = None - - def run_state( - self, - graphs: dict[str, dict[str, Any]], - command: str = "validate", - *arguments: str, - ) -> subprocess.CompletedProcess[str]: - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - (root / "artifact.md").write_text("# Artifact\n", encoding="utf-8") - for feature, graph in graphs.items(): - graph_root = root / "codeops" / "features" / feature - graph_root.mkdir(parents=True) - (graph_root / "traceability.json").write_text( - json.dumps(graph), - encoding="utf-8", - ) - return subprocess.run( - [ - sys.executable, - str(SCRIPT), - command, - "--root", - str(root), - *arguments, - "--json", - ], - text=True, - capture_output=True, - check=False, - ) - - @staticmethod - def graph(feature: str, nodes: list[dict[str, Any]]) -> dict[str, Any]: - return {"schema": 2, "feature": feature, "nodes": nodes} - - def payload(self, result: subprocess.CompletedProcess[str]) -> dict[str, Any]: - self.assertTrue(result.stdout, result.stderr) - return json.loads(result.stdout) - - def test_st_1_valid_directed_typed_edge(self) -> None: - graph = self.graph( - "accounting", - [ - node( - "RD-001", - "requirement", - edges=[{"relation": "specified-by", "target": "accounting/SPEC-001"}], - ), - node("SPEC-001", "specification"), - ], - ) - - result = self.run_state({"accounting": graph}) - - self.assertEqual(result.returncode, 0, result.stderr) - self.assertTrue(self.payload(result)["ready"]) - - def test_st_2_rejects_persisted_inverse_and_names_canonical_relation(self) -> None: - graph = self.graph( - "accounting", - [ - node( - "SPEC-001", - "specification", - edges=[{"relation": "blocks", "target": "accounting/RD-001"}], - ), - node("RD-001", "requirement"), - ], - ) - - result = self.run_state({"accounting": graph}) - problems = "\n".join(self.payload(result)["problems"]) - - self.assertEqual(result.returncode, 1) - self.assertIn("blocks", problems) - self.assertIn("depends-on", problems) - - def test_st_5_bare_target_is_rejected_without_guessing(self) -> None: - graph = self.graph("accounting", [node("RD-001", "requirement")]) - - result = self.run_state( - {"accounting": graph}, - "readiness", - "--gate", - "requirements", - "--target", - "RD-001", - ) - problems = "\n".join(self.payload(result)["problems"]) - - self.assertEqual(result.returncode, 1) - self.assertIn("canonical", problems) - self.assertIn("--feature", problems) - - def test_st_7_duplicate_canonical_identity_blocks_validation(self) -> None: - graph = self.graph( - "accounting", - [node("RD-001", "requirement"), node("RD-001", "requirement")], - ) - - result = self.run_state({"accounting": graph}) - - self.assertEqual(result.returncode, 1) - self.assertIn("accounting/RD-001", "\n".join(self.payload(result)["problems"])) - - def test_st_8_related_edge_does_not_enter_readiness_closure(self) -> None: - graph = self.graph( - "accounting", - [ - node( - "RD-001", - "requirement", - edges=[{"relation": "related", "target": "accounting/RD-002"}], - ), - node("RD-002", "requirement", status="draft"), - ], - ) - - result = self.run_state( - {"accounting": graph}, - "readiness", - "--gate", - "requirements", - "--target", - "accounting/RD-001", - ) - payload = self.payload(result) - - self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(payload["target"], "accounting/RD-001") - self.assertNotIn("accounting/RD-002", payload["closure"]) - - def test_st_15_contract_maturity_blocks_consumer(self) -> None: - graph = self.graph( - "compiler", - [ - node( - "PLAN-001", - "plan", - edges=[ - { - "relation": "consumes-contract", - "target": "compiler/CONTRACT-IR", - "requiredMaturity": "stable", - } - ], - validations=[ - snapshot("compiler/CONTRACT-IR", "consumes-contract", "plan") - ], - ), - node("CONTRACT-IR", "contract", maturity="provisional"), - ], - ) - - result = self.run_state( - {"compiler": graph}, - "readiness", - "--gate", - "plan", - "--target", - "compiler/PLAN-001", - ) - problems = "\n".join(self.payload(result)["problems"]) - - self.assertEqual(result.returncode, 1) - self.assertIn("stable", problems) - self.assertIn("provisional", problems) - - def test_st_16_stronger_contract_maturity_satisfies_consumer(self) -> None: - graph = self.graph( - "compiler", - [ - node( - "PLAN-001", - "plan", - edges=[ - { - "relation": "consumes-contract", - "target": "compiler/CONTRACT-IR", - "requiredMaturity": "provisional", - } - ], - validations=[ - snapshot("compiler/CONTRACT-IR", "consumes-contract", "plan") - ], - ), - node("CONTRACT-IR", "contract", maturity="stable"), - ], - ) - - result = self.run_state( - {"compiler": graph}, - "readiness", - "--gate", - "plan", - "--target", - "compiler/PLAN-001", - ) - - self.assertEqual(result.returncode, 0, result.stderr) - - def test_st_17_planning_group_expands_atomically(self) -> None: - graph = self.graph( - "compiler", - [ - node("RD-LEX", "requirement"), - node("RD-PARSE", "requirement"), - node( - "GROUP-FRONTEND", - "planning-group", - members=["compiler/RD-LEX", "compiler/RD-PARSE"], - ), - ], - ) - - result = self.run_state( - {"compiler": graph}, - "readiness", - "--gate", - "requirements", - "--target", - "compiler/RD-LEX", - ) - payload = self.payload(result) - - self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual( - set(payload["group_expansions"]["compiler/GROUP-FRONTEND"]), - {"compiler/RD-LEX", "compiler/RD-PARSE"}, - ) - - def test_st_18_cycle_after_group_contraction_is_deterministic(self) -> None: - graph = self.graph( - "compiler", - [ - node( - "RD-LEX", - "requirement", - edges=[{"relation": "depends-on", "target": "compiler/RD-TYPE"}], - ), - node( - "RD-TYPE", - "requirement", - edges=[{"relation": "depends-on", "target": "compiler/RD-LEX"}], - ), - ], - ) - - first = self.run_state({"compiler": graph}) - second = self.run_state({"compiler": graph}) - first_problems = self.payload(first)["problems"] - - self.assertEqual(first.returncode, 1) - self.assertEqual(first_problems, self.payload(second)["problems"]) - self.assertIn( - "compiler/RD-LEX -> compiler/RD-TYPE -> compiler/RD-LEX", - "\n".join(first_problems), - ) - - def test_st_42_revision_normalization_is_content_based(self) -> None: - normalized = "# Artifact\n\nMeaningful text\n" - revision = "sha256:" + hashlib.sha256(normalized.encode()).hexdigest() - graph = self.graph( - "compiler", - [node("RD-LEX", "requirement", revision=revision)], - ) - variants = ( - "\ufeff# Artifact\r\n\r\nMeaningful text \r\n", - "# Artifact\r\rMeaningful text\r", - "# Artifact\n\nMeaningful text\n\n", - ) - for content in variants: - with self.subTest(content=repr(content)): - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - (root / "artifact.md").write_text(content, encoding="utf-8", newline="") - graph_root = root / "codeops" / "features" / "compiler" - graph_root.mkdir(parents=True) - (graph_root / "traceability.json").write_text( - json.dumps(graph), - encoding="utf-8", - ) - result = subprocess.run( - [ - sys.executable, - str(SCRIPT), - "validate", - "--root", - str(root), - "--json", - ], - text=True, - capture_output=True, - check=False, - ) - self.assertEqual(result.returncode, 0, result.stdout) - - changed = self.graph( - "compiler", - [node("RD-LEX", "requirement", revision=revision)], - ) - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - (root / "artifact.md").write_text( - "# Artifact\n\nChanged text\n", - encoding="utf-8", - ) - graph_root = root / "codeops" / "features" / "compiler" - graph_root.mkdir(parents=True) - (graph_root / "traceability.json").write_text( - json.dumps(changed), - encoding="utf-8", - ) - result = subprocess.run( - [sys.executable, str(SCRIPT), "validate", "--root", str(root), "--json"], - text=True, - capture_output=True, - check=False, - ) - self.assertEqual(result.returncode, 1) - self.assertIn("revision-mismatch", result.stdout) - - ordered_payload = "# A\n# B\n" - ordered_revision = "sha256:" + hashlib.sha256(ordered_payload.encode()).hexdigest() - sources = [ - { - "path": "a.md", - "selector": {"kind": "whole-file"}, - "normalization": "utf8-lf-trim-trailing-v1", - "digest": "sha256", - }, - { - "path": "b.md", - "selector": {"kind": "whole-file"}, - "normalization": "utf8-lf-trim-trailing-v1", - "digest": "sha256", - }, - ] - for declaration in (sources, list(reversed(sources))): - with self.subTest(order=[source["path"] for source in declaration]): - graph = self.graph( - "compiler", - [ - node( - "RD-LEX", - "requirement", - semanticSources=declaration, - revision=ordered_revision, - ) - ], - ) - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - (root / "a.md").write_text("# A\n", encoding="utf-8") - (root / "b.md").write_text("# B\n", encoding="utf-8") - graph_root = root / "codeops" / "features" / "compiler" - graph_root.mkdir(parents=True) - (graph_root / "traceability.json").write_text( - json.dumps(graph), - encoding="utf-8", - ) - result = subprocess.run( - [sys.executable, str(SCRIPT), "validate", "--root", str(root), "--json"], - text=True, - capture_output=True, - check=False, - ) - self.assertEqual(result.returncode, 0, result.stdout) - - def test_gate_profile_matrix_rejects_non_ready_target_state(self) -> None: - cases = ( - ("requirements", node("RD-001", "requirement", status="draft")), - ("specifications", node("SPEC-001", "specification", status="draft")), - ("plan", node("PLAN-001", "plan", status="draft")), - ( - "audit", - node( - "AUDIT-001", - "audit-artifact", - status="draft", - auditStage="requirements", - ), - ), - ("execution", node("PLAN-001", "plan", status="draft")), - ("task-complete", node("TASK-001", "task", status="implemented")), - ( - "feature-acceptance", - node( - "FEATURE-001", - "feature", - status="draft", - members=["sample/TASK-001"], - memberGates={"sample/TASK-001": "task-complete"}, - ), - ), - ( - "release", - node( - "RELEASE-001", - "release", - status="draft", - required=["sample/TASK-001"], - optional=[], - excluded=[], - ), - ), - ) - for gate, target_node in cases: - with self.subTest(gate=gate): - nodes = [target_node] - if gate in {"feature-acceptance", "release"}: - nodes.append(node("TASK-001", "task", status="verified")) - graph = self.graph("sample", nodes) - result = self.run_state( - {"sample": graph}, - "readiness", - "--gate", - gate, - "--target", - f"sample/{target_node['id']}", - ) - payload = self.payload(result) - self.assertEqual(result.returncode, 1, payload) - self.assertFalse(payload["ready"]) - self.assertEqual(payload["gate"], gate) - self.assertEqual(payload["target"], f"sample/{target_node['id']}") - self.assertTrue( - any("status" in problem for problem in payload["problems"]), - payload, - ) - - def test_st_3_canonical_target_resolves_exactly(self) -> None: - graph = self.graph("accounting", [node("RD-001", "requirement")]) - result = self.run_state( - {"accounting": graph}, - "readiness", - "--gate", - "requirements", - "--target", - "accounting/RD-001", - ) - self.assertEqual(result.returncode, 0, result.stdout) - self.assertEqual(self.payload(result)["target"], "accounting/RD-001") - - def test_st_4_feature_scoped_target_resolves_exactly(self) -> None: - graph = self.graph("accounting", [node("RD-001", "requirement")]) - result = self.run_state( - {"accounting": graph}, - "readiness", - "--gate", - "requirements", - "--feature", - "accounting", - "--target", - "RD-001", - ) - self.assertEqual(result.returncode, 0, result.stdout) - self.assertEqual(self.payload(result)["target"], "accounting/RD-001") - - def test_st_6_incompatible_target_type_names_allowed_types(self) -> None: - graph = self.graph("accounting", [node("TASK-001", "task", status="pending")]) - result = self.run_state( - {"accounting": graph}, - "readiness", - "--gate", - "requirements", - "--target", - "accounting/TASK-001", - ) - problems = "\n".join(self.payload(result)["problems"]) - self.assertEqual(result.returncode, 1) - self.assertIn("incompatible-target", problems) - self.assertIn("requirement-set", problems) - - def test_st_10_dependency_blocker_reports_shortest_path(self) -> None: - graph = self.graph( - "accounting", - [ - node( - "RD-001", - "requirement", - edges=[{"relation": "depends-on", "target": "identity/RD-IAM-004"}], - ) - ], - ) - identity = self.graph( - "identity", - [node("RD-IAM-004", "requirement", status="draft")], - ) - result = self.run_state( - {"accounting": graph, "identity": identity}, - "readiness", - "--gate", - "requirements", - "--target", - "accounting/RD-001", - ) - payload = self.payload(result) - self.assertEqual(result.returncode, 1) - self.assertIn("blockers", payload) - self.assertIn( - ["accounting/RD-001", "identity/RD-IAM-004"], - [problem["path"] for problem in payload["blockers"]], - ) - - def test_st_19_release_closure_uses_only_required_members(self) -> None: - graph = self.graph( - "_releases", - [ - node( - "RELEASE-1", - "release", - required=["_releases/TASK-REQ"], - optional=["_releases/TASK-OPT"], - excluded=["_releases/TASK-OUT"], - ), - node("TASK-REQ", "task", status="verified"), - node("TASK-OPT", "task", status="pending"), - node("TASK-OUT", "task", status="pending"), - ], - ) - result = self.run_state( - {"_releases": graph}, - "readiness", - "--gate", - "release", - "--target", - "_releases/RELEASE-1", - ) - payload = self.payload(result) - self.assertEqual(result.returncode, 0, payload) - self.assertIn("_releases/TASK-REQ", payload["closure"]) - self.assertNotIn("_releases/TASK-OPT", payload["closure"]) - self.assertNotIn("_releases/TASK-OUT", payload["closure"]) - - def test_st_36_status_reports_lifecycle_transitions_and_gate_summaries(self) -> None: - graph = self.graph("accounting", [node("RD-001", "requirement", status="draft")]) - result = self.run_state( - {"accounting": graph}, - "status", - "--target", - "accounting/RD-001", - ) - payload = self.payload(result) - self.assertEqual(result.returncode, 0, payload) - self.assertEqual(payload["target"], "accounting/RD-001") - self.assertIn("status", payload) - self.assertEqual(payload["status"], "draft") - self.assertEqual(payload["lifecycle"], "requirements") - self.assertFalse(payload["ready"]) - self.assertIn("approved", payload["valid_transitions"]) - self.assertIn("requirements", payload["gates"]) - self.assertFalse(payload["gates"]["requirements"]["ready"]) - self.assertTrue(payload["gates"]["requirements"]["blockers"]) - self.assertIn("stale_snapshots", payload) - - def test_st_9_unrelated_draft_sibling_is_excluded(self) -> None: - graph = self.graph( - "erp", - [ - node("RD-001", "requirement"), - node("RD-002", "requirement", status="draft"), - ], - ) - result = self.run_state( - {"erp": graph}, "readiness", "--gate", "requirements", "--target", "erp/RD-001" - ) - payload = self.payload(result) - self.assertEqual(result.returncode, 0, payload) - self.assertNotIn("erp/RD-002", payload["closure"]) - - def test_st_11_downstream_consumer_does_not_block_provider(self) -> None: - graph = self.graph( - "erp", - [ - node("CONTRACT-1", "contract", maturity="stable"), - node( - "RD-CONSUMER", - "requirement", - status="draft", - edges=[{ - "relation": "consumes-contract", - "target": "erp/CONTRACT-1", - "requiredMaturity": "stable", - }], - ), - ], - ) - result = self.run_state( - {"erp": graph}, "readiness", "--gate", "specifications", "--target", "erp/CONTRACT-1" - ) - payload = self.payload(result) - self.assertEqual(result.returncode, 0, payload) - self.assertNotIn("erp/RD-CONSUMER", payload["closure"]) - - def test_st_12_gate_profiles_select_distinct_trace_closures(self) -> None: - graph = self.graph( - "erp", - [ - node( - "RD-001", - "requirement", - edges=[ - {"relation": "specified-by", "target": "erp/SPEC-001"}, - {"relation": "accepted-by", "target": "erp/AC-001"}, - ], - validations=[ - snapshot("erp/AC-001", "accepted-by", "requirements"), - snapshot("erp/AC-001", "accepted-by", "specifications"), - snapshot("erp/SPEC-001", "specified-by", "specifications"), - ], - ), - node("SPEC-001", "specification"), - node("AC-001", "criterion"), - ], - ) - closures = {} - for gate in ("requirements", "specifications"): - result = self.run_state( - {"erp": graph}, "readiness", "--gate", gate, "--target", "erp/RD-001" - ) - self.assertEqual(result.returncode, 0, result.stdout) - closures[gate] = self.payload(result)["closure"] - self.assertNotIn("erp/SPEC-001", closures["requirements"]) - self.assertIn("erp/SPEC-001", closures["specifications"]) - - def test_st_14_invalid_unrelated_graph_is_diagnostic_only(self) -> None: - root = ROOT / "tests" / "fixtures" / "state-v2-invalid" - result = subprocess.run( - [ - sys.executable, str(SCRIPT), "readiness", "--root", str(root), - "--gate", "requirements", "--target", "valid/RD-001", "--json", - ], - text=True, capture_output=True, check=False, - ) - payload = self.payload(result) - self.assertEqual(result.returncode, 0, payload) - self.assertEqual(payload["problems"], []) - self.assertEqual(payload["diagnostics"][0]["feature"], "unrelated") - - def test_st_13_invalid_entered_dependency_blocks_with_path(self) -> None: - with tempfile.TemporaryDirectory() as raw: - root = Path(raw) - (root / "artifact.md").write_text("# Artifact\n", encoding="utf-8") - accounting = root / "codeops" / "features" / "accounting" - identity = root / "codeops" / "features" / "identity" - accounting.mkdir(parents=True) - identity.mkdir(parents=True) - accounting_graph = self.graph( - "accounting", - [node( - "RD-001", "requirement", - edges=[{"relation": "depends-on", "target": "identity/RD-001"}], - )], - ) - invalid_graph = {"schema": 2, "feature": "identity", "nodes": "invalid"} - (accounting / "traceability.json").write_text( - json.dumps(accounting_graph), encoding="utf-8" - ) - (identity / "traceability.json").write_text( - json.dumps(invalid_graph), encoding="utf-8" - ) - result = subprocess.run( - [ - sys.executable, str(SCRIPT), "readiness", "--root", str(root), - "--gate", "requirements", "--target", "accounting/RD-001", "--json", - ], - text=True, capture_output=True, check=False, - ) - payload = self.payload(result) - self.assertEqual(result.returncode, 1) - self.assertIn( - ["accounting/RD-001", "identity/RD-001"], - [blocker.get("path") for blocker in payload["blockers"]], - ) - - def test_st_20_release_coupled_member_enters_closure(self) -> None: - graph = self.graph( - "_releases", - [ - node( - "RELEASE-1", "release", - required=["_releases/RD-A"], optional=[], excluded=[], - ), - node( - "RD-A", "requirement", - edges=[{"relation": "release-coupled", "target": "_releases/RD-B"}], - validations=[ - snapshot("_releases/RD-B", "release-coupled", "release") - ], - ), - node("RD-B", "requirement"), - ], - ) - result = self.run_state( - {"_releases": graph}, "readiness", "--gate", "release", - "--target", "_releases/RELEASE-1", - ) - payload = self.payload(result) - self.assertEqual(result.returncode, 0, payload) - self.assertIn("_releases/RD-B", payload["closure"]) - - def test_st_35_specifications_gate_is_independent(self) -> None: - graph = self.graph( - "erp", - [ - node( - "RD-001", "requirement", - edges=[{"relation": "specified-by", "target": "erp/SPEC-001"}], - ), - node("SPEC-001", "specification", status="draft"), - ], - ) - requirements = self.run_state( - {"erp": graph}, "readiness", "--gate", "requirements", "--target", "erp/RD-001" - ) - specifications = self.run_state( - {"erp": graph}, "readiness", "--gate", "specifications", "--target", "erp/RD-001" - ) - self.assertEqual(requirements.returncode, 0, requirements.stdout) - self.assertEqual(specifications.returncode, 1, specifications.stdout) - - def test_st_38_repository_discovery_excludes_fixtures(self) -> None: - repository = subprocess.run( - [sys.executable, str(SCRIPT), "status", "--root", str(ROOT), "--json"], - text=True, capture_output=True, check=False, - ) - fixture = subprocess.run( - [ - sys.executable, str(SCRIPT), "validate", - "--root", str(ROOT / "tests" / "fixtures" / "state-v2-cross-feature"), - "--json", - ], - text=True, capture_output=True, check=False, - ) - self.assertEqual(json.loads(repository.stdout)["graphs"], 1) - self.assertEqual(json.loads(fixture.stdout)["graphs"], 2) - - def test_st_21_matching_dependency_snapshot_is_current(self) -> None: - snapshot = { - "upstream": "sample/RD-UP", - "relation": "depends-on", - "revision": REVISION, - "gate": "requirements", - "validatedAt": "2026-07-23T12:00:00Z", - } - graph = self.graph( - "sample", - [ - node( - "RD-DOWN", - "requirement", - edges=[{"relation": "depends-on", "target": "sample/RD-UP"}], - validations=[snapshot], - ), - node("RD-UP", "requirement"), - ], - ) - result = self.run_state( - {"sample": graph}, "readiness", "--gate", "requirements", - "--target", "sample/RD-DOWN", - ) - self.assertEqual(result.returncode, 0, result.stdout) - - def test_st_22_snapshot_revision_mismatch_blocks_dependent(self) -> None: - snapshot = { - "upstream": "sample/RD-UP", - "relation": "depends-on", - "revision": "sha256:" + ("1" * 64), - "gate": "requirements", - "validatedAt": "2026-07-23T12:00:00Z", - } - graph = self.graph( - "sample", - [ - node( - "RD-DOWN", - "requirement", - edges=[{"relation": "depends-on", "target": "sample/RD-UP"}], - validations=[snapshot], - ), - node("RD-UP", "requirement"), - ], - ) - result = self.run_state( - {"sample": graph}, "readiness", "--gate", "requirements", - "--target", "sample/RD-DOWN", - ) - self.assertEqual(result.returncode, 1) - self.assertIn("stale-snapshot", {item["code"] for item in self.payload(result)["blockers"]}) - - def test_st_23_related_revision_is_not_an_invalidation_edge(self) -> None: - graph = self.graph( - "sample", - [ - node( - "RD-DOWN", - "requirement", - edges=[{"relation": "related", "target": "sample/RD-UP"}], - ), - node("RD-UP", "requirement"), - ], - ) - result = self.run_state( - {"sample": graph}, "readiness", "--gate", "requirements", - "--target", "sample/RD-DOWN", - ) - self.assertEqual(result.returncode, 0, result.stdout) - - def test_st_24_release_snapshot_is_gate_specific(self) -> None: - snapshot = { - "upstream": "sample/RD-B", - "relation": "release-coupled", - "revision": "sha256:" + ("1" * 64), - "gate": "release", - "validatedAt": "2026-07-23T12:00:00Z", - } - graph = self.graph( - "sample", - [ - node( - "RELEASE-1", "release", - required=["sample/RD-A"], optional=[], excluded=[], - ), - node( - "RD-A", "requirement", - edges=[{"relation": "release-coupled", "target": "sample/RD-B"}], - validations=[snapshot], - ), - node("RD-B", "requirement"), - ], - ) - requirements = self.run_state( - {"sample": graph}, "readiness", "--gate", "requirements", - "--target", "sample/RD-A", - ) - release = self.run_state( - {"sample": graph}, "readiness", "--gate", "release", - "--target", "sample/RELEASE-1", - ) - self.assertEqual(requirements.returncode, 0, requirements.stdout) - self.assertEqual(release.returncode, 1) - self.assertIn("stale-snapshot", {item["code"] for item in self.payload(release)["blockers"]}) - - def test_missing_blocking_snapshot_is_reported(self) -> None: - graph = self.graph( - "erp", - [ - node( - "RD-001", - "requirement", - edges=[{"relation": "depends-on", "target": "erp/RD-002"}], - ), - node("RD-002", "requirement"), - ], - ) - result = self.run_state( - {"erp": graph}, "readiness", "--gate", "requirements", - "--target", "erp/RD-001", - ) - self.assertIn( - "missing-snapshot", - {item["code"] for item in self.payload(result)["blockers"]}, - ) - - def test_snapshot_without_persisted_relationship_is_reported(self) -> None: - graph = self.graph( - "erp", - [ - node( - "RD-001", - "requirement", - validations=[ - snapshot("erp/RD-002", "depends-on", "requirements") - ], - ), - node("RD-002", "requirement"), - ], - ) - result = self.run_state( - {"erp": graph}, "readiness", "--gate", "requirements", - "--target", "erp/RD-001", - ) - self.assertIn( - "extraneous-snapshot", - {item["code"] for item in self.payload(result)["blockers"]}, - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/conformance/test_targeted_workflows_impl.py b/tests/conformance/test_targeted_workflows_impl.py deleted file mode 100644 index 0e4c9e1..0000000 --- a/tests/conformance/test_targeted_workflows_impl.py +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import pathlib -import unittest - -from scripts.codeops_state_lib.gates import evaluate_target -from scripts.codeops_state_lib.models import Edge, Node - - -ROOT = pathlib.Path(__file__).resolve().parents[2] - - -class TargetedWorkflowImplementation(unittest.TestCase): - def test_task_completion_does_not_pull_sibling_tasks(self) -> None: - def node(node_id: str, node_type: str, status: str, edges: tuple[Edge, ...] = ()) -> Node: - return Node("sample", node_id, node_type, node_id, status, (), "sha256:x", edges, ()) - nodes = { - "sample/PLAN": node("PLAN", "plan", "approved", ( - Edge("implemented-by", "sample/TASK-1"), Edge("implemented-by", "sample/TASK-2"), - )), - "sample/TASK-1": node("TASK-1", "task", "verified"), - "sample/TASK-2": node("TASK-2", "task", "implemented"), - } - sources = {identity: ROOT / "plan.md" for identity in nodes} - closure, problems = evaluate_target("sample/TASK-1", "task-complete", nodes, sources) - self.assertNotIn("sample/TASK-2", closure.members) - self.assertFalse(problems) - - def test_workflow_documents_have_no_legacy_feature_gate(self) -> None: - for path in ("skills/make-plan/SKILL.md", "skills/exec-plan/SKILL.md"): - self.assertNotIn("readiness --root . --feature", (ROOT / path).read_text()) - - def test_public_document_links_resolve(self) -> None: - for path in ("docs/concepts.md", "docs/tutorial.md", "docs/migration.md", "docs/troubleshooting.md"): - self.assertTrue((ROOT / path).is_file()) - - def test_roadmap_contract_names_explicit_aggregates(self) -> None: - text = (ROOT / "skills/roadmap/SKILL.md").read_text() - self.assertIn("--gate feature-acceptance", text) - self.assertIn("--gate release", text) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/conformance/test_targeted_workflows_spec.py b/tests/conformance/test_targeted_workflows_spec.py deleted file mode 100644 index a529679..0000000 --- a/tests/conformance/test_targeted_workflows_spec.py +++ /dev/null @@ -1,109 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import hashlib -import json -import pathlib -import subprocess -import sys -import unittest - - -ROOT = pathlib.Path(__file__).resolve().parents[2] -STATE = ROOT / "scripts/codeops_state.py" - - -def read(path: str) -> str: - return (ROOT / path).read_text() - - -class TargetedWorkflowSpecification(unittest.TestCase): - def assert_contract(self, path: str, *tokens: str) -> None: - content = read(path) - for token in tokens: - self.assertIn(token, content, f"{path} lacks {token!r}") - - def test_st_28_requirements_uses_exact_target(self) -> None: - self.assert_contract("skills/make-requirements/SKILL.md", "--gate requirements", "--target ") - - def test_st_29_preflight_is_narrow(self) -> None: - self.assert_contract("skills/preflight/SKILL.md", "--gate audit", "--target ", "modification set") - - def test_st_30_plan_uses_exact_target(self) -> None: - self.assert_contract("skills/make-plan/SKILL.md", "--gate plan", "--target ") - - def test_st_31_execution_has_entry_and_task_gates(self) -> None: - self.assert_contract( - "skills/exec-plan/SKILL.md", - "--gate execution", - "--target ", - "`task-complete`", - "--request ", - ) - - def test_st_32_roadmap_keeps_siblings_independent(self) -> None: - root = ROOT / "tests/fixtures/state-v2-cross-feature" - graphs = sorted(root.glob("codeops/features/*/traceability.json")) - before = [hashlib.sha256(path.read_bytes()).hexdigest() for path in graphs] - result = subprocess.run( - [sys.executable, str(STATE), "status", "--root", str(root), - "--target", "accounting/RD-001", "--json"], - text=True, capture_output=True, check=False, - ) - payload = json.loads(result.stdout) - after = [hashlib.sha256(path.read_bytes()).hexdigest() for path in graphs] - self.assertEqual(result.returncode, 0, payload) - self.assertEqual(payload["target"], "accounting/RD-001") - self.assertEqual(before, after) - - def test_st_33_feature_acceptance_is_explicit(self) -> None: - root = ROOT / "tests/fixtures/state-v2-release" - result = subprocess.run( - [sys.executable, str(STATE), "readiness", "--root", str(root), - "--gate", "release", "--target", "_releases/RELEASE-1", "--json"], - text=True, capture_output=True, check=False, - ) - payload = json.loads(result.stdout) - self.assertEqual(result.returncode, 0, payload) - self.assertTrue(payload["ready"]) - self.assertNotIn("_releases/TASK-OPT", payload["closure"]) - - def test_st_34_public_workflow_commands_are_documented(self) -> None: - self.assert_contract("docs/tutorial.md", "--gate requirements", "--gate execution") - - def test_execution_cannot_complete_with_missing_code_documentation(self) -> None: - # Implementation is not complete until its public and non-trivial entities are understandable. - self.assert_contract( - "skills/exec-plan/execution-protocol.md", - "Documentation-standard self-check (NON-NEGOTIABLE, before every `[x]`)", - "every public, exported, or external-facing class", - "every non-trivial internal entity is documented", - "Missing required documentation blocks `[x]`", - ) - for path in ( - "agent-templates/plan-task-executor.md", - "agent-templates/plan-task-executor-opus.md", - ): - self.assert_contract( - path, - "Documentation gate (non-negotiable)", - "Missing documentation blocks completion", - ) - self.assert_contract( - "agent-templates/phase-reviewer.md", - "Documentation compliance", - "Missing required documentation is a standards finding", - ) - - def test_st_40_collection_declares_all_cases(self) -> None: - self.assertIn("set(range(1, 50))", read("tests/conformance/test_state_test_collection.py")) - - def test_st_41_codex_port_closes_only_with_pilot(self) -> None: - plan = read("plans/codex-port/99-execution-plan.md") - evidence = json.loads(read("tests/evidence/dependency-aware-readiness-pilot.json")) - self.assertFalse(evidence["qualifiesForCodexPortTask6_8"]) - self.assertIn("- [ ] 6.8", plan) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/fixtures/state-invalid/codeops/features/ledger/requirements.md b/tests/fixtures/state-invalid/codeops/features/ledger/requirements.md deleted file mode 100644 index 543d867..0000000 --- a/tests/fixtures/state-invalid/codeops/features/ledger/requirements.md +++ /dev/null @@ -1 +0,0 @@ -# Incomplete ledger requirement diff --git a/tests/fixtures/state-invalid/codeops/features/ledger/traceability.json b/tests/fixtures/state-invalid/codeops/features/ledger/traceability.json deleted file mode 100644 index a9ce863..0000000 --- a/tests/fixtures/state-invalid/codeops/features/ledger/traceability.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "schema": 1, - "feature": "ledger", - "nodes": [ - {"id": "RD-001", "type": "requirement", "title": "Post entries", "status": "draft", "path": "requirements.md", "links": ["SPEC-MISSING"]}, - {"id": "AR-001", "type": "ambiguity", "title": "Rounding rule", "status": "open", "risk": "critical", "path": "requirements.md", "links": []} - ] -} diff --git a/tests/fixtures/state-v1-upgrade/ambiguous/codeops/features/sample/artifact.md b/tests/fixtures/state-v1-upgrade/ambiguous/codeops/features/sample/artifact.md deleted file mode 100644 index 9e71a0a..0000000 --- a/tests/fixtures/state-v1-upgrade/ambiguous/codeops/features/sample/artifact.md +++ /dev/null @@ -1,7 +0,0 @@ -# Requirement - -The system records a durable requirement. - -# Specification - -The system implements the requirement. diff --git a/tests/fixtures/state-v1-upgrade/ambiguous/codeops/features/sample/traceability.json b/tests/fixtures/state-v1-upgrade/ambiguous/codeops/features/sample/traceability.json deleted file mode 100644 index 4ed321b..0000000 --- a/tests/fixtures/state-v1-upgrade/ambiguous/codeops/features/sample/traceability.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "schema": 1, - "feature": "sample", - "nodes": [ - { - "id": "RD-001", - "type": "requirement", - "title": "Requirement", - "status": "approved", - "path": "artifact.md", - "links": [ - "SPEC-001" - ], - "evidence": [ - "artifact.md" - ], - "risk": "high" - }, - { - "id": "SPEC-001", - "type": "specification", - "title": "Specification", - "status": "approved", - "path": "artifact.md", - "links": [ - "RD-001" - ] - } - ] -} diff --git a/tests/fixtures/state-v1-upgrade/resolutions-template.json b/tests/fixtures/state-v1-upgrade/resolutions-template.json deleted file mode 100644 index 7be0171..0000000 --- a/tests/fixtures/state-v1-upgrade/resolutions-template.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "schema": 1, - "previewHash": "REPLACED-BY-TEST", - "decisions": { - "edge:RD-001:SPEC-001": { - "relation": "specified-by" - }, - "edge:SPEC-001:RD-001": { - "relation": "related" - }, - "source:RD-001": { - "selector": { - "kind": "heading", - "value": "Requirement" - } - }, - "source:SPEC-001": { - "selector": { - "kind": "heading", - "value": "Specification" - } - } - } -} diff --git a/tests/fixtures/state-v2-compiler/codeops/features/compiler/artifact.md b/tests/fixtures/state-v2-compiler/codeops/features/compiler/artifact.md deleted file mode 100644 index c115357..0000000 --- a/tests/fixtures/state-v2-compiler/codeops/features/compiler/artifact.md +++ /dev/null @@ -1,3 +0,0 @@ -# Compiler frontend - -The lexer and parser form one atomic frontend planning group. diff --git a/tests/fixtures/state-v2-compiler/codeops/features/compiler/traceability.json b/tests/fixtures/state-v2-compiler/codeops/features/compiler/traceability.json deleted file mode 100644 index d2912dc..0000000 --- a/tests/fixtures/state-v2-compiler/codeops/features/compiler/traceability.json +++ /dev/null @@ -1,80 +0,0 @@ -{ - "schema": 2, - "feature": "compiler", - "nodes": [ - { - "id": "RD-LEX", - "type": "requirement", - "title": "Lex source text", - "status": "approved", - "semanticSources": [ - { - "path": "codeops/features/compiler/artifact.md", - "selector": {"kind": "whole-file"}, - "normalization": "utf8-lf-trim-trailing-v1", - "digest": "sha256" - } - ], - "revision": "sha256:7ad883fe8d221c3c0cead01676161b28816b4dba79fa03ac67dd8d405f633053", - "edges": [ - {"relation": "depends-on", "target": "compiler/RD-PARSE"} - ], - "validations": [] - }, - { - "id": "RD-PARSE", - "type": "requirement", - "title": "Parse token stream", - "status": "approved", - "semanticSources": [ - { - "path": "codeops/features/compiler/artifact.md", - "selector": {"kind": "whole-file"}, - "normalization": "utf8-lf-trim-trailing-v1", - "digest": "sha256" - } - ], - "revision": "sha256:7ad883fe8d221c3c0cead01676161b28816b4dba79fa03ac67dd8d405f633053", - "edges": [ - {"relation": "depends-on", "target": "compiler/RD-LEX"} - ], - "validations": [] - }, - { - "id": "GROUP-FRONTEND", - "type": "planning-group", - "title": "Frontend planning group", - "status": "approved", - "members": ["compiler/RD-LEX", "compiler/RD-PARSE"], - "semanticSources": [ - { - "path": "codeops/features/compiler/artifact.md", - "selector": {"kind": "whole-file"}, - "normalization": "utf8-lf-trim-trailing-v1", - "digest": "sha256" - } - ], - "revision": "sha256:7ad883fe8d221c3c0cead01676161b28816b4dba79fa03ac67dd8d405f633053", - "edges": [], - "validations": [] - }, - { - "id": "CONTRACT-IR", - "type": "contract", - "title": "Intermediate representation contract", - "status": "approved", - "maturity": "provisional", - "semanticSources": [ - { - "path": "codeops/features/compiler/artifact.md", - "selector": {"kind": "whole-file"}, - "normalization": "utf8-lf-trim-trailing-v1", - "digest": "sha256" - } - ], - "revision": "sha256:7ad883fe8d221c3c0cead01676161b28816b4dba79fa03ac67dd8d405f633053", - "edges": [], - "validations": [] - } - ] -} diff --git a/tests/fixtures/state-v2-cross-feature/artifact.md b/tests/fixtures/state-v2-cross-feature/artifact.md deleted file mode 100644 index 21e3101..0000000 --- a/tests/fixtures/state-v2-cross-feature/artifact.md +++ /dev/null @@ -1 +0,0 @@ -# Artifact diff --git a/tests/fixtures/state-v2-cross-feature/codeops/features/accounting/traceability.json b/tests/fixtures/state-v2-cross-feature/codeops/features/accounting/traceability.json deleted file mode 100644 index 8b08dfe..0000000 --- a/tests/fixtures/state-v2-cross-feature/codeops/features/accounting/traceability.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "schema": 2, - "feature": "accounting", - "nodes": [{ - "id": "RD-001", - "type": "requirement", - "title": "Accounting", - "status": "approved", - "semanticSources": [{"path": "artifact.md", "selector": {"kind": "whole-file"}, "normalization": "utf8-lf-trim-trailing-v1", "digest": "sha256"}], - "revision": "sha256:0bcc31e6a23b12f4122ea07f8b4cb41660e7c660bb3359be39bad1454fe91875", - "edges": [{"relation": "depends-on", "target": "identity/RD-001"}], - "validations": [] - }] -} diff --git a/tests/fixtures/state-v2-cross-feature/codeops/features/identity/traceability.json b/tests/fixtures/state-v2-cross-feature/codeops/features/identity/traceability.json deleted file mode 100644 index 5b356cc..0000000 --- a/tests/fixtures/state-v2-cross-feature/codeops/features/identity/traceability.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "schema": 2, - "feature": "identity", - "nodes": [{ - "id": "RD-001", - "type": "requirement", - "title": "Identity", - "status": "draft", - "semanticSources": [{"path": "artifact.md", "selector": {"kind": "whole-file"}, "normalization": "utf8-lf-trim-trailing-v1", "digest": "sha256"}], - "revision": "sha256:0bcc31e6a23b12f4122ea07f8b4cb41660e7c660bb3359be39bad1454fe91875", - "edges": [], - "validations": [] - }] -} diff --git a/tests/fixtures/state-v2-erp/codeops/features/accounting/artifact.md b/tests/fixtures/state-v2-erp/codeops/features/accounting/artifact.md deleted file mode 100644 index e8edb18..0000000 --- a/tests/fixtures/state-v2-erp/codeops/features/accounting/artifact.md +++ /dev/null @@ -1,3 +0,0 @@ -# Accounting - -Capture supplier invoices against the stable identity contract. diff --git a/tests/fixtures/state-v2-erp/codeops/features/accounting/traceability.json b/tests/fixtures/state-v2-erp/codeops/features/accounting/traceability.json deleted file mode 100644 index 5e26dd0..0000000 --- a/tests/fixtures/state-v2-erp/codeops/features/accounting/traceability.json +++ /dev/null @@ -1,84 +0,0 @@ -{ - "schema": 2, - "feature": "accounting", - "nodes": [ - { - "id": "CONTRACT-IDENTITY", - "type": "contract", - "title": "Identity contract", - "status": "approved", - "maturity": "stable", - "semanticSources": [ - { - "path": "codeops/features/accounting/artifact.md", - "selector": {"kind": "whole-file"}, - "normalization": "utf8-lf-trim-trailing-v1", - "digest": "sha256" - } - ], - "revision": "sha256:9198509620898656868729770f0182b71274e6f9a4c9e743ad9f344eda3ab46c", - "edges": [], - "validations": [] - }, - { - "id": "RD-AP-001", - "type": "requirement", - "title": "Capture supplier invoice", - "status": "approved", - "semanticSources": [ - { - "path": "codeops/features/accounting/artifact.md", - "selector": {"kind": "whole-file"}, - "normalization": "utf8-lf-trim-trailing-v1", - "digest": "sha256" - } - ], - "revision": "sha256:9198509620898656868729770f0182b71274e6f9a4c9e743ad9f344eda3ab46c", - "edges": [ - { - "relation": "consumes-contract", - "target": "accounting/CONTRACT-IDENTITY", - "requiredMaturity": "stable" - } - ], - "validations": [] - }, - { - "id": "RD-AP-002", - "type": "requirement", - "title": "Schedule payment run", - "status": "draft", - "semanticSources": [ - { - "path": "codeops/features/accounting/artifact.md", - "selector": {"kind": "whole-file"}, - "normalization": "utf8-lf-trim-trailing-v1", - "digest": "sha256" - } - ], - "revision": "sha256:9198509620898656868729770f0182b71274e6f9a4c9e743ad9f344eda3ab46c", - "edges": [ - {"relation": "related", "target": "accounting/RD-AP-001"} - ], - "validations": [] - }, - { - "id": "SET-AP", - "type": "requirement-set", - "title": "Accounts payable requirements", - "status": "approved", - "members": ["accounting/RD-AP-001", "accounting/RD-AP-002"], - "semanticSources": [ - { - "path": "codeops/features/accounting/artifact.md", - "selector": {"kind": "whole-file"}, - "normalization": "utf8-lf-trim-trailing-v1", - "digest": "sha256" - } - ], - "revision": "sha256:9198509620898656868729770f0182b71274e6f9a4c9e743ad9f344eda3ab46c", - "edges": [], - "validations": [] - } - ] -} diff --git a/tests/fixtures/state-v2-invalid/artifact.md b/tests/fixtures/state-v2-invalid/artifact.md deleted file mode 100644 index 21e3101..0000000 --- a/tests/fixtures/state-v2-invalid/artifact.md +++ /dev/null @@ -1 +0,0 @@ -# Artifact diff --git a/tests/fixtures/state-v2-invalid/codeops/features/unrelated/traceability.json b/tests/fixtures/state-v2-invalid/codeops/features/unrelated/traceability.json deleted file mode 100644 index 1cd902a..0000000 --- a/tests/fixtures/state-v2-invalid/codeops/features/unrelated/traceability.json +++ /dev/null @@ -1 +0,0 @@ -{"schema": 2, "feature": "unrelated", "nodes": "not-an-array"} diff --git a/tests/fixtures/state-v2-invalid/codeops/features/valid/traceability.json b/tests/fixtures/state-v2-invalid/codeops/features/valid/traceability.json deleted file mode 100644 index 244958e..0000000 --- a/tests/fixtures/state-v2-invalid/codeops/features/valid/traceability.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "schema": 2, - "feature": "valid", - "nodes": [{ - "id": "RD-001", - "type": "requirement", - "title": "Valid target", - "status": "approved", - "semanticSources": [{"path": "artifact.md", "selector": {"kind": "whole-file"}, "normalization": "utf8-lf-trim-trailing-v1", "digest": "sha256"}], - "revision": "sha256:0bcc31e6a23b12f4122ea07f8b4cb41660e7c660bb3359be39bad1454fe91875", - "edges": [], - "validations": [] - }] -} diff --git a/tests/fixtures/state-v2-release/artifact.md b/tests/fixtures/state-v2-release/artifact.md deleted file mode 100644 index 21e3101..0000000 --- a/tests/fixtures/state-v2-release/artifact.md +++ /dev/null @@ -1 +0,0 @@ -# Artifact diff --git a/tests/fixtures/state-v2-release/codeops/features/_releases/traceability.json b/tests/fixtures/state-v2-release/codeops/features/_releases/traceability.json deleted file mode 100644 index 1487849..0000000 --- a/tests/fixtures/state-v2-release/codeops/features/_releases/traceability.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "schema": 2, - "feature": "_releases", - "nodes": [ - { - "id": "RELEASE-1", - "type": "release", - "title": "Release 1", - "status": "approved", - "semanticSources": [{"path": "artifact.md", "selector": {"kind": "whole-file"}, "normalization": "utf8-lf-trim-trailing-v1", "digest": "sha256"}], - "revision": "sha256:0bcc31e6a23b12f4122ea07f8b4cb41660e7c660bb3359be39bad1454fe91875", - "edges": [], - "validations": [], - "required": ["_releases/TASK-REQ"], - "optional": ["_releases/TASK-OPT"], - "excluded": [] - }, - { - "id": "TASK-REQ", - "type": "task", - "title": "Required", - "status": "verified", - "semanticSources": [{"path": "artifact.md", "selector": {"kind": "whole-file"}, "normalization": "utf8-lf-trim-trailing-v1", "digest": "sha256"}], - "revision": "sha256:0bcc31e6a23b12f4122ea07f8b4cb41660e7c660bb3359be39bad1454fe91875", - "edges": [], - "validations": [] - }, - { - "id": "TASK-OPT", - "type": "task", - "title": "Optional", - "status": "pending", - "semanticSources": [{"path": "artifact.md", "selector": {"kind": "whole-file"}, "normalization": "utf8-lf-trim-trailing-v1", "digest": "sha256"}], - "revision": "sha256:0bcc31e6a23b12f4122ea07f8b4cb41660e7c660bb3359be39bad1454fe91875", - "edges": [], - "validations": [] - } - ] -} diff --git a/tests/fixtures/state-valid/codeops/features/ledger/requirements.md b/tests/fixtures/state-valid/codeops/features/ledger/requirements.md deleted file mode 100644 index 4e85e4e..0000000 --- a/tests/fixtures/state-valid/codeops/features/ledger/requirements.md +++ /dev/null @@ -1 +0,0 @@ -# Ledger requirement diff --git a/tests/fixtures/state-valid/codeops/features/ledger/specification.md b/tests/fixtures/state-valid/codeops/features/ledger/specification.md deleted file mode 100644 index 077fc52..0000000 --- a/tests/fixtures/state-valid/codeops/features/ledger/specification.md +++ /dev/null @@ -1 +0,0 @@ -# Ledger specification diff --git a/tests/fixtures/state-valid/codeops/features/ledger/traceability.json b/tests/fixtures/state-valid/codeops/features/ledger/traceability.json deleted file mode 100644 index 3ebb802..0000000 --- a/tests/fixtures/state-valid/codeops/features/ledger/traceability.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "schema": 1, - "feature": "ledger", - "nodes": [ - {"id": "RD-001", "type": "requirement", "title": "Post balanced entries", "status": "approved", "path": "requirements.md", "links": ["SPEC-001"]}, - {"id": "AR-001", "type": "ambiguity", "title": "Rounding rule", "status": "resolved", "risk": "critical", "path": "requirements.md", "links": ["SPEC-001"]}, - {"id": "SPEC-001", "type": "specification", "title": "Atomic posting", "status": "approved", "path": "specification.md", "links": ["RD-001", "AC-001", "TASK-001"]}, - {"id": "AC-001", "type": "criterion", "title": "Debits equal credits", "status": "approved", "path": "specification.md", "links": ["SPEC-001", "ST-001", "TASK-001", "VER-001"]}, - {"id": "ST-001", "type": "test", "title": "Reject imbalance", "status": "passing", "path": "verification.md", "links": ["AC-001"]}, - {"id": "TASK-001", "type": "task", "title": "Implement posting", "status": "verified", "path": "specification.md", "links": ["SPEC-001", "AC-001", "IMPL-001"]}, - {"id": "IMPL-001", "type": "implementation", "title": "Posting implementation", "status": "present", "path": "specification.md", "links": ["TASK-001", "VER-001"]}, - {"id": "VER-001", "type": "verification", "title": "Ledger suite", "status": "passing", "path": "verification.md", "links": ["AC-001", "IMPL-001"], "evidence": ["verification.md"]} - ] -} diff --git a/tests/fixtures/state-valid/codeops/features/ledger/verification.md b/tests/fixtures/state-valid/codeops/features/ledger/verification.md deleted file mode 100644 index df715d8..0000000 --- a/tests/fixtures/state-valid/codeops/features/ledger/verification.md +++ /dev/null @@ -1 +0,0 @@ -# Verification evidence