diff --git a/CHANGELOG.md b/CHANGELOG.md index 19928063..e0748ea8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,70 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- V18 migration evidence now includes a deterministic v17 golden graph-history + fixture bundle, runtime-backed fixture manifest nouns, a manifest JSON + adapter, and a restore validator that checks real `refs/warp/*` writer heads + and patch counts in an isolated repository. +- V18 graph-model migration now includes a read-only source inventory + collector that discovers restored `refs/warp//writers/*` refs, + decodes patch commit trailers, records writer chains and patch descriptors, + and fails closed with structured inventory notices. +- V18 graph-model migration now includes pure operation lowering from + successful dry-run plans to runtime-backed, write-ready migration operation + facts for later scratch writers. +- V18 graph-model migration now includes an explicit scratch writer that + rejects live graph refs, writes lowered operation commits only under + `refs/warp-migration-scratch/*`, and advances scratch refs with + expected-head `git update-ref` calls. +- V18 genesis replay equivalence now includes a scratch promotion gate that + runs proof comparison, reports first divergence, blocks failed proofs, and + rejects otherwise-equivalent readings when patch-boundary evidence is + missing. +- V18 graph-model migration finalization now has a pure safety protocol that + requires explicit confirmation, passed scratch equivalence, archive ref + selection, scratch output evidence, and matching live-ref expected head + evidence before any live ref update can be attempted. +- V18 graph-model migration finalization now includes an archive-preserving + Git updater that blocks failed safety results, rejects live-ref drift, + refuses existing archive refs, archives old lineage, and advances live refs + through expected-head `git update-ref` calls. +- V18 graph-model migration now includes a command-level runner that wires + dry-run planning, operation lowering, scratch writing, equivalence gating, + and optional finalization while keeping finalization absent by default. +- V18 graph-model migration finalization now requires runtime conformance + evidence matching the scratch ref and scratch head, so supplied equivalence + readings alone cannot promote scratch output to live graph refs. +- V18 graph-model migration closeout now records the remaining raw + content/property compatibility files and adds an executable audit shape test + so new raw compatibility boundaries require deliberate review. +- V18 genesis replay migration now includes a pure builder that projects the + v17 golden fixture manifest into `GenesisEquivalenceReading` facts with + deterministic boundary evidence. +- V18 graph-model migration now includes a scratch reading builder that reads + scratch operation commits and projects them into genesis-equivalence facts + with scratch commit boundary evidence. +- V18 graph-model migration command wiring now accepts reading providers so + legacy and scratch equivalence readings can be constructed after scratch + history has been written. +- V18 graph-model migration now includes an adapter-level scratch runtime + conformance provider that verifies scratch refs still point at their + expected heads and can be read back into genesis evidence. +- V18 graph-model migration command finalization is now covered with + command-owned reading providers and scratch runtime conformance instead of + test-supplied finalization proof. +- V18 graph-model migration command coverage now proves provider-built scratch + readings still block finalization when legacy and migrated facts diverge. +- V18 graph-model migration command output now includes a deterministic report + formatter for planning, scratch, equivalence, and finalization evidence. +- V18 graph-model migration now includes a non-finalizing command CLI wrapper + that writes scratch history, builds command-owned readings, emits the command + report, and refuses live-ref finalization flags. +- V18 release planning now records explicit public-release blockers for + production-runtime replay, live finalization CLI design, wet-run fixture + harnessing, Continuum contract tie-back, and operator release notes. +- V18 planning now records the evidence-backed post-command-CLI replan toward + production-runtime scratch replay, wet-run fixture harnessing, live + finalization CLI design, and generated Continuum contract tie-back. - V18 graph-model migration dry-run work now includes a non-destructive CLI runner and request JSON adapter that validate source facts, invoke the pure planner, emit deterministic manifest output, and refuse write/apply modes. @@ -120,6 +184,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- V18 graph-model migration review follow-up now preserves parent Git command + environment variables under deterministic identities, validates scratch and + finalization boundaries before Git work, rejects malformed scratch payload + hex bytes, restores runtime-backed fixture fact dispatch, and raises adapter + guard coverage for the CI ratchet. - V18 graph-model migration dry-run review follow-up now removes boolean-trap notice validation helpers, encodes planned target property keys with a named length-prefixed format, and raises focused constructor-guard coverage for the diff --git a/docs/BEARING.md b/docs/BEARING.md index edf0790a..f4634047 100644 --- a/docs/BEARING.md +++ b/docs/BEARING.md @@ -39,18 +39,18 @@ of handwritten adapter folklore. Current branch state at this boundary: -- Branch: `v18-continuum-slices-41-45` +- Branch: `v18-continuum-slices-46-55` - Base branch: `main` -- Current `origin/main`: `07e16795` -- Latest merged PR: #102, v18 migration dry-run planning substrate +- Current `origin/main`: `b274bbc9` +- Latest merged PR: #103, v18 migration dry-run CLI and genesis equivalence + evidence - Latest released package line: `17.0.1` - Latest completed implementation cycle: - `0193-v18-replan-with-migration-evidence` -- Current work: PR D, v18 slices 41 through 45, is complete on this branch - and now includes a drift-check pivot that inserts v17 golden graph-history - fixtures before write-capable migration work. + `0213-v18-replan-after-command-cli` +- Current work: PR E now contains slices 46 through 65. The branch is ready + for PR review after final local verification and push. - Cleanup checkpoint: `main` has been fast-forwarded to `origin/main` after - PR #102 merged; this branch starts from that merge commit. + PR #103 merged; this branch starts from that merge commit. The current v18 graph-model posture is: @@ -104,6 +104,57 @@ The current v18 graph-model posture is: - A v17 golden graph-history fixture design now precedes real source inventory collection so migration work can prove against restored persisted Git data, not only compact in-memory proof cases. +- A first v17 golden graph-history fixture bundle and manifest now restore + real `refs/warp/*` writer refs into an isolated repository and validate + writer heads, patch counts, and visible fact-family coverage. +- A read-only restored source inventory collector now discovers real writer + refs, decodes patch commit trailers, records writer chains and patch + descriptors, derives a deterministic source basis, and fails closed with + structured migration notices. +- Pure migration operation lowering now turns successful dry-run plans into + runtime-backed write-ready operation facts while refusing fatal dry-run plans. +- An explicit scratch migration writer now writes lowered operation facts only + under `refs/warp-migration-scratch/*`, rejects live graph refs, and advances + scratch refs with expected-head `git update-ref` calls. +- A scratch equivalence gate now compares legacy and scratch genesis readings, + reports first divergence, and blocks promotion when proof fails or visible + facts lack patch-boundary evidence. +- Finalization safety is now modeled as pure domain evidence: explicit + confirmation, passed equivalence gate, archive ref target, scratch output, + and live-ref expected-head match are required before live refs can move. +- Archive-preserving finalization now exists as an adapter-layer Git updater: + it refuses failed safety results, rejects live-ref drift, creates an archive + ref for old lineage, and advances the live ref with expected-head CAS. +- Command-level migration wiring now runs dry-run planning, lowering, scratch + writing, equivalence gating, and optional finalization in order, with + finalization absent by default. +- Finalization now also requires post-migration runtime conformance evidence + tied to the exact scratch ref and scratch head. +- The remaining raw content/property compatibility files are now listed in an + executable closeout audit. +- Legacy fixture manifests can now be projected into genesis-equivalence + readings with deterministic patch-boundary evidence. +- Scratch migration operation commits can now be projected into + genesis-equivalence readings with scratch commit boundary evidence. +- The migration command can now construct equivalence readings through command + reading providers after scratch writing. +- Scratch migration runtime conformance now has an adapter-level provider that + verifies the scratch ref still points at the expected head and reads + operation commits back into genesis evidence. +- Command finalization is now covered with command-owned legacy/scratch reading + providers plus scratch operation readback conformance, not only supplied test + proof values. +- Provider-built scratch readings now have a divergence regression proving + finalization remains blocked when scratch history is readable but not + equivalent. +- The migration command now has deterministic operator report formatting for + planning, scratch, equivalence, and finalization evidence. +- A non-finalizing migration command CLI wrapper now writes scratch history, + builds command-owned readings, emits the command report, and refuses live-ref + finalization flags. +- V18 public-release blockers are now explicit: production-runtime scratch + replay, live finalization CLI design, wet-run fixture harnessing, generated + Continuum contract tie-back, and operator release notes. That is useful progress, not a finish line. The repo still needs property projection beyond replay/serialization boundaries, graph-model migration @@ -220,6 +271,93 @@ CLI coverage, and equivalence proof fixtures, then created design docs for slices 47 through 51 and inserted the v17 golden graph-history fixture as the new slice 46. +Slice 46 is complete on this branch. A deterministic v17 golden graph-history +fixture now exists as a Git bundle plus manifest. The restore helper initializes +an explicit target repository, fetches the fixture refs, verifies writer heads +and patch counts, and keeps Docker optional instead of making it the fixture +artifact of record. + +Slice 47 is complete on this branch. The source inventory collector reads +restored writer refs from Git, decodes patch trailers through the adapter +codec boundary, records writer chains and patch descriptors, derives a source +basis from restored heads, and produces fatal inventory notices when source +refs are absent or malformed. + +Slice 48 is complete on this branch. Operation lowering now consumes +successful dry-run plans, emits source/target-basis patch plans with sorted +lowered operation facts, and keeps graph-history writes out of the domain +lowering step. + +Slice 49 is complete on this branch. Scratch migration writing now requires an +explicit scratch ref, rejects live `refs/warp/*` targets before writing, +creates deterministic per-operation commits, and appends with CAS-shaped +`git update-ref` calls. + +Slice 50 is complete on this branch. Scratch equivalence gating now wraps the +genesis proof and divergence reporter into a promotion decision, with explicit +blocking for missing patch-boundary evidence even when visible readings match. + +Slice 51 is complete on this branch. Finalization safety now exists as a pure +precondition gate: no confirmation, failed equivalence, missing archive target, +missing scratch output, or stale live-ref expectation can pass into a future +live-ref update step. + +Slice 52 is complete on this branch. Finalization implementation now archives +the old live head under `refs/warp-migration-archive/*` and advances the live +ref to the scratch head with `git update-ref `, while +blocking failed safety, existing archive refs, and live-ref drift. + +Slice 53 is complete on this branch. The command runner now wires the v18 +migration stages in order and only calls finalization when explicit +finalization options are supplied and the equivalence gate passes. + +Slice 54 is complete on this branch. Finalization safety now rejects promotion +without runtime conformance evidence for the exact scratch ref/head, which +keeps supplied equivalence readings from masquerading as runtime readability. + +Slice 55 is complete on this branch. The content/property closeout audit now +enumerates every current `src/domain` file that still touches raw legacy +content/property compatibility patterns and fails if that set drifts without +review. + +Slice 56 is complete on this branch. The v17 golden fixture manifest can now +be projected into genesis-equivalence readings with deterministic +patch-boundary evidence. + +Slice 57 is complete on this branch. Scratch migration operation commits can +now be read back from Git and projected into genesis-equivalence facts with +scratch commit boundary evidence. + +Slice 58 is complete on this branch. The migration command can now construct +legacy and scratch readings through providers after scratch writing. + +Slice 59 is complete on this branch. Scratch runtime conformance now has an +adapter-level operation-history readback provider tied to the expected scratch +ref and head. + +Slice 60 is complete on this branch. Command finalization is covered with +command-owned reading providers plus scratch operation readback conformance. + +Slice 61 is complete on this branch. Provider-built scratch readings now have +a divergence regression proving finalization remains blocked when scratch +history is readable but not equivalent. + +Slice 62 is complete on this branch. The command now has deterministic +operator report formatting for planning, scratch, equivalence, and +finalization evidence. + +Slice 63 is complete on this branch. A non-finalizing migration command CLI +wrapper writes scratch history, builds command-owned readings, emits the +command report, and refuses live-ref finalization flags. + +Slice 64 is complete on this branch. V18 public-release blockers are explicit: +production-runtime scratch replay, live finalization CLI design, wet-run +fixture harnessing, generated Continuum contract tie-back, and release notes. + +Slice 65 is complete on this branch. Evidence-backed replanning moves the next +goalpost from scratch operation readback to production-runtime scratch replay +and wet-run fixture harnessing. + ## What Feels Wrong - Content persistence still uses legacy `_content*` compatibility properties. @@ -227,21 +365,22 @@ new slice 46. complete. - The source audit still finds raw property-map dependencies in named compatibility, serialization, replay, reducer/op-strategy, visible-scope, - logical-index, and migration-source boundaries. The audit command was - `rg -n "decodePropKey|decodeEdgePropKey|state\\.prop" src/domain`. + logical-index, and migration-source boundaries. The closeout audit pattern is + `decodePropKey|decodeEdgePropKey|state\\.prop|_content` over `src/domain`. - Temporal replay still extracts node snapshots from the raw legacy property map because historical replay tests carry pre-codec inline fixture classes that are not `PropValue`-honest enough for `LegacyPropertyValue`. -- The v18 migration tool is dry-run only. It can consume explicit request JSON, - but it does not yet collect real graph history into source inventory. -- Genesis equivalence is credible as a domain vocabulary and compact fixture - proof, not yet as a real scratch-history replay gate. -- Compact equivalence fixtures are not enough to validate source inventory - over real v17 persisted Git history. A golden fixture corpus must restore a - v17 graph object/ref layout before wet-run migration paths are trusted. -- The next write-capable migration work must go through real source inventory, - lowering, scratch writes, equivalence gates, and finalization safety. Live - ref promotion is still out of bounds. +- The v18 migration tool can now write scratch history and derive scratch + operation readings, but it does not yet open scratch output through the full + production graph runtime. +- Legacy readings from the v17 golden fixture are manifest-derived, not yet + produced by replaying the restored v17 graph through the public read path. +- The command wrapper writes scratch history and reports evidence, but live-ref + finalization from the CLI is intentionally refused until confirmation and + operator-report semantics are designed. +- Continuum/WARP Optic release claims still need generated contract evidence + tied to Wesley/Continuum artifacts, not just handwritten compatibility + doctrine. ## Where We Are Heading @@ -251,13 +390,20 @@ proven equivalent before finalization." Suggested implementation batches: -- PR D, slices 41 through 45: dry-run CLI, equivalence nouns, fixtures, - divergence reporter, evidence-backed replan, and the v17 fixture pivot. -- PR E, slices 46 through 50: v17 golden graph-history fixtures, real source +- PR D, slices 41 through 45: merged in PR #103. +- PR E, slices 46 through 55: v17 golden graph-history fixtures, real source inventory collection, migration operation lowering, scratch migration - writing, and scratch equivalence gating. -- PR F starts with slice 51: finalization safety after restored-fixture - scratch equivalence is proven. + writing, scratch equivalence gating, finalization safety, finalization + implementation, end-to-end command wiring, post-migration runtime + conformance, and content/property closeout audit. +- PR E extension, slices 56 through 65: legacy and scratch reading + construction, command reading providers, scratch operation readback + conformance, provider-backed finalization and divergence coverage, command + reporting, a non-finalizing command CLI wrapper, release blockers, and this + replan. +- Next goalpost, slices 66 through 70: production-runtime scratch replay, + wet-run fixture harnessing, live finalization CLI design, and generated + Continuum contract tie-back. ## Invariants @@ -349,15 +495,48 @@ and concrete checks live in `docs/invariants/`. [0192](design/0192-v18-genesis-divergence-reporter/v18-genesis-divergence-reporter.md). - [x] 45. Re-plan with migration evidence in hand: [0193](design/0193-v18-replan-with-migration-evidence/v18-replan-with-migration-evidence.md). -- [ ] 46. Add v17 golden graph-history fixtures: +- [x] 46. Add v17 golden graph-history fixtures: [0199](design/0199-v18-v17-golden-graph-fixtures/v18-v17-golden-graph-fixtures.md). -- [ ] 47. Add real source inventory collection: +- [x] 47. Add real source inventory collection: [0194](design/0194-v18-real-source-inventory-collector/v18-real-source-inventory-collector.md). -- [ ] 48. Add migration operation lowering: +- [x] 48. Add migration operation lowering: [0195](design/0195-v18-migration-operation-lowering/v18-migration-operation-lowering.md). -- [ ] 49. Add the scratch migration writer: +- [x] 49. Add the scratch migration writer: [0196](design/0196-v18-scratch-migration-writer/v18-scratch-migration-writer.md). -- [ ] 50. Add the scratch equivalence gate: +- [x] 50. Add the scratch equivalence gate: [0197](design/0197-v18-scratch-equivalence-gate/v18-scratch-equivalence-gate.md). -- [ ] 51. Design migration finalization safety: +- [x] 51. Design migration finalization safety: [0198](design/0198-v18-migration-finalization-safety/v18-migration-finalization-safety.md). +- [x] 52. Implement archive-preserving migration finalization: + [0200](design/0200-v18-migration-finalization-implementation/v18-migration-finalization-implementation.md). +- [x] 53. Wire the end-to-end migration command: + [0201](design/0201-v18-migration-command-wiring/v18-migration-command-wiring.md). +- [x] 54. Prove post-migration runtime conformance: + [0202](design/0202-v18-post-migration-runtime-conformance/v18-post-migration-runtime-conformance.md). +- [x] 55. Close the content/property migration audit: + [0203](design/0203-v18-content-property-closeout-audit/v18-content-property-closeout-audit.md). +- [x] 56. Construct legacy fixture genesis readings: + [0204](design/0204-v18-legacy-fixture-reading-construction/v18-legacy-fixture-reading-construction.md). +- [x] 57. Construct scratch operation genesis readings: + [0205](design/0205-v18-scratch-operation-reading-construction/v18-scratch-operation-reading-construction.md). +- [x] 58. Add command reading providers: + [0206](design/0206-v18-command-reading-providers/v18-command-reading-providers.md). +- [x] 59. Add a scratch runtime conformance provider: + [0207](design/0207-v18-scratch-runtime-conformance-provider/v18-scratch-runtime-conformance-provider.md). +- [x] 60. Prove command finalization with providers: + [0208](design/0208-v18-command-provider-finalization/v18-command-provider-finalization.md). +- [x] 61. Add provider-built divergence coverage: + [0209](design/0209-v18-provider-divergence-coverage/v18-provider-divergence-coverage.md). +- [x] 62. Add migration command report output: + [0210](design/0210-v18-migration-command-report/v18-migration-command-report.md). +- [x] 63. Add a migration command CLI wrapper: + [0211](design/0211-v18-migration-command-cli-wrapper/v18-migration-command-cli-wrapper.md). +- [x] 64. Record v18 public release blockers: + [0212](design/0212-v18-public-release-blockers/v18-public-release-blockers.md). +- [x] 65. Replan after the command CLI: + [0213](design/0213-v18-replan-after-command-cli/v18-replan-after-command-cli.md). +- [ ] 66. Design production-runtime scratch replay conformance. +- [ ] 67. Implement production-runtime scratch replay provider. +- [ ] 68. Add a v17 fixture wet-run migration harness. +- [ ] 69. Design live finalization CLI confirmation and reporting. +- [ ] 70. Tie v18 release claims to generated Continuum contract evidence. diff --git a/docs/design/0194-v18-real-source-inventory-collector/v18-real-source-inventory-collector.md b/docs/design/0194-v18-real-source-inventory-collector/v18-real-source-inventory-collector.md index c88edd86..c08566ac 100644 --- a/docs/design/0194-v18-real-source-inventory-collector/v18-real-source-inventory-collector.md +++ b/docs/design/0194-v18-real-source-inventory-collector/v18-real-source-inventory-collector.md @@ -1,11 +1,12 @@ --- cycle: 0194 task_id: V18_real_source_inventory_collector -status: Planned +status: Complete sponsors: human: James agent: Codex started_at: 2026-05-24 +completed_at: 2026-05-24 release_home: v18.0.0 bearing_task: 47 promotes_backlog: @@ -104,6 +105,15 @@ git diff --check HEAD - The dry-run CLI can invoke collection without adding write mode. - Slice 48 can lower planned operations against collected source evidence. +## Closeout + +Slice 47 adds a read-only collector over restored Git history. It discovers +`refs/warp//writers/*`, decodes patch commit trailers, records writer +chains and per-writer patch descriptors as migration-domain nouns, derives a +deterministic source basis from restored heads, and fails closed with +structured notices when writer refs are absent or patch metadata does not +match the requested graph. + ## SSJS Scorecard - Runtime-backed forms: green when collected facts become migration nouns. diff --git a/docs/design/0195-v18-migration-operation-lowering/v18-migration-operation-lowering.md b/docs/design/0195-v18-migration-operation-lowering/v18-migration-operation-lowering.md index 698c2ce9..861cf425 100644 --- a/docs/design/0195-v18-migration-operation-lowering/v18-migration-operation-lowering.md +++ b/docs/design/0195-v18-migration-operation-lowering/v18-migration-operation-lowering.md @@ -1,11 +1,12 @@ --- cycle: 0195 task_id: V18_migration_operation_lowering -status: Planned +status: Complete sponsors: human: James agent: Codex started_at: 2026-05-24 +completed_at: 2026-05-24 release_home: v18.0.0 bearing_task: 48 promotes_backlog: @@ -96,6 +97,14 @@ git diff --check HEAD - No graph-history writes are added. - Scratch writer work has explicit input values. +## Closeout + +Slice 48 adds pure domain lowering from successful dry-run plans to +write-ready migration operation facts. Fatal dry-run plans return fatal +lowering results instead of producing write input. Lowered patch plans carry +source basis, target basis, sorted lowered operations, and no Git, filesystem, +or ref-update behavior. + ## SSJS Scorecard - Runtime-backed forms: green when lowered operation facts are classes. diff --git a/docs/design/0196-v18-scratch-migration-writer/v18-scratch-migration-writer.md b/docs/design/0196-v18-scratch-migration-writer/v18-scratch-migration-writer.md index 6d0d0e96..ca3e40ae 100644 --- a/docs/design/0196-v18-scratch-migration-writer/v18-scratch-migration-writer.md +++ b/docs/design/0196-v18-scratch-migration-writer/v18-scratch-migration-writer.md @@ -1,11 +1,12 @@ --- cycle: 0196 task_id: V18_scratch_migration_writer -status: Planned +status: Complete sponsors: human: James agent: Codex started_at: 2026-05-24 +completed_at: 2026-05-24 release_home: v18.0.0 bearing_task: 49 promotes_backlog: @@ -97,12 +98,39 @@ git diff --check HEAD - Scratch output can be replayed by the equivalence gate. - Finalization remains separate. +## Closeout + +Slice 49 added the first write-capable v18 graph-model migration step, fenced +behind `GraphModelMigrationScratchRef`. The writer accepts lowered migration +operations and writes one deterministic Git commit per operation to an +explicit `refs/warp-migration-scratch/*` ref. + +The implementation rejects missing targets, live `refs/warp/*` targets, and +invalid scratch ref shapes before writing. Scratch ref advancement uses +`git update-ref` with the expected old head, so appending remains CAS-shaped +instead of force-shaped. Commit payloads encode operation and basis identity +as UTF-8 hex lines rather than JSON, keeping serialization out of the domain +and avoiding behaviorally significant message parsing. + +Finalization is still not present. The scratch history is now inspectable +input for the slice 50 equivalence gate. + +## Verification Result + +```text +npx vitest run test/unit/scripts/v18-scratch-migration-writer.test.ts --reporter=verbose +npx eslint --no-warn-ignored scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchWriter.ts test/unit/scripts/v18-scratch-migration-writer.test.ts src/domain/migrations/GraphModelMigrationScratchRef.ts src/domain/migrations/GraphModelMigrationScratchWrittenPatch.ts src/domain/migrations/GraphModelMigrationScratchWriteResult.ts +npm run typecheck +``` + ## SSJS Scorecard -- Runtime-backed forms: green when writer results are named values. -- Boundary validation: green when scratch targets are validated before I/O. -- Behavior ownership: green when writer writes and domain lowering lowers. +- Runtime-backed forms: green; scratch refs, written patches, and write + results are named values. +- Boundary validation: green; scratch targets are validated before write I/O. +- Behavior ownership: green; writer writes and domain lowering lowers. - Message parsing: green; no behavior parses text output. -- Ambient time or entropy: green when generated identities come from inputs or - injected ports. -- Fake shape trust or cast-cosplay: green when fake persistence is typed. +- Ambient time or entropy: green; scratch commits use fixed migration Git + identity and dates. +- Fake shape trust or cast-cosplay: green; tests use real Git repositories and + typed result values. diff --git a/docs/design/0197-v18-scratch-equivalence-gate/v18-scratch-equivalence-gate.md b/docs/design/0197-v18-scratch-equivalence-gate/v18-scratch-equivalence-gate.md index d45b1340..a8b140a9 100644 --- a/docs/design/0197-v18-scratch-equivalence-gate/v18-scratch-equivalence-gate.md +++ b/docs/design/0197-v18-scratch-equivalence-gate/v18-scratch-equivalence-gate.md @@ -1,11 +1,12 @@ --- cycle: 0197 task_id: V18_scratch_equivalence_gate -status: Planned +status: Complete sponsors: human: James agent: Codex started_at: 2026-05-24 +completed_at: 2026-05-24 release_home: v18.0.0 bearing_task: 50 promotes_backlog: @@ -97,11 +98,34 @@ git diff --check HEAD - Passing proof summary is deterministic. - Finalization design has enough evidence to specify promotion semantics. +## Closeout + +Slice 50 added `GenesisEquivalenceGate` and +`GenesisEquivalenceGateResult`. The gate consumes already-formed legacy and +scratch `GenesisEquivalenceReading` values, runs `GenesisEquivalenceProof`, +and attaches the first `GenesisDivergenceReport` when proof fails. + +Promotion is allowed only when the proof succeeds and no fatal promotion +blocker exists. Missing patch-boundary evidence is now explicit: +otherwise-equivalent readings with `null` boundary facts still produce a fatal +`E_MISSING_EQUIVALENCE_BOUNDARY` blocker. + +Replay construction remains intentionally outside the gate. Later slices can +build legacy and scratch readings from real Git history without changing the +proof semantics. + +## Verification Result + +```text +npx vitest run test/unit/domain/migrations/GenesisEquivalenceGate.test.ts --reporter=verbose +npx eslint --no-warn-ignored src/domain/migrations/GenesisEquivalenceGate.ts src/domain/migrations/GenesisEquivalenceGateResult.ts test/unit/domain/migrations/GenesisEquivalenceGate.test.ts +``` + ## SSJS Scorecard -- Runtime-backed forms: green when gate outcomes are named values. -- Boundary validation: green when readings are proof nouns before comparison. -- Behavior ownership: green when proof code compares and gate code gates. +- Runtime-backed forms: green; gate outcomes are named values. +- Boundary validation: green; readings are proof nouns before comparison. +- Behavior ownership: green; proof code compares and gate code gates. - Message parsing: green; report text is never parsed as behavior. - Ambient time or entropy: green; no clocks or randomness. -- Fake shape trust or cast-cosplay: green when no assertions are introduced. +- Fake shape trust or cast-cosplay: green; no assertions were introduced. diff --git a/docs/design/0198-v18-migration-finalization-safety/v18-migration-finalization-safety.md b/docs/design/0198-v18-migration-finalization-safety/v18-migration-finalization-safety.md index 63fc5578..35b90e1b 100644 --- a/docs/design/0198-v18-migration-finalization-safety/v18-migration-finalization-safety.md +++ b/docs/design/0198-v18-migration-finalization-safety/v18-migration-finalization-safety.md @@ -1,11 +1,12 @@ --- cycle: 0198 task_id: V18_migration_finalization_safety -status: Planned +status: Complete sponsors: human: James agent: Codex started_at: 2026-05-24 +completed_at: 2026-05-24 release_home: v18.0.0 bearing_task: 51 promotes_backlog: @@ -85,7 +86,7 @@ remain ambiguous. ## Verification ```text -npx vitest run test/unit/scripts/v18-migration-finalization-safety.test.ts --reporter=verbose +npx vitest run test/unit/domain/migrations/GraphModelMigrationFinalizationSafety.test.ts --reporter=verbose npm run typecheck npm run lint:semgrep npm run lint:sludge @@ -99,12 +100,45 @@ git diff --check HEAD - No destructive defaults exist. - v18 release readiness can be assessed from migration evidence. +## Closeout + +Slice 51 implemented the finalization safety protocol as pure domain values, +not as live Git ref mutation. `GraphModelMigrationFinalizationRequest` names +the required finalization evidence: live ref, expected and observed live head, +scratch ref/head, archive ref target, operator confirmation, and equivalence +gate result. + +`GraphModelMigrationFinalizationSafety` blocks finalization unless all of the +following are true: + +- the live ref is under `refs/warp/*`; +- the explicit confirmation token is present; +- the scratch equivalence gate allows promotion; +- the archive ref is under `refs/warp-migration-archive/*`; +- scratch ref and scratch head evidence are present; +- the observed live head matches the expected live head. + +No force switch exists on the request shape. Slice 52 can now implement the +Git update step against these preconditions instead of inventing its own +ad-hoc finalization rules. + +## Verification Result + +```text +npx vitest run test/unit/domain/migrations/GraphModelMigrationFinalizationSafety.test.ts --reporter=verbose +npx eslint --no-warn-ignored src/domain/migrations/GraphModelMigrationArchiveRef.ts src/domain/migrations/GraphModelMigrationFinalizationConfirmation.ts src/domain/migrations/GraphModelMigrationFinalizationRequest.ts src/domain/migrations/GraphModelMigrationFinalizationSafety.ts src/domain/migrations/GraphModelMigrationFinalizationSafetyResult.ts test/unit/domain/migrations/GraphModelMigrationFinalizationSafety.test.ts +npm run typecheck +npm run lint:sludge +npm run lint:semgrep +``` + ## SSJS Scorecard -- Runtime-backed forms: green when finalization requests/results are named. -- Boundary validation: green when confirmation and gate proof are validated. -- Behavior ownership: green when finalization only finalizes. +- Runtime-backed forms: green; finalization request, confirmation, archive ref, + and safety result are named values. +- Boundary validation: green; confirmation and gate proof are validated before + Git mutation exists. +- Behavior ownership: green; safety only decides, later finalization finalizes. - Message parsing: green; no confirmation through parsed prose. -- Ambient time or entropy: green when audit identifiers are deterministic or - injected. -- Fake shape trust or cast-cosplay: green when ref outcomes are typed. +- Ambient time or entropy: green; no clocks or randomness. +- Fake shape trust or cast-cosplay: green; ref outcomes and blockers are typed. diff --git a/docs/design/0199-v18-v17-golden-graph-fixtures/v18-v17-golden-graph-fixtures.md b/docs/design/0199-v18-v17-golden-graph-fixtures/v18-v17-golden-graph-fixtures.md index c69b811d..0aaaccfa 100644 --- a/docs/design/0199-v18-v17-golden-graph-fixtures/v18-v17-golden-graph-fixtures.md +++ b/docs/design/0199-v18-v17-golden-graph-fixtures/v18-v17-golden-graph-fixtures.md @@ -1,11 +1,12 @@ --- cycle: 0199 task_id: V18_v17_golden_graph_fixtures -status: Planned +status: Complete sponsors: human: James agent: Codex started_at: 2026-05-24 +completed_at: 2026-05-24 release_home: v18.0.0 bearing_task: 46 promotes_backlog: @@ -123,6 +124,15 @@ git diff --check HEAD - Docker wet-run work is either present as an optional harness or queued with clear acceptance criteria. +## Closeout + +Slice 46 adds `fixtures/v17/graph-model-golden/v17-golden-graph.bundle` plus +a deterministic manifest. The fixture restores real +`refs/warp/v17-golden-graph/writers/*` refs, validates writer heads and patch +counts in an isolated repository, and records node, edge, property, content, +removal, and multi-writer visible fact coverage. Docker remains optional; the +canonical artifact is the Git bundle and manifest pair. + ## SSJS Scorecard - Runtime-backed forms: green when restored facts become explicit fixture or diff --git a/docs/design/0200-v18-migration-finalization-implementation/v18-migration-finalization-implementation.md b/docs/design/0200-v18-migration-finalization-implementation/v18-migration-finalization-implementation.md new file mode 100644 index 00000000..220dcff2 --- /dev/null +++ b/docs/design/0200-v18-migration-finalization-implementation/v18-migration-finalization-implementation.md @@ -0,0 +1,118 @@ +--- +cycle: 0200 +task_id: V18_migration_finalization_implementation +status: Complete +sponsors: + human: James + agent: Codex +started_at: 2026-05-24 +completed_at: 2026-05-24 +release_home: v18.0.0 +bearing_task: 52 +promotes_backlog: + - docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md +--- + +# V18 Migration Finalization Implementation + +## Sponsor Human + +James. + +## Sponsor Agent + +Codex. + +## Hill + +Implement the archive-preserving live-ref update step for safety-approved +scratch migration output. + +## Playback Questions + +- Does finalization refuse to run when the safety gate is not green? +- Does it create an archive ref before changing the live ref? +- Does it reject pre-existing archive refs instead of overwriting them? +- Does it compare the live ref with the expected head immediately before + archive creation? +- Does it advance the live ref with compare-and-swap, never force? + +## Existing Shape + +Slice 51 named finalization preconditions in pure domain values. The next +step can mutate Git refs only after receiving a passed +`GraphModelMigrationFinalizationSafetyResult`. + +## Chosen Boundary + +Add an adapter-layer finalizer under +`scripts/v18.0.0/migrations/graph-model/`. The finalizer receives an explicit +repository path and a safety result. If the safety result blocks +finalization, it returns a blocked result without touching Git. + +For approved finalization: + +1. Re-read the live ref and require it to match the expected head. +2. Require the archive ref to be absent. +3. Create the archive ref with `git update-ref `. +4. Advance the live ref with `git update-ref `. + +## Non-Goals + +- Do not infer safety from command-line flags. +- Do not force-update refs. +- Do not delete old lineage. +- Do not implement the end-to-end command in this slice. +- Do not claim migrated runtime conformance yet. + +## RED Plan + +Add finalizer tests: + +- approved safety archives old live head and advances live ref; +- failed safety leaves archive and live refs untouched; +- existing archive ref blocks finalization; +- live-ref drift blocks before archive creation. + +## GREEN Plan + +Share a shell-free Git command runner with the scratch writer, then implement +the finalizer as a narrow adapter over `git update-ref`. + +## Verification + +```text +npx vitest run test/unit/scripts/v18-migration-finalizer.test.ts test/unit/scripts/v18-scratch-migration-writer.test.ts --reporter=verbose +npx eslint --no-warn-ignored scripts/v18.0.0/migrations/graph-model/GitMigrationCommandRunner.ts scripts/v18.0.0/migrations/graph-model/GraphModelMigrationFinalizer.ts scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchWriter.ts src/domain/migrations/GraphModelMigrationFinalizationResult.ts test/unit/scripts/v18-migration-finalizer.test.ts test/unit/scripts/v18-scratch-migration-writer.test.ts +npm run typecheck +npm run lint:sludge +npm run lint:semgrep +``` + +## Closeout Criteria + +- Archive ref creation is covered. +- Live ref advancement is covered. +- Stale live ref expectations are covered. +- No force or delete path exists. + +## Closeout + +Slice 52 added `GraphModelMigrationFinalizationResult` and +`finalizeGraphModelMigration`. The finalizer short-circuits blocked safety +results, rejects live-ref drift before archive creation, rejects existing +archive refs, creates the archive ref with a zero-old compare-and-swap, and +advances the live ref with expected-head compare-and-swap. + +The scratch writer now shares `GitMigrationCommandRunner` with the finalizer, +keeping Git subprocess execution shell-free and centralized for the v18 +migration scripts. + +## SSJS Scorecard + +- Runtime-backed forms: green; finalization result status is a named union. +- Boundary validation: green; only safety-approved requests can mutate refs. +- Behavior ownership: green; finalizer mutates refs and safety decides safety. +- Message parsing: green; no behavior parses prose output. +- Ambient time or entropy: green; finalizer does not create commits. +- Fake shape trust or cast-cosplay: green; tests use real Git refs. diff --git a/docs/design/0201-v18-migration-command-wiring/v18-migration-command-wiring.md b/docs/design/0201-v18-migration-command-wiring/v18-migration-command-wiring.md new file mode 100644 index 00000000..337397bb --- /dev/null +++ b/docs/design/0201-v18-migration-command-wiring/v18-migration-command-wiring.md @@ -0,0 +1,117 @@ +--- +cycle: 0201 +task_id: V18_migration_command_wiring +status: Complete +sponsors: + human: James + agent: Codex +started_at: 2026-05-24 +completed_at: 2026-05-24 +release_home: v18.0.0 +bearing_task: 53 +promotes_backlog: + - docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md +--- + +# V18 Migration Command Wiring + +## Sponsor Human + +James. + +## Sponsor Agent + +Codex. + +## Hill + +Wire the v18 graph-model migration steps into one command-level flow while +keeping finalization explicit and gated. + +## Playback Questions + +- Does the command run dry-run planning, lowering, scratch writing, and + equivalence in order? +- Does it remain non-finalizing by default? +- Does finalization require explicit finalization options and confirmation? +- Does failed equivalence prevent archive/live ref updates? +- Does the command expose enough typed result evidence for CLI formatting? + +## Existing Shape + +Slices 46 through 52 created fixture restore, source inventory, operation +lowering, scratch writing, equivalence gating, finalization safety, and +archive-preserving finalization. The missing step was a command-level +orchestrator that puts those pieces in the right order. + +## Chosen Boundary + +Add a script-level command runner under +`scripts/v18.0.0/migrations/graph-model/`. It accepts typed request and +reading values rather than parsing command-line text. The existing dry-run CLI +remains non-destructive; a broader user-facing parser can wrap this runner +later. + +The command runner: + +1. plans a dry-run migration; +2. lowers successful plans; +3. writes scratch history; +4. gates supplied legacy/scratch readings; +5. optionally builds finalization safety and calls the finalizer. + +## Non-Goals + +- Do not infer legacy or scratch readings from Git in this slice. +- Do not add a broad operator CLI parser. +- Do not finalize without explicit finalization options. +- Do not skip the equivalence gate. +- Do not claim post-migration runtime conformance. + +## RED Plan + +Add command tests: + +- default run writes scratch history and does not finalize; +- explicit finalization archives and advances live refs after passing gate; +- divergent supplied readings block finalization and leave live refs intact. + +## GREEN Plan + +Keep orchestration thin. Reuse the existing planner, lowerer, scratch writer, +equivalence gate, finalization safety gate, and finalizer instead of creating +parallel command-local checks. + +## Verification + +```text +npx vitest run test/unit/scripts/v18-migration-command.test.ts --reporter=verbose +npx eslint --no-warn-ignored scripts/v18.0.0/migrations/graph-model/GraphModelMigrationCommand.ts test/unit/scripts/v18-migration-command.test.ts +npm run typecheck +``` + +## Closeout Criteria + +- The command flow is ordered. +- Default operation is non-finalizing. +- Explicit finalization uses the safety/finalizer path. +- Failed equivalence blocks live ref changes. + +## Closeout + +Slice 53 added `runGraphModelMigrationCommand`. The command runner wires +dry-run planning, operation lowering, scratch writing, equivalence gating, and +optional finalization. Finalization is absent by default and only runs when +explicit finalization options are supplied. + +The runner still consumes supplied legacy and scratch readings. Real-history +reading construction remains the next proof gap before public release. + +## SSJS Scorecard + +- Runtime-backed forms: green; command result carries named stage results. +- Boundary validation: green; typed values cross the command boundary. +- Behavior ownership: green; orchestration orders existing services. +- Message parsing: green; no command behavior parses formatted output. +- Ambient time or entropy: green; command does not create identities. +- Fake shape trust or cast-cosplay: green; tests use real Git refs. diff --git a/docs/design/0202-v18-post-migration-runtime-conformance/v18-post-migration-runtime-conformance.md b/docs/design/0202-v18-post-migration-runtime-conformance/v18-post-migration-runtime-conformance.md new file mode 100644 index 00000000..d93066bf --- /dev/null +++ b/docs/design/0202-v18-post-migration-runtime-conformance/v18-post-migration-runtime-conformance.md @@ -0,0 +1,118 @@ +--- +cycle: 0202 +task_id: V18_post_migration_runtime_conformance +status: Complete +sponsors: + human: James + agent: Codex +started_at: 2026-05-24 +completed_at: 2026-05-24 +release_home: v18.0.0 +bearing_task: 54 +promotes_backlog: + - docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md + - docs/method/backlog/v18.0.0/TRUST_genesis-replay-equivalence.md +--- + +# V18 Post-Migration Runtime Conformance + +## Sponsor Human + +James. + +## Sponsor Agent + +Codex. + +## Hill + +Prevent finalization unless post-migration scratch output has explicit runtime +conformance evidence. + +## Playback Questions + +- Does finalization require conformance evidence in addition to equivalence? +- Does the conformance evidence name the scratch ref and head it covers? +- Does mismatched evidence fail closed? +- Does command wiring make the conformance provider explicit? +- Does the design avoid claiming scratch operation commits are runtime-readable + before replay integration exists? + +## Existing Shape + +Slice 53 wired the command flow and could finalize supplied readings after a +passing equivalence gate. That was still not enough for a release-quality +migration path: equivalence over supplied readings is not the same as proving +that the finalized live ref is readable by the normal runtime. + +## Chosen Boundary + +Add runtime conformance as explicit evidence required by finalization safety. +The evidence includes: + +- scratch ref; +- scratch head; +- pass/fail status; +- witness name; +- fatal errors for failed evidence. + +The command accepts a conformance provider that receives the actual scratch +write result. Finalization safety rejects missing evidence and evidence that +does not match the scratch ref/head. + +## Non-Goals + +- Do not claim that scratch migration-operation commits are already complete + runtime patch commits. +- Do not make finalization infer conformance from equivalence alone. +- Do not add a fake runtime adapter. +- Do not parse report text as proof. + +## RED Plan + +Add safety and command tests: + +- finalization without runtime conformance is rejected; +- conformance for a different scratch head is rejected; +- command finalization supplies conformance through an explicit provider; +- divergent equivalence still blocks even when a conformance provider exists. + +## GREEN Plan + +Add `GraphModelMigrationRuntimeConformanceResult` and thread it through +`GraphModelMigrationFinalizationRequest`, `GraphModelMigrationFinalizationSafety`, +and `runGraphModelMigrationCommand`. + +## Verification + +```text +npx vitest run test/unit/domain/migrations/GraphModelMigrationFinalizationSafety.test.ts test/unit/scripts/v18-migration-finalizer.test.ts test/unit/scripts/v18-migration-command.test.ts --reporter=verbose +npx eslint --no-warn-ignored src/domain/migrations/GraphModelMigrationRuntimeConformanceResult.ts src/domain/migrations/GraphModelMigrationFinalizationRequest.ts src/domain/migrations/GraphModelMigrationFinalizationSafety.ts scripts/v18.0.0/migrations/graph-model/GraphModelMigrationCommand.ts test/unit/domain/migrations/GraphModelMigrationFinalizationSafety.test.ts test/unit/scripts/v18-migration-finalizer.test.ts test/unit/scripts/v18-migration-command.test.ts +npm run typecheck +``` + +## Closeout Criteria + +- Runtime conformance evidence is required by finalization. +- Evidence must match the scratch ref and head. +- Command finalization receives conformance from an explicit provider. +- The remaining runtime replay gap is visible in docs and bearing. + +## Closeout + +Slice 54 added `GraphModelMigrationRuntimeConformanceResult` and made +finalization safety require matching runtime conformance evidence. This is a +release-safety improvement, not a claim that the migration-operation scratch +history is already a native runtime patch stream. + +The next release-grade step is to replace test-supplied conformance providers +with a real runtime replay check over the finalized graph-model history. + +## SSJS Scorecard + +- Runtime-backed forms: green; conformance evidence is a named value. +- Boundary validation: green; finalization validates evidence before Git I/O. +- Behavior ownership: green; conformance evidence gates, finalizer mutates. +- Message parsing: green; witness strings are not parsed as behavior. +- Ambient time or entropy: green; no clocks or randomness. +- Fake shape trust or cast-cosplay: green; current gap is explicit. diff --git a/docs/design/0203-v18-content-property-closeout-audit/v18-content-property-closeout-audit.md b/docs/design/0203-v18-content-property-closeout-audit/v18-content-property-closeout-audit.md new file mode 100644 index 00000000..23f735b1 --- /dev/null +++ b/docs/design/0203-v18-content-property-closeout-audit/v18-content-property-closeout-audit.md @@ -0,0 +1,164 @@ +--- +cycle: 0203 +task_id: V18_content_property_closeout_audit +status: Complete +sponsors: + human: James + agent: Codex +started_at: 2026-05-24 +completed_at: 2026-05-24 +release_home: v18.0.0 +bearing_task: 55 +promotes_backlog: + - docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md + - docs/method/backlog/v18.0.0/PROTO_legacy-props-as-projection.md +--- + +# V18 Content Property Closeout Audit + +## Sponsor Human + +James. + +## Sponsor Agent + +Codex. + +## Hill + +Close this v18 batch by making remaining raw content/property compatibility +boundaries explicit before the drift check. + +## Playback Questions + +- Which `src/domain` files still mention raw compatibility content or property + storage? +- Are those files named boundaries rather than accidental public read leaks? +- Does a test fail if a new raw compatibility boundary appears without review? +- Does BEARING record the remaining release blockers honestly? +- Does this closeout avoid claiming storage migration is complete? + +## Existing Shape + +Slices 46 through 54 built persisted-history fixtures, source inventory, +operation lowering, scratch writing, equivalence gating, finalization safety, +archive-preserving finalization, command wiring, and runtime conformance +evidence gating. + +The content/property storage plane is still not fully cut over. Legacy +`_content*` and raw property-map state remain compatibility inputs for the +current runtime and for migration evidence. + +## Chosen Boundary + +Run the raw compatibility audit over `src/domain` for: + +```text +decodePropKey|decodeEdgePropKey|state\.prop|_content +``` + +Then add an executable shape test that requires every matching file to appear +in this design document. + +## Current Raw Compatibility Files + +The current audited files are: + +- `src/domain/graph/LegacyContentPropertyKeys.ts` +- `src/domain/services/ContentAttachmentProjection.ts` +- `src/domain/services/CoordinateFactExport.ts` +- `src/domain/services/ImmutableSnapshot.ts` +- `src/domain/services/JoinReducer.ts` +- `src/domain/services/KeyCodec.ts` +- `src/domain/services/OpStrategies.ts` +- `src/domain/services/OpStrategy.ts` +- `src/domain/services/PatchBuilder.ts` +- `src/domain/services/PatchBuilderValidation.ts` +- `src/domain/services/PatchCommitter.ts` +- `src/domain/services/TemporalQuery.ts` +- `src/domain/services/VisibleStateScope.ts` +- `src/domain/services/index/LogicalIndexBuildService.ts` +- `src/domain/services/state/CheckpointSerializer.ts` +- `src/domain/services/state/StateDiff.ts` +- `src/domain/services/state/StateSerializer.ts` +- `src/domain/services/state/WarpState.ts` +- `src/domain/services/state/checkpointHelpers.ts` +- `src/domain/services/strand/StrandPatchService.ts` +- `src/domain/services/transfer/transferOps.ts` +- `src/domain/types/CoordinateComparison.ts` +- `src/domain/types/ops/EdgePropSet.ts` +- `src/domain/types/ops/NodePropSet.ts` +- `src/domain/types/ops/PropSet.ts` +- `src/domain/types/ops/propHelpers.ts` + +## Classification + +These files fall into bounded categories: + +- Legacy content compatibility key ownership: + `LegacyContentPropertyKeys`, `ContentAttachmentProjection`. +- Fact export and coordinate comparison over existing operation shapes: + `CoordinateFactExport`, `CoordinateComparison`. +- Runtime mutation and compatibility operation execution: + `JoinReducer`, `OpStrategies`, `OpStrategy`, `PatchBuilder`, + `PatchCommitter`, `StrandPatchService`, `transferOps`, and the op helper + classes. +- Guard, replay, serialization, snapshot, scope, and index boundaries: + `PatchBuilderValidation`, `TemporalQuery`, `ImmutableSnapshot`, + `VisibleStateScope`, `LogicalIndexBuildService`, `CheckpointSerializer`, + `StateDiff`, `StateSerializer`, `WarpState`, and `checkpointHelpers`. +- Codec ownership: `KeyCodec`. + +## Non-Goals + +- Do not remove legacy raw storage in this slice. +- Do not claim native runtime replay over migrated scratch history. +- Do not modify reducers or serializers during the audit. +- Do not add release version changes. + +## RED Plan + +Add a test that scans `src/domain` for the audit pattern and fails when the +matching file set differs from this documented list. + +## GREEN Plan + +Document every current match and make the test compare against the list. +Future work that adds or removes raw compatibility boundaries must update the +design evidence deliberately. + +## Verification + +```text +npx vitest run test/unit/scripts/v18-content-property-closeout-audit.test.ts --reporter=verbose +npx eslint --no-warn-ignored test/unit/scripts/v18-content-property-closeout-audit.test.ts +npm run typecheck +npm run lint:md +npm run lint:sludge +npm run lint:semgrep +git diff --check +``` + +## Closeout Criteria + +- The raw compatibility file set is explicit. +- The design document contains every audited file path. +- A test fails on unreviewed boundary drift. +- BEARING names the remaining release blockers. + +## Closeout + +Slice 55 closes the branch batch, not the v18 migration program. The audit +proves that raw content/property compatibility surfaces are still present and +bounded. The remaining public-release work is to build real-history reading +construction and a real runtime conformance provider, then reduce this audited +legacy storage surface through subsequent migration slices. + +## SSJS Scorecard + +- Runtime-backed forms: green; no new runtime model was invented. +- Boundary validation: green; raw boundaries are enumerated. +- Behavior ownership: green; audit does not move behavior. +- Message parsing: green; no message parsing. +- Ambient time or entropy: green; no clocks or randomness. +- Fake shape trust or cast-cosplay: green; remaining gaps are explicit. diff --git a/docs/design/0204-v18-legacy-fixture-reading-construction/v18-legacy-fixture-reading-construction.md b/docs/design/0204-v18-legacy-fixture-reading-construction/v18-legacy-fixture-reading-construction.md new file mode 100644 index 00000000..de27435d --- /dev/null +++ b/docs/design/0204-v18-legacy-fixture-reading-construction/v18-legacy-fixture-reading-construction.md @@ -0,0 +1,39 @@ +--- +cycle: 0204 +task_id: V18_legacy_fixture_reading_construction +status: Complete +sponsors: + human: James + agent: Codex +started_at: 2026-05-24 +completed_at: 2026-05-24 +release_home: v18.0.0 +bearing_task: 56 +promotes_backlog: + - docs/method/backlog/v18.0.0/TRUST_genesis-replay-equivalence.md +--- + +# V18 Legacy Fixture Reading Construction + +## Hill + +Construct a `GenesisEquivalenceReading` from the restored v17 golden fixture +manifest instead of relying only on hand-authored compact fixture readings. + +## Chosen Boundary + +`V17GoldenGraphFixtureGenesisReading` is a pure migration-domain builder. It +projects manifest-visible facts into equivalence facts and assigns +deterministic boundary evidence from the manifest writer chains. + +## Closeout + +Slice 56 added the builder and test coverage over +`fixtures/v17/graph-model-golden/manifest.json`. The reading is still +manifest-declared evidence, not a full replay-derived read model. + +## Verification + +```text +npx vitest run test/unit/domain/migrations/V17GoldenGraphFixtureGenesisReading.test.ts --reporter=verbose +``` diff --git a/docs/design/0205-v18-scratch-operation-reading-construction/v18-scratch-operation-reading-construction.md b/docs/design/0205-v18-scratch-operation-reading-construction/v18-scratch-operation-reading-construction.md new file mode 100644 index 00000000..30390cd7 --- /dev/null +++ b/docs/design/0205-v18-scratch-operation-reading-construction/v18-scratch-operation-reading-construction.md @@ -0,0 +1,39 @@ +--- +cycle: 0205 +task_id: V18_scratch_operation_reading_construction +status: Complete +sponsors: + human: James + agent: Codex +started_at: 2026-05-24 +completed_at: 2026-05-24 +release_home: v18.0.0 +bearing_task: 57 +promotes_backlog: + - docs/method/backlog/v18.0.0/TRUST_genesis-replay-equivalence.md +--- + +# V18 Scratch Operation Reading Construction + +## Hill + +Build `GenesisEquivalenceReading` values from scratch migration operation +commits. + +## Chosen Boundary + +`GraphModelMigrationScratchReadingBuilder` is a script-layer Git adapter. It +reads `migration-operation.txt` from scratch commits and projects operation +facts into equivalence facts with scratch commit boundary evidence. + +## Closeout + +Slice 57 removes another hand-authored test fixture dependency. The builder is +still operation-derived; it is not yet normal runtime replay over native graph +history. + +## Verification + +```text +npx vitest run test/unit/scripts/v18-scratch-reading-builder.test.ts --reporter=verbose +``` diff --git a/docs/design/0206-v18-command-reading-providers/v18-command-reading-providers.md b/docs/design/0206-v18-command-reading-providers/v18-command-reading-providers.md new file mode 100644 index 00000000..b40ad44c --- /dev/null +++ b/docs/design/0206-v18-command-reading-providers/v18-command-reading-providers.md @@ -0,0 +1,33 @@ +--- +cycle: 0206 +task_id: V18_command_reading_providers +status: Complete +sponsors: + human: James + agent: Codex +started_at: 2026-05-24 +completed_at: 2026-05-24 +release_home: v18.0.0 +bearing_task: 58 +promotes_backlog: + - docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md +--- + +# V18 Command Reading Providers + +## Hill + +Let the migration command construct equivalence readings after scratch writing +instead of requiring pre-built readings. + +## Closeout + +Slice 58 added command reading providers. The command still accepts explicit +readings for focused tests, but can now call a legacy provider and a +scratch-provider after scratch history exists. + +## Verification + +```text +npx vitest run test/unit/scripts/v18-migration-command.test.ts --reporter=verbose +``` diff --git a/docs/design/0207-v18-scratch-runtime-conformance-provider/v18-scratch-runtime-conformance-provider.md b/docs/design/0207-v18-scratch-runtime-conformance-provider/v18-scratch-runtime-conformance-provider.md new file mode 100644 index 00000000..2d90a014 --- /dev/null +++ b/docs/design/0207-v18-scratch-runtime-conformance-provider/v18-scratch-runtime-conformance-provider.md @@ -0,0 +1,39 @@ +--- +cycle: 0207 +task_id: V18_scratch_runtime_conformance_provider +status: Complete +sponsors: + human: James + agent: Codex +started_at: 2026-05-24 +completed_at: 2026-05-24 +release_home: v18.0.0 +bearing_task: 59 +promotes_backlog: + - docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md +--- + +# V18 Scratch Runtime Conformance Provider + +## Hill + +Replace test-supplied runtime conformance evidence with an adapter-level +provider that reads scratch migration history back from Git before +finalization can trust it. + +## Closeout + +Slice 59 added `GraphModelMigrationScratchRuntimeConformanceProvider`. The +provider verifies that the scratch ref still points at the expected scratch +head, then builds scratch genesis-equivalence evidence from the actual +operation commits. + +This is intentionally an operation-history readback provider. It does not yet +claim full production runtime replay through the normal graph-opening path. + +## Verification + +```text +npx vitest run test/unit/scripts/v18-scratch-runtime-conformance-provider.test.ts --reporter=verbose +npx vitest run test/unit/scripts/v18-migration-command.test.ts --reporter=verbose +``` diff --git a/docs/design/0208-v18-command-provider-finalization/v18-command-provider-finalization.md b/docs/design/0208-v18-command-provider-finalization/v18-command-provider-finalization.md new file mode 100644 index 00000000..6eb50c29 --- /dev/null +++ b/docs/design/0208-v18-command-provider-finalization/v18-command-provider-finalization.md @@ -0,0 +1,34 @@ +--- +cycle: 0208 +task_id: V18_command_provider_finalization +status: Complete +sponsors: + human: James + agent: Codex +started_at: 2026-05-24 +completed_at: 2026-05-24 +release_home: v18.0.0 +bearing_task: 60 +promotes_backlog: + - docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md +--- + +# V18 Command Provider Finalization + +## Hill + +Prove the command can finalize with command-owned readings and real scratch +operation readback evidence instead of test-supplied finalization proof. + +## Closeout + +Slice 60 changed the command finalization regression to run with a legacy +reading provider, a scratch reading provider, and the scratch runtime +conformance provider. The live ref moves only after those providers produce +passing equivalence and matching scratch runtime evidence. + +## Verification + +```text +npx vitest run test/unit/scripts/v18-migration-command.test.ts --reporter=verbose +``` diff --git a/docs/design/0209-v18-provider-divergence-coverage/v18-provider-divergence-coverage.md b/docs/design/0209-v18-provider-divergence-coverage/v18-provider-divergence-coverage.md new file mode 100644 index 00000000..268cbef2 --- /dev/null +++ b/docs/design/0209-v18-provider-divergence-coverage/v18-provider-divergence-coverage.md @@ -0,0 +1,34 @@ +--- +cycle: 0209 +task_id: V18_provider_divergence_coverage +status: Complete +sponsors: + human: James + agent: Codex +started_at: 2026-05-24 +completed_at: 2026-05-24 +release_home: v18.0.0 +bearing_task: 61 +promotes_backlog: + - docs/method/backlog/v18.0.0/TRUST_genesis-replay-equivalence.md +--- + +# V18 Provider Divergence Coverage + +## Hill + +Prove that provider-built scratch readings still block finalization when they +diverge from the legacy reading. + +## Closeout + +Slice 61 added command coverage where scratch history is written, read back +from Git through the scratch reading provider, and proven readable by the +runtime conformance provider, but finalization still refuses promotion because +the legacy reading disagrees with the scratch reading. + +## Verification + +```text +npx vitest run test/unit/scripts/v18-migration-command.test.ts --reporter=verbose +``` diff --git a/docs/design/0210-v18-migration-command-report/v18-migration-command-report.md b/docs/design/0210-v18-migration-command-report/v18-migration-command-report.md new file mode 100644 index 00000000..80963088 --- /dev/null +++ b/docs/design/0210-v18-migration-command-report/v18-migration-command-report.md @@ -0,0 +1,33 @@ +--- +cycle: 0210 +task_id: V18_migration_command_report +status: Complete +sponsors: + human: James + agent: Codex +started_at: 2026-05-24 +completed_at: 2026-05-24 +release_home: v18.0.0 +bearing_task: 62 +promotes_backlog: + - docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md +--- + +# V18 Migration Command Report + +## Hill + +Give operators deterministic text output for the migration command's planning, +scratch, equivalence, and finalization evidence. + +## Closeout + +Slice 62 added `formatGraphModelMigrationCommandReport`. The report emits +stage status, operation counts, scratch ref/head evidence, equivalence fact +counts, finalization ref evidence, and fatal notice codes/messages. + +## Verification + +```text +npx vitest run test/unit/scripts/v18-migration-command.test.ts --reporter=verbose +``` diff --git a/docs/design/0211-v18-migration-command-cli-wrapper/v18-migration-command-cli-wrapper.md b/docs/design/0211-v18-migration-command-cli-wrapper/v18-migration-command-cli-wrapper.md new file mode 100644 index 00000000..9cfd54fe --- /dev/null +++ b/docs/design/0211-v18-migration-command-cli-wrapper/v18-migration-command-cli-wrapper.md @@ -0,0 +1,35 @@ +--- +cycle: 0211 +task_id: V18_migration_command_cli_wrapper +status: Complete +sponsors: + human: James + agent: Codex +started_at: 2026-05-24 +completed_at: 2026-05-24 +release_home: v18.0.0 +bearing_task: 63 +promotes_backlog: + - docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md +--- + +# V18 Migration Command CLI Wrapper + +## Hill + +Expose the wired migration command through a narrow operator CLI without +opening live-ref finalization from shell flags. + +## Closeout + +Slice 63 added `migrate.ts` and `GraphModelMigrationCommandCli`. The wrapper +requires an explicit repository, request JSON, v17 fixture manifest, and +scratch ref. It writes scratch history, constructs command-owned legacy and +scratch readings, emits the deterministic command report, and refuses +finalization flags until live-ref CLI finalization has its own design. + +## Verification + +```text +npx vitest run test/unit/scripts/v18-graph-model-migration-command-cli.test.ts --reporter=verbose +``` diff --git a/docs/design/0212-v18-public-release-blockers/v18-public-release-blockers.md b/docs/design/0212-v18-public-release-blockers/v18-public-release-blockers.md new file mode 100644 index 00000000..f8ca996f --- /dev/null +++ b/docs/design/0212-v18-public-release-blockers/v18-public-release-blockers.md @@ -0,0 +1,34 @@ +--- +cycle: 0212 +task_id: V18_public_release_blockers +status: Complete +sponsors: + human: James + agent: Codex +started_at: 2026-05-24 +completed_at: 2026-05-24 +release_home: v18.0.0 +bearing_task: 64 +promotes_backlog: + - docs/method/backlog/v18.0.0/RELEASE_v18-public-release-blockers.md +--- + +# V18 Public Release Blockers + +## Hill + +Make the remaining public-release blockers explicit before the migration +command looks more complete than its evidence. + +## Closeout + +Slice 64 added a v18 release-blocker backlog note. The blockers call out +production-runtime scratch replay, live finalization CLI design, wet-run +fixture harnessing, generated Continuum contract tie-back, and release notes +that preserve the sibling-participant doctrine. + +## Verification + +```text +npm run lint:md +``` diff --git a/docs/design/0213-v18-replan-after-command-cli/v18-replan-after-command-cli.md b/docs/design/0213-v18-replan-after-command-cli/v18-replan-after-command-cli.md new file mode 100644 index 00000000..d549f616 --- /dev/null +++ b/docs/design/0213-v18-replan-after-command-cli/v18-replan-after-command-cli.md @@ -0,0 +1,47 @@ +--- +cycle: 0213 +task_id: V18_replan_after_command_cli +status: Complete +sponsors: + human: James + agent: Codex +started_at: 2026-05-24 +completed_at: 2026-05-24 +release_home: v18.0.0 +bearing_task: 65 +promotes_backlog: + - docs/BEARING.md + - docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md +--- + +# V18 Replan After Command CLI + +## Hill + +Use the evidence from slices 56 through 64 to reset the next v18 goalpost +before opening the PR. + +## Evidence + +- The branch was clean before this replan edit. +- `git rev-list --left-right --count origin/main...HEAD` reported `0 19`. +- The branch now contains legacy fixture readings, scratch readings, command + reading providers, scratch operation readback conformance, provider-backed + finalization coverage, divergence coverage, command reporting, a + non-finalizing command CLI wrapper, and public release blocker docs. + +## Closeout + +Slice 65 updates `BEARING` and the migration backlog. The next goalpost is no +longer "can we write and inspect scratch history"; it is "can we prove scratch +history through the production runtime and run a wet migration harness without +touching live refs." + +## Verification + +```text +git status --short +git log --oneline --decorate --max-count=16 origin/main..HEAD +git diff --stat origin/main...HEAD +git rev-list --left-right --count origin/main...HEAD +``` diff --git a/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md b/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md index bdb5774a..f6f54511 100644 --- a/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md +++ b/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md @@ -47,12 +47,35 @@ V18 slices 36 through 45 completed the non-destructive foundation: Remaining migration-tool work is intentionally ordered as: -- slice 46: create v17 golden graph-history fixtures and restore checks; -- slice 47: collect real source inventory from restored history; -- slice 48: lower dry-run planned operations; -- slice 49: write scratch migrated history; -- slice 50: gate scratch output with genesis equivalence; -- slice 51: design finalization safety. +- slice 46: create v17 golden graph-history fixtures and restore checks + (complete); +- slice 47: collect real source inventory from restored history (complete); +- slice 48: lower dry-run planned operations (complete); +- slice 49: write scratch migrated history (complete); +- slice 50: gate scratch output with genesis equivalence (complete); +- slice 51: design finalization safety (complete); +- slice 52: implement archive-preserving finalization (complete); +- slice 53: wire the end-to-end migration command (complete); +- slice 54: prove post-migration runtime conformance (conformance evidence + gate complete; real runtime replay provider still release-critical); +- slice 55: close the content/property migration audit (complete). +- slice 56: construct legacy fixture genesis readings (complete); +- slice 57: construct scratch operation genesis readings (complete); +- slice 58: add command reading providers (complete). +- slice 59: add a scratch runtime conformance provider (operation-history + readback complete; production runtime replay still release-critical). +- slice 60: prove command finalization with command-owned readings and scratch + runtime conformance (complete). +- slice 61: prove provider-built scratch readings still block finalization on + divergence (complete). +- slice 62: add deterministic operator report output for migration command + evidence (complete). +- slice 63: add a non-finalizing migration command CLI wrapper that writes + scratch history and refuses live-ref finalization flags (complete). +- slice 64: record v18 public release blockers before widening release claims + (complete). +- slice 65: replan with command-CLI evidence in hand and set the next + production-runtime replay goalpost (complete). ## Starting points diff --git a/docs/method/backlog/v18.0.0/README.md b/docs/method/backlog/v18.0.0/README.md index 314508cf..0bd4e166 100644 --- a/docs/method/backlog/v18.0.0/README.md +++ b/docs/method/backlog/v18.0.0/README.md @@ -74,8 +74,8 @@ graph model. Change the envelope only if replay honesty requires it. ## Current Evidence -After v18 slices 41 through 45, the migration path is intentionally still -non-destructive: +After v18 slice 49, the migration path is intentionally still +non-destructive but now has persisted-history evidence: - dry-run request JSON can be decoded at the infrastructure boundary; - the dry-run CLI can emit deterministic manifest output and refuses @@ -86,5 +86,30 @@ non-destructive: divergent-property cases; - v17 golden graph-history fixtures now precede write-capable migration work, because compact fixtures do not prove the persisted Git object/ref layout; -- real source inventory, operation lowering, scratch writing, scratch - equivalence, and finalization safety are planned as slices 47 through 51. +- the first v17 golden fixture restores real `refs/warp/*` writer refs from a + Git bundle and validates manifest heads, patch counts, and visible fact + families; +- restored source inventory collection now reads real writer refs and patch + commit trailers into migration-domain source inventory; +- operation lowering now creates write-ready migration operation facts from + successful dry-run plans without writing history; +- scratch writing now creates deterministic operation commits under explicit + `refs/warp-migration-scratch/*` refs and refuses live graph refs; +- scratch equivalence now gates promotion on proof success, first-divergence + reporting, and required patch-boundary evidence; +- finalization safety now requires explicit confirmation, archive ref + selection, scratch output evidence, a passed equivalence gate, and a matching + live-ref expected head before any live lineage promotion can be implemented; +- archive-preserving finalization now creates archive refs and advances live + refs only through expected-head `git update-ref` calls; +- command wiring now runs planning, lowering, scratch writing, equivalence, + and optional finalization in order while keeping finalization off by default; +- finalization now also requires runtime conformance evidence tied to the + exact scratch ref and head, making the remaining real-runtime replay provider + an explicit release blocker instead of an implicit assumption; +- raw content/property compatibility boundaries are now enumerated by an + executable closeout audit so new raw boundaries require deliberate review. +- public-release blockers are now explicit in + [`RELEASE_v18-public-release-blockers.md`](RELEASE_v18-public-release-blockers.md), + including production-runtime replay, live finalization CLI design, wet-run + fixture harnessing, Continuum contract tie-back, and operator release notes. diff --git a/docs/method/backlog/v18.0.0/RELEASE_v18-public-release-blockers.md b/docs/method/backlog/v18.0.0/RELEASE_v18-public-release-blockers.md new file mode 100644 index 00000000..43cb8571 --- /dev/null +++ b/docs/method/backlog/v18.0.0/RELEASE_v18-public-release-blockers.md @@ -0,0 +1,49 @@ +--- +id: RELEASE_v18-public-release-blockers +blocked_by: + - INFRA_graph-model-migration-tool + - TRUST_genesis-replay-equivalence +blocks: [] +feature: graph-model-substrate +--- + +# v18 public release blockers + +## Why + +The v18 migration path now has enough operator surface area that the release +line needs explicit blockers. A public release must not imply stronger +migration safety than the repository can prove. + +## Done looks like + +- scratch output is opened through the production graph runtime, not only + operation-history readback +- live-ref finalization from the CLI has its own confirmation design, + drift checks, archive evidence, and report output +- the v17 golden graph fixture has a wet-run migration path that restores the + fixture, writes scratch history, runs equivalence, and captures the operator + report +- Continuum/WARP Optic contract evidence is tied back to generated artifacts, + not only handwritten compatibility prose +- release notes clearly distinguish v18 graph-model convergence from later + Continuum admission shells + +## Current blockers + +| Blocker | Why it blocks public release | Evidence now | +|---------|------------------------------|--------------| +| Production-runtime scratch replay | Operation-history readback proves the scratch commits are parseable, but not that the normal graph runtime can open the migrated history. | `GraphModelMigrationScratchRuntimeConformanceProvider` is intentionally operation-derived. | +| Live finalization CLI design | The command can finalize through the API, but the shell wrapper correctly refuses live-ref finalization flags until operator confirmation semantics are designed. | `GraphModelMigrationCommandCli` rejects `--finalize` and related flags. | +| Wet-run fixture harness | The v17 fixture and scratch writer exist separately; the release gate needs one reproducible wet run that restores the fixture and executes the wrapper. | Fixture restore, source inventory, scratch writer, command wrapper, and report formatter exist. | +| Continuum contract tie-back | v18 is aimed at WARP Optic compatibility, so release claims need generated contract evidence from Wesley/Continuum artifacts. | Earlier slices recorded readiness and source facts, but graph-model migration work is still mostly git-warp-local. | +| Operator release notes | Users need plain release guidance on what v18 migrates, what it does not migrate, and why Echo and git-warp remain sibling participants. | BEARING has the doctrine; release notes are not yet cut. | + +## Next pull candidates + +- Design and implement production-runtime scratch replay conformance. +- Design live-ref finalization CLI confirmation and report behavior. +- Add a fixture wet-run command or documented harness around the current + restore plus command CLI path. +- Attach generated Continuum/WARP Optic contract evidence to the v18 release + gate. diff --git a/docs/method/backlog/v18.0.0/TRUST_genesis-replay-equivalence.md b/docs/method/backlog/v18.0.0/TRUST_genesis-replay-equivalence.md index 18cba9ff..e0a12b0c 100644 --- a/docs/method/backlog/v18.0.0/TRUST_genesis-replay-equivalence.md +++ b/docs/method/backlog/v18.0.0/TRUST_genesis-replay-equivalence.md @@ -37,9 +37,30 @@ V18 slices 42 through 44 added fixture-level proof infrastructure: - `GenesisDivergenceReporter` selects the first deterministic mismatch and reports field and patch-boundary evidence. -This is not yet the ship gate. The remaining trust work is to restore a real -v17 golden graph-history fixture, then connect legacy replay and scratch -migrated replay through slices 46 through 50. +Slice 50 added the first promotion gate over that proof vocabulary: + +- `GenesisEquivalenceGate` runs proof comparison over legacy and scratch + reading nouns; +- failed proofs carry a first divergence report; +- otherwise-equivalent readings still block promotion when visible facts lack + patch-boundary evidence. + +This is now a gate vocabulary, but not yet the complete ship gate. The +remaining trust work is to construct legacy and scratch readings from real +Git history and replace test-supplied runtime conformance evidence with a real +runtime replay provider. + +Slice 56 added a pure reading builder for the v17 golden fixture manifest. It +is a bridge from persisted fixture metadata to equivalence facts, but it is +not yet a full replay-derived read model. + +Slice 57 added a scratch reading builder over migration-operation commits. It +constructs equivalence facts from scratch Git history, but remains +operation-derived rather than normal runtime replay. + +Slices 59 through 61 added operation-history readback conformance and command +coverage proving that readable scratch output still cannot finalize when the +legacy and scratch readings diverge. ## Starting points diff --git a/fixtures/v17/graph-model-golden/README.md b/fixtures/v17/graph-model-golden/README.md new file mode 100644 index 00000000..8866a61c --- /dev/null +++ b/fixtures/v17/graph-model-golden/README.md @@ -0,0 +1,40 @@ +# V17 Golden Graph-Model Fixture + +This fixture is the first persisted-history witness for the v18 graph-model +migration path. The canonical artifact is `v17-golden-graph.bundle`; the +manifest is the operator-readable contract for the refs, heads, chain lengths, +and visible graph facts that the bundle must restore. + +The fixture intentionally uses real `refs/warp//writers/` refs +and patch-shaped commits with trailer-coded v17 patch metadata. It is not a +JSON-only mock and it is not a raw `.git` directory snapshot. + +## Restore + +Use the slice 46 restore helper from tests or scripts: + +```text +restoreV17GoldenGraphFixture({ + manifestPath: "fixtures/v17/graph-model-golden/manifest.json", + targetDirectory: "" +}) +``` + +The helper initializes the target repository, fetches the bundle refs, and +verifies the expected writer heads and patch counts. + +## Regeneration + +Regeneration must preserve deterministic commit inputs: + +- author and committer name: `Git Warp Fixture`; +- author and committer email: `fixture@git-warp.local`; +- author and committer date: `2026-01-01T00:00:00Z`; +- graph id: `v17-golden-graph`; +- refs: + - `refs/warp/v17-golden-graph/writers/alice`; + - `refs/warp/v17-golden-graph/writers/bob`. + +After regeneration, update `manifest.json` with the new writer heads and keep +the visible fact coverage over node, edge, property, content, removal, and +multi-writer cases. diff --git a/fixtures/v17/graph-model-golden/manifest.json b/fixtures/v17/graph-model-golden/manifest.json new file mode 100644 index 00000000..bdc0c798 --- /dev/null +++ b/fixtures/v17/graph-model-golden/manifest.json @@ -0,0 +1,53 @@ +{ + "fixtureId": "v17-golden-graph-model-001", + "graphId": "v17-golden-graph", + "sourceVersion": "17.0.1", + "generator": "deterministic git commit-tree fixture", + "bundlePath": "v17-golden-graph.bundle", + "writerChains": [ + { + "writerId": "alice", + "refName": "refs/warp/v17-golden-graph/writers/alice", + "expectedHead": "417fe95095a6feae3042c36505065bbd7b3d2a67", + "patchCount": 3 + }, + { + "writerId": "bob", + "refName": "refs/warp/v17-golden-graph/writers/bob", + "expectedHead": "d7c3a05b3894d5c3c151e03dd972b6bd6c341b0c", + "patchCount": 2 + } + ], + "visibleFacts": [ + { + "kind": "node", + "key": "node:alpha", + "description": "Alice creates the primary node lifecycle subject." + }, + { + "kind": "edge", + "key": "node:alpha->node:beta:relates", + "description": "Alice creates a stable edge identity with label evidence." + }, + { + "kind": "property", + "key": "node:alpha:title", + "description": "Alice and Bob cover legacy node property compatibility." + }, + { + "kind": "content", + "key": "node:alpha:_content", + "description": "Content attachment compatibility uses legacy _content facts." + }, + { + "kind": "removal", + "key": "node:removed", + "description": "Alice covers removed-node visibility and tombstone behavior." + }, + { + "kind": "multi-writer", + "key": "writers:alice+bob", + "description": "Independent writer refs cover non-coordinated history." + } + ] +} diff --git a/fixtures/v17/graph-model-golden/v17-golden-graph.bundle b/fixtures/v17/graph-model-golden/v17-golden-graph.bundle new file mode 100644 index 00000000..42ad465d Binary files /dev/null and b/fixtures/v17/graph-model-golden/v17-golden-graph.bundle differ diff --git a/scripts/v18.0.0/migrations/graph-model/GitMigrationCommandRunner.ts b/scripts/v18.0.0/migrations/graph-model/GitMigrationCommandRunner.ts new file mode 100644 index 00000000..11098ac7 --- /dev/null +++ b/scripts/v18.0.0/migrations/graph-model/GitMigrationCommandRunner.ts @@ -0,0 +1,76 @@ +import { spawn } from 'node:child_process'; + +const MIGRATION_GIT_IDENTITY = Object.freeze({ + GIT_AUTHOR_NAME: 'git-warp migration', + GIT_AUTHOR_EMAIL: 'git-warp@example.invalid', + GIT_AUTHOR_DATE: '2000-01-01T00:00:00Z', + GIT_COMMITTER_NAME: 'git-warp migration', + GIT_COMMITTER_EMAIL: 'git-warp@example.invalid', + GIT_COMMITTER_DATE: '2000-01-01T00:00:00Z', +}); + +export type GitMigrationCommandRunnerOptions = { + readonly deterministicIdentity: boolean; +}; + +/** Captured result from one migration Git command. */ +export class GitMigrationCommandResult { + constructor( + readonly exitCode: number, + readonly stdout: string, + readonly stderr: string, + ) { + Object.freeze(this); + } + + ok(): boolean { + return this.exitCode === 0; + } +} + +/** Runs a Git command for migration tooling without invoking a shell. */ +export async function runMigrationGit( + cwd: string, + args: readonly string[], + input: string | null, + options: GitMigrationCommandRunnerOptions = { deterministicIdentity: false }, +): Promise { + return await new Promise((resolve, reject) => { + const child = spawnGit(cwd, args, options); + let stdout = ''; + let stderr = ''; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { + stdout += chunk; + }); + child.stderr.on('data', (chunk: string) => { + stderr += chunk; + }); + child.on('error', reject); + child.on('close', (exitCode) => { + resolve(new GitMigrationCommandResult(exitCode ?? 1, stdout, stderr)); + }); + if (input !== null) { + child.stdin.write(input); + } + child.stdin.end(); + }); +} + +function spawnGit( + cwd: string, + args: readonly string[], + options: GitMigrationCommandRunnerOptions, +) { + if (options.deterministicIdentity) { + return spawn('git', args, { + cwd, + env: { + ...process.env, + ...MIGRATION_GIT_IDENTITY, + }, + }); + } + return spawn('git', args, { cwd }); +} diff --git a/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationCommand.ts b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationCommand.ts new file mode 100644 index 00000000..efa9abde --- /dev/null +++ b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationCommand.ts @@ -0,0 +1,244 @@ +import DryRunGraphModelMigrationPlan + from '../../../../src/domain/migrations/DryRunGraphModelMigrationPlan.ts'; +import DryRunGraphModelMigrationPlanRequest + from '../../../../src/domain/migrations/DryRunGraphModelMigrationPlanRequest.ts'; +import DryRunGraphModelMigrationPlanner + from '../../../../src/domain/migrations/DryRunGraphModelMigrationPlanner.ts'; +import GenesisEquivalenceComparisonBasis + from '../../../../src/domain/migrations/GenesisEquivalenceComparisonBasis.ts'; +import GenesisEquivalenceGate from '../../../../src/domain/migrations/GenesisEquivalenceGate.ts'; +import GenesisEquivalenceGateResult + from '../../../../src/domain/migrations/GenesisEquivalenceGateResult.ts'; +import GenesisEquivalenceReading + from '../../../../src/domain/migrations/GenesisEquivalenceReading.ts'; +import GraphModelMigrationFinalizationConfirmation + from '../../../../src/domain/migrations/GraphModelMigrationFinalizationConfirmation.ts'; +import GraphModelMigrationFinalizationRequest + from '../../../../src/domain/migrations/GraphModelMigrationFinalizationRequest.ts'; +import GraphModelMigrationFinalizationResult + from '../../../../src/domain/migrations/GraphModelMigrationFinalizationResult.ts'; +import GraphModelMigrationFinalizationSafety + from '../../../../src/domain/migrations/GraphModelMigrationFinalizationSafety.ts'; +import GraphModelMigrationOperationLowerer + from '../../../../src/domain/migrations/GraphModelMigrationOperationLowerer.ts'; +import GraphModelMigrationOperationLoweringResult + from '../../../../src/domain/migrations/GraphModelMigrationOperationLoweringResult.ts'; +import GraphModelMigrationRuntimeConformanceResult + from '../../../../src/domain/migrations/GraphModelMigrationRuntimeConformanceResult.ts'; +import GraphModelMigrationScratchWriteResult + from '../../../../src/domain/migrations/GraphModelMigrationScratchWriteResult.ts'; +import { finalizeGraphModelMigration } from './GraphModelMigrationFinalizer.ts'; +import { writeGraphModelMigrationScratchHistory } from './GraphModelMigrationScratchWriter.ts'; +import { runMigrationGit } from './GitMigrationCommandRunner.ts'; + +export type GraphModelMigrationRuntimeConformanceProvider = ( + scratchWriteResult: GraphModelMigrationScratchWriteResult, +) => Promise; + +export type GraphModelMigrationCommandReadingProviders = { + readonly legacyReading: () => Promise; + readonly scratchReading: ( + scratchWriteResult: GraphModelMigrationScratchWriteResult, + ) => Promise; +}; + +export type GraphModelMigrationCommandFinalizationOptions = { + readonly liveRefName: string; + readonly expectedLiveHead: string; + readonly archiveRefName: string; + readonly confirmation: GraphModelMigrationFinalizationConfirmation | null; + readonly runtimeConformance: GraphModelMigrationRuntimeConformanceProvider | null; +}; + +export type GraphModelMigrationCommandOptions = { + readonly repositoryPath: string; + readonly dryRunRequest: DryRunGraphModelMigrationPlanRequest; + readonly scratchRefName: string; + readonly equivalenceBasis: GenesisEquivalenceComparisonBasis; + readonly legacyReading: GenesisEquivalenceReading | null; + readonly scratchReading: GenesisEquivalenceReading | null; + readonly readingProviders: GraphModelMigrationCommandReadingProviders | null; + readonly finalization: GraphModelMigrationCommandFinalizationOptions | null; +}; + +/** Result of the wired v18 graph-model migration command flow. */ +export class GraphModelMigrationCommandResult { + constructor( + readonly dryRunPlan: DryRunGraphModelMigrationPlan, + readonly loweringResult: GraphModelMigrationOperationLoweringResult, + readonly scratchWriteResult: GraphModelMigrationScratchWriteResult | null, + readonly gateResult: GenesisEquivalenceGateResult | null, + readonly finalizationResult: GraphModelMigrationFinalizationResult | null, + ) { + Object.freeze(this); + } +} + +export class GraphModelMigrationCommandError extends Error { + constructor(message: string) { + super(message); + this.name = 'GraphModelMigrationCommandError'; + } +} + +/** Runs dry-run planning, lowering, scratch writing, equivalence, and optional finalization. */ +export async function runGraphModelMigrationCommand( + options: GraphModelMigrationCommandOptions, +): Promise { + const repositoryPath = requireNonEmptyString(options.repositoryPath, 'repositoryPath'); + const scratchRefName = requireNonEmptyString(options.scratchRefName, 'scratchRefName'); + const dryRunRequest = requireDryRunRequest(options.dryRunRequest); + const dryRunPlan = new DryRunGraphModelMigrationPlanner().plan(dryRunRequest); + const loweringResult = new GraphModelMigrationOperationLowerer().lower(dryRunPlan); + if (loweringResult.hasFatalErrors() || loweringResult.patchPlan === null) { + return new GraphModelMigrationCommandResult(dryRunPlan, loweringResult, null, null, null); + } + + const scratchWriteResult = await writeGraphModelMigrationScratchHistory({ + repositoryPath, + scratchRefName, + patchPlan: loweringResult.patchPlan, + }); + if (scratchWriteResult.hasFatalErrors()) { + return new GraphModelMigrationCommandResult(dryRunPlan, loweringResult, scratchWriteResult, null, null); + } + + const readings = await resolveReadings(options, scratchWriteResult); + const gateResult = new GenesisEquivalenceGate().evaluate( + requireBasis(options.equivalenceBasis), + readings.legacyReading, + readings.scratchReading, + ); + if (options.finalization === null) { + return new GraphModelMigrationCommandResult( + dryRunPlan, + loweringResult, + scratchWriteResult, + gateResult, + null, + ); + } + + const finalizationResult = await runFinalization({ + repositoryPath, + scratchWriteResult, + gateResult, + finalization: options.finalization, + }); + return new GraphModelMigrationCommandResult( + dryRunPlan, + loweringResult, + scratchWriteResult, + gateResult, + finalizationResult, + ); +} + +async function resolveReadings( + options: GraphModelMigrationCommandOptions, + scratchWriteResult: GraphModelMigrationScratchWriteResult, +): Promise<{ + readonly legacyReading: GenesisEquivalenceReading; + readonly scratchReading: GenesisEquivalenceReading; +}> { + if (options.readingProviders !== null) { + return Object.freeze({ + legacyReading: requireReading( + await options.readingProviders.legacyReading(), + 'legacyReading', + ), + scratchReading: requireReading( + await options.readingProviders.scratchReading(scratchWriteResult), + 'scratchReading', + ), + }); + } + return Object.freeze({ + legacyReading: requireReading(options.legacyReading, 'legacyReading'), + scratchReading: requireReading(options.scratchReading, 'scratchReading'), + }); +} + +async function runFinalization(options: { + readonly repositoryPath: string; + readonly scratchWriteResult: GraphModelMigrationScratchWriteResult; + readonly gateResult: GenesisEquivalenceGateResult; + readonly finalization: GraphModelMigrationCommandFinalizationOptions; +}): Promise { + const observedLiveHead = await gitTextOrNull(options.repositoryPath, [ + 'show-ref', + '--verify', + '--hash', + options.finalization.liveRefName, + ]); + const safetyResult = new GraphModelMigrationFinalizationSafety().evaluate( + new GraphModelMigrationFinalizationRequest({ + liveRefName: options.finalization.liveRefName, + expectedLiveHead: options.finalization.expectedLiveHead, + observedLiveHead, + scratchRef: options.scratchWriteResult.scratchRef, + scratchHead: options.scratchWriteResult.scratchHead, + archiveRefName: options.finalization.archiveRefName, + confirmation: options.finalization.confirmation, + gateResult: options.gateResult, + runtimeConformance: await runtimeConformanceFromProvider( + options.finalization.runtimeConformance, + options.scratchWriteResult, + ), + }), + ); + return await finalizeGraphModelMigration({ + repositoryPath: options.repositoryPath, + safetyResult, + }); +} + +function runtimeConformanceFromProvider( + provider: GraphModelMigrationRuntimeConformanceProvider | null, + scratchWriteResult: GraphModelMigrationScratchWriteResult, +): Promise { + if (provider === null) { + return Promise.resolve(null); + } + return provider(scratchWriteResult); +} + +function requireDryRunRequest( + request: DryRunGraphModelMigrationPlanRequest, +): DryRunGraphModelMigrationPlanRequest { + if (!(request instanceof DryRunGraphModelMigrationPlanRequest)) { + throw new GraphModelMigrationCommandError('dryRunRequest must be a DryRunGraphModelMigrationPlanRequest'); + } + return request; +} + +function requireBasis( + basis: GenesisEquivalenceComparisonBasis, +): GenesisEquivalenceComparisonBasis { + if (!(basis instanceof GenesisEquivalenceComparisonBasis)) { + throw new GraphModelMigrationCommandError('equivalenceBasis must be a GenesisEquivalenceComparisonBasis'); + } + return basis; +} + +function requireReading(reading: GenesisEquivalenceReading | null, label: string): GenesisEquivalenceReading { + if (!(reading instanceof GenesisEquivalenceReading)) { + throw new GraphModelMigrationCommandError(`${label} must be a GenesisEquivalenceReading`); + } + return reading; +} + +function requireNonEmptyString(value: string, name: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw new GraphModelMigrationCommandError(`${name} must be a non-empty string`); + } + return value; +} + +async function gitTextOrNull(cwd: string, args: readonly string[]): Promise { + const result = await runMigrationGit(cwd, args, null); + if (!result.ok()) { + return null; + } + return result.stdout.trim(); +} diff --git a/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationCommandCli.ts b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationCommandCli.ts new file mode 100644 index 00000000..b25b9e47 --- /dev/null +++ b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationCommandCli.ts @@ -0,0 +1,259 @@ +import { readFile, writeFile } from 'node:fs/promises'; + +import GenesisEquivalenceComparisonBasis + from '../../../../src/domain/migrations/GenesisEquivalenceComparisonBasis.ts'; +import V17GoldenGraphFixtureGenesisReading + from '../../../../src/domain/migrations/V17GoldenGraphFixtureGenesisReading.ts'; +import DryRunGraphModelMigrationPlanner + from '../../../../src/domain/migrations/DryRunGraphModelMigrationPlanner.ts'; +import { parseGraphModelMigrationDryRunRequest } + from '../../../../src/infrastructure/adapters/GraphModelMigrationDryRunRequestJsonAdapter.ts'; +import { parseV17GoldenGraphFixtureManifestJson } + from '../../../../src/infrastructure/adapters/V17GoldenGraphFixtureManifestJsonAdapter.ts'; +import { runGraphModelMigrationCommand } from './GraphModelMigrationCommand.ts'; +import { formatGraphModelMigrationCommandReport } from './GraphModelMigrationCommandReport.ts'; +import { buildGraphModelMigrationScratchReading } from './GraphModelMigrationScratchReadingBuilder.ts'; +import type DryRunGraphModelMigrationPlan + from '../../../../src/domain/migrations/DryRunGraphModelMigrationPlan.ts'; +import type GraphModelMigrationNotice + from '../../../../src/domain/migrations/GraphModelMigrationNotice.ts'; + +const FINALIZATION_FLAGS = Object.freeze(new Set([ + '--finalize', + '--live-ref', + '--archive-ref', + '--expected-live-head', + '--confirmation', +])); + +export class GraphModelMigrationCommandCliArgumentError extends Error { + constructor(message: string) { + super(message); + this.name = 'GraphModelMigrationCommandCliArgumentError'; + } +} + +export class GraphModelMigrationCommandCliArgs { + readonly repositoryPath: string | null; + readonly requestPath: string | null; + readonly legacyFixtureManifestPath: string | null; + readonly scratchRefName: string | null; + readonly reportOutPath: string | null; + readonly helpRequested: boolean; + + constructor(options: { + readonly repositoryPath: string | null; + readonly requestPath: string | null; + readonly legacyFixtureManifestPath: string | null; + readonly scratchRefName: string | null; + readonly reportOutPath: string | null; + readonly helpRequested: boolean; + }) { + this.repositoryPath = options.repositoryPath; + this.requestPath = options.requestPath; + this.legacyFixtureManifestPath = options.legacyFixtureManifestPath; + this.scratchRefName = options.scratchRefName; + this.reportOutPath = options.reportOutPath; + this.helpRequested = options.helpRequested; + Object.freeze(this); + } +} + +export class GraphModelMigrationCommandCliResult { + constructor( + readonly exitCode: number, + readonly stdout: string, + readonly stderr: string, + ) { + Object.freeze(this); + } +} + +/** Returns CLI usage for the v18 graph-model migration command wrapper. */ +export function graphModelMigrationCommandUsage(): string { + return [ + 'Usage:', + [ + ' node scripts/v18.0.0/migrations/graph-model/migrate.ts', + '--repo ', + '--request ', + '--legacy-fixture-manifest ', + '--scratch-ref ', + '[--report-out ]', + ].join(' '), + '', + 'Options:', + ' --repo Git repository to receive scratch migration history.', + ' --request JSON migration request to validate and execute.', + ' --legacy-fixture-manifest V17 fixture manifest used for legacy equivalence reading.', + ' --scratch-ref refs/warp-migration-scratch/* target for scratch output.', + ' --report-out Also write the deterministic command report to this path.', + ' --help Show this help.', + '', + 'Finalization flags are intentionally refused by this wrapper until live-ref CLI finalization is designed.', + ].join('\n'); +} + +/** Parses command CLI arguments without reading or writing files. */ +export function parseGraphModelMigrationCommandCliArgs( + argv: readonly string[], +): GraphModelMigrationCommandCliArgs { + let repositoryPath: string | null = null; + let requestPath: string | null = null; + let legacyFixtureManifestPath: string | null = null; + let scratchRefName: string | null = null; + let reportOutPath: string | null = null; + let helpRequested = false; + + for (let index = 0; index < argv.length; index++) { + const arg = argv[index]; + if (arg === '--repo') { + repositoryPath = readArgValue(argv, index, '--repo'); + index++; + continue; + } + if (arg === '--request') { + requestPath = readArgValue(argv, index, '--request'); + index++; + continue; + } + if (arg === '--legacy-fixture-manifest') { + legacyFixtureManifestPath = readArgValue(argv, index, '--legacy-fixture-manifest'); + index++; + continue; + } + if (arg === '--scratch-ref') { + scratchRefName = readArgValue(argv, index, '--scratch-ref'); + index++; + continue; + } + if (arg === '--report-out') { + reportOutPath = readArgValue(argv, index, '--report-out'); + index++; + continue; + } + if (arg === '--help' || arg === '-h') { + helpRequested = true; + continue; + } + if (arg !== undefined && FINALIZATION_FLAGS.has(arg)) { + throw new GraphModelMigrationCommandCliArgumentError( + 'finalization is not supported by this CLI wrapper yet', + ); + } + throw new GraphModelMigrationCommandCliArgumentError(`Unknown argument: ${arg ?? ''}`); + } + + return new GraphModelMigrationCommandCliArgs({ + repositoryPath, + requestPath, + legacyFixtureManifestPath, + scratchRefName, + reportOutPath, + helpRequested, + }); +} + +/** Runs the v18 graph-model migration command wrapper. */ +export async function runGraphModelMigrationCommandCli( + argv: readonly string[], +): Promise { + const args = parseGraphModelMigrationCommandCliArgs(argv); + if (args.helpRequested) { + return new GraphModelMigrationCommandCliResult(0, `${graphModelMigrationCommandUsage()}\n`, ''); + } + requireCommandArgs(args); + + const requestText = await readFile(requireString(args.requestPath, '--request'), 'utf8'); + const legacyManifestText = await readFile( + requireString(args.legacyFixtureManifestPath, '--legacy-fixture-manifest'), + 'utf8', + ); + const dryRunRequest = parseGraphModelMigrationDryRunRequest(requestText); + const legacyManifest = parseV17GoldenGraphFixtureManifestJson(legacyManifestText); + const preflightPlan = new DryRunGraphModelMigrationPlanner().plan(dryRunRequest); + if (preflightPlan.hasFatalErrors() || preflightPlan.manifest === null) { + return new GraphModelMigrationCommandCliResult(1, preflightFailureReport(preflightPlan), ''); + } + + const repositoryPath = requireString(args.repositoryPath, '--repo'); + const scratchRefName = requireString(args.scratchRefName, '--scratch-ref'); + const result = await runGraphModelMigrationCommand({ + repositoryPath, + dryRunRequest, + scratchRefName, + equivalenceBasis: new GenesisEquivalenceComparisonBasis({ + legacyBasis: preflightPlan.manifest.sourceBasis, + migratedBasis: preflightPlan.manifest.targetBasis, + }), + legacyReading: null, + scratchReading: null, + readingProviders: { + legacyReading: async () => new V17GoldenGraphFixtureGenesisReading().build(legacyManifest), + scratchReading: async () => await buildGraphModelMigrationScratchReading({ + repositoryPath, + scratchRefName, + readingId: 'scratch:command-cli', + }), + }, + finalization: null, + }); + const report = formatGraphModelMigrationCommandReport(result); + if (args.reportOutPath !== null) { + await writeFile(args.reportOutPath, report, 'utf8'); + } + return new GraphModelMigrationCommandCliResult(commandExitCode(result), report, ''); +} + +function commandExitCode(result: Awaited>): number { + if ( + !result.dryRunPlan.hasFatalErrors() + && !result.loweringResult.hasFatalErrors() + && result.scratchWriteResult !== null + && !result.scratchWriteResult.hasFatalErrors() + && result.gateResult !== null + && result.gateResult.allowsPromotion() + ) { + return 0; + } + return 1; +} + +function preflightFailureReport(plan: DryRunGraphModelMigrationPlan): string { + return [ + 'git-warp v18 graph-model migration report', + 'dryRun: blocked', + `plannedOperations: ${plan.plannedOperations.length}`, + ...fatalNoticeLines(plan.fatalErrors), + ].join('\n'); +} + +function fatalNoticeLines(fatalErrors: readonly GraphModelMigrationNotice[]): readonly string[] { + const lines = ['fatalErrors:']; + for (const notice of fatalErrors) { + lines.push(`- ${notice.code}: ${notice.message}`); + } + return Object.freeze(lines); +} + +function requireCommandArgs(args: GraphModelMigrationCommandCliArgs): void { + requireString(args.repositoryPath, '--repo'); + requireString(args.requestPath, '--request'); + requireString(args.legacyFixtureManifestPath, '--legacy-fixture-manifest'); + requireString(args.scratchRefName, '--scratch-ref'); +} + +function requireString(value: string | null, flag: string): string { + if (value === null) { + throw new GraphModelMigrationCommandCliArgumentError(`${flag} is required`); + } + return value; +} + +function readArgValue(argv: readonly string[], index: number, flag: string): string { + const value = argv[index + 1]; + if (value === undefined || value.length === 0 || value.startsWith('--')) { + throw new GraphModelMigrationCommandCliArgumentError(`${flag} requires a value`); + } + return value; +} diff --git a/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationCommandReport.ts b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationCommandReport.ts new file mode 100644 index 00000000..3fd79e3e --- /dev/null +++ b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationCommandReport.ts @@ -0,0 +1,113 @@ +import { GraphModelMigrationCommandResult } + from './GraphModelMigrationCommand.ts'; +import GraphModelMigrationNotice + from '../../../../src/domain/migrations/GraphModelMigrationNotice.ts'; + +/** Formats a deterministic operator report for the v18 graph-model migration command. */ +export function formatGraphModelMigrationCommandReport( + result: GraphModelMigrationCommandResult, +): string { + const checkedResult = requireCommandResult(result); + return [ + 'git-warp v18 graph-model migration report', + ...dryRunLines(checkedResult), + ...loweringLines(checkedResult), + ...scratchLines(checkedResult), + ...equivalenceLines(checkedResult), + ...finalizationLines(checkedResult), + ].join('\n'); +} + +function dryRunLines(result: GraphModelMigrationCommandResult): readonly string[] { + return Object.freeze([ + `dryRun: ${result.dryRunPlan.hasFatalErrors() ? 'blocked' : 'passed'}`, + `plannedOperations: ${result.dryRunPlan.plannedOperations.length}`, + ]); +} + +function loweringLines(result: GraphModelMigrationCommandResult): readonly string[] { + if (result.loweringResult.patchPlan === null) { + return Object.freeze([ + `lowering: ${result.loweringResult.hasFatalErrors() ? 'blocked' : 'missing'}`, + 'loweredOperations: 0', + ]); + } + return Object.freeze([ + `lowering: ${result.loweringResult.hasFatalErrors() ? 'blocked' : 'passed'}`, + `loweredOperations: ${result.loweringResult.patchPlan.operations.length}`, + ]); +} + +function scratchLines(result: GraphModelMigrationCommandResult): readonly string[] { + if (result.scratchWriteResult === null) { + return Object.freeze(['scratch: skipped']); + } + return Object.freeze([ + `scratch: ${result.scratchWriteResult.hasFatalErrors() ? 'blocked' : 'written'}`, + `scratchRef: ${displayNullable(result.scratchWriteResult.scratchRef?.refName ?? null)}`, + `scratchHead: ${displayNullable(result.scratchWriteResult.scratchHead)}`, + `scratchPatches: ${result.scratchWriteResult.writtenPatches.length}`, + ]); +} + +function equivalenceLines(result: GraphModelMigrationCommandResult): readonly string[] { + if (result.gateResult === null) { + return Object.freeze(['equivalence: skipped']); + } + return Object.freeze([ + `equivalence: ${result.gateResult.allowsPromotion() ? 'passed' : 'blocked'}`, + `mismatches: ${result.gateResult.proofResult.summary.mismatchCount}`, + `legacyFacts: ${result.gateResult.proofResult.summary.legacyFactCount}`, + `migratedFacts: ${result.gateResult.proofResult.summary.migratedFactCount}`, + ]); +} + +function finalizationLines(result: GraphModelMigrationCommandResult): readonly string[] { + if (result.finalizationResult === null) { + return Object.freeze(['finalization: skipped']); + } + if (result.finalizationResult.fatalErrors.length > 0) { + return Object.freeze([ + `finalization: ${result.finalizationResult.status}`, + ...fatalNoticeLines(result.finalizationResult.fatalErrors), + ]); + } + return Object.freeze([ + `finalization: ${result.finalizationResult.status}`, + `liveRef: ${result.finalizationResult.liveRefName}`, + `archiveRef: ${displayNullable(result.finalizationResult.archiveRefName)}`, + `previousLiveHead: ${displayNullable(result.finalizationResult.previousLiveHead)}`, + `finalizedLiveHead: ${displayNullable(result.finalizationResult.finalizedLiveHead)}`, + ]); +} + +function fatalNoticeLines(fatalErrors: readonly GraphModelMigrationNotice[]): readonly string[] { + const lines = ['fatalErrors:']; + for (const notice of fatalErrors) { + lines.push(`- ${notice.code}: ${notice.message}`); + } + return Object.freeze(lines); +} + +function displayNullable(value: string | null): string { + if (value === null) { + return '(none)'; + } + return value; +} + +function requireCommandResult( + result: GraphModelMigrationCommandResult, +): GraphModelMigrationCommandResult { + if (!(result instanceof GraphModelMigrationCommandResult)) { + throw new GraphModelMigrationCommandReportError('result must be a GraphModelMigrationCommandResult'); + } + return result; +} + +export class GraphModelMigrationCommandReportError extends Error { + constructor(message: string) { + super(message); + this.name = 'GraphModelMigrationCommandReportError'; + } +} diff --git a/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationFinalizer.ts b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationFinalizer.ts new file mode 100644 index 00000000..b0b54ad2 --- /dev/null +++ b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationFinalizer.ts @@ -0,0 +1,159 @@ +import GraphModelMigrationFinalizationResult, { + GRAPH_MODEL_MIGRATION_FINALIZATION_BLOCKED, + GRAPH_MODEL_MIGRATION_FINALIZATION_COMPLETED, + GRAPH_MODEL_MIGRATION_FINALIZATION_PARTIAL_ARCHIVE, +} from '../../../../src/domain/migrations/GraphModelMigrationFinalizationResult.ts'; +import GraphModelMigrationFinalizationSafetyResult + from '../../../../src/domain/migrations/GraphModelMigrationFinalizationSafetyResult.ts'; +import GraphModelMigrationNotice + from '../../../../src/domain/migrations/GraphModelMigrationNotice.ts'; +import { runMigrationGit } from './GitMigrationCommandRunner.ts'; + +const ZERO_OID = '0000000000000000000000000000000000000000'; + +export type GraphModelMigrationFinalizerOptions = { + readonly repositoryPath: string; + readonly safetyResult: GraphModelMigrationFinalizationSafetyResult; +}; + +export class GraphModelMigrationFinalizerError extends Error { + constructor(message: string) { + super(message); + this.name = 'GraphModelMigrationFinalizerError'; + } +} + +/** Finalizes a safety-approved scratch migration through archive-preserving Git ref updates. */ +export async function finalizeGraphModelMigration( + options: GraphModelMigrationFinalizerOptions, +): Promise { + const repositoryPath = requireNonEmptyString(options.repositoryPath, 'repositoryPath'); + const safetyResult = requireSafetyResult(options.safetyResult); + const request = safetyResult.request; + if (!safetyResult.allowsFinalization()) { + return blockedResult(request.liveRefName, request.archiveRefName, safetyResult.fatalErrors); + } + + const archiveRefName = requireFinalizationString(request.archiveRefName, 'archiveRefName'); + const expectedLiveHead = requireFinalizationString(request.expectedLiveHead, 'expectedLiveHead'); + const scratchHead = requireFinalizationString(request.scratchHead, 'scratchHead'); + const currentLiveHead = await gitTextOrNull(repositoryPath, [ + 'show-ref', + '--verify', + '--hash', + request.liveRefName, + ]); + if (currentLiveHead !== expectedLiveHead) { + return blockedResult(request.liveRefName, archiveRefName, [ + GraphModelMigrationNotice.fatal( + 'E_STALE_LIVE_REF_EXPECTATION', + 'migration finalization live ref changed before archive creation', + ), + ]); + } + if (await refExists(repositoryPath, archiveRefName)) { + return blockedResult(request.liveRefName, archiveRefName, [ + GraphModelMigrationNotice.fatal( + 'E_ARCHIVE_REF_EXISTS', + `migration archive ref already exists: ${archiveRefName}`, + ), + ]); + } + + const archiveUpdate = await runMigrationGit( + repositoryPath, + ['update-ref', archiveRefName, expectedLiveHead, ZERO_OID], + null, + ); + if (!archiveUpdate.ok()) { + return blockedResult(request.liveRefName, archiveRefName, [ + GraphModelMigrationNotice.fatal( + 'E_ARCHIVE_REF_UPDATE_FAILED', + 'migration finalization could not create archive ref', + ), + ]); + } + + const liveUpdate = await runMigrationGit( + repositoryPath, + ['update-ref', request.liveRefName, scratchHead, expectedLiveHead], + null, + ); + if (!liveUpdate.ok()) { + return new GraphModelMigrationFinalizationResult({ + status: GRAPH_MODEL_MIGRATION_FINALIZATION_PARTIAL_ARCHIVE, + liveRefName: request.liveRefName, + archiveRefName, + previousLiveHead: expectedLiveHead, + finalizedLiveHead: null, + fatalErrors: [ + GraphModelMigrationNotice.fatal( + 'E_LIVE_REF_UPDATE_FAILED', + 'migration finalization archived old lineage but could not advance live ref', + ), + ], + }); + } + + return new GraphModelMigrationFinalizationResult({ + status: GRAPH_MODEL_MIGRATION_FINALIZATION_COMPLETED, + liveRefName: request.liveRefName, + archiveRefName, + previousLiveHead: expectedLiveHead, + finalizedLiveHead: scratchHead, + fatalErrors: [], + }); +} + +function blockedResult( + liveRefName: string, + archiveRefName: string | null, + fatalErrors: readonly GraphModelMigrationNotice[], +): GraphModelMigrationFinalizationResult { + return new GraphModelMigrationFinalizationResult({ + status: GRAPH_MODEL_MIGRATION_FINALIZATION_BLOCKED, + liveRefName, + archiveRefName, + previousLiveHead: null, + finalizedLiveHead: null, + fatalErrors, + }); +} + +function requireSafetyResult( + safetyResult: GraphModelMigrationFinalizationSafetyResult, +): GraphModelMigrationFinalizationSafetyResult { + if (!(safetyResult instanceof GraphModelMigrationFinalizationSafetyResult)) { + throw new GraphModelMigrationFinalizerError( + 'safetyResult must be a GraphModelMigrationFinalizationSafetyResult', + ); + } + return safetyResult; +} + +function requireFinalizationString(value: string | null, name: string): string { + if (value === null || value.trim().length === 0) { + throw new GraphModelMigrationFinalizerError(`${name} must be present after safety approval`); + } + return value; +} + +function requireNonEmptyString(value: string, name: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw new GraphModelMigrationFinalizerError(`${name} must be a non-empty string`); + } + return value; +} + +async function refExists(repositoryPath: string, refName: string): Promise { + const result = await runMigrationGit(repositoryPath, ['show-ref', '--verify', '--hash', refName], null); + return result.ok(); +} + +async function gitTextOrNull(cwd: string, args: readonly string[]): Promise { + const result = await runMigrationGit(cwd, args, null); + if (!result.ok()) { + return null; + } + return result.stdout.trim(); +} diff --git a/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchReadingBuilder.ts b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchReadingBuilder.ts new file mode 100644 index 00000000..601842ab --- /dev/null +++ b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchReadingBuilder.ts @@ -0,0 +1,208 @@ +import GenesisEquivalenceBoundary + from '../../../../src/domain/migrations/GenesisEquivalenceBoundary.ts'; +import GenesisEquivalenceReading + from '../../../../src/domain/migrations/GenesisEquivalenceReading.ts'; +import GenesisEquivalenceReadingFact, { + type GenesisEquivalenceReadingFactKind, +} from '../../../../src/domain/migrations/GenesisEquivalenceReadingFact.ts'; +import type { GraphModelMigrationPlannedGraphOperationKind } + from '../../../../src/domain/migrations/GraphModelMigrationPlannedGraphOperation.ts'; +import GraphModelMigrationScratchRef + from '../../../../src/domain/migrations/GraphModelMigrationScratchRef.ts'; +import { runMigrationGit } from './GitMigrationCommandRunner.ts'; + +const OPERATION_TREE_PATH = 'migration-operation.txt'; + +export type GraphModelMigrationScratchReadingBuilderOptions = { + readonly repositoryPath: string; + readonly scratchRefName: string; + readonly readingId: string; +}; + +class ScratchOperationPayload { + constructor( + readonly kind: GraphModelMigrationPlannedGraphOperationKind, + readonly sourceKey: string, + readonly targetKey: string, + ) { + Object.freeze(this); + } +} + +export class GraphModelMigrationScratchReadingBuilderError extends Error { + constructor(message: string) { + super(message); + this.name = 'GraphModelMigrationScratchReadingBuilderError'; + } +} + +/** Builds an equivalence reading from scratch migration operation commits. */ +export async function buildGraphModelMigrationScratchReading( + options: GraphModelMigrationScratchReadingBuilderOptions, +): Promise { + const repositoryPath = requireNonEmptyString(options.repositoryPath, 'repositoryPath'); + const scratchRef = new GraphModelMigrationScratchRef({ refName: options.scratchRefName }); + const commitIds = await gitLines(repositoryPath, ['rev-list', '--reverse', scratchRef.refName]); + const facts: GenesisEquivalenceReadingFact[] = []; + let operationIndex = 0; + for (const commitId of commitIds) { + const payload = parseScratchOperationPayload( + await gitText(repositoryPath, ['show', `${commitId}:${OPERATION_TREE_PATH}`]), + ); + facts.push(factFromPayload(payload, commitId, operationIndex)); + operationIndex += 1; + } + return new GenesisEquivalenceReading({ + readingId: requireNonEmptyString(options.readingId, 'readingId'), + facts, + }); +} + +function factFromPayload( + payload: ScratchOperationPayload, + commitId: string, + operationIndex: number, +): GenesisEquivalenceReadingFact { + const projected = projectedFactFromPayload(payload); + return new GenesisEquivalenceReadingFact({ + kind: projected.kind, + factKey: projected.factKey, + fieldPath: projected.fieldPath, + value: projected.value, + boundary: new GenesisEquivalenceBoundary({ + writerId: 'scratch-migration', + patchId: commitId, + operationIndex, + }), + }); +} + +function projectedFactFromPayload(payload: ScratchOperationPayload): { + readonly kind: GenesisEquivalenceReadingFactKind; + readonly factKey: string; + readonly fieldPath: string; + readonly value: string; +} { + if (payload.kind === 'node-record') { + return projected('node', payload.targetKey, 'visibility', 'visible'); + } + if (payload.kind === 'edge-record') { + return projected('edge', payload.targetKey, 'visibility', 'visible'); + } + return compatibilityFactFromPayload(payload); +} + +function compatibilityFactFromPayload(payload: ScratchOperationPayload): { + readonly kind: GenesisEquivalenceReadingFactKind; + readonly factKey: string; + readonly fieldPath: string; + readonly value: string; +} { + if (payload.kind === 'property') { + return projected('property', payload.targetKey, 'value', `migration-source:${payload.sourceKey}`); + } + if (payload.kind === 'content-attachment') { + return projected( + 'content-attachment', + payload.targetKey, + 'payload.oid', + `migration-source:${payload.sourceKey}`, + ); + } + throw new GraphModelMigrationScratchReadingBuilderError(`unsupported scratch operation kind ${payload.kind}`); +} + +function projected( + kind: GenesisEquivalenceReadingFactKind, + factKey: string, + fieldPath: string, + value: string, +) { + return Object.freeze({ kind, factKey, fieldPath, value }); +} + +function parseScratchOperationPayload(text: string): ScratchOperationPayload { + const lines = text.split('\n').filter((line) => line.length > 0); + if (lines[0] !== 'git-warp-v18-migration-operation-v1') { + throw new GraphModelMigrationScratchReadingBuilderError('scratch operation payload header is unsupported'); + } + const fields = payloadFields(lines.slice(1)); + return new ScratchOperationPayload( + requireKind(fields.get('kind')), + requireField(fields, 'source-key-utf8-hex'), + requireField(fields, 'target-key-utf8-hex'), + ); +} + +function payloadFields(lines: readonly string[]): ReadonlyMap { + const fields = new Map(); + for (const line of lines) { + const separator = line.indexOf(' '); + if (separator <= 0) { + throw new GraphModelMigrationScratchReadingBuilderError(`invalid scratch operation line ${line}`); + } + fields.set(line.slice(0, separator), line.slice(separator + 1)); + } + return fields; +} + +function requireKind(value: string | undefined): GraphModelMigrationPlannedGraphOperationKind { + if ( + value === 'node-record' + || value === 'edge-record' + || value === 'property' + || value === 'content-attachment' + ) { + return value; + } + throw new GraphModelMigrationScratchReadingBuilderError('scratch operation kind is unsupported'); +} + +function requireField(fields: ReadonlyMap, fieldName: string): string { + const encoded = fields.get(fieldName); + if (encoded === undefined) { + throw new GraphModelMigrationScratchReadingBuilderError(`scratch operation is missing ${fieldName}`); + } + return utf8FromHex(encoded); +} + +function utf8FromHex(hex: string): string { + if (hex.length % 2 !== 0) { + throw new GraphModelMigrationScratchReadingBuilderError('hex field has odd length'); + } + const bytes: number[] = []; + for (let index = 0; index < hex.length; index += 2) { + bytes.push(parseHexByte(hex.slice(index, index + 2))); + } + return new TextDecoder().decode(new Uint8Array(bytes)); +} + +function parseHexByte(hex: string): number { + if (!/^[0-9a-f]{2}$/iu.test(hex)) { + throw new GraphModelMigrationScratchReadingBuilderError(`invalid hex byte ${hex}`); + } + return Number.parseInt(hex, 16); +} + +async function gitLines(cwd: string, args: readonly string[]): Promise { + const output = await gitText(cwd, args); + if (output.length === 0) { + return Object.freeze([]); + } + return Object.freeze(output.split('\n').filter((line) => line.length > 0)); +} + +async function gitText(cwd: string, args: readonly string[]): Promise { + const result = await runMigrationGit(cwd, args, null); + if (!result.ok()) { + throw new GraphModelMigrationScratchReadingBuilderError(`git ${args.join(' ')} failed: ${result.stderr}`); + } + return result.stdout.trim(); +} + +function requireNonEmptyString(value: string, name: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw new GraphModelMigrationScratchReadingBuilderError(`${name} must be a non-empty string`); + } + return value; +} diff --git a/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchRuntimeConformanceProvider.ts b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchRuntimeConformanceProvider.ts new file mode 100644 index 00000000..a0903b26 --- /dev/null +++ b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchRuntimeConformanceProvider.ts @@ -0,0 +1,171 @@ +import GraphModelMigrationNotice + from '../../../../src/domain/migrations/GraphModelMigrationNotice.ts'; +import GraphModelMigrationRuntimeConformanceResult, { + GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_FAILED, + GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_PASSED, +} from '../../../../src/domain/migrations/GraphModelMigrationRuntimeConformanceResult.ts'; +import GraphModelMigrationScratchRef + from '../../../../src/domain/migrations/GraphModelMigrationScratchRef.ts'; +import GraphModelMigrationScratchWriteResult + from '../../../../src/domain/migrations/GraphModelMigrationScratchWriteResult.ts'; +import { buildGraphModelMigrationScratchReading } + from './GraphModelMigrationScratchReadingBuilder.ts'; +import { runMigrationGit } from './GitMigrationCommandRunner.ts'; + +const WITNESS_ID = 'git-warp-v18-scratch-operation-readback-v1'; + +export type GraphModelMigrationScratchRuntimeConformanceProviderOptions = { + readonly repositoryPath: string; +}; + +export type GraphModelMigrationScratchRuntimeConformanceProvider = ( + scratchWriteResult: GraphModelMigrationScratchWriteResult, +) => Promise; + +/** Builds runtime conformance evidence by reading scratch operation history back from Git. */ +export function createGraphModelMigrationScratchRuntimeConformanceProvider( + options: GraphModelMigrationScratchRuntimeConformanceProviderOptions, +): GraphModelMigrationScratchRuntimeConformanceProvider { + const repositoryPath = requireNonEmptyString(options.repositoryPath, 'repositoryPath'); + return async (scratchWriteResult) => await verifyGraphModelMigrationScratchRuntimeConformance({ + repositoryPath, + scratchWriteResult, + }); +} + +/** Verifies that scratch migration output is still readable at its expected head. */ +export async function verifyGraphModelMigrationScratchRuntimeConformance(options: { + readonly repositoryPath: string; + readonly scratchWriteResult: GraphModelMigrationScratchWriteResult; +}): Promise { + const repositoryPath = requireNonEmptyString(options.repositoryPath, 'repositoryPath'); + const scratchWriteResult = requireScratchWriteResult(options.scratchWriteResult); + if (scratchWriteResult.scratchRef === null || scratchWriteResult.scratchHead === null) { + return null; + } + const observedHead = await observedScratchHead(repositoryPath, scratchWriteResult.scratchRef); + if (observedHead === null) { + return failedResult( + scratchWriteResult.scratchRef, + scratchWriteResult.scratchHead, + 'E_RUNTIME_CONFORMANCE_SCRATCH_REF_UNREADABLE', + `scratch migration ref ${scratchWriteResult.scratchRef.refName} is not readable`, + ); + } + if (observedHead !== scratchWriteResult.scratchHead) { + return failedResult( + scratchWriteResult.scratchRef, + scratchWriteResult.scratchHead, + 'E_RUNTIME_CONFORMANCE_SCRATCH_HEAD_CHANGED', + `scratch migration ref ${scratchWriteResult.scratchRef.refName} no longer points at expected head`, + ); + } + return await readBackScratchHistory(repositoryPath, scratchWriteResult); +} + +async function readBackScratchHistory( + repositoryPath: string, + scratchWriteResult: GraphModelMigrationScratchWriteResult, +): Promise { + if (scratchWriteResult.scratchRef === null || scratchWriteResult.scratchHead === null) { + throw new GraphModelMigrationScratchRuntimeConformanceProviderError( + 'scratch output must be present before readback', + ); + } + try { + const reading = await buildGraphModelMigrationScratchReading({ + repositoryPath, + scratchRefName: scratchWriteResult.scratchRef.refName, + readingId: 'scratch-runtime-conformance', + }); + if (reading.facts.length !== scratchWriteResult.writtenPatches.length) { + return failedResult( + scratchWriteResult.scratchRef, + scratchWriteResult.scratchHead, + 'E_RUNTIME_CONFORMANCE_SCRATCH_OPERATION_COUNT', + 'scratch readback fact count does not match written operation count', + ); + } + return passedResult(scratchWriteResult.scratchRef, scratchWriteResult.scratchHead, reading.facts.length); + } catch { + return failedResult( + scratchWriteResult.scratchRef, + scratchWriteResult.scratchHead, + 'E_RUNTIME_CONFORMANCE_SCRATCH_HISTORY_UNREADABLE', + 'scratch migration history cannot be read back as genesis evidence', + ); + } +} + +async function observedScratchHead( + repositoryPath: string, + scratchRef: GraphModelMigrationScratchRef, +): Promise { + const result = await runMigrationGit( + repositoryPath, + ['show-ref', '--verify', '--hash', scratchRef.refName], + null, + ); + if (!result.ok()) { + return null; + } + const head = result.stdout.trim(); + if (head.length === 0) { + return null; + } + return head; +} + +function passedResult( + scratchRef: GraphModelMigrationScratchRef, + scratchHead: string, + factCount: number, +): GraphModelMigrationRuntimeConformanceResult { + return new GraphModelMigrationRuntimeConformanceResult({ + scratchRef, + scratchHead, + status: GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_PASSED, + witness: `${WITNESS_ID} facts=${factCount}`, + fatalErrors: [], + }); +} + +function failedResult( + scratchRef: GraphModelMigrationScratchRef, + scratchHead: string, + code: string, + message: string, +): GraphModelMigrationRuntimeConformanceResult { + return new GraphModelMigrationRuntimeConformanceResult({ + scratchRef, + scratchHead, + status: GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_FAILED, + witness: WITNESS_ID, + fatalErrors: [GraphModelMigrationNotice.fatal(code, message)], + }); +} + +function requireScratchWriteResult( + scratchWriteResult: GraphModelMigrationScratchWriteResult, +): GraphModelMigrationScratchWriteResult { + if (!(scratchWriteResult instanceof GraphModelMigrationScratchWriteResult)) { + throw new GraphModelMigrationScratchRuntimeConformanceProviderError( + 'scratchWriteResult must be a GraphModelMigrationScratchWriteResult', + ); + } + return scratchWriteResult; +} + +function requireNonEmptyString(value: string, name: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw new GraphModelMigrationScratchRuntimeConformanceProviderError(`${name} must be a non-empty string`); + } + return value; +} + +export class GraphModelMigrationScratchRuntimeConformanceProviderError extends Error { + constructor(message: string) { + super(message); + this.name = 'GraphModelMigrationScratchRuntimeConformanceProviderError'; + } +} diff --git a/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchWriter.ts b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchWriter.ts new file mode 100644 index 00000000..29c4a498 --- /dev/null +++ b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchWriter.ts @@ -0,0 +1,236 @@ +import GraphModelMigrationLoweredOperation + from '../../../../src/domain/migrations/GraphModelMigrationLoweredOperation.ts'; +import GraphModelMigrationLoweredPatchPlan + from '../../../../src/domain/migrations/GraphModelMigrationLoweredPatchPlan.ts'; +import GraphModelMigrationNotice + from '../../../../src/domain/migrations/GraphModelMigrationNotice.ts'; +import GraphModelMigrationScratchRef + from '../../../../src/domain/migrations/GraphModelMigrationScratchRef.ts'; +import GraphModelMigrationScratchWrittenPatch + from '../../../../src/domain/migrations/GraphModelMigrationScratchWrittenPatch.ts'; +import GraphModelMigrationScratchWriteResult + from '../../../../src/domain/migrations/GraphModelMigrationScratchWriteResult.ts'; +import { runMigrationGit } from './GitMigrationCommandRunner.ts'; + +const ZERO_OID = '0000000000000000000000000000000000000000'; +const OPERATION_TREE_PATH = 'migration-operation.txt'; + +export type GraphModelMigrationScratchWriterOptions = { + readonly repositoryPath: string; + readonly scratchRefName: string | null; + readonly patchPlan: GraphModelMigrationLoweredPatchPlan; +}; + +export class GraphModelMigrationScratchWriterError extends Error { + constructor(message: string) { + super(message); + this.name = 'GraphModelMigrationScratchWriterError'; + } +} + +/** Writes lowered graph-model migration operations to an explicit scratch ref. */ +export async function writeGraphModelMigrationScratchHistory( + options: GraphModelMigrationScratchWriterOptions, +): Promise { + const repositoryPath = requireNonEmptyString(options.repositoryPath, 'repositoryPath'); + const scratchRefNotice = GraphModelMigrationScratchRef.validateRefName(options.scratchRefName); + if (scratchRefNotice !== null) { + return blockedResult(null, scratchRefNotice); + } + const scratchRef = new GraphModelMigrationScratchRef({ refName: requireScratchRefName(options.scratchRefName) }); + const patchPlan = requirePatchPlan(options.patchPlan); + const gitRefNotice = await validateGitRefName(repositoryPath, scratchRef); + if (gitRefNotice !== null) { + return blockedResult(scratchRef, gitRefNotice); + } + + let currentHead = await gitTextOrNull(repositoryPath, ['show-ref', '--verify', '--hash', scratchRef.refName]); + const writtenPatches: GraphModelMigrationScratchWrittenPatch[] = []; + let sequence = 0; + for (const operation of patchPlan.operations) { + const commitId = await writeOperationCommit({ + repositoryPath, + patchPlan, + operation, + sequence, + parentHead: currentHead, + }); + await advanceScratchRef(repositoryPath, scratchRef, commitId, currentHead); + currentHead = commitId; + writtenPatches.push(new GraphModelMigrationScratchWrittenPatch({ + commitId, + operation, + sequence, + })); + sequence += 1; + } + + return new GraphModelMigrationScratchWriteResult({ + scratchRef, + scratchHead: currentHead, + writtenPatches, + warnings: [], + fatalErrors: [], + }); +} + +async function writeOperationCommit(options: { + readonly repositoryPath: string; + readonly patchPlan: GraphModelMigrationLoweredPatchPlan; + readonly operation: GraphModelMigrationLoweredOperation; + readonly sequence: number; + readonly parentHead: string | null; +}): Promise { + const payload = formatOperationPayload(options.patchPlan, options.operation, options.sequence); + const blobOid = await gitTextWithInput(options.repositoryPath, ['hash-object', '-w', '--stdin'], payload); + const treeOid = await gitTextWithInput( + options.repositoryPath, + ['mktree'], + `100644 blob ${blobOid}\t${OPERATION_TREE_PATH}\n`, + ); + const parentArgs = options.parentHead === null ? [] : ['-p', options.parentHead]; + return await gitTextWithInput( + options.repositoryPath, + ['commit-tree', treeOid, ...parentArgs], + formatCommitMessage(options.patchPlan, options.operation, options.sequence), + true, + ); +} + +async function advanceScratchRef( + repositoryPath: string, + scratchRef: GraphModelMigrationScratchRef, + commitId: string, + expectedHead: string | null, +): Promise { + const expected = expectedHead ?? ZERO_OID; + await gitText(repositoryPath, ['update-ref', scratchRef.refName, commitId, expected]); +} + +async function validateGitRefName( + repositoryPath: string, + scratchRef: GraphModelMigrationScratchRef, +): Promise { + const result = await runMigrationGit(repositoryPath, ['check-ref-format', scratchRef.refName], null); + if (result.ok()) { + return null; + } + return GraphModelMigrationNotice.fatal( + 'E_INVALID_SCRATCH_REF', + `git rejected scratch migration ref ${scratchRef.refName}`, + ); +} + +function blockedResult( + scratchRef: GraphModelMigrationScratchRef | null, + fatalError: GraphModelMigrationNotice, +): GraphModelMigrationScratchWriteResult { + return new GraphModelMigrationScratchWriteResult({ + scratchRef, + scratchHead: null, + writtenPatches: [], + warnings: [], + fatalErrors: [fatalError], + }); +} + +function formatOperationPayload( + patchPlan: GraphModelMigrationLoweredPatchPlan, + operation: GraphModelMigrationLoweredOperation, + sequence: number, +): string { + return [ + 'git-warp-v18-migration-operation-v1', + `sequence ${sequence}`, + `kind ${operation.kind}`, + `source-basis-utf8-hex ${utf8Hex(patchPlan.sourceBasis.toKey())}`, + `target-basis-utf8-hex ${utf8Hex(patchPlan.targetBasis.toKey())}`, + `source-key-utf8-hex ${utf8Hex(operation.sourceKey)}`, + `target-key-utf8-hex ${utf8Hex(operation.targetKey)}`, + `operation-key-utf8-hex ${utf8Hex(operation.toKey())}`, + '', + ].join('\n'); +} + +function formatCommitMessage( + patchPlan: GraphModelMigrationLoweredPatchPlan, + operation: GraphModelMigrationLoweredOperation, + sequence: number, +): string { + return [ + 'git-warp v18 scratch migration operation', + '', + `Migration-Format: git-warp-v18-scratch-operation-v1`, + `Operation-Sequence: ${sequence}`, + `Operation-Kind: ${operation.kind}`, + `Source-Basis-UTF8-Hex: ${utf8Hex(patchPlan.sourceBasis.toKey())}`, + `Target-Basis-UTF8-Hex: ${utf8Hex(patchPlan.targetBasis.toKey())}`, + `Operation-Key-UTF8-Hex: ${utf8Hex(operation.toKey())}`, + '', + ].join('\n'); +} + +function utf8Hex(value: string): string { + const bytes = new TextEncoder().encode(value); + const parts: string[] = []; + for (const byte of bytes) { + parts.push(byte.toString(16).padStart(2, '0')); + } + return parts.join(''); +} + +function requirePatchPlan( + patchPlan: GraphModelMigrationLoweredPatchPlan, +): GraphModelMigrationLoweredPatchPlan { + if (!(patchPlan instanceof GraphModelMigrationLoweredPatchPlan)) { + throw new GraphModelMigrationScratchWriterError('patchPlan must be a GraphModelMigrationLoweredPatchPlan'); + } + return patchPlan; +} + +function requireScratchRefName(scratchRefName: string | null): string { + if (scratchRefName === null) { + throw new GraphModelMigrationScratchWriterError('scratchRefName must not be null after validation'); + } + return scratchRefName; +} + +function requireNonEmptyString(value: string, name: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw new GraphModelMigrationScratchWriterError(`${name} must be a non-empty string`); + } + return value; +} + +async function gitText(cwd: string, args: readonly string[]): Promise { + const result = await runMigrationGit(cwd, args, null); + if (!result.ok()) { + throw new GraphModelMigrationScratchWriterError( + `git ${args.join(' ')} failed: ${result.stderr}`, + ); + } + return result.stdout.trim(); +} + +async function gitTextWithInput( + cwd: string, + args: readonly string[], + input: string, + deterministicIdentity = false, +): Promise { + const result = await runMigrationGit(cwd, args, input, { deterministicIdentity }); + if (!result.ok()) { + throw new GraphModelMigrationScratchWriterError( + `git ${args.join(' ')} failed: ${result.stderr}`, + ); + } + return result.stdout.trim(); +} + +async function gitTextOrNull(cwd: string, args: readonly string[]): Promise { + const result = await runMigrationGit(cwd, args, null); + if (!result.ok()) { + return null; + } + return result.stdout.trim(); +} diff --git a/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationSourceInventoryCollector.ts b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationSourceInventoryCollector.ts new file mode 100644 index 00000000..0e1d6f29 --- /dev/null +++ b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationSourceInventoryCollector.ts @@ -0,0 +1,204 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +import GraphModelMigrationBasis from '../../../../src/domain/migrations/GraphModelMigrationBasis.ts'; +import GraphModelMigrationContentSource + from '../../../../src/domain/migrations/GraphModelMigrationContentSource.ts'; +import GraphModelMigrationNotice from '../../../../src/domain/migrations/GraphModelMigrationNotice.ts'; +import GraphModelMigrationPatchDescriptor + from '../../../../src/domain/migrations/GraphModelMigrationPatchDescriptor.ts'; +import GraphModelMigrationSourceInventory + from '../../../../src/domain/migrations/GraphModelMigrationSourceInventory.ts'; +import GraphModelMigrationWriterChainDescriptor + from '../../../../src/domain/migrations/GraphModelMigrationWriterChainDescriptor.ts'; +import V17GoldenGraphFixtureManifest, { + V17_GOLDEN_CONTENT_FACT, +} from '../../../../src/domain/migrations/V17GoldenGraphFixtureManifest.ts'; +import { compareStrings } from '../../../../src/domain/utils/StringComparison.ts'; +import { DEFAULT_COMMIT_MESSAGE_CODEC } + from '../../../../src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts'; + +const execFileAsync = promisify(execFile); +const NO_WRITER_REFS_CODE = 'E_NO_WRITER_REFS'; +const NON_PATCH_COMMIT_CODE = 'E_NON_PATCH_COMMIT'; +const WRONG_GRAPH_CODE = 'E_WRONG_GRAPH'; +const WRONG_WRITER_CODE = 'E_WRONG_WRITER'; + +export type GraphModelMigrationSourceInventoryCollectorOptions = { + readonly repositoryPath: string; + readonly graphId: string; + readonly fixtureManifest?: V17GoldenGraphFixtureManifest | null; +}; + +/** Collects v18 migration source inventory from real restored graph-history refs. */ +export async function collectGraphModelMigrationSourceInventory( + options: GraphModelMigrationSourceInventoryCollectorOptions, +): Promise { + const repositoryPath = requireNonEmptyString(options.repositoryPath, 'repositoryPath'); + const graphId = requireNonEmptyString(options.graphId, 'graphId'); + const refNames = await listWriterRefs(repositoryPath, graphId); + if (refNames.length === 0) { + return emptyInventory(graphId, NO_WRITER_REFS_CODE, `no writer refs found for graph ${graphId}`); + } + + const fatalErrors: GraphModelMigrationNotice[] = []; + const writerChains: GraphModelMigrationWriterChainDescriptor[] = []; + const patchDescriptors: GraphModelMigrationPatchDescriptor[] = []; + const basisParts: string[] = []; + + for (const refName of refNames) { + const writerId = writerIdFromRef(refName, graphId); + const patchIds = await gitLines(repositoryPath, ['rev-list', '--reverse', refName]); + const expectedWriter = writerId; + await collectPatchDescriptors({ + repositoryPath, + graphId, + writerId: expectedWriter, + patchIds, + patchDescriptors, + fatalErrors, + }); + writerChains.push(new GraphModelMigrationWriterChainDescriptor({ + writerId, + patchIds, + })); + const head = await gitText(repositoryPath, ['rev-parse', '--verify', refName]); + basisParts.push(`${refName}@${head}`); + } + + const sourceBasis = fatalErrors.length === 0 + ? new GraphModelMigrationBasis({ + graphId, + basisId: basisParts.sort(compareStrings).join('|'), + }) + : null; + + return new GraphModelMigrationSourceInventory({ + graphId, + sourceBasis, + writerChains, + patchDescriptors, + stateSnapshot: null, + contentSources: collectContentSources(options.fixtureManifest ?? null), + warnings: [], + fatalErrors, + }); +} + +async function collectPatchDescriptors(options: { + readonly repositoryPath: string; + readonly graphId: string; + readonly writerId: string; + readonly patchIds: readonly string[]; + readonly patchDescriptors: GraphModelMigrationPatchDescriptor[]; + readonly fatalErrors: GraphModelMigrationNotice[]; +}): Promise { + let sequence = 0; + for (const patchId of options.patchIds) { + await verifyPatchCommit(options.repositoryPath, options.graphId, options.writerId, patchId, options.fatalErrors); + options.patchDescriptors.push(new GraphModelMigrationPatchDescriptor({ + patchId, + writerId: options.writerId, + writerSequence: sequence, + })); + sequence += 1; + } +} + +async function verifyPatchCommit( + repositoryPath: string, + graphId: string, + writerId: string, + patchId: string, + fatalErrors: GraphModelMigrationNotice[], +): Promise { + const message = await gitText(repositoryPath, ['show', '-s', '--format=%B', patchId]); + try { + const decoded = DEFAULT_COMMIT_MESSAGE_CODEC.decodePatch(message); + if (decoded.graph !== graphId) { + fatalErrors.push(GraphModelMigrationNotice.fatal( + WRONG_GRAPH_CODE, + `patch ${patchId} belongs to graph ${decoded.graph}, expected ${graphId}`, + )); + } + if (decoded.writer !== writerId) { + fatalErrors.push(GraphModelMigrationNotice.fatal( + WRONG_WRITER_CODE, + `patch ${patchId} belongs to writer ${decoded.writer}, expected ${writerId}`, + )); + } + } catch { + fatalErrors.push(GraphModelMigrationNotice.fatal( + NON_PATCH_COMMIT_CODE, + `patch ${patchId} does not decode as a v17 patch commit`, + )); + } +} + +function collectContentSources( + fixtureManifest: V17GoldenGraphFixtureManifest | null, +): readonly GraphModelMigrationContentSource[] { + if (fixtureManifest === null) { + return Object.freeze([]); + } + return Object.freeze(fixtureManifest.visibleFacts + .filter((fact) => fact.kind === V17_GOLDEN_CONTENT_FACT) + .map((fact) => new GraphModelMigrationContentSource({ + legacyContentKey: fact.key, + contentOid: `fixture-content:${fact.key}`, + }))); +} + +async function listWriterRefs(repositoryPath: string, graphId: string): Promise { + const lines = await gitLines(repositoryPath, [ + 'for-each-ref', + '--format=%(refname)', + `refs/warp/${graphId}/writers/`, + ]); + return Object.freeze([...lines].sort(compareStrings)); +} + +function emptyInventory( + graphId: string, + code: string, + message: string, +): GraphModelMigrationSourceInventory { + return new GraphModelMigrationSourceInventory({ + graphId, + sourceBasis: null, + writerChains: [], + patchDescriptors: [], + stateSnapshot: null, + contentSources: [], + warnings: [], + fatalErrors: [GraphModelMigrationNotice.fatal(code, message)], + }); +} + +function writerIdFromRef(refName: string, graphId: string): string { + const prefix = `refs/warp/${graphId}/writers/`; + if (!refName.startsWith(prefix)) { + throw new Error(`writer ref ${refName} is outside ${prefix}`); + } + return requireNonEmptyString(refName.slice(prefix.length), 'writerId'); +} + +function requireNonEmptyString(value: string, name: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw new Error(`${name} must be a non-empty string`); + } + return value; +} + +async function gitLines(cwd: string, args: readonly string[]): Promise { + const output = await gitText(cwd, args); + if (output.length === 0) { + return Object.freeze([]); + } + return Object.freeze(output.split('\n').filter((line) => line.length > 0)); +} + +async function gitText(cwd: string, args: readonly string[]): Promise { + const result = await execFileAsync('git', args, { cwd }); + return result.stdout.trim(); +} diff --git a/scripts/v18.0.0/migrations/graph-model/V17GoldenGraphFixtureRestore.ts b/scripts/v18.0.0/migrations/graph-model/V17GoldenGraphFixtureRestore.ts new file mode 100644 index 00000000..ab2215db --- /dev/null +++ b/scripts/v18.0.0/migrations/graph-model/V17GoldenGraphFixtureRestore.ts @@ -0,0 +1,103 @@ +import { execFile } from 'node:child_process'; +import { mkdir, readFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { promisify } from 'node:util'; + +import V17GoldenGraphFixtureManifest from '../../../../src/domain/migrations/V17GoldenGraphFixtureManifest.ts'; +import { parseV17GoldenGraphFixtureManifestJson } + from '../../../../src/infrastructure/adapters/V17GoldenGraphFixtureManifestJsonAdapter.ts'; + +const execFileAsync = promisify(execFile); + +export type V17GoldenGraphFixtureRestoreOptions = { + readonly manifestPath: string; + readonly targetDirectory: string; +}; + +export type V17GoldenGraphFixtureRestoredRef = { + readonly refName: string; + readonly head: string; + readonly patchCount: number; +}; + +export type V17GoldenGraphFixtureRestoreResult = { + readonly repositoryPath: string; + readonly manifest: V17GoldenGraphFixtureManifest; + readonly restoredRefs: readonly V17GoldenGraphFixtureRestoredRef[]; +}; + +/** Restores and validates a v17 golden graph-history fixture into an explicit repository. */ +export async function restoreV17GoldenGraphFixture( + options: V17GoldenGraphFixtureRestoreOptions, +): Promise { + const manifestPath = requireNonEmptyString(options.manifestPath, 'manifestPath'); + const targetDirectory = requireNonEmptyString(options.targetDirectory, 'targetDirectory'); + const manifest = await readManifest(manifestPath); + const repositoryPath = resolve(targetDirectory); + const bundlePath = resolve(dirname(manifestPath), manifest.bundlePath); + + await mkdir(repositoryPath, { recursive: true }); + await runGit(repositoryPath, ['init', '-q']); + for (const chain of manifest.writerChains) { + await runGit(repositoryPath, ['fetch', '-q', bundlePath, `${chain.refName}:${chain.refName}`]); + } + + const restoredRefs = await verifyRestoredRefs(repositoryPath, manifest); + return Object.freeze({ + repositoryPath, + manifest, + restoredRefs, + }); +} + +async function readManifest(path: string): Promise { + const raw = await readFile(path, 'utf8'); + return parseV17GoldenGraphFixtureManifestJson(raw); +} + +function requireNonEmptyString(value: string, name: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw new Error(`${name} must be a non-empty string`); + } + return value; +} + +async function verifyRestoredRefs( + repositoryPath: string, + manifest: V17GoldenGraphFixtureManifest, +): Promise { + const restoredRefs: V17GoldenGraphFixtureRestoredRef[] = []; + for (const chain of manifest.writerChains) { + const head = await gitText(repositoryPath, ['rev-parse', '--verify', chain.refName]); + if (head !== chain.expectedHead) { + throw new Error( + `Restored ref ${chain.refName} expected ${chain.expectedHead}, got ${head}`, + ); + } + const patchCountText = await gitText(repositoryPath, ['rev-list', '--count', chain.refName]); + const patchCount = Number(patchCountText); + if (patchCount !== chain.patchCount) { + throw new Error( + `Restored ref ${chain.refName} expected ${chain.patchCount} patches, got ${patchCount}`, + ); + } + restoredRefs.push(Object.freeze({ + refName: chain.refName, + head, + patchCount, + })); + } + return Object.freeze(restoredRefs); +} + +async function gitText(cwd: string, args: readonly string[]): Promise { + const result = await runGit(cwd, args); + return result.stdout.trim(); +} + +async function runGit( + cwd: string, + args: readonly string[], +): Promise<{ readonly stdout: string; readonly stderr: string }> { + return await execFileAsync('git', args, { cwd }); +} diff --git a/scripts/v18.0.0/migrations/graph-model/migrate.ts b/scripts/v18.0.0/migrations/graph-model/migrate.ts new file mode 100644 index 00000000..b5e49234 --- /dev/null +++ b/scripts/v18.0.0/migrations/graph-model/migrate.ts @@ -0,0 +1,27 @@ +#!/usr/bin/env node + +import process from 'node:process'; + +import { + graphModelMigrationCommandUsage, + runGraphModelMigrationCommandCli, +} from './GraphModelMigrationCommandCli.ts'; + +function errorMessage(error: Error | string): string { + if (error instanceof Error) { + return error.message; + } + return error; +} + +runGraphModelMigrationCommandCli(process.argv.slice(2)) + .then((result) => { + process.stdout.write(result.stdout); + process.stderr.write(result.stderr); + process.exitCode = result.exitCode; + }) + .catch((error) => { + const message = error instanceof Error ? errorMessage(error) : 'unexpected migration command failure'; + process.stderr.write(`${message}\n\n${graphModelMigrationCommandUsage()}\n`); + process.exitCode = 1; + }); diff --git a/src/domain/migrations/GenesisEquivalenceGate.ts b/src/domain/migrations/GenesisEquivalenceGate.ts new file mode 100644 index 00000000..89a946aa --- /dev/null +++ b/src/domain/migrations/GenesisEquivalenceGate.ts @@ -0,0 +1,70 @@ +import GenesisDivergenceReporter from './GenesisDivergenceReporter.ts'; +import GenesisEquivalenceComparisonBasis from './GenesisEquivalenceComparisonBasis.ts'; +import GenesisEquivalenceGateResult from './GenesisEquivalenceGateResult.ts'; +import GenesisEquivalenceProof from './GenesisEquivalenceProof.ts'; +import GenesisEquivalenceProofFailure from './GenesisEquivalenceProofFailure.ts'; +import GenesisEquivalenceReading from './GenesisEquivalenceReading.ts'; +import GraphModelMigrationNotice from './GraphModelMigrationNotice.ts'; +import WarpError from '../errors/WarpError.ts'; + +const MISSING_EQUIVALENCE_BOUNDARY_CODE = 'E_MISSING_EQUIVALENCE_BOUNDARY'; + +/** Gates scratch migration promotion on genesis replay equivalence. */ +export default class GenesisEquivalenceGate { + /** Compares legacy and scratch readings and returns promotion evidence. */ + evaluate( + basis: GenesisEquivalenceComparisonBasis, + legacyReading: GenesisEquivalenceReading, + scratchReading: GenesisEquivalenceReading, + ): GenesisEquivalenceGateResult { + const checkedBasis = requireBasis(basis); + const checkedLegacy = requireReading(legacyReading, 'legacyReading'); + const checkedScratch = requireReading(scratchReading, 'scratchReading'); + const proofResult = new GenesisEquivalenceProof().compare( + checkedBasis, + checkedLegacy, + checkedScratch, + ); + const divergenceReport = proofResult instanceof GenesisEquivalenceProofFailure + ? new GenesisDivergenceReporter().report(proofResult) + : null; + + return new GenesisEquivalenceGateResult({ + proofResult, + divergenceReport, + fatalErrors: collectBoundaryFatalErrors(checkedLegacy, checkedScratch), + }); + } +} + +function collectBoundaryFatalErrors( + legacyReading: GenesisEquivalenceReading, + scratchReading: GenesisEquivalenceReading, +): readonly GraphModelMigrationNotice[] { + const missing = legacyReading.facts + .concat(scratchReading.facts) + .filter((fact) => fact.boundary === null); + if (missing.length === 0) { + return Object.freeze([]); + } + return Object.freeze([ + GraphModelMigrationNotice.fatal( + MISSING_EQUIVALENCE_BOUNDARY_CODE, + `genesis equivalence gate requires boundary evidence for ${missing.length} visible fact(s)`, + ), + ]); +} + +function requireBasis(basis: GenesisEquivalenceComparisonBasis): GenesisEquivalenceComparisonBasis { + if (!(basis instanceof GenesisEquivalenceComparisonBasis)) { + throw new WarpError('basis must be a GenesisEquivalenceComparisonBasis', 'E_VALIDATION'); + } + return basis; +} + +function requireReading(reading: GenesisEquivalenceReading, label: string): GenesisEquivalenceReading { + if (!(reading instanceof GenesisEquivalenceReading)) { + throw new WarpError(`${label} must be a GenesisEquivalenceReading`, 'E_VALIDATION'); + } + return reading; +} diff --git a/src/domain/migrations/GenesisEquivalenceGateResult.ts b/src/domain/migrations/GenesisEquivalenceGateResult.ts new file mode 100644 index 00000000..6ad347fa --- /dev/null +++ b/src/domain/migrations/GenesisEquivalenceGateResult.ts @@ -0,0 +1,88 @@ +import GenesisDivergenceReport from './GenesisDivergenceReport.ts'; +import GenesisEquivalenceProofFailure from './GenesisEquivalenceProofFailure.ts'; +import type { GenesisEquivalenceProofResult } from './GenesisEquivalenceProofResult.ts'; +import GenesisEquivalenceProofSuccess from './GenesisEquivalenceProofSuccess.ts'; +import GraphModelMigrationNotice from './GraphModelMigrationNotice.ts'; +import WarpError from '../errors/WarpError.ts'; + +export type GenesisEquivalenceGateResultFields = { + readonly proofResult: GenesisEquivalenceProofResult; + readonly divergenceReport: GenesisDivergenceReport | null; + readonly fatalErrors: readonly GraphModelMigrationNotice[]; +}; + +/** Promotion gate result for scratch migration genesis equivalence. */ +export default class GenesisEquivalenceGateResult { + readonly proofResult: GenesisEquivalenceProofResult; + readonly divergenceReport: GenesisDivergenceReport | null; + readonly fatalErrors: readonly GraphModelMigrationNotice[]; + + constructor(fields: GenesisEquivalenceGateResultFields) { + const checkedFields = requireFields(fields); + this.proofResult = requireProofResult(checkedFields.proofResult); + this.divergenceReport = requireOptionalDivergenceReport(checkedFields.divergenceReport); + this.fatalErrors = freezeFatalNotices(checkedFields.fatalErrors); + requireReportMatchesProof(this.proofResult, this.divergenceReport); + Object.freeze(this); + } + + /** Returns true only when equivalence passed and no promotion blocker exists. */ + allowsPromotion(): boolean { + return this.proofResult instanceof GenesisEquivalenceProofSuccess + && this.fatalErrors.length === 0; + } +} + +function requireFields( + fields: GenesisEquivalenceGateResultFields | null | undefined, +): GenesisEquivalenceGateResultFields { + if (fields === null || fields === undefined) { + throw new WarpError('GenesisEquivalenceGateResult fields must be provided', 'E_VALIDATION'); + } + return fields; +} + +function requireProofResult(result: GenesisEquivalenceProofResult): GenesisEquivalenceProofResult { + if (!(result instanceof GenesisEquivalenceProofSuccess) + && !(result instanceof GenesisEquivalenceProofFailure)) { + throw new WarpError('proofResult must be a genesis equivalence proof result', 'E_VALIDATION'); + } + return result; +} + +function requireOptionalDivergenceReport( + report: GenesisDivergenceReport | null, +): GenesisDivergenceReport | null { + if (report !== null && !(report instanceof GenesisDivergenceReport)) { + throw new WarpError('divergenceReport must be a GenesisDivergenceReport or null', 'E_VALIDATION'); + } + return report; +} + +function freezeFatalNotices( + fatalErrors: readonly GraphModelMigrationNotice[], +): readonly GraphModelMigrationNotice[] { + if (!Array.isArray(fatalErrors)) { + throw new WarpError('fatalErrors must be an array', 'E_VALIDATION'); + } + return Object.freeze(fatalErrors.map(requireFatalNotice)); +} + +function requireFatalNotice(notice: GraphModelMigrationNotice): GraphModelMigrationNotice { + if (!(notice instanceof GraphModelMigrationNotice) || !notice.isFatal()) { + throw new WarpError('fatalErrors must contain fatal migration notices', 'E_VALIDATION'); + } + return notice; +} + +function requireReportMatchesProof( + proofResult: GenesisEquivalenceProofResult, + report: GenesisDivergenceReport | null, +): void { + if (proofResult instanceof GenesisEquivalenceProofFailure && report === null) { + throw new WarpError('failed gate results must include a divergence report', 'E_VALIDATION'); + } + if (proofResult instanceof GenesisEquivalenceProofSuccess && report !== null) { + throw new WarpError('successful gate results must not include a divergence report', 'E_VALIDATION'); + } +} diff --git a/src/domain/migrations/GraphModelMigrationArchiveRef.ts b/src/domain/migrations/GraphModelMigrationArchiveRef.ts new file mode 100644 index 00000000..388be41d --- /dev/null +++ b/src/domain/migrations/GraphModelMigrationArchiveRef.ts @@ -0,0 +1,102 @@ +import GraphModelMigrationNotice from './GraphModelMigrationNotice.ts'; +import WarpError from '../errors/WarpError.ts'; + +const ARCHIVE_REF_PREFIX = 'refs/warp-migration-archive/'; +const LIVE_WARP_REF_PREFIX = 'refs/warp/'; +const MISSING_ARCHIVE_REF_CODE = 'E_MISSING_ARCHIVE_REF'; +const LIVE_ARCHIVE_REF_TARGET_CODE = 'E_LIVE_ARCHIVE_REF_TARGET'; +const INVALID_ARCHIVE_REF_CODE = 'E_INVALID_ARCHIVE_REF'; +const INVALID_REF_CHARACTERS = Object.freeze(new Set(['~', '^', ':', '?', '*', '[', '\\'])); + +export type GraphModelMigrationArchiveRefFields = { + readonly refName: string; +}; + +/** Explicit archive ref target for preserved pre-migration lineage. */ +export default class GraphModelMigrationArchiveRef { + readonly refName: string; + + constructor(fields: GraphModelMigrationArchiveRefFields) { + const checkedFields = requireFields(fields); + const notice = GraphModelMigrationArchiveRef.validateRefName(checkedFields.refName); + if (notice !== null) { + throw new WarpError(notice.message, notice.code); + } + this.refName = checkedFields.refName; + Object.freeze(this); + } + + /** Validates an archive ref target without constructing one. */ + static validateRefName(refName: string | null | undefined): GraphModelMigrationNotice | null { + if (typeof refName !== 'string' || refName.length === 0) { + return GraphModelMigrationNotice.fatal( + MISSING_ARCHIVE_REF_CODE, + 'migration finalization requires an explicit archive ref target', + ); + } + const prefixNotice = validateRefPrefix(refName); + return prefixNotice ?? validateRefShape(refName); + } +} + +function requireFields( + fields: GraphModelMigrationArchiveRefFields | null | undefined, +): GraphModelMigrationArchiveRefFields { + if (fields === null || fields === undefined) { + throw new WarpError('GraphModelMigrationArchiveRef fields must be provided', 'E_VALIDATION'); + } + return fields; +} + +function validateRefPrefix(refName: string): GraphModelMigrationNotice | null { + if (refName.startsWith(LIVE_WARP_REF_PREFIX)) { + return GraphModelMigrationNotice.fatal( + LIVE_ARCHIVE_REF_TARGET_CODE, + `archive ref must not target live graph ref ${refName}`, + ); + } + if (!refName.startsWith(ARCHIVE_REF_PREFIX)) { + return GraphModelMigrationNotice.fatal( + INVALID_ARCHIVE_REF_CODE, + `archive ref must start with ${ARCHIVE_REF_PREFIX}`, + ); + } + return null; +} + +function validateRefShape(refName: string): GraphModelMigrationNotice | null { + if (!hasInvalidRefShape(refName)) { + return null; + } + return GraphModelMigrationNotice.fatal( + INVALID_ARCHIVE_REF_CODE, + `archive ref has invalid shape ${refName}`, + ); +} + +function hasInvalidRefShape(refName: string): boolean { + const suffix = refName.slice(ARCHIVE_REF_PREFIX.length); + return [ + suffix.length === 0, + suffix.startsWith('/'), + suffix.endsWith('/'), + refName.includes('//'), + refName.includes('..'), + refName.trim() !== refName, + containsInvalidRefCharacter(refName), + ].some((invalid) => invalid); +} + +function containsInvalidRefCharacter(refName: string): boolean { + for (const character of refName) { + if (isInvalidRefCharacter(character)) { + return true; + } + } + return false; +} + +function isInvalidRefCharacter(character: string): boolean { + const code = character.charCodeAt(0); + return code <= 32 || code === 127 || INVALID_REF_CHARACTERS.has(character); +} diff --git a/src/domain/migrations/GraphModelMigrationFinalizationConfirmation.ts b/src/domain/migrations/GraphModelMigrationFinalizationConfirmation.ts new file mode 100644 index 00000000..7fc5563e --- /dev/null +++ b/src/domain/migrations/GraphModelMigrationFinalizationConfirmation.ts @@ -0,0 +1,38 @@ +import WarpError from '../errors/WarpError.ts'; + +export const V18_GRAPH_MODEL_FINALIZATION_CONFIRMATION = + 'CONFIRM_V18_GRAPH_MODEL_MIGRATION_FINALIZATION'; + +export type GraphModelMigrationFinalizationConfirmationFields = { + readonly token: string; +}; + +/** Explicit operator confirmation for graph-model migration finalization. */ +export default class GraphModelMigrationFinalizationConfirmation { + readonly token: string; + + constructor(fields: GraphModelMigrationFinalizationConfirmationFields) { + const checkedFields = requireFields(fields); + this.token = requireFinalizationToken(checkedFields.token); + Object.freeze(this); + } +} + +function requireFields( + fields: GraphModelMigrationFinalizationConfirmationFields | null | undefined, +): GraphModelMigrationFinalizationConfirmationFields { + if (fields === null || fields === undefined) { + throw new WarpError( + 'GraphModelMigrationFinalizationConfirmation fields must be provided', + 'E_VALIDATION', + ); + } + return fields; +} + +function requireFinalizationToken(token: string): string { + if (token !== V18_GRAPH_MODEL_FINALIZATION_CONFIRMATION) { + throw new WarpError('finalization confirmation token is invalid', 'E_VALIDATION'); + } + return token; +} diff --git a/src/domain/migrations/GraphModelMigrationFinalizationRequest.ts b/src/domain/migrations/GraphModelMigrationFinalizationRequest.ts new file mode 100644 index 00000000..e0ef245b --- /dev/null +++ b/src/domain/migrations/GraphModelMigrationFinalizationRequest.ts @@ -0,0 +1,114 @@ +import GenesisEquivalenceGateResult from './GenesisEquivalenceGateResult.ts'; +import GraphModelMigrationFinalizationConfirmation + from './GraphModelMigrationFinalizationConfirmation.ts'; +import GraphModelMigrationRuntimeConformanceResult + from './GraphModelMigrationRuntimeConformanceResult.ts'; +import GraphModelMigrationScratchRef from './GraphModelMigrationScratchRef.ts'; +import WarpError from '../errors/WarpError.ts'; + +export type GraphModelMigrationFinalizationRequestFields = { + readonly liveRefName: string; + readonly expectedLiveHead: string | null; + readonly observedLiveHead: string | null; + readonly scratchRef: GraphModelMigrationScratchRef | null; + readonly scratchHead: string | null; + readonly archiveRefName: string | null; + readonly confirmation: GraphModelMigrationFinalizationConfirmation | null; + readonly gateResult: GenesisEquivalenceGateResult | null; + readonly runtimeConformance: GraphModelMigrationRuntimeConformanceResult | null; +}; + +/** Pure finalization request envelope; it does not move Git refs. */ +export default class GraphModelMigrationFinalizationRequest { + readonly liveRefName: string; + readonly expectedLiveHead: string | null; + readonly observedLiveHead: string | null; + readonly scratchRef: GraphModelMigrationScratchRef | null; + readonly scratchHead: string | null; + readonly archiveRefName: string | null; + readonly confirmation: GraphModelMigrationFinalizationConfirmation | null; + readonly gateResult: GenesisEquivalenceGateResult | null; + readonly runtimeConformance: GraphModelMigrationRuntimeConformanceResult | null; + + constructor(fields: GraphModelMigrationFinalizationRequestFields) { + const checkedFields = requireFields(fields); + this.liveRefName = requireNonEmptyString(checkedFields.liveRefName, 'liveRefName'); + this.expectedLiveHead = requireOptionalString(checkedFields.expectedLiveHead, 'expectedLiveHead'); + this.observedLiveHead = requireOptionalString(checkedFields.observedLiveHead, 'observedLiveHead'); + this.scratchRef = requireOptionalScratchRef(checkedFields.scratchRef); + this.scratchHead = requireOptionalString(checkedFields.scratchHead, 'scratchHead'); + this.archiveRefName = requireOptionalString(checkedFields.archiveRefName, 'archiveRefName'); + this.confirmation = requireOptionalConfirmation(checkedFields.confirmation); + this.gateResult = requireOptionalGateResult(checkedFields.gateResult); + this.runtimeConformance = requireOptionalRuntimeConformance(checkedFields.runtimeConformance); + Object.freeze(this); + } +} + +function requireFields( + fields: GraphModelMigrationFinalizationRequestFields | null | undefined, +): GraphModelMigrationFinalizationRequestFields { + if (fields === null || fields === undefined) { + throw new WarpError('GraphModelMigrationFinalizationRequest fields must be provided', 'E_VALIDATION'); + } + return fields; +} + +function requireNonEmptyString(value: string, name: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw new WarpError(`${name} must be a non-empty string`, 'E_VALIDATION'); + } + return value; +} + +function requireOptionalString(value: string | null, name: string): string | null { + if (value !== null && (typeof value !== 'string' || value.length === 0)) { + throw new WarpError(`${name} must be a non-empty string or null`, 'E_VALIDATION'); + } + return value; +} + +function requireOptionalScratchRef( + scratchRef: GraphModelMigrationScratchRef | null, +): GraphModelMigrationScratchRef | null { + if (scratchRef !== null && !(scratchRef instanceof GraphModelMigrationScratchRef)) { + throw new WarpError('scratchRef must be a GraphModelMigrationScratchRef or null', 'E_VALIDATION'); + } + return scratchRef; +} + +function requireOptionalConfirmation( + confirmation: GraphModelMigrationFinalizationConfirmation | null, +): GraphModelMigrationFinalizationConfirmation | null { + if (confirmation !== null && !(confirmation instanceof GraphModelMigrationFinalizationConfirmation)) { + throw new WarpError( + 'confirmation must be a GraphModelMigrationFinalizationConfirmation or null', + 'E_VALIDATION', + ); + } + return confirmation; +} + +function requireOptionalGateResult( + gateResult: GenesisEquivalenceGateResult | null, +): GenesisEquivalenceGateResult | null { + if (gateResult !== null && !(gateResult instanceof GenesisEquivalenceGateResult)) { + throw new WarpError('gateResult must be a GenesisEquivalenceGateResult or null', 'E_VALIDATION'); + } + return gateResult; +} + +function requireOptionalRuntimeConformance( + runtimeConformance: GraphModelMigrationRuntimeConformanceResult | null, +): GraphModelMigrationRuntimeConformanceResult | null { + if ( + runtimeConformance !== null + && !(runtimeConformance instanceof GraphModelMigrationRuntimeConformanceResult) + ) { + throw new WarpError( + 'runtimeConformance must be a GraphModelMigrationRuntimeConformanceResult or null', + 'E_VALIDATION', + ); + } + return runtimeConformance; +} diff --git a/src/domain/migrations/GraphModelMigrationFinalizationResult.ts b/src/domain/migrations/GraphModelMigrationFinalizationResult.ts new file mode 100644 index 00000000..91d7706e --- /dev/null +++ b/src/domain/migrations/GraphModelMigrationFinalizationResult.ts @@ -0,0 +1,116 @@ +import GraphModelMigrationNotice from './GraphModelMigrationNotice.ts'; +import WarpError from '../errors/WarpError.ts'; + +export const GRAPH_MODEL_MIGRATION_FINALIZATION_BLOCKED = 'blocked'; +export const GRAPH_MODEL_MIGRATION_FINALIZATION_PARTIAL_ARCHIVE = 'partial-archive'; +export const GRAPH_MODEL_MIGRATION_FINALIZATION_COMPLETED = 'completed'; + +export type GraphModelMigrationFinalizationStatus = + | typeof GRAPH_MODEL_MIGRATION_FINALIZATION_BLOCKED + | typeof GRAPH_MODEL_MIGRATION_FINALIZATION_PARTIAL_ARCHIVE + | typeof GRAPH_MODEL_MIGRATION_FINALIZATION_COMPLETED; + +export type GraphModelMigrationFinalizationResultFields = { + readonly status: GraphModelMigrationFinalizationStatus; + readonly liveRefName: string; + readonly archiveRefName: string | null; + readonly previousLiveHead: string | null; + readonly finalizedLiveHead: string | null; + readonly fatalErrors: readonly GraphModelMigrationNotice[]; +}; + +/** Result of an archive-preserving graph-model migration finalization attempt. */ +export default class GraphModelMigrationFinalizationResult { + readonly status: GraphModelMigrationFinalizationStatus; + readonly liveRefName: string; + readonly archiveRefName: string | null; + readonly previousLiveHead: string | null; + readonly finalizedLiveHead: string | null; + readonly fatalErrors: readonly GraphModelMigrationNotice[]; + + constructor(fields: GraphModelMigrationFinalizationResultFields) { + const checkedFields = requireFields(fields); + this.status = requireStatus(checkedFields.status); + this.liveRefName = requireNonEmptyString(checkedFields.liveRefName, 'liveRefName'); + this.archiveRefName = requireOptionalString(checkedFields.archiveRefName, 'archiveRefName'); + this.previousLiveHead = requireOptionalString(checkedFields.previousLiveHead, 'previousLiveHead'); + this.finalizedLiveHead = requireOptionalString(checkedFields.finalizedLiveHead, 'finalizedLiveHead'); + this.fatalErrors = freezeFatalNotices(checkedFields.fatalErrors); + requireStatusMatchesEvidence(this); + Object.freeze(this); + } + + /** Returns true when the live ref was advanced to the scratch head. */ + finalized(): boolean { + return this.status === GRAPH_MODEL_MIGRATION_FINALIZATION_COMPLETED; + } +} + +function requireFields( + fields: GraphModelMigrationFinalizationResultFields | null | undefined, +): GraphModelMigrationFinalizationResultFields { + if (fields === null || fields === undefined) { + throw new WarpError('GraphModelMigrationFinalizationResult fields must be provided', 'E_VALIDATION'); + } + return fields; +} + +function requireStatus( + status: GraphModelMigrationFinalizationStatus, +): GraphModelMigrationFinalizationStatus { + if (status !== GRAPH_MODEL_MIGRATION_FINALIZATION_BLOCKED + && status !== GRAPH_MODEL_MIGRATION_FINALIZATION_PARTIAL_ARCHIVE + && status !== GRAPH_MODEL_MIGRATION_FINALIZATION_COMPLETED) { + throw new WarpError('finalization status is unsupported', 'E_VALIDATION'); + } + return status; +} + +function requireNonEmptyString(value: string, name: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw new WarpError(`${name} must be a non-empty string`, 'E_VALIDATION'); + } + return value; +} + +function requireOptionalString(value: string | null, name: string): string | null { + if (value !== null && (typeof value !== 'string' || value.length === 0)) { + throw new WarpError(`${name} must be a non-empty string or null`, 'E_VALIDATION'); + } + return value; +} + +function freezeFatalNotices( + fatalErrors: readonly GraphModelMigrationNotice[], +): readonly GraphModelMigrationNotice[] { + if (!Array.isArray(fatalErrors)) { + throw new WarpError('fatalErrors must be an array', 'E_VALIDATION'); + } + return Object.freeze(fatalErrors.map(requireFatalNotice)); +} + +function requireFatalNotice(notice: GraphModelMigrationNotice): GraphModelMigrationNotice { + if (!(notice instanceof GraphModelMigrationNotice) || !notice.isFatal()) { + throw new WarpError('fatalErrors must contain fatal migration notices', 'E_VALIDATION'); + } + return notice; +} + +function requireStatusMatchesEvidence(result: GraphModelMigrationFinalizationResult): void { + if (result.status === GRAPH_MODEL_MIGRATION_FINALIZATION_COMPLETED) { + requireCompletedEvidence(result); + return; + } + if (result.fatalErrors.length === 0) { + throw new WarpError('non-completed finalization results require fatal errors', 'E_VALIDATION'); + } +} + +function requireCompletedEvidence(result: GraphModelMigrationFinalizationResult): void { + if (result.fatalErrors.length > 0) { + throw new WarpError('completed finalization results must not include fatal errors', 'E_VALIDATION'); + } + if (result.archiveRefName === null || result.previousLiveHead === null || result.finalizedLiveHead === null) { + throw new WarpError('completed finalization results require archive and head evidence', 'E_VALIDATION'); + } +} diff --git a/src/domain/migrations/GraphModelMigrationFinalizationSafety.ts b/src/domain/migrations/GraphModelMigrationFinalizationSafety.ts new file mode 100644 index 00000000..58bf6326 --- /dev/null +++ b/src/domain/migrations/GraphModelMigrationFinalizationSafety.ts @@ -0,0 +1,144 @@ +import GraphModelMigrationArchiveRef from './GraphModelMigrationArchiveRef.ts'; +import GraphModelMigrationFinalizationRequest from './GraphModelMigrationFinalizationRequest.ts'; +import GraphModelMigrationFinalizationSafetyResult from './GraphModelMigrationFinalizationSafetyResult.ts'; +import GraphModelMigrationNotice from './GraphModelMigrationNotice.ts'; +import WarpError from '../errors/WarpError.ts'; + +const LIVE_REF_PREFIX = 'refs/warp/'; + +/** Pure finalization safety gate before any live Git ref update can run. */ +export default class GraphModelMigrationFinalizationSafety { + /** Evaluates finalization preconditions without mutating Git history. */ + evaluate(request: GraphModelMigrationFinalizationRequest): GraphModelMigrationFinalizationSafetyResult { + const checkedRequest = requireRequest(request); + return new GraphModelMigrationFinalizationSafetyResult({ + request: checkedRequest, + fatalErrors: collectFatalErrors(checkedRequest), + }); + } +} + +function collectFatalErrors( + request: GraphModelMigrationFinalizationRequest, +): readonly GraphModelMigrationNotice[] { + return Object.freeze([ + validateLiveRef(request.liveRefName), + validateConfirmation(request), + validateGateResult(request), + validateArchiveRef(request.archiveRefName), + validateScratchOutput(request), + validateRuntimeConformance(request), + validateLiveHeadExpectation(request), + ].filter((notice) => notice !== null)); +} + +function validateLiveRef(liveRefName: string): GraphModelMigrationNotice | null { + if (liveRefName.startsWith(LIVE_REF_PREFIX)) { + return null; + } + return GraphModelMigrationNotice.fatal( + 'E_INVALID_LIVE_REF', + `finalization live ref must start with ${LIVE_REF_PREFIX}`, + ); +} + +function validateConfirmation( + request: GraphModelMigrationFinalizationRequest, +): GraphModelMigrationNotice | null { + if (request.confirmation !== null) { + return null; + } + return GraphModelMigrationNotice.fatal( + 'E_MISSING_FINALIZATION_CONFIRMATION', + 'migration finalization requires explicit operator confirmation', + ); +} + +function validateGateResult( + request: GraphModelMigrationFinalizationRequest, +): GraphModelMigrationNotice | null { + if (request.gateResult !== null && request.gateResult.allowsPromotion()) { + return null; + } + return GraphModelMigrationNotice.fatal( + 'E_EQUIVALENCE_GATE_NOT_PASSED', + 'migration finalization requires a passed scratch equivalence gate', + ); +} + +function validateArchiveRef(archiveRefName: string | null): GraphModelMigrationNotice | null { + return GraphModelMigrationArchiveRef.validateRefName(archiveRefName); +} + +function validateScratchOutput( + request: GraphModelMigrationFinalizationRequest, +): GraphModelMigrationNotice | null { + if (request.scratchRef !== null && request.scratchHead !== null) { + return null; + } + return GraphModelMigrationNotice.fatal( + 'E_MISSING_SCRATCH_OUTPUT', + 'migration finalization requires scratch ref and scratch head evidence', + ); +} + +function validateRuntimeConformance( + request: GraphModelMigrationFinalizationRequest, +): GraphModelMigrationNotice | null { + if (request.runtimeConformance === null || !request.runtimeConformance.allowsFinalization()) { + return GraphModelMigrationNotice.fatal( + 'E_RUNTIME_CONFORMANCE_NOT_PASSED', + 'migration finalization requires post-migration runtime conformance evidence', + ); + } + if (!runtimeConformanceMatchesScratchOutput(request)) { + return GraphModelMigrationNotice.fatal( + 'E_RUNTIME_CONFORMANCE_MISMATCH', + 'runtime conformance evidence must match the scratch ref and head', + ); + } + return null; +} + +function runtimeConformanceMatchesScratchOutput( + request: GraphModelMigrationFinalizationRequest, +): boolean { + return request.scratchRef !== null + && request.scratchHead !== null + && request.runtimeConformance !== null + && request.runtimeConformance.scratchRef.refName === request.scratchRef.refName + && request.runtimeConformance.scratchHead === request.scratchHead; +} + +function validateLiveHeadExpectation( + request: GraphModelMigrationFinalizationRequest, +): GraphModelMigrationNotice | null { + if (request.expectedLiveHead === null) { + return GraphModelMigrationNotice.fatal( + 'E_MISSING_EXPECTED_LIVE_HEAD', + 'migration finalization requires an expected live ref head', + ); + } + if (request.observedLiveHead === null) { + return GraphModelMigrationNotice.fatal( + 'E_MISSING_OBSERVED_LIVE_HEAD', + 'migration finalization requires observed live ref head evidence', + ); + } + if (request.expectedLiveHead === request.observedLiveHead) { + return null; + } + return GraphModelMigrationNotice.fatal( + 'E_STALE_LIVE_REF_EXPECTATION', + 'migration finalization live ref expectation is stale', + ); +} + +function requireRequest( + request: GraphModelMigrationFinalizationRequest, +): GraphModelMigrationFinalizationRequest { + if (!(request instanceof GraphModelMigrationFinalizationRequest)) { + throw new WarpError('request must be a GraphModelMigrationFinalizationRequest', 'E_VALIDATION'); + } + return request; +} diff --git a/src/domain/migrations/GraphModelMigrationFinalizationSafetyResult.ts b/src/domain/migrations/GraphModelMigrationFinalizationSafetyResult.ts new file mode 100644 index 00000000..3048e89b --- /dev/null +++ b/src/domain/migrations/GraphModelMigrationFinalizationSafetyResult.ts @@ -0,0 +1,63 @@ +import GraphModelMigrationFinalizationRequest from './GraphModelMigrationFinalizationRequest.ts'; +import GraphModelMigrationNotice from './GraphModelMigrationNotice.ts'; +import WarpError from '../errors/WarpError.ts'; + +export type GraphModelMigrationFinalizationSafetyResultFields = { + readonly request: GraphModelMigrationFinalizationRequest; + readonly fatalErrors: readonly GraphModelMigrationNotice[]; +}; + +/** Pure safety decision for graph-model migration finalization. */ +export default class GraphModelMigrationFinalizationSafetyResult { + readonly request: GraphModelMigrationFinalizationRequest; + readonly fatalErrors: readonly GraphModelMigrationNotice[]; + + constructor(fields: GraphModelMigrationFinalizationSafetyResultFields) { + const checkedFields = requireFields(fields); + this.request = requireRequest(checkedFields.request); + this.fatalErrors = freezeFatalNotices(checkedFields.fatalErrors); + Object.freeze(this); + } + + /** Returns true when finalization may move to the Git ref update step. */ + allowsFinalization(): boolean { + return this.fatalErrors.length === 0; + } +} + +function requireFields( + fields: GraphModelMigrationFinalizationSafetyResultFields | null | undefined, +): GraphModelMigrationFinalizationSafetyResultFields { + if (fields === null || fields === undefined) { + throw new WarpError( + 'GraphModelMigrationFinalizationSafetyResult fields must be provided', + 'E_VALIDATION', + ); + } + return fields; +} + +function requireRequest( + request: GraphModelMigrationFinalizationRequest, +): GraphModelMigrationFinalizationRequest { + if (!(request instanceof GraphModelMigrationFinalizationRequest)) { + throw new WarpError('request must be a GraphModelMigrationFinalizationRequest', 'E_VALIDATION'); + } + return request; +} + +function freezeFatalNotices( + fatalErrors: readonly GraphModelMigrationNotice[], +): readonly GraphModelMigrationNotice[] { + if (!Array.isArray(fatalErrors)) { + throw new WarpError('fatalErrors must be an array', 'E_VALIDATION'); + } + return Object.freeze(fatalErrors.map(requireFatalNotice)); +} + +function requireFatalNotice(notice: GraphModelMigrationNotice): GraphModelMigrationNotice { + if (!(notice instanceof GraphModelMigrationNotice) || !notice.isFatal()) { + throw new WarpError('fatalErrors must contain fatal migration notices', 'E_VALIDATION'); + } + return notice; +} diff --git a/src/domain/migrations/GraphModelMigrationLoweredOperation.ts b/src/domain/migrations/GraphModelMigrationLoweredOperation.ts new file mode 100644 index 00000000..b7011b61 --- /dev/null +++ b/src/domain/migrations/GraphModelMigrationLoweredOperation.ts @@ -0,0 +1,58 @@ +import GraphModelMigrationPlannedGraphOperation, { + type GraphModelMigrationPlannedGraphOperationKind, +} from './GraphModelMigrationPlannedGraphOperation.ts'; +import WarpError from '../errors/WarpError.ts'; + +export type GraphModelMigrationLoweredOperationFields = { + readonly kind: GraphModelMigrationPlannedGraphOperationKind; + readonly sourceKey: string; + readonly targetKey: string; +}; + +/** Runtime-backed write-ready migration operation fact. */ +export default class GraphModelMigrationLoweredOperation { + readonly kind: GraphModelMigrationPlannedGraphOperationKind; + readonly sourceKey: string; + readonly targetKey: string; + + constructor(fields: GraphModelMigrationLoweredOperationFields) { + const checkedFields = requireFields(fields); + const planned = new GraphModelMigrationPlannedGraphOperation({ + kind: checkedFields.kind, + sourceKey: checkedFields.sourceKey, + targetKey: checkedFields.targetKey, + }); + this.kind = planned.kind; + this.sourceKey = planned.sourceKey; + this.targetKey = planned.targetKey; + Object.freeze(this); + } + + /** Lowers a planned dry-run fact into a write-ready operation fact. */ + static fromPlanned( + operation: GraphModelMigrationPlannedGraphOperation, + ): GraphModelMigrationLoweredOperation { + if (!(operation instanceof GraphModelMigrationPlannedGraphOperation)) { + throw new WarpError('operation must be a planned graph operation', 'E_VALIDATION'); + } + return new GraphModelMigrationLoweredOperation({ + kind: operation.kind, + sourceKey: operation.sourceKey, + targetKey: operation.targetKey, + }); + } + + /** Returns a deterministic operation key for ordering and dedupe. */ + toKey(): string { + return `lowered\0${this.kind}\0${this.sourceKey}\0${this.targetKey}`; + } +} + +function requireFields( + fields: GraphModelMigrationLoweredOperationFields | null | undefined, +): GraphModelMigrationLoweredOperationFields { + if (fields === null || fields === undefined) { + throw new WarpError('GraphModelMigrationLoweredOperation fields must be provided', 'E_VALIDATION'); + } + return fields; +} diff --git a/src/domain/migrations/GraphModelMigrationLoweredPatchPlan.ts b/src/domain/migrations/GraphModelMigrationLoweredPatchPlan.ts new file mode 100644 index 00000000..27a19483 --- /dev/null +++ b/src/domain/migrations/GraphModelMigrationLoweredPatchPlan.ts @@ -0,0 +1,83 @@ +import { compareStrings } from '../utils/StringComparison.ts'; +import GraphModelMigrationBasis from './GraphModelMigrationBasis.ts'; +import GraphModelMigrationLoweredOperation from './GraphModelMigrationLoweredOperation.ts'; +import WarpError from '../errors/WarpError.ts'; + +export type GraphModelMigrationLoweredPatchPlanFields = { + readonly sourceBasis: GraphModelMigrationBasis; + readonly targetBasis: GraphModelMigrationBasis; + readonly operations: readonly GraphModelMigrationLoweredOperation[]; +}; + +/** Frozen write-ready migration patch plan for scratch writers. */ +export default class GraphModelMigrationLoweredPatchPlan { + readonly sourceBasis: GraphModelMigrationBasis; + readonly targetBasis: GraphModelMigrationBasis; + readonly operations: readonly GraphModelMigrationLoweredOperation[]; + + constructor(fields: GraphModelMigrationLoweredPatchPlanFields) { + const checkedFields = requireFields(fields); + this.sourceBasis = requireBasis(checkedFields.sourceBasis, 'sourceBasis'); + this.targetBasis = requireBasis(checkedFields.targetBasis, 'targetBasis'); + this.operations = freezeOperations(checkedFields.operations); + Object.freeze(this); + } + + /** Returns true when the plan has at least one lowered write fact. */ + hasOperations(): boolean { + return this.operations.length > 0; + } +} + +function requireFields( + fields: GraphModelMigrationLoweredPatchPlanFields | null | undefined, +): GraphModelMigrationLoweredPatchPlanFields { + if (fields === null || fields === undefined) { + throw new WarpError('GraphModelMigrationLoweredPatchPlan fields must be provided', 'E_VALIDATION'); + } + return fields; +} + +function requireBasis(basis: GraphModelMigrationBasis, name: string): GraphModelMigrationBasis { + if (!(basis instanceof GraphModelMigrationBasis)) { + throw new WarpError(`${name} must be a GraphModelMigrationBasis`, 'E_VALIDATION'); + } + return basis; +} + +function freezeOperations( + operations: readonly GraphModelMigrationLoweredOperation[], +): readonly GraphModelMigrationLoweredOperation[] { + if (!Array.isArray(operations)) { + throw new WarpError('operations must be an array', 'E_VALIDATION'); + } + const checked = operations.map(requireOperation); + requireUnique(checked.map((operation) => operation.toKey())); + return Object.freeze([...checked].sort(compareOperations)); +} + +function requireOperation( + operation: GraphModelMigrationLoweredOperation, +): GraphModelMigrationLoweredOperation { + if (!(operation instanceof GraphModelMigrationLoweredOperation)) { + throw new WarpError('operations must contain lowered migration operations', 'E_VALIDATION'); + } + return operation; +} + +function requireUnique(keys: readonly string[]): void { + const seen = new Set(); + for (const key of keys) { + if (seen.has(key)) { + throw new WarpError(`GraphModelMigrationLoweredPatchPlan duplicates operation ${key}`, 'E_VALIDATION'); + } + seen.add(key); + } +} + +function compareOperations( + left: GraphModelMigrationLoweredOperation, + right: GraphModelMigrationLoweredOperation, +): number { + return compareStrings(left.toKey(), right.toKey()); +} diff --git a/src/domain/migrations/GraphModelMigrationOperationLowerer.ts b/src/domain/migrations/GraphModelMigrationOperationLowerer.ts new file mode 100644 index 00000000..c96a5d30 --- /dev/null +++ b/src/domain/migrations/GraphModelMigrationOperationLowerer.ts @@ -0,0 +1,41 @@ +import DryRunGraphModelMigrationPlan from './DryRunGraphModelMigrationPlan.ts'; +import GraphModelMigrationLoweredOperation from './GraphModelMigrationLoweredOperation.ts'; +import GraphModelMigrationLoweredPatchPlan from './GraphModelMigrationLoweredPatchPlan.ts'; +import GraphModelMigrationOperationLoweringResult from './GraphModelMigrationOperationLoweringResult.ts'; +import WarpError from '../errors/WarpError.ts'; + +/** Pure lowering service from dry-run facts to scratch-writer input values. */ +export default class GraphModelMigrationOperationLowerer { + /** Lowers a dry-run migration plan without reading or writing graph history. */ + lower(plan: DryRunGraphModelMigrationPlan): GraphModelMigrationOperationLoweringResult { + const checkedPlan = requirePlan(plan); + if (checkedPlan.hasFatalErrors()) { + return new GraphModelMigrationOperationLoweringResult({ + patchPlan: null, + warnings: checkedPlan.warnings, + fatalErrors: checkedPlan.fatalErrors, + }); + } + const { manifest } = checkedPlan; + if (manifest === null) { + throw new WarpError('successful dry-run plan must contain a manifest', 'E_VALIDATION'); + } + return new GraphModelMigrationOperationLoweringResult({ + patchPlan: new GraphModelMigrationLoweredPatchPlan({ + sourceBasis: manifest.sourceBasis, + targetBasis: manifest.targetBasis, + operations: checkedPlan.plannedOperations + .map((operation) => GraphModelMigrationLoweredOperation.fromPlanned(operation)), + }), + warnings: checkedPlan.warnings, + fatalErrors: [], + }); + } +} + +function requirePlan(plan: DryRunGraphModelMigrationPlan): DryRunGraphModelMigrationPlan { + if (!(plan instanceof DryRunGraphModelMigrationPlan)) { + throw new WarpError('plan must be a DryRunGraphModelMigrationPlan', 'E_VALIDATION'); + } + return plan; +} diff --git a/src/domain/migrations/GraphModelMigrationOperationLoweringResult.ts b/src/domain/migrations/GraphModelMigrationOperationLoweringResult.ts new file mode 100644 index 00000000..22e4ea18 --- /dev/null +++ b/src/domain/migrations/GraphModelMigrationOperationLoweringResult.ts @@ -0,0 +1,101 @@ +import GraphModelMigrationLoweredPatchPlan from './GraphModelMigrationLoweredPatchPlan.ts'; +import GraphModelMigrationNotice from './GraphModelMigrationNotice.ts'; +import WarpError from '../errors/WarpError.ts'; + +export type GraphModelMigrationOperationLoweringResultFields = { + readonly patchPlan: GraphModelMigrationLoweredPatchPlan | null; + readonly warnings: readonly GraphModelMigrationNotice[]; + readonly fatalErrors: readonly GraphModelMigrationNotice[]; +}; + +/** Result value for pure graph-model migration operation lowering. */ +export default class GraphModelMigrationOperationLoweringResult { + readonly patchPlan: GraphModelMigrationLoweredPatchPlan | null; + readonly warnings: readonly GraphModelMigrationNotice[]; + readonly fatalErrors: readonly GraphModelMigrationNotice[]; + + constructor(fields: GraphModelMigrationOperationLoweringResultFields) { + const checkedFields = requireFields(fields); + this.patchPlan = requireOptionalPatchPlan(checkedFields.patchPlan); + this.warnings = freezeWarningNotices(checkedFields.warnings); + this.fatalErrors = freezeFatalNotices(checkedFields.fatalErrors); + requirePatchPlanMatchesFatality(this.patchPlan, this.fatalErrors); + Object.freeze(this); + } + + /** Returns true when lowering failed closed. */ + hasFatalErrors(): boolean { + return this.fatalErrors.length > 0; + } +} + +function requireFields( + fields: GraphModelMigrationOperationLoweringResultFields | null | undefined, +): GraphModelMigrationOperationLoweringResultFields { + if (fields === null || fields === undefined) { + throw new WarpError('GraphModelMigrationOperationLoweringResult fields must be provided', 'E_VALIDATION'); + } + return fields; +} + +function requireOptionalPatchPlan( + patchPlan: GraphModelMigrationLoweredPatchPlan | null, +): GraphModelMigrationLoweredPatchPlan | null { + if (patchPlan !== null && !(patchPlan instanceof GraphModelMigrationLoweredPatchPlan)) { + throw new WarpError('patchPlan must be a GraphModelMigrationLoweredPatchPlan', 'E_VALIDATION'); + } + return patchPlan; +} + +function freezeWarningNotices( + notices: readonly GraphModelMigrationNotice[], +): readonly GraphModelMigrationNotice[] { + const checked = requireNoticeArray(notices, 'warnings'); + for (const notice of checked) { + if (notice.isFatal()) { + throw new WarpError('warnings contains the wrong notice kind', 'E_VALIDATION'); + } + } + return Object.freeze(checked); +} + +function freezeFatalNotices( + notices: readonly GraphModelMigrationNotice[], +): readonly GraphModelMigrationNotice[] { + const checked = requireNoticeArray(notices, 'fatalErrors'); + for (const notice of checked) { + if (!notice.isFatal()) { + throw new WarpError('fatalErrors contains the wrong notice kind', 'E_VALIDATION'); + } + } + return Object.freeze(checked); +} + +function requireNoticeArray( + notices: readonly GraphModelMigrationNotice[], + label: string, +): readonly GraphModelMigrationNotice[] { + if (!Array.isArray(notices)) { + throw new WarpError(`${label} must be an array`, 'E_VALIDATION'); + } + return notices.map(requireNotice); +} + +function requireNotice(notice: GraphModelMigrationNotice): GraphModelMigrationNotice { + if (!(notice instanceof GraphModelMigrationNotice)) { + throw new WarpError('notices must contain GraphModelMigrationNotice instances', 'E_VALIDATION'); + } + return notice; +} + +function requirePatchPlanMatchesFatality( + patchPlan: GraphModelMigrationLoweredPatchPlan | null, + fatalErrors: readonly GraphModelMigrationNotice[], +): void { + if (fatalErrors.length > 0 && patchPlan !== null) { + throw new WarpError('fatal lowering results must not contain a patch plan', 'E_VALIDATION'); + } + if (fatalErrors.length === 0 && patchPlan === null) { + throw new WarpError('successful lowering results must contain a patch plan', 'E_VALIDATION'); + } +} diff --git a/src/domain/migrations/GraphModelMigrationRuntimeConformanceResult.ts b/src/domain/migrations/GraphModelMigrationRuntimeConformanceResult.ts new file mode 100644 index 00000000..7f2c8e64 --- /dev/null +++ b/src/domain/migrations/GraphModelMigrationRuntimeConformanceResult.ts @@ -0,0 +1,107 @@ +import GraphModelMigrationNotice from './GraphModelMigrationNotice.ts'; +import GraphModelMigrationScratchRef from './GraphModelMigrationScratchRef.ts'; +import WarpError from '../errors/WarpError.ts'; + +export const GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_PASSED = 'passed'; +export const GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_FAILED = 'failed'; + +export type GraphModelMigrationRuntimeConformanceStatus = + | typeof GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_PASSED + | typeof GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_FAILED; + +export type GraphModelMigrationRuntimeConformanceResultFields = { + readonly scratchRef: GraphModelMigrationScratchRef; + readonly scratchHead: string; + readonly status: GraphModelMigrationRuntimeConformanceStatus; + readonly witness: string; + readonly fatalErrors: readonly GraphModelMigrationNotice[]; +}; + +/** Runtime conformance evidence for post-migration scratch history. */ +export default class GraphModelMigrationRuntimeConformanceResult { + readonly scratchRef: GraphModelMigrationScratchRef; + readonly scratchHead: string; + readonly status: GraphModelMigrationRuntimeConformanceStatus; + readonly witness: string; + readonly fatalErrors: readonly GraphModelMigrationNotice[]; + + constructor(fields: GraphModelMigrationRuntimeConformanceResultFields) { + const checkedFields = requireFields(fields); + this.scratchRef = requireScratchRef(checkedFields.scratchRef); + this.scratchHead = requireNonEmptyString(checkedFields.scratchHead, 'scratchHead'); + this.status = requireStatus(checkedFields.status); + this.witness = requireNonEmptyString(checkedFields.witness, 'witness'); + this.fatalErrors = freezeFatalNotices(checkedFields.fatalErrors); + requireStatusMatchesFatalErrors(this.status, this.fatalErrors); + Object.freeze(this); + } + + /** Returns true when scratch output is proven runtime-readable. */ + allowsFinalization(): boolean { + return this.status === GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_PASSED; + } +} + +function requireFields( + fields: GraphModelMigrationRuntimeConformanceResultFields | null | undefined, +): GraphModelMigrationRuntimeConformanceResultFields { + if (fields === null || fields === undefined) { + throw new WarpError( + 'GraphModelMigrationRuntimeConformanceResult fields must be provided', + 'E_VALIDATION', + ); + } + return fields; +} + +function requireScratchRef(scratchRef: GraphModelMigrationScratchRef): GraphModelMigrationScratchRef { + if (!(scratchRef instanceof GraphModelMigrationScratchRef)) { + throw new WarpError('scratchRef must be a GraphModelMigrationScratchRef', 'E_VALIDATION'); + } + return scratchRef; +} + +function requireStatus( + status: GraphModelMigrationRuntimeConformanceStatus, +): GraphModelMigrationRuntimeConformanceStatus { + if (status !== GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_PASSED + && status !== GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_FAILED) { + throw new WarpError('runtime conformance status is unsupported', 'E_VALIDATION'); + } + return status; +} + +function requireNonEmptyString(value: string, name: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw new WarpError(`${name} must be a non-empty string`, 'E_VALIDATION'); + } + return value; +} + +function freezeFatalNotices( + fatalErrors: readonly GraphModelMigrationNotice[], +): readonly GraphModelMigrationNotice[] { + if (!Array.isArray(fatalErrors)) { + throw new WarpError('fatalErrors must be an array', 'E_VALIDATION'); + } + return Object.freeze(fatalErrors.map(requireFatalNotice)); +} + +function requireFatalNotice(notice: GraphModelMigrationNotice): GraphModelMigrationNotice { + if (!(notice instanceof GraphModelMigrationNotice) || !notice.isFatal()) { + throw new WarpError('fatalErrors must contain fatal migration notices', 'E_VALIDATION'); + } + return notice; +} + +function requireStatusMatchesFatalErrors( + status: GraphModelMigrationRuntimeConformanceStatus, + fatalErrors: readonly GraphModelMigrationNotice[], +): void { + if (status === GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_PASSED && fatalErrors.length > 0) { + throw new WarpError('passed runtime conformance must not contain fatal errors', 'E_VALIDATION'); + } + if (status === GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_FAILED && fatalErrors.length === 0) { + throw new WarpError('failed runtime conformance must contain fatal errors', 'E_VALIDATION'); + } +} diff --git a/src/domain/migrations/GraphModelMigrationScratchRef.ts b/src/domain/migrations/GraphModelMigrationScratchRef.ts new file mode 100644 index 00000000..f2e6b539 --- /dev/null +++ b/src/domain/migrations/GraphModelMigrationScratchRef.ts @@ -0,0 +1,107 @@ +import GraphModelMigrationNotice from './GraphModelMigrationNotice.ts'; +import WarpError from '../errors/WarpError.ts'; + +const SCRATCH_REF_PREFIX = 'refs/warp-migration-scratch/'; +const LIVE_WARP_REF_PREFIX = 'refs/warp/'; +const MISSING_SCRATCH_REF_CODE = 'E_MISSING_SCRATCH_REF'; +const LIVE_REF_TARGET_CODE = 'E_LIVE_REF_TARGET'; +const INVALID_SCRATCH_REF_CODE = 'E_INVALID_SCRATCH_REF'; +const INVALID_REF_CHARACTERS = Object.freeze(new Set(['~', '^', ':', '?', '*', '[', '\\'])); + +export type GraphModelMigrationScratchRefFields = { + readonly refName: string; +}; + +/** Explicit scratch ref target for graph-model migration writes. */ +export default class GraphModelMigrationScratchRef { + readonly refName: string; + + constructor(fields: GraphModelMigrationScratchRefFields) { + const checkedFields = requireFields(fields); + const notice = GraphModelMigrationScratchRef.validateRefName(checkedFields.refName); + if (notice !== null) { + throw new WarpError(notice.message, notice.code); + } + this.refName = checkedFields.refName; + Object.freeze(this); + } + + /** Validates a scratch ref target without constructing one. */ + static validateRefName(refName: string | null | undefined): GraphModelMigrationNotice | null { + if (typeof refName !== 'string' || refName.length === 0) { + return GraphModelMigrationNotice.fatal( + MISSING_SCRATCH_REF_CODE, + 'graph-model migration requires an explicit scratch ref target', + ); + } + const prefixNotice = validateRefPrefix(refName); + return prefixNotice ?? validateRefShape(refName); + } + + /** Returns the Git ref name. */ + toString(): string { + return this.refName; + } +} + +function requireFields( + fields: GraphModelMigrationScratchRefFields | null | undefined, +): GraphModelMigrationScratchRefFields { + if (fields === null || fields === undefined) { + throw new WarpError('GraphModelMigrationScratchRef fields must be provided', 'E_VALIDATION'); + } + return fields; +} + +function hasInvalidRefShape(refName: string): boolean { + const suffix = refName.slice(SCRATCH_REF_PREFIX.length); + return [ + suffix.length === 0, + suffix.startsWith('/'), + suffix.endsWith('/'), + refName.includes('//'), + refName.includes('..'), + refName.trim() !== refName, + containsInvalidRefCharacter(refName), + ].some((invalid) => invalid); +} + +function containsInvalidRefCharacter(refName: string): boolean { + for (const character of refName) { + if (isInvalidRefCharacter(character)) { + return true; + } + } + return false; +} + +function validateRefPrefix(refName: string): GraphModelMigrationNotice | null { + if (refName.startsWith(LIVE_WARP_REF_PREFIX)) { + return GraphModelMigrationNotice.fatal( + LIVE_REF_TARGET_CODE, + `scratch migration writer refuses live graph ref target ${refName}`, + ); + } + if (!refName.startsWith(SCRATCH_REF_PREFIX)) { + return GraphModelMigrationNotice.fatal( + INVALID_SCRATCH_REF_CODE, + `scratch migration ref must start with ${SCRATCH_REF_PREFIX}`, + ); + } + return null; +} + +function validateRefShape(refName: string): GraphModelMigrationNotice | null { + if (!hasInvalidRefShape(refName)) { + return null; + } + return GraphModelMigrationNotice.fatal( + INVALID_SCRATCH_REF_CODE, + `scratch migration ref has invalid shape ${refName}`, + ); +} + +function isInvalidRefCharacter(character: string): boolean { + const code = character.charCodeAt(0); + return code <= 32 || code === 127 || INVALID_REF_CHARACTERS.has(character); +} diff --git a/src/domain/migrations/GraphModelMigrationScratchWriteResult.ts b/src/domain/migrations/GraphModelMigrationScratchWriteResult.ts new file mode 100644 index 00000000..ae5dae28 --- /dev/null +++ b/src/domain/migrations/GraphModelMigrationScratchWriteResult.ts @@ -0,0 +1,140 @@ +import GraphModelMigrationNotice from './GraphModelMigrationNotice.ts'; +import GraphModelMigrationScratchRef from './GraphModelMigrationScratchRef.ts'; +import GraphModelMigrationScratchWrittenPatch from './GraphModelMigrationScratchWrittenPatch.ts'; +import WarpError from '../errors/WarpError.ts'; + +export type GraphModelMigrationScratchWriteResultFields = { + readonly scratchRef: GraphModelMigrationScratchRef | null; + readonly scratchHead: string | null; + readonly writtenPatches: readonly GraphModelMigrationScratchWrittenPatch[]; + readonly warnings: readonly GraphModelMigrationNotice[]; + readonly fatalErrors: readonly GraphModelMigrationNotice[]; +}; + +/** Result value for an explicit scratch migration history write. */ +export default class GraphModelMigrationScratchWriteResult { + readonly scratchRef: GraphModelMigrationScratchRef | null; + readonly scratchHead: string | null; + readonly writtenPatches: readonly GraphModelMigrationScratchWrittenPatch[]; + readonly warnings: readonly GraphModelMigrationNotice[]; + readonly fatalErrors: readonly GraphModelMigrationNotice[]; + + constructor(fields: GraphModelMigrationScratchWriteResultFields) { + const checkedFields = requireFields(fields); + this.scratchRef = requireOptionalScratchRef(checkedFields.scratchRef); + this.scratchHead = requireOptionalHead(checkedFields.scratchHead); + this.writtenPatches = freezeWrittenPatches(checkedFields.writtenPatches); + this.warnings = freezeWarningNotices(checkedFields.warnings); + this.fatalErrors = freezeFatalNotices(checkedFields.fatalErrors); + requireFatalResultShape(this.scratchHead, this.writtenPatches, this.fatalErrors); + Object.freeze(this); + } + + /** Returns true when the write was blocked before completion. */ + hasFatalErrors(): boolean { + return this.fatalErrors.length > 0; + } +} + +function requireFields( + fields: GraphModelMigrationScratchWriteResultFields | null | undefined, +): GraphModelMigrationScratchWriteResultFields { + if (fields === null || fields === undefined) { + throw new WarpError('GraphModelMigrationScratchWriteResult fields must be provided', 'E_VALIDATION'); + } + return fields; +} + +function requireOptionalScratchRef( + scratchRef: GraphModelMigrationScratchRef | null, +): GraphModelMigrationScratchRef | null { + if (scratchRef !== null && !(scratchRef instanceof GraphModelMigrationScratchRef)) { + throw new WarpError('scratchRef must be a GraphModelMigrationScratchRef or null', 'E_VALIDATION'); + } + return scratchRef; +} + +function requireOptionalHead(scratchHead: string | null): string | null { + if (scratchHead === null) { + return null; + } + if (typeof scratchHead !== 'string' || scratchHead.length === 0) { + throw new WarpError('scratchHead must be a non-empty string or null', 'E_VALIDATION'); + } + return scratchHead; +} + +function freezeWrittenPatches( + writtenPatches: readonly GraphModelMigrationScratchWrittenPatch[], +): readonly GraphModelMigrationScratchWrittenPatch[] { + if (!Array.isArray(writtenPatches)) { + throw new WarpError('writtenPatches must be an array', 'E_VALIDATION'); + } + const checked = writtenPatches.map(requireWrittenPatch); + requireUniqueOperationKeys(checked); + return Object.freeze([...checked]); +} + +function requireWrittenPatch( + writtenPatch: GraphModelMigrationScratchWrittenPatch, +): GraphModelMigrationScratchWrittenPatch { + if (!(writtenPatch instanceof GraphModelMigrationScratchWrittenPatch)) { + throw new WarpError('writtenPatches must contain scratch written patches', 'E_VALIDATION'); + } + return writtenPatch; +} + +function requireUniqueOperationKeys( + writtenPatches: readonly GraphModelMigrationScratchWrittenPatch[], +): void { + const seen = new Set(); + for (const writtenPatch of writtenPatches) { + const key = writtenPatch.operationKey(); + if (seen.has(key)) { + throw new WarpError(`duplicate scratch written operation ${key}`, 'E_VALIDATION'); + } + seen.add(key); + } +} + +function freezeWarningNotices( + warnings: readonly GraphModelMigrationNotice[], +): readonly GraphModelMigrationNotice[] { + if (!Array.isArray(warnings)) { + throw new WarpError('warnings must be an array', 'E_VALIDATION'); + } + return Object.freeze(warnings.map(requireWarningNotice)); +} + +function freezeFatalNotices( + fatalErrors: readonly GraphModelMigrationNotice[], +): readonly GraphModelMigrationNotice[] { + if (!Array.isArray(fatalErrors)) { + throw new WarpError('fatalErrors must be an array', 'E_VALIDATION'); + } + return Object.freeze(fatalErrors.map(requireFatalNotice)); +} + +function requireWarningNotice(notice: GraphModelMigrationNotice): GraphModelMigrationNotice { + if (!(notice instanceof GraphModelMigrationNotice) || notice.isFatal()) { + throw new WarpError('warnings must contain warning migration notices', 'E_VALIDATION'); + } + return notice; +} + +function requireFatalNotice(notice: GraphModelMigrationNotice): GraphModelMigrationNotice { + if (!(notice instanceof GraphModelMigrationNotice) || !notice.isFatal()) { + throw new WarpError('fatalErrors must contain fatal migration notices', 'E_VALIDATION'); + } + return notice; +} + +function requireFatalResultShape( + scratchHead: string | null, + writtenPatches: readonly GraphModelMigrationScratchWrittenPatch[], + fatalErrors: readonly GraphModelMigrationNotice[], +): void { + if (fatalErrors.length > 0 && (scratchHead !== null || writtenPatches.length > 0)) { + throw new WarpError('fatal scratch write results must not include written output', 'E_VALIDATION'); + } +} diff --git a/src/domain/migrations/GraphModelMigrationScratchWrittenPatch.ts b/src/domain/migrations/GraphModelMigrationScratchWrittenPatch.ts new file mode 100644 index 00000000..97460735 --- /dev/null +++ b/src/domain/migrations/GraphModelMigrationScratchWrittenPatch.ts @@ -0,0 +1,60 @@ +import GraphModelMigrationLoweredOperation from './GraphModelMigrationLoweredOperation.ts'; +import WarpError from '../errors/WarpError.ts'; + +export type GraphModelMigrationScratchWrittenPatchFields = { + readonly commitId: string; + readonly operation: GraphModelMigrationLoweredOperation; + readonly sequence: number; +}; + +/** One scratch-history commit written for a lowered migration operation. */ +export default class GraphModelMigrationScratchWrittenPatch { + readonly commitId: string; + readonly operation: GraphModelMigrationLoweredOperation; + readonly sequence: number; + + constructor(fields: GraphModelMigrationScratchWrittenPatchFields) { + const checkedFields = requireFields(fields); + this.commitId = requireNonEmptyString(checkedFields.commitId, 'commitId'); + this.operation = requireOperation(checkedFields.operation); + this.sequence = requireNonNegativeInteger(checkedFields.sequence, 'sequence'); + Object.freeze(this); + } + + /** Returns the deterministic lowered operation key carried by this commit. */ + operationKey(): string { + return this.operation.toKey(); + } +} + +function requireFields( + fields: GraphModelMigrationScratchWrittenPatchFields | null | undefined, +): GraphModelMigrationScratchWrittenPatchFields { + if (fields === null || fields === undefined) { + throw new WarpError('GraphModelMigrationScratchWrittenPatch fields must be provided', 'E_VALIDATION'); + } + return fields; +} + +function requireOperation( + operation: GraphModelMigrationLoweredOperation, +): GraphModelMigrationLoweredOperation { + if (!(operation instanceof GraphModelMigrationLoweredOperation)) { + throw new WarpError('operation must be a GraphModelMigrationLoweredOperation', 'E_VALIDATION'); + } + return operation; +} + +function requireNonEmptyString(value: string, name: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw new WarpError(`${name} must be a non-empty string`, 'E_VALIDATION'); + } + return value; +} + +function requireNonNegativeInteger(value: number, name: string): number { + if (!Number.isInteger(value) || value < 0) { + throw new WarpError(`${name} must be a non-negative integer`, 'E_VALIDATION'); + } + return value; +} diff --git a/src/domain/migrations/V17GoldenGraphFixtureGenesisReading.ts b/src/domain/migrations/V17GoldenGraphFixtureGenesisReading.ts new file mode 100644 index 00000000..53dab6d7 --- /dev/null +++ b/src/domain/migrations/V17GoldenGraphFixtureGenesisReading.ts @@ -0,0 +1,117 @@ +import GenesisEquivalenceBoundary from './GenesisEquivalenceBoundary.ts'; +import GenesisEquivalenceReading from './GenesisEquivalenceReading.ts'; +import GenesisEquivalenceReadingFact, { + type GenesisEquivalenceReadingFactKind, +} from './GenesisEquivalenceReadingFact.ts'; +import V17GoldenGraphFixtureManifest, { + V17GoldenContentFact, + V17GoldenEdgeFact, + type V17GoldenGraphFixtureVisibleFact, + V17GoldenMultiWriterFact, + V17GoldenNodeFact, + V17GoldenPropertyFact, + V17GoldenRemovalFact, +} from './V17GoldenGraphFixtureManifest.ts'; +import WarpError from '../errors/WarpError.ts'; + +const LEGACY_FIXTURE_READING_PREFIX = 'v17-golden-fixture'; + +type ProjectedFactFields = { + readonly kind: GenesisEquivalenceReadingFactKind; + readonly factKey: string; + readonly fieldPath: string; + readonly value: string; +}; + +/** Builds a genesis-equivalence reading from a v17 golden fixture manifest. */ +export default class V17GoldenGraphFixtureGenesisReading { + /** Projects declared fixture facts into observer-visible equivalence facts. */ + build(manifest: V17GoldenGraphFixtureManifest): GenesisEquivalenceReading { + const checkedManifest = requireManifest(manifest); + return new GenesisEquivalenceReading({ + readingId: `${LEGACY_FIXTURE_READING_PREFIX}:${checkedManifest.fixtureId}`, + facts: checkedManifest.visibleFacts.map((fact, index) => projectFact(checkedManifest, fact, index)), + }); + } +} + +function projectFact( + manifest: V17GoldenGraphFixtureManifest, + fact: V17GoldenGraphFixtureVisibleFact, + index: number, +): GenesisEquivalenceReadingFact { + const projected = projectionFor(fact); + return new GenesisEquivalenceReadingFact({ + kind: projected.kind, + factKey: projected.factKey, + fieldPath: projected.fieldPath, + value: projected.value, + boundary: boundaryFor(manifest, index), + }); +} + +function projectionFor(fact: V17GoldenGraphFixtureVisibleFact): ProjectedFactFields { + if (fact instanceof V17GoldenNodeFact) { + return projection({ kind: 'node', factKey: fact.key, fieldPath: 'visibility', value: 'visible' }); + } + if (fact instanceof V17GoldenEdgeFact) { + return projection({ kind: 'edge', factKey: fact.key, fieldPath: 'visibility', value: 'visible' }); + } + return compatibilityProjectionFor(fact); +} + +function compatibilityProjectionFor(fact: V17GoldenGraphFixtureVisibleFact): ProjectedFactFields { + if (fact instanceof V17GoldenPropertyFact) { + return projection({ kind: 'property', factKey: fact.key, fieldPath: 'value', value: fact.description }); + } + if (fact instanceof V17GoldenContentFact) { + return projection({ + kind: 'content-attachment', + factKey: fact.key, + fieldPath: 'payload.oid', + value: `fixture-content:${fact.key}`, + }); + } + return nonVisibleLifecycleProjectionFor(fact); +} + +function nonVisibleLifecycleProjectionFor(fact: V17GoldenGraphFixtureVisibleFact): ProjectedFactFields { + if (fact instanceof V17GoldenRemovalFact) { + return projection({ kind: 'node', factKey: fact.key, fieldPath: 'visibility', value: 'removed' }); + } + if (fact instanceof V17GoldenMultiWriterFact) { + return projection({ + kind: 'property', + factKey: fact.key, + fieldPath: 'coverage', + value: fact.description, + }); + } + throw new WarpError('unsupported v17 fixture visible fact kind', 'E_VALIDATION'); +} + +function projection(fields: ProjectedFactFields): ProjectedFactFields { + return Object.freeze(fields); +} + +function boundaryFor( + manifest: V17GoldenGraphFixtureManifest, + index: number, +): GenesisEquivalenceBoundary { + const chain = manifest.writerChains[index % manifest.writerChains.length]; + if (chain === undefined) { + throw new WarpError('v17 fixture manifest must contain writer chain evidence', 'E_VALIDATION'); + } + return new GenesisEquivalenceBoundary({ + writerId: chain.writerId, + patchId: chain.expectedHead, + operationIndex: index, + }); +} + +function requireManifest(manifest: V17GoldenGraphFixtureManifest): V17GoldenGraphFixtureManifest { + if (!(manifest instanceof V17GoldenGraphFixtureManifest)) { + throw new WarpError('manifest must be a V17GoldenGraphFixtureManifest', 'E_VALIDATION'); + } + return manifest; +} diff --git a/src/domain/migrations/V17GoldenGraphFixtureManifest.ts b/src/domain/migrations/V17GoldenGraphFixtureManifest.ts new file mode 100644 index 00000000..f76e0b12 --- /dev/null +++ b/src/domain/migrations/V17GoldenGraphFixtureManifest.ts @@ -0,0 +1,342 @@ +import { compareStrings } from '../utils/StringComparison.ts'; +import WarpError from '../errors/WarpError.ts'; + +const OID_PATTERN = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/; +const PATH_SEGMENT_SEPARATOR = '/'; + +export const V17_GOLDEN_NODE_FACT = 'node'; +export const V17_GOLDEN_EDGE_FACT = 'edge'; +export const V17_GOLDEN_PROPERTY_FACT = 'property'; +export const V17_GOLDEN_CONTENT_FACT = 'content'; +export const V17_GOLDEN_REMOVAL_FACT = 'removal'; +export const V17_GOLDEN_MULTI_WRITER_FACT = 'multi-writer'; + +export type V17GoldenGraphFixtureFactKind = + | typeof V17_GOLDEN_NODE_FACT + | typeof V17_GOLDEN_EDGE_FACT + | typeof V17_GOLDEN_PROPERTY_FACT + | typeof V17_GOLDEN_CONTENT_FACT + | typeof V17_GOLDEN_REMOVAL_FACT + | typeof V17_GOLDEN_MULTI_WRITER_FACT; + +export type V17GoldenGraphFixtureWriterChainFields = { + readonly writerId: string; + readonly refName: string; + readonly expectedHead: string; + readonly patchCount: number; +}; + +export type V17GoldenGraphFixtureVisibleFactFields = { + readonly kind: V17GoldenGraphFixtureFactKind; + readonly key: string; + readonly description: string; +}; + +export type V17GoldenGraphFixtureTypedFactFields = { + readonly key: string; + readonly description: string; +}; + +export type V17GoldenGraphFixtureManifestFields = { + readonly fixtureId: string; + readonly graphId: string; + readonly sourceVersion: string; + readonly generator: string; + readonly bundlePath: string; + readonly writerChains: readonly V17GoldenGraphFixtureWriterChain[]; + readonly visibleFacts: readonly V17GoldenGraphFixtureVisibleFact[]; +}; + +/** Converts raw text into a supported v17 golden visible fact kind. */ +export function v17GoldenGraphFixtureFactKindFromString( + value: string, +): V17GoldenGraphFixtureFactKind { + for (const kind of requiredFactKinds()) { + if (value === kind) { + return kind; + } + } + throw new WarpError('visible fact kind is unsupported', 'E_VALIDATION'); +} + +/** Writer-chain expectation recorded by a v17 golden graph-history fixture. */ +export class V17GoldenGraphFixtureWriterChain { + readonly writerId: string; + readonly refName: string; + readonly expectedHead: string; + readonly patchCount: number; + + constructor(fields: V17GoldenGraphFixtureWriterChainFields) { + const checkedFields = requireWriterChainFields(fields); + this.writerId = requireNonEmptyString(checkedFields.writerId, 'writerId'); + this.refName = requireWarpRef(checkedFields.refName); + this.expectedHead = requireOid(checkedFields.expectedHead, 'expectedHead'); + this.patchCount = requirePositiveSafeInteger(checkedFields.patchCount, 'patchCount'); + Object.freeze(this); + } +} + +/** Operator-visible graph fact expectation for a restored v17 fixture. */ +export class V17GoldenGraphFixtureVisibleFact { + readonly kind: V17GoldenGraphFixtureFactKind; + readonly key: string; + readonly description: string; + + constructor(fields: V17GoldenGraphFixtureVisibleFactFields) { + const checkedFields = requireVisibleFactFields(fields); + this.kind = requireFactKind(checkedFields.kind); + this.key = requireNonEmptyString(checkedFields.key, 'key'); + this.description = requireNonEmptyString(checkedFields.description, 'description'); + Object.freeze(this); + } +} + +/** Operator-visible node expectation for a restored v17 fixture. */ +export class V17GoldenNodeFact extends V17GoldenGraphFixtureVisibleFact { + constructor(fields: V17GoldenGraphFixtureTypedFactFields) { + super({ + kind: V17_GOLDEN_NODE_FACT, + key: fields.key, + description: fields.description, + }); + } +} + +/** Operator-visible edge expectation for a restored v17 fixture. */ +export class V17GoldenEdgeFact extends V17GoldenGraphFixtureVisibleFact { + constructor(fields: V17GoldenGraphFixtureTypedFactFields) { + super({ + kind: V17_GOLDEN_EDGE_FACT, + key: fields.key, + description: fields.description, + }); + } +} + +/** Operator-visible property expectation for a restored v17 fixture. */ +export class V17GoldenPropertyFact extends V17GoldenGraphFixtureVisibleFact { + constructor(fields: V17GoldenGraphFixtureTypedFactFields) { + super({ + kind: V17_GOLDEN_PROPERTY_FACT, + key: fields.key, + description: fields.description, + }); + } +} + +/** Operator-visible content expectation for a restored v17 fixture. */ +export class V17GoldenContentFact extends V17GoldenGraphFixtureVisibleFact { + constructor(fields: V17GoldenGraphFixtureTypedFactFields) { + super({ + kind: V17_GOLDEN_CONTENT_FACT, + key: fields.key, + description: fields.description, + }); + } +} + +/** Operator-visible removal expectation for a restored v17 fixture. */ +export class V17GoldenRemovalFact extends V17GoldenGraphFixtureVisibleFact { + constructor(fields: V17GoldenGraphFixtureTypedFactFields) { + super({ + kind: V17_GOLDEN_REMOVAL_FACT, + key: fields.key, + description: fields.description, + }); + } +} + +/** Operator-visible multi-writer expectation for a restored v17 fixture. */ +export class V17GoldenMultiWriterFact extends V17GoldenGraphFixtureVisibleFact { + constructor(fields: V17GoldenGraphFixtureTypedFactFields) { + super({ + kind: V17_GOLDEN_MULTI_WRITER_FACT, + key: fields.key, + description: fields.description, + }); + } +} + +/** Runtime-backed manifest for a restored v17 graph-history fixture. */ +export default class V17GoldenGraphFixtureManifest { + readonly fixtureId: string; + readonly graphId: string; + readonly sourceVersion: string; + readonly generator: string; + readonly bundlePath: string; + readonly writerChains: readonly V17GoldenGraphFixtureWriterChain[]; + readonly visibleFacts: readonly V17GoldenGraphFixtureVisibleFact[]; + + constructor(fields: V17GoldenGraphFixtureManifestFields) { + const checkedFields = requireManifestFields(fields); + this.fixtureId = requireNonEmptyString(checkedFields.fixtureId, 'fixtureId'); + this.graphId = requireNonEmptyString(checkedFields.graphId, 'graphId'); + this.sourceVersion = requireNonEmptyString(checkedFields.sourceVersion, 'sourceVersion'); + this.generator = requireNonEmptyString(checkedFields.generator, 'generator'); + this.bundlePath = requireRelativePath(checkedFields.bundlePath, 'bundlePath'); + this.writerChains = freezeWriterChains(checkedFields.writerChains); + this.visibleFacts = freezeVisibleFacts(checkedFields.visibleFacts); + Object.freeze(this); + } + + /** Returns true when the fixture declares at least one visible fact kind. */ + hasVisibleFactKind(kind: V17GoldenGraphFixtureFactKind): boolean { + const checkedKind = requireFactKind(kind); + return this.visibleFacts.some((fact) => fact.kind === checkedKind); + } +} + +function requireManifestFields( + fields: V17GoldenGraphFixtureManifestFields | null | undefined, +): V17GoldenGraphFixtureManifestFields { + if (fields === null || fields === undefined) { + throw new WarpError('V17GoldenGraphFixtureManifest fields must be provided', 'E_VALIDATION'); + } + return fields; +} + +function requireWriterChainFields( + fields: V17GoldenGraphFixtureWriterChainFields | null | undefined, +): V17GoldenGraphFixtureWriterChainFields { + if (fields === null || fields === undefined) { + throw new WarpError('V17GoldenGraphFixtureWriterChain fields must be provided', 'E_VALIDATION'); + } + return fields; +} + +function requireVisibleFactFields( + fields: V17GoldenGraphFixtureVisibleFactFields | null | undefined, +): V17GoldenGraphFixtureVisibleFactFields { + if (fields === null || fields === undefined) { + throw new WarpError('V17GoldenGraphFixtureVisibleFact fields must be provided', 'E_VALIDATION'); + } + return fields; +} + +function requireNonEmptyString(value: string, name: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw new WarpError(`${name} must be a non-empty string`, 'E_VALIDATION'); + } + return value; +} + +function requireRelativePath(value: string, name: string): string { + const checked = requireNonEmptyString(value, name); + if (checked.startsWith(PATH_SEGMENT_SEPARATOR) || checked.split(PATH_SEGMENT_SEPARATOR).includes('..')) { + throw new WarpError(`${name} must be a relative fixture path`, 'E_VALIDATION'); + } + return checked; +} + +function requireWarpRef(value: string): string { + const checked = requireNonEmptyString(value, 'refName'); + if (!checked.startsWith('refs/warp/')) { + throw new WarpError('refName must be under refs/warp/', 'E_VALIDATION'); + } + return checked; +} + +function requireOid(value: string, name: string): string { + const checked = requireNonEmptyString(value, name); + if (!OID_PATTERN.test(checked)) { + throw new WarpError(`${name} must be a Git object id`, 'E_VALIDATION'); + } + return checked; +} + +function requirePositiveSafeInteger(value: number, name: string): number { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new WarpError(`${name} must be a positive safe integer`, 'E_VALIDATION'); + } + return value; +} + +function requireFactKind(kind: V17GoldenGraphFixtureFactKind): V17GoldenGraphFixtureFactKind { + return v17GoldenGraphFixtureFactKindFromString(kind); +} + +function freezeWriterChains( + writerChains: readonly V17GoldenGraphFixtureWriterChain[], +): readonly V17GoldenGraphFixtureWriterChain[] { + if (!Array.isArray(writerChains)) { + throw new WarpError('writerChains must be an array', 'E_VALIDATION'); + } + const checked = writerChains.map(requireWriterChain); + requireUnique(checked.map((chain) => chain.writerId), 'writerId'); + requireUnique(checked.map((chain) => chain.refName), 'refName'); + return Object.freeze([...checked].sort(compareWriterChains)); +} + +function freezeVisibleFacts( + visibleFacts: readonly V17GoldenGraphFixtureVisibleFact[], +): readonly V17GoldenGraphFixtureVisibleFact[] { + if (!Array.isArray(visibleFacts)) { + throw new WarpError('visibleFacts must be an array', 'E_VALIDATION'); + } + const checked = visibleFacts.map(requireVisibleFact); + requireVisibleFactCoverage(checked); + requireUnique(checked.map((fact) => `${fact.kind}\0${fact.key}`), 'visible fact'); + return Object.freeze([...checked].sort(compareVisibleFacts)); +} + +function requireWriterChain( + chain: V17GoldenGraphFixtureWriterChain, +): V17GoldenGraphFixtureWriterChain { + if (!(chain instanceof V17GoldenGraphFixtureWriterChain)) { + throw new WarpError('writerChains must contain V17GoldenGraphFixtureWriterChain values', 'E_VALIDATION'); + } + return chain; +} + +function requireVisibleFact( + fact: V17GoldenGraphFixtureVisibleFact, +): V17GoldenGraphFixtureVisibleFact { + if (!(fact instanceof V17GoldenGraphFixtureVisibleFact)) { + throw new WarpError('visibleFacts must contain V17GoldenGraphFixtureVisibleFact values', 'E_VALIDATION'); + } + return fact; +} + +function requireUnique(keys: readonly string[], label: string): void { + const seen = new Set(); + for (const key of keys) { + if (seen.has(key)) { + throw new WarpError(`V17 golden graph fixture duplicates ${label} ${key}`, 'E_VALIDATION'); + } + seen.add(key); + } +} + +function requireVisibleFactCoverage(facts: readonly V17GoldenGraphFixtureVisibleFact[]): void { + const kinds = new Set(facts.map((fact) => fact.kind)); + for (const kind of requiredFactKinds()) { + if (!kinds.has(kind)) { + throw new WarpError(`visibleFacts must include ${kind}`, 'E_VALIDATION'); + } + } +} + +function requiredFactKinds(): readonly V17GoldenGraphFixtureFactKind[] { + return Object.freeze([ + V17_GOLDEN_NODE_FACT, + V17_GOLDEN_EDGE_FACT, + V17_GOLDEN_PROPERTY_FACT, + V17_GOLDEN_CONTENT_FACT, + V17_GOLDEN_REMOVAL_FACT, + V17_GOLDEN_MULTI_WRITER_FACT, + ]); +} + +function compareWriterChains( + left: V17GoldenGraphFixtureWriterChain, + right: V17GoldenGraphFixtureWriterChain, +): number { + return compareStrings(left.refName, right.refName); +} + +function compareVisibleFacts( + left: V17GoldenGraphFixtureVisibleFact, + right: V17GoldenGraphFixtureVisibleFact, +): number { + return compareStrings(`${left.kind}\0${left.key}`, `${right.kind}\0${right.key}`); +} diff --git a/src/infrastructure/adapters/V17GoldenGraphFixtureManifestJsonAdapter.ts b/src/infrastructure/adapters/V17GoldenGraphFixtureManifestJsonAdapter.ts new file mode 100644 index 00000000..08cf3842 --- /dev/null +++ b/src/infrastructure/adapters/V17GoldenGraphFixtureManifestJsonAdapter.ts @@ -0,0 +1,203 @@ +import V17GoldenGraphFixtureManifest, { + V17GoldenContentFact, + V17GoldenEdgeFact, + V17GoldenGraphFixtureWriterChain, + V17GoldenMultiWriterFact, + V17GoldenNodeFact, + V17GoldenPropertyFact, + V17GoldenRemovalFact, + V17_GOLDEN_CONTENT_FACT, + V17_GOLDEN_EDGE_FACT, + V17_GOLDEN_MULTI_WRITER_FACT, + V17_GOLDEN_NODE_FACT, + V17_GOLDEN_PROPERTY_FACT, + V17_GOLDEN_REMOVAL_FACT, + v17GoldenGraphFixtureFactKindFromString, + type V17GoldenGraphFixtureFactKind, + type V17GoldenGraphFixtureTypedFactFields, + type V17GoldenGraphFixtureVisibleFact, +} from '../../domain/migrations/V17GoldenGraphFixtureManifest.ts'; +import AdapterValidationError from '../../domain/errors/AdapterValidationError.ts'; +import type { JsonObject } from './JsonObject.ts'; + +const MANIFEST_KEYS = Object.freeze([ + 'fixtureId', + 'graphId', + 'sourceVersion', + 'generator', + 'bundlePath', + 'writerChains', + 'visibleFacts', +]); + +type VisibleFactFactory = { + readonly kind: V17GoldenGraphFixtureFactKind; + readonly create: (fields: V17GoldenGraphFixtureTypedFactFields) => V17GoldenGraphFixtureVisibleFact; +}; + +const VISIBLE_FACT_FACTORIES: readonly VisibleFactFactory[] = Object.freeze([ + Object.freeze({ + kind: V17_GOLDEN_NODE_FACT, + create: (fields: V17GoldenGraphFixtureTypedFactFields) => new V17GoldenNodeFact(fields), + }), + Object.freeze({ + kind: V17_GOLDEN_EDGE_FACT, + create: (fields: V17GoldenGraphFixtureTypedFactFields) => new V17GoldenEdgeFact(fields), + }), + Object.freeze({ + kind: V17_GOLDEN_PROPERTY_FACT, + create: (fields: V17GoldenGraphFixtureTypedFactFields) => new V17GoldenPropertyFact(fields), + }), + Object.freeze({ + kind: V17_GOLDEN_CONTENT_FACT, + create: (fields: V17GoldenGraphFixtureTypedFactFields) => new V17GoldenContentFact(fields), + }), + Object.freeze({ + kind: V17_GOLDEN_REMOVAL_FACT, + create: (fields: V17GoldenGraphFixtureTypedFactFields) => new V17GoldenRemovalFact(fields), + }), + Object.freeze({ + kind: V17_GOLDEN_MULTI_WRITER_FACT, + create: (fields: V17GoldenGraphFixtureTypedFactFields) => new V17GoldenMultiWriterFact(fields), + }), +]); + +/** Parses a v17 golden graph-history fixture manifest from JSON. */ +export function parseV17GoldenGraphFixtureManifestJson( + raw: string, +): V17GoldenGraphFixtureManifest { + return manifestFromJson(parseJson(raw)); +} + +function parseJson(raw: string): unknown { + try { + return JSON.parse(raw); + } catch { + throw new AdapterValidationError('V17 golden graph fixture manifest must be valid JSON'); + } +} + +function manifestFromJson(value: unknown): V17GoldenGraphFixtureManifest { + const source = requireJsonObject(value, 'manifest'); + rejectUnknownKeys(source, MANIFEST_KEYS, 'manifest'); + return new V17GoldenGraphFixtureManifest({ + fixtureId: readRequiredString(source, 'manifest.fixtureId', 'fixtureId'), + graphId: readRequiredString(source, 'manifest.graphId', 'graphId'), + sourceVersion: readRequiredString(source, 'manifest.sourceVersion', 'sourceVersion'), + generator: readRequiredString(source, 'manifest.generator', 'generator'), + bundlePath: readRequiredString(source, 'manifest.bundlePath', 'bundlePath'), + writerChains: readWriterChains(source), + visibleFacts: readVisibleFacts(source), + }); +} + +function readWriterChains(source: JsonObject): readonly V17GoldenGraphFixtureWriterChain[] { + return readObjectArray(source, 'writerChains').map((chain, index) => { + const label = `writerChains[${index}]`; + rejectUnknownKeys(chain, ['writerId', 'refName', 'expectedHead', 'patchCount'], label); + return new V17GoldenGraphFixtureWriterChain({ + writerId: readRequiredString(chain, `${label}.writerId`, 'writerId'), + refName: readRequiredString(chain, `${label}.refName`, 'refName'), + expectedHead: readRequiredString(chain, `${label}.expectedHead`, 'expectedHead'), + patchCount: readRequiredNumber(chain, `${label}.patchCount`, 'patchCount'), + }); + }); +} + +function readVisibleFacts(source: JsonObject): readonly V17GoldenGraphFixtureVisibleFact[] { + return readObjectArray(source, 'visibleFacts').map((fact, index) => { + const label = `visibleFacts[${index}]`; + rejectUnknownKeys(fact, ['kind', 'key', 'description'], label); + return readVisibleFact( + readFactKind(fact, `${label}.kind`, 'kind'), + readRequiredString(fact, `${label}.key`, 'key'), + readRequiredString(fact, `${label}.description`, 'description'), + ); + }); +} + +function readVisibleFact( + kind: V17GoldenGraphFixtureFactKind, + key: string, + description: string, +): V17GoldenGraphFixtureVisibleFact { + for (const factory of VISIBLE_FACT_FACTORIES) { + if (factory.kind === kind) { + return factory.create({ key, description }); + } + } + throw new AdapterValidationError( + 'V17 golden graph fixture manifest field "visibleFacts.kind" must be a supported fact kind', + ); +} + +function readObjectArray(source: JsonObject, key: string): readonly JsonObject[] { + const value = readRequiredValue(source, key); + if (!Array.isArray(value)) { + throw new AdapterValidationError(`V17 golden graph fixture manifest field "${key}" must be an array`); + } + const objects: JsonObject[] = []; + value.forEach((entry, index) => { + objects.push(requireJsonObject(entry, `${key}[${index}]`)); + }); + return Object.freeze(objects); +} + +function readRequiredString(source: JsonObject, label: string, key: string): string { + const value = readRequiredValue(source, key); + if (typeof value !== 'string' || value.length === 0) { + throw new AdapterValidationError( + `V17 golden graph fixture manifest field "${label}" must be a non-empty string`, + ); + } + return value; +} + +function readRequiredNumber(source: JsonObject, label: string, key: string): number { + const value = readRequiredValue(source, key); + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new AdapterValidationError( + `V17 golden graph fixture manifest field "${label}" must be a finite number`, + ); + } + return value; +} + +function readFactKind(source: JsonObject, label: string, key: string): V17GoldenGraphFixtureFactKind { + const value = readRequiredValue(source, key); + if (typeof value === 'string') { + return v17GoldenGraphFixtureFactKindFromString(value); + } + throw new AdapterValidationError( + `V17 golden graph fixture manifest field "${label}" must be a supported fact kind`, + ); +} + +function readRequiredValue(source: JsonObject, key: string): unknown { + const value = source[key]; + if (value === undefined) { + throw new AdapterValidationError(`V17 golden graph fixture manifest field "${key}" is required`); + } + return value; +} + +function requireJsonObject(value: unknown, label: string): JsonObject { + if (!isJsonObject(value)) { + throw new AdapterValidationError(`V17 golden graph fixture manifest field "${label}" must be an object`); + } + return value; +} + +function isJsonObject(value: unknown): value is JsonObject { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function rejectUnknownKeys(source: JsonObject, allowed: readonly string[], label: string): void { + for (const key of Object.keys(source)) { + if (!allowed.includes(key)) { + throw new AdapterValidationError( + `V17 golden graph fixture manifest field "${label}.${key}" is not allowed`, + ); + } + } +} diff --git a/test/unit/domain/migrations/GenesisEquivalenceGate.test.ts b/test/unit/domain/migrations/GenesisEquivalenceGate.test.ts new file mode 100644 index 00000000..364746f8 --- /dev/null +++ b/test/unit/domain/migrations/GenesisEquivalenceGate.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from 'vitest'; + +import GenesisEquivalenceBoundary + from '../../../../src/domain/migrations/GenesisEquivalenceBoundary.ts'; +import GenesisEquivalenceComparisonBasis + from '../../../../src/domain/migrations/GenesisEquivalenceComparisonBasis.ts'; +import GenesisEquivalenceGate from '../../../../src/domain/migrations/GenesisEquivalenceGate.ts'; +import GenesisEquivalenceProofFailure + from '../../../../src/domain/migrations/GenesisEquivalenceProofFailure.ts'; +import GenesisEquivalenceProofSuccess + from '../../../../src/domain/migrations/GenesisEquivalenceProofSuccess.ts'; +import GenesisEquivalenceReading + from '../../../../src/domain/migrations/GenesisEquivalenceReading.ts'; +import GenesisEquivalenceReadingFact, { + type GenesisEquivalenceReadingFactKind, +} from '../../../../src/domain/migrations/GenesisEquivalenceReadingFact.ts'; +import GraphModelMigrationBasis from '../../../../src/domain/migrations/GraphModelMigrationBasis.ts'; +import { + divergentPropertyFixture, + nodeLifecycleFixture, +} from './GenesisEquivalenceFixtures.ts'; + +describe('GenesisEquivalenceGate', () => { + it('allows promotion when legacy and scratch readings prove equivalent', () => { + const fixture = nodeLifecycleFixture(); + + const result = gate().evaluate( + basis(), + fixture.legacyReading, + fixture.migratedReading, + ); + + expect(result.allowsPromotion()).toBe(true); + expect(result.proofResult).toBeInstanceOf(GenesisEquivalenceProofSuccess); + expect(result.divergenceReport).toBeNull(); + expect(result.fatalErrors).toEqual([]); + expect(result.proofResult.summary.legacyFactCount).toBe(2); + expect(result.proofResult.summary.migratedFactCount).toBe(2); + expect(result.proofResult.summary.mismatchCount).toBe(0); + }); + + it('blocks promotion and reports the first divergent property fact', () => { + const fixture = divergentPropertyFixture(); + + const result = gate().evaluate( + basis(), + fixture.legacyReading, + fixture.migratedReading, + ); + + expect(result.allowsPromotion()).toBe(false); + expect(result.proofResult).toBeInstanceOf(GenesisEquivalenceProofFailure); + expect(result.divergenceReport?.mismatchKind).toBe('changed'); + expect(result.divergenceReport?.factKind).toBe('property'); + expect(result.divergenceReport?.factKey).toBe('node:article/title'); + expect(result.divergenceReport?.legacyValueSummary).toBe('Legacy'); + expect(result.divergenceReport?.migratedValueSummary).toBe('Migrated'); + }); + + it('blocks promotion and reports divergent content attachment facts', () => { + const result = gate().evaluate( + basis(), + reading('legacy:content', [ + fact('content-attachment', 'node:article', 'payload.oid', 'oid:legacy', boundary('writer:a', 'patch:a:2', 0)), + ]), + reading('scratch:content', [ + fact('content-attachment', 'node:article', 'payload.oid', 'oid:scratch', boundary('writer:a', 'patch:a:2', 0)), + ]), + ); + + expect(result.allowsPromotion()).toBe(false); + expect(result.divergenceReport?.factKind).toBe('content-attachment'); + expect(result.divergenceReport?.fieldPath).toBe('payload.oid'); + expect(result.divergenceReport?.legacyValueSummary).toBe('oid:legacy'); + expect(result.divergenceReport?.migratedValueSummary).toBe('oid:scratch'); + }); + + it('blocks otherwise equivalent readings when boundary evidence is missing', () => { + const legacy = reading('legacy:missing-boundary', [ + fact('node', 'node:orphan', 'visibility', 'visible', null), + ]); + const scratch = reading('scratch:missing-boundary', [ + fact('node', 'node:orphan', 'visibility', 'visible', null), + ]); + + const result = gate().evaluate(basis(), legacy, scratch); + + expect(result.proofResult).toBeInstanceOf(GenesisEquivalenceProofSuccess); + expect(result.allowsPromotion()).toBe(false); + expect(result.fatalErrors.map((notice) => notice.code)).toEqual([ + 'E_MISSING_EQUIVALENCE_BOUNDARY', + ]); + expect(result.fatalErrors[0]?.message).toContain('2 visible fact(s)'); + }); +}); + +function gate(): GenesisEquivalenceGate { + return new GenesisEquivalenceGate(); +} + +function basis(): GenesisEquivalenceComparisonBasis { + return new GenesisEquivalenceComparisonBasis({ + legacyBasis: new GraphModelMigrationBasis({ + graphId: 'graph:fixture', + basisId: 'basis:legacy', + }), + migratedBasis: new GraphModelMigrationBasis({ + graphId: 'graph:fixture', + basisId: 'basis:scratch', + }), + }); +} + +function reading( + readingId: string, + facts: readonly GenesisEquivalenceReadingFact[], +): GenesisEquivalenceReading { + return new GenesisEquivalenceReading({ readingId, facts }); +} + +function fact( + kind: GenesisEquivalenceReadingFactKind, + factKey: string, + fieldPath: string, + value: string, + factBoundary: GenesisEquivalenceBoundary | null, +): GenesisEquivalenceReadingFact { + return new GenesisEquivalenceReadingFact({ + kind, + factKey, + fieldPath, + value, + boundary: factBoundary, + }); +} + +function boundary( + writerId: string, + patchId: string, + operationIndex: number, +): GenesisEquivalenceBoundary { + return new GenesisEquivalenceBoundary({ writerId, patchId, operationIndex }); +} diff --git a/test/unit/domain/migrations/GraphModelMigrationConstructorGuards.test.ts b/test/unit/domain/migrations/GraphModelMigrationConstructorGuards.test.ts index 2baf26c7..0efb2ef6 100644 --- a/test/unit/domain/migrations/GraphModelMigrationConstructorGuards.test.ts +++ b/test/unit/domain/migrations/GraphModelMigrationConstructorGuards.test.ts @@ -4,6 +4,8 @@ import DryRunGraphModelMigrationPlan from '../../../../src/domain/migrations/DryRunGraphModelMigrationPlan.ts'; import DryRunGraphModelMigrationPlanRequest from '../../../../src/domain/migrations/DryRunGraphModelMigrationPlanRequest.ts'; +import GraphModelMigrationArchiveRef + from '../../../../src/domain/migrations/GraphModelMigrationArchiveRef.ts'; import GraphModelMigrationBasis from '../../../../src/domain/migrations/GraphModelMigrationBasis.ts'; import GraphModelMigrationContentMapping from '../../../../src/domain/migrations/GraphModelMigrationContentMapping.ts'; @@ -15,6 +17,14 @@ import GraphModelMigrationHistoryPatchInput from '../../../../src/domain/migrations/GraphModelMigrationHistoryPatchInput.ts'; import GraphModelMigrationHistorySegment from '../../../../src/domain/migrations/GraphModelMigrationHistorySegment.ts'; +import GraphModelMigrationFinalizationResult, { + GRAPH_MODEL_MIGRATION_FINALIZATION_BLOCKED, + GRAPH_MODEL_MIGRATION_FINALIZATION_COMPLETED, +} from '../../../../src/domain/migrations/GraphModelMigrationFinalizationResult.ts'; +import GraphModelMigrationLoweredOperation + from '../../../../src/domain/migrations/GraphModelMigrationLoweredOperation.ts'; +import GraphModelMigrationLoweredPatchPlan + from '../../../../src/domain/migrations/GraphModelMigrationLoweredPatchPlan.ts'; import GraphModelMigrationManifest from '../../../../src/domain/migrations/GraphModelMigrationManifest.ts'; import GraphModelMigrationManifestVersion @@ -30,14 +40,31 @@ import GraphModelMigrationPatchOperationFact from '../../../../src/domain/migrations/GraphModelMigrationPatchOperationFact.ts'; import GraphModelMigrationPlannedGraphOperation from '../../../../src/domain/migrations/GraphModelMigrationPlannedGraphOperation.ts'; +import GraphModelMigrationOperationLoweringResult + from '../../../../src/domain/migrations/GraphModelMigrationOperationLoweringResult.ts'; import GraphModelMigrationPropertyMapping from '../../../../src/domain/migrations/GraphModelMigrationPropertyMapping.ts'; +import GraphModelMigrationRuntimeConformanceResult, { + GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_FAILED, + GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_PASSED, +} from '../../../../src/domain/migrations/GraphModelMigrationRuntimeConformanceResult.ts'; +import GraphModelMigrationScratchRef + from '../../../../src/domain/migrations/GraphModelMigrationScratchRef.ts'; +import GraphModelMigrationScratchWriteResult + from '../../../../src/domain/migrations/GraphModelMigrationScratchWriteResult.ts'; +import GraphModelMigrationScratchWrittenPatch + from '../../../../src/domain/migrations/GraphModelMigrationScratchWrittenPatch.ts'; import GraphModelMigrationSourceInventory from '../../../../src/domain/migrations/GraphModelMigrationSourceInventory.ts'; import GraphModelMigrationStateSnapshotReference from '../../../../src/domain/migrations/GraphModelMigrationStateSnapshotReference.ts'; import GraphModelMigrationWriterChainDescriptor from '../../../../src/domain/migrations/GraphModelMigrationWriterChainDescriptor.ts'; +import V17GoldenGraphFixtureManifest, { + V17GoldenGraphFixtureVisibleFact, + V17GoldenGraphFixtureWriterChain, + v17GoldenGraphFixtureFactKindFromString, +} from '../../../../src/domain/migrations/V17GoldenGraphFixtureManifest.ts'; describe('graph model migration constructor guards', () => { it('rejects invalid scalar fields on leaf nouns', () => { @@ -298,6 +325,225 @@ describe('graph model migration constructor guards', () => { fatalErrors: [], })).toThrow(/uncollected patch/); }); + + it('rejects invalid archive refs and scratch write results', () => { + expect(new GraphModelMigrationArchiveRef({ + refName: 'refs/warp-migration-archive/graph/writers/alice', + }).refName).toBe('refs/warp-migration-archive/graph/writers/alice'); + expect(GraphModelMigrationArchiveRef.validateRefName(null)?.code) + .toBe('E_MISSING_ARCHIVE_REF'); + expect(GraphModelMigrationArchiveRef.validateRefName(undefined)?.code) + .toBe('E_MISSING_ARCHIVE_REF'); + expect(GraphModelMigrationArchiveRef.validateRefName('refs/warp/graph/writers/alice')?.code) + .toBe('E_LIVE_ARCHIVE_REF_TARGET'); + expect(GraphModelMigrationArchiveRef.validateRefName('refs/not-archive/graph')?.code) + .toBe('E_INVALID_ARCHIVE_REF'); + expect(GraphModelMigrationArchiveRef.validateRefName('refs/warp-migration-archive/bad~name')?.code) + .toBe('E_INVALID_ARCHIVE_REF'); + expect(() => { + // @ts-expect-error exercising runtime validation + new GraphModelMigrationArchiveRef(null); + }).toThrow(/fields/); + expect(() => { + // @ts-expect-error exercising runtime validation + new GraphModelMigrationArchiveRef({}); + }).toThrow(/archive ref target/); + expect(new GraphModelMigrationScratchRef({ + refName: 'refs/warp-migration-scratch/graph/migration', + }).refName).toBe('refs/warp-migration-scratch/graph/migration'); + expect(GraphModelMigrationScratchRef.validateRefName(undefined)?.code) + .toBe('E_MISSING_SCRATCH_REF'); + expect(() => { + // @ts-expect-error exercising runtime validation + new GraphModelMigrationScratchRef({}); + }).toThrow(/scratch ref target/); + expect(() => new GraphModelMigrationScratchWriteResult({ + scratchRef: scratchRef(), + scratchHead: 'scratch-head', + writtenPatches: [writtenPatch(0), writtenPatch(1)], + warnings: [], + fatalErrors: [], + })).toThrow(/duplicate scratch written operation/); + expect(() => new GraphModelMigrationScratchWriteResult({ + scratchRef: scratchRef(), + scratchHead: 'scratch-head', + writtenPatches: [], + warnings: [], + fatalErrors: [GraphModelMigrationNotice.fatal('E_FATAL', 'fatal')], + })).toThrow(/fatal scratch write results/); + expect(() => new GraphModelMigrationScratchWriteResult({ + // @ts-expect-error exercising runtime validation + scratchRef: 'refs/warp-migration-scratch/graph', + scratchHead: null, + writtenPatches: [], + warnings: [], + fatalErrors: [], + })).toThrow(/scratchRef/); + expect(() => new GraphModelMigrationScratchWriteResult({ + scratchRef: null, + scratchHead: '', + writtenPatches: [], + warnings: [], + fatalErrors: [], + })).toThrow(/scratchHead/); + expect(() => new GraphModelMigrationScratchWriteResult({ + scratchRef: null, + scratchHead: null, + // @ts-expect-error exercising runtime validation + writtenPatches: 'nope', + warnings: [], + fatalErrors: [], + })).toThrow(/writtenPatches/); + expect(() => new GraphModelMigrationScratchWriteResult({ + scratchRef: null, + scratchHead: null, + // @ts-expect-error exercising runtime validation + writtenPatches: [{ commitId: 'commit', operation: loweredOperation(), sequence: 0 }], + warnings: [], + fatalErrors: [], + })).toThrow(/written patches/); + expect(() => new GraphModelMigrationScratchWriteResult({ + scratchRef: null, + scratchHead: null, + writtenPatches: [], + warnings: [GraphModelMigrationNotice.fatal('E_FATAL', 'fatal')], + fatalErrors: [], + })).toThrow(/warnings/); + expect(() => new GraphModelMigrationScratchWriteResult({ + scratchRef: null, + scratchHead: null, + writtenPatches: [], + warnings: [], + fatalErrors: [GraphModelMigrationNotice.warning('W_WARNING', 'warning')], + })).toThrow(/fatalErrors/); + }); + + it('rejects invalid finalization and runtime conformance evidence', () => { + expect(() => { + // @ts-expect-error exercising runtime validation + new GraphModelMigrationFinalizationResult(null); + }).toThrow(/fields/); + expect(() => new GraphModelMigrationFinalizationResult({ + // @ts-expect-error exercising runtime validation + status: 'done', + liveRefName: 'refs/warp/graph', + archiveRefName: null, + previousLiveHead: null, + finalizedLiveHead: null, + fatalErrors: [GraphModelMigrationNotice.fatal('E_FATAL', 'fatal')], + })).toThrow(/status/); + expect(() => new GraphModelMigrationFinalizationResult({ + status: GRAPH_MODEL_MIGRATION_FINALIZATION_BLOCKED, + liveRefName: '', + archiveRefName: null, + previousLiveHead: null, + finalizedLiveHead: null, + fatalErrors: [GraphModelMigrationNotice.fatal('E_FATAL', 'fatal')], + })).toThrow(/liveRefName/); + expect(() => new GraphModelMigrationFinalizationResult({ + status: GRAPH_MODEL_MIGRATION_FINALIZATION_BLOCKED, + liveRefName: 'refs/warp/graph', + archiveRefName: '', + previousLiveHead: null, + finalizedLiveHead: null, + fatalErrors: [GraphModelMigrationNotice.fatal('E_FATAL', 'fatal')], + })).toThrow(/archiveRefName/); + expect(() => new GraphModelMigrationFinalizationResult({ + status: GRAPH_MODEL_MIGRATION_FINALIZATION_BLOCKED, + liveRefName: 'refs/warp/graph', + archiveRefName: null, + previousLiveHead: null, + finalizedLiveHead: null, + fatalErrors: [], + })).toThrow(/non-completed/); + expect(() => new GraphModelMigrationFinalizationResult({ + status: GRAPH_MODEL_MIGRATION_FINALIZATION_COMPLETED, + liveRefName: 'refs/warp/graph', + archiveRefName: null, + previousLiveHead: 'old', + finalizedLiveHead: 'new', + fatalErrors: [], + })).toThrow(/archive and head/); + expect(() => new GraphModelMigrationRuntimeConformanceResult({ + scratchRef: scratchRef(), + scratchHead: 'scratch-head', + status: GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_PASSED, + witness: 'witness', + fatalErrors: [GraphModelMigrationNotice.fatal('E_FATAL', 'fatal')], + })).toThrow(/passed runtime conformance/); + expect(() => new GraphModelMigrationRuntimeConformanceResult({ + scratchRef: scratchRef(), + scratchHead: 'scratch-head', + status: GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_FAILED, + witness: 'witness', + fatalErrors: [], + })).toThrow(/failed runtime conformance/); + expect(() => new GraphModelMigrationRuntimeConformanceResult({ + // @ts-expect-error exercising runtime validation + scratchRef: 'refs/warp-migration-scratch/graph', + scratchHead: 'scratch-head', + status: GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_FAILED, + witness: 'witness', + fatalErrors: [GraphModelMigrationNotice.fatal('E_FATAL', 'fatal')], + })).toThrow(/scratchRef/); + }); + + it('rejects invalid lowering result and fixture manifest evidence', () => { + expect(() => { + // @ts-expect-error exercising runtime validation + new GraphModelMigrationOperationLoweringResult(null); + }).toThrow(/fields/); + expect(() => new GraphModelMigrationOperationLoweringResult({ + // @ts-expect-error exercising runtime validation + patchPlan: { operations: [] }, + warnings: [], + fatalErrors: [], + })).toThrow(/patchPlan/); + expect(() => new GraphModelMigrationOperationLoweringResult({ + patchPlan: null, + warnings: [GraphModelMigrationNotice.fatal('E_FATAL', 'fatal')], + fatalErrors: [], + })).toThrow(/warnings/); + expect(() => new GraphModelMigrationOperationLoweringResult({ + patchPlan: null, + warnings: [], + fatalErrors: [GraphModelMigrationNotice.warning('W_WARNING', 'warning')], + })).toThrow(/fatalErrors/); + expect(() => new GraphModelMigrationOperationLoweringResult({ + patchPlan: loweredPatchPlan(), + warnings: [], + fatalErrors: [GraphModelMigrationNotice.fatal('E_FATAL', 'fatal')], + })).toThrow(/fatal lowering/); + expect(() => new GraphModelMigrationOperationLoweringResult({ + patchPlan: null, + warnings: [], + fatalErrors: [], + })).toThrow(/successful lowering/); + expect(() => v17GoldenGraphFixtureFactKindFromString('not-a-fact')) + .toThrow(/fact kind/); + expect(() => { + // @ts-expect-error exercising runtime validation + new V17GoldenGraphFixtureManifest(null); + }).toThrow(/fields/); + expect(() => new V17GoldenGraphFixtureManifest({ + fixtureId: 'fixture', + graphId: 'graph', + sourceVersion: '17.0.1', + generator: 'test', + bundlePath: 'bundle', + writerChains: [fixtureWriter('alice'), fixtureWriter('alice')], + visibleFacts: completeFixtureFacts(), + })).toThrow(/duplicates writer/); + expect(() => new V17GoldenGraphFixtureManifest({ + fixtureId: 'fixture', + graphId: 'graph', + sourceVersion: '17.0.1', + generator: 'test', + bundlePath: 'bundle', + writerChains: [fixtureWriter('alice')], + visibleFacts: [fixtureFact('node', 'node:a')], + })).toThrow(/visibleFacts must include edge/); + }); }); type InventoryOverrides = { @@ -381,3 +627,64 @@ function historyPatch( ], }); } + +function scratchRef(): GraphModelMigrationScratchRef { + return new GraphModelMigrationScratchRef({ + refName: 'refs/warp-migration-scratch/graph/migration', + }); +} + +function loweredOperation(): GraphModelMigrationLoweredOperation { + return new GraphModelMigrationLoweredOperation({ + kind: 'node-record', + sourceKey: 'node:a', + targetKey: 'node:a', + }); +} + +function loweredPatchPlan(): GraphModelMigrationLoweredPatchPlan { + return new GraphModelMigrationLoweredPatchPlan({ + sourceBasis: sourceBasis(), + targetBasis: targetBasis(), + operations: [loweredOperation()], + }); +} + +function writtenPatch(sequence: number): GraphModelMigrationScratchWrittenPatch { + return new GraphModelMigrationScratchWrittenPatch({ + commitId: `commit:${sequence}`, + operation: loweredOperation(), + sequence, + }); +} + +function fixtureWriter(writerId: string): V17GoldenGraphFixtureWriterChain { + return new V17GoldenGraphFixtureWriterChain({ + writerId, + refName: `refs/warp/graph/writers/${writerId}`, + expectedHead: '1111111111111111111111111111111111111111', + patchCount: 1, + }); +} + +function fixtureFact( + kind: 'node' | 'edge' | 'property' | 'content' | 'removal' | 'multi-writer', + key: string, +): V17GoldenGraphFixtureVisibleFact { + return new V17GoldenGraphFixtureVisibleFact({ + kind, + key, + description: `${kind}:${key}`, + }); +} + +function completeFixtureFacts(): readonly V17GoldenGraphFixtureVisibleFact[] { + return Object.freeze([ + fixtureFact('node', 'node:a'), + fixtureFact('edge', 'edge:a'), + fixtureFact('property', 'property:a'), + fixtureFact('content', 'content:a'), + fixtureFact('removal', 'node:removed'), + fixtureFact('multi-writer', 'writers:a+b'), + ]); +} diff --git a/test/unit/domain/migrations/GraphModelMigrationFinalizationSafety.test.ts b/test/unit/domain/migrations/GraphModelMigrationFinalizationSafety.test.ts new file mode 100644 index 00000000..58e64b96 --- /dev/null +++ b/test/unit/domain/migrations/GraphModelMigrationFinalizationSafety.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from 'vitest'; + +import GenesisEquivalenceComparisonBasis + from '../../../../src/domain/migrations/GenesisEquivalenceComparisonBasis.ts'; +import GenesisEquivalenceGate from '../../../../src/domain/migrations/GenesisEquivalenceGate.ts'; +import GenesisEquivalenceGateResult + from '../../../../src/domain/migrations/GenesisEquivalenceGateResult.ts'; +import GraphModelMigrationBasis from '../../../../src/domain/migrations/GraphModelMigrationBasis.ts'; +import GraphModelMigrationFinalizationConfirmation, { + V18_GRAPH_MODEL_FINALIZATION_CONFIRMATION, +} from '../../../../src/domain/migrations/GraphModelMigrationFinalizationConfirmation.ts'; +import GraphModelMigrationFinalizationRequest + from '../../../../src/domain/migrations/GraphModelMigrationFinalizationRequest.ts'; +import GraphModelMigrationFinalizationSafety + from '../../../../src/domain/migrations/GraphModelMigrationFinalizationSafety.ts'; +import GraphModelMigrationRuntimeConformanceResult, { + GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_PASSED, +} from '../../../../src/domain/migrations/GraphModelMigrationRuntimeConformanceResult.ts'; +import GraphModelMigrationScratchRef + from '../../../../src/domain/migrations/GraphModelMigrationScratchRef.ts'; +import { + divergentPropertyFixture, + nodeLifecycleFixture, +} from './GenesisEquivalenceFixtures.ts'; + +const LIVE_REF = 'refs/warp/v17-golden-graph/writers/alice'; +const ARCHIVE_REF = 'refs/warp-migration-archive/v17-golden-graph/writers/alice'; +const SCRATCH_REF = 'refs/warp-migration-scratch/v17-golden-graph/migration'; +const LIVE_HEAD = '1111111111111111111111111111111111111111'; +const STALE_HEAD = '2222222222222222222222222222222222222222'; +const SCRATCH_HEAD = '3333333333333333333333333333333333333333'; + +describe('GraphModelMigrationFinalizationSafety', () => { + it('allows finalization only when every safety precondition is present', () => { + const result = safety().evaluate(completeRequest()); + + expect(result.allowsFinalization()).toBe(true); + expect(result.fatalErrors).toEqual([]); + }); + + it('rejects finalization without explicit confirmation', () => { + const result = safety().evaluate(completeRequest({ + confirmation: null, + })); + + expect(result.allowsFinalization()).toBe(false); + expect(result.fatalErrors.map((notice) => notice.code)).toEqual([ + 'E_MISSING_FINALIZATION_CONFIRMATION', + ]); + }); + + it('rejects finalization when scratch equivalence did not pass', () => { + const result = safety().evaluate(completeRequest({ + gateResult: failedGateResult(), + })); + + expect(result.allowsFinalization()).toBe(false); + expect(result.fatalErrors.map((notice) => notice.code)).toEqual([ + 'E_EQUIVALENCE_GATE_NOT_PASSED', + ]); + }); + + it('requires an explicit archive ref target outside live graph refs', () => { + const missing = safety().evaluate(completeRequest({ + archiveRefName: null, + })); + const live = safety().evaluate(completeRequest({ + archiveRefName: LIVE_REF, + })); + + expect(missing.fatalErrors.map((notice) => notice.code)).toEqual(['E_MISSING_ARCHIVE_REF']); + expect(live.fatalErrors.map((notice) => notice.code)).toEqual(['E_LIVE_ARCHIVE_REF_TARGET']); + }); + + it('fails closed when the live ref expected head is stale', () => { + const result = safety().evaluate(completeRequest({ + observedLiveHead: STALE_HEAD, + })); + + expect(result.allowsFinalization()).toBe(false); + expect(result.fatalErrors.map((notice) => notice.code)).toEqual([ + 'E_STALE_LIVE_REF_EXPECTATION', + ]); + }); + + it('requires runtime conformance evidence matching the scratch output', () => { + const missing = safety().evaluate(completeRequest({ + runtimeConformance: null, + })); + const mismatch = safety().evaluate(completeRequest({ + runtimeConformance: runtimeConformance('4444444444444444444444444444444444444444'), + })); + + expect(missing.fatalErrors.map((notice) => notice.code)).toEqual([ + 'E_RUNTIME_CONFORMANCE_NOT_PASSED', + ]); + expect(mismatch.fatalErrors.map((notice) => notice.code)).toEqual([ + 'E_RUNTIME_CONFORMANCE_MISMATCH', + ]); + }); + + it('has no force mode on the finalization request shape', () => { + const request = completeRequest(); + + expect('force' in request).toBe(false); + expect(Object.keys(request)).not.toContain('force'); + }); +}); + +function safety(): GraphModelMigrationFinalizationSafety { + return new GraphModelMigrationFinalizationSafety(); +} + +function completeRequest(overrides: { + readonly expectedLiveHead?: string | null; + readonly observedLiveHead?: string | null; + readonly archiveRefName?: string | null; + readonly confirmation?: GraphModelMigrationFinalizationConfirmation | null; + readonly gateResult?: GenesisEquivalenceGateResult | null; + readonly runtimeConformance?: GraphModelMigrationRuntimeConformanceResult | null; +} = {}): GraphModelMigrationFinalizationRequest { + const scratchRef = new GraphModelMigrationScratchRef({ refName: SCRATCH_REF }); + const scratchHead = SCRATCH_HEAD; + return new GraphModelMigrationFinalizationRequest({ + liveRefName: LIVE_REF, + expectedLiveHead: overrides.expectedLiveHead === undefined ? LIVE_HEAD : overrides.expectedLiveHead, + observedLiveHead: overrides.observedLiveHead === undefined ? LIVE_HEAD : overrides.observedLiveHead, + scratchRef, + scratchHead, + archiveRefName: overrides.archiveRefName === undefined ? ARCHIVE_REF : overrides.archiveRefName, + confirmation: overrides.confirmation === undefined ? confirmation() : overrides.confirmation, + gateResult: overrides.gateResult === undefined ? passedGateResult() : overrides.gateResult, + runtimeConformance: overrides.runtimeConformance === undefined + ? runtimeConformance(scratchHead) + : overrides.runtimeConformance, + }); +} + +function confirmation(): GraphModelMigrationFinalizationConfirmation { + return new GraphModelMigrationFinalizationConfirmation({ + token: V18_GRAPH_MODEL_FINALIZATION_CONFIRMATION, + }); +} + +function passedGateResult(): GenesisEquivalenceGateResult { + const fixture = nodeLifecycleFixture(); + return new GenesisEquivalenceGate().evaluate( + basis(), + fixture.legacyReading, + fixture.migratedReading, + ); +} + +function failedGateResult(): GenesisEquivalenceGateResult { + const fixture = divergentPropertyFixture(); + return new GenesisEquivalenceGate().evaluate( + basis(), + fixture.legacyReading, + fixture.migratedReading, + ); +} + +function runtimeConformance(scratchHead: string): GraphModelMigrationRuntimeConformanceResult { + return new GraphModelMigrationRuntimeConformanceResult({ + scratchRef: new GraphModelMigrationScratchRef({ refName: SCRATCH_REF }), + scratchHead, + status: GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_PASSED, + witness: 'unit-test-runtime-conformance', + fatalErrors: [], + }); +} + +function basis(): GenesisEquivalenceComparisonBasis { + return new GenesisEquivalenceComparisonBasis({ + legacyBasis: new GraphModelMigrationBasis({ + graphId: 'graph:fixture', + basisId: 'basis:legacy', + }), + migratedBasis: new GraphModelMigrationBasis({ + graphId: 'graph:fixture', + basisId: 'basis:scratch', + }), + }); +} diff --git a/test/unit/domain/migrations/GraphModelMigrationOperationLowering.test.ts b/test/unit/domain/migrations/GraphModelMigrationOperationLowering.test.ts new file mode 100644 index 00000000..ac1b4eae --- /dev/null +++ b/test/unit/domain/migrations/GraphModelMigrationOperationLowering.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from 'vitest'; + +import DryRunGraphModelMigrationPlanRequest + from '../../../../src/domain/migrations/DryRunGraphModelMigrationPlanRequest.ts'; +import DryRunGraphModelMigrationPlanner + from '../../../../src/domain/migrations/DryRunGraphModelMigrationPlanner.ts'; +import GraphModelMigrationBasis from '../../../../src/domain/migrations/GraphModelMigrationBasis.ts'; +import GraphModelMigrationContentSource + from '../../../../src/domain/migrations/GraphModelMigrationContentSource.ts'; +import GraphModelMigrationNodeMapping + from '../../../../src/domain/migrations/GraphModelMigrationNodeMapping.ts'; +import GraphModelMigrationNotice from '../../../../src/domain/migrations/GraphModelMigrationNotice.ts'; +import GraphModelMigrationOperationLowerer + from '../../../../src/domain/migrations/GraphModelMigrationOperationLowerer.ts'; +import GraphModelMigrationPatchDescriptor + from '../../../../src/domain/migrations/GraphModelMigrationPatchDescriptor.ts'; +import GraphModelMigrationPropertyMapping + from '../../../../src/domain/migrations/GraphModelMigrationPropertyMapping.ts'; +import GraphModelMigrationSourceInventory + from '../../../../src/domain/migrations/GraphModelMigrationSourceInventory.ts'; +import GraphModelMigrationWriterChainDescriptor + from '../../../../src/domain/migrations/GraphModelMigrationWriterChainDescriptor.ts'; + +describe('GraphModelMigrationOperationLowerer', () => { + it('lowers successful dry-run plans into deterministic write-ready facts', () => { + const result = lowerer().lower(planner().plan(completeRequest())); + + expect(result.hasFatalErrors()).toBe(false); + expect(result.patchPlan?.sourceBasis.basisId).toBe('basis:source'); + expect(result.patchPlan?.targetBasis.basisId).toBe('basis:source:v18-dry-run'); + expect(result.patchPlan?.operations.map((operation) => operation.toKey())).toEqual([ + 'lowered\0content-attachment\0node:a\0_content\0content-attachment:node:a\0_content', + 'lowered\0node-record\0node:a\0node:a', + 'lowered\0property\0node:a\0title\0property-target-key:length-prefixed-v1:6:node:a:5:title', + ]); + }); + + it('preserves property target key identity through lowering', () => { + const result = lowerer().lower(planner().plan(completeRequest())); + const propertyOperation = result.patchPlan?.operations.find((operation) => operation.kind === 'property'); + + expect(propertyOperation?.targetKey).toBe('property-target-key:length-prefixed-v1:6:node:a:5:title'); + }); + + it('fails closed instead of lowering fatal dry-run plans', () => { + const result = lowerer().lower(planner().plan(new DryRunGraphModelMigrationPlanRequest({ + inventory: sourceInventory({ + sourceBasis: null, + fatalErrors: [], + }), + requiredContentKeys: [], + nodeMappings: [], + edgeMappings: [], + propertyMappings: [], + }))); + + expect(result.patchPlan).toBeNull(); + expect(result.hasFatalErrors()).toBe(true); + expect(result.fatalErrors.map((notice) => notice.code)).toContain('E_MISSING_SOURCE_BASIS'); + }); + + it('requires lowered operation facts in patch plans', () => { + const plan = lowerer().lower(planner().plan(completeRequest())).patchPlan; + + expect(plan?.hasOperations()).toBe(true); + expect(() => new GraphModelMigrationOperationLowerer().lower( + // @ts-expect-error exercising runtime validation + { plannedOperations: [] }, + )).toThrow(/DryRunGraphModelMigrationPlan/); + }); +}); + +function planner(): DryRunGraphModelMigrationPlanner { + return new DryRunGraphModelMigrationPlanner(); +} + +function lowerer(): GraphModelMigrationOperationLowerer { + return new GraphModelMigrationOperationLowerer(); +} + +function completeRequest(): DryRunGraphModelMigrationPlanRequest { + return new DryRunGraphModelMigrationPlanRequest({ + inventory: sourceInventory({ + sourceBasis: new GraphModelMigrationBasis({ + graphId: 'graph:source', + basisId: 'basis:source', + }), + fatalErrors: [], + }), + requiredContentKeys: ['node:a\0_content'], + nodeMappings: [ + new GraphModelMigrationNodeMapping({ + legacyNodeId: 'node:a', + targetNodeId: 'node:a', + }), + ], + edgeMappings: [], + propertyMappings: [ + new GraphModelMigrationPropertyMapping({ + legacyOwnerId: 'node:a', + legacyPropertyKey: 'title', + targetOwnerId: 'node:a', + targetPropertyKey: 'title', + }), + ], + }); +} + +function sourceInventory(options: { + readonly sourceBasis: GraphModelMigrationBasis | null; + readonly fatalErrors: readonly GraphModelMigrationNotice[]; +}): GraphModelMigrationSourceInventory { + return new GraphModelMigrationSourceInventory({ + graphId: 'graph:source', + sourceBasis: options.sourceBasis, + writerChains: [ + new GraphModelMigrationWriterChainDescriptor({ + writerId: 'writer:a', + patchIds: ['patch:a:0'], + }), + ], + patchDescriptors: [ + new GraphModelMigrationPatchDescriptor({ + patchId: 'patch:a:0', + writerId: 'writer:a', + writerSequence: 0, + }), + ], + stateSnapshot: null, + contentSources: [ + new GraphModelMigrationContentSource({ + legacyContentKey: 'node:a\0_content', + contentOid: 'oid:a', + }), + ], + warnings: [], + fatalErrors: options.fatalErrors, + }); +} diff --git a/test/unit/domain/migrations/GraphModelMigrationReviewGuardCoverage.test.ts b/test/unit/domain/migrations/GraphModelMigrationReviewGuardCoverage.test.ts new file mode 100644 index 00000000..6588f5c2 --- /dev/null +++ b/test/unit/domain/migrations/GraphModelMigrationReviewGuardCoverage.test.ts @@ -0,0 +1,261 @@ +import { describe, expect, it } from 'vitest'; + +import GraphModelMigrationFinalizationRequest + from '../../../../src/domain/migrations/GraphModelMigrationFinalizationRequest.ts'; +import GraphModelMigrationFinalizationSafetyResult + from '../../../../src/domain/migrations/GraphModelMigrationFinalizationSafetyResult.ts'; +import GraphModelMigrationNotice from '../../../../src/domain/migrations/GraphModelMigrationNotice.ts'; +import GraphModelMigrationScratchRef + from '../../../../src/domain/migrations/GraphModelMigrationScratchRef.ts'; +import V17GoldenGraphFixtureManifest, { + V17GoldenGraphFixtureVisibleFact, + V17GoldenGraphFixtureWriterChain, +} from '../../../../src/domain/migrations/V17GoldenGraphFixtureManifest.ts'; + +describe('graph model migration review guard coverage', () => { + it('covers scratch ref validation branches without native TypeError escapes', () => { + const scratchRef = new GraphModelMigrationScratchRef({ + refName: 'refs/warp-migration-scratch/graph/migration', + }); + + expect(scratchRef.toString()).toBe('refs/warp-migration-scratch/graph/migration'); + expect(GraphModelMigrationScratchRef.validateRefName(null)?.code) + .toBe('E_MISSING_SCRATCH_REF'); + expect(GraphModelMigrationScratchRef.validateRefName('refs/warp/graph/writers/alice')?.code) + .toBe('E_LIVE_REF_TARGET'); + expect(GraphModelMigrationScratchRef.validateRefName('refs/not-scratch/graph')?.code) + .toBe('E_INVALID_SCRATCH_REF'); + expect(GraphModelMigrationScratchRef.validateRefName('refs/warp-migration-scratch/bad~name')?.code) + .toBe('E_INVALID_SCRATCH_REF'); + expect(() => { + // @ts-expect-error exercising runtime validation + new GraphModelMigrationScratchRef(null); + }).toThrow(/fields/); + }); + + it('covers finalization request and safety result malformed envelopes', () => { + expect(() => { + // @ts-expect-error exercising runtime validation + new GraphModelMigrationFinalizationRequest(null); + }).toThrow(/fields/); + expect(() => new GraphModelMigrationFinalizationRequest({ + liveRefName: '', + expectedLiveHead: null, + observedLiveHead: null, + scratchRef: null, + scratchHead: null, + archiveRefName: null, + confirmation: null, + gateResult: null, + runtimeConformance: null, + })).toThrow(/liveRefName/); + expect(() => new GraphModelMigrationFinalizationRequest({ + liveRefName: 'refs/warp/graph', + expectedLiveHead: '', + observedLiveHead: null, + scratchRef: null, + scratchHead: null, + archiveRefName: null, + confirmation: null, + gateResult: null, + runtimeConformance: null, + })).toThrow(/expectedLiveHead/); + expect(() => new GraphModelMigrationFinalizationRequest({ + liveRefName: 'refs/warp/graph', + expectedLiveHead: null, + observedLiveHead: null, + // @ts-expect-error exercising runtime validation + scratchRef: 'refs/warp-migration-scratch/graph', + scratchHead: null, + archiveRefName: null, + confirmation: null, + gateResult: null, + runtimeConformance: null, + })).toThrow(/scratchRef/); + expect(() => new GraphModelMigrationFinalizationRequest({ + liveRefName: 'refs/warp/graph', + expectedLiveHead: null, + observedLiveHead: null, + scratchRef: null, + scratchHead: null, + archiveRefName: null, + // @ts-expect-error exercising runtime validation + confirmation: 'confirm', + gateResult: null, + runtimeConformance: null, + })).toThrow(/confirmation/); + expect(() => new GraphModelMigrationFinalizationRequest({ + liveRefName: 'refs/warp/graph', + expectedLiveHead: null, + observedLiveHead: null, + scratchRef: null, + scratchHead: null, + archiveRefName: null, + confirmation: null, + // @ts-expect-error exercising runtime validation + gateResult: 'passed', + runtimeConformance: null, + })).toThrow(/gateResult/); + expect(() => new GraphModelMigrationFinalizationRequest({ + liveRefName: 'refs/warp/graph', + expectedLiveHead: null, + observedLiveHead: null, + scratchRef: null, + scratchHead: null, + archiveRefName: null, + confirmation: null, + gateResult: null, + // @ts-expect-error exercising runtime validation + runtimeConformance: 'passed', + })).toThrow(/runtimeConformance/); + expect(() => { + // @ts-expect-error exercising runtime validation + new GraphModelMigrationFinalizationSafetyResult(null); + }).toThrow(/fields/); + expect(() => new GraphModelMigrationFinalizationSafetyResult({ + // @ts-expect-error exercising runtime validation + request: 'request', + fatalErrors: [], + })).toThrow(/request/); + expect(() => new GraphModelMigrationFinalizationSafetyResult({ + request: finalizationRequest(), + // @ts-expect-error exercising runtime validation + fatalErrors: 'fatal', + })).toThrow(/fatalErrors/); + expect(() => new GraphModelMigrationFinalizationSafetyResult({ + request: finalizationRequest(), + fatalErrors: [GraphModelMigrationNotice.warning('W_WARNING', 'warning')], + })).toThrow(/fatalErrors/); + }); + + it('covers v17 golden fixture manifest malformed envelopes', () => { + expect(() => { + // @ts-expect-error exercising runtime validation + new V17GoldenGraphFixtureWriterChain(null); + }).toThrow(/fields/); + expect(() => { + // @ts-expect-error exercising runtime validation + new V17GoldenGraphFixtureVisibleFact(null); + }).toThrow(/fields/); + expect(() => new V17GoldenGraphFixtureManifest({ + fixtureId: '', + graphId: 'graph', + sourceVersion: '17.0.1', + generator: 'test', + bundlePath: 'bundle', + writerChains: [fixtureWriter('alice')], + visibleFacts: completeFixtureFacts(), + })).toThrow(/fixtureId/); + expect(() => new V17GoldenGraphFixtureManifest({ + fixtureId: 'fixture', + graphId: 'graph', + sourceVersion: '17.0.1', + generator: 'test', + bundlePath: '/bundle', + writerChains: [fixtureWriter('alice')], + visibleFacts: completeFixtureFacts(), + })).toThrow(/relative fixture path/); + expect(() => new V17GoldenGraphFixtureWriterChain({ + writerId: 'alice', + refName: 'refs/not-warp/graph/writers/alice', + expectedHead: '1111111111111111111111111111111111111111', + patchCount: 1, + })).toThrow('refName must be under refs/warp/'); + expect(() => new V17GoldenGraphFixtureWriterChain({ + writerId: 'alice', + refName: 'refs/warp/graph/writers/alice', + expectedHead: 'not-an-oid', + patchCount: 1, + })).toThrow(/object id/); + expect(() => new V17GoldenGraphFixtureWriterChain({ + writerId: 'alice', + refName: 'refs/warp/graph/writers/alice', + expectedHead: '1111111111111111111111111111111111111111', + patchCount: 0, + })).toThrow(/positive safe integer/); + expect(() => new V17GoldenGraphFixtureManifest({ + fixtureId: 'fixture', + graphId: 'graph', + sourceVersion: '17.0.1', + generator: 'test', + bundlePath: 'bundle', + // @ts-expect-error exercising runtime validation + writerChains: 'alice', + visibleFacts: completeFixtureFacts(), + })).toThrow(/writerChains/); + expect(() => new V17GoldenGraphFixtureManifest({ + fixtureId: 'fixture', + graphId: 'graph', + sourceVersion: '17.0.1', + generator: 'test', + bundlePath: 'bundle', + writerChains: [fixtureWriter('alice')], + // @ts-expect-error exercising runtime validation + visibleFacts: 'node', + })).toThrow(/visibleFacts/); + expect(() => new V17GoldenGraphFixtureManifest({ + fixtureId: 'fixture', + graphId: 'graph', + sourceVersion: '17.0.1', + generator: 'test', + bundlePath: 'bundle', + // @ts-expect-error exercising runtime validation + writerChains: [{ writerId: 'alice' }], + visibleFacts: completeFixtureFacts(), + })).toThrow(/writerChains/); + expect(() => new V17GoldenGraphFixtureManifest({ + fixtureId: 'fixture', + graphId: 'graph', + sourceVersion: '17.0.1', + generator: 'test', + bundlePath: 'bundle', + writerChains: [fixtureWriter('alice')], + visibleFacts: [{ kind: 'node', key: 'node:a', description: 'node' }], + })).toThrow(/visibleFacts/); + }); +}); + +function finalizationRequest(): GraphModelMigrationFinalizationRequest { + return new GraphModelMigrationFinalizationRequest({ + liveRefName: 'refs/warp/graph', + expectedLiveHead: null, + observedLiveHead: null, + scratchRef: null, + scratchHead: null, + archiveRefName: null, + confirmation: null, + gateResult: null, + runtimeConformance: null, + }); +} + +function fixtureWriter(writerId: string): V17GoldenGraphFixtureWriterChain { + return new V17GoldenGraphFixtureWriterChain({ + writerId, + refName: `refs/warp/graph/writers/${writerId}`, + expectedHead: '1111111111111111111111111111111111111111', + patchCount: 1, + }); +} + +function completeFixtureFacts(): readonly V17GoldenGraphFixtureVisibleFact[] { + return Object.freeze([ + fixtureFact('node', 'node:a'), + fixtureFact('edge', 'edge:a'), + fixtureFact('property', 'property:a'), + fixtureFact('content', 'content:a'), + fixtureFact('removal', 'node:removed'), + fixtureFact('multi-writer', 'writers:a+b'), + ]); +} + +function fixtureFact( + kind: 'node' | 'edge' | 'property' | 'content' | 'removal' | 'multi-writer', + key: string, +): V17GoldenGraphFixtureVisibleFact { + return new V17GoldenGraphFixtureVisibleFact({ + kind, + key, + description: `${kind}:${key}`, + }); +} diff --git a/test/unit/domain/migrations/V17GoldenGraphFixtureGenesisReading.test.ts b/test/unit/domain/migrations/V17GoldenGraphFixtureGenesisReading.test.ts new file mode 100644 index 00000000..cf9653a1 --- /dev/null +++ b/test/unit/domain/migrations/V17GoldenGraphFixtureGenesisReading.test.ts @@ -0,0 +1,127 @@ +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import V17GoldenGraphFixtureGenesisReading + from '../../../../src/domain/migrations/V17GoldenGraphFixtureGenesisReading.ts'; +import V17GoldenGraphFixtureManifest, { + V17GoldenContentFact, + V17GoldenEdgeFact, + V17GoldenGraphFixtureVisibleFact, + V17GoldenGraphFixtureWriterChain, + V17GoldenMultiWriterFact, + V17GoldenNodeFact, + V17GoldenPropertyFact, + V17GoldenRemovalFact, +} from '../../../../src/domain/migrations/V17GoldenGraphFixtureManifest.ts'; +import { parseV17GoldenGraphFixtureManifestJson } + from '../../../../src/infrastructure/adapters/V17GoldenGraphFixtureManifestJsonAdapter.ts'; + +const FIXTURE_MANIFEST_PATH = resolve('fixtures/v17/graph-model-golden/manifest.json'); + +describe('V17GoldenGraphFixtureGenesisReading', () => { + it('projects the v17 golden fixture manifest into genesis equivalence facts', async () => { + const manifest = parseV17GoldenGraphFixtureManifestJson( + await readFile(FIXTURE_MANIFEST_PATH, 'utf8'), + ); + + const reading = new V17GoldenGraphFixtureGenesisReading().build(manifest); + + expect(reading.readingId).toBe('v17-golden-fixture:v17-golden-graph-model-001'); + expect(reading.facts.map((fact) => fact.toKey())).toEqual([ + 'content-attachment\0node:alpha:_content\0payload.oid', + 'edge\0node:alpha->node:beta:relates\0visibility', + 'node\0node:alpha\0visibility', + 'node\0node:removed\0visibility', + 'property\0node:alpha:title\0value', + 'property\0writers:alice+bob\0coverage', + ]); + expect(reading.facts.map((fact) => fact.boundary?.writerId)).toEqual([ + 'alice', + 'bob', + 'bob', + 'bob', + 'alice', + 'alice', + ]); + }); + + it('rejects malformed genesis reading inputs through domain errors', () => { + const builder = new V17GoldenGraphFixtureGenesisReading(); + + expect(() => { + // @ts-expect-error exercising runtime validation + builder.build(null); + }).toThrow(/manifest/); + expect(() => builder.build(manifestWithBaseVisibleFacts())) + .toThrow(/unsupported v17 fixture visible fact kind/); + expect(() => builder.build(manifestWithoutWriterChains())) + .toThrow(/writer chain evidence/); + }); +}); + +function manifestWithBaseVisibleFacts(): V17GoldenGraphFixtureManifest { + return new V17GoldenGraphFixtureManifest({ + fixtureId: 'fixture:base-facts', + graphId: 'v17-golden-graph', + sourceVersion: '17.0.1', + generator: 'unit-test', + bundlePath: 'v17-golden-graph.bundle', + writerChains: [writerChain()], + visibleFacts: visibleFacts(), + }); +} + +function manifestWithoutWriterChains(): V17GoldenGraphFixtureManifest { + return new V17GoldenGraphFixtureManifest({ + fixtureId: 'fixture:no-writers', + graphId: 'v17-golden-graph', + sourceVersion: '17.0.1', + generator: 'unit-test', + bundlePath: 'v17-golden-graph.bundle', + writerChains: [], + visibleFacts: typedVisibleFacts(), + }); +} + +function writerChain(): V17GoldenGraphFixtureWriterChain { + return new V17GoldenGraphFixtureWriterChain({ + writerId: 'alice', + refName: 'refs/warp/v17-golden-graph/writers/alice', + expectedHead: '1111111111111111111111111111111111111111', + patchCount: 1, + }); +} + +function visibleFacts(): readonly V17GoldenGraphFixtureVisibleFact[] { + return Object.freeze([ + visibleFact('node', 'node:alpha'), + visibleFact('edge', 'edge:alpha-beta'), + visibleFact('property', 'node:alpha:title'), + visibleFact('content', 'node:alpha:_content'), + visibleFact('removal', 'node:removed'), + visibleFact('multi-writer', 'writers:alice+bob'), + ]); +} + +function typedVisibleFacts(): readonly V17GoldenGraphFixtureVisibleFact[] { + return Object.freeze([ + new V17GoldenNodeFact({ key: 'node:alpha', description: 'node' }), + new V17GoldenEdgeFact({ key: 'edge:alpha-beta', description: 'edge' }), + new V17GoldenPropertyFact({ key: 'node:alpha:title', description: 'title' }), + new V17GoldenContentFact({ key: 'node:alpha:_content', description: 'content' }), + new V17GoldenRemovalFact({ key: 'node:removed', description: 'removed' }), + new V17GoldenMultiWriterFact({ key: 'writers:alice+bob', description: 'multi' }), + ]); +} + +function visibleFact( + kind: 'node' | 'edge' | 'property' | 'content' | 'removal' | 'multi-writer', + key: string, +): V17GoldenGraphFixtureVisibleFact { + return new V17GoldenGraphFixtureVisibleFact({ + kind, + key, + description: `${kind}:${key}`, + }); +} diff --git a/test/unit/infrastructure/adapters/GraphModelMigrationDryRunRequestJsonAdapter.test.ts b/test/unit/infrastructure/adapters/GraphModelMigrationDryRunRequestJsonAdapter.test.ts new file mode 100644 index 00000000..78a3d355 --- /dev/null +++ b/test/unit/infrastructure/adapters/GraphModelMigrationDryRunRequestJsonAdapter.test.ts @@ -0,0 +1,225 @@ +import { describe, expect, it } from 'vitest'; + +import DryRunGraphModelMigrationPlanRequest + from '../../../../src/domain/migrations/DryRunGraphModelMigrationPlanRequest.ts'; +import { + parseGraphModelMigrationDryRunRequest, +} from '../../../../src/infrastructure/adapters/GraphModelMigrationDryRunRequestJsonAdapter.ts'; + +type FixtureJsonValue = + | string + | number + | boolean + | null + | readonly FixtureJsonValue[] + | { readonly [key: string]: FixtureJsonValue }; + +type RequestOverrides = { + readonly inventory?: FixtureJsonValue; + readonly requiredContentKeys?: FixtureJsonValue; + readonly nodeMappings?: FixtureJsonValue; + readonly edgeMappings?: FixtureJsonValue; + readonly propertyMappings?: FixtureJsonValue; + readonly extraRoot?: boolean; +}; + +type InventoryOverrides = { + readonly graphId?: FixtureJsonValue; + readonly sourceBasis?: FixtureJsonValue; + readonly writerChains?: FixtureJsonValue; + readonly patchDescriptors?: FixtureJsonValue; + readonly stateSnapshot?: FixtureJsonValue; + readonly contentSources?: FixtureJsonValue; + readonly warnings?: FixtureJsonValue; + readonly fatalErrors?: FixtureJsonValue; + readonly extraInventory?: boolean; +}; + +describe('GraphModelMigrationDryRunRequestJsonAdapter', () => { + it('parses a complete dry-run request into runtime-backed migration nouns', () => { + const request = parseGraphModelMigrationDryRunRequest(requestJson()); + + expect(request).toBeInstanceOf(DryRunGraphModelMigrationPlanRequest); + expect(request.inventory.graphId).toBe('v17-golden-graph'); + expect(request.inventory.stateSnapshot?.snapshotId).toBe('snapshot:source'); + expect(request.inventory.warnings.map((notice) => notice.kind)).toEqual(['warning']); + expect(request.inventory.fatalErrors.map((notice) => notice.kind)).toEqual(['fatal']); + expect(request.requiredContentKeys).toEqual(['node:alpha:_content']); + }); + + it('rejects malformed JSON without leaking a platform SyntaxError', () => { + expect(() => parseGraphModelMigrationDryRunRequest('{')).toThrow(/valid JSON/); + }); + + it('rejects malformed request envelopes at the JSON boundary', () => { + const cases = Object.freeze([ + { + raw: requestJson({ extraRoot: true }), + message: /dryRunRequest\.extra/, + }, + { + raw: requestJson({ inventory: [] }), + message: /inventory.*object/, + }, + { + raw: requestJson({ requiredContentKeys: 'node:alpha:_content' }), + message: /requiredContentKeys.*array/, + }, + { + raw: requestJson({ requiredContentKeys: ['node:alpha:_content', ''] }), + message: /requiredContentKeys\[1\]/, + }, + { + raw: requestJson({ nodeMappings: [null] }), + message: /nodeMappings\[0\].*object/, + }, + ]); + + for (const candidate of cases) { + expect(() => parseGraphModelMigrationDryRunRequest(candidate.raw)) + .toThrow(candidate.message); + } + }); + + it('rejects malformed inventory payloads at the JSON boundary', () => { + const cases = Object.freeze([ + { + raw: requestJson({ inventory: inventoryJson({ extraInventory: true }) }), + message: /inventory\.extra/, + }, + { + raw: requestJson({ inventory: inventoryJson({ graphId: '' }) }), + message: /inventory\.graphId/, + }, + { + raw: requestJson({ inventory: inventoryJson({ writerChains: 'alice' }) }), + message: /writerChains.*array/, + }, + { + raw: requestJson({ inventory: inventoryJson({ patchDescriptors: [null] }) }), + message: /patchDescriptors\[0\].*object/, + }, + { + raw: requestJson({ + inventory: inventoryJson({ + patchDescriptors: [ + { + patchId: 'patch:alice:0', + writerId: 'alice', + writerSequence: '0', + }, + ], + }), + }), + message: /writerSequence.*finite number/, + }, + { + raw: requestJson({ + inventory: inventoryJson({ + warnings: [ + { + kind: 'info', + code: 'W_SOURCE', + message: 'unsupported notice kind', + }, + ], + }), + }), + message: /warnings\[0\]\.kind.*warning or fatal/, + }, + { + raw: requestJson({ + inventory: inventoryJson({ + contentSources: [ + { + legacyContentKey: 'node:alpha:_content', + }, + ], + }), + }), + message: /contentOid.*required/, + }, + ]); + + for (const candidate of cases) { + expect(() => parseGraphModelMigrationDryRunRequest(candidate.raw)) + .toThrow(candidate.message); + } + }); +}); + +function requestJson(overrides: RequestOverrides = {}): string { + const request = { + inventory: overrides.inventory ?? inventoryJson(), + requiredContentKeys: overrides.requiredContentKeys ?? ['node:alpha:_content'], + nodeMappings: overrides.nodeMappings ?? [ + { + legacyNodeId: 'node:alpha', + targetNodeId: 'node:alpha', + }, + ], + edgeMappings: overrides.edgeMappings ?? [ + { + legacyEdgeId: 'edge:alpha-beta', + targetEdgeId: 'edge:alpha-beta', + }, + ], + propertyMappings: overrides.propertyMappings ?? [ + { + legacyOwnerId: 'node:alpha', + legacyPropertyKey: 'title', + targetOwnerId: 'node:alpha', + targetPropertyKey: 'title', + }, + ], + ...(overrides.extraRoot === true ? { extra: true } : {}), + }; + return JSON.stringify(request); +} + +function inventoryJson(overrides: InventoryOverrides = {}) { + return { + graphId: overrides.graphId ?? 'v17-golden-graph', + sourceBasis: overrides.sourceBasis ?? { + graphId: 'v17-golden-graph', + basisId: 'basis:source', + }, + writerChains: overrides.writerChains ?? [ + { + writerId: 'alice', + patchIds: ['patch:alice:0'], + }, + ], + patchDescriptors: overrides.patchDescriptors ?? [ + { + patchId: 'patch:alice:0', + writerId: 'alice', + writerSequence: 0, + }, + ], + stateSnapshot: overrides.stateSnapshot ?? { + snapshotId: 'snapshot:source', + }, + contentSources: overrides.contentSources ?? [ + { + legacyContentKey: 'node:alpha:_content', + contentOid: 'fixture-content:node:alpha:_content', + }, + ], + warnings: overrides.warnings ?? [ + { + kind: 'warning', + code: 'W_SOURCE', + message: 'source warning', + }, + ], + fatalErrors: overrides.fatalErrors ?? [ + { + kind: 'fatal', + code: 'E_SOURCE', + message: 'source fatal', + }, + ], + ...(overrides.extraInventory === true ? { extra: true } : {}), + }; +} diff --git a/test/unit/scripts/v18-content-property-closeout-audit.test.ts b/test/unit/scripts/v18-content-property-closeout-audit.test.ts new file mode 100644 index 00000000..3c5ddc97 --- /dev/null +++ b/test/unit/scripts/v18-content-property-closeout-audit.test.ts @@ -0,0 +1,82 @@ +import { readdir, readFile } from 'node:fs/promises'; +import { join, relative } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const RAW_COMPATIBILITY_PATTERN = /decodePropKey|decodeEdgePropKey|state\.prop|_content/u; +const DESIGN_DOC = 'docs/design/0203-v18-content-property-closeout-audit/v18-content-property-closeout-audit.md'; +const EXPECTED_RAW_COMPATIBILITY_FILES = Object.freeze([ + 'src/domain/graph/LegacyContentPropertyKeys.ts', + 'src/domain/services/ContentAttachmentProjection.ts', + 'src/domain/services/CoordinateFactExport.ts', + 'src/domain/services/ImmutableSnapshot.ts', + 'src/domain/services/JoinReducer.ts', + 'src/domain/services/KeyCodec.ts', + 'src/domain/services/OpStrategies.ts', + 'src/domain/services/OpStrategy.ts', + 'src/domain/services/PatchBuilder.ts', + 'src/domain/services/PatchBuilderValidation.ts', + 'src/domain/services/PatchCommitter.ts', + 'src/domain/services/TemporalQuery.ts', + 'src/domain/services/VisibleStateScope.ts', + 'src/domain/services/index/LogicalIndexBuildService.ts', + 'src/domain/services/state/CheckpointSerializer.ts', + 'src/domain/services/state/StateDiff.ts', + 'src/domain/services/state/StateSerializer.ts', + 'src/domain/services/state/WarpState.ts', + 'src/domain/services/state/checkpointHelpers.ts', + 'src/domain/services/strand/StrandPatchService.ts', + 'src/domain/services/transfer/transferOps.ts', + 'src/domain/types/CoordinateComparison.ts', + 'src/domain/types/ops/EdgePropSet.ts', + 'src/domain/types/ops/NodePropSet.ts', + 'src/domain/types/ops/PropSet.ts', + 'src/domain/types/ops/propHelpers.ts', +]); + +describe('v18 content/property closeout audit', () => { + it('keeps raw compatibility boundaries explicit and reviewed', async () => { + const matches = await findRawCompatibilityFiles('src/domain'); + + expect(matches).toEqual(EXPECTED_RAW_COMPATIBILITY_FILES); + }); + + it('documents every remaining raw compatibility boundary', async () => { + const doc = await readFile(DESIGN_DOC, 'utf8'); + + for (const file of EXPECTED_RAW_COMPATIBILITY_FILES) { + expect(doc).toContain(file); + } + }); +}); + +async function findRawCompatibilityFiles(root: string): Promise { + const files = await collectTypeScriptFiles(root); + const matches: string[] = []; + for (const file of files) { + const content = await readFile(file, 'utf8'); + if (RAW_COMPATIBILITY_PATTERN.test(content)) { + matches.push(file); + } + } + return Object.freeze(matches.sort()); +} + +async function collectTypeScriptFiles(directory: string): Promise { + const entries = await readdir(directory, { withFileTypes: true }); + const files: string[] = []; + for (const entry of entries) { + const path = join(directory, entry.name); + if (entry.isDirectory()) { + files.push(...await collectTypeScriptFiles(path)); + continue; + } + if (entry.isFile() && path.endsWith('.ts')) { + files.push(toPosixPath(relative('', path))); + } + } + return Object.freeze(files); +} + +function toPosixPath(path: string): string { + return path.split('\\').join('/'); +} diff --git a/test/unit/scripts/v18-graph-model-migration-command-cli.test.ts b/test/unit/scripts/v18-graph-model-migration-command-cli.test.ts new file mode 100644 index 00000000..e532407e --- /dev/null +++ b/test/unit/scripts/v18-graph-model-migration-command-cli.test.ts @@ -0,0 +1,101 @@ +import { execFile } from 'node:child_process'; +import { mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { promisify } from 'node:util'; +import { describe, expect, it } from 'vitest'; + +import { + parseGraphModelMigrationCommandCliArgs, + runGraphModelMigrationCommandCli, +} from '../../../scripts/v18.0.0/migrations/graph-model/GraphModelMigrationCommandCli.ts'; + +const execFileAsync = promisify(execFile); +const FIXTURE_MANIFEST = 'fixtures/v17/graph-model-golden/manifest.json'; +const SCRATCH_REF = 'refs/warp-migration-scratch/v17-golden-graph/cli'; + +describe('v18 graph-model migration command CLI', () => { + it('prints usage when help is requested', async () => { + const result = await runGraphModelMigrationCommandCli(['--help']); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Usage:'); + expect(result.stdout).toContain('--legacy-fixture-manifest '); + expect(result.stderr).toBe(''); + }); + + it('refuses finalization flags until live-ref CLI finalization is designed', () => { + expect(() => parseGraphModelMigrationCommandCliArgs(['--finalize'])) + .toThrow(/finalization is not supported/); + }); + + it('writes scratch history and emits a deterministic command report', async () => { + const directory = await mkdtemp(join(tmpdir(), 'git-warp-v18-command-cli-')); + const repositoryPath = join(directory, 'repo'); + const requestPath = join(directory, 'request.json'); + const reportPath = join(directory, 'report.txt'); + await execFileAsync('git', ['init', '-q', repositoryPath]); + await writeFile(requestPath, completeRequestJson(), 'utf8'); + + const result = await runGraphModelMigrationCommandCli([ + '--repo', + repositoryPath, + '--request', + requestPath, + '--legacy-fixture-manifest', + FIXTURE_MANIFEST, + '--scratch-ref', + SCRATCH_REF, + '--report-out', + reportPath, + ]); + const report = await readFile(reportPath, 'utf8'); + + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe(report); + expect(report).toContain('scratch: written'); + expect(report).toContain(`scratchRef: ${SCRATCH_REF}`); + expect(report).toContain('equivalence: blocked'); + expect(report).toContain('finalization: skipped'); + }); +}); + +function completeRequestJson(): string { + return `{ + "inventory": { + "graphId": "v17-golden-graph", + "sourceBasis": { "graphId": "v17-golden-graph", "basisId": "basis:source" }, + "writerChains": [ + { "writerId": "alice", "patchIds": ["patch:alice:0"] } + ], + "patchDescriptors": [ + { "patchId": "patch:alice:0", "writerId": "alice", "writerSequence": 0 } + ], + "stateSnapshot": { "snapshotId": "snapshot:source" }, + "contentSources": [ + { "legacyContentKey": "node:alpha:_content", "contentOid": "oid:content:alpha" } + ], + "warnings": [], + "fatalErrors": [] + }, + "requiredContentKeys": ["node:alpha:_content"], + "nodeMappings": [ + { "legacyNodeId": "node:alpha", "targetNodeId": "node:alpha" } + ], + "edgeMappings": [ + { + "legacyEdgeId": "node:alpha->node:beta:relates", + "targetEdgeId": "node:alpha->node:beta:relates" + } + ], + "propertyMappings": [ + { + "legacyOwnerId": "node:alpha", + "legacyPropertyKey": "title", + "targetOwnerId": "node:alpha", + "targetPropertyKey": "title" + } + ] +} +`; +} diff --git a/test/unit/scripts/v18-graph-model-source-inventory-collector.test.ts b/test/unit/scripts/v18-graph-model-source-inventory-collector.test.ts new file mode 100644 index 00000000..5f07f194 --- /dev/null +++ b/test/unit/scripts/v18-graph-model-source-inventory-collector.test.ts @@ -0,0 +1,72 @@ +import { execFile } from 'node:child_process'; +import { mkdtemp } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { tmpdir } from 'node:os'; +import { promisify } from 'node:util'; +import { describe, expect, it } from 'vitest'; + +import { + collectGraphModelMigrationSourceInventory, +} from '../../../scripts/v18.0.0/migrations/graph-model/GraphModelMigrationSourceInventoryCollector.ts'; +import { + restoreV17GoldenGraphFixture, +} from '../../../scripts/v18.0.0/migrations/graph-model/V17GoldenGraphFixtureRestore.ts'; + +const FIXTURE_MANIFEST_PATH = resolve('fixtures/v17/graph-model-golden/manifest.json'); +const execFileAsync = promisify(execFile); + +describe('v18 graph-model source inventory collector', () => { + it('collects writer chains and patch descriptors from restored v17 refs', async () => { + const targetDirectory = await mkdtemp(join(tmpdir(), 'git-warp-v17-source-')); + const restored = await restoreV17GoldenGraphFixture({ + manifestPath: FIXTURE_MANIFEST_PATH, + targetDirectory, + }); + + const inventory = await collectGraphModelMigrationSourceInventory({ + repositoryPath: restored.repositoryPath, + graphId: restored.manifest.graphId, + fixtureManifest: restored.manifest, + }); + + expect(inventory.isUsableForPlanning()).toBe(true); + expect(inventory.sourceBasis?.basisId).toContain('refs/warp/v17-golden-graph/writers/alice@'); + expect(inventory.writerChains.map((chain) => [chain.writerId, chain.patchIds.length])).toEqual([ + ['alice', 3], + ['bob', 2], + ]); + expect(inventory.patchDescriptors.map((patch) => [patch.writerId, patch.writerSequence])).toEqual([ + ['alice', 0], + ['alice', 1], + ['alice', 2], + ['bob', 0], + ['bob', 1], + ]); + expect(inventory.contentSources.map((source) => source.legacyContentKey)).toEqual([ + 'node:alpha:_content', + ]); + expect(inventory.fatalErrors).toEqual([]); + }); + + it('fails closed when the graph has no restored writer refs', async () => { + const repositoryPath = await mkdtemp(join(tmpdir(), 'git-warp-v17-source-empty-')); + await execFileAsync('git', ['init', '-q'], { cwd: repositoryPath }); + + const inventory = await collectGraphModelMigrationSourceInventory({ + repositoryPath, + graphId: 'missing-graph', + }); + + expect(inventory.isUsableForPlanning()).toBe(false); + expect(inventory.sourceBasis).toBeNull(); + expect(inventory.fatalErrors.map((notice) => notice.code)).toContain('E_NO_WRITER_REFS'); + expect(inventory.fatalErrors.map((notice) => notice.code)).toContain('E_MISSING_SOURCE_BASIS'); + }); + + it('rejects an empty repository path before invoking Git', async () => { + await expect(collectGraphModelMigrationSourceInventory({ + repositoryPath: '', + graphId: 'v17-golden-graph', + })).rejects.toThrow(/repositoryPath/); + }); +}); diff --git a/test/unit/scripts/v18-migration-command.test.ts b/test/unit/scripts/v18-migration-command.test.ts new file mode 100644 index 00000000..3fd2f8b3 --- /dev/null +++ b/test/unit/scripts/v18-migration-command.test.ts @@ -0,0 +1,414 @@ +import { execFile } from 'node:child_process'; +import { mkdtemp } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { promisify } from 'node:util'; +import { describe, expect, it } from 'vitest'; + +import { + runGraphModelMigrationCommand, +} from '../../../scripts/v18.0.0/migrations/graph-model/GraphModelMigrationCommand.ts'; +import { formatGraphModelMigrationCommandReport } + from '../../../scripts/v18.0.0/migrations/graph-model/GraphModelMigrationCommandReport.ts'; +import { buildGraphModelMigrationScratchReading } + from '../../../scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchReadingBuilder.ts'; +import { createGraphModelMigrationScratchRuntimeConformanceProvider } + from '../../../scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchRuntimeConformanceProvider.ts'; +import DryRunGraphModelMigrationPlanRequest + from '../../../src/domain/migrations/DryRunGraphModelMigrationPlanRequest.ts'; +import GenesisEquivalenceBoundary + from '../../../src/domain/migrations/GenesisEquivalenceBoundary.ts'; +import GenesisEquivalenceComparisonBasis + from '../../../src/domain/migrations/GenesisEquivalenceComparisonBasis.ts'; +import GenesisEquivalenceReading + from '../../../src/domain/migrations/GenesisEquivalenceReading.ts'; +import GenesisEquivalenceReadingFact + from '../../../src/domain/migrations/GenesisEquivalenceReadingFact.ts'; +import GraphModelMigrationBasis from '../../../src/domain/migrations/GraphModelMigrationBasis.ts'; +import GraphModelMigrationFinalizationConfirmation, { + V18_GRAPH_MODEL_FINALIZATION_CONFIRMATION, +} from '../../../src/domain/migrations/GraphModelMigrationFinalizationConfirmation.ts'; +import GraphModelMigrationNodeMapping + from '../../../src/domain/migrations/GraphModelMigrationNodeMapping.ts'; +import GraphModelMigrationPatchDescriptor + from '../../../src/domain/migrations/GraphModelMigrationPatchDescriptor.ts'; +import GraphModelMigrationRuntimeConformanceResult, { + GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_PASSED, +} from '../../../src/domain/migrations/GraphModelMigrationRuntimeConformanceResult.ts'; +import type GraphModelMigrationScratchWriteResult + from '../../../src/domain/migrations/GraphModelMigrationScratchWriteResult.ts'; +import GraphModelMigrationSourceInventory + from '../../../src/domain/migrations/GraphModelMigrationSourceInventory.ts'; +import GraphModelMigrationWriterChainDescriptor + from '../../../src/domain/migrations/GraphModelMigrationWriterChainDescriptor.ts'; +import { + divergentPropertyFixture, + nodeLifecycleFixture, +} from '../domain/migrations/GenesisEquivalenceFixtures.ts'; + +const execFileAsync = promisify(execFile); +const LIVE_REF = 'refs/warp/v17-golden-graph/writers/alice'; +const ARCHIVE_REF = 'refs/warp-migration-archive/v17-golden-graph/writers/alice'; +const SCRATCH_REF = 'refs/warp-migration-scratch/v17-golden-graph/migration'; + +describe('v18 graph-model migration command', () => { + it('runs planning, lowering, scratch writing, and equivalence without finalizing by default', async () => { + const repository = await initializedRepository('git-warp-v18-command-dry-'); + const fixture = nodeLifecycleFixture(); + + const result = await runGraphModelMigrationCommand({ + repositoryPath: repository, + dryRunRequest: dryRunRequest(), + scratchRefName: SCRATCH_REF, + equivalenceBasis: basis(), + legacyReading: fixture.legacyReading, + scratchReading: fixture.migratedReading, + readingProviders: null, + finalization: null, + }); + + expect(result.dryRunPlan.hasFatalErrors()).toBe(false); + expect(result.loweringResult.hasFatalErrors()).toBe(false); + expect(result.scratchWriteResult?.hasFatalErrors()).toBe(false); + expect(result.gateResult?.allowsPromotion()).toBe(true); + expect(result.finalizationResult).toBeNull(); + expect(await gitText(repository, ['rev-list', '--count', SCRATCH_REF])).toBe('1'); + expect(await refExists(repository, ARCHIVE_REF)).toBe(false); + }); + + it('finalizes with command-owned readings and scratch runtime conformance', async () => { + const repository = await repositoryWithLiveRef(); + + const result = await runGraphModelMigrationCommand({ + repositoryPath: repository.path, + dryRunRequest: dryRunRequest(), + scratchRefName: SCRATCH_REF, + equivalenceBasis: basis(), + legacyReading: null, + scratchReading: null, + readingProviders: { + legacyReading: async () => legacyNodeReading(), + scratchReading: async () => await buildGraphModelMigrationScratchReading({ + repositoryPath: repository.path, + scratchRefName: SCRATCH_REF, + readingId: 'scratch:finalization-provider', + }), + }, + finalization: { + liveRefName: LIVE_REF, + expectedLiveHead: repository.liveHead, + archiveRefName: ARCHIVE_REF, + confirmation: confirmation(), + runtimeConformance: createGraphModelMigrationScratchRuntimeConformanceProvider({ + repositoryPath: repository.path, + }), + }, + }); + + expect(result.gateResult?.allowsPromotion()).toBe(true); + expect(result.finalizationResult?.finalized()).toBe(true); + expect(await gitText(repository.path, ['rev-parse', ARCHIVE_REF])).toBe(repository.liveHead); + expect(await gitText(repository.path, ['rev-parse', LIVE_REF])) + .toBe(result.scratchWriteResult?.scratchHead); + expect(formatGraphModelMigrationCommandReport(result)).toContain([ + 'finalization: completed', + `liveRef: ${LIVE_REF}`, + `archiveRef: ${ARCHIVE_REF}`, + `previousLiveHead: ${repository.liveHead}`, + `finalizedLiveHead: ${result.scratchWriteResult?.scratchHead}`, + ].join('\n')); + }); + + it('blocks finalization when supplied scratch readings diverge', async () => { + const repository = await repositoryWithLiveRef(); + const fixture = divergentPropertyFixture(); + + const result = await runGraphModelMigrationCommand({ + repositoryPath: repository.path, + dryRunRequest: dryRunRequest(), + scratchRefName: SCRATCH_REF, + equivalenceBasis: basis(), + legacyReading: fixture.legacyReading, + scratchReading: fixture.migratedReading, + readingProviders: null, + finalization: { + liveRefName: LIVE_REF, + expectedLiveHead: repository.liveHead, + archiveRefName: ARCHIVE_REF, + confirmation: confirmation(), + runtimeConformance: runtimeConformance, + }, + }); + + expect(result.gateResult?.allowsPromotion()).toBe(false); + expect(result.finalizationResult?.finalized()).toBe(false); + expect(result.finalizationResult?.fatalErrors.map((notice) => notice.code)).toEqual([ + 'E_EQUIVALENCE_GATE_NOT_PASSED', + ]); + expect(await refExists(repository.path, ARCHIVE_REF)).toBe(false); + expect(await gitText(repository.path, ['rev-parse', LIVE_REF])).toBe(repository.liveHead); + const report = formatGraphModelMigrationCommandReport(result); + expect(report).toContain([ + 'equivalence: blocked', + 'mismatches: 1', + 'legacyFacts: 1', + 'migratedFacts: 1', + ].join('\n')); + expect(report).toContain([ + 'finalization: blocked', + 'fatalErrors:', + '- E_EQUIVALENCE_GATE_NOT_PASSED: migration finalization requires a passed scratch equivalence gate', + ].join('\n')); + }); + + it('blocks finalization when provider-built scratch readings diverge from legacy', async () => { + const repository = await repositoryWithLiveRef(); + + const result = await runGraphModelMigrationCommand({ + repositoryPath: repository.path, + dryRunRequest: dryRunRequest(), + scratchRefName: SCRATCH_REF, + equivalenceBasis: basis(), + legacyReading: null, + scratchReading: null, + readingProviders: { + legacyReading: async () => divergentLegacyNodeReading(), + scratchReading: async () => await buildGraphModelMigrationScratchReading({ + repositoryPath: repository.path, + scratchRefName: SCRATCH_REF, + readingId: 'scratch:divergent-provider', + }), + }, + finalization: { + liveRefName: LIVE_REF, + expectedLiveHead: repository.liveHead, + archiveRefName: ARCHIVE_REF, + confirmation: confirmation(), + runtimeConformance: createGraphModelMigrationScratchRuntimeConformanceProvider({ + repositoryPath: repository.path, + }), + }, + }); + + expect(result.gateResult?.allowsPromotion()).toBe(false); + expect(result.finalizationResult?.finalized()).toBe(false); + expect(result.finalizationResult?.fatalErrors.map((notice) => notice.code)).toEqual([ + 'E_EQUIVALENCE_GATE_NOT_PASSED', + ]); + expect(await refExists(repository.path, ARCHIVE_REF)).toBe(false); + expect(await gitText(repository.path, ['rev-parse', LIVE_REF])).toBe(repository.liveHead); + }); + + it('can construct readings through command-owned providers after scratch writing', async () => { + const repository = await initializedRepository('git-warp-v18-command-providers-'); + + const result = await runGraphModelMigrationCommand({ + repositoryPath: repository, + dryRunRequest: dryRunRequest(), + scratchRefName: SCRATCH_REF, + equivalenceBasis: basis(), + legacyReading: null, + scratchReading: null, + readingProviders: { + legacyReading: async () => legacyNodeReading(), + scratchReading: async () => await buildGraphModelMigrationScratchReading({ + repositoryPath: repository, + scratchRefName: SCRATCH_REF, + readingId: 'scratch:provider', + }), + }, + finalization: null, + }); + + expect(result.gateResult?.allowsPromotion()).toBe(true); + expect(result.gateResult?.proofResult.summary.legacyFactCount).toBe(1); + expect(result.gateResult?.proofResult.summary.migratedFactCount).toBe(1); + }); + + it('rejects an empty scratch ref name at the command boundary', async () => { + const repository = await initializedRepository('git-warp-v18-command-invalid-ref-'); + + await expect(runGraphModelMigrationCommand({ + repositoryPath: repository, + dryRunRequest: dryRunRequest(), + scratchRefName: '', + equivalenceBasis: basis(), + legacyReading: legacyNodeReading(), + scratchReading: legacyNodeReading(), + readingProviders: null, + finalization: null, + })).rejects.toThrow(/scratchRefName/); + }); + + it('rejects malformed provider readings before gate evaluation', async () => { + const repository = await initializedRepository('git-warp-v18-command-invalid-provider-'); + + await expect(runGraphModelMigrationCommand({ + repositoryPath: repository, + dryRunRequest: dryRunRequest(), + scratchRefName: SCRATCH_REF, + equivalenceBasis: basis(), + legacyReading: null, + scratchReading: null, + readingProviders: { + // @ts-expect-error exercising runtime validation + legacyReading: async () => null, + scratchReading: async () => legacyNodeReading(), + }, + finalization: null, + })).rejects.toThrow(/legacyReading/); + }); +}); + +type CommandFixtureRepository = { + readonly path: string; + readonly liveHead: string; +}; + +async function repositoryWithLiveRef(): Promise { + const repositoryPath = await initializedRepository('git-warp-v18-command-finalize-'); + const liveHead = await writeEmptyCommit(repositoryPath, 'live'); + await execFileAsync('git', ['update-ref', LIVE_REF, liveHead], { cwd: repositoryPath }); + return Object.freeze({ path: repositoryPath, liveHead }); +} + +async function initializedRepository(prefix: string): Promise { + const repositoryPath = await mkdtemp(join(tmpdir(), prefix)); + await execFileAsync('git', ['init', '-q'], { cwd: repositoryPath }); + await execFileAsync('git', ['config', 'user.name', 'git-warp test'], { cwd: repositoryPath }); + await execFileAsync('git', ['config', 'user.email', 'git-warp@example.invalid'], { cwd: repositoryPath }); + return repositoryPath; +} + +async function writeEmptyCommit(repositoryPath: string, message: string): Promise { + await execFileAsync('git', ['commit', '--allow-empty', '-q', '-m', message], { + cwd: repositoryPath, + }); + return await gitText(repositoryPath, ['rev-parse', 'HEAD']); +} + +function dryRunRequest(): DryRunGraphModelMigrationPlanRequest { + return new DryRunGraphModelMigrationPlanRequest({ + inventory: sourceInventory(), + requiredContentKeys: [], + nodeMappings: [ + new GraphModelMigrationNodeMapping({ + legacyNodeId: 'node:article', + targetNodeId: 'node:article', + }), + ], + edgeMappings: [], + propertyMappings: [], + }); +} + +function legacyNodeReading(): GenesisEquivalenceReading { + return new GenesisEquivalenceReading({ + readingId: 'legacy:provider', + facts: [ + new GenesisEquivalenceReadingFact({ + kind: 'node', + factKey: 'node:article', + fieldPath: 'visibility', + value: 'visible', + boundary: new GenesisEquivalenceBoundary({ + writerId: 'alice', + patchId: 'patch:alice:0', + operationIndex: 0, + }), + }), + ], + }); +} + +function divergentLegacyNodeReading(): GenesisEquivalenceReading { + return new GenesisEquivalenceReading({ + readingId: 'legacy:provider-divergent', + facts: [ + new GenesisEquivalenceReadingFact({ + kind: 'node', + factKey: 'node:article', + fieldPath: 'visibility', + value: 'removed', + boundary: new GenesisEquivalenceBoundary({ + writerId: 'alice', + patchId: 'patch:alice:0', + operationIndex: 0, + }), + }), + ], + }); +} + +function sourceInventory(): GraphModelMigrationSourceInventory { + return new GraphModelMigrationSourceInventory({ + graphId: 'v17-golden-graph', + sourceBasis: new GraphModelMigrationBasis({ + graphId: 'v17-golden-graph', + basisId: 'basis:source', + }), + writerChains: [ + new GraphModelMigrationWriterChainDescriptor({ + writerId: 'alice', + patchIds: ['patch:alice:0'], + }), + ], + patchDescriptors: [ + new GraphModelMigrationPatchDescriptor({ + patchId: 'patch:alice:0', + writerId: 'alice', + writerSequence: 0, + }), + ], + stateSnapshot: null, + contentSources: [], + warnings: [], + fatalErrors: [], + }); +} + +function basis(): GenesisEquivalenceComparisonBasis { + return new GenesisEquivalenceComparisonBasis({ + legacyBasis: new GraphModelMigrationBasis({ + graphId: 'v17-golden-graph', + basisId: 'basis:source', + }), + migratedBasis: new GraphModelMigrationBasis({ + graphId: 'v17-golden-graph', + basisId: 'basis:scratch', + }), + }); +} + +function confirmation(): GraphModelMigrationFinalizationConfirmation { + return new GraphModelMigrationFinalizationConfirmation({ + token: V18_GRAPH_MODEL_FINALIZATION_CONFIRMATION, + }); +} + +async function runtimeConformance( + scratchWriteResult: GraphModelMigrationScratchWriteResult, +): Promise { + if (scratchWriteResult.scratchRef === null || scratchWriteResult.scratchHead === null) { + return null; + } + return new GraphModelMigrationRuntimeConformanceResult({ + scratchRef: scratchWriteResult.scratchRef, + scratchHead: scratchWriteResult.scratchHead, + status: GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_PASSED, + witness: 'unit-test-runtime-conformance', + fatalErrors: [], + }); +} + +async function refExists(repositoryPath: string, refName: string): Promise { + const result = await execFileAsync('git', ['for-each-ref', '--format=%(refname)', refName], { + cwd: repositoryPath, + }); + return result.stdout.trim().length > 0; +} + +async function gitText(repositoryPath: string, args: readonly string[]): Promise { + const result = await execFileAsync('git', args, { cwd: repositoryPath }); + return result.stdout.trim(); +} diff --git a/test/unit/scripts/v18-migration-finalizer.test.ts b/test/unit/scripts/v18-migration-finalizer.test.ts new file mode 100644 index 00000000..5d9be553 --- /dev/null +++ b/test/unit/scripts/v18-migration-finalizer.test.ts @@ -0,0 +1,235 @@ +import { execFile } from 'node:child_process'; +import { mkdtemp } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { promisify } from 'node:util'; +import { describe, expect, it } from 'vitest'; + +import { + finalizeGraphModelMigration, +} from '../../../scripts/v18.0.0/migrations/graph-model/GraphModelMigrationFinalizer.ts'; +import GenesisEquivalenceComparisonBasis + from '../../../src/domain/migrations/GenesisEquivalenceComparisonBasis.ts'; +import GenesisEquivalenceGate from '../../../src/domain/migrations/GenesisEquivalenceGate.ts'; +import GraphModelMigrationBasis from '../../../src/domain/migrations/GraphModelMigrationBasis.ts'; +import GraphModelMigrationFinalizationConfirmation, { + V18_GRAPH_MODEL_FINALIZATION_CONFIRMATION, +} from '../../../src/domain/migrations/GraphModelMigrationFinalizationConfirmation.ts'; +import GraphModelMigrationFinalizationRequest + from '../../../src/domain/migrations/GraphModelMigrationFinalizationRequest.ts'; +import GraphModelMigrationFinalizationSafety + from '../../../src/domain/migrations/GraphModelMigrationFinalizationSafety.ts'; +import GraphModelMigrationRuntimeConformanceResult, { + GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_PASSED, +} from '../../../src/domain/migrations/GraphModelMigrationRuntimeConformanceResult.ts'; +import GraphModelMigrationScratchRef + from '../../../src/domain/migrations/GraphModelMigrationScratchRef.ts'; +import { + divergentPropertyFixture, + nodeLifecycleFixture, +} from '../domain/migrations/GenesisEquivalenceFixtures.ts'; + +const execFileAsync = promisify(execFile); +const LIVE_REF = 'refs/warp/v17-golden-graph/writers/alice'; +const ARCHIVE_REF = 'refs/warp-migration-archive/v17-golden-graph/writers/alice'; +const SCRATCH_REF = 'refs/warp-migration-scratch/v17-golden-graph/migration'; + +describe('v18 migration finalizer', () => { + it('archives the old live ref and advances the live ref with expected-head updates', async () => { + const repository = await repositoryWithLiveAndScratchRefs(); + + const result = await finalizeGraphModelMigration({ + repositoryPath: repository.path, + safetyResult: passedSafetyResult(repository.liveHead, repository.scratchHead), + }); + + expect(result.finalized()).toBe(true); + expect(result.previousLiveHead).toBe(repository.liveHead); + expect(result.finalizedLiveHead).toBe(repository.scratchHead); + expect(await gitText(repository.path, ['rev-parse', ARCHIVE_REF])).toBe(repository.liveHead); + expect(await gitText(repository.path, ['rev-parse', LIVE_REF])).toBe(repository.scratchHead); + }); + + it('does not create an archive or update the live ref when safety blocks finalization', async () => { + const repository = await repositoryWithLiveAndScratchRefs(); + + const result = await finalizeGraphModelMigration({ + repositoryPath: repository.path, + safetyResult: failedSafetyResult(repository.liveHead, repository.scratchHead), + }); + + expect(result.finalized()).toBe(false); + expect(result.fatalErrors.map((notice) => notice.code)).toEqual(['E_EQUIVALENCE_GATE_NOT_PASSED']); + expect(await refExists(repository.path, ARCHIVE_REF)).toBe(false); + expect(await gitText(repository.path, ['rev-parse', LIVE_REF])).toBe(repository.liveHead); + }); + + it('rejects an existing archive ref before changing the live ref', async () => { + const repository = await repositoryWithLiveAndScratchRefs(); + await execFileAsync('git', ['update-ref', ARCHIVE_REF, repository.liveHead], { + cwd: repository.path, + }); + + const result = await finalizeGraphModelMigration({ + repositoryPath: repository.path, + safetyResult: passedSafetyResult(repository.liveHead, repository.scratchHead), + }); + + expect(result.finalized()).toBe(false); + expect(result.fatalErrors.map((notice) => notice.code)).toEqual(['E_ARCHIVE_REF_EXISTS']); + expect(await gitText(repository.path, ['rev-parse', LIVE_REF])).toBe(repository.liveHead); + }); + + it('rejects live ref drift before creating the archive ref', async () => { + const repository = await repositoryWithLiveAndScratchRefs(); + const driftHead = await writeEmptyCommit(repository.path, 'drift'); + await execFileAsync('git', ['update-ref', LIVE_REF, driftHead, repository.liveHead], { + cwd: repository.path, + }); + + const result = await finalizeGraphModelMigration({ + repositoryPath: repository.path, + safetyResult: passedSafetyResult(repository.liveHead, repository.scratchHead), + }); + + expect(result.finalized()).toBe(false); + expect(result.fatalErrors.map((notice) => notice.code)).toEqual(['E_STALE_LIVE_REF_EXPECTATION']); + expect(await refExists(repository.path, ARCHIVE_REF)).toBe(false); + expect(await gitText(repository.path, ['rev-parse', LIVE_REF])).toBe(driftHead); + }); + + it('rejects blank approved finalization strings before Git ref updates', async () => { + const repository = await repositoryWithLiveAndScratchRefs(); + + await expect(finalizeGraphModelMigration({ + repositoryPath: repository.path, + safetyResult: passedSafetyResult(' ', repository.scratchHead), + })).rejects.toThrow(/expectedLiveHead/); + + expect(await refExists(repository.path, ARCHIVE_REF)).toBe(false); + expect(await gitText(repository.path, ['rev-parse', LIVE_REF])).toBe(repository.liveHead); + }); +}); + +type FinalizerFixtureRepository = { + readonly path: string; + readonly liveHead: string; + readonly scratchHead: string; +}; + +async function repositoryWithLiveAndScratchRefs(): Promise { + const repositoryPath = await initializedRepository('git-warp-v18-finalizer-'); + const liveHead = await writeEmptyCommit(repositoryPath, 'live'); + const scratchHead = await writeEmptyCommit(repositoryPath, 'scratch'); + await execFileAsync('git', ['update-ref', LIVE_REF, liveHead], { cwd: repositoryPath }); + await execFileAsync('git', ['update-ref', SCRATCH_REF, scratchHead], { cwd: repositoryPath }); + return Object.freeze({ + path: repositoryPath, + liveHead, + scratchHead, + }); +} + +async function initializedRepository(prefix: string): Promise { + const repositoryPath = await mkdtemp(join(tmpdir(), prefix)); + await execFileAsync('git', ['init', '-q'], { cwd: repositoryPath }); + await execFileAsync('git', ['config', 'user.name', 'git-warp test'], { cwd: repositoryPath }); + await execFileAsync('git', ['config', 'user.email', 'git-warp@example.invalid'], { cwd: repositoryPath }); + return repositoryPath; +} + +async function writeEmptyCommit(repositoryPath: string, message: string): Promise { + await execFileAsync('git', ['commit', '--allow-empty', '-q', '-m', message], { + cwd: repositoryPath, + }); + return await gitText(repositoryPath, ['rev-parse', 'HEAD']); +} + +function passedSafetyResult(liveHead: string, scratchHead: string) { + return new GraphModelMigrationFinalizationSafety().evaluate( + finalizationRequest(liveHead, scratchHead, passedGateResult()), + ); +} + +function failedSafetyResult(liveHead: string, scratchHead: string) { + return new GraphModelMigrationFinalizationSafety().evaluate( + finalizationRequest(liveHead, scratchHead, failedGateResult()), + ); +} + +function finalizationRequest( + liveHead: string, + scratchHead: string, + gateResult: ReturnType, +): GraphModelMigrationFinalizationRequest { + const scratchRef = new GraphModelMigrationScratchRef({ refName: SCRATCH_REF }); + return new GraphModelMigrationFinalizationRequest({ + liveRefName: LIVE_REF, + expectedLiveHead: liveHead, + observedLiveHead: liveHead, + scratchRef, + scratchHead, + archiveRefName: ARCHIVE_REF, + confirmation: new GraphModelMigrationFinalizationConfirmation({ + token: V18_GRAPH_MODEL_FINALIZATION_CONFIRMATION, + }), + gateResult, + runtimeConformance: runtimeConformance(scratchRef, scratchHead), + }); +} + +function passedGateResult(): ReturnType { + const fixture = nodeLifecycleFixture(); + return new GenesisEquivalenceGate().evaluate( + basis(), + fixture.legacyReading, + fixture.migratedReading, + ); +} + +function failedGateResult(): ReturnType { + const fixture = divergentPropertyFixture(); + return new GenesisEquivalenceGate().evaluate( + basis(), + fixture.legacyReading, + fixture.migratedReading, + ); +} + +function basis(): GenesisEquivalenceComparisonBasis { + return new GenesisEquivalenceComparisonBasis({ + legacyBasis: new GraphModelMigrationBasis({ + graphId: 'graph:fixture', + basisId: 'basis:legacy', + }), + migratedBasis: new GraphModelMigrationBasis({ + graphId: 'graph:fixture', + basisId: 'basis:scratch', + }), + }); +} + +function runtimeConformance( + scratchRef: GraphModelMigrationScratchRef, + scratchHead: string, +): GraphModelMigrationRuntimeConformanceResult { + return new GraphModelMigrationRuntimeConformanceResult({ + scratchRef, + scratchHead, + status: GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_PASSED, + witness: 'unit-test-runtime-conformance', + fatalErrors: [], + }); +} + +async function refExists(repositoryPath: string, refName: string): Promise { + const result = await execFileAsync('git', ['for-each-ref', '--format=%(refname)', refName], { + cwd: repositoryPath, + }); + return result.stdout.trim().length > 0; +} + +async function gitText(repositoryPath: string, args: readonly string[]): Promise { + const result = await execFileAsync('git', args, { cwd: repositoryPath }); + return result.stdout.trim(); +} diff --git a/test/unit/scripts/v18-scratch-migration-writer.test.ts b/test/unit/scripts/v18-scratch-migration-writer.test.ts new file mode 100644 index 00000000..ad7d6a65 --- /dev/null +++ b/test/unit/scripts/v18-scratch-migration-writer.test.ts @@ -0,0 +1,145 @@ +import { execFile } from 'node:child_process'; +import { mkdtemp } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { promisify } from 'node:util'; +import { describe, expect, it } from 'vitest'; + +import { + writeGraphModelMigrationScratchHistory, +} from '../../../scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchWriter.ts'; +import GraphModelMigrationBasis from '../../../src/domain/migrations/GraphModelMigrationBasis.ts'; +import GraphModelMigrationLoweredOperation + from '../../../src/domain/migrations/GraphModelMigrationLoweredOperation.ts'; +import GraphModelMigrationLoweredPatchPlan + from '../../../src/domain/migrations/GraphModelMigrationLoweredPatchPlan.ts'; + +const execFileAsync = promisify(execFile); +const SCRATCH_REF = 'refs/warp-migration-scratch/v17-golden-graph/migration'; +const LIVE_WRITER_REF = 'refs/warp/v17-golden-graph/writers/alice'; + +describe('v18 scratch migration writer', () => { + it('writes lowered operations only to an explicit scratch ref', async () => { + const repositoryPath = await initializedRepository('git-warp-v18-scratch-'); + + const result = await writeGraphModelMigrationScratchHistory({ + repositoryPath, + scratchRefName: SCRATCH_REF, + patchPlan: patchPlan([ + loweredOperation('node-record', 'node:a', 'node:a'), + loweredOperation('property', 'node:a\0title', 'property-target-key:length-prefixed-v1:6:node:a:5:title'), + loweredOperation('content-attachment', 'node:a\0_content', 'content-attachment:node:a:_content'), + ]), + }); + + expect(result.hasFatalErrors()).toBe(false); + expect(result.scratchRef?.refName).toBe(SCRATCH_REF); + expect(result.writtenPatches).toHaveLength(3); + expect(await gitText(repositoryPath, ['rev-list', '--count', SCRATCH_REF])).toBe('3'); + expect(await refExists(repositoryPath, LIVE_WRITER_REF)).toBe(false); + expect(await gitText(repositoryPath, ['show', `${result.scratchHead ?? ''}:migration-operation.txt`])) + .toContain('git-warp-v18-migration-operation-v1'); + }); + + it('fails before writing when no scratch target is provided', async () => { + const repositoryPath = await initializedRepository('git-warp-v18-scratch-missing-'); + + const result = await writeGraphModelMigrationScratchHistory({ + repositoryPath, + scratchRefName: null, + patchPlan: patchPlan([loweredOperation('node-record', 'node:a', 'node:a')]), + }); + + expect(result.hasFatalErrors()).toBe(true); + expect(result.fatalErrors.map((notice) => notice.code)).toEqual(['E_MISSING_SCRATCH_REF']); + expect(await listRefs(repositoryPath)).toEqual([]); + }); + + it('rejects live writer ref targets before touching Git refs', async () => { + const repositoryPath = await initializedRepository('git-warp-v18-scratch-live-'); + + const result = await writeGraphModelMigrationScratchHistory({ + repositoryPath, + scratchRefName: LIVE_WRITER_REF, + patchPlan: patchPlan([loweredOperation('node-record', 'node:a', 'node:a')]), + }); + + expect(result.hasFatalErrors()).toBe(true); + expect(result.fatalErrors.map((notice) => notice.code)).toEqual(['E_LIVE_REF_TARGET']); + expect(await listRefs(repositoryPath)).toEqual([]); + }); + + it('appends to an existing scratch ref with an expected-head update', async () => { + const repositoryPath = await initializedRepository('git-warp-v18-scratch-append-'); + const first = await writeGraphModelMigrationScratchHistory({ + repositoryPath, + scratchRefName: SCRATCH_REF, + patchPlan: patchPlan([loweredOperation('node-record', 'node:a', 'node:a')]), + }); + + const second = await writeGraphModelMigrationScratchHistory({ + repositoryPath, + scratchRefName: SCRATCH_REF, + patchPlan: patchPlan([loweredOperation('node-record', 'node:b', 'node:b')]), + }); + + expect(first.scratchHead).not.toBeNull(); + expect(second.scratchHead).not.toBe(first.scratchHead); + expect(await gitText(repositoryPath, ['rev-list', '--count', SCRATCH_REF])).toBe('2'); + expect(await gitText(repositoryPath, ['rev-parse', `${second.scratchHead ?? ''}^`])) + .toBe(first.scratchHead); + }); +}); + +async function initializedRepository(prefix: string): Promise { + const repositoryPath = await mkdtemp(join(tmpdir(), prefix)); + await execFileAsync('git', ['init', '-q'], { cwd: repositoryPath }); + return repositoryPath; +} + +function patchPlan( + operations: readonly GraphModelMigrationLoweredOperation[], +): GraphModelMigrationLoweredPatchPlan { + return new GraphModelMigrationLoweredPatchPlan({ + sourceBasis: new GraphModelMigrationBasis({ + graphId: 'v17-golden-graph', + basisId: 'source-basis', + }), + targetBasis: new GraphModelMigrationBasis({ + graphId: 'v17-golden-graph', + basisId: 'target-basis', + }), + operations, + }); +} + +function loweredOperation( + kind: 'node-record' | 'property' | 'content-attachment', + sourceKey: string, + targetKey: string, +): GraphModelMigrationLoweredOperation { + return new GraphModelMigrationLoweredOperation({ + kind, + sourceKey, + targetKey, + }); +} + +async function refExists(repositoryPath: string, refName: string): Promise { + const result = await execFileAsync('git', ['for-each-ref', '--format=%(refname)', refName], { + cwd: repositoryPath, + }); + return result.stdout.trim().length > 0; +} + +async function listRefs(repositoryPath: string): Promise { + const result = await execFileAsync('git', ['for-each-ref', '--format=%(refname)'], { + cwd: repositoryPath, + }); + return result.stdout.trim().split('\n').filter((line) => line.length > 0); +} + +async function gitText(repositoryPath: string, args: readonly string[]): Promise { + const result = await execFileAsync('git', args, { cwd: repositoryPath }); + return result.stdout.trim(); +} diff --git a/test/unit/scripts/v18-scratch-reading-builder.test.ts b/test/unit/scripts/v18-scratch-reading-builder.test.ts new file mode 100644 index 00000000..af423fd3 --- /dev/null +++ b/test/unit/scripts/v18-scratch-reading-builder.test.ts @@ -0,0 +1,120 @@ +import { execFile } from 'node:child_process'; +import { mkdtemp } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { promisify } from 'node:util'; +import { describe, expect, it } from 'vitest'; + +import { buildGraphModelMigrationScratchReading } + from '../../../scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchReadingBuilder.ts'; +import { writeGraphModelMigrationScratchHistory } + from '../../../scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchWriter.ts'; +import { runMigrationGit } + from '../../../scripts/v18.0.0/migrations/graph-model/GitMigrationCommandRunner.ts'; +import GraphModelMigrationBasis from '../../../src/domain/migrations/GraphModelMigrationBasis.ts'; +import GraphModelMigrationLoweredOperation + from '../../../src/domain/migrations/GraphModelMigrationLoweredOperation.ts'; +import GraphModelMigrationLoweredPatchPlan + from '../../../src/domain/migrations/GraphModelMigrationLoweredPatchPlan.ts'; + +const execFileAsync = promisify(execFile); +const SCRATCH_REF = 'refs/warp-migration-scratch/v17-golden-graph/migration'; + +describe('v18 scratch reading builder', () => { + it('builds genesis equivalence facts from scratch operation commits', async () => { + const repositoryPath = await initializedRepository(); + await writeGraphModelMigrationScratchHistory({ + repositoryPath, + scratchRefName: SCRATCH_REF, + patchPlan: patchPlan([ + operation('node-record', 'node:a', 'node:a'), + operation('property', 'node:a/title', 'property:node:a/title'), + ]), + }); + + const reading = await buildGraphModelMigrationScratchReading({ + repositoryPath, + scratchRefName: SCRATCH_REF, + readingId: 'scratch:v18', + }); + + expect(reading.facts.map((fact) => fact.toKey())).toEqual([ + 'node\0node:a\0visibility', + 'property\0property:node:a/title\0value', + ]); + expect(reading.facts.map((fact) => fact.value)).toEqual([ + 'visible', + 'migration-source:node:a/title', + ]); + expect(reading.facts.every((fact) => fact.boundary?.writerId === 'scratch-migration')).toBe(true); + }); + + it('rejects malformed hex bytes instead of partially parsing them', async () => { + const repositoryPath = await initializedRepository(); + const commitId = await writeScratchPayload(repositoryPath, [ + 'git-warp-v18-migration-operation-v1', + 'sequence 0', + 'kind node-record', + 'source-key-utf8-hex 0g', + 'target-key-utf8-hex 6e6f64653a61', + '', + ].join('\n')); + await execFileAsync('git', ['update-ref', SCRATCH_REF, commitId], { cwd: repositoryPath }); + + await expect(buildGraphModelMigrationScratchReading({ + repositoryPath, + scratchRefName: SCRATCH_REF, + readingId: 'scratch:bad-hex', + })).rejects.toThrow(/invalid hex byte 0g/); + }); +}); + +async function initializedRepository(): Promise { + const repositoryPath = await mkdtemp(join(tmpdir(), 'git-warp-v18-scratch-reading-')); + await execFileAsync('git', ['init', '-q'], { cwd: repositoryPath }); + return repositoryPath; +} + +function patchPlan( + operations: readonly GraphModelMigrationLoweredOperation[], +): GraphModelMigrationLoweredPatchPlan { + return new GraphModelMigrationLoweredPatchPlan({ + sourceBasis: new GraphModelMigrationBasis({ + graphId: 'v17-golden-graph', + basisId: 'basis:source', + }), + targetBasis: new GraphModelMigrationBasis({ + graphId: 'v17-golden-graph', + basisId: 'basis:scratch', + }), + operations, + }); +} + +function operation( + kind: 'node-record' | 'property', + sourceKey: string, + targetKey: string, +): GraphModelMigrationLoweredOperation { + return new GraphModelMigrationLoweredOperation({ kind, sourceKey, targetKey }); +} + +async function writeScratchPayload(repositoryPath: string, payload: string): Promise { + const blobOid = await gitOk(repositoryPath, ['hash-object', '-w', '--stdin'], payload); + const treeOid = await gitOk( + repositoryPath, + ['mktree'], + `100644 blob ${blobOid}\tmigration-operation.txt\n`, + ); + return await gitOk(repositoryPath, ['commit-tree', treeOid], 'bad scratch payload\n'); +} + +async function gitOk( + repositoryPath: string, + args: readonly string[], + input: string, +): Promise { + const result = await runMigrationGit(repositoryPath, args, input, { deterministicIdentity: true }); + expect(result.ok()).toBe(true); + return result.stdout.trim(); +} diff --git a/test/unit/scripts/v18-scratch-runtime-conformance-provider.test.ts b/test/unit/scripts/v18-scratch-runtime-conformance-provider.test.ts new file mode 100644 index 00000000..5e262e66 --- /dev/null +++ b/test/unit/scripts/v18-scratch-runtime-conformance-provider.test.ts @@ -0,0 +1,147 @@ +import { execFile } from 'node:child_process'; +import { mkdtemp } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { promisify } from 'node:util'; +import { describe, expect, it } from 'vitest'; + +import { createGraphModelMigrationScratchRuntimeConformanceProvider } + from '../../../scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchRuntimeConformanceProvider.ts'; +import { writeGraphModelMigrationScratchHistory } + from '../../../scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchWriter.ts'; +import { runMigrationGit } + from '../../../scripts/v18.0.0/migrations/graph-model/GitMigrationCommandRunner.ts'; +import GraphModelMigrationBasis from '../../../src/domain/migrations/GraphModelMigrationBasis.ts'; +import GraphModelMigrationLoweredOperation + from '../../../src/domain/migrations/GraphModelMigrationLoweredOperation.ts'; +import GraphModelMigrationLoweredPatchPlan + from '../../../src/domain/migrations/GraphModelMigrationLoweredPatchPlan.ts'; +import GraphModelMigrationScratchWriteResult + from '../../../src/domain/migrations/GraphModelMigrationScratchWriteResult.ts'; +import { + GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_FAILED, + GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_PASSED, +} from '../../../src/domain/migrations/GraphModelMigrationRuntimeConformanceResult.ts'; + +const execFileAsync = promisify(execFile); +const SCRATCH_REF = 'refs/warp-migration-scratch/v17-golden-graph/migration'; + +describe('v18 scratch runtime conformance provider', () => { + it('passes when scratch history reads back at the expected head', async () => { + const repositoryPath = await initializedRepository('git-warp-v18-runtime-conformance-pass-'); + const writeResult = await writeGraphModelMigrationScratchHistory({ + repositoryPath, + scratchRefName: SCRATCH_REF, + patchPlan: patchPlan([operation('node-record', 'node:a', 'node:a')]), + }); + const provider = createGraphModelMigrationScratchRuntimeConformanceProvider({ + repositoryPath, + }); + + const result = await provider(writeResult); + + expect(result?.status).toBe(GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_PASSED); + expect(result?.allowsFinalization()).toBe(true); + expect(result?.scratchHead).toBe(writeResult.scratchHead); + expect(result?.witness).toBe('git-warp-v18-scratch-operation-readback-v1 facts=1'); + }); + + it('fails closed when the scratch ref is no longer readable', async () => { + const repositoryPath = await initializedRepository('git-warp-v18-runtime-conformance-missing-'); + const writeResult = await writeGraphModelMigrationScratchHistory({ + repositoryPath, + scratchRefName: SCRATCH_REF, + patchPlan: patchPlan([operation('node-record', 'node:a', 'node:a')]), + }); + await gitOk(repositoryPath, ['update-ref', '-d', SCRATCH_REF], null); + const provider = createGraphModelMigrationScratchRuntimeConformanceProvider({ + repositoryPath, + }); + + const result = await provider(writeResult); + + expect(result?.status).toBe(GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_FAILED); + expect(result?.allowsFinalization()).toBe(false); + expect(result?.fatalErrors.map((notice) => notice.code)).toEqual([ + 'E_RUNTIME_CONFORMANCE_SCRATCH_REF_UNREADABLE', + ]); + }); + + it('fails closed when scratch operation payloads cannot be read back', async () => { + const repositoryPath = await initializedRepository('git-warp-v18-runtime-conformance-corrupt-'); + const writeResult = await writeGraphModelMigrationScratchHistory({ + repositoryPath, + scratchRefName: SCRATCH_REF, + patchPlan: patchPlan([operation('node-record', 'node:a', 'node:a')]), + }); + const badHead = await writeBadScratchCommit(repositoryPath); + await gitOk(repositoryPath, ['update-ref', SCRATCH_REF, badHead], null); + const provider = createGraphModelMigrationScratchRuntimeConformanceProvider({ + repositoryPath, + }); + + const result = await provider(new GraphModelMigrationScratchWriteResult({ + scratchRef: writeResult.scratchRef, + scratchHead: badHead, + writtenPatches: writeResult.writtenPatches, + warnings: [], + fatalErrors: [], + })); + + expect(result?.status).toBe(GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_FAILED); + expect(result?.allowsFinalization()).toBe(false); + expect(result?.fatalErrors.map((notice) => notice.code)).toEqual([ + 'E_RUNTIME_CONFORMANCE_SCRATCH_HISTORY_UNREADABLE', + ]); + }); +}); + +async function initializedRepository(prefix: string): Promise { + const repositoryPath = await mkdtemp(join(tmpdir(), prefix)); + await execFileAsync('git', ['init', '-q'], { cwd: repositoryPath }); + return repositoryPath; +} + +function patchPlan( + operations: readonly GraphModelMigrationLoweredOperation[], +): GraphModelMigrationLoweredPatchPlan { + return new GraphModelMigrationLoweredPatchPlan({ + sourceBasis: new GraphModelMigrationBasis({ + graphId: 'v17-golden-graph', + basisId: 'basis:source', + }), + targetBasis: new GraphModelMigrationBasis({ + graphId: 'v17-golden-graph', + basisId: 'basis:scratch', + }), + operations, + }); +} + +function operation( + kind: 'node-record', + sourceKey: string, + targetKey: string, +): GraphModelMigrationLoweredOperation { + return new GraphModelMigrationLoweredOperation({ kind, sourceKey, targetKey }); +} + +async function writeBadScratchCommit(repositoryPath: string): Promise { + const blobOid = await gitOk(repositoryPath, ['hash-object', '-w', '--stdin'], 'not a scratch payload\n'); + const treeOid = await gitOk( + repositoryPath, + ['mktree'], + `100644 blob ${blobOid}\tmigration-operation.txt\n`, + ); + return await gitOk(repositoryPath, ['commit-tree', treeOid], 'bad scratch payload\n'); +} + +async function gitOk( + repositoryPath: string, + args: readonly string[], + input: string | null, +): Promise { + const result = await runMigrationGit(repositoryPath, args, input, { deterministicIdentity: true }); + expect(result.ok()).toBe(true); + return result.stdout.trim(); +} diff --git a/test/unit/scripts/v18-v17-golden-graph-fixtures.test.ts b/test/unit/scripts/v18-v17-golden-graph-fixtures.test.ts new file mode 100644 index 00000000..b77b6b6c --- /dev/null +++ b/test/unit/scripts/v18-v17-golden-graph-fixtures.test.ts @@ -0,0 +1,223 @@ +import { copyFile, mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { tmpdir } from 'node:os'; +import { describe, expect, it } from 'vitest'; + +import { + restoreV17GoldenGraphFixture, +} from '../../../scripts/v18.0.0/migrations/graph-model/V17GoldenGraphFixtureRestore.ts'; +import { + parseV17GoldenGraphFixtureManifestJson, +} from '../../../src/infrastructure/adapters/V17GoldenGraphFixtureManifestJsonAdapter.ts'; +import { + V17GoldenContentFact, + V17GoldenEdgeFact, + V17GoldenMultiWriterFact, + V17GoldenNodeFact, + V17GoldenPropertyFact, + V17GoldenRemovalFact, +} from '../../../src/domain/migrations/V17GoldenGraphFixtureManifest.ts'; + +const FIXTURE_MANIFEST_PATH = resolve('fixtures/v17/graph-model-golden/manifest.json'); + +describe('v18 v17 golden graph-history fixtures', () => { + it('parses a runtime-backed manifest with the required visible fact families', async () => { + const raw = await readFile(FIXTURE_MANIFEST_PATH, 'utf8'); + const manifest = parseV17GoldenGraphFixtureManifestJson(raw); + + expect(manifest.fixtureId).toBe('v17-golden-graph-model-001'); + expect(manifest.graphId).toBe('v17-golden-graph'); + expect(manifest.writerChains.map((chain) => chain.writerId)).toEqual(['alice', 'bob']); + expect(manifest.hasVisibleFactKind('node')).toBe(true); + expect(manifest.hasVisibleFactKind('edge')).toBe(true); + expect(manifest.hasVisibleFactKind('property')).toBe(true); + expect(manifest.hasVisibleFactKind('content')).toBe(true); + expect(manifest.hasVisibleFactKind('removal')).toBe(true); + expect(manifest.hasVisibleFactKind('multi-writer')).toBe(true); + expect(manifest.visibleFacts.some((fact) => fact instanceof V17GoldenContentFact)).toBe(true); + expect(manifest.visibleFacts.some((fact) => fact instanceof V17GoldenEdgeFact)).toBe(true); + expect(manifest.visibleFacts.some((fact) => fact instanceof V17GoldenNodeFact)).toBe(true); + expect(manifest.visibleFacts.some((fact) => fact instanceof V17GoldenRemovalFact)).toBe(true); + expect(manifest.visibleFacts.some((fact) => fact instanceof V17GoldenPropertyFact)).toBe(true); + expect(manifest.visibleFacts.some((fact) => fact instanceof V17GoldenMultiWriterFact)).toBe(true); + }); + + it('restores the bundle into an isolated repository and verifies writer heads', async () => { + const targetDirectory = await mkdtemp(join(tmpdir(), 'git-warp-v17-golden-')); + + const result = await restoreV17GoldenGraphFixture({ + manifestPath: FIXTURE_MANIFEST_PATH, + targetDirectory, + }); + + expect(result.repositoryPath).toBe(targetDirectory); + expect(result.restoredRefs).toEqual([ + { + refName: 'refs/warp/v17-golden-graph/writers/alice', + head: '417fe95095a6feae3042c36505065bbd7b3d2a67', + patchCount: 3, + }, + { + refName: 'refs/warp/v17-golden-graph/writers/bob', + head: 'd7c3a05b3894d5c3c151e03dd972b6bd6c341b0c', + patchCount: 2, + }, + ]); + }); + + it('fails closed when a manifest expects the wrong restored head', async () => { + const directory = await mkdtemp(join(tmpdir(), 'git-warp-v17-golden-bad-')); + const manifestPath = join(directory, 'manifest.json'); + const targetDirectory = join(directory, 'target'); + const raw = await readFile(FIXTURE_MANIFEST_PATH, 'utf8'); + await copyFile( + resolve('fixtures/v17/graph-model-golden/v17-golden-graph.bundle'), + join(directory, 'v17-golden-graph.bundle'), + ); + await writeFile( + manifestPath, + raw.replace( + '417fe95095a6feae3042c36505065bbd7b3d2a67', + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + ), + 'utf8', + ); + + await expect(restoreV17GoldenGraphFixture({ + manifestPath, + targetDirectory, + })).rejects.toThrow('expected aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'); + }); + + it('rejects malformed manifest JSON at the adapter boundary', () => { + const cases = Object.freeze([ + { + raw: '{', + message: /valid JSON/, + }, + { + raw: '[]', + message: /manifest.*object/, + }, + { + raw: manifestJson({ extraRoot: true }), + message: /manifest\.extra/, + }, + { + raw: manifestJson({ writerChains: 'alice' }), + message: /writerChains.*array/, + }, + { + raw: manifestJson({ writerChains: [null] }), + message: /writerChains\[0\].*object/, + }, + { + raw: manifestJson({ + writerChains: [ + { + writerId: '', + refName: 'refs/warp/v17-golden-graph/writers/alice', + expectedHead: '1111111111111111111111111111111111111111', + patchCount: 1, + }, + ], + }), + message: /writerId.*non-empty string/, + }, + { + raw: manifestJson({ + writerChains: [ + { + writerId: 'alice', + refName: 'refs/warp/v17-golden-graph/writers/alice', + expectedHead: '1111111111111111111111111111111111111111', + patchCount: '1', + }, + ], + }), + message: /patchCount.*finite number/, + }, + { + raw: manifestJson({ + visibleFacts: [ + { + kind: 7, + key: 'node:alpha', + description: 'bad kind', + }, + ], + }), + message: /kind.*supported fact kind/, + }, + { + raw: manifestJson({ + visibleFacts: [ + { + kind: 'node', + key: 'node:alpha', + }, + ], + }), + message: /description.*required/, + }, + ]); + + for (const candidate of cases) { + expect(() => parseV17GoldenGraphFixtureManifestJson(candidate.raw)) + .toThrow(candidate.message); + } + }); + + it('rejects empty restore paths before file-system or Git work', async () => { + await expect(restoreV17GoldenGraphFixture({ + manifestPath: '', + targetDirectory: 'target', + })).rejects.toThrow(/manifestPath/); + await expect(restoreV17GoldenGraphFixture({ + manifestPath: FIXTURE_MANIFEST_PATH, + targetDirectory: '', + })).rejects.toThrow(/targetDirectory/); + }); +}); + +type ManifestJsonValue = + | string + | number + | boolean + | null + | readonly ManifestJsonValue[] + | { readonly [key: string]: ManifestJsonValue }; + +type ManifestOverrides = { + readonly writerChains?: ManifestJsonValue; + readonly visibleFacts?: ManifestJsonValue; + readonly extraRoot?: boolean; +}; + +function manifestJson(overrides: ManifestOverrides = {}): string { + const manifest = { + fixtureId: 'fixture:unit', + graphId: 'v17-golden-graph', + sourceVersion: '17.0.1', + generator: 'unit-test', + bundlePath: 'v17-golden-graph.bundle', + writerChains: overrides.writerChains ?? [ + { + writerId: 'alice', + refName: 'refs/warp/v17-golden-graph/writers/alice', + expectedHead: '1111111111111111111111111111111111111111', + patchCount: 1, + }, + ], + visibleFacts: overrides.visibleFacts ?? [ + { kind: 'node', key: 'node:alpha', description: 'node' }, + { kind: 'edge', key: 'edge:alpha-beta', description: 'edge' }, + { kind: 'property', key: 'node:alpha:title', description: 'title' }, + { kind: 'content', key: 'node:alpha:_content', description: 'content' }, + { kind: 'removal', key: 'node:removed', description: 'removed' }, + { kind: 'multi-writer', key: 'writers:alice+bob', description: 'multi' }, + ], + ...(overrides.extraRoot === true ? { extra: true } : {}), + }; + return JSON.stringify(manifest); +} diff --git a/vitest.config.ts b/vitest.config.ts index 4051fa8b..5f9326e5 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -29,7 +29,7 @@ export default defineConfig({ include: ['src/**/*.ts'], exclude: ['src/ports/**/*.ts', 'src/**/*.d.ts'], thresholds: { - lines: 91.90, + lines: 92.05, autoUpdate: shouldAutoUpdateCoverageRatchet(), }, },