From 4e508f1c97523a0ec11b626174e5af0e01efd226 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 10 Aug 2026 14:33:12 +0200 Subject: [PATCH 01/41] test(stack): encode managed stack acceptance contract --- ...managed-stack-contract.integration.test.ts | 62 + .../0015-managed-stack-contract-fixtures.md | 118 + docs/adr/README.md | 1 + packages/stack/src/entrypoints.unit.test.ts | 8 +- ...managed-stack-contract.integration.test.ts | 580 +++ packages/stack/src/managed-stack-contract.ts | 3733 +++++++++++++++++ packages/stack/src/testing.ts | 16 + 7 files changed, 4517 insertions(+), 1 deletion(-) create mode 100644 apps/cli/src/next/managed-stack-contract.integration.test.ts create mode 100644 docs/adr/0015-managed-stack-contract-fixtures.md create mode 100644 packages/stack/src/managed-stack-contract.integration.test.ts create mode 100644 packages/stack/src/managed-stack-contract.ts diff --git a/apps/cli/src/next/managed-stack-contract.integration.test.ts b/apps/cli/src/next/managed-stack-contract.integration.test.ts new file mode 100644 index 0000000000..e0c2eb0306 --- /dev/null +++ b/apps/cli/src/next/managed-stack-contract.integration.test.ts @@ -0,0 +1,62 @@ +import { managedStackContractFixtures } from "@supabase/stack/testing"; +import { describe, expect, it } from "vitest"; + +describe("experimental managed stack command contract", () => { + it("consumes the shared new-branch result as CLI arguments and user-visible output", () => { + const scenario = managedStackContractFixtures.find( + ({ id }) => id === "identity.new-branch-first-start-creates-stack", + ); + + expect(scenario?.when).toEqual({ + interface: "cli", + argv: ["start", "--experimental"], + cwd: "checkout-a", + }); + expect(scenario?.expected.output).toEqual({ + human: { + summary: "Created feat-a/default", + fields: { + branch: "feat-a", + stack: "default", + stackId: "stack-feat-a-default", + }, + }, + json: { + outcome: "create", + context_id: "context-feat-a", + stack_id: "stack-feat-a-default", + }, + }); + }); + + it("consumes structured engine failures without adding a second port decision", () => { + const scenario = managedStackContractFixtures.find( + ({ id }) => id === "ports.explicit-port-conflict-fails", + ); + + expect(scenario?.when).toEqual({ + interface: "cli", + argv: ["start", "--experimental"], + cwd: "checkout-a", + }); + expect(scenario?.expected.output).toMatchObject({ + human: { + summary: "Cannot start because configured port 54321 is in use", + fields: { + port: "54321", + configKey: "api.port", + owner: "external-process", + }, + }, + json: { + outcome: "error", + code: "exact_port_occupied", + port: 54321, + config_key: "api.port", + owner: "external-process", + }, + }); + expect(scenario?.expected.writes).toEqual([]); + expect(scenario?.expected.runtimeEffects).toEqual([]); + }); +}); diff --git a/docs/adr/0015-managed-stack-contract-fixtures.md b/docs/adr/0015-managed-stack-contract-fixtures.md new file mode 100644 index 0000000000..6ebaba2553 --- /dev/null +++ b/docs/adr/0015-managed-stack-contract-fixtures.md @@ -0,0 +1,118 @@ +# 0015. Managed Stack Contract Fixtures + +**Status**: accepted +**Date**: 2026-08-10 + +## Problem Statement + +The managed local-stack design combines project, checkout, branch, and named-stack identity with +mutable state, host-wide port allocation, runtime selection, legacy bootstrap, credentials, and +reclamation. These decisions affect both the reusable `@supabase/stack` package and the CLI. If each +layer encodes its own behavior matrix, they will drift and tests will eventually validate +implementation details instead of the behavior developers observe. + +The persistence technology is intentionally not part of the product contract. A later adapter may +use SQLite or another store, but changing storage must not change identity or lifecycle semantics. + +## Decision + +The typed fixtures exported from `@supabase/stack/testing` are the normative executable description +of the M1 managed-stack behavior. Each scenario records: + +- explicit input state; +- a public CLI, Git, direct-stack API, or managed-stack API action; +- the resolved opaque identities and outcome; +- the complete set of permitted managed-state writes and runtime side effects; and +- human, JSON, or programmatic output, including deterministic recovery guidance for errors. + +Opaque symbolic IDs make the same scenario reusable across an in-memory repository, a persistent +adapter, the managed package, and CLI integration tests. Linear records the decision history and +links to implementation work; it is not a second source of executable truth. + +`@supabase/stack` has two distinct public responsibilities: + +1. Direct `createStack(config)` creates one caller-controlled stack. Omitted stack and runtime roots + are temporary. It does not inspect Git, create identity markers, or mutate a global managed + registry. +2. The explicit managed surface owns system-aware discovery, identity, stack selection, ports, + runtime persistence, bootstrap, and reclamation. It accepts an isolated state root or injected + repository so applications and tests can use it without the CLI. + +The CLI is a consumer and presentation layer. It translates arguments into managed operations and +projects managed results into human and JSON output. It must not implement a second identity, +selection, port, runtime, or lifecycle decision path. + +Persistence sits behind the managed package's repository boundary. Contract fixtures must run +against a storage-independent test repository and then against each selected persistent adapter. +The choice of SQLite, files, or another implementation does not move product policy into the CLI or +change the package boundary. + +## Testing Strategy + +Tests should be as close as possible to how a developer uses the product: + +- Package integration tests invoke public direct or managed APIs and compare their observable + result with the shared fixture. +- CLI integration tests invoke command handlers and assert argument translation plus human/JSON + projection from that same managed result. +- Repository conformance tests execute the same fixtures against the isolated repository and the + selected persistent adapter. +- Unit tests are reserved for genuinely pure algorithms and public export/type checks; they do not + duplicate the behavior matrix through private helpers. +- E2E tests cover a small number of real subprocess/runtime golden paths. Add a targeted E2E test + when a boundary cannot be represented faithfully in an in-process integration test, rather than + mocking away the behavior under test. + +CLI-2102 checks in the fixture data and public consumer seams before the managed engine and +persistent adapter exist. The implementation issues it unblocks must attach real drivers to these +fixtures. A fixture-presence test is not evidence that an unimplemented command already satisfies +the behavior. + +## Rationale + +A single typed matrix makes disagreements visible in review and allows every layer to consume the +same expected result. Public-interface integration tests survive refactors because they assert +commands, API calls, outputs, and state transitions rather than internal call graphs. Injected +repositories keep system-aware behavior programmatically reusable while preventing a persistence +choice from leaking into product semantics. + +Keeping direct and managed stack creation separate also preserves a simple embedding API for tests: +`createStack()` remains isolated, while callers that want branch/worktree-aware state opt into the +managed surface explicitly. + +## Consequences + +### Positive + +- Package, CLI, and persistence adapters share one reviewed behavioral authority. +- Tests describe developer-visible journeys and remain useful through implementation refactors. +- Programmatic consumers can use managed state without importing CLI code. +- Direct test stacks stay isolated from Git and system-wide state. +- Storage technology can change without changing package ownership or managed semantics. + +### Negative / Trade-offs + +- The fixture catalog is intentionally large because it records edge cases that otherwise become + implicit behavior. +- New managed behavior requires updating the shared matrix before layer-specific tests. +- Until downstream implementations attach real drivers, fixture catalog tests validate contract + completeness and projection seams, not the future engine itself. + +## Alternatives Considered + +1. **Duplicate package and CLI test tables**: rejected because identity and lifecycle rules would + drift and reviewers could not identify the authoritative result. +2. **Make CLI tests authoritative**: rejected because managed behavior must be reusable from Node + and Bun without the CLI. +3. **Define behavior through a SQLite schema**: rejected because schemas describe persistence, not + product semantics, and would make a technology choice distort package boundaries. +4. **Put the whole matrix in E2E tests**: rejected because the suite would be slow and failure + diagnosis poor. E2E remains the fallback for boundaries that integration tests cannot exercise + faithfully. + +## See Also + +- [CLI-2102](https://linear.app/supabase/issue/CLI-2102/contract-encode-approved-behavior-as-cross-layer-acceptance-fixtures) +- [CLI-2103](https://linear.app/supabase/issue/CLI-2103/contract-freeze-project-checkout-worktree-branch-and-named-stack) +- [CLI-2104](https://linear.app/supabase/issue/CLI-2104/contract-freeze-legacy-migration-declared-port-stop-and-rollback) +- [CLI-2105](https://linear.app/supabase/issue/CLI-2105/contract-freeze-runtime-selection-naming-precedence-and-persistence) diff --git a/docs/adr/README.md b/docs/adr/README.md index 90f4694b45..c7b4b45212 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -55,6 +55,7 @@ When an ADR becomes outdated, mark it as `deprecated` or reference the supersedi | 0009 | [Configuration Schema & Validation](0009-configuration-schema-and-validation.md) | proposed | | 0011 | [CLI Release & Distribution Strategy](0011-cli-release-and-distribution-strategy.md) | proposed | | 0013 | [Live E2E Tests Bypass the Replay Server](0013-live-e2e-bypasses-replay-server.md) | proposed | +| 0015 | [Managed Stack Contract Fixtures](0015-managed-stack-contract-fixtures.md) | accepted | ## Template diff --git a/packages/stack/src/entrypoints.unit.test.ts b/packages/stack/src/entrypoints.unit.test.ts index 06b9b548ed..67e366ebf5 100644 --- a/packages/stack/src/entrypoints.unit.test.ts +++ b/packages/stack/src/entrypoints.unit.test.ts @@ -71,6 +71,12 @@ describe("@supabase/stack entrypoints", () => { }); it("isolates consumer test seams in the testing entry", () => { - expect(Object.keys(testing).sort()).toEqual(["DaemonServer", "UnixHttpClient"]); + expect(Object.keys(testing).sort()).toEqual([ + "DaemonServer", + "UnixHttpClient", + "managedNativeServiceMatrix", + "managedStackContractFixtures", + "validateManagedStackContractFixtures", + ]); }); }); diff --git a/packages/stack/src/managed-stack-contract.integration.test.ts b/packages/stack/src/managed-stack-contract.integration.test.ts new file mode 100644 index 0000000000..995c59a62c --- /dev/null +++ b/packages/stack/src/managed-stack-contract.integration.test.ts @@ -0,0 +1,580 @@ +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { createStack } from "./node.ts"; +import { + managedNativeServiceMatrix, + managedStackContractFixtures, + validateManagedStackContractFixtures, +} from "./testing.ts"; + +describe("managed stack acceptance contract", () => { + it("keeps every shared scenario readable and executable through a public interface", () => { + expect(validateManagedStackContractFixtures(managedStackContractFixtures)).toEqual([]); + }); + + it("covers the approved identity journeys through public commands and APIs", () => { + expect( + managedStackContractFixtures + .filter(({ area }) => area === "identity") + .map(({ id }) => id) + .sort(), + ).toEqual( + [ + "identity.branch-commit-preserves-context", + "identity.branch-copy-ambiguous-read-only", + "identity.branch-copy-known-owner-creates-context-on-mutation", + "identity.branch-copy-read-only-does-not-write", + "identity.branch-create-and-switch-is-no-op", + "identity.branch-delete-recreate-creates-context", + "identity.branch-rebase-preserves-context", + "identity.branch-rename-preserves-context", + "identity.branch-reset-preserves-context", + "identity.concurrent-create-publishes-once", + "identity.copied-checkout-reports-duplicate-claim", + "identity.detached-commits-reuse-checkout-context", + "identity.folder-to-git-ambiguous-claim-fails", + "identity.folder-to-git-exact-claim-preserves-identity", + "identity.folder-to-git-without-claim-creates-git-identity", + "identity.fresh-clone-creates-project-and-checkout", + "identity.fresh-clone-ignores-tracked-marker", + "identity.inaccessible-previous-path-fails", + "identity.invalid-stack-name-fails", + "identity.linked-worktrees-share-project-not-checkout", + "identity.manual-ref-replacement-orphans-context", + "identity.missing-previous-path-rebinds-checkout", + "identity.moved-checkout-reuses-identity", + "identity.named-stacks-are-context-scoped", + "identity.new-branch-first-start-creates-stack", + "identity.non-git-folder-reuses-workspace-context", + "identity.original-gone-turns-copy-into-rename", + "identity.read-only-unregistered-checkout-does-not-write", + "identity.return-to-branch-reuses-stack", + "identity.same-branch-in-two-worktrees-is-isolated", + "identity.same-checkout-branch-and-name-reuses-stack", + "identity.same-commit-different-branches-are-independent", + "identity.symlink-alias-reuses-checkout", + "identity.valid-stack-names-resolve-deterministically", + "identity.bare-repository-linked-worktrees-share-project", + ].sort(), + ); + }); + + it("covers exact declarative ports and sticky automatic allocation", () => { + expect( + managedStackContractFixtures + .filter(({ area }) => area === "ports") + .map(({ id }) => id) + .sort(), + ).toEqual( + [ + "ports.config-change-on-running-stack-reports-drift", + "ports.config-change-on-stopped-stack-applies", + "ports.env-and-remote-values-remain-exact", + "ports.exact-default-value-differs-from-omitted-default", + "ports.explicit-free-port-is-used", + "ports.explicit-port-conflict-fails", + "ports.later-sticky-port-collision-fails", + "ports.new-target-allocates-and-persists-omitted-ports", + "ports.removing-exact-key-keeps-current-port-sticky", + "ports.running-legacy-source-fails-before-allocation", + "ports.sibling-targets-allocate-independent-ports", + "ports.sticky-ports-reuse-on-return", + ].sort(), + ); + }); + + it("freezes runtime selection and the atomic native service graph", () => { + expect(managedNativeServiceMatrix).toEqual({ + targetPlatforms: ["darwin-arm64", "linux-amd64", "linux-arm64"], + unsupportedPlatforms: ["darwin-x64", "windows-amd64", "windows-arm64"], + services: [ + ["postgres", "17.6.1.160"], + ["postgrest", "v14.16"], + ["auth", "v2.195.0"], + ["edge-runtime", "v1.74.3"], + ["realtime", "v2.124.1"], + ["storage", "v1.68.9"], + ["pg-meta", "v0.96.8"], + ["studio", "2026.08.03-sha-022b374"], + ["analytics", "v1.50.1"], + ["pooler", "v2.9.10"], + ["mailpit", "v1.30.2"], + ["vector", "v0.53.0"], + ["imgproxy", "v3.8.0"], + ], + }); + + expect( + managedStackContractFixtures + .filter(({ area }) => area === "runtime" || area === "native-qualification") + .map(({ id }) => id) + .sort(), + ).toEqual( + [ + "native-qualification.all-services-qualify-platform", + "native-qualification.one-service-failure-disables-platform", + "native-qualification.unsupported-platform-fails-preflight", + "runtime.auto-fails-when-neither-runtime-is-available", + "runtime.auto-prefers-docker", + "runtime.auto-selects-fully-qualified-native", + "runtime.config-overrides-default-auto", + "runtime.explicit-and-config-conflict-fails", + "runtime.explicit-api-overrides-auto", + "runtime.explicit-runtime-is-strict", + "runtime.missing-persisted-prerequisite-fails", + "runtime.persisted-runtime-conflict-fails", + "runtime.persisted-runtime-reused-for-auto", + "runtime.status-reports-one-stack-wide-runtime", + ].sort(), + ); + }); + + it("covers read-compatible bootstrap without coupling managed and legacy timelines", () => { + expect( + managedStackContractFixtures + .filter(({ area }) => area === "bootstrap") + .map(({ id }) => id) + .sort(), + ).toEqual( + [ + "bootstrap.existing-managed-target-ignores-legacy", + "bootstrap.failed-copy-rolls-back-and-retries", + "bootstrap.first-start-copies-compatible-legacy-state", + "bootstrap.incompatible-or-absent-legacy-starts-fresh", + "bootstrap.managed-and-legacy-diverge-after-copy", + "bootstrap.running-legacy-source-fails-without-mutation", + ].sort(), + ); + }); + + it("covers credential authority, stability, drift, and secret boundaries", () => { + expect( + managedStackContractFixtures + .filter(({ area }) => area === "credentials") + .map(({ id }) => id) + .sort(), + ).toEqual( + [ + "credentials.compatible-legacy-auth-is-retained", + "credentials.configured-values-are-authoritative", + "credentials.explicit-change-applies-after-stop", + "credentials.omitted-values-use-stable-defaults", + "credentials.plaintext-secrets-stay-out-of-global-state", + "credentials.running-change-reports-drift", + "credentials.unchanged-values-survive-restart", + ].sort(), + ); + }); + + it("covers preservation, global deletion, tombstones, prune, and engine-scoped stop", () => { + expect( + managedStackContractFixtures + .filter(({ area }) => area === "reclamation") + .map(({ id }) => id) + .sort(), + ).toEqual( + [ + "reclamation.branch-delete-does-not-delete-data", + "reclamation.default-stop-preserves-data", + "reclamation.delete-orphan-by-stack-id", + "reclamation.delete-repeat-is-idempotent", + "reclamation.prune-removes-metadata-only", + "reclamation.selectors-are-mutually-exclusive", + "reclamation.stop-is-engine-scoped", + ].sort(), + ); + }); + + it("freezes the direct, managed, repository, CLI, and portable runtime boundaries", () => { + expect( + managedStackContractFixtures + .filter(({ area }) => area === "api-boundary") + .map(({ id }) => id) + .sort(), + ).toEqual( + [ + "api-boundary.cli-projects-shared-managed-results", + "api-boundary.direct-create-stack-is-ephemeral", + "api-boundary.managed-api-accepts-injected-repository", + "api-boundary.managed-api-accepts-isolated-state-root", + "api-boundary.managed-surface-is-node-and-bun-portable", + "api-boundary.repository-contract-is-storage-agnostic", + ].sort(), + ); + }); + + it("keeps public createStack usage isolated when state roots are omitted", async () => { + const testRoot = mkdtempSync(join(tmpdir(), "supabase-direct-contract-")); + const projectDir = join(testRoot, "project"); + const cacheRoot = join(testRoot, "cache"); + const gitConfig = join(projectDir, ".git", "config"); + const identityMarker = join(projectDir, ".supabase", "identity.json"); + const registrySentinel = join(cacheRoot, "managed-registry.json"); + + mkdirSync(join(projectDir, ".git"), { recursive: true }); + mkdirSync(join(projectDir, ".supabase"), { recursive: true }); + mkdirSync(cacheRoot, { recursive: true }); + writeFileSync(gitConfig, "[core]\n\trepositoryformatversion = 0\n"); + writeFileSync(identityMarker, '{"sentinel":true}\n'); + writeFileSync(registrySentinel, '{"sentinel":true}\n'); + + try { + const stack = await createStack({ cacheRoot, projectDir, startupMode: "lazy" }); + expect(stack).toMatchObject({ + url: expect.stringMatching(/^http:\/\/127\.0\.0\.1:/), + dbUrl: expect.stringMatching(/^postgresql:\/\//), + }); + await stack.dispose(); + + expect(readFileSync(gitConfig, "utf8")).toBe("[core]\n\trepositoryformatversion = 0\n"); + expect(readFileSync(identityMarker, "utf8")).toBe('{"sentinel":true}\n'); + expect(readFileSync(registrySentinel, "utf8")).toBe('{"sentinel":true}\n'); + expect(existsSync(join(cacheRoot, "projects"))).toBe(false); + } finally { + rmSync(testRoot, { recursive: true, force: true }); + } + }); + + it("reuses the existing stack when a developer returns to a branch", () => { + const scenario = managedStackContractFixtures.find( + ({ id }) => id === "identity.return-to-branch-reuses-stack", + ); + + expect(scenario).toEqual({ + id: "identity.return-to-branch-reuses-stack", + title: "Returning to a previously used branch reuses its stack", + area: "identity", + given: [ + { + kind: "checkout", + path: "checkout-a", + projectId: "project-a", + checkoutId: "checkout-a", + }, + { + kind: "branch", + name: "main", + contextId: "context-main", + checkedOut: true, + }, + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + contextId: "context-main", + lifecycle: "stopped", + }, + ], + when: { + interface: "cli", + argv: ["start", "--experimental"], + cwd: "checkout-a", + }, + expected: { + outcome: "reuse", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-main", + stackId: "stack-main-default", + stackName: "default", + }, + writes: [{ target: "runtime-state", operation: "start", id: "stack-main-default" }], + runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], + output: { + human: { + summary: "Reused main/default", + fields: { + branch: "main", + stack: "default", + stackId: "stack-main-default", + }, + }, + json: { + outcome: "reuse", + project_id: "project-a", + checkout_id: "checkout-a", + context_id: "context-main", + stack_id: "stack-main-default", + stack_name: "default", + }, + }, + }, + }); + }); + + it("reports an ambiguous copied branch without mutating either branch", () => { + const scenario = managedStackContractFixtures.find( + ({ id }) => id === "identity.branch-copy-ambiguous-read-only", + ); + + expect(scenario?.when).toEqual({ + interface: "cli", + argv: ["status", "--experimental", "--output", "json"], + cwd: "checkout-a", + }); + expect(scenario?.expected).toEqual({ + outcome: "error", + error: { + code: "ambiguous_context_owner", + message: "Branches feat-copy and main both claim context-main", + recovery: [ + "supabase stack inspect --context-id context-main", + "supabase stack new-context --branch feat-copy", + ], + }, + writes: [], + runtimeEffects: [], + output: { + human: { + summary: "Cannot determine which branch owns this stack context", + fields: { + contextId: "context-main", + branches: "feat-copy, main", + }, + recovery: [ + "supabase stack inspect --context-id context-main", + "supabase stack new-context --branch feat-copy", + ], + }, + json: { + outcome: "error", + code: "ambiguous_context_owner", + context_id: "context-main", + branches: ["feat-copy", "main"], + recovery: [ + "supabase stack inspect --context-id context-main", + "supabase stack new-context --branch feat-copy", + ], + }, + }, + }); + }); + + it("fails on an occupied declarative port instead of relocating the stack", () => { + const scenario = managedStackContractFixtures.find( + ({ id }) => id === "ports.explicit-port-conflict-fails", + ); + + expect(scenario).toMatchObject({ + area: "ports", + given: [ + { + kind: "config-port", + key: "api.port", + intent: "exact", + value: 54321, + }, + { + kind: "occupied-port", + port: 54321, + owner: "external-process", + }, + ], + when: { + interface: "cli", + argv: ["start", "--experimental"], + }, + expected: { + outcome: "error", + error: { + code: "exact_port_occupied", + recovery: [ + "Stop the process using port 54321", + "Change api.port in supabase/config.toml", + "Remove api.port to use automatic allocation", + ], + }, + writes: [], + runtimeEffects: [], + output: { + json: { + outcome: "error", + code: "exact_port_occupied", + port: 54321, + config_key: "api.port", + }, + }, + }, + }); + }); + + it("keeps an existing stack on its persisted runtime", () => { + const scenario = managedStackContractFixtures.find( + ({ id }) => id === "runtime.persisted-runtime-conflict-fails", + ); + + expect(scenario).toMatchObject({ + area: "runtime", + given: [ + { + kind: "persisted-runtime", + stackId: "stack-main-default", + runtime: "docker", + }, + { + kind: "runtime-request", + source: "cli", + runtime: "native", + }, + ], + when: { + interface: "cli", + argv: ["start", "--experimental", "--runtime", "native"], + }, + expected: { + outcome: "error", + error: { + code: "runtime_conflicts_with_persisted_stack", + recovery: [ + "Start a new named stack with --stack ", + "Delete and recreate stack-main-default", + ], + }, + writes: [], + runtimeEffects: [], + output: { + json: { + outcome: "error", + code: "runtime_conflicts_with_persisted_stack", + persisted_runtime: "docker", + requested_runtime: "native", + }, + }, + }, + }); + }); + + it("bootstraps compatible stopped legacy state without mutating it", () => { + const scenario = managedStackContractFixtures.find( + ({ id }) => id === "bootstrap.first-start-copies-compatible-legacy-state", + ); + + expect(scenario).toMatchObject({ + area: "bootstrap", + given: [ + { + kind: "managed-target", + stackId: "stack-main-default", + exists: false, + }, + { + kind: "legacy-state", + lifecycle: "stopped", + database: "compatible", + storage: "compatible", + credentials: "compatible", + }, + ], + when: { + interface: "cli", + argv: ["start", "--experimental"], + }, + expected: { + outcome: "create", + writes: [ + { target: "managed-state", operation: "copy", id: "stack-main-default" }, + { target: "registry", operation: "create", id: "stack-main-default" }, + { target: "runtime-state", operation: "start", id: "stack-main-default" }, + ], + runtimeEffects: [ + { operation: "copy", stackId: "stack-main-default" }, + { operation: "start", stackId: "stack-main-default" }, + ], + details: { + bootstrap: "copied", + legacy_state_mutated: false, + credentials: "preserved", + }, + output: { + json: { + outcome: "create", + bootstrap: "copied", + stack_id: "stack-main-default", + }, + }, + }, + }); + }); + + it("deletes an orphaned stack by opaque ID without a checkout", () => { + const scenario = managedStackContractFixtures.find( + ({ id }) => id === "reclamation.delete-orphan-by-stack-id", + ); + + expect(scenario).toMatchObject({ + area: "reclamation", + given: [ + { + kind: "stack", + stackId: "stack-orphan", + lifecycle: "running", + orphaned: true, + }, + ], + when: { + interface: "cli", + argv: ["stop", "--experimental", "--stack-id", "stack-orphan", "--no-backup"], + }, + expected: { + outcome: "delete", + writes: [ + { target: "runtime-state", operation: "delete", id: "stack-orphan" }, + { target: "managed-state", operation: "delete", id: "stack-orphan" }, + { target: "registry", operation: "tombstone", id: "stack-orphan" }, + ], + runtimeEffects: [ + { operation: "stop", stackId: "stack-orphan" }, + { operation: "delete", stackId: "stack-orphan" }, + ], + output: { + json: { + outcome: "delete", + stack_id: "stack-orphan", + tombstoned: true, + }, + }, + }, + }); + }); + + it("keeps direct createStack usage isolated from system-wide managed state", () => { + const scenario = managedStackContractFixtures.find( + ({ id }) => id === "api-boundary.direct-create-stack-is-ephemeral", + ); + + expect(scenario).toMatchObject({ + area: "api-boundary", + given: [ + { + kind: "direct-stack-options", + roots: "omitted", + }, + ], + when: { + interface: "stack-api", + method: "createStack", + input: {}, + }, + expected: { + outcome: "create", + writes: [{ target: "ephemeral-state", operation: "create", id: "ephemeral-stack" }], + runtimeEffects: [], + details: { + git_inspected: false, + identity_marker_created: false, + global_registry_mutated: false, + state_root: "temporary", + }, + output: { + api: { + handle: "stack-handle", + state_root: "temporary", + }, + }, + }, + }); + }); +}); diff --git a/packages/stack/src/managed-stack-contract.ts b/packages/stack/src/managed-stack-contract.ts new file mode 100644 index 0000000000..b8bfa34d56 --- /dev/null +++ b/packages/stack/src/managed-stack-contract.ts @@ -0,0 +1,3733 @@ +export type ManagedStackContractArea = + | "api-boundary" + | "bootstrap" + | "credentials" + | "identity" + | "native-qualification" + | "ports" + | "reclamation" + | "runtime"; + +export type ManagedStackContractJson = + | null + | boolean + | number + | string + | ReadonlyArray + | { readonly [key: string]: ManagedStackContractJson }; + +export type ManagedStackContractFact = + | { + readonly kind: "workspace"; + readonly mode: "bare-worktree" | "git" | "linked-worktree" | "ordinary-folder"; + readonly path: string; + readonly canonicalPath?: string; + readonly previousPath?: string; + readonly previousPathAccess?: "inaccessible" | "missing" | "reachable"; + readonly copiedFrom?: string; + readonly clonedFrom?: string; + } + | { + readonly kind: "git-state"; + readonly commonDirectory: string; + readonly gitDirectory: string; + readonly head: "branch" | "detached"; + readonly branch?: string; + readonly commit: string; + readonly trackedIdentityMarker?: boolean; + } + | { + readonly kind: "identity-claim"; + readonly scope: "checkout" | "context" | "project"; + readonly id: string; + readonly path?: string; + readonly owner?: string; + readonly status: "absent" | "ambiguous" | "duplicate" | "exact"; + } + | { + readonly kind: "identity-transition"; + readonly operation: + | "branch-commit" + | "branch-copy" + | "branch-delete-recreate" + | "branch-rebase" + | "branch-rename" + | "branch-reset" + | "checkout-copy" + | "checkout-move" + | "clone" + | "folder-to-git" + | "ref-replacement" + | "symlink-alias"; + readonly from?: string; + readonly to?: string; + readonly originalExists?: boolean; + } + | { + readonly kind: "concurrent-operation"; + readonly operation: "create-stack"; + readonly target: string; + readonly contenders: number; + } + | { + readonly kind: "stack-names"; + readonly names: ReadonlyArray; + } + | { + readonly kind: "checkout"; + readonly path: string; + readonly projectId: string; + readonly checkoutId: string; + } + | { + readonly kind: "branch"; + readonly name: string; + readonly contextId: string; + readonly checkedOut: boolean; + } + | { + readonly kind: "stack"; + readonly name: string; + readonly stackId: string; + readonly contextId: string; + readonly lifecycle: "running" | "stopped"; + readonly orphaned?: boolean; + } + | { + readonly kind: "config-port"; + readonly key: string; + readonly intent: "automatic" | "exact"; + readonly value?: number; + readonly previousValue?: number; + readonly source?: "environment" | "local" | "omitted" | "remote"; + } + | { + readonly kind: "port-assignment"; + readonly stackId: string; + readonly key: string; + readonly port: number; + readonly intent: "automatic" | "exact"; + } + | { + readonly kind: "occupied-port"; + readonly port: number; + readonly owner: "external-process" | "legacy-stack" | "managed-stack"; + readonly ownerId?: string; + } + | { + readonly kind: "persisted-runtime"; + readonly stackId: string; + readonly runtime: "docker" | "native"; + } + | { + readonly kind: "runtime-request"; + readonly source: "cli" | "config" | "default" | "managed-api"; + readonly runtime: "auto" | "docker" | "native"; + } + | { + readonly kind: "runtime-availability"; + readonly runtime: "docker" | "native"; + readonly available: boolean; + readonly reason?: string; + } + | { + readonly kind: "native-qualification"; + readonly platform: string; + readonly qualifiedServices: ReadonlyArray; + readonly failedServices: ReadonlyArray; + } + | { + readonly kind: "managed-target"; + readonly stackId: string; + readonly exists: boolean; + } + | { + readonly kind: "managed-record"; + readonly stackId: string; + readonly status: "active" | "orphaned" | "tombstoned"; + } + | { + readonly kind: "legacy-state"; + readonly lifecycle: "absent" | "running" | "stopped"; + readonly database: "absent" | "compatible" | "incompatible"; + readonly storage: "absent" | "compatible" | "incompatible"; + readonly credentials: "absent" | "compatible" | "incompatible"; + } + | { + readonly kind: "credential-state"; + readonly source: "configured" | "legacy" | "local-default" | "persisted"; + readonly valuesId: string; + readonly previousValuesId?: string; + readonly plaintextPresentInGlobalState?: boolean; + } + | { + readonly kind: "direct-stack-options"; + readonly roots: "explicit" | "omitted"; + } + | { + readonly kind: "managed-api-options"; + readonly stateRoot: "default" | "isolated"; + readonly repository: "in-memory" | "injected" | "persistent-adapter"; + readonly runtime: "bun" | "node"; + }; + +export interface ManagedStackContractOutput { + readonly human?: { + readonly summary: string; + readonly fields: Readonly>; + readonly recovery?: ReadonlyArray; + }; + readonly json?: Readonly>; + readonly api?: Readonly>; +} + +export interface ManagedStackContractEffects { + readonly writes: ReadonlyArray<{ + readonly target: + | "git-config" + | "ephemeral-state" + | "identity-marker" + | "managed-state" + | "registry" + | "runtime-state"; + readonly operation: "copy" | "create" | "delete" | "publish" | "start" | "tombstone" | "update"; + readonly id?: string; + }>; + readonly runtimeEffects: ReadonlyArray<{ + readonly operation: "copy" | "delete" | "start" | "stop"; + readonly stackId?: string; + }>; + readonly output: ManagedStackContractOutput; +} + +export interface ManagedStackContractExpectation extends ManagedStackContractEffects { + readonly outcome: "create" | "delete" | "error" | "no-op" | "report" | "reuse" | "update"; + readonly selection?: { + readonly projectId: string; + readonly checkoutId: string; + readonly contextId: string; + readonly stackId: string; + readonly stackName: string; + }; + readonly error?: { + readonly code: string; + readonly message: string; + readonly recovery: ReadonlyArray; + }; + readonly details?: Readonly>; +} + +export type ManagedStackContractAction = + | { + readonly interface: "cli"; + readonly argv: ReadonlyArray; + readonly cwd: string; + } + | { + readonly interface: "git"; + readonly argv: ReadonlyArray; + readonly cwd: string; + } + | { + readonly interface: "managed-api"; + readonly method: string; + readonly input: Readonly>; + } + | { + readonly interface: "stack-api"; + readonly method: string; + readonly input: Readonly>; + }; + +export interface ManagedStackContractScenario { + readonly id: string; + readonly title: string; + readonly area: ManagedStackContractArea; + readonly given: ReadonlyArray; + readonly when: ManagedStackContractAction; + readonly expected: ManagedStackContractExpectation; +} + +export interface ManagedNativeServiceMatrix { + readonly targetPlatforms: ReadonlyArray; + readonly unsupportedPlatforms: ReadonlyArray; + readonly services: ReadonlyArray; +} + +export const managedNativeServiceMatrix: ManagedNativeServiceMatrix = { + targetPlatforms: ["darwin-arm64", "linux-amd64", "linux-arm64"], + unsupportedPlatforms: ["darwin-x64", "windows-amd64", "windows-arm64"], + services: [ + ["postgres", "17.6.1.160"], + ["postgrest", "v14.16"], + ["auth", "v2.195.0"], + ["edge-runtime", "v1.74.3"], + ["realtime", "v2.124.1"], + ["storage", "v1.68.9"], + ["pg-meta", "v0.96.8"], + ["studio", "2026.08.03-sha-022b374"], + ["analytics", "v1.50.1"], + ["pooler", "v2.9.10"], + ["mailpit", "v1.30.2"], + ["vector", "v0.53.0"], + ["imgproxy", "v3.8.0"], + ], +}; + +const defineManagedStackContractFixtures = < + const Fixtures extends ReadonlyArray, +>( + fixtures: Fixtures, +): Fixtures => fixtures; + +export const validateManagedStackContractFixtures = ( + fixtures: ReadonlyArray, +): ReadonlyArray => { + const errors: Array = []; + const ids = new Set(); + + for (const scenario of fixtures) { + if (ids.has(scenario.id)) { + errors.push(`${scenario.id}: duplicate scenario ID`); + } + ids.add(scenario.id); + + if (!scenario.id.startsWith(`${scenario.area}.`)) { + errors.push(`${scenario.id}: ID must start with ${scenario.area}.`); + } + if (scenario.title.trim().length === 0) { + errors.push(`${scenario.id}: title is required`); + } + if (scenario.given.length === 0) { + errors.push(`${scenario.id}: at least one given fact is required`); + } + + if (scenario.when.interface === "cli" || scenario.when.interface === "git") { + if (scenario.when.argv.length === 0) { + errors.push(`${scenario.id}: argv must contain a public command`); + } + if (scenario.when.cwd.trim().length === 0) { + errors.push(`${scenario.id}: cwd is required for command scenarios`); + } + } else { + if (scenario.when.method.trim().length === 0) { + errors.push(`${scenario.id}: public API method is required`); + } + } + + const { output } = scenario.expected; + if (output.human === undefined && output.json === undefined && output.api === undefined) { + errors.push(`${scenario.id}: at least one observable output is required`); + } + + if (scenario.expected.outcome === "error") { + if (scenario.expected.error === undefined) { + errors.push(`${scenario.id}: error outcome requires structured error metadata`); + } else if (scenario.expected.error.recovery.length === 0) { + errors.push(`${scenario.id}: error outcome requires recovery guidance`); + } + } else if (scenario.expected.error !== undefined) { + errors.push(`${scenario.id}: non-error outcome cannot include error metadata`); + } + } + + return errors; +}; + +const branchHistoryFixture = ( + label: "commit" | "rebase" | "reset", + operation: "branch-commit" | "branch-rebase" | "branch-reset", +): ManagedStackContractScenario => ({ + id: `identity.branch-${label}-preserves-context`, + title: `A branch ${label} preserves its context and stack`, + area: "identity", + given: [ + { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, + { kind: "branch", name: "feat-a", contextId: "context-feat", checkedOut: true }, + { kind: "identity-transition", operation, from: "commit-a", to: "commit-b" }, + { + kind: "stack", + name: "default", + stackId: "stack-feat-default", + contextId: "context-feat", + lifecycle: "stopped", + }, + ], + when: { + interface: "managed-api", + method: "resolveStack", + input: { cwd: "checkout-a", stackName: "default", operation: "start" }, + }, + expected: { + outcome: "reuse", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-feat", + stackId: "stack-feat-default", + stackName: "default", + }, + writes: [], + runtimeEffects: [{ operation: "start", stackId: "stack-feat-default" }], + output: { api: { outcome: "reuse", contextId: "context-feat", stackId: "stack-feat-default" } }, + }, +}); + +const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ + { + id: "identity.same-checkout-branch-and-name-reuses-stack", + title: "The same checkout, branch, and stack name resolve the same stack", + area: "identity", + given: [ + { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, + { kind: "branch", name: "main", contextId: "context-main", checkedOut: true }, + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + contextId: "context-main", + lifecycle: "running", + }, + ], + when: { + interface: "managed-api", + method: "resolveStack", + input: { cwd: "checkout-a", stackName: "default", operation: "status" }, + }, + expected: { + outcome: "reuse", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-main", + stackId: "stack-main-default", + stackName: "default", + }, + writes: [], + runtimeEffects: [], + output: { + api: { + outcome: "reuse", + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-main", + stackId: "stack-main-default", + stackName: "default", + }, + }, + }, + }, + { + id: "identity.branch-create-and-switch-is-no-op", + title: "Creating and switching Git branches alone does not touch managed state", + area: "identity", + given: [ + { kind: "workspace", mode: "git", path: "checkout-a" }, + { + kind: "git-state", + commonDirectory: "repo/.git", + gitDirectory: "repo/.git", + head: "branch", + branch: "main", + commit: "commit-a", + }, + ], + when: { + interface: "git", + argv: ["switch", "-c", "feat-a"], + cwd: "checkout-a", + }, + expected: { + outcome: "no-op", + writes: [], + runtimeEffects: [], + details: { managed_command_ran: false }, + output: { + human: { summary: "Switched to a new branch 'feat-a'", fields: {} }, + }, + }, + }, + { + id: "identity.new-branch-first-start-creates-stack", + title: "First start on a new branch creates an independent context and stack", + area: "identity", + given: [ + { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, + { kind: "branch", name: "main", contextId: "context-main", checkedOut: false }, + { + kind: "git-state", + commonDirectory: "repo/.git", + gitDirectory: "repo/.git", + head: "branch", + branch: "feat-a", + commit: "commit-a", + }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "create", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-feat-a", + stackId: "stack-feat-a-default", + stackName: "default", + }, + writes: [ + { target: "git-config", operation: "create", id: "context-feat-a" }, + { target: "registry", operation: "publish", id: "stack-feat-a-default" }, + { target: "managed-state", operation: "create", id: "stack-feat-a-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-feat-a-default" }], + output: { + human: { + summary: "Created feat-a/default", + fields: { branch: "feat-a", stack: "default", stackId: "stack-feat-a-default" }, + }, + json: { + outcome: "create", + context_id: "context-feat-a", + stack_id: "stack-feat-a-default", + }, + }, + }, + }, + { + id: "identity.branch-rename-preserves-context", + title: "A standard branch rename preserves its context and stack", + area: "identity", + given: [ + { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, + { kind: "branch", name: "feat-a", contextId: "context-feat", checkedOut: true }, + { kind: "identity-transition", operation: "branch-rename", from: "feature", to: "feat-a" }, + { + kind: "stack", + name: "default", + stackId: "stack-feat-default", + contextId: "context-feat", + lifecycle: "stopped", + }, + ], + when: { + interface: "managed-api", + method: "resolveStack", + input: { cwd: "checkout-a", stackName: "default", operation: "start" }, + }, + expected: { + outcome: "reuse", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-feat", + stackId: "stack-feat-default", + stackName: "default", + }, + writes: [{ target: "git-config", operation: "update", id: "context-feat" }], + runtimeEffects: [{ operation: "start", stackId: "stack-feat-default" }], + output: { + api: { outcome: "reuse", contextId: "context-feat", stackId: "stack-feat-default" }, + }, + }, + }, + { + id: "identity.branch-delete-recreate-creates-context", + title: "Deleting and recreating a branch name creates a new context", + area: "identity", + given: [ + { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, + { + kind: "identity-transition", + operation: "branch-delete-recreate", + from: "feat-a", + to: "feat-a", + }, + { + kind: "identity-claim", + scope: "context", + id: "context-old", + owner: "feat-a", + status: "absent", + }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "create", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-new", + stackId: "stack-new-default", + stackName: "default", + }, + writes: [ + { target: "git-config", operation: "create", id: "context-new" }, + { target: "registry", operation: "publish", id: "stack-new-default" }, + { target: "managed-state", operation: "create", id: "stack-new-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-new-default" }], + details: { orphaned_context_id: "context-old" }, + output: { + json: { + outcome: "create", + context_id: "context-new", + stack_id: "stack-new-default", + orphaned_context_id: "context-old", + }, + }, + }, + }, + branchHistoryFixture("commit", "branch-commit"), + branchHistoryFixture("rebase", "branch-rebase"), + branchHistoryFixture("reset", "branch-reset"), + { + id: "identity.same-commit-different-branches-are-independent", + title: "Two branches at one commit retain independent contexts", + area: "identity", + given: [ + { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, + { kind: "branch", name: "main", contextId: "context-main", checkedOut: false }, + { kind: "branch", name: "feat-a", contextId: "context-feat", checkedOut: true }, + { + kind: "git-state", + commonDirectory: "repo/.git", + gitDirectory: "repo/.git", + head: "branch", + branch: "feat-a", + commit: "shared-commit", + }, + ], + when: { + interface: "managed-api", + method: "resolveStack", + input: { cwd: "checkout-a", stackName: "default", operation: "status" }, + }, + expected: { + outcome: "reuse", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-feat", + stackId: "stack-feat-default", + stackName: "default", + }, + writes: [], + runtimeEffects: [], + output: { + api: { + outcome: "reuse", + contextId: "context-feat", + stackId: "stack-feat-default", + otherContextId: "context-main", + }, + }, + }, + }, + { + id: "identity.manual-ref-replacement-orphans-context", + title: "Replacing a branch ref manually creates a new context and orphans the old one", + area: "identity", + given: [ + { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, + { + kind: "identity-transition", + operation: "ref-replacement", + from: "commit-a", + to: "commit-b", + }, + { + kind: "identity-claim", + scope: "context", + id: "context-old", + owner: "feat-a", + status: "absent", + }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "create", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-new", + stackId: "stack-new-default", + stackName: "default", + }, + writes: [ + { target: "git-config", operation: "create", id: "context-new" }, + { target: "registry", operation: "publish", id: "stack-new-default" }, + { target: "managed-state", operation: "create", id: "stack-new-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-new-default" }], + details: { orphaned_context_id: "context-old", adoption_required: true }, + output: { + json: { + outcome: "create", + context_id: "context-new", + stack_id: "stack-new-default", + orphaned_context_id: "context-old", + }, + }, + }, + }, + { + id: "identity.detached-commits-reuse-checkout-context", + title: "Different detached commits in one checkout reuse its detached context", + area: "identity", + given: [ + { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, + { kind: "branch", name: "(detached)", contextId: "context-detached", checkedOut: true }, + { + kind: "git-state", + commonDirectory: "repo/.git", + gitDirectory: "repo/.git", + head: "detached", + commit: "commit-b", + }, + { + kind: "stack", + name: "default", + stackId: "stack-detached-default", + contextId: "context-detached", + lifecycle: "stopped", + }, + ], + when: { + interface: "managed-api", + method: "resolveStack", + input: { cwd: "checkout-a", stackName: "default", operation: "start" }, + }, + expected: { + outcome: "reuse", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-detached", + stackId: "stack-detached-default", + stackName: "default", + }, + writes: [], + runtimeEffects: [{ operation: "start", stackId: "stack-detached-default" }], + output: { + api: { outcome: "reuse", contextId: "context-detached", stackId: "stack-detached-default" }, + }, + }, + }, + { + id: "identity.non-git-folder-reuses-workspace-context", + title: "A non-Git folder reuses one workspace-scoped context", + area: "identity", + given: [ + { + kind: "workspace", + mode: "ordinary-folder", + path: "project-a", + canonicalPath: "/work/project-a", + }, + { + kind: "checkout", + path: "/work/project-a", + projectId: "project-a", + checkoutId: "checkout-a", + }, + { kind: "branch", name: "(workspace)", contextId: "context-workspace", checkedOut: true }, + { + kind: "stack", + name: "default", + stackId: "stack-workspace-default", + contextId: "context-workspace", + lifecycle: "stopped", + }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "/work/project-a" }, + expected: { + outcome: "reuse", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-workspace", + stackId: "stack-workspace-default", + stackName: "default", + }, + writes: [], + runtimeEffects: [{ operation: "start", stackId: "stack-workspace-default" }], + output: { + json: { + outcome: "reuse", + context_id: "context-workspace", + stack_id: "stack-workspace-default", + }, + }, + }, + }, + { + id: "identity.linked-worktrees-share-project-not-checkout", + title: "Sibling linked worktrees share a project and use independent checkouts", + area: "identity", + given: [ + { kind: "workspace", mode: "linked-worktree", path: "worktree-a" }, + { kind: "workspace", mode: "linked-worktree", path: "worktree-b" }, + { kind: "checkout", path: "worktree-a", projectId: "project-a", checkoutId: "checkout-a" }, + { kind: "checkout", path: "worktree-b", projectId: "project-a", checkoutId: "checkout-b" }, + ], + when: { + interface: "managed-api", + method: "resolveStack", + input: { cwd: "worktree-b", stackName: "default", operation: "start" }, + }, + expected: { + outcome: "create", + selection: { + projectId: "project-a", + checkoutId: "checkout-b", + contextId: "context-b-main", + stackId: "stack-b-main-default", + stackName: "default", + }, + writes: [ + { target: "git-config", operation: "create", id: "context-b-main" }, + { target: "registry", operation: "publish", id: "stack-b-main-default" }, + { target: "managed-state", operation: "create", id: "stack-b-main-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-b-main-default" }], + output: { + api: { projectId: "project-a", checkoutId: "checkout-b", stackId: "stack-b-main-default" }, + }, + }, + }, + { + id: "identity.same-branch-in-two-worktrees-is-isolated", + title: "The same branch forced into two worktrees remains checkout-isolated", + area: "identity", + given: [ + { kind: "checkout", path: "worktree-a", projectId: "project-a", checkoutId: "checkout-a" }, + { kind: "checkout", path: "worktree-b", projectId: "project-a", checkoutId: "checkout-b" }, + { kind: "branch", name: "main", contextId: "context-a-main", checkedOut: true }, + { + kind: "identity-claim", + scope: "context", + id: "context-b-main", + owner: "checkout-b/main", + status: "exact", + }, + ], + when: { + interface: "managed-api", + method: "resolveStack", + input: { cwd: "worktree-b", stackName: "default", operation: "start" }, + }, + expected: { + outcome: "create", + selection: { + projectId: "project-a", + checkoutId: "checkout-b", + contextId: "context-b-main", + stackId: "stack-b-main-default", + stackName: "default", + }, + writes: [ + { target: "registry", operation: "publish", id: "stack-b-main-default" }, + { target: "managed-state", operation: "create", id: "stack-b-main-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-b-main-default" }], + output: { + api: { + checkoutId: "checkout-b", + contextId: "context-b-main", + stackId: "stack-b-main-default", + }, + }, + }, + }, + { + id: "identity.named-stacks-are-context-scoped", + title: "Named stacks are scoped inside the active branch context", + area: "identity", + given: [ + { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, + { kind: "branch", name: "feat-a", contextId: "context-feat", checkedOut: true }, + { + kind: "stack", + name: "default", + stackId: "stack-feat-default", + contextId: "context-feat", + lifecycle: "running", + }, + ], + when: { + interface: "cli", + argv: ["start", "--experimental", "--stack", "review"], + cwd: "checkout-a", + }, + expected: { + outcome: "create", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-feat", + stackId: "stack-feat-review", + stackName: "review", + }, + writes: [ + { target: "registry", operation: "publish", id: "stack-feat-review" }, + { target: "managed-state", operation: "create", id: "stack-feat-review" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-feat-review" }], + output: { + human: { + summary: "Created feat-a/review", + fields: { stack: "review", stackId: "stack-feat-review" }, + }, + json: { outcome: "create", context_id: "context-feat", stack_id: "stack-feat-review" }, + }, + }, + }, + { + id: "identity.moved-checkout-reuses-identity", + title: "Moving a checkout rebinds its existing identity", + area: "identity", + given: [ + { + kind: "workspace", + mode: "git", + path: "/new/project-a", + canonicalPath: "/new/project-a", + previousPath: "/old/project-a", + previousPathAccess: "missing", + }, + { + kind: "identity-transition", + operation: "checkout-move", + from: "/old/project-a", + to: "/new/project-a", + }, + { + kind: "identity-claim", + scope: "checkout", + id: "checkout-a", + path: "/old/project-a", + status: "exact", + }, + ], + when: { + interface: "cli", + argv: ["status", "--experimental", "--output", "json"], + cwd: "/new/project-a", + }, + expected: { + outcome: "reuse", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-main", + stackId: "stack-main-default", + stackName: "default", + }, + writes: [{ target: "registry", operation: "update", id: "checkout-a" }], + runtimeEffects: [], + output: { + json: { outcome: "reuse", checkout_id: "checkout-a", rebound_from: "/old/project-a" }, + }, + }, + }, + { + id: "identity.symlink-alias-reuses-checkout", + title: "A symlink alias resolves the canonical checkout identity", + area: "identity", + given: [ + { + kind: "workspace", + mode: "git", + path: "/alias/project-a", + canonicalPath: "/work/project-a", + }, + { + kind: "identity-transition", + operation: "symlink-alias", + from: "/work/project-a", + to: "/alias/project-a", + }, + { + kind: "identity-claim", + scope: "checkout", + id: "checkout-a", + path: "/work/project-a", + status: "exact", + }, + ], + when: { + interface: "cli", + argv: ["status", "--experimental", "--output", "json"], + cwd: "/alias/project-a", + }, + expected: { + outcome: "reuse", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-main", + stackId: "stack-main-default", + stackName: "default", + }, + writes: [], + runtimeEffects: [], + output: { + json: { outcome: "reuse", checkout_id: "checkout-a", canonical_path: "/work/project-a" }, + }, + }, + }, + { + id: "identity.copied-checkout-reports-duplicate-claim", + title: "A copied checkout reports a duplicate identity while its source exists", + area: "identity", + given: [ + { kind: "workspace", mode: "git", path: "/copy/project-a", copiedFrom: "/work/project-a" }, + { + kind: "identity-transition", + operation: "checkout-copy", + from: "/work/project-a", + to: "/copy/project-a", + }, + { + kind: "identity-claim", + scope: "checkout", + id: "checkout-a", + path: "/work/project-a", + status: "duplicate", + }, + ], + when: { + interface: "cli", + argv: ["status", "--experimental", "--output", "json"], + cwd: "/copy/project-a", + }, + expected: { + outcome: "error", + error: { + code: "duplicate_checkout_claim", + message: "Two live paths claim checkout-a", + recovery: [ + "Use the original checkout at /work/project-a", + "Remove .supabase/identity.json from the copy and run supabase start --experimental", + ], + }, + writes: [], + runtimeEffects: [], + output: { + json: { + outcome: "error", + code: "duplicate_checkout_claim", + checkout_id: "checkout-a", + paths: ["/copy/project-a", "/work/project-a"], + recovery: [ + "Use the original checkout at /work/project-a", + "Remove .supabase/identity.json from the copy and run supabase start --experimental", + ], + }, + }, + }, + }, + { + id: "identity.fresh-clone-creates-project-and-checkout", + title: "A fresh clone receives new project and checkout identities", + area: "identity", + given: [ + { kind: "workspace", mode: "git", path: "/clone/project-a", clonedFrom: "/work/project-a" }, + { + kind: "identity-transition", + operation: "clone", + from: "/work/project-a", + to: "/clone/project-a", + }, + { kind: "identity-claim", scope: "project", id: "project-a", status: "absent" }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "/clone/project-a" }, + expected: { + outcome: "create", + selection: { + projectId: "project-clone", + checkoutId: "checkout-clone", + contextId: "context-clone-main", + stackId: "stack-clone-main-default", + stackName: "default", + }, + writes: [ + { target: "identity-marker", operation: "create", id: "project-clone" }, + { target: "git-config", operation: "create", id: "checkout-clone" }, + { target: "registry", operation: "publish", id: "stack-clone-main-default" }, + { target: "managed-state", operation: "create", id: "stack-clone-main-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-clone-main-default" }], + output: { + json: { + outcome: "create", + project_id: "project-clone", + checkout_id: "checkout-clone", + context_id: "context-clone-main", + stack_id: "stack-clone-main-default", + }, + }, + }, + }, + { + id: "identity.missing-previous-path-rebinds-checkout", + title: "A missing previous checkout path is rebound automatically", + area: "identity", + given: [ + { + kind: "workspace", + mode: "git", + path: "/new/project-a", + previousPath: "/old/project-a", + previousPathAccess: "missing", + }, + { + kind: "identity-claim", + scope: "checkout", + id: "checkout-a", + path: "/old/project-a", + status: "exact", + }, + ], + when: { + interface: "managed-api", + method: "resolveStack", + input: { cwd: "/new/project-a", stackName: "default", operation: "start" }, + }, + expected: { + outcome: "reuse", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-main", + stackId: "stack-main-default", + stackName: "default", + }, + writes: [{ target: "registry", operation: "update", id: "checkout-a" }], + runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], + output: { api: { outcome: "reuse", checkoutId: "checkout-a", rebound: true } }, + }, + }, + { + id: "identity.inaccessible-previous-path-fails", + title: "An inaccessible previous path fails instead of guessing ownership", + area: "identity", + given: [ + { + kind: "workspace", + mode: "git", + path: "/new/project-a", + previousPath: "/mnt/project-a", + previousPathAccess: "inaccessible", + }, + { + kind: "identity-claim", + scope: "checkout", + id: "checkout-a", + path: "/mnt/project-a", + status: "ambiguous", + }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "/new/project-a" }, + expected: { + outcome: "error", + error: { + code: "checkout_path_inaccessible", + message: "Cannot verify whether /mnt/project-a still owns checkout-a", + recovery: [ + "Restore access to /mnt/project-a and retry", + "Explicitly adopt checkout-a for /new/project-a", + ], + }, + writes: [], + runtimeEffects: [], + output: { + human: { + summary: "Cannot safely rebind checkout-a", + fields: { previousPath: "/mnt/project-a", currentPath: "/new/project-a" }, + recovery: [ + "Restore access to /mnt/project-a and retry", + "Explicitly adopt checkout-a for /new/project-a", + ], + }, + json: { outcome: "error", code: "checkout_path_inaccessible", checkout_id: "checkout-a" }, + }, + }, + }, + { + id: "identity.concurrent-create-publishes-once", + title: "Concurrent creation publishes one stack without aliases", + area: "identity", + given: [ + { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, + { kind: "branch", name: "feat-a", contextId: "context-feat", checkedOut: true }, + { + kind: "concurrent-operation", + operation: "create-stack", + target: "context-feat/default", + contenders: 2, + }, + ], + when: { + interface: "managed-api", + method: "startConcurrently", + input: { cwd: "checkout-a", stackName: "default", contenders: 2 }, + }, + expected: { + outcome: "create", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-feat", + stackId: "stack-feat-default", + stackName: "default", + }, + writes: [ + { target: "registry", operation: "publish", id: "stack-feat-default" }, + { target: "managed-state", operation: "create", id: "stack-feat-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-feat-default" }], + details: { published_stack_count: 1, alias_count: 0, contender_results: ["create", "reuse"] }, + output: { + api: { + stackId: "stack-feat-default", + publishedStackCount: 1, + aliasCount: 0, + contenderResults: ["create", "reuse"], + }, + }, + }, + }, + { + id: "identity.invalid-stack-name-fails", + title: "Invalid stack names fail before registration", + area: "identity", + given: [{ kind: "stack-names", names: ["Feature_A", "-review", "review..two"] }], + when: { + interface: "cli", + argv: ["start", "--experimental", "--stack", "Feature_A"], + cwd: "checkout-a", + }, + expected: { + outcome: "error", + error: { + code: "invalid_stack_name", + message: "Feature_A is not a lowercase DNS-label name", + recovery: ["Use default or a lowercase DNS-label name such as feature-a"], + }, + writes: [], + runtimeEffects: [], + output: { + human: { + summary: "Invalid stack name: Feature_A", + fields: { stack: "Feature_A" }, + recovery: ["Use default or a lowercase DNS-label name such as feature-a"], + }, + json: { outcome: "error", code: "invalid_stack_name", stack_name: "Feature_A" }, + }, + }, + }, + { + id: "identity.valid-stack-names-resolve-deterministically", + title: "Default and lowercase DNS-label stack names resolve deterministically", + area: "identity", + given: [ + { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, + { kind: "branch", name: "feat-a", contextId: "context-feat", checkedOut: true }, + { kind: "stack-names", names: ["default", "review-42"] }, + ], + when: { + interface: "managed-api", + method: "resolveStackNames", + input: { cwd: "checkout-a", stackNames: ["default", "review-42"] }, + }, + expected: { + outcome: "report", + writes: [], + runtimeEffects: [], + details: { default: "stack-feat-default", "review-42": "stack-feat-review-42" }, + output: { + api: { + default: { contextId: "context-feat", stackId: "stack-feat-default" }, + "review-42": { contextId: "context-feat", stackId: "stack-feat-review-42" }, + }, + }, + }, + }, + { + id: "identity.read-only-unregistered-checkout-does-not-write", + title: "Read-only discovery of an unregistered checkout performs no writes", + area: "identity", + given: [ + { kind: "workspace", mode: "git", path: "checkout-new" }, + { kind: "identity-claim", scope: "checkout", id: "checkout-unregistered", status: "absent" }, + ], + when: { + interface: "cli", + argv: ["status", "--experimental", "--output", "json"], + cwd: "checkout-new", + }, + expected: { + outcome: "report", + writes: [], + runtimeEffects: [], + details: { registered: false, identity_marker_created: false }, + output: { json: { outcome: "report", registered: false, stacks: [] } }, + }, + }, + { + id: "identity.branch-copy-known-owner-creates-context-on-mutation", + title: "A copied branch with a known owner gets a new context on first mutation", + area: "identity", + given: [ + { + kind: "identity-transition", + operation: "branch-copy", + from: "main", + to: "feat-copy", + originalExists: true, + }, + { kind: "branch", name: "main", contextId: "context-main", checkedOut: false }, + { kind: "branch", name: "feat-copy", contextId: "context-main", checkedOut: true }, + { + kind: "identity-claim", + scope: "context", + id: "context-main", + owner: "main", + status: "exact", + }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "create", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-copy", + stackId: "stack-copy-default", + stackName: "default", + }, + writes: [ + { target: "git-config", operation: "update", id: "context-copy" }, + { target: "registry", operation: "publish", id: "stack-copy-default" }, + { target: "managed-state", operation: "create", id: "stack-copy-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-copy-default" }], + details: { original_context_id: "context-main", original_owner: "main" }, + output: { + json: { + outcome: "create", + branch: "feat-copy", + context_id: "context-copy", + original_context_id: "context-main", + stack_id: "stack-copy-default", + }, + }, + }, + }, + { + id: "identity.branch-copy-read-only-does-not-write", + title: "Read-only discovery reports a copied-branch conflict without resolving it", + area: "identity", + given: [ + { + kind: "identity-transition", + operation: "branch-copy", + from: "main", + to: "feat-copy", + originalExists: true, + }, + { kind: "branch", name: "main", contextId: "context-main", checkedOut: false }, + { kind: "branch", name: "feat-copy", contextId: "context-main", checkedOut: true }, + { + kind: "identity-claim", + scope: "context", + id: "context-main", + owner: "main", + status: "exact", + }, + ], + when: { + interface: "cli", + argv: ["status", "--experimental", "--output", "json"], + cwd: "checkout-a", + }, + expected: { + outcome: "error", + error: { + code: "copied_branch_context_conflict", + message: "feat-copy copied context-main from main", + recovery: [ + "Run supabase start --experimental to create an independent context for feat-copy", + ], + }, + writes: [], + runtimeEffects: [], + output: { + json: { + outcome: "error", + code: "copied_branch_context_conflict", + branch: "feat-copy", + owner: "main", + context_id: "context-main", + recovery: [ + "Run supabase start --experimental to create an independent context for feat-copy", + ], + }, + }, + }, + }, + { + id: "identity.original-gone-turns-copy-into-rename", + title: "A copied context is preserved as a rename when its original branch is gone", + area: "identity", + given: [ + { + kind: "identity-transition", + operation: "branch-copy", + from: "main", + to: "renamed", + originalExists: false, + }, + { kind: "branch", name: "renamed", contextId: "context-main", checkedOut: true }, + { + kind: "identity-claim", + scope: "context", + id: "context-main", + owner: "main", + status: "absent", + }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "reuse", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-main", + stackId: "stack-main-default", + stackName: "default", + }, + writes: [{ target: "git-config", operation: "update", id: "context-main" }], + runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], + output: { + json: { + outcome: "reuse", + branch: "renamed", + context_id: "context-main", + rename_detected: true, + }, + }, + }, + }, + { + id: "identity.fresh-clone-ignores-tracked-marker", + title: "A tracked non-Git identity marker is inert in a fresh Git clone", + area: "identity", + given: [ + { kind: "workspace", mode: "git", path: "/clone/project-a", clonedFrom: "/work/project-a" }, + { + kind: "git-state", + commonDirectory: "/clone/project-a/.git", + gitDirectory: "/clone/project-a/.git", + head: "branch", + branch: "main", + commit: "commit-a", + trackedIdentityMarker: true, + }, + { kind: "identity-claim", scope: "project", id: "project-from-marker", status: "absent" }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "/clone/project-a" }, + expected: { + outcome: "create", + selection: { + projectId: "project-clone", + checkoutId: "checkout-clone", + contextId: "context-clone-main", + stackId: "stack-clone-default", + stackName: "default", + }, + writes: [ + { target: "git-config", operation: "create", id: "checkout-clone" }, + { target: "registry", operation: "publish", id: "stack-clone-default" }, + { target: "managed-state", operation: "create", id: "stack-clone-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-clone-default" }], + details: { tracked_marker_ignored: true, git_index_mutated: false }, + output: { + json: { outcome: "create", project_id: "project-clone", tracked_marker_ignored: true }, + }, + }, + }, + { + id: "identity.folder-to-git-exact-claim-preserves-identity", + title: "Folder-to-Git conversion preserves one exact live path claim", + area: "identity", + given: [ + { + kind: "identity-transition", + operation: "folder-to-git", + from: "ordinary-folder", + to: "git", + }, + { kind: "workspace", mode: "git", path: "/work/project-a", canonicalPath: "/work/project-a" }, + { + kind: "identity-claim", + scope: "project", + id: "project-a", + path: "/work/project-a", + status: "exact", + }, + { + kind: "identity-claim", + scope: "checkout", + id: "checkout-a", + path: "/work/project-a", + status: "exact", + }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "/work/project-a" }, + expected: { + outcome: "reuse", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-main", + stackId: "stack-main-default", + stackName: "default", + }, + writes: [{ target: "git-config", operation: "create", id: "checkout-a" }], + runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], + details: { git_index_mutated: false }, + output: { + json: { + outcome: "reuse", + project_id: "project-a", + checkout_id: "checkout-a", + converted_to_git: true, + }, + }, + }, + }, + { + id: "identity.folder-to-git-without-claim-creates-git-identity", + title: "Folder-to-Git conversion without a live claim creates Git-owned identities", + area: "identity", + given: [ + { + kind: "identity-transition", + operation: "folder-to-git", + from: "ordinary-folder", + to: "git", + }, + { kind: "workspace", mode: "git", path: "/work/project-a", canonicalPath: "/work/project-a" }, + { kind: "identity-claim", scope: "project", id: "project-folder", status: "absent" }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "/work/project-a" }, + expected: { + outcome: "create", + selection: { + projectId: "project-git", + checkoutId: "checkout-git", + contextId: "context-git-main", + stackId: "stack-git-default", + stackName: "default", + }, + writes: [ + { target: "git-config", operation: "create", id: "checkout-git" }, + { target: "registry", operation: "publish", id: "stack-git-default" }, + { target: "managed-state", operation: "create", id: "stack-git-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-git-default" }], + details: { git_index_mutated: false }, + output: { + json: { outcome: "create", project_id: "project-git", checkout_id: "checkout-git" }, + }, + }, + }, + { + id: "identity.folder-to-git-ambiguous-claim-fails", + title: "Folder-to-Git conversion fails on ambiguous live identity claims", + area: "identity", + given: [ + { + kind: "identity-transition", + operation: "folder-to-git", + from: "ordinary-folder", + to: "git", + }, + { kind: "workspace", mode: "git", path: "/work/project-a", canonicalPath: "/work/project-a" }, + { + kind: "identity-claim", + scope: "project", + id: "project-folder", + path: "/work/project-a", + status: "ambiguous", + }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "/work/project-a" }, + expected: { + outcome: "error", + error: { + code: "ambiguous_folder_to_git_identity", + message: "Multiple live claims can preserve the folder identity", + recovery: [ + "Inspect the claims and explicitly adopt one identity or create a fresh Git identity", + ], + }, + writes: [], + runtimeEffects: [], + details: { git_index_mutated: false }, + output: { + json: { + outcome: "error", + code: "ambiguous_folder_to_git_identity", + recovery: [ + "Inspect the claims and explicitly adopt one identity or create a fresh Git identity", + ], + }, + }, + }, + }, + { + id: "identity.bare-repository-linked-worktrees-share-project", + title: "Bare-repository worktrees share common project identity without a primary worktree", + area: "identity", + given: [ + { kind: "workspace", mode: "bare-worktree", path: "worktree-a" }, + { kind: "workspace", mode: "bare-worktree", path: "worktree-b" }, + { + kind: "git-state", + commonDirectory: "repo.git", + gitDirectory: "repo.git/worktrees/worktree-a", + head: "branch", + branch: "main", + commit: "commit-a", + }, + { kind: "checkout", path: "worktree-a", projectId: "project-bare", checkoutId: "checkout-a" }, + { kind: "checkout", path: "worktree-b", projectId: "project-bare", checkoutId: "checkout-b" }, + ], + when: { + interface: "managed-api", + method: "resolveStack", + input: { cwd: "worktree-b", stackName: "default", operation: "start" }, + }, + expected: { + outcome: "create", + selection: { + projectId: "project-bare", + checkoutId: "checkout-b", + contextId: "context-b-main", + stackId: "stack-b-main-default", + stackName: "default", + }, + writes: [ + { target: "git-config", operation: "create", id: "checkout-b" }, + { target: "registry", operation: "publish", id: "stack-b-main-default" }, + { target: "managed-state", operation: "create", id: "stack-b-main-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-b-main-default" }], + details: { + project_marker_location: "repo.git", + checkout_marker_location: "repo.git/worktrees/worktree-b", + }, + output: { + api: { + projectId: "project-bare", + checkoutId: "checkout-b", + primaryWorktreeRequired: false, + }, + }, + }, + }, +]); + +const additionalPortContractFixtures = defineManagedStackContractFixtures([ + { + id: "ports.exact-default-value-differs-from-omitted-default", + title: "A present default port is exact while the same omitted default is automatic", + area: "ports", + given: [ + { kind: "config-port", key: "api.port", intent: "exact", value: 54321, source: "local" }, + { kind: "config-port", key: "db.port", intent: "automatic", source: "omitted" }, + ], + when: { + interface: "managed-api", + method: "resolvePortIntents", + input: { + config: { "api.port": 54321 }, + decodedDefaults: { "api.port": 54321, "db.port": 54322 }, + }, + }, + expected: { + outcome: "report", + writes: [], + runtimeEffects: [], + output: { + api: { + "api.port": { intent: "exact", port: 54321, source: "local" }, + "db.port": { intent: "automatic", source: "omitted" }, + }, + }, + }, + }, + { + id: "ports.env-and-remote-values-remain-exact", + title: "Environment-backed and selected remote ports remain exact after resolution", + area: "ports", + given: [ + { + kind: "config-port", + key: "api.port", + intent: "exact", + value: 55321, + source: "environment", + }, + { kind: "config-port", key: "db.port", intent: "exact", value: 55322, source: "remote" }, + ], + when: { + interface: "managed-api", + method: "resolvePortIntents", + input: { effectiveConfig: { "api.port": 55321, "db.port": 55322 } }, + }, + expected: { + outcome: "report", + writes: [], + runtimeEffects: [], + output: { + api: { + "api.port": { intent: "exact", port: 55321, source: "environment" }, + "db.port": { intent: "exact", port: 55322, source: "remote" }, + }, + }, + }, + }, + { + id: "ports.explicit-free-port-is-used", + title: "A free declarative port is used exactly", + area: "ports", + given: [ + { kind: "config-port", key: "api.port", intent: "exact", value: 54321, source: "local" }, + ], + when: { + interface: "managed-api", + method: "startStack", + input: { + stackId: "stack-main-default", + portIntents: { "api.port": { intent: "exact", port: 54321 } }, + }, + }, + expected: { + outcome: "update", + writes: [ + { target: "managed-state", operation: "update", id: "stack-main-default" }, + { target: "runtime-state", operation: "start", id: "stack-main-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], + output: { + api: { + outcome: "update", + stackId: "stack-main-default", + ports: { api: 54321 }, + intent: "exact", + }, + }, + }, + }, + { + id: "ports.new-target-allocates-and-persists-omitted-ports", + title: "A new target allocates and persists host-wide ports for omitted keys", + area: "ports", + given: [ + { kind: "managed-target", stackId: "stack-feat-default", exists: false }, + { kind: "config-port", key: "api.port", intent: "automatic", source: "omitted" }, + { kind: "config-port", key: "db.port", intent: "automatic", source: "omitted" }, + ], + when: { + interface: "managed-api", + method: "startStack", + input: { + stackId: "stack-feat-default", + portIntents: { "api.port": "automatic", "db.port": "automatic" }, + }, + }, + expected: { + outcome: "create", + writes: [ + { target: "managed-state", operation: "create", id: "stack-feat-default" }, + { target: "registry", operation: "update", id: "stack-feat-default" }, + { target: "runtime-state", operation: "start", id: "stack-feat-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-feat-default" }], + details: { host_wide: true, sticky: true }, + output: { + api: { + outcome: "create", + stackId: "stack-feat-default", + ports: { api: 55421, db: 55422 }, + intents: { api: "automatic", db: "automatic" }, + }, + }, + }, + }, + { + id: "ports.sibling-targets-allocate-independent-ports", + title: "Sibling branches, worktrees, and named stacks allocate independent automatic ports", + area: "ports", + given: [ + { + kind: "port-assignment", + stackId: "stack-main-default", + key: "api.port", + port: 55421, + intent: "automatic", + }, + { + kind: "port-assignment", + stackId: "stack-feat-default", + key: "api.port", + port: 55422, + intent: "automatic", + }, + { + kind: "port-assignment", + stackId: "stack-worktree-default", + key: "api.port", + port: 55423, + intent: "automatic", + }, + { + kind: "port-assignment", + stackId: "stack-main-review", + key: "api.port", + port: 55424, + intent: "automatic", + }, + ], + when: { interface: "managed-api", method: "listStackPorts", input: { projectId: "project-a" } }, + expected: { + outcome: "report", + writes: [], + runtimeEffects: [], + output: { + api: { + "stack-main-default": { api: 55421 }, + "stack-feat-default": { api: 55422 }, + "stack-worktree-default": { api: 55423 }, + "stack-main-review": { api: 55424 }, + }, + }, + }, + }, + { + id: "ports.sticky-ports-reuse-on-return", + title: "Returning to an existing target reuses its sticky automatic ports", + area: "ports", + given: [ + { + kind: "stack", + name: "default", + stackId: "stack-feat-default", + contextId: "context-feat", + lifecycle: "stopped", + }, + { kind: "config-port", key: "api.port", intent: "automatic", source: "omitted" }, + { + kind: "port-assignment", + stackId: "stack-feat-default", + key: "api.port", + port: 55421, + intent: "automatic", + }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "reuse", + writes: [{ target: "runtime-state", operation: "start", id: "stack-feat-default" }], + runtimeEffects: [{ operation: "start", stackId: "stack-feat-default" }], + output: { + human: { summary: "Started feat-a/default", fields: { apiUrl: "http://127.0.0.1:55421" } }, + json: { + outcome: "reuse", + stack_id: "stack-feat-default", + ports: { api: 55421 }, + sticky: true, + }, + }, + }, + }, + { + id: "ports.later-sticky-port-collision-fails", + title: "A later collision on a sticky automatic port fails without relocation", + area: "ports", + given: [ + { + kind: "port-assignment", + stackId: "stack-feat-default", + key: "api.port", + port: 55421, + intent: "automatic", + }, + { kind: "occupied-port", port: 55421, owner: "external-process" }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "error", + error: { + code: "sticky_port_occupied", + message: "stack-feat-default owns sticky api.port 55421, but it is in use", + recovery: [ + "Stop the process using port 55421", + "Delete and recreate the stack to allocate new automatic ports", + ], + }, + writes: [], + runtimeEffects: [], + output: { + json: { + outcome: "error", + code: "sticky_port_occupied", + stack_id: "stack-feat-default", + port: 55421, + config_key: "api.port", + relocated: false, + }, + }, + }, + }, + { + id: "ports.config-change-on-stopped-stack-applies", + title: "Changing an exact port on a stopped stack applies on next start", + area: "ports", + given: [ + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + contextId: "context-main", + lifecycle: "stopped", + }, + { + kind: "config-port", + key: "api.port", + intent: "exact", + value: 55321, + previousValue: 54321, + source: "local", + }, + { + kind: "port-assignment", + stackId: "stack-main-default", + key: "api.port", + port: 54321, + intent: "exact", + }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "update", + writes: [ + { target: "managed-state", operation: "update", id: "stack-main-default" }, + { target: "runtime-state", operation: "start", id: "stack-main-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], + output: { + human: { summary: "Started main/default", fields: { apiUrl: "http://127.0.0.1:55321" } }, + json: { + outcome: "update", + stack_id: "stack-main-default", + previous_port: 54321, + port: 55321, + }, + }, + }, + }, + { + id: "ports.config-change-on-running-stack-reports-drift", + title: "Changing an exact port on a running stack reports drift", + area: "ports", + given: [ + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + contextId: "context-main", + lifecycle: "running", + }, + { + kind: "config-port", + key: "api.port", + intent: "exact", + value: 55321, + previousValue: 54321, + source: "local", + }, + { + kind: "port-assignment", + stackId: "stack-main-default", + key: "api.port", + port: 54321, + intent: "exact", + }, + ], + when: { + interface: "cli", + argv: ["status", "--experimental", "--output", "json"], + cwd: "checkout-a", + }, + expected: { + outcome: "error", + error: { + code: "running_stack_config_drift", + message: "api.port is running on 54321 but config requires 55321", + recovery: ["Run supabase stop --experimental, then supabase start --experimental"], + }, + writes: [], + runtimeEffects: [], + output: { + json: { + outcome: "error", + code: "running_stack_config_drift", + stack_id: "stack-main-default", + config_key: "api.port", + running_port: 54321, + requested_port: 55321, + }, + }, + }, + }, + { + id: "ports.removing-exact-key-keeps-current-port-sticky", + title: "Removing an exact key keeps the current port as sticky automatic state", + area: "ports", + given: [ + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + contextId: "context-main", + lifecycle: "stopped", + }, + { + kind: "config-port", + key: "api.port", + intent: "automatic", + previousValue: 54321, + source: "omitted", + }, + { + kind: "port-assignment", + stackId: "stack-main-default", + key: "api.port", + port: 54321, + intent: "exact", + }, + ], + when: { + interface: "managed-api", + method: "startStack", + input: { stackId: "stack-main-default", portIntents: { "api.port": "automatic" } }, + }, + expected: { + outcome: "update", + writes: [ + { target: "managed-state", operation: "update", id: "stack-main-default" }, + { target: "runtime-state", operation: "start", id: "stack-main-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], + details: { sibling_allocation_independent: true }, + output: { + api: { + stackId: "stack-main-default", + ports: { api: 54321 }, + intents: { api: "automatic" }, + sticky: true, + }, + }, + }, + }, + { + id: "ports.running-legacy-source-fails-before-allocation", + title: "A running legacy source fails before bootstrap or port allocation", + area: "ports", + given: [ + { + kind: "legacy-state", + lifecycle: "running", + database: "compatible", + storage: "compatible", + credentials: "compatible", + }, + { kind: "occupied-port", port: 54321, owner: "legacy-stack", ownerId: "legacy-project-a" }, + { kind: "config-port", key: "api.port", intent: "exact", value: 54321, source: "local" }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "error", + error: { + code: "legacy_source_running", + message: "The matching legacy stack is still running on api.port 54321", + recovery: ["Stop the legacy stack, then retry supabase start --experimental"], + }, + writes: [], + runtimeEffects: [], + details: { allocation_attempted: false, legacy_source_stopped: false }, + output: { + json: { + outcome: "error", + code: "legacy_source_running", + port: 54321, + config_key: "api.port", + allocation_attempted: false, + }, + }, + }, + }, +]); + +const nativeServiceNames = managedNativeServiceMatrix.services.map(([service]) => service); + +const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ + { + id: "runtime.explicit-api-overrides-auto", + title: "An explicit managed-API runtime overrides the default automatic selection", + area: "runtime", + given: [ + { kind: "runtime-request", source: "managed-api", runtime: "native" }, + { kind: "runtime-request", source: "default", runtime: "auto" }, + { kind: "runtime-availability", runtime: "native", available: true }, + ], + when: { + interface: "managed-api", + method: "startStack", + input: { stackId: "stack-main-default", runtime: "native" }, + }, + expected: { + outcome: "create", + writes: [ + { target: "managed-state", operation: "update", id: "stack-main-default" }, + { target: "runtime-state", operation: "start", id: "stack-main-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], + details: { resolved_runtime: "native", source: "managed-api", stack_wide: true }, + output: { + api: { stackId: "stack-main-default", runtime: "native", runtimeSource: "managed-api" }, + }, + }, + }, + { + id: "runtime.config-overrides-default-auto", + title: "A config runtime overrides automatic selection when no explicit override exists", + area: "runtime", + given: [ + { kind: "runtime-request", source: "config", runtime: "native" }, + { kind: "runtime-request", source: "default", runtime: "auto" }, + { kind: "runtime-availability", runtime: "native", available: true }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "create", + writes: [ + { target: "managed-state", operation: "update", id: "stack-main-default" }, + { target: "runtime-state", operation: "start", id: "stack-main-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], + details: { resolved_runtime: "native", source: "config" }, + output: { + human: { + summary: "Started main/default with native runtime", + fields: { runtime: "native" }, + }, + json: { + outcome: "create", + stack_id: "stack-main-default", + runtime: "native", + runtime_source: "config", + }, + }, + }, + }, + { + id: "runtime.explicit-and-config-conflict-fails", + title: "Conflicting explicit and config runtimes fail before services start", + area: "runtime", + given: [ + { kind: "runtime-request", source: "cli", runtime: "docker" }, + { kind: "runtime-request", source: "config", runtime: "native" }, + ], + when: { + interface: "cli", + argv: ["start", "--experimental", "--runtime", "docker"], + cwd: "checkout-a", + }, + expected: { + outcome: "error", + error: { + code: "runtime_selection_conflict", + message: "CLI requests docker while config.toml requests native", + recovery: ["Remove one runtime override", "Make the CLI and config runtime values agree"], + }, + writes: [], + runtimeEffects: [], + output: { + json: { + outcome: "error", + code: "runtime_selection_conflict", + cli_runtime: "docker", + config_runtime: "native", + recovery: ["Remove one runtime override", "Make the CLI and config runtime values agree"], + }, + }, + }, + }, + { + id: "runtime.auto-prefers-docker", + title: "Automatic selection prefers usable Docker", + area: "runtime", + given: [ + { kind: "runtime-request", source: "default", runtime: "auto" }, + { kind: "runtime-availability", runtime: "docker", available: true }, + { kind: "runtime-availability", runtime: "native", available: true }, + ], + when: { + interface: "managed-api", + method: "startStack", + input: { stackId: "stack-main-default", runtime: "auto" }, + }, + expected: { + outcome: "create", + writes: [ + { target: "managed-state", operation: "update", id: "stack-main-default" }, + { target: "runtime-state", operation: "start", id: "stack-main-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], + details: { resolved_runtime: "docker", persisted: true }, + output: { api: { stackId: "stack-main-default", runtime: "docker", runtimeSource: "auto" } }, + }, + }, + { + id: "runtime.auto-selects-fully-qualified-native", + title: + "Automatic selection uses native only when Docker is unusable and the full graph qualifies", + area: "runtime", + given: [ + { kind: "runtime-request", source: "default", runtime: "auto" }, + { + kind: "runtime-availability", + runtime: "docker", + available: false, + reason: "daemon unavailable", + }, + { kind: "runtime-availability", runtime: "native", available: true }, + { + kind: "native-qualification", + platform: "darwin-arm64", + qualifiedServices: nativeServiceNames, + failedServices: [], + }, + ], + when: { + interface: "managed-api", + method: "startStack", + input: { stackId: "stack-main-default", runtime: "auto" }, + }, + expected: { + outcome: "create", + writes: [ + { target: "managed-state", operation: "update", id: "stack-main-default" }, + { target: "runtime-state", operation: "start", id: "stack-main-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], + details: { resolved_runtime: "native", qualified_service_count: 13, mixed_runtime: false }, + output: { + api: { stackId: "stack-main-default", runtime: "native", qualifiedServiceCount: 13 }, + }, + }, + }, + { + id: "runtime.auto-fails-when-neither-runtime-is-available", + title: "Automatic selection reports both availability failures", + area: "runtime", + given: [ + { kind: "runtime-request", source: "default", runtime: "auto" }, + { + kind: "runtime-availability", + runtime: "docker", + available: false, + reason: "daemon unavailable", + }, + { + kind: "runtime-availability", + runtime: "native", + available: false, + reason: "platform graph not qualified", + }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "error", + error: { + code: "no_runtime_available", + message: "Neither Docker nor native can run this stack", + recovery: [ + "Start or install Docker", + "Use a platform with a fully qualified native service graph", + ], + }, + writes: [], + runtimeEffects: [], + output: { + human: { + summary: "No runtime is available", + fields: { docker: "daemon unavailable", native: "platform graph not qualified" }, + recovery: [ + "Start or install Docker", + "Use a platform with a fully qualified native service graph", + ], + }, + json: { + outcome: "error", + code: "no_runtime_available", + docker_reason: "daemon unavailable", + native_reason: "platform graph not qualified", + }, + }, + }, + }, + { + id: "runtime.explicit-runtime-is-strict", + title: "An explicit runtime fails strictly when its prerequisite is missing", + area: "runtime", + given: [ + { kind: "runtime-request", source: "cli", runtime: "docker" }, + { + kind: "runtime-availability", + runtime: "docker", + available: false, + reason: "daemon unavailable", + }, + { kind: "runtime-availability", runtime: "native", available: true }, + ], + when: { + interface: "cli", + argv: ["start", "--experimental", "--runtime", "docker"], + cwd: "checkout-a", + }, + expected: { + outcome: "error", + error: { + code: "docker_unavailable", + message: "Docker was explicitly requested but its daemon is unavailable", + recovery: ["Start Docker", "Remove --runtime docker to use automatic selection"], + }, + writes: [], + runtimeEffects: [], + details: { fallback_attempted: false }, + output: { + json: { + outcome: "error", + code: "docker_unavailable", + requested_runtime: "docker", + fallback_attempted: false, + }, + }, + }, + }, + { + id: "runtime.persisted-runtime-reused-for-auto", + title: "An existing stack reuses its persisted runtime for omitted or automatic selection", + area: "runtime", + given: [ + { kind: "persisted-runtime", stackId: "stack-main-default", runtime: "native" }, + { kind: "runtime-request", source: "default", runtime: "auto" }, + { kind: "runtime-availability", runtime: "docker", available: true }, + { kind: "runtime-availability", runtime: "native", available: true }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "reuse", + writes: [{ target: "runtime-state", operation: "start", id: "stack-main-default" }], + runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], + details: { runtime: "native", auto_re_evaluated: false }, + output: { + json: { + outcome: "reuse", + stack_id: "stack-main-default", + runtime: "native", + persisted: true, + }, + }, + }, + }, + { + id: "runtime.missing-persisted-prerequisite-fails", + title: "A missing prerequisite for the persisted runtime fails without switching", + area: "runtime", + given: [ + { kind: "persisted-runtime", stackId: "stack-main-default", runtime: "native" }, + { kind: "runtime-request", source: "default", runtime: "auto" }, + { + kind: "runtime-availability", + runtime: "native", + available: false, + reason: "artifact missing", + }, + { kind: "runtime-availability", runtime: "docker", available: true }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "error", + error: { + code: "persisted_runtime_unavailable", + message: "stack-main-default uses native, but a required artifact is missing", + recovery: [ + "Restore the native prerequisite", + "Create a new Docker named stack", + "Delete and recreate this stack", + ], + }, + writes: [], + runtimeEffects: [], + details: { switched_to_docker: false }, + output: { + json: { + outcome: "error", + code: "persisted_runtime_unavailable", + stack_id: "stack-main-default", + runtime: "native", + reason: "artifact missing", + }, + }, + }, + }, + { + id: "runtime.status-reports-one-stack-wide-runtime", + title: "Status reports one persisted stack-wide runtime and any drift", + area: "runtime", + given: [ + { kind: "persisted-runtime", stackId: "stack-main-default", runtime: "docker" }, + { kind: "runtime-request", source: "config", runtime: "native" }, + ], + when: { + interface: "cli", + argv: ["status", "--experimental", "--output", "json"], + cwd: "checkout-a", + }, + expected: { + outcome: "report", + writes: [], + runtimeEffects: [], + details: { mixed_runtime: false }, + output: { + human: { + summary: "main/default is running with Docker", + fields: { runtime: "docker", configuredRuntime: "native", drift: "true" }, + }, + json: { + outcome: "report", + stack_id: "stack-main-default", + runtime: "docker", + configured_runtime: "native", + drift: true, + services: { runtime: "docker" }, + }, + }, + }, + }, + { + id: "native-qualification.all-services-qualify-platform", + title: "A platform is native-supported only when all 13 services qualify", + area: "native-qualification", + given: [ + { + kind: "native-qualification", + platform: "darwin-arm64", + qualifiedServices: nativeServiceNames, + failedServices: [], + }, + ], + when: { + interface: "managed-api", + method: "preflightNative", + input: { platform: "darwin-arm64" }, + }, + expected: { + outcome: "report", + writes: [], + runtimeEffects: [], + details: { qualified: true, qualified_service_count: 13 }, + output: { api: { platform: "darwin-arm64", qualified: true, services: nativeServiceNames } }, + }, + }, + { + id: "native-qualification.one-service-failure-disables-platform", + title: "One failed service disables native mode for the whole platform", + area: "native-qualification", + given: [ + { + kind: "native-qualification", + platform: "linux-amd64", + qualifiedServices: nativeServiceNames.filter((service) => service !== "imgproxy"), + failedServices: ["imgproxy"], + }, + ], + when: { + interface: "managed-api", + method: "preflightNative", + input: { platform: "linux-amd64" }, + }, + expected: { + outcome: "error", + error: { + code: "native_platform_not_qualified", + message: "linux-amd64 is missing qualification for imgproxy", + recovery: ["Use Docker", "Complete imgproxy qualification for linux-amd64"], + }, + writes: [], + runtimeEffects: [], + details: { reduced_graph: false, docker_fallback_per_service: false }, + output: { + api: { + platform: "linux-amd64", + qualified: false, + failedServices: ["imgproxy"], + availableServices: [], + }, + }, + }, + }, + { + id: "native-qualification.unsupported-platform-fails-preflight", + title: "An unsupported native platform fails deterministic preflight", + area: "native-qualification", + given: [ + { + kind: "native-qualification", + platform: "darwin-x64", + qualifiedServices: [], + failedServices: nativeServiceNames, + }, + ], + when: { + interface: "cli", + argv: ["start", "--experimental", "--runtime", "native"], + cwd: "checkout-a", + }, + expected: { + outcome: "error", + error: { + code: "native_platform_unsupported", + message: "Native mode is not qualified on darwin-x64", + recovery: ["Use Docker", "Use darwin-arm64, linux-amd64, or linux-arm64"], + }, + writes: [], + runtimeEffects: [], + output: { + json: { + outcome: "error", + code: "native_platform_unsupported", + platform: "darwin-x64", + supported_platforms: ["darwin-arm64", "linux-amd64", "linux-arm64"], + }, + }, + }, + }, +]); + +const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ + { + id: "bootstrap.existing-managed-target-ignores-legacy", + title: "An existing managed target starts without reading legacy state", + area: "bootstrap", + given: [ + { kind: "managed-target", stackId: "stack-main-default", exists: true }, + { + kind: "legacy-state", + lifecycle: "running", + database: "compatible", + storage: "compatible", + credentials: "compatible", + }, + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + contextId: "context-main", + lifecycle: "stopped", + }, + ], + when: { + interface: "managed-api", + method: "startStack", + input: { stackId: "stack-main-default" }, + }, + expected: { + outcome: "reuse", + writes: [{ target: "runtime-state", operation: "start", id: "stack-main-default" }], + runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], + details: { legacy_state_read: false, legacy_state_mutated: false }, + output: { + api: { stackId: "stack-main-default", bootstrap: "not-attempted", legacyStateRead: false }, + }, + }, + }, + { + id: "bootstrap.incompatible-or-absent-legacy-starts-fresh", + title: "A first start without compatible legacy state creates a fresh managed target", + area: "bootstrap", + given: [ + { kind: "managed-target", stackId: "stack-main-default", exists: false }, + { + kind: "legacy-state", + lifecycle: "stopped", + database: "incompatible", + storage: "absent", + credentials: "absent", + }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "create", + writes: [ + { target: "managed-state", operation: "create", id: "stack-main-default" }, + { target: "registry", operation: "publish", id: "stack-main-default" }, + { target: "runtime-state", operation: "start", id: "stack-main-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], + details: { bootstrap: "fresh", legacy_state_mutated: false }, + output: { + human: { summary: "Created a fresh main/default stack", fields: { bootstrap: "fresh" } }, + json: { + outcome: "create", + stack_id: "stack-main-default", + bootstrap: "fresh", + legacy_state_mutated: false, + }, + }, + }, + }, + { + id: "bootstrap.running-legacy-source-fails-without-mutation", + title: "A running legacy source fails without stopping, copying, or publishing", + area: "bootstrap", + given: [ + { kind: "managed-target", stackId: "stack-main-default", exists: false }, + { + kind: "legacy-state", + lifecycle: "running", + database: "compatible", + storage: "compatible", + credentials: "compatible", + }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "error", + error: { + code: "legacy_source_running", + message: "The legacy stack must be stopped before it can be copied", + recovery: ["Stop the legacy stack", "Retry supabase start --experimental"], + }, + writes: [], + runtimeEffects: [], + details: { + legacy_source_stopped: false, + managed_target_published: false, + partial_state: false, + }, + output: { + json: { + outcome: "error", + code: "legacy_source_running", + legacy_source_stopped: false, + managed_target_published: false, + }, + }, + }, + }, + { + id: "bootstrap.failed-copy-rolls-back-and-retries", + title: "A failed bootstrap leaves no active target and the same start retries safely", + area: "bootstrap", + given: [ + { kind: "managed-target", stackId: "stack-main-default", exists: false }, + { + kind: "legacy-state", + lifecycle: "stopped", + database: "compatible", + storage: "compatible", + credentials: "compatible", + }, + ], + when: { + interface: "managed-api", + method: "startStack", + input: { stackId: "stack-main-default", injectCopyFailure: true }, + }, + expected: { + outcome: "error", + error: { + code: "legacy_bootstrap_failed", + message: "Copying compatible legacy state failed before publication", + recovery: ["Retry the same start command after correcting the copy failure"], + }, + writes: [{ target: "managed-state", operation: "delete", id: "stack-main-default" }], + runtimeEffects: [{ operation: "delete", stackId: "stack-main-default" }], + details: { + active_target_exists: false, + registry_record_published: false, + legacy_state_mutated: false, + retry_is_same_command: true, + }, + output: { + api: { + outcome: "error", + code: "legacy_bootstrap_failed", + activeTargetExists: false, + registryRecordPublished: false, + retryable: true, + }, + }, + }, + }, + { + id: "bootstrap.managed-and-legacy-diverge-after-copy", + title: "Managed starts never reread legacy state after a successful bootstrap", + area: "bootstrap", + given: [ + { kind: "managed-target", stackId: "stack-main-default", exists: true }, + { + kind: "legacy-state", + lifecycle: "stopped", + database: "compatible", + storage: "incompatible", + credentials: "incompatible", + }, + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + contextId: "context-main", + lifecycle: "stopped", + }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "reuse", + writes: [{ target: "runtime-state", operation: "start", id: "stack-main-default" }], + runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], + details: { legacy_state_read: false, legacy_state_mutated: false, timelines_diverged: true }, + output: { + json: { + outcome: "reuse", + stack_id: "stack-main-default", + bootstrap: "not-attempted", + timelines_diverged: true, + }, + }, + }, + }, + { + id: "credentials.configured-values-are-authoritative", + title: "Configured auth values are authoritative for a new managed stack", + area: "credentials", + given: [{ kind: "credential-state", source: "configured", valuesId: "configured-auth-v1" }], + when: { + interface: "managed-api", + method: "startStack", + input: { stackId: "stack-main-default", auth: "configured-auth-v1" }, + }, + expected: { + outcome: "create", + writes: [ + { target: "managed-state", operation: "create", id: "stack-main-default" }, + { target: "runtime-state", operation: "start", id: "stack-main-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], + details: { credential_values_id: "configured-auth-v1", source: "configured" }, + output: { + api: { + stackId: "stack-main-default", + credentialsSource: "configured", + credentialsValuesId: "configured-auth-v1", + }, + }, + }, + }, + { + id: "credentials.omitted-values-use-stable-defaults", + title: "Omitted auth values use stable local defaults", + area: "credentials", + given: [ + { kind: "credential-state", source: "local-default", valuesId: "stable-local-defaults-v1" }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "create", + writes: [ + { target: "managed-state", operation: "create", id: "stack-main-default" }, + { target: "runtime-state", operation: "start", id: "stack-main-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], + details: { credential_values_id: "stable-local-defaults-v1", generated_per_start: false }, + output: { + json: { + stack_id: "stack-main-default", + credentials_source: "local-default", + credentials_stable: true, + }, + }, + }, + }, + { + id: "credentials.unchanged-values-survive-restart", + title: "Unchanged credential values remain valid across restart", + area: "credentials", + given: [ + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + contextId: "context-main", + lifecycle: "stopped", + }, + { kind: "credential-state", source: "persisted", valuesId: "stable-local-defaults-v1" }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "reuse", + writes: [{ target: "runtime-state", operation: "start", id: "stack-main-default" }], + runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], + details: { credential_values_id: "stable-local-defaults-v1", credentials_rotated: false }, + output: { + json: { outcome: "reuse", stack_id: "stack-main-default", credentials_unchanged: true }, + }, + }, + }, + { + id: "credentials.explicit-change-applies-after-stop", + title: "An explicit auth change applies to a stopped stack on next start", + area: "credentials", + given: [ + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + contextId: "context-main", + lifecycle: "stopped", + }, + { + kind: "credential-state", + source: "configured", + valuesId: "configured-auth-v2", + previousValuesId: "configured-auth-v1", + }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "update", + writes: [ + { target: "managed-state", operation: "update", id: "stack-main-default" }, + { target: "runtime-state", operation: "start", id: "stack-main-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], + output: { + json: { + outcome: "update", + stack_id: "stack-main-default", + previous_credentials_values_id: "configured-auth-v1", + credentials_values_id: "configured-auth-v2", + }, + }, + }, + }, + { + id: "credentials.running-change-reports-drift", + title: "An auth change on a running stack reports unapplied drift", + area: "credentials", + given: [ + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + contextId: "context-main", + lifecycle: "running", + }, + { + kind: "credential-state", + source: "configured", + valuesId: "configured-auth-v2", + previousValuesId: "configured-auth-v1", + }, + ], + when: { + interface: "cli", + argv: ["status", "--experimental", "--output", "json"], + cwd: "checkout-a", + }, + expected: { + outcome: "error", + error: { + code: "running_stack_credentials_drift", + message: "Configured auth values differ from the running stack", + recovery: ["Run supabase stop --experimental, then supabase start --experimental"], + }, + writes: [], + runtimeEffects: [], + output: { + json: { + outcome: "error", + code: "running_stack_credentials_drift", + stack_id: "stack-main-default", + drift: true, + }, + }, + }, + }, + { + id: "credentials.compatible-legacy-auth-is-retained", + title: "Compatible legacy auth configuration is retained during bootstrap", + area: "credentials", + given: [ + { kind: "managed-target", stackId: "stack-main-default", exists: false }, + { + kind: "legacy-state", + lifecycle: "stopped", + database: "compatible", + storage: "compatible", + credentials: "compatible", + }, + { kind: "credential-state", source: "legacy", valuesId: "legacy-auth-v1" }, + ], + when: { + interface: "managed-api", + method: "startStack", + input: { stackId: "stack-main-default" }, + }, + expected: { + outcome: "create", + writes: [ + { target: "managed-state", operation: "copy", id: "stack-main-default" }, + { target: "registry", operation: "publish", id: "stack-main-default" }, + { target: "runtime-state", operation: "start", id: "stack-main-default" }, + ], + runtimeEffects: [ + { operation: "copy", stackId: "stack-main-default" }, + { operation: "start", stackId: "stack-main-default" }, + ], + details: { credential_values_id: "legacy-auth-v1", legacy_state_mutated: false }, + output: { + api: { + stackId: "stack-main-default", + bootstrap: "copied", + credentialsValuesId: "legacy-auth-v1", + }, + }, + }, + }, + { + id: "credentials.plaintext-secrets-stay-out-of-global-state", + title: "Resolved plaintext secrets are absent from the global managed registry", + area: "credentials", + given: [ + { + kind: "credential-state", + source: "persisted", + valuesId: "configured-auth-v1", + plaintextPresentInGlobalState: false, + }, + { kind: "managed-record", stackId: "stack-main-default", status: "active" }, + ], + when: { + interface: "managed-api", + method: "inspectGlobalRecord", + input: { stackId: "stack-main-default" }, + }, + expected: { + outcome: "report", + writes: [], + runtimeEffects: [], + details: { plaintext_secrets_present: false }, + output: { + api: { + stackId: "stack-main-default", + credentialsReference: "configured-auth-v1", + plaintextSecrets: [], + }, + }, + }, + }, + { + id: "reclamation.default-stop-preserves-data", + title: "Default experimental stop preserves managed data", + area: "reclamation", + given: [ + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + contextId: "context-main", + lifecycle: "running", + }, + ], + when: { interface: "cli", argv: ["stop", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "update", + writes: [{ target: "runtime-state", operation: "update", id: "stack-main-default" }], + runtimeEffects: [{ operation: "stop", stackId: "stack-main-default" }], + details: { data_preserved: true, registry_record_preserved: true }, + output: { + human: { summary: "Stopped main/default", fields: { dataPreserved: "true" } }, + json: { outcome: "update", stack_id: "stack-main-default", data_preserved: true }, + }, + }, + }, + { + id: "reclamation.delete-repeat-is-idempotent", + title: "Repeating global deletion of a tombstoned stack is a successful no-op", + area: "reclamation", + given: [{ kind: "managed-record", stackId: "stack-orphan", status: "tombstoned" }], + when: { + interface: "cli", + argv: ["stop", "--experimental", "--stack-id", "stack-orphan", "--no-backup"], + cwd: "outside-any-checkout", + }, + expected: { + outcome: "no-op", + writes: [], + runtimeEffects: [], + details: { tombstoned: true, idempotent: true }, + output: { + json: { + outcome: "no-op", + stack_id: "stack-orphan", + tombstoned: true, + already_deleted: true, + }, + }, + }, + }, + { + id: "reclamation.branch-delete-does-not-delete-data", + title: "Deleting a Git branch alone never deletes its mutable stack data", + area: "reclamation", + given: [ + { + kind: "stack", + name: "default", + stackId: "stack-feat-default", + contextId: "context-feat", + lifecycle: "stopped", + }, + ], + when: { interface: "git", argv: ["branch", "-D", "feat-a"], cwd: "checkout-a" }, + expected: { + outcome: "no-op", + writes: [], + runtimeEffects: [], + details: { stack_data_preserved: true, stack_orphaned: true }, + output: { human: { summary: "Deleted branch feat-a", fields: {} } }, + }, + }, + { + id: "reclamation.prune-removes-metadata-only", + title: "Prune removes orphan metadata without deleting mutable stack data", + area: "reclamation", + given: [{ kind: "managed-record", stackId: "stack-orphan", status: "orphaned" }], + when: { interface: "cli", argv: ["stack", "prune", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "update", + writes: [{ target: "registry", operation: "delete", id: "stack-orphan" }], + runtimeEffects: [], + details: { metadata_removed: true, mutable_data_deleted: false }, + output: { + human: { summary: "Pruned 1 orphaned metadata record", fields: { dataDeleted: "false" } }, + json: { outcome: "update", pruned_records: ["stack-orphan"], mutable_data_deleted: false }, + }, + }, + }, + { + id: "reclamation.selectors-are-mutually-exclusive", + title: "Contextual, named, global-ID, and all-stack selectors cannot be combined", + area: "reclamation", + given: [{ kind: "managed-record", stackId: "stack-main-default", status: "active" }], + when: { + interface: "cli", + argv: [ + "stop", + "--experimental", + "--stack", + "review", + "--stack-id", + "stack-main-default", + "--all", + ], + cwd: "checkout-a", + }, + expected: { + outcome: "error", + error: { + code: "mutually_exclusive_stack_selectors", + message: "Choose exactly one of contextual, --stack, --stack-id, or --all selection", + recovery: ["Remove all but one stack selector"], + }, + writes: [], + runtimeEffects: [], + output: { + human: { + summary: "Stack selectors cannot be combined", + fields: { selectors: "--stack, --stack-id, --all" }, + recovery: ["Remove all but one stack selector"], + }, + json: { outcome: "error", code: "mutually_exclusive_stack_selectors" }, + }, + }, + }, + { + id: "reclamation.stop-is-engine-scoped", + title: "Experimental stop affects the selected managed stack and never the legacy engine", + area: "reclamation", + given: [ + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + contextId: "context-main", + lifecycle: "running", + }, + { + kind: "legacy-state", + lifecycle: "running", + database: "compatible", + storage: "compatible", + credentials: "compatible", + }, + ], + when: { interface: "cli", argv: ["stop", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "update", + writes: [{ target: "runtime-state", operation: "update", id: "stack-main-default" }], + runtimeEffects: [{ operation: "stop", stackId: "stack-main-default" }], + details: { + managed_stack_stopped: true, + legacy_stack_stopped: false, + legacy_state_mutated: false, + }, + output: { + json: { + outcome: "update", + stack_id: "stack-main-default", + managed_stack_stopped: true, + legacy_stack_stopped: false, + }, + }, + }, + }, +]); + +const additionalApiBoundaryContractFixtures = defineManagedStackContractFixtures([ + { + id: "api-boundary.managed-api-accepts-injected-repository", + title: "The managed API accepts an injected repository without CLI ownership", + area: "api-boundary", + given: [ + { + kind: "managed-api-options", + stateRoot: "isolated", + repository: "injected", + runtime: "node", + }, + ], + when: { + interface: "managed-api", + method: "createManagedStackService", + input: { repository: "test-repository", stateRoot: "/tmp/managed-contract" }, + }, + expected: { + outcome: "create", + writes: [{ target: "ephemeral-state", operation: "create", id: "test-repository" }], + runtimeEffects: [], + details: { cli_required: false, repository_injected: true }, + output: { + api: { + service: "managed-stack-service", + repository: "test-repository", + cliRequired: false, + }, + }, + }, + }, + { + id: "api-boundary.managed-api-accepts-isolated-state-root", + title: "The managed API can run against an isolated caller-provided state root", + area: "api-boundary", + given: [ + { + kind: "managed-api-options", + stateRoot: "isolated", + repository: "in-memory", + runtime: "bun", + }, + ], + when: { + interface: "managed-api", + method: "resolveStack", + input: { cwd: "checkout-a", stackName: "default", stateRoot: "/tmp/managed-contract" }, + }, + expected: { + outcome: "create", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-main", + stackId: "stack-main-default", + stackName: "default", + }, + writes: [ + { target: "identity-marker", operation: "create", id: "project-a" }, + { target: "registry", operation: "publish", id: "stack-main-default" }, + { target: "managed-state", operation: "create", id: "stack-main-default" }, + ], + runtimeEffects: [], + details: { state_root: "/tmp/managed-contract", default_system_state_mutated: false }, + output: { + api: { projectId: "project-a", checkoutId: "checkout-a", stackId: "stack-main-default" }, + }, + }, + }, + { + id: "api-boundary.repository-contract-is-storage-agnostic", + title: "The same repository contract produces identical decisions across storage adapters", + area: "api-boundary", + given: [ + { + kind: "managed-api-options", + stateRoot: "isolated", + repository: "in-memory", + runtime: "node", + }, + { + kind: "managed-api-options", + stateRoot: "isolated", + repository: "persistent-adapter", + runtime: "node", + }, + ], + when: { + interface: "managed-api", + method: "runRepositoryContract", + input: { + adapters: ["in-memory", "persistent-adapter"], + scenarioId: "identity.return-to-branch-reuses-stack", + }, + }, + expected: { + outcome: "report", + writes: [], + runtimeEffects: [], + details: { decisions_equal: true, persistence_semantics_leaked: false }, + output: { + api: { + "in-memory": { outcome: "reuse", stackId: "stack-main-default" }, + "persistent-adapter": { outcome: "reuse", stackId: "stack-main-default" }, + equal: true, + }, + }, + }, + }, + { + id: "api-boundary.cli-projects-shared-managed-results", + title: "The CLI projects one shared managed result instead of deciding identity twice", + area: "api-boundary", + given: [ + { + kind: "managed-api-options", + stateRoot: "default", + repository: "persistent-adapter", + runtime: "bun", + }, + { kind: "managed-record", stackId: "stack-main-default", status: "active" }, + ], + when: { + interface: "cli", + argv: ["status", "--experimental", "--output", "json"], + cwd: "checkout-a", + }, + expected: { + outcome: "report", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-main", + stackId: "stack-main-default", + stackName: "default", + }, + writes: [], + runtimeEffects: [], + details: { identity_decisions_in_cli: 0, managed_result_projected: true }, + output: { + human: { + summary: "main/default is running", + fields: { stackId: "stack-main-default", runtime: "docker" }, + }, + json: { + outcome: "report", + project_id: "project-a", + checkout_id: "checkout-a", + context_id: "context-main", + stack_id: "stack-main-default", + runtime: "docker", + }, + }, + }, + }, + { + id: "api-boundary.managed-surface-is-node-and-bun-portable", + title: "The managed service contract has the same public result under Node and Bun", + area: "api-boundary", + given: [ + { + kind: "managed-api-options", + stateRoot: "isolated", + repository: "in-memory", + runtime: "node", + }, + { + kind: "managed-api-options", + stateRoot: "isolated", + repository: "in-memory", + runtime: "bun", + }, + ], + when: { + interface: "managed-api", + method: "runPortableContract", + input: { + runtimes: ["node", "bun"], + scenarioId: "identity.same-checkout-branch-and-name-reuses-stack", + }, + }, + expected: { + outcome: "report", + writes: [], + runtimeEffects: [], + details: { results_equal: true, bun_specific_state_api: false }, + output: { + api: { + node: { outcome: "reuse", stackId: "stack-main-default" }, + bun: { outcome: "reuse", stackId: "stack-main-default" }, + equal: true, + }, + }, + }, + }, +]); + +export const managedStackContractFixtures = defineManagedStackContractFixtures([ + ...additionalIdentityContractFixtures, + ...additionalPortContractFixtures, + ...additionalRuntimeContractFixtures, + ...additionalLifecycleContractFixtures, + ...additionalApiBoundaryContractFixtures, + { + id: "identity.return-to-branch-reuses-stack", + title: "Returning to a previously used branch reuses its stack", + area: "identity", + given: [ + { + kind: "checkout", + path: "checkout-a", + projectId: "project-a", + checkoutId: "checkout-a", + }, + { + kind: "branch", + name: "main", + contextId: "context-main", + checkedOut: true, + }, + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + contextId: "context-main", + lifecycle: "stopped", + }, + ], + when: { + interface: "cli", + argv: ["start", "--experimental"], + cwd: "checkout-a", + }, + expected: { + outcome: "reuse", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-main", + stackId: "stack-main-default", + stackName: "default", + }, + writes: [{ target: "runtime-state", operation: "start", id: "stack-main-default" }], + runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], + output: { + human: { + summary: "Reused main/default", + fields: { + branch: "main", + stack: "default", + stackId: "stack-main-default", + }, + }, + json: { + outcome: "reuse", + project_id: "project-a", + checkout_id: "checkout-a", + context_id: "context-main", + stack_id: "stack-main-default", + stack_name: "default", + }, + }, + }, + }, + { + id: "identity.branch-copy-ambiguous-read-only", + title: "An ambiguous copied branch is reported without mutation", + area: "identity", + given: [ + { + kind: "checkout", + path: "checkout-a", + projectId: "project-a", + checkoutId: "checkout-a", + }, + { + kind: "branch", + name: "main", + contextId: "context-main", + checkedOut: false, + }, + { + kind: "branch", + name: "feat-copy", + contextId: "context-main", + checkedOut: true, + }, + ], + when: { + interface: "cli", + argv: ["status", "--experimental", "--output", "json"], + cwd: "checkout-a", + }, + expected: { + outcome: "error", + error: { + code: "ambiguous_context_owner", + message: "Branches feat-copy and main both claim context-main", + recovery: [ + "supabase stack inspect --context-id context-main", + "supabase stack new-context --branch feat-copy", + ], + }, + writes: [], + runtimeEffects: [], + output: { + human: { + summary: "Cannot determine which branch owns this stack context", + fields: { + contextId: "context-main", + branches: "feat-copy, main", + }, + recovery: [ + "supabase stack inspect --context-id context-main", + "supabase stack new-context --branch feat-copy", + ], + }, + json: { + outcome: "error", + code: "ambiguous_context_owner", + context_id: "context-main", + branches: ["feat-copy", "main"], + recovery: [ + "supabase stack inspect --context-id context-main", + "supabase stack new-context --branch feat-copy", + ], + }, + }, + }, + }, + { + id: "ports.explicit-port-conflict-fails", + title: "An occupied declarative port fails without relocation", + area: "ports", + given: [ + { + kind: "config-port", + key: "api.port", + intent: "exact", + value: 54321, + }, + { + kind: "occupied-port", + port: 54321, + owner: "external-process", + }, + ], + when: { + interface: "cli", + argv: ["start", "--experimental"], + cwd: "checkout-a", + }, + expected: { + outcome: "error", + error: { + code: "exact_port_occupied", + message: "api.port requires 54321, but that port is already in use", + recovery: [ + "Stop the process using port 54321", + "Change api.port in supabase/config.toml", + "Remove api.port to use automatic allocation", + ], + }, + writes: [], + runtimeEffects: [], + output: { + human: { + summary: "Cannot start because configured port 54321 is in use", + fields: { + port: "54321", + configKey: "api.port", + owner: "external-process", + }, + recovery: [ + "Stop the process using port 54321", + "Change api.port in supabase/config.toml", + "Remove api.port to use automatic allocation", + ], + }, + json: { + outcome: "error", + code: "exact_port_occupied", + port: 54321, + config_key: "api.port", + owner: "external-process", + recovery: [ + "Stop the process using port 54321", + "Change api.port in supabase/config.toml", + "Remove api.port to use automatic allocation", + ], + }, + }, + }, + }, + { + id: "runtime.persisted-runtime-conflict-fails", + title: "An existing stack cannot be switched to another runtime by start", + area: "runtime", + given: [ + { + kind: "persisted-runtime", + stackId: "stack-main-default", + runtime: "docker", + }, + { + kind: "runtime-request", + source: "cli", + runtime: "native", + }, + ], + when: { + interface: "cli", + argv: ["start", "--experimental", "--runtime", "native"], + cwd: "checkout-a", + }, + expected: { + outcome: "error", + error: { + code: "runtime_conflicts_with_persisted_stack", + message: "stack-main-default uses docker, but start requested native", + recovery: [ + "Start a new named stack with --stack ", + "Delete and recreate stack-main-default", + ], + }, + writes: [], + runtimeEffects: [], + output: { + human: { + summary: "Cannot change the runtime of an existing stack", + fields: { + stackId: "stack-main-default", + persistedRuntime: "docker", + requestedRuntime: "native", + }, + recovery: [ + "Start a new named stack with --stack ", + "Delete and recreate stack-main-default", + ], + }, + json: { + outcome: "error", + code: "runtime_conflicts_with_persisted_stack", + stack_id: "stack-main-default", + persisted_runtime: "docker", + requested_runtime: "native", + recovery: [ + "Start a new named stack with --stack ", + "Delete and recreate stack-main-default", + ], + }, + }, + }, + }, + { + id: "bootstrap.first-start-copies-compatible-legacy-state", + title: "First experimental start copies compatible stopped legacy state", + area: "bootstrap", + given: [ + { + kind: "managed-target", + stackId: "stack-main-default", + exists: false, + }, + { + kind: "legacy-state", + lifecycle: "stopped", + database: "compatible", + storage: "compatible", + credentials: "compatible", + }, + ], + when: { + interface: "cli", + argv: ["start", "--experimental"], + cwd: "checkout-a", + }, + expected: { + outcome: "create", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-main", + stackId: "stack-main-default", + stackName: "default", + }, + writes: [ + { target: "managed-state", operation: "copy", id: "stack-main-default" }, + { target: "registry", operation: "create", id: "stack-main-default" }, + { target: "runtime-state", operation: "start", id: "stack-main-default" }, + ], + runtimeEffects: [ + { operation: "copy", stackId: "stack-main-default" }, + { operation: "start", stackId: "stack-main-default" }, + ], + details: { + bootstrap: "copied", + legacy_state_mutated: false, + credentials: "preserved", + }, + output: { + human: { + summary: "Created main/default from compatible legacy state", + fields: { + stack: "default", + stackId: "stack-main-default", + bootstrap: "copied", + credentials: "preserved", + }, + }, + json: { + outcome: "create", + bootstrap: "copied", + stack_id: "stack-main-default", + credentials: "preserved", + legacy_state_mutated: false, + }, + }, + }, + }, + { + id: "reclamation.delete-orphan-by-stack-id", + title: "An orphaned stack can be deleted globally by opaque ID", + area: "reclamation", + given: [ + { + kind: "stack", + name: "default", + stackId: "stack-orphan", + contextId: "context-orphan", + lifecycle: "running", + orphaned: true, + }, + ], + when: { + interface: "cli", + argv: ["stop", "--experimental", "--stack-id", "stack-orphan", "--no-backup"], + cwd: "outside-any-checkout", + }, + expected: { + outcome: "delete", + writes: [ + { target: "runtime-state", operation: "delete", id: "stack-orphan" }, + { target: "managed-state", operation: "delete", id: "stack-orphan" }, + { target: "registry", operation: "tombstone", id: "stack-orphan" }, + ], + runtimeEffects: [ + { operation: "stop", stackId: "stack-orphan" }, + { operation: "delete", stackId: "stack-orphan" }, + ], + details: { + tombstoned: true, + checkout_required: false, + }, + output: { + human: { + summary: "Deleted managed stack stack-orphan", + fields: { + stackId: "stack-orphan", + orphaned: "true", + tombstoned: "true", + }, + }, + json: { + outcome: "delete", + stack_id: "stack-orphan", + orphaned: true, + tombstoned: true, + }, + }, + }, + }, + { + id: "api-boundary.direct-create-stack-is-ephemeral", + title: "Direct createStack usage is isolated from managed system state", + area: "api-boundary", + given: [ + { + kind: "direct-stack-options", + roots: "omitted", + }, + ], + when: { + interface: "stack-api", + method: "createStack", + input: {}, + }, + expected: { + outcome: "create", + writes: [{ target: "ephemeral-state", operation: "create", id: "ephemeral-stack" }], + runtimeEffects: [], + details: { + git_inspected: false, + identity_marker_created: false, + global_registry_mutated: false, + state_root: "temporary", + }, + output: { + api: { + handle: "stack-handle", + state_root: "temporary", + }, + }, + }, + }, +]); diff --git a/packages/stack/src/testing.ts b/packages/stack/src/testing.ts index 206459eeeb..b820d09e61 100644 --- a/packages/stack/src/testing.ts +++ b/packages/stack/src/testing.ts @@ -1,3 +1,19 @@ /** Test-only service tags for building deterministic consumer layers. */ export { DaemonServer } from "./DaemonServer.ts"; +export type { + ManagedStackContractArea, + ManagedStackContractAction, + ManagedStackContractEffects, + ManagedStackContractExpectation, + ManagedStackContractFact, + ManagedStackContractJson, + ManagedStackContractOutput, + ManagedStackContractScenario, + ManagedNativeServiceMatrix, +} from "./managed-stack-contract.ts"; +export { + managedNativeServiceMatrix, + managedStackContractFixtures, + validateManagedStackContractFixtures, +} from "./managed-stack-contract.ts"; export { UnixHttpClient } from "./UnixHttpClient.ts"; From 42a9cc13551be89b725c6bee303c240179703494 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 10 Aug 2026 15:13:08 +0200 Subject: [PATCH 02/41] test(stack): strengthen managed stack contract --- ...managed-stack-contract.integration.test.ts | 37 +- .../0015-managed-stack-contract-fixtures.md | 29 +- ...managed-stack-contract.integration.test.ts | 63 ++- packages/stack/src/managed-stack-contract.ts | 438 +++++++++++++++++- 4 files changed, 540 insertions(+), 27 deletions(-) diff --git a/apps/cli/src/next/managed-stack-contract.integration.test.ts b/apps/cli/src/next/managed-stack-contract.integration.test.ts index e0c2eb0306..c57faef11b 100644 --- a/apps/cli/src/next/managed-stack-contract.integration.test.ts +++ b/apps/cli/src/next/managed-stack-contract.integration.test.ts @@ -1,4 +1,7 @@ -import { managedStackContractFixtures } from "@supabase/stack/testing"; +import { + managedStackContractFixtures, + type ManagedStackContractScenario, +} from "@supabase/stack/testing"; import { describe, expect, it } from "vitest"; describe("experimental managed stack command contract", () => { @@ -59,4 +62,36 @@ describe("experimental managed stack command contract", () => { expect(scenario?.expected.writes).toEqual([]); expect(scenario?.expected.runtimeEffects).toEqual([]); }); + + it("projects running configuration drift as status data instead of command failure", () => { + for (const id of [ + "ports.config-change-on-running-stack-reports-drift", + "runtime.status-reports-one-stack-wide-runtime", + "credentials.running-change-reports-drift", + ]) { + const scenario: ManagedStackContractScenario | undefined = managedStackContractFixtures.find( + (fixture) => fixture.id === id, + ); + + expect(scenario?.when).toMatchObject({ + interface: "cli", + argv: ["status", "--experimental", "--output", "json"], + }); + expect(scenario?.expected).toMatchObject({ + outcome: "report", + warning: { + code: expect.stringMatching(/^running_stack_/), + }, + writes: [], + runtimeEffects: [], + output: { + json: { + outcome: "report", + drift: true, + }, + }, + }); + expect(scenario?.expected.warning?.recovery.length).toBeGreaterThan(0); + } + }); }); diff --git a/docs/adr/0015-managed-stack-contract-fixtures.md b/docs/adr/0015-managed-stack-contract-fixtures.md index 6ebaba2553..640a15e905 100644 --- a/docs/adr/0015-managed-stack-contract-fixtures.md +++ b/docs/adr/0015-managed-stack-contract-fixtures.md @@ -23,7 +23,8 @@ of the M1 managed-stack behavior. Each scenario records: - a public CLI, Git, direct-stack API, or managed-stack API action; - the resolved opaque identities and outcome; - the complete set of permitted managed-state writes and runtime side effects; and -- human, JSON, or programmatic output, including deterministic recovery guidance for errors. +- human, JSON, or programmatic output, including structured warnings and deterministic recovery + guidance. Opaque symbolic IDs make the same scenario reusable across an in-memory repository, a persistent adapter, the managed package, and CLI integration tests. Linear records the decision history and @@ -42,6 +43,11 @@ The CLI is a consumer and presentation layer. It translates arguments into manag projects managed results into human and JSON output. It must not implement a second identity, selection, port, runtime, or lifecycle decision path. +Read-only status remains a successful `report` when it can identify a running stack but finds +unapplied port, credential, or runtime configuration. The report includes a structured warning and +recovery guidance. Conditions that prevent safe identity selection, such as ambiguous ownership, +remain errors. + Persistence sits behind the managed package's repository boundary. Contract fixtures must run against a storage-independent test repository and then against each selected persistent adapter. The choice of SQLite, files, or another implementation does not move product policy into the CLI or @@ -68,6 +74,27 @@ persistent adapter exist. The implementation issues it unblocks must attach real fixtures. A fixture-presence test is not evidence that an unimplemented command already satisfies the behavior. +The fixture validator therefore checks more than catalog shape: selected, written, and effected +identities must be declared, runtime effects must agree with permitted state writes, and +human/API/JSON projections cannot contradict the managed result. We deliberately do not introduce +a parallel test-only identity resolver; it would duplicate product policy before the real managed +surface exists and could pass while the production implementation drifts. + +## Implementation Handoff + +The downstream implementation issues own the executable drivers, while this ADR and fixture data +own the expected behavior: + +- CLI-2106, CLI-2107, and CLI-2108 attach the repository and identity resolver to the identity + fixtures, including ordinary folders, worktrees, branches, and orphan handling. +- CLI-2109 attaches automatic legacy bootstrap and rollback-safe publication. +- CLI-2110 attaches exact and automatic port intent, allocation, stickiness, drift, and collisions. +- CLI-2124 attaches runtime selection, persistence, and strict conflict handling. +- CLI-2114 attaches the experimental CLI handlers and verifies that their human and JSON output is + projected from managed results. +- The selected persistent adapter must run the same repository contract as the isolated test + repository before its implementation issue is complete. + ## Rationale A single typed matrix makes disagreements visible in review and allows every layer to consume the diff --git a/packages/stack/src/managed-stack-contract.integration.test.ts b/packages/stack/src/managed-stack-contract.integration.test.ts index 995c59a62c..499533ad32 100644 --- a/packages/stack/src/managed-stack-contract.integration.test.ts +++ b/packages/stack/src/managed-stack-contract.integration.test.ts @@ -6,6 +6,7 @@ import { createStack } from "./node.ts"; import { managedNativeServiceMatrix, managedStackContractFixtures, + type ManagedStackContractScenario, validateManagedStackContractFixtures, } from "./testing.ts"; @@ -14,6 +15,63 @@ describe("managed stack acceptance contract", () => { expect(validateManagedStackContractFixtures(managedStackContractFixtures)).toEqual([]); }); + it("rejects contract edits that make IDs, effects, and projections disagree", () => { + const scenario: ManagedStackContractScenario | undefined = managedStackContractFixtures.find( + ({ id }) => id === "identity.return-to-branch-reuses-stack", + ); + if (scenario === undefined) { + throw new Error("identity.return-to-branch-reuses-stack fixture is required"); + } + if (scenario.expected.selection === undefined || scenario.expected.output.json === undefined) { + throw new Error("identity.return-to-branch-reuses-stack must select and project a stack"); + } + + const missingStartWrite = { + ...scenario, + expected: { ...scenario.expected, writes: [] }, + }; + expect(validateManagedStackContractFixtures([missingStartWrite])).toContain( + `${scenario.id}: start runtime effect requires a matching state write`, + ); + + const undeclaredSelection = { + ...scenario, + expected: { + ...scenario.expected, + selection: { ...scenario.expected.selection, stackId: "stack-undeclared" }, + }, + }; + expect(validateManagedStackContractFixtures([undeclaredSelection])).toContain( + `${scenario.id}: selection references undeclared ID stack-undeclared`, + ); + + const undeclaredWrite = { + ...scenario, + expected: { + ...scenario.expected, + writes: [{ target: "runtime-state", operation: "start", id: "stack-undeclared" }], + runtimeEffects: [{ operation: "start", stackId: "stack-undeclared" }], + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([undeclaredWrite])).toContain( + `${scenario.id}: runtime-state start references undeclared ID stack-undeclared`, + ); + + const divergentProjection = { + ...scenario, + expected: { + ...scenario.expected, + output: { + ...scenario.expected.output, + json: { ...scenario.expected.output.json, outcome: "create" }, + }, + }, + }; + expect(validateManagedStackContractFixtures([divergentProjection])).toContain( + `${scenario.id}: projected outcome disagrees with the managed result`, + ); + }); + it("covers the approved identity journeys through public commands and APIs", () => { expect( managedStackContractFixtures @@ -75,6 +133,7 @@ describe("managed stack acceptance contract", () => { "ports.exact-default-value-differs-from-omitted-default", "ports.explicit-free-port-is-used", "ports.explicit-port-conflict-fails", + "ports.explicit-port-conflict-with-sibling-fails", "ports.later-sticky-port-collision-fails", "ports.new-target-allocates-and-persists-omitted-ports", "ports.removing-exact-key-keeps-current-port-sticky", @@ -454,7 +513,7 @@ describe("managed stack acceptance contract", () => { expect(scenario).toMatchObject({ area: "bootstrap", - given: [ + given: expect.arrayContaining([ { kind: "managed-target", stackId: "stack-main-default", @@ -467,7 +526,7 @@ describe("managed stack acceptance contract", () => { storage: "compatible", credentials: "compatible", }, - ], + ]), when: { interface: "cli", argv: ["start", "--experimental"], diff --git a/packages/stack/src/managed-stack-contract.ts b/packages/stack/src/managed-stack-contract.ts index b8bfa34d56..acbe8b78f4 100644 --- a/packages/stack/src/managed-stack-contract.ts +++ b/packages/stack/src/managed-stack-contract.ts @@ -214,6 +214,11 @@ export interface ManagedStackContractExpectation extends ManagedStackContractEff readonly message: string; readonly recovery: ReadonlyArray; }; + readonly warning?: { + readonly code: string; + readonly message: string; + readonly recovery: ReadonlyArray; + }; readonly details?: Readonly>; } @@ -329,6 +334,179 @@ export const validateManagedStackContractFixtures = ( } else if (scenario.expected.error !== undefined) { errors.push(`${scenario.id}: non-error outcome cannot include error metadata`); } + + if (scenario.expected.warning !== undefined) { + if (scenario.expected.outcome === "error") { + errors.push(`${scenario.id}: error outcome cannot also include warning metadata`); + } + if (scenario.expected.warning.recovery.length === 0) { + errors.push(`${scenario.id}: warning metadata requires recovery guidance`); + } + } + + const declaredIds = new Set(); + for (const fact of scenario.given) { + switch (fact.kind) { + case "branch": + declaredIds.add(fact.contextId); + break; + case "checkout": + declaredIds.add(fact.projectId); + declaredIds.add(fact.checkoutId); + break; + case "credential-state": + declaredIds.add(fact.valuesId); + if (fact.previousValuesId !== undefined) { + declaredIds.add(fact.previousValuesId); + } + break; + case "identity-claim": + declaredIds.add(fact.id); + break; + case "managed-record": + case "managed-target": + case "persisted-runtime": + declaredIds.add(fact.stackId); + break; + case "occupied-port": + if (fact.ownerId !== undefined) { + declaredIds.add(fact.ownerId); + } + break; + case "port-assignment": + declaredIds.add(fact.stackId); + break; + case "stack": + declaredIds.add(fact.contextId); + declaredIds.add(fact.stackId); + break; + default: + break; + } + } + for (const write of scenario.expected.writes) { + if ( + write.id !== undefined && + (write.operation === "copy" || + write.operation === "create" || + write.operation === "publish") + ) { + declaredIds.add(write.id); + } + } + for (const write of scenario.expected.writes) { + if ( + write.id !== undefined && + write.operation !== "copy" && + write.operation !== "create" && + write.operation !== "publish" && + !declaredIds.has(write.id) + ) { + errors.push( + `${scenario.id}: ${write.target} ${write.operation} references undeclared ID ${write.id}`, + ); + } + } + + const selection = scenario.expected.selection; + if (selection !== undefined) { + for (const id of [ + selection.projectId, + selection.checkoutId, + selection.contextId, + selection.stackId, + ]) { + if (!declaredIds.has(id)) { + errors.push(`${scenario.id}: selection references undeclared ID ${id}`); + } + } + } + + for (const effect of scenario.expected.runtimeEffects) { + if (effect.stackId !== undefined && !declaredIds.has(effect.stackId)) { + errors.push(`${scenario.id}: runtime effect references undeclared ID ${effect.stackId}`); + } + + const hasWrite = scenario.expected.writes.some((write) => { + if (write.id !== effect.stackId) { + return false; + } + switch (effect.operation) { + case "copy": + return write.target === "managed-state" && write.operation === "copy"; + case "delete": + return ( + (write.target === "managed-state" || write.target === "runtime-state") && + write.operation === "delete" + ); + case "start": + return write.target === "runtime-state" && write.operation === "start"; + case "stop": + return ( + write.target === "runtime-state" && + (write.operation === "delete" || write.operation === "update") + ); + } + }); + if (!hasWrite) { + errors.push( + `${scenario.id}: ${effect.operation} runtime effect requires a matching state write`, + ); + } + } + + for (const write of scenario.expected.writes) { + const requiredRuntimeOperation = + write.target === "runtime-state" && write.operation === "start" + ? "start" + : write.target === "managed-state" && write.operation === "copy" + ? "copy" + : write.target === "managed-state" && write.operation === "delete" + ? "delete" + : undefined; + if ( + requiredRuntimeOperation !== undefined && + !scenario.expected.runtimeEffects.some( + (effect) => effect.operation === requiredRuntimeOperation && effect.stackId === write.id, + ) + ) { + errors.push( + `${scenario.id}: ${write.target} ${write.operation} requires a matching runtime effect`, + ); + } + } + + const checkProjection = ( + projection: Readonly> | undefined, + key: string, + expected: ManagedStackContractJson, + ): void => { + if (projection?.[key] !== undefined && projection[key] !== expected) { + errors.push(`${scenario.id}: projected ${key} disagrees with the managed result`); + } + }; + + for (const projection of [scenario.expected.output.json, scenario.expected.output.api]) { + checkProjection(projection, "outcome", scenario.expected.outcome); + if (scenario.expected.error !== undefined) { + checkProjection(projection, "code", scenario.expected.error.code); + } + if (scenario.expected.warning !== undefined) { + checkProjection(projection, "code", scenario.expected.warning.code); + } + } + if (selection !== undefined) { + checkProjection(scenario.expected.output.json, "project_id", selection.projectId); + checkProjection(scenario.expected.output.json, "checkout_id", selection.checkoutId); + checkProjection(scenario.expected.output.json, "context_id", selection.contextId); + checkProjection(scenario.expected.output.json, "stack_id", selection.stackId); + checkProjection(scenario.expected.output.json, "stack_name", selection.stackName); + checkProjection(scenario.expected.output.api, "projectId", selection.projectId); + checkProjection(scenario.expected.output.api, "checkoutId", selection.checkoutId); + checkProjection(scenario.expected.output.api, "contextId", selection.contextId); + checkProjection(scenario.expected.output.api, "stackId", selection.stackId); + checkProjection(scenario.expected.output.api, "stackName", selection.stackName); + } } return errors; @@ -367,7 +545,7 @@ const branchHistoryFixture = ( stackId: "stack-feat-default", stackName: "default", }, - writes: [], + writes: [{ target: "runtime-state", operation: "start", id: "stack-feat-default" }], runtimeEffects: [{ operation: "start", stackId: "stack-feat-default" }], output: { api: { outcome: "reuse", contextId: "context-feat", stackId: "stack-feat-default" } }, }, @@ -477,6 +655,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ { target: "git-config", operation: "create", id: "context-feat-a" }, { target: "registry", operation: "publish", id: "stack-feat-a-default" }, { target: "managed-state", operation: "create", id: "stack-feat-a-default" }, + { target: "runtime-state", operation: "start", id: "stack-feat-a-default" }, ], runtimeEffects: [{ operation: "start", stackId: "stack-feat-a-default" }], output: { @@ -522,7 +701,10 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ stackId: "stack-feat-default", stackName: "default", }, - writes: [{ target: "git-config", operation: "update", id: "context-feat" }], + writes: [ + { target: "git-config", operation: "update", id: "context-feat" }, + { target: "runtime-state", operation: "start", id: "stack-feat-default" }, + ], runtimeEffects: [{ operation: "start", stackId: "stack-feat-default" }], output: { api: { outcome: "reuse", contextId: "context-feat", stackId: "stack-feat-default" }, @@ -563,6 +745,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ { target: "git-config", operation: "create", id: "context-new" }, { target: "registry", operation: "publish", id: "stack-new-default" }, { target: "managed-state", operation: "create", id: "stack-new-default" }, + { target: "runtime-state", operation: "start", id: "stack-new-default" }, ], runtimeEffects: [{ operation: "start", stackId: "stack-new-default" }], details: { orphaned_context_id: "context-old" }, @@ -587,6 +770,13 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, { kind: "branch", name: "main", contextId: "context-main", checkedOut: false }, { kind: "branch", name: "feat-a", contextId: "context-feat", checkedOut: true }, + { + kind: "stack", + name: "default", + stackId: "stack-feat-default", + contextId: "context-feat", + lifecycle: "running", + }, { kind: "git-state", commonDirectory: "repo/.git", @@ -656,6 +846,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ { target: "git-config", operation: "create", id: "context-new" }, { target: "registry", operation: "publish", id: "stack-new-default" }, { target: "managed-state", operation: "create", id: "stack-new-default" }, + { target: "runtime-state", operation: "start", id: "stack-new-default" }, ], runtimeEffects: [{ operation: "start", stackId: "stack-new-default" }], details: { orphaned_context_id: "context-old", adoption_required: true }, @@ -705,7 +896,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ stackId: "stack-detached-default", stackName: "default", }, - writes: [], + writes: [{ target: "runtime-state", operation: "start", id: "stack-detached-default" }], runtimeEffects: [{ operation: "start", stackId: "stack-detached-default" }], output: { api: { outcome: "reuse", contextId: "context-detached", stackId: "stack-detached-default" }, @@ -748,7 +939,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ stackId: "stack-workspace-default", stackName: "default", }, - writes: [], + writes: [{ target: "runtime-state", operation: "start", id: "stack-workspace-default" }], runtimeEffects: [{ operation: "start", stackId: "stack-workspace-default" }], output: { json: { @@ -787,6 +978,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ { target: "git-config", operation: "create", id: "context-b-main" }, { target: "registry", operation: "publish", id: "stack-b-main-default" }, { target: "managed-state", operation: "create", id: "stack-b-main-default" }, + { target: "runtime-state", operation: "start", id: "stack-b-main-default" }, ], runtimeEffects: [{ operation: "start", stackId: "stack-b-main-default" }], output: { @@ -827,6 +1019,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ writes: [ { target: "registry", operation: "publish", id: "stack-b-main-default" }, { target: "managed-state", operation: "create", id: "stack-b-main-default" }, + { target: "runtime-state", operation: "start", id: "stack-b-main-default" }, ], runtimeEffects: [{ operation: "start", stackId: "stack-b-main-default" }], output: { @@ -870,6 +1063,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ writes: [ { target: "registry", operation: "publish", id: "stack-feat-review" }, { target: "managed-state", operation: "create", id: "stack-feat-review" }, + { target: "runtime-state", operation: "start", id: "stack-feat-review" }, ], runtimeEffects: [{ operation: "start", stackId: "stack-feat-review" }], output: { @@ -907,10 +1101,24 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ path: "/old/project-a", status: "exact", }, + { + kind: "checkout", + path: "/old/project-a", + projectId: "project-a", + checkoutId: "checkout-a", + }, + { kind: "branch", name: "main", contextId: "context-main", checkedOut: true }, + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + contextId: "context-main", + lifecycle: "stopped", + }, ], when: { interface: "cli", - argv: ["status", "--experimental", "--output", "json"], + argv: ["start", "--experimental"], cwd: "/new/project-a", }, expected: { @@ -922,8 +1130,11 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ stackId: "stack-main-default", stackName: "default", }, - writes: [{ target: "registry", operation: "update", id: "checkout-a" }], - runtimeEffects: [], + writes: [ + { target: "registry", operation: "update", id: "checkout-a" }, + { target: "runtime-state", operation: "start", id: "stack-main-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], output: { json: { outcome: "reuse", checkout_id: "checkout-a", rebound_from: "/old/project-a" }, }, @@ -953,6 +1164,20 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ path: "/work/project-a", status: "exact", }, + { + kind: "checkout", + path: "/work/project-a", + projectId: "project-a", + checkoutId: "checkout-a", + }, + { kind: "branch", name: "main", contextId: "context-main", checkedOut: true }, + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + contextId: "context-main", + lifecycle: "running", + }, ], when: { interface: "cli", @@ -1053,8 +1278,10 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ writes: [ { target: "identity-marker", operation: "create", id: "project-clone" }, { target: "git-config", operation: "create", id: "checkout-clone" }, + { target: "git-config", operation: "create", id: "context-clone-main" }, { target: "registry", operation: "publish", id: "stack-clone-main-default" }, { target: "managed-state", operation: "create", id: "stack-clone-main-default" }, + { target: "runtime-state", operation: "start", id: "stack-clone-main-default" }, ], runtimeEffects: [{ operation: "start", stackId: "stack-clone-main-default" }], output: { @@ -1087,6 +1314,20 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ path: "/old/project-a", status: "exact", }, + { + kind: "checkout", + path: "/old/project-a", + projectId: "project-a", + checkoutId: "checkout-a", + }, + { kind: "branch", name: "main", contextId: "context-main", checkedOut: true }, + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + contextId: "context-main", + lifecycle: "stopped", + }, ], when: { interface: "managed-api", @@ -1102,7 +1343,10 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ stackId: "stack-main-default", stackName: "default", }, - writes: [{ target: "registry", operation: "update", id: "checkout-a" }], + writes: [ + { target: "registry", operation: "update", id: "checkout-a" }, + { target: "runtime-state", operation: "start", id: "stack-main-default" }, + ], runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], output: { api: { outcome: "reuse", checkoutId: "checkout-a", rebound: true } }, }, @@ -1184,6 +1428,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ writes: [ { target: "registry", operation: "publish", id: "stack-feat-default" }, { target: "managed-state", operation: "create", id: "stack-feat-default" }, + { target: "runtime-state", operation: "start", id: "stack-feat-default" }, ], runtimeEffects: [{ operation: "start", stackId: "stack-feat-default" }], details: { published_stack_count: 1, alias_count: 0, contender_results: ["create", "reuse"] }, @@ -1279,6 +1524,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ title: "A copied branch with a known owner gets a new context on first mutation", area: "identity", given: [ + { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, { kind: "identity-transition", operation: "branch-copy", @@ -1295,6 +1541,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ owner: "main", status: "exact", }, + { kind: "identity-claim", scope: "context", id: "context-copy", status: "absent" }, ], when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, expected: { @@ -1310,6 +1557,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ { target: "git-config", operation: "update", id: "context-copy" }, { target: "registry", operation: "publish", id: "stack-copy-default" }, { target: "managed-state", operation: "create", id: "stack-copy-default" }, + { target: "runtime-state", operation: "start", id: "stack-copy-default" }, ], runtimeEffects: [{ operation: "start", stackId: "stack-copy-default" }], details: { original_context_id: "context-main", original_owner: "main" }, @@ -1352,8 +1600,8 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ cwd: "checkout-a", }, expected: { - outcome: "error", - error: { + outcome: "report", + warning: { code: "copied_branch_context_conflict", message: "feat-copy copied context-main from main", recovery: [ @@ -1364,7 +1612,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ runtimeEffects: [], output: { json: { - outcome: "error", + outcome: "report", code: "copied_branch_context_conflict", branch: "feat-copy", owner: "main", @@ -1381,6 +1629,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ title: "A copied context is preserved as a rename when its original branch is gone", area: "identity", given: [ + { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, { kind: "identity-transition", operation: "branch-copy", @@ -1396,6 +1645,13 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ owner: "main", status: "absent", }, + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + contextId: "context-main", + lifecycle: "stopped", + }, ], when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, expected: { @@ -1407,7 +1663,10 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ stackId: "stack-main-default", stackName: "default", }, - writes: [{ target: "git-config", operation: "update", id: "context-main" }], + writes: [ + { target: "git-config", operation: "update", id: "context-main" }, + { target: "runtime-state", operation: "start", id: "stack-main-default" }, + ], runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], output: { json: { @@ -1447,9 +1706,12 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ stackName: "default", }, writes: [ + { target: "identity-marker", operation: "create", id: "project-clone" }, { target: "git-config", operation: "create", id: "checkout-clone" }, + { target: "git-config", operation: "create", id: "context-clone-main" }, { target: "registry", operation: "publish", id: "stack-clone-default" }, { target: "managed-state", operation: "create", id: "stack-clone-default" }, + { target: "runtime-state", operation: "start", id: "stack-clone-default" }, ], runtimeEffects: [{ operation: "start", stackId: "stack-clone-default" }], details: { tracked_marker_ignored: true, git_index_mutated: false }, @@ -1484,6 +1746,14 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ path: "/work/project-a", status: "exact", }, + { kind: "branch", name: "main", contextId: "context-main", checkedOut: true }, + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + contextId: "context-main", + lifecycle: "stopped", + }, ], when: { interface: "cli", argv: ["start", "--experimental"], cwd: "/work/project-a" }, expected: { @@ -1495,7 +1765,10 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ stackId: "stack-main-default", stackName: "default", }, - writes: [{ target: "git-config", operation: "create", id: "checkout-a" }], + writes: [ + { target: "git-config", operation: "create", id: "checkout-a" }, + { target: "runtime-state", operation: "start", id: "stack-main-default" }, + ], runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], details: { git_index_mutated: false }, output: { @@ -1533,9 +1806,12 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ stackName: "default", }, writes: [ + { target: "identity-marker", operation: "create", id: "project-git" }, { target: "git-config", operation: "create", id: "checkout-git" }, + { target: "git-config", operation: "create", id: "context-git-main" }, { target: "registry", operation: "publish", id: "stack-git-default" }, { target: "managed-state", operation: "create", id: "stack-git-default" }, + { target: "runtime-state", operation: "start", id: "stack-git-default" }, ], runtimeEffects: [{ operation: "start", stackId: "stack-git-default" }], details: { git_index_mutated: false }, @@ -1622,8 +1898,10 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ }, writes: [ { target: "git-config", operation: "create", id: "checkout-b" }, + { target: "git-config", operation: "create", id: "context-b-main" }, { target: "registry", operation: "publish", id: "stack-b-main-default" }, { target: "managed-state", operation: "create", id: "stack-b-main-default" }, + { target: "runtime-state", operation: "start", id: "stack-b-main-default" }, ], runtimeEffects: [{ operation: "start", stackId: "stack-b-main-default" }], details: { @@ -1707,6 +1985,7 @@ const additionalPortContractFixtures = defineManagedStackContractFixtures([ area: "ports", given: [ { kind: "config-port", key: "api.port", intent: "exact", value: 54321, source: "local" }, + { kind: "managed-target", stackId: "stack-main-default", exists: true }, ], when: { interface: "managed-api", @@ -1975,8 +2254,8 @@ const additionalPortContractFixtures = defineManagedStackContractFixtures([ cwd: "checkout-a", }, expected: { - outcome: "error", - error: { + outcome: "report", + warning: { code: "running_stack_config_drift", message: "api.port is running on 54321 but config requires 55321", recovery: ["Run supabase stop --experimental, then supabase start --experimental"], @@ -1984,13 +2263,26 @@ const additionalPortContractFixtures = defineManagedStackContractFixtures([ writes: [], runtimeEffects: [], output: { + human: { + summary: "main/default is running with unapplied port configuration", + fields: { + stackId: "stack-main-default", + configKey: "api.port", + runningPort: "54321", + configuredPort: "55321", + drift: "true", + }, + recovery: ["Run supabase stop --experimental, then supabase start --experimental"], + }, json: { - outcome: "error", + outcome: "report", code: "running_stack_config_drift", stack_id: "stack-main-default", config_key: "api.port", running_port: 54321, requested_port: 55321, + drift: true, + recovery: ["Run supabase stop --experimental, then supabase start --experimental"], }, }, }, @@ -2104,7 +2396,7 @@ const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ expected: { outcome: "create", writes: [ - { target: "managed-state", operation: "update", id: "stack-main-default" }, + { target: "managed-state", operation: "create", id: "stack-main-default" }, { target: "runtime-state", operation: "start", id: "stack-main-default" }, ], runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], @@ -2127,7 +2419,7 @@ const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ expected: { outcome: "create", writes: [ - { target: "managed-state", operation: "update", id: "stack-main-default" }, + { target: "managed-state", operation: "create", id: "stack-main-default" }, { target: "runtime-state", operation: "start", id: "stack-main-default" }, ], runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], @@ -2196,7 +2488,7 @@ const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ expected: { outcome: "create", writes: [ - { target: "managed-state", operation: "update", id: "stack-main-default" }, + { target: "managed-state", operation: "create", id: "stack-main-default" }, { target: "runtime-state", operation: "start", id: "stack-main-default" }, ], runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], @@ -2233,7 +2525,7 @@ const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ expected: { outcome: "create", writes: [ - { target: "managed-state", operation: "update", id: "stack-main-default" }, + { target: "managed-state", operation: "create", id: "stack-main-default" }, { target: "runtime-state", operation: "start", id: "stack-main-default" }, ], runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], @@ -2414,6 +2706,15 @@ const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ }, expected: { outcome: "report", + warning: { + code: "running_stack_runtime_drift", + message: "stack-main-default runs with docker but config requests native", + recovery: [ + "Keep using Docker by restoring runtime = docker", + "Create a new native named stack", + "Delete and recreate stack-main-default with native", + ], + }, writes: [], runtimeEffects: [], details: { mixed_runtime: false }, @@ -2421,14 +2722,25 @@ const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ human: { summary: "main/default is running with Docker", fields: { runtime: "docker", configuredRuntime: "native", drift: "true" }, + recovery: [ + "Keep using Docker by restoring runtime = docker", + "Create a new native named stack", + "Delete and recreate stack-main-default with native", + ], }, json: { outcome: "report", + code: "running_stack_runtime_drift", stack_id: "stack-main-default", runtime: "docker", configured_runtime: "native", drift: true, services: { runtime: "docker" }, + recovery: [ + "Keep using Docker by restoring runtime = docker", + "Create a new native named stack", + "Delete and recreate stack-main-default with native", + ], }, }, }, @@ -2865,8 +3177,8 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ cwd: "checkout-a", }, expected: { - outcome: "error", - error: { + outcome: "report", + warning: { code: "running_stack_credentials_drift", message: "Configured auth values differ from the running stack", recovery: ["Run supabase stop --experimental, then supabase start --experimental"], @@ -2874,11 +3186,17 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ writes: [], runtimeEffects: [], output: { + human: { + summary: "main/default is running with unapplied auth configuration", + fields: { stackId: "stack-main-default", drift: "true" }, + recovery: ["Run supabase stop --experimental, then supabase start --experimental"], + }, json: { - outcome: "error", + outcome: "report", code: "running_stack_credentials_drift", stack_id: "stack-main-default", drift: true, + recovery: ["Run supabase stop --experimental, then supabase start --experimental"], }, }, }, @@ -3184,6 +3502,8 @@ const additionalApiBoundaryContractFixtures = defineManagedStackContractFixtures }, writes: [ { target: "identity-marker", operation: "create", id: "project-a" }, + { target: "git-config", operation: "create", id: "checkout-a" }, + { target: "git-config", operation: "create", id: "context-main" }, { target: "registry", operation: "publish", id: "stack-main-default" }, { target: "managed-state", operation: "create", id: "stack-main-default" }, ], @@ -3245,6 +3565,8 @@ const additionalApiBoundaryContractFixtures = defineManagedStackContractFixtures repository: "persistent-adapter", runtime: "bun", }, + { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, + { kind: "branch", name: "main", contextId: "context-main", checkedOut: true }, { kind: "managed-record", stackId: "stack-main-default", status: "active" }, ], when: { @@ -3519,6 +3841,74 @@ export const managedStackContractFixtures = defineManagedStackContractFixtures([ }, }, }, + { + id: "ports.explicit-port-conflict-with-sibling-fails", + title: "A sibling managed stack holding a declarative port is identified precisely", + area: "ports", + given: [ + { + kind: "config-port", + key: "api.port", + intent: "exact", + value: 54321, + source: "local", + }, + { + kind: "occupied-port", + port: 54321, + owner: "managed-stack", + ownerId: "stack-main-default", + }, + ], + when: { + interface: "cli", + argv: ["start", "--experimental"], + cwd: "worktree-feat-a", + }, + expected: { + outcome: "error", + error: { + code: "exact_port_occupied", + message: "api.port requires 54321, but stack-main-default already owns that port", + recovery: [ + "Stop managed stack stack-main-default", + "Change api.port in supabase/config.toml", + "Remove api.port to let sibling stacks allocate independent ports", + ], + }, + writes: [], + runtimeEffects: [], + output: { + human: { + summary: "Cannot start because a sibling stack uses configured port 54321", + fields: { + port: "54321", + configKey: "api.port", + owner: "managed-stack", + ownerStackId: "stack-main-default", + }, + recovery: [ + "Stop managed stack stack-main-default", + "Change api.port in supabase/config.toml", + "Remove api.port to let sibling stacks allocate independent ports", + ], + }, + json: { + outcome: "error", + code: "exact_port_occupied", + port: 54321, + config_key: "api.port", + owner: "managed-stack", + owner_stack_id: "stack-main-default", + recovery: [ + "Stop managed stack stack-main-default", + "Change api.port in supabase/config.toml", + "Remove api.port to let sibling stacks allocate independent ports", + ], + }, + }, + }, + }, { id: "runtime.persisted-runtime-conflict-fails", title: "An existing stack cannot be switched to another runtime by start", @@ -3584,6 +3974,8 @@ export const managedStackContractFixtures = defineManagedStackContractFixtures([ title: "First experimental start copies compatible stopped legacy state", area: "bootstrap", given: [ + { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, + { kind: "branch", name: "main", contextId: "context-main", checkedOut: true }, { kind: "managed-target", stackId: "stack-main-default", From 0c1b010287daab7c28a3bd5a85f86bf9a3a2b331 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 10 Aug 2026 16:50:20 +0200 Subject: [PATCH 03/41] test(stack): address managed stack review --- ...managed-stack-contract.integration.test.ts | 97 ------------------- .../0015-managed-stack-contract-fixtures.md | 12 +-- docs/adr/README.md | 28 +++--- ...managed-stack-contract.integration.test.ts | 15 +-- packages/stack/src/managed-stack-contract.ts | 32 ++++-- 5 files changed, 53 insertions(+), 131 deletions(-) delete mode 100644 apps/cli/src/next/managed-stack-contract.integration.test.ts diff --git a/apps/cli/src/next/managed-stack-contract.integration.test.ts b/apps/cli/src/next/managed-stack-contract.integration.test.ts deleted file mode 100644 index c57faef11b..0000000000 --- a/apps/cli/src/next/managed-stack-contract.integration.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { - managedStackContractFixtures, - type ManagedStackContractScenario, -} from "@supabase/stack/testing"; -import { describe, expect, it } from "vitest"; - -describe("experimental managed stack command contract", () => { - it("consumes the shared new-branch result as CLI arguments and user-visible output", () => { - const scenario = managedStackContractFixtures.find( - ({ id }) => id === "identity.new-branch-first-start-creates-stack", - ); - - expect(scenario?.when).toEqual({ - interface: "cli", - argv: ["start", "--experimental"], - cwd: "checkout-a", - }); - expect(scenario?.expected.output).toEqual({ - human: { - summary: "Created feat-a/default", - fields: { - branch: "feat-a", - stack: "default", - stackId: "stack-feat-a-default", - }, - }, - json: { - outcome: "create", - context_id: "context-feat-a", - stack_id: "stack-feat-a-default", - }, - }); - }); - - it("consumes structured engine failures without adding a second port decision", () => { - const scenario = managedStackContractFixtures.find( - ({ id }) => id === "ports.explicit-port-conflict-fails", - ); - - expect(scenario?.when).toEqual({ - interface: "cli", - argv: ["start", "--experimental"], - cwd: "checkout-a", - }); - expect(scenario?.expected.output).toMatchObject({ - human: { - summary: "Cannot start because configured port 54321 is in use", - fields: { - port: "54321", - configKey: "api.port", - owner: "external-process", - }, - }, - json: { - outcome: "error", - code: "exact_port_occupied", - port: 54321, - config_key: "api.port", - owner: "external-process", - }, - }); - expect(scenario?.expected.writes).toEqual([]); - expect(scenario?.expected.runtimeEffects).toEqual([]); - }); - - it("projects running configuration drift as status data instead of command failure", () => { - for (const id of [ - "ports.config-change-on-running-stack-reports-drift", - "runtime.status-reports-one-stack-wide-runtime", - "credentials.running-change-reports-drift", - ]) { - const scenario: ManagedStackContractScenario | undefined = managedStackContractFixtures.find( - (fixture) => fixture.id === id, - ); - - expect(scenario?.when).toMatchObject({ - interface: "cli", - argv: ["status", "--experimental", "--output", "json"], - }); - expect(scenario?.expected).toMatchObject({ - outcome: "report", - warning: { - code: expect.stringMatching(/^running_stack_/), - }, - writes: [], - runtimeEffects: [], - output: { - json: { - outcome: "report", - drift: true, - }, - }, - }); - expect(scenario?.expected.warning?.recovery.length).toBeGreaterThan(0); - } - }); -}); diff --git a/docs/adr/0015-managed-stack-contract-fixtures.md b/docs/adr/0015-managed-stack-contract-fixtures.md index 640a15e905..f0225978d2 100644 --- a/docs/adr/0015-managed-stack-contract-fixtures.md +++ b/docs/adr/0015-managed-stack-contract-fixtures.md @@ -1,6 +1,6 @@ # 0015. Managed Stack Contract Fixtures -**Status**: accepted +**Status**: proposed **Date**: 2026-08-10 ## Problem Statement @@ -27,8 +27,8 @@ of the M1 managed-stack behavior. Each scenario records: guidance. Opaque symbolic IDs make the same scenario reusable across an in-memory repository, a persistent -adapter, the managed package, and CLI integration tests. Linear records the decision history and -links to implementation work; it is not a second source of executable truth. +adapter, the managed package, and future CLI integration tests. Linear records the decision history +and links to implementation work; it is not a second source of executable truth. `@supabase/stack` has two distinct public responsibilities: @@ -69,10 +69,10 @@ Tests should be as close as possible to how a developer uses the product: when a boundary cannot be represented faithfully in an in-process integration test, rather than mocking away the behavior under test. -CLI-2102 checks in the fixture data and public consumer seams before the managed engine and +CLI-2102 checks in the fixture data and public direct-stack boundary before the managed engine and persistent adapter exist. The implementation issues it unblocks must attach real drivers to these -fixtures. A fixture-presence test is not evidence that an unimplemented command already satisfies -the behavior. +fixtures. CLI integration coverage begins when a real command boundary exists; a fixture-presence +test is not evidence that an unimplemented command already satisfies the behavior. The fixture validator therefore checks more than catalog shape: selected, written, and effected identities must be declared, runtime effects must agree with permitted state writes, and diff --git a/docs/adr/README.md b/docs/adr/README.md index c7b4b45212..057d8c3283 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -41,21 +41,21 @@ When an ADR becomes outdated, mark it as `deprecated` or reference the supersedi ## ADR index -| ID | Title | Status | -| ---- | ---------------------------------------------------------------------------------------- | -------- | -| 0000 | [Use ADR to Record Decisions](0000-use-adr-to-record-decisions.md) | accepted | -| 0001 | [CLI DX Architecture: The 7 Pillars](0001-cli-dx-architecture-pillars.md) | accepted | -| 0002 | [CLI Product Metrics](0002-cli-product-metrics.md) | accepted | -| 0003 | [Self-Documenting CLI & Documentation Strategy](0003-self-documenting-cli.md) | accepted | -| 0004 | [CLI Design Goals & Development Workflows](0004-cli-design-goals-and-workflows.md) | accepted | +| ID | Title | Status | +| ---- | ------------------------------------------------------------------------------------------ | -------- | +| 0000 | [Use ADR to Record Decisions](0000-use-adr-to-record-decisions.md) | accepted | +| 0001 | [CLI DX Architecture: The 7 Pillars](0001-cli-dx-architecture-pillars.md) | accepted | +| 0002 | [CLI Product Metrics](0002-cli-product-metrics.md) | accepted | +| 0003 | [Self-Documenting CLI & Documentation Strategy](0003-self-documenting-cli.md) | accepted | +| 0004 | [CLI Design Goals & Development Workflows](0004-cli-design-goals-and-workflows.md) | accepted | | 0005 | [OpenAPI-Driven Code Generation for CRUD Commands](0005-openapi-driven-code-generation.md) | proposed | -| 0006 | [Environment Management & Variable Resolution](0006-environment-management.md) | proposed | -| 0007 | [Real-time Progress in Command Handlers](0007-realtime-progress-in-command-handlers.md) | proposed | -| 0008 | [Authentication & Token Management](0008-authentication-and-token-management.md) | proposed | -| 0009 | [Configuration Schema & Validation](0009-configuration-schema-and-validation.md) | proposed | -| 0011 | [CLI Release & Distribution Strategy](0011-cli-release-and-distribution-strategy.md) | proposed | -| 0013 | [Live E2E Tests Bypass the Replay Server](0013-live-e2e-bypasses-replay-server.md) | proposed | -| 0015 | [Managed Stack Contract Fixtures](0015-managed-stack-contract-fixtures.md) | accepted | +| 0006 | [Environment Management & Variable Resolution](0006-environment-management.md) | proposed | +| 0007 | [Real-time Progress in Command Handlers](0007-realtime-progress-in-command-handlers.md) | proposed | +| 0008 | [Authentication & Token Management](0008-authentication-and-token-management.md) | proposed | +| 0009 | [Configuration Schema & Validation](0009-configuration-schema-and-validation.md) | proposed | +| 0011 | [CLI Release & Distribution Strategy](0011-cli-release-and-distribution-strategy.md) | proposed | +| 0013 | [Live E2E Tests Bypass the Replay Server](0013-live-e2e-bypasses-replay-server.md) | proposed | +| 0015 | [Managed Stack Contract Fixtures](0015-managed-stack-contract-fixtures.md) | proposed | ## Template diff --git a/packages/stack/src/managed-stack-contract.integration.test.ts b/packages/stack/src/managed-stack-contract.integration.test.ts index 499533ad32..e9fe359166 100644 --- a/packages/stack/src/managed-stack-contract.integration.test.ts +++ b/packages/stack/src/managed-stack-contract.integration.test.ts @@ -155,7 +155,7 @@ describe("managed stack acceptance contract", () => { ["edge-runtime", "v1.74.3"], ["realtime", "v2.124.1"], ["storage", "v1.68.9"], - ["pg-meta", "v0.96.8"], + ["pgmeta", "v0.96.8"], ["studio", "2026.08.03-sha-022b374"], ["analytics", "v1.50.1"], ["pooler", "v2.9.10"], @@ -281,11 +281,14 @@ describe("managed stack acceptance contract", () => { try { const stack = await createStack({ cacheRoot, projectDir, startupMode: "lazy" }); - expect(stack).toMatchObject({ - url: expect.stringMatching(/^http:\/\/127\.0\.0\.1:/), - dbUrl: expect.stringMatching(/^postgresql:\/\//), - }); - await stack.dispose(); + try { + expect(stack).toMatchObject({ + url: expect.stringMatching(/^http:\/\/127\.0\.0\.1:/), + dbUrl: expect.stringMatching(/^postgresql:\/\//), + }); + } finally { + await stack.dispose(); + } expect(readFileSync(gitConfig, "utf8")).toBe("[core]\n\trepositoryformatversion = 0\n"); expect(readFileSync(identityMarker, "utf8")).toBe('{"sentinel":true}\n'); diff --git a/packages/stack/src/managed-stack-contract.ts b/packages/stack/src/managed-stack-contract.ts index acbe8b78f4..2a724671c1 100644 --- a/packages/stack/src/managed-stack-contract.ts +++ b/packages/stack/src/managed-stack-contract.ts @@ -1,3 +1,5 @@ +import type { ServiceName } from "./ServiceName.ts"; + export type ManagedStackContractArea = | "api-boundary" | "bootstrap" @@ -256,7 +258,7 @@ export interface ManagedStackContractScenario { export interface ManagedNativeServiceMatrix { readonly targetPlatforms: ReadonlyArray; readonly unsupportedPlatforms: ReadonlyArray; - readonly services: ReadonlyArray; + readonly services: ReadonlyArray; } export const managedNativeServiceMatrix: ManagedNativeServiceMatrix = { @@ -269,7 +271,7 @@ export const managedNativeServiceMatrix: ManagedNativeServiceMatrix = { ["edge-runtime", "v1.74.3"], ["realtime", "v2.124.1"], ["storage", "v1.68.9"], - ["pg-meta", "v0.96.8"], + ["pgmeta", "v0.96.8"], ["studio", "2026.08.03-sha-022b374"], ["analytics", "v1.50.1"], ["pooler", "v2.9.10"], @@ -1702,21 +1704,28 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ projectId: "project-clone", checkoutId: "checkout-clone", contextId: "context-clone-main", - stackId: "stack-clone-default", + stackId: "stack-clone-main-default", stackName: "default", }, writes: [ { target: "identity-marker", operation: "create", id: "project-clone" }, { target: "git-config", operation: "create", id: "checkout-clone" }, { target: "git-config", operation: "create", id: "context-clone-main" }, - { target: "registry", operation: "publish", id: "stack-clone-default" }, - { target: "managed-state", operation: "create", id: "stack-clone-default" }, - { target: "runtime-state", operation: "start", id: "stack-clone-default" }, + { target: "registry", operation: "publish", id: "stack-clone-main-default" }, + { target: "managed-state", operation: "create", id: "stack-clone-main-default" }, + { target: "runtime-state", operation: "start", id: "stack-clone-main-default" }, ], - runtimeEffects: [{ operation: "start", stackId: "stack-clone-default" }], + runtimeEffects: [{ operation: "start", stackId: "stack-clone-main-default" }], details: { tracked_marker_ignored: true, git_index_mutated: false }, output: { - json: { outcome: "create", project_id: "project-clone", tracked_marker_ignored: true }, + json: { + outcome: "create", + project_id: "project-clone", + checkout_id: "checkout-clone", + context_id: "context-clone-main", + stack_id: "stack-clone-main-default", + tracked_marker_ignored: true, + }, }, }, }, @@ -2696,6 +2705,13 @@ const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ title: "Status reports one persisted stack-wide runtime and any drift", area: "runtime", given: [ + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + contextId: "context-main", + lifecycle: "running", + }, { kind: "persisted-runtime", stackId: "stack-main-default", runtime: "docker" }, { kind: "runtime-request", source: "config", runtime: "native" }, ], From 4b4705d22d7c7a29b222802ab4a869ac100210d5 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 10 Aug 2026 17:14:08 +0200 Subject: [PATCH 04/41] test(stack): tighten managed contract invariants --- .../0015-managed-stack-contract-fixtures.md | 14 +- ...managed-stack-contract.integration.test.ts | 58 +++++++- packages/stack/src/managed-stack-contract.ts | 129 ++++++++++++++++-- 3 files changed, 185 insertions(+), 16 deletions(-) diff --git a/docs/adr/0015-managed-stack-contract-fixtures.md b/docs/adr/0015-managed-stack-contract-fixtures.md index f0225978d2..b133920284 100644 --- a/docs/adr/0015-managed-stack-contract-fixtures.md +++ b/docs/adr/0015-managed-stack-contract-fixtures.md @@ -43,6 +43,10 @@ The CLI is a consumer and presentation layer. It translates arguments into manag projects managed results into human and JSON output. It must not implement a second identity, selection, port, runtime, or lifecycle decision path. +Git workspaces store project, checkout, and context identities in Git-local metadata, using common +or worktree scope as appropriate. A tracked working-tree identity marker is inert: discovery never +trusts or rewrites it. Ordinary non-Git folders may use an untracked local identity marker. + Read-only status remains a successful `report` when it can identify a running stack but finds unapplied port, credential, or runtime configuration. The report includes a structured warning and recovery guidance. Conditions that prevent safe identity selection, such as ambiguous ownership, @@ -75,10 +79,12 @@ fixtures. CLI integration coverage begins when a real command boundary exists; a test is not evidence that an unimplemented command already satisfies the behavior. The fixture validator therefore checks more than catalog shape: selected, written, and effected -identities must be declared, runtime effects must agree with permitted state writes, and -human/API/JSON projections cannot contradict the managed result. We deliberately do not introduce -a parallel test-only identity resolver; it would duplicate product policy before the real managed -surface exists and could pass while the production implementation drifts. +identities must be declared; starts of existing stacks must declare a stopped lifecycle; managed +state creation must publish its registry record; tracked identity markers must remain untouched; +runtime effects must agree with permitted state writes; and human/API/JSON projections cannot +contradict the managed result. We deliberately do not introduce a parallel test-only identity +resolver; it would duplicate product policy before the real managed surface exists and could pass +while the production implementation drifts. ## Implementation Handoff diff --git a/packages/stack/src/managed-stack-contract.integration.test.ts b/packages/stack/src/managed-stack-contract.integration.test.ts index e9fe359166..b884e67317 100644 --- a/packages/stack/src/managed-stack-contract.integration.test.ts +++ b/packages/stack/src/managed-stack-contract.integration.test.ts @@ -6,6 +6,7 @@ import { createStack } from "./node.ts"; import { managedNativeServiceMatrix, managedStackContractFixtures, + type ManagedStackContractFact, type ManagedStackContractScenario, validateManagedStackContractFixtures, } from "./testing.ts"; @@ -70,6 +71,58 @@ describe("managed stack acceptance contract", () => { expect(validateManagedStackContractFixtures([divergentProjection])).toContain( `${scenario.id}: projected outcome disagrees with the managed result`, ); + + const existingTarget: ManagedStackContractFact = { + kind: "managed-target", + stackId: "stack-main-default", + exists: true, + }; + const ambiguousExistingStart = { + ...scenario, + given: [...scenario.given.filter(({ kind }) => kind !== "stack"), existingTarget], + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([ambiguousExistingStart])).toContain( + `${scenario.id}: starting existing stack stack-main-default requires an explicit stopped lifecycle`, + ); + + const trackedMarkerScenario = managedStackContractFixtures.find( + ({ id }) => id === "identity.fresh-clone-ignores-tracked-marker", + ); + if (trackedMarkerScenario === undefined) { + throw new Error("identity.fresh-clone-ignores-tracked-marker fixture is required"); + } + const identityMarkerWrite = { + target: "identity-marker", + operation: "create", + id: "project-clone", + } satisfies ManagedStackContractScenario["expected"]["writes"][number]; + const trackedMarkerMutation = { + ...trackedMarkerScenario, + expected: { + ...trackedMarkerScenario.expected, + writes: [...trackedMarkerScenario.expected.writes, identityMarkerWrite], + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([trackedMarkerMutation])).toContain( + `${trackedMarkerScenario.id}: a tracked identity marker must remain untouched`, + ); + + const absentLegacyScenario = managedStackContractFixtures.find( + ({ id }) => id === "bootstrap.absent-legacy-starts-fresh", + ); + if (absentLegacyScenario === undefined) { + throw new Error("bootstrap.absent-legacy-starts-fresh fixture is required"); + } + const unpublishedManagedState = { + ...absentLegacyScenario, + expected: { + ...absentLegacyScenario.expected, + writes: absentLegacyScenario.expected.writes.filter(({ target }) => target !== "registry"), + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([unpublishedManagedState])).toContain( + `${absentLegacyScenario.id}: managed-state create requires registry publication`, + ); }); it("covers the approved identity journeys through public commands and APIs", () => { @@ -198,10 +251,11 @@ describe("managed stack acceptance contract", () => { .sort(), ).toEqual( [ + "bootstrap.absent-legacy-starts-fresh", "bootstrap.existing-managed-target-ignores-legacy", "bootstrap.failed-copy-rolls-back-and-retries", "bootstrap.first-start-copies-compatible-legacy-state", - "bootstrap.incompatible-or-absent-legacy-starts-fresh", + "bootstrap.incompatible-legacy-starts-fresh", "bootstrap.managed-and-legacy-diverge-after-copy", "bootstrap.running-legacy-source-fails-without-mutation", ].sort(), @@ -538,7 +592,7 @@ describe("managed stack acceptance contract", () => { outcome: "create", writes: [ { target: "managed-state", operation: "copy", id: "stack-main-default" }, - { target: "registry", operation: "create", id: "stack-main-default" }, + { target: "registry", operation: "publish", id: "stack-main-default" }, { target: "runtime-state", operation: "start", id: "stack-main-default" }, ], runtimeEffects: [ diff --git a/packages/stack/src/managed-stack-contract.ts b/packages/stack/src/managed-stack-contract.ts index 2a724671c1..5880a90ed0 100644 --- a/packages/stack/src/managed-stack-contract.ts +++ b/packages/stack/src/managed-stack-contract.ts @@ -386,6 +386,35 @@ export const validateManagedStackContractFixtures = ( break; } } + + if ( + scenario.given.some( + (fact) => fact.kind === "git-state" && fact.trackedIdentityMarker === true, + ) && + scenario.expected.writes.some((write) => write.target === "identity-marker") + ) { + errors.push(`${scenario.id}: a tracked identity marker must remain untouched`); + } + + if (scenario.expected.outcome !== "create") { + for (const effect of scenario.expected.runtimeEffects) { + if (effect.operation !== "start" || effect.stackId === undefined) { + continue; + } + const explicitlyStopped = scenario.given.some( + (fact) => + fact.kind === "stack" && + fact.stackId === effect.stackId && + fact.lifecycle === "stopped", + ); + if (!explicitlyStopped) { + errors.push( + `${scenario.id}: starting existing stack ${effect.stackId} requires an explicit stopped lifecycle`, + ); + } + } + } + for (const write of scenario.expected.writes) { if ( write.id !== undefined && @@ -458,6 +487,21 @@ export const validateManagedStackContractFixtures = ( } for (const write of scenario.expected.writes) { + if ( + write.target === "managed-state" && + (write.operation === "copy" || write.operation === "create") && + !scenario.expected.writes.some( + (candidate) => + candidate.target === "registry" && + candidate.operation === "publish" && + candidate.id === write.id, + ) + ) { + errors.push( + `${scenario.id}: managed-state ${write.operation} requires registry publication`, + ); + } + const requiredRuntimeOperation = write.target === "runtime-state" && write.operation === "start" ? "start" @@ -1278,7 +1322,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ stackName: "default", }, writes: [ - { target: "identity-marker", operation: "create", id: "project-clone" }, + { target: "git-config", operation: "create", id: "project-clone" }, { target: "git-config", operation: "create", id: "checkout-clone" }, { target: "git-config", operation: "create", id: "context-clone-main" }, { target: "registry", operation: "publish", id: "stack-clone-main-default" }, @@ -1286,6 +1330,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ { target: "runtime-state", operation: "start", id: "stack-clone-main-default" }, ], runtimeEffects: [{ operation: "start", stackId: "stack-clone-main-default" }], + details: { project_identity_storage: "git-local", git_index_mutated: false }, output: { json: { outcome: "create", @@ -1708,7 +1753,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ stackName: "default", }, writes: [ - { target: "identity-marker", operation: "create", id: "project-clone" }, + { target: "git-config", operation: "create", id: "project-clone" }, { target: "git-config", operation: "create", id: "checkout-clone" }, { target: "git-config", operation: "create", id: "context-clone-main" }, { target: "registry", operation: "publish", id: "stack-clone-main-default" }, @@ -1716,7 +1761,12 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ { target: "runtime-state", operation: "start", id: "stack-clone-main-default" }, ], runtimeEffects: [{ operation: "start", stackId: "stack-clone-main-default" }], - details: { tracked_marker_ignored: true, git_index_mutated: false }, + details: { + project_identity_storage: "git-local", + tracked_marker_ignored: true, + tracked_marker_mutated: false, + git_index_mutated: false, + }, output: { json: { outcome: "create", @@ -1815,7 +1865,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ stackName: "default", }, writes: [ - { target: "identity-marker", operation: "create", id: "project-git" }, + { target: "git-config", operation: "create", id: "project-git" }, { target: "git-config", operation: "create", id: "checkout-git" }, { target: "git-config", operation: "create", id: "context-git-main" }, { target: "registry", operation: "publish", id: "stack-git-default" }, @@ -1823,7 +1873,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ { target: "runtime-state", operation: "start", id: "stack-git-default" }, ], runtimeEffects: [{ operation: "start", stackId: "stack-git-default" }], - details: { git_index_mutated: false }, + details: { project_identity_storage: "git-local", git_index_mutated: false }, output: { json: { outcome: "create", project_id: "project-git", checkout_id: "checkout-git" }, }, @@ -1995,6 +2045,13 @@ const additionalPortContractFixtures = defineManagedStackContractFixtures([ given: [ { kind: "config-port", key: "api.port", intent: "exact", value: 54321, source: "local" }, { kind: "managed-target", stackId: "stack-main-default", exists: true }, + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + contextId: "context-main", + lifecycle: "stopped", + }, ], when: { interface: "managed-api", @@ -2042,7 +2099,7 @@ const additionalPortContractFixtures = defineManagedStackContractFixtures([ outcome: "create", writes: [ { target: "managed-state", operation: "create", id: "stack-feat-default" }, - { target: "registry", operation: "update", id: "stack-feat-default" }, + { target: "registry", operation: "publish", id: "stack-feat-default" }, { target: "runtime-state", operation: "start", id: "stack-feat-default" }, ], runtimeEffects: [{ operation: "start", stackId: "stack-feat-default" }], @@ -2406,6 +2463,7 @@ const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ outcome: "create", writes: [ { target: "managed-state", operation: "create", id: "stack-main-default" }, + { target: "registry", operation: "publish", id: "stack-main-default" }, { target: "runtime-state", operation: "start", id: "stack-main-default" }, ], runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], @@ -2429,6 +2487,7 @@ const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ outcome: "create", writes: [ { target: "managed-state", operation: "create", id: "stack-main-default" }, + { target: "registry", operation: "publish", id: "stack-main-default" }, { target: "runtime-state", operation: "start", id: "stack-main-default" }, ], runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], @@ -2498,6 +2557,7 @@ const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ outcome: "create", writes: [ { target: "managed-state", operation: "create", id: "stack-main-default" }, + { target: "registry", operation: "publish", id: "stack-main-default" }, { target: "runtime-state", operation: "start", id: "stack-main-default" }, ], runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], @@ -2535,6 +2595,7 @@ const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ outcome: "create", writes: [ { target: "managed-state", operation: "create", id: "stack-main-default" }, + { target: "registry", operation: "publish", id: "stack-main-default" }, { target: "runtime-state", operation: "start", id: "stack-main-default" }, ], runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], @@ -2639,6 +2700,13 @@ const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ area: "runtime", given: [ { kind: "persisted-runtime", stackId: "stack-main-default", runtime: "native" }, + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + contextId: "context-main", + lifecycle: "stopped", + }, { kind: "runtime-request", source: "default", runtime: "auto" }, { kind: "runtime-availability", runtime: "docker", available: true }, { kind: "runtime-availability", runtime: "native", available: true }, @@ -2899,8 +2967,8 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ }, }, { - id: "bootstrap.incompatible-or-absent-legacy-starts-fresh", - title: "A first start without compatible legacy state creates a fresh managed target", + id: "bootstrap.incompatible-legacy-starts-fresh", + title: "A first start with incompatible stopped legacy state creates a fresh managed target", area: "bootstrap", given: [ { kind: "managed-target", stackId: "stack-main-default", exists: false }, @@ -2921,7 +2989,46 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ { target: "runtime-state", operation: "start", id: "stack-main-default" }, ], runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], - details: { bootstrap: "fresh", legacy_state_mutated: false }, + details: { + bootstrap: "fresh", + legacy_state: "incompatible", + legacy_state_mutated: false, + }, + output: { + human: { summary: "Created a fresh main/default stack", fields: { bootstrap: "fresh" } }, + json: { + outcome: "create", + stack_id: "stack-main-default", + bootstrap: "fresh", + legacy_state_mutated: false, + }, + }, + }, + }, + { + id: "bootstrap.absent-legacy-starts-fresh", + title: "A first start without legacy state creates a fresh managed target", + area: "bootstrap", + given: [ + { kind: "managed-target", stackId: "stack-main-default", exists: false }, + { + kind: "legacy-state", + lifecycle: "absent", + database: "absent", + storage: "absent", + credentials: "absent", + }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "create", + writes: [ + { target: "managed-state", operation: "create", id: "stack-main-default" }, + { target: "registry", operation: "publish", id: "stack-main-default" }, + { target: "runtime-state", operation: "start", id: "stack-main-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], + details: { bootstrap: "fresh", legacy_state: "absent", legacy_state_mutated: false }, output: { human: { summary: "Created a fresh main/default stack", fields: { bootstrap: "fresh" } }, json: { @@ -3068,6 +3175,7 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ outcome: "create", writes: [ { target: "managed-state", operation: "create", id: "stack-main-default" }, + { target: "registry", operation: "publish", id: "stack-main-default" }, { target: "runtime-state", operation: "start", id: "stack-main-default" }, ], runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], @@ -3093,6 +3201,7 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ outcome: "create", writes: [ { target: "managed-state", operation: "create", id: "stack-main-default" }, + { target: "registry", operation: "publish", id: "stack-main-default" }, { target: "runtime-state", operation: "start", id: "stack-main-default" }, ], runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], @@ -4021,7 +4130,7 @@ export const managedStackContractFixtures = defineManagedStackContractFixtures([ }, writes: [ { target: "managed-state", operation: "copy", id: "stack-main-default" }, - { target: "registry", operation: "create", id: "stack-main-default" }, + { target: "registry", operation: "publish", id: "stack-main-default" }, { target: "runtime-state", operation: "start", id: "stack-main-default" }, ], runtimeEffects: [ From 09cc918af5b791abe2668e773270a9bfd2f8d2ee Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 10 Aug 2026 17:21:35 +0200 Subject: [PATCH 05/41] test(stack): align git identity fixtures --- ...managed-stack-contract.integration.test.ts | 17 +++++++ packages/stack/src/managed-stack-contract.ts | 47 ++++++++++++++----- 2 files changed, 51 insertions(+), 13 deletions(-) diff --git a/packages/stack/src/managed-stack-contract.integration.test.ts b/packages/stack/src/managed-stack-contract.integration.test.ts index b884e67317..9baec3ce33 100644 --- a/packages/stack/src/managed-stack-contract.integration.test.ts +++ b/packages/stack/src/managed-stack-contract.integration.test.ts @@ -107,6 +107,23 @@ describe("managed stack acceptance contract", () => { `${trackedMarkerScenario.id}: a tracked identity marker must remain untouched`, ); + const gitWorkspaceScenario = managedStackContractFixtures.find( + ({ id }) => id === "identity.fresh-clone-creates-project-and-checkout", + ); + if (gitWorkspaceScenario === undefined) { + throw new Error("identity.fresh-clone-creates-project-and-checkout fixture is required"); + } + const gitWorkspaceMarkerMutation = { + ...gitWorkspaceScenario, + expected: { + ...gitWorkspaceScenario.expected, + writes: [...gitWorkspaceScenario.expected.writes, identityMarkerWrite], + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([gitWorkspaceMarkerMutation])).toContain( + `${gitWorkspaceScenario.id}: Git workspace identity must use Git-local metadata`, + ); + const absentLegacyScenario = managedStackContractFixtures.find( ({ id }) => id === "bootstrap.absent-legacy-starts-fresh", ); diff --git a/packages/stack/src/managed-stack-contract.ts b/packages/stack/src/managed-stack-contract.ts index 5880a90ed0..abff92aaf7 100644 --- a/packages/stack/src/managed-stack-contract.ts +++ b/packages/stack/src/managed-stack-contract.ts @@ -387,13 +387,21 @@ export const validateManagedStackContractFixtures = ( } } - if ( - scenario.given.some( - (fact) => fact.kind === "git-state" && fact.trackedIdentityMarker === true, - ) && - scenario.expected.writes.some((write) => write.target === "identity-marker") - ) { - errors.push(`${scenario.id}: a tracked identity marker must remain untouched`); + const writesIdentityMarker = scenario.expected.writes.some( + (write) => write.target === "identity-marker", + ); + if (writesIdentityMarker) { + if ( + scenario.given.some( + (fact) => fact.kind === "git-state" && fact.trackedIdentityMarker === true, + ) + ) { + errors.push(`${scenario.id}: a tracked identity marker must remain untouched`); + } else if ( + scenario.given.some((fact) => fact.kind === "workspace" && fact.mode !== "ordinary-folder") + ) { + errors.push(`${scenario.id}: Git workspace identity must use Git-local metadata`); + } } if (scenario.expected.outcome !== "create") { @@ -1278,7 +1286,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ message: "Two live paths claim checkout-a", recovery: [ "Use the original checkout at /work/project-a", - "Remove .supabase/identity.json from the copy and run supabase start --experimental", + "Recreate the copy with git clone and run supabase start --experimental", ], }, writes: [], @@ -1291,7 +1299,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ paths: ["/copy/project-a", "/work/project-a"], recovery: [ "Use the original checkout at /work/project-a", - "Remove .supabase/identity.json from the copy and run supabase start --experimental", + "Recreate the copy with git clone and run supabase start --experimental", ], }, }, @@ -1964,8 +1972,8 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ ], runtimeEffects: [{ operation: "start", stackId: "stack-b-main-default" }], details: { - project_marker_location: "repo.git", - checkout_marker_location: "repo.git/worktrees/worktree-b", + project_identity_location: "repo.git", + checkout_identity_location: "repo.git/worktrees/worktree-b", }, output: { api: { @@ -3573,6 +3581,15 @@ const additionalApiBoundaryContractFixtures = defineManagedStackContractFixtures title: "The managed API accepts an injected repository without CLI ownership", area: "api-boundary", given: [ + { kind: "workspace", mode: "git", path: "checkout-a" }, + { + kind: "git-state", + commonDirectory: "repo/.git", + gitDirectory: "repo/.git", + head: "branch", + branch: "main", + commit: "commit-a", + }, { kind: "managed-api-options", stateRoot: "isolated", @@ -3626,14 +3643,18 @@ const additionalApiBoundaryContractFixtures = defineManagedStackContractFixtures stackName: "default", }, writes: [ - { target: "identity-marker", operation: "create", id: "project-a" }, + { target: "git-config", operation: "create", id: "project-a" }, { target: "git-config", operation: "create", id: "checkout-a" }, { target: "git-config", operation: "create", id: "context-main" }, { target: "registry", operation: "publish", id: "stack-main-default" }, { target: "managed-state", operation: "create", id: "stack-main-default" }, ], runtimeEffects: [], - details: { state_root: "/tmp/managed-contract", default_system_state_mutated: false }, + details: { + state_root: "/tmp/managed-contract", + project_identity_storage: "git-local", + default_system_state_mutated: false, + }, output: { api: { projectId: "project-a", checkoutId: "checkout-a", stackId: "stack-main-default" }, }, From 5360a05eb37f66f517f9d3e681f4de464f8190fa Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 10 Aug 2026 17:30:21 +0200 Subject: [PATCH 06/41] test(stack): pin patched imgproxy contract version --- packages/stack/src/managed-stack-contract.integration.test.ts | 2 +- packages/stack/src/managed-stack-contract.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/stack/src/managed-stack-contract.integration.test.ts b/packages/stack/src/managed-stack-contract.integration.test.ts index 9baec3ce33..6302cdf1c4 100644 --- a/packages/stack/src/managed-stack-contract.integration.test.ts +++ b/packages/stack/src/managed-stack-contract.integration.test.ts @@ -231,7 +231,7 @@ describe("managed stack acceptance contract", () => { ["pooler", "v2.9.10"], ["mailpit", "v1.30.2"], ["vector", "v0.53.0"], - ["imgproxy", "v3.8.0"], + ["imgproxy", "v3.27.2"], ], }); diff --git a/packages/stack/src/managed-stack-contract.ts b/packages/stack/src/managed-stack-contract.ts index abff92aaf7..85815b10d0 100644 --- a/packages/stack/src/managed-stack-contract.ts +++ b/packages/stack/src/managed-stack-contract.ts @@ -277,7 +277,7 @@ export const managedNativeServiceMatrix: ManagedNativeServiceMatrix = { ["pooler", "v2.9.10"], ["mailpit", "v1.30.2"], ["vector", "v0.53.0"], - ["imgproxy", "v3.8.0"], + ["imgproxy", "v3.27.2"], ], }; From 1bed79e7611011ee2543c9fb5b6915aa4e05402a Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 10 Aug 2026 17:45:52 +0200 Subject: [PATCH 07/41] test(stack): tighten contract conformance rules --- ...managed-stack-contract.integration.test.ts | 82 +++++++++++++ packages/stack/src/managed-stack-contract.ts | 109 ++++++++++++++++-- 2 files changed, 182 insertions(+), 9 deletions(-) diff --git a/packages/stack/src/managed-stack-contract.integration.test.ts b/packages/stack/src/managed-stack-contract.integration.test.ts index 6302cdf1c4..c9f5bad427 100644 --- a/packages/stack/src/managed-stack-contract.integration.test.ts +++ b/packages/stack/src/managed-stack-contract.integration.test.ts @@ -140,6 +140,88 @@ describe("managed stack acceptance contract", () => { expect(validateManagedStackContractFixtures([unpublishedManagedState])).toContain( `${absentLegacyScenario.id}: managed-state create requires registry publication`, ); + + const stoppedStackScenario = managedStackContractFixtures.find( + ({ id }) => id === "reclamation.default-stop-preserves-data", + ); + if (stoppedStackScenario === undefined) { + throw new Error("reclamation.default-stop-preserves-data fixture is required"); + } + const missingStopEffect = { + ...stoppedStackScenario, + expected: { ...stoppedStackScenario.expected, runtimeEffects: [] }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([missingStopEffect])).toContain( + `${stoppedStackScenario.id}: runtime-state update requires a matching runtime effect`, + ); + + const folderToGitScenario = managedStackContractFixtures.find( + ({ id }) => id === "identity.folder-to-git-exact-claim-preserves-identity", + ); + if (folderToGitScenario === undefined) { + throw new Error("identity.folder-to-git-exact-claim-preserves-identity fixture is required"); + } + const incompleteGitIdentity = { + ...folderToGitScenario, + expected: { + ...folderToGitScenario.expected, + writes: folderToGitScenario.expected.writes.filter(({ id }) => id !== "project-a"), + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([incompleteGitIdentity])).toContain( + `${folderToGitScenario.id}: folder-to-Git identity project-a must be persisted in Git-local metadata`, + ); + + const qualificationScenario = managedStackContractFixtures.find( + ({ id }) => id === "native-qualification.all-services-qualify-platform", + ); + if (qualificationScenario === undefined) { + throw new Error("native-qualification.all-services-qualify-platform fixture is required"); + } + const incompleteQualification = { + ...qualificationScenario, + given: qualificationScenario.given.map((fact) => + fact.kind === "native-qualification" + ? { ...fact, qualifiedServices: fact.qualifiedServices.slice(1) } + : fact, + ), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([incompleteQualification])).toContain( + `${qualificationScenario.id}: native qualification omits service postgres`, + ); + + const statusScenario: ManagedStackContractScenario | undefined = + managedStackContractFixtures.find( + ({ id }) => id === "identity.symlink-alias-reuses-checkout", + ); + if (statusScenario === undefined || statusScenario.expected.output.json === undefined) { + throw new Error("identity.symlink-alias-reuses-checkout JSON fixture is required"); + } + const mutatingStatus = { + ...statusScenario, + expected: { + ...statusScenario.expected, + outcome: "reuse", + output: { + ...statusScenario.expected.output, + json: { ...statusScenario.expected.output.json, outcome: "reuse" }, + }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([mutatingStatus])).toContain( + `${statusScenario.id}: successful status commands must report`, + ); + + const stateWritingStatus = { + ...statusScenario, + expected: { + ...statusScenario.expected, + writes: [{ target: "registry", operation: "update", id: "checkout-a" }], + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([stateWritingStatus])).toContain( + `${statusScenario.id}: status commands must not mutate state`, + ); }); it("covers the approved identity journeys through public commands and APIs", () => { diff --git a/packages/stack/src/managed-stack-contract.ts b/packages/stack/src/managed-stack-contract.ts index 85815b10d0..1eb5efd35e 100644 --- a/packages/stack/src/managed-stack-contract.ts +++ b/packages/stack/src/managed-stack-contract.ts @@ -135,8 +135,8 @@ export type ManagedStackContractFact = | { readonly kind: "native-qualification"; readonly platform: string; - readonly qualifiedServices: ReadonlyArray; - readonly failedServices: ReadonlyArray; + readonly qualifiedServices: ReadonlyArray; + readonly failedServices: ReadonlyArray; } | { readonly kind: "managed-target"; @@ -292,6 +292,8 @@ export const validateManagedStackContractFixtures = ( ): ReadonlyArray => { const errors: Array = []; const ids = new Set(); + const nativeServices = managedNativeServiceMatrix.services.map(([service]) => service); + const nativeServiceSet = new Set(nativeServices); for (const scenario of fixtures) { if (ids.has(scenario.id)) { @@ -322,6 +324,21 @@ export const validateManagedStackContractFixtures = ( } } + const isCliStatus = scenario.when.interface === "cli" && scenario.when.argv[0] === "status"; + if ( + isCliStatus && + scenario.expected.outcome !== "error" && + scenario.expected.outcome !== "report" + ) { + errors.push(`${scenario.id}: successful status commands must report`); + } + if ( + isCliStatus && + (scenario.expected.writes.length > 0 || scenario.expected.runtimeEffects.length > 0) + ) { + errors.push(`${scenario.id}: status commands must not mutate state`); + } + const { output } = scenario.expected; if (output.human === undefined && output.json === undefined && output.api === undefined) { errors.push(`${scenario.id}: at least one observable output is required`); @@ -387,6 +404,41 @@ export const validateManagedStackContractFixtures = ( } } + for (const fact of scenario.given) { + if (fact.kind !== "native-qualification") { + continue; + } + + const qualified = new Set(); + const failed = new Set(); + for (const service of fact.qualifiedServices) { + if (!nativeServiceSet.has(service)) { + errors.push(`${scenario.id}: native qualification contains unknown service ${service}`); + } + if (qualified.has(service)) { + errors.push(`${scenario.id}: native qualification duplicates service ${service}`); + } + qualified.add(service); + } + for (const service of fact.failedServices) { + if (!nativeServiceSet.has(service)) { + errors.push(`${scenario.id}: native qualification contains unknown service ${service}`); + } + if (failed.has(service)) { + errors.push(`${scenario.id}: native qualification duplicates service ${service}`); + } + if (qualified.has(service)) { + errors.push(`${scenario.id}: native qualification places ${service} in both partitions`); + } + failed.add(service); + } + for (const service of nativeServices) { + if (!qualified.has(service) && !failed.has(service)) { + errors.push(`${scenario.id}: native qualification omits service ${service}`); + } + } + } + const writesIdentityMarker = scenario.expected.writes.some( (write) => write.target === "identity-marker", ); @@ -459,6 +511,24 @@ export const validateManagedStackContractFixtures = ( errors.push(`${scenario.id}: selection references undeclared ID ${id}`); } } + + if ( + scenario.given.some( + (fact) => fact.kind === "identity-transition" && fact.operation === "folder-to-git", + ) + ) { + for (const id of [selection.projectId, selection.checkoutId, selection.contextId]) { + if ( + !scenario.expected.writes.some( + (write) => write.target === "git-config" && write.id === id, + ) + ) { + errors.push( + `${scenario.id}: folder-to-Git identity ${id} must be persisted in Git-local metadata`, + ); + } + } + } } for (const effect of scenario.expected.runtimeEffects) { @@ -513,11 +583,13 @@ export const validateManagedStackContractFixtures = ( const requiredRuntimeOperation = write.target === "runtime-state" && write.operation === "start" ? "start" - : write.target === "managed-state" && write.operation === "copy" - ? "copy" - : write.target === "managed-state" && write.operation === "delete" - ? "delete" - : undefined; + : write.target === "runtime-state" && write.operation === "update" + ? "stop" + : write.target === "managed-state" && write.operation === "copy" + ? "copy" + : write.target === "managed-state" && write.operation === "delete" + ? "delete" + : undefined; if ( requiredRuntimeOperation !== undefined && !scenario.expected.runtimeEffects.some( @@ -1239,7 +1311,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ cwd: "/alias/project-a", }, expected: { - outcome: "reuse", + outcome: "report", selection: { projectId: "project-a", checkoutId: "checkout-a", @@ -1250,7 +1322,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ writes: [], runtimeEffects: [], output: { - json: { outcome: "reuse", checkout_id: "checkout-a", canonical_path: "/work/project-a" }, + json: { outcome: "report", checkout_id: "checkout-a", canonical_path: "/work/project-a" }, }, }, }, @@ -1833,7 +1905,9 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ stackName: "default", }, writes: [ + { target: "git-config", operation: "create", id: "project-a" }, { target: "git-config", operation: "create", id: "checkout-a" }, + { target: "git-config", operation: "create", id: "context-main" }, { target: "runtime-state", operation: "start", id: "stack-main-default" }, ], runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], @@ -3621,6 +3695,15 @@ const additionalApiBoundaryContractFixtures = defineManagedStackContractFixtures title: "The managed API can run against an isolated caller-provided state root", area: "api-boundary", given: [ + { kind: "workspace", mode: "git", path: "checkout-a" }, + { + kind: "git-state", + commonDirectory: "repo/.git", + gitDirectory: "repo/.git", + head: "branch", + branch: "main", + commit: "commit-a", + }, { kind: "managed-api-options", stateRoot: "isolated", @@ -3714,6 +3797,14 @@ const additionalApiBoundaryContractFixtures = defineManagedStackContractFixtures { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, { kind: "branch", name: "main", contextId: "context-main", checkedOut: true }, { kind: "managed-record", stackId: "stack-main-default", status: "active" }, + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + contextId: "context-main", + lifecycle: "running", + }, + { kind: "persisted-runtime", stackId: "stack-main-default", runtime: "docker" }, ], when: { interface: "cli", From a95a8cb5dab34583ba5f4166577536857428a4a9 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 10 Aug 2026 18:04:46 +0200 Subject: [PATCH 08/41] test(stack): encode Git identity scopes --- .../0015-managed-stack-contract-fixtures.md | 19 +- ...managed-stack-contract.integration.test.ts | 74 ++++++- packages/stack/src/managed-stack-contract.ts | 205 ++++++++++++++---- 3 files changed, 244 insertions(+), 54 deletions(-) diff --git a/docs/adr/0015-managed-stack-contract-fixtures.md b/docs/adr/0015-managed-stack-contract-fixtures.md index b133920284..949e49cf15 100644 --- a/docs/adr/0015-managed-stack-contract-fixtures.md +++ b/docs/adr/0015-managed-stack-contract-fixtures.md @@ -44,8 +44,10 @@ projects managed results into human and JSON output. It must not implement a sec selection, port, runtime, or lifecycle decision path. Git workspaces store project, checkout, and context identities in Git-local metadata, using common -or worktree scope as appropriate. A tracked working-tree identity marker is inert: discovery never -trusts or rewrites it. Ordinary non-Git folders may use an untracked local identity marker. +or worktree scope as appropriate. Contract effects record that scope explicitly: project identity +uses common Git config, while checkout and context identities use worktree-local config. A tracked +working-tree identity marker is inert: discovery never trusts or rewrites it. Ordinary non-Git +folders may use an untracked local identity marker. Read-only status remains a successful `report` when it can identify a running stack but finds unapplied port, credential, or runtime configuration. The report includes a structured warning and @@ -80,11 +82,14 @@ test is not evidence that an unimplemented command already satisfies the behavio The fixture validator therefore checks more than catalog shape: selected, written, and effected identities must be declared; starts of existing stacks must declare a stopped lifecycle; managed -state creation must publish its registry record; tracked identity markers must remain untouched; -runtime effects must agree with permitted state writes; and human/API/JSON projections cannot -contradict the managed result. We deliberately do not introduce a parallel test-only identity -resolver; it would duplicate product policy before the real managed surface exists and could pass -while the production implementation drifts. +state creation and registry publication must imply each other; Git identity writes must use the +correct common or worktree scope; selected linked worktrees must declare their own Git state; +tracked identity markers must remain untouched; native qualification facts must partition the +service matrix; status operations must remain read-only reports; runtime effects must agree with +permitted state writes; and human/API/JSON projections cannot contradict the managed result. We +deliberately do not introduce a parallel test-only identity resolver; it would duplicate product +policy before the real managed surface exists and could pass while the production implementation +drifts. ## Implementation Handoff diff --git a/packages/stack/src/managed-stack-contract.integration.test.ts b/packages/stack/src/managed-stack-contract.integration.test.ts index c9f5bad427..e91efa8123 100644 --- a/packages/stack/src/managed-stack-contract.integration.test.ts +++ b/packages/stack/src/managed-stack-contract.integration.test.ts @@ -141,6 +141,19 @@ describe("managed stack acceptance contract", () => { `${absentLegacyScenario.id}: managed-state create requires registry publication`, ); + const publishedWithoutState = { + ...absentLegacyScenario, + expected: { + ...absentLegacyScenario.expected, + writes: absentLegacyScenario.expected.writes.filter( + ({ target }) => target !== "managed-state", + ), + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([publishedWithoutState])).toContain( + `${absentLegacyScenario.id}: registry publication requires managed-state creation or copy`, + ); + const stoppedStackScenario = managedStackContractFixtures.find( ({ id }) => id === "reclamation.default-stop-preserves-data", ); @@ -172,6 +185,21 @@ describe("managed stack acceptance contract", () => { `${folderToGitScenario.id}: folder-to-Git identity project-a must be persisted in Git-local metadata`, ); + const incorrectlyScopedGitIdentity = { + ...folderToGitScenario, + expected: { + ...folderToGitScenario.expected, + writes: folderToGitScenario.expected.writes.map((write) => + write.target === "git-config" && write.id === "project-a" + ? { ...write, scope: "worktree" } + : write, + ), + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([incorrectlyScopedGitIdentity])).toContain( + `${folderToGitScenario.id}: Git identity project-a must use common config scope`, + ); + const qualificationScenario = managedStackContractFixtures.find( ({ id }) => id === "native-qualification.all-services-qualify-platform", ); @@ -209,7 +237,7 @@ describe("managed stack acceptance contract", () => { }, } satisfies ManagedStackContractScenario; expect(validateManagedStackContractFixtures([mutatingStatus])).toContain( - `${statusScenario.id}: successful status commands must report`, + `${statusScenario.id}: successful status operations must report`, ); const stateWritingStatus = { @@ -220,7 +248,49 @@ describe("managed stack acceptance contract", () => { }, } satisfies ManagedStackContractScenario; expect(validateManagedStackContractFixtures([stateWritingStatus])).toContain( - `${statusScenario.id}: status commands must not mutate state`, + `${statusScenario.id}: status operations must not mutate state`, + ); + + const apiStatusScenario: ManagedStackContractScenario | undefined = + managedStackContractFixtures.find( + ({ id }) => id === "identity.same-checkout-branch-and-name-reuses-stack", + ); + if (apiStatusScenario === undefined || apiStatusScenario.expected.output.api === undefined) { + throw new Error( + "identity.same-checkout-branch-and-name-reuses-stack API fixture is required", + ); + } + const apiStatusReturningReuse = { + ...apiStatusScenario, + expected: { + ...apiStatusScenario.expected, + outcome: "reuse", + output: { + ...apiStatusScenario.expected.output, + api: { ...apiStatusScenario.expected.output.api, outcome: "reuse" }, + }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([apiStatusReturningReuse])).toContain( + `${apiStatusScenario.id}: successful status operations must report`, + ); + + const bareWorktreeScenario = managedStackContractFixtures.find( + ({ id }) => id === "identity.bare-repository-linked-worktrees-share-project", + ); + if (bareWorktreeScenario === undefined) { + throw new Error( + "identity.bare-repository-linked-worktrees-share-project fixture is required", + ); + } + const siblingGitStateOnly = { + ...bareWorktreeScenario, + given: bareWorktreeScenario.given.map((fact) => + fact.kind === "git-state" ? { ...fact, workspacePath: "worktree-a" } : fact, + ), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([siblingGitStateOnly])).toContain( + `${bareWorktreeScenario.id}: resolving worktree worktree-b requires its Git state`, ); }); diff --git a/packages/stack/src/managed-stack-contract.ts b/packages/stack/src/managed-stack-contract.ts index 1eb5efd35e..32fd46cffe 100644 --- a/packages/stack/src/managed-stack-contract.ts +++ b/packages/stack/src/managed-stack-contract.ts @@ -31,6 +31,7 @@ export type ManagedStackContractFact = } | { readonly kind: "git-state"; + readonly workspacePath: string; readonly commonDirectory: string; readonly gitDirectory: string; readonly head: "branch" | "detached"; @@ -183,18 +184,33 @@ export interface ManagedStackContractOutput { readonly api?: Readonly>; } +type ManagedStackContractWrite = + | { + readonly target: "git-config"; + readonly operation: "create" | "update"; + readonly id: string; + readonly scope: "common" | "worktree"; + } + | { + readonly target: + | "ephemeral-state" + | "identity-marker" + | "managed-state" + | "registry" + | "runtime-state"; + readonly operation: + | "copy" + | "create" + | "delete" + | "publish" + | "start" + | "tombstone" + | "update"; + readonly id?: string; + }; + export interface ManagedStackContractEffects { - readonly writes: ReadonlyArray<{ - readonly target: - | "git-config" - | "ephemeral-state" - | "identity-marker" - | "managed-state" - | "registry" - | "runtime-state"; - readonly operation: "copy" | "create" | "delete" | "publish" | "start" | "tombstone" | "update"; - readonly id?: string; - }>; + readonly writes: ReadonlyArray; readonly runtimeEffects: ReadonlyArray<{ readonly operation: "copy" | "delete" | "start" | "stop"; readonly stackId?: string; @@ -324,19 +340,22 @@ export const validateManagedStackContractFixtures = ( } } - const isCliStatus = scenario.when.interface === "cli" && scenario.when.argv[0] === "status"; + const isStatusOperation = + (scenario.when.interface === "cli" && scenario.when.argv[0] === "status") || + ((scenario.when.interface === "managed-api" || scenario.when.interface === "stack-api") && + scenario.when.input.operation === "status"); if ( - isCliStatus && + isStatusOperation && scenario.expected.outcome !== "error" && scenario.expected.outcome !== "report" ) { - errors.push(`${scenario.id}: successful status commands must report`); + errors.push(`${scenario.id}: successful status operations must report`); } if ( - isCliStatus && + isStatusOperation && (scenario.expected.writes.length > 0 || scenario.expected.runtimeEffects.length > 0) ) { - errors.push(`${scenario.id}: status commands must not mutate state`); + errors.push(`${scenario.id}: status operations must not mutate state`); } const { output } = scenario.expected; @@ -364,14 +383,20 @@ export const validateManagedStackContractFixtures = ( } const declaredIds = new Set(); + const projectIds = new Set(); + const checkoutIds = new Set(); + const contextIds = new Set(); for (const fact of scenario.given) { switch (fact.kind) { case "branch": declaredIds.add(fact.contextId); + contextIds.add(fact.contextId); break; case "checkout": declaredIds.add(fact.projectId); declaredIds.add(fact.checkoutId); + projectIds.add(fact.projectId); + checkoutIds.add(fact.checkoutId); break; case "credential-state": declaredIds.add(fact.valuesId); @@ -381,6 +406,17 @@ export const validateManagedStackContractFixtures = ( break; case "identity-claim": declaredIds.add(fact.id); + switch (fact.scope) { + case "checkout": + checkoutIds.add(fact.id); + break; + case "context": + contextIds.add(fact.id); + break; + case "project": + projectIds.add(fact.id); + break; + } break; case "managed-record": case "managed-target": @@ -398,12 +434,32 @@ export const validateManagedStackContractFixtures = ( case "stack": declaredIds.add(fact.contextId); declaredIds.add(fact.stackId); + contextIds.add(fact.contextId); break; default: break; } } + const actionCwd = + scenario.when.interface === "cli" || scenario.when.interface === "git" + ? scenario.when.cwd + : typeof scenario.when.input.cwd === "string" + ? scenario.when.input.cwd + : undefined; + if ( + actionCwd !== undefined && + scenario.given.some( + (fact) => + fact.kind === "workspace" && + fact.path === actionCwd && + (fact.mode === "bare-worktree" || fact.mode === "linked-worktree"), + ) && + !scenario.given.some((fact) => fact.kind === "git-state" && fact.workspacePath === actionCwd) + ) { + errors.push(`${scenario.id}: resolving worktree ${actionCwd} requires its Git state`); + } + for (const fact of scenario.given) { if (fact.kind !== "native-qualification") { continue; @@ -501,6 +557,9 @@ export const validateManagedStackContractFixtures = ( const selection = scenario.expected.selection; if (selection !== undefined) { + projectIds.add(selection.projectId); + checkoutIds.add(selection.checkoutId); + contextIds.add(selection.contextId); for (const id of [ selection.projectId, selection.checkoutId, @@ -531,6 +590,22 @@ export const validateManagedStackContractFixtures = ( } } + for (const write of scenario.expected.writes) { + if (write.target !== "git-config") { + continue; + } + const expectedScope = projectIds.has(write.id) + ? "common" + : checkoutIds.has(write.id) || contextIds.has(write.id) + ? "worktree" + : undefined; + if (expectedScope !== undefined && write.scope !== expectedScope) { + errors.push( + `${scenario.id}: Git identity ${write.id} must use ${expectedScope} config scope`, + ); + } + } + for (const effect of scenario.expected.runtimeEffects) { if (effect.stackId !== undefined && !declaredIds.has(effect.stackId)) { errors.push(`${scenario.id}: runtime effect references undeclared ID ${effect.stackId}`); @@ -580,6 +655,19 @@ export const validateManagedStackContractFixtures = ( ); } + if ( + write.target === "registry" && + write.operation === "publish" && + !scenario.expected.writes.some( + (candidate) => + candidate.target === "managed-state" && + (candidate.operation === "create" || candidate.operation === "copy") && + candidate.id === write.id, + ) + ) { + errors.push(`${scenario.id}: registry publication requires managed-state creation or copy`); + } + const requiredRuntimeOperation = write.target === "runtime-state" && write.operation === "start" ? "start" @@ -699,7 +787,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ input: { cwd: "checkout-a", stackName: "default", operation: "status" }, }, expected: { - outcome: "reuse", + outcome: "report", selection: { projectId: "project-a", checkoutId: "checkout-a", @@ -711,7 +799,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ runtimeEffects: [], output: { api: { - outcome: "reuse", + outcome: "report", projectId: "project-a", checkoutId: "checkout-a", contextId: "context-main", @@ -729,6 +817,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ { kind: "workspace", mode: "git", path: "checkout-a" }, { kind: "git-state", + workspacePath: "checkout-a", commonDirectory: "repo/.git", gitDirectory: "repo/.git", head: "branch", @@ -760,6 +849,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ { kind: "branch", name: "main", contextId: "context-main", checkedOut: false }, { kind: "git-state", + workspacePath: "checkout-a", commonDirectory: "repo/.git", gitDirectory: "repo/.git", head: "branch", @@ -778,7 +868,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ stackName: "default", }, writes: [ - { target: "git-config", operation: "create", id: "context-feat-a" }, + { target: "git-config", operation: "create", id: "context-feat-a", scope: "worktree" }, { target: "registry", operation: "publish", id: "stack-feat-a-default" }, { target: "managed-state", operation: "create", id: "stack-feat-a-default" }, { target: "runtime-state", operation: "start", id: "stack-feat-a-default" }, @@ -828,7 +918,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ stackName: "default", }, writes: [ - { target: "git-config", operation: "update", id: "context-feat" }, + { target: "git-config", operation: "update", id: "context-feat", scope: "worktree" }, { target: "runtime-state", operation: "start", id: "stack-feat-default" }, ], runtimeEffects: [{ operation: "start", stackId: "stack-feat-default" }], @@ -868,7 +958,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ stackName: "default", }, writes: [ - { target: "git-config", operation: "create", id: "context-new" }, + { target: "git-config", operation: "create", id: "context-new", scope: "worktree" }, { target: "registry", operation: "publish", id: "stack-new-default" }, { target: "managed-state", operation: "create", id: "stack-new-default" }, { target: "runtime-state", operation: "start", id: "stack-new-default" }, @@ -905,6 +995,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ }, { kind: "git-state", + workspacePath: "checkout-a", commonDirectory: "repo/.git", gitDirectory: "repo/.git", head: "branch", @@ -918,7 +1009,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ input: { cwd: "checkout-a", stackName: "default", operation: "status" }, }, expected: { - outcome: "reuse", + outcome: "report", selection: { projectId: "project-a", checkoutId: "checkout-a", @@ -930,7 +1021,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ runtimeEffects: [], output: { api: { - outcome: "reuse", + outcome: "report", contextId: "context-feat", stackId: "stack-feat-default", otherContextId: "context-main", @@ -969,7 +1060,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ stackName: "default", }, writes: [ - { target: "git-config", operation: "create", id: "context-new" }, + { target: "git-config", operation: "create", id: "context-new", scope: "worktree" }, { target: "registry", operation: "publish", id: "stack-new-default" }, { target: "managed-state", operation: "create", id: "stack-new-default" }, { target: "runtime-state", operation: "start", id: "stack-new-default" }, @@ -995,6 +1086,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ { kind: "branch", name: "(detached)", contextId: "context-detached", checkedOut: true }, { kind: "git-state", + workspacePath: "checkout-a", commonDirectory: "repo/.git", gitDirectory: "repo/.git", head: "detached", @@ -1083,6 +1175,15 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ given: [ { kind: "workspace", mode: "linked-worktree", path: "worktree-a" }, { kind: "workspace", mode: "linked-worktree", path: "worktree-b" }, + { + kind: "git-state", + workspacePath: "worktree-b", + commonDirectory: "repo/.git", + gitDirectory: "repo/.git/worktrees/worktree-b", + head: "branch", + branch: "main", + commit: "commit-b", + }, { kind: "checkout", path: "worktree-a", projectId: "project-a", checkoutId: "checkout-a" }, { kind: "checkout", path: "worktree-b", projectId: "project-a", checkoutId: "checkout-b" }, ], @@ -1101,7 +1202,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ stackName: "default", }, writes: [ - { target: "git-config", operation: "create", id: "context-b-main" }, + { target: "git-config", operation: "create", id: "context-b-main", scope: "worktree" }, { target: "registry", operation: "publish", id: "stack-b-main-default" }, { target: "managed-state", operation: "create", id: "stack-b-main-default" }, { target: "runtime-state", operation: "start", id: "stack-b-main-default" }, @@ -1402,9 +1503,14 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ stackName: "default", }, writes: [ - { target: "git-config", operation: "create", id: "project-clone" }, - { target: "git-config", operation: "create", id: "checkout-clone" }, - { target: "git-config", operation: "create", id: "context-clone-main" }, + { target: "git-config", operation: "create", id: "project-clone", scope: "common" }, + { target: "git-config", operation: "create", id: "checkout-clone", scope: "worktree" }, + { + target: "git-config", + operation: "create", + id: "context-clone-main", + scope: "worktree", + }, { target: "registry", operation: "publish", id: "stack-clone-main-default" }, { target: "managed-state", operation: "create", id: "stack-clone-main-default" }, { target: "runtime-state", operation: "start", id: "stack-clone-main-default" }, @@ -1681,7 +1787,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ stackName: "default", }, writes: [ - { target: "git-config", operation: "update", id: "context-copy" }, + { target: "git-config", operation: "update", id: "context-copy", scope: "worktree" }, { target: "registry", operation: "publish", id: "stack-copy-default" }, { target: "managed-state", operation: "create", id: "stack-copy-default" }, { target: "runtime-state", operation: "start", id: "stack-copy-default" }, @@ -1791,7 +1897,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ stackName: "default", }, writes: [ - { target: "git-config", operation: "update", id: "context-main" }, + { target: "git-config", operation: "update", id: "context-main", scope: "worktree" }, { target: "runtime-state", operation: "start", id: "stack-main-default" }, ], runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], @@ -1813,6 +1919,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ { kind: "workspace", mode: "git", path: "/clone/project-a", clonedFrom: "/work/project-a" }, { kind: "git-state", + workspacePath: "/clone/project-a", commonDirectory: "/clone/project-a/.git", gitDirectory: "/clone/project-a/.git", head: "branch", @@ -1833,9 +1940,14 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ stackName: "default", }, writes: [ - { target: "git-config", operation: "create", id: "project-clone" }, - { target: "git-config", operation: "create", id: "checkout-clone" }, - { target: "git-config", operation: "create", id: "context-clone-main" }, + { target: "git-config", operation: "create", id: "project-clone", scope: "common" }, + { target: "git-config", operation: "create", id: "checkout-clone", scope: "worktree" }, + { + target: "git-config", + operation: "create", + id: "context-clone-main", + scope: "worktree", + }, { target: "registry", operation: "publish", id: "stack-clone-main-default" }, { target: "managed-state", operation: "create", id: "stack-clone-main-default" }, { target: "runtime-state", operation: "start", id: "stack-clone-main-default" }, @@ -1905,9 +2017,9 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ stackName: "default", }, writes: [ - { target: "git-config", operation: "create", id: "project-a" }, - { target: "git-config", operation: "create", id: "checkout-a" }, - { target: "git-config", operation: "create", id: "context-main" }, + { target: "git-config", operation: "create", id: "project-a", scope: "common" }, + { target: "git-config", operation: "create", id: "checkout-a", scope: "worktree" }, + { target: "git-config", operation: "create", id: "context-main", scope: "worktree" }, { target: "runtime-state", operation: "start", id: "stack-main-default" }, ], runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], @@ -1947,9 +2059,9 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ stackName: "default", }, writes: [ - { target: "git-config", operation: "create", id: "project-git" }, - { target: "git-config", operation: "create", id: "checkout-git" }, - { target: "git-config", operation: "create", id: "context-git-main" }, + { target: "git-config", operation: "create", id: "project-git", scope: "common" }, + { target: "git-config", operation: "create", id: "checkout-git", scope: "worktree" }, + { target: "git-config", operation: "create", id: "context-git-main", scope: "worktree" }, { target: "registry", operation: "publish", id: "stack-git-default" }, { target: "managed-state", operation: "create", id: "stack-git-default" }, { target: "runtime-state", operation: "start", id: "stack-git-default" }, @@ -2014,8 +2126,9 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ { kind: "workspace", mode: "bare-worktree", path: "worktree-b" }, { kind: "git-state", + workspacePath: "worktree-b", commonDirectory: "repo.git", - gitDirectory: "repo.git/worktrees/worktree-a", + gitDirectory: "repo.git/worktrees/worktree-b", head: "branch", branch: "main", commit: "commit-a", @@ -2038,8 +2151,8 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ stackName: "default", }, writes: [ - { target: "git-config", operation: "create", id: "checkout-b" }, - { target: "git-config", operation: "create", id: "context-b-main" }, + { target: "git-config", operation: "create", id: "checkout-b", scope: "worktree" }, + { target: "git-config", operation: "create", id: "context-b-main", scope: "worktree" }, { target: "registry", operation: "publish", id: "stack-b-main-default" }, { target: "managed-state", operation: "create", id: "stack-b-main-default" }, { target: "runtime-state", operation: "start", id: "stack-b-main-default" }, @@ -3658,6 +3771,7 @@ const additionalApiBoundaryContractFixtures = defineManagedStackContractFixtures { kind: "workspace", mode: "git", path: "checkout-a" }, { kind: "git-state", + workspacePath: "checkout-a", commonDirectory: "repo/.git", gitDirectory: "repo/.git", head: "branch", @@ -3698,6 +3812,7 @@ const additionalApiBoundaryContractFixtures = defineManagedStackContractFixtures { kind: "workspace", mode: "git", path: "checkout-a" }, { kind: "git-state", + workspacePath: "checkout-a", commonDirectory: "repo/.git", gitDirectory: "repo/.git", head: "branch", @@ -3726,9 +3841,9 @@ const additionalApiBoundaryContractFixtures = defineManagedStackContractFixtures stackName: "default", }, writes: [ - { target: "git-config", operation: "create", id: "project-a" }, - { target: "git-config", operation: "create", id: "checkout-a" }, - { target: "git-config", operation: "create", id: "context-main" }, + { target: "git-config", operation: "create", id: "project-a", scope: "common" }, + { target: "git-config", operation: "create", id: "checkout-a", scope: "worktree" }, + { target: "git-config", operation: "create", id: "context-main", scope: "worktree" }, { target: "registry", operation: "publish", id: "stack-main-default" }, { target: "managed-state", operation: "create", id: "stack-main-default" }, ], From c362e15fc7500e37e46c8ef46b6341fbb42c9bec Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 10 Aug 2026 18:17:45 +0200 Subject: [PATCH 09/41] test(stack): tighten cross-scenario invariants --- .../0015-managed-stack-contract-fixtures.md | 14 +-- ...managed-stack-contract.integration.test.ts | 80 ++++++++++++++ packages/stack/src/managed-stack-contract.ts | 102 +++++++++++++++++- 3 files changed, 187 insertions(+), 9 deletions(-) diff --git a/docs/adr/0015-managed-stack-contract-fixtures.md b/docs/adr/0015-managed-stack-contract-fixtures.md index 949e49cf15..0b7b4dbc81 100644 --- a/docs/adr/0015-managed-stack-contract-fixtures.md +++ b/docs/adr/0015-managed-stack-contract-fixtures.md @@ -83,13 +83,15 @@ test is not evidence that an unimplemented command already satisfies the behavio The fixture validator therefore checks more than catalog shape: selected, written, and effected identities must be declared; starts of existing stacks must declare a stopped lifecycle; managed state creation and registry publication must imply each other; Git identity writes must use the -correct common or worktree scope; selected linked worktrees must declare their own Git state; +correct common or worktree scope and cannot recreate an identity already declared by a checkout; +selected linked worktrees must declare their own Git state; managed sibling-port conflicts must +identify a distinct target; persisted-runtime preflight failures must identify a stopped stack; tracked identity markers must remain untouched; native qualification facts must partition the -service matrix; status operations must remain read-only reports; runtime effects must agree with -permitted state writes; and human/API/JSON projections cannot contradict the managed result. We -deliberately do not introduce a parallel test-only identity resolver; it would duplicate product -policy before the real managed surface exists and could pass while the production implementation -drifts. +service matrix; status operations must remain read-only reports; portable runtime projections must +match their referenced scenario; runtime effects must agree with permitted state writes; and +human/API/JSON projections cannot contradict the managed result. We deliberately do not introduce +a parallel test-only identity resolver; it would duplicate product policy before the real managed +surface exists and could pass while the production implementation drifts. ## Implementation Handoff diff --git a/packages/stack/src/managed-stack-contract.integration.test.ts b/packages/stack/src/managed-stack-contract.integration.test.ts index e91efa8123..d027f0480d 100644 --- a/packages/stack/src/managed-stack-contract.integration.test.ts +++ b/packages/stack/src/managed-stack-contract.integration.test.ts @@ -292,6 +292,86 @@ describe("managed stack acceptance contract", () => { expect(validateManagedStackContractFixtures([siblingGitStateOnly])).toContain( `${bareWorktreeScenario.id}: resolving worktree worktree-b requires its Git state`, ); + + const recreatedCheckoutIdentity = { + ...bareWorktreeScenario, + expected: { + ...bareWorktreeScenario.expected, + writes: [ + ...bareWorktreeScenario.expected.writes, + { + target: "git-config", + operation: "create", + id: "checkout-b", + scope: "worktree", + }, + ], + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([recreatedCheckoutIdentity])).toContain( + `${bareWorktreeScenario.id}: Git identity checkout-b is already declared`, + ); + + const unavailableRuntimeScenario = managedStackContractFixtures.find( + ({ id }) => id === "runtime.missing-persisted-prerequisite-fails", + ); + if (unavailableRuntimeScenario === undefined) { + throw new Error("runtime.missing-persisted-prerequisite-fails fixture is required"); + } + const ambiguousRuntimeFailure = { + ...unavailableRuntimeScenario, + given: unavailableRuntimeScenario.given.filter(({ kind }) => kind !== "stack"), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([ambiguousRuntimeFailure])).toContain( + `${unavailableRuntimeScenario.id}: persisted runtime failure for stack-main-default requires an explicit stopped lifecycle`, + ); + + const portabilityScenario = managedStackContractFixtures.find( + ({ id }) => id === "api-boundary.managed-surface-is-node-and-bun-portable", + ); + if (portabilityScenario === undefined) { + throw new Error("api-boundary.managed-surface-is-node-and-bun-portable fixture is required"); + } + const divergentPortableResult = { + ...portabilityScenario, + expected: { + ...portabilityScenario.expected, + output: { + ...portabilityScenario.expected.output, + api: { + node: { outcome: "reuse", stackId: "stack-main-default" }, + bun: { outcome: "report", stackId: "stack-main-default" }, + equal: true, + }, + }, + }, + } satisfies ManagedStackContractScenario; + expect( + validateManagedStackContractFixtures([apiStatusScenario, divergentPortableResult]), + ).toContain( + `${portabilityScenario.id}: portable node outcome must match ${apiStatusScenario.id}`, + ); + + const siblingPortScenario: ManagedStackContractScenario | undefined = + managedStackContractFixtures.find( + ({ id }) => id === "ports.explicit-port-conflict-with-sibling-fails", + ); + if (siblingPortScenario?.expected.selection === undefined) { + throw new Error("ports.explicit-port-conflict-with-sibling-fails selection is required"); + } + const targetOwnsConflictingPort = { + ...siblingPortScenario, + expected: { + ...siblingPortScenario.expected, + selection: { + ...siblingPortScenario.expected.selection, + stackId: "stack-main-default", + }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([targetOwnsConflictingPort])).toContain( + `${siblingPortScenario.id}: managed sibling port owner must differ from the selected target`, + ); }); it("covers the approved identity journeys through public commands and APIs", () => { diff --git a/packages/stack/src/managed-stack-contract.ts b/packages/stack/src/managed-stack-contract.ts index 32fd46cffe..8a4777b426 100644 --- a/packages/stack/src/managed-stack-contract.ts +++ b/packages/stack/src/managed-stack-contract.ts @@ -303,6 +303,11 @@ const defineManagedStackContractFixtures = < fixtures: Fixtures, ): Fixtures => fixtures; +const isManagedStackContractRecord = ( + value: ManagedStackContractJson | undefined, +): value is Readonly> => + typeof value === "object" && value !== null && !Array.isArray(value); + export const validateManagedStackContractFixtures = ( fixtures: ReadonlyArray, ): ReadonlyArray => { @@ -310,6 +315,7 @@ export const validateManagedStackContractFixtures = ( const ids = new Set(); const nativeServices = managedNativeServiceMatrix.services.map(([service]) => service); const nativeServiceSet = new Set(nativeServices); + const fixturesById = new Map(fixtures.map((scenario) => [scenario.id, scenario])); for (const scenario of fixtures) { if (ids.has(scenario.id)) { @@ -363,6 +369,30 @@ export const validateManagedStackContractFixtures = ( errors.push(`${scenario.id}: at least one observable output is required`); } + if ( + scenario.when.interface === "managed-api" && + scenario.when.method === "runPortableContract" + ) { + const referencedId = scenario.when.input.scenarioId; + const referencedScenario = + typeof referencedId === "string" ? fixturesById.get(referencedId) : undefined; + if (referencedScenario === undefined) { + errors.push(`${scenario.id}: portable contract must reference a declared scenario`); + } else { + for (const runtime of ["node", "bun"]) { + const runtimeResult = output.api?.[runtime]; + if ( + !isManagedStackContractRecord(runtimeResult) || + runtimeResult.outcome !== referencedScenario.expected.outcome + ) { + errors.push( + `${scenario.id}: portable ${runtime} outcome must match ${referencedScenario.id}`, + ); + } + } + } + } + if (scenario.expected.outcome === "error") { if (scenario.expected.error === undefined) { errors.push(`${scenario.id}: error outcome requires structured error metadata`); @@ -386,6 +416,7 @@ export const validateManagedStackContractFixtures = ( const projectIds = new Set(); const checkoutIds = new Set(); const contextIds = new Set(); + const existingCheckoutIdentityIds = new Set(); for (const fact of scenario.given) { switch (fact.kind) { case "branch": @@ -397,6 +428,8 @@ export const validateManagedStackContractFixtures = ( declaredIds.add(fact.checkoutId); projectIds.add(fact.projectId); checkoutIds.add(fact.checkoutId); + existingCheckoutIdentityIds.add(fact.projectId); + existingCheckoutIdentityIds.add(fact.checkoutId); break; case "credential-state": declaredIds.add(fact.valuesId); @@ -531,6 +564,25 @@ export const validateManagedStackContractFixtures = ( } } + if (scenario.expected.error?.code === "persisted_runtime_unavailable") { + for (const fact of scenario.given) { + if (fact.kind !== "persisted-runtime") { + continue; + } + const explicitlyStopped = scenario.given.some( + (candidate) => + candidate.kind === "stack" && + candidate.stackId === fact.stackId && + candidate.lifecycle === "stopped", + ); + if (!explicitlyStopped) { + errors.push( + `${scenario.id}: persisted runtime failure for ${fact.stackId} requires an explicit stopped lifecycle`, + ); + } + } + } + for (const write of scenario.expected.writes) { if ( write.id !== undefined && @@ -590,10 +642,33 @@ export const validateManagedStackContractFixtures = ( } } + if ( + scenario.expected.error?.code === "exact_port_occupied" && + scenario.given.some((fact) => fact.kind === "occupied-port" && fact.owner === "managed-stack") + ) { + if (selection === undefined) { + errors.push(`${scenario.id}: managed sibling port conflict requires a selected target`); + } else if ( + scenario.given.some( + (fact) => + fact.kind === "occupied-port" && + fact.owner === "managed-stack" && + fact.ownerId === selection.stackId, + ) + ) { + errors.push( + `${scenario.id}: managed sibling port owner must differ from the selected target`, + ); + } + } + for (const write of scenario.expected.writes) { if (write.target !== "git-config") { continue; } + if (write.operation === "create" && existingCheckoutIdentityIds.has(write.id)) { + errors.push(`${scenario.id}: Git identity ${write.id} is already declared`); + } const expectedScope = projectIds.has(write.id) ? "common" : checkoutIds.has(write.id) || contextIds.has(write.id) @@ -2151,7 +2226,6 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ stackName: "default", }, writes: [ - { target: "git-config", operation: "create", id: "checkout-b", scope: "worktree" }, { target: "git-config", operation: "create", id: "context-b-main", scope: "worktree" }, { target: "registry", operation: "publish", id: "stack-b-main-default" }, { target: "managed-state", operation: "create", id: "stack-b-main-default" }, @@ -2928,6 +3002,13 @@ const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ area: "runtime", given: [ { kind: "persisted-runtime", stackId: "stack-main-default", runtime: "native" }, + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + contextId: "context-main", + lifecycle: "stopped", + }, { kind: "runtime-request", source: "default", runtime: "auto" }, { kind: "runtime-availability", @@ -3987,8 +4068,8 @@ const additionalApiBoundaryContractFixtures = defineManagedStackContractFixtures details: { results_equal: true, bun_specific_state_api: false }, output: { api: { - node: { outcome: "reuse", stackId: "stack-main-default" }, - bun: { outcome: "reuse", stackId: "stack-main-default" }, + node: { outcome: "report", stackId: "stack-main-default" }, + bun: { outcome: "report", stackId: "stack-main-default" }, equal: true, }, }, @@ -4198,6 +4279,14 @@ export const managedStackContractFixtures = defineManagedStackContractFixtures([ title: "A sibling managed stack holding a declarative port is identified precisely", area: "ports", given: [ + { + kind: "checkout", + path: "worktree-feat-a", + projectId: "project-a", + checkoutId: "checkout-feat-a", + }, + { kind: "branch", name: "feat-a", contextId: "context-feat-a", checkedOut: true }, + { kind: "managed-target", stackId: "stack-feat-a-default", exists: false }, { kind: "config-port", key: "api.port", @@ -4219,6 +4308,13 @@ export const managedStackContractFixtures = defineManagedStackContractFixtures([ }, expected: { outcome: "error", + selection: { + projectId: "project-a", + checkoutId: "checkout-feat-a", + contextId: "context-feat-a", + stackId: "stack-feat-a-default", + stackName: "default", + }, error: { code: "exact_port_occupied", message: "api.port requires 54321, but stack-main-default already owns that port", From aac1248fa663c31f7247b0fab04927e0bf93c6fc Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 10 Aug 2026 18:37:40 +0200 Subject: [PATCH 10/41] test(stack): make contract transitions executable --- .../0015-managed-stack-contract-fixtures.md | 16 +- ...managed-stack-contract.integration.test.ts | 120 +++++++- packages/stack/src/managed-stack-contract.ts | 288 +++++++++++++++--- 3 files changed, 371 insertions(+), 53 deletions(-) diff --git a/docs/adr/0015-managed-stack-contract-fixtures.md b/docs/adr/0015-managed-stack-contract-fixtures.md index 0b7b4dbc81..93f6cf01c1 100644 --- a/docs/adr/0015-managed-stack-contract-fixtures.md +++ b/docs/adr/0015-managed-stack-contract-fixtures.md @@ -84,14 +84,18 @@ The fixture validator therefore checks more than catalog shape: selected, writte identities must be declared; starts of existing stacks must declare a stopped lifecycle; managed state creation and registry publication must imply each other; Git identity writes must use the correct common or worktree scope and cannot recreate an identity already declared by a checkout; -selected linked worktrees must declare their own Git state; managed sibling-port conflicts must -identify a distinct target; persisted-runtime preflight failures must identify a stopped stack; +new Git-derived contexts and selected linked worktrees must declare the relevant Git state; managed +and sticky port conflicts and persisted-runtime conflicts must identify their actual target; +persisted-runtime preflight failures must identify a stopped stack; a successful bootstrap retry +must follow an explicit rolled-back attempt; configured-credential creation must prove that global +state contains references rather than plaintext; data-preserving prune must begin with mutable data; tracked identity markers must remain untouched; native qualification facts must partition the service matrix; status operations must remain read-only reports; portable runtime projections must -match their referenced scenario; runtime effects must agree with permitted state writes; and -human/API/JSON projections cannot contradict the managed result. We deliberately do not introduce -a parallel test-only identity resolver; it would duplicate product policy before the real managed -surface exists and could pass while the production implementation drifts. +match their referenced scenario; destructive runtime effects must map to mutable-state deletion; +other runtime effects must agree with permitted state writes; and human/API/JSON projections cannot +contradict the managed result. We deliberately do not introduce a parallel test-only identity +resolver; it would duplicate product policy before the real managed surface exists and could pass +while the production implementation drifts. ## Implementation Handoff diff --git a/packages/stack/src/managed-stack-contract.integration.test.ts b/packages/stack/src/managed-stack-contract.integration.test.ts index d027f0480d..47fdd0f12b 100644 --- a/packages/stack/src/managed-stack-contract.integration.test.ts +++ b/packages/stack/src/managed-stack-contract.integration.test.ts @@ -372,6 +372,118 @@ describe("managed stack acceptance contract", () => { expect(validateManagedStackContractFixtures([targetOwnsConflictingPort])).toContain( `${siblingPortScenario.id}: managed sibling port owner must differ from the selected target`, ); + + const stickyPortScenario: ManagedStackContractScenario | undefined = + managedStackContractFixtures.find( + ({ id }) => id === "ports.later-sticky-port-collision-fails", + ); + if (stickyPortScenario === undefined) { + throw new Error("ports.later-sticky-port-collision-fails fixture is required"); + } + const stickyPortWithoutStoppedTarget = { + ...stickyPortScenario, + given: stickyPortScenario.given.filter(({ kind }) => kind !== "stack"), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([stickyPortWithoutStoppedTarget])).toContain( + `${stickyPortScenario.id}: sticky port conflict requires a stopped selected stack`, + ); + + const runtimeConflictScenario: ManagedStackContractScenario | undefined = + managedStackContractFixtures.find( + ({ id }) => id === "runtime.persisted-runtime-conflict-fails", + ); + if (runtimeConflictScenario === undefined) { + throw new Error("runtime.persisted-runtime-conflict-fails fixture is required"); + } + const unrelatedPersistedRuntime = { + ...runtimeConflictScenario, + given: runtimeConflictScenario.given.map((fact) => + fact.kind === "persisted-runtime" ? { ...fact, stackId: "stack-other" } : fact, + ), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([unrelatedPersistedRuntime])).toContain( + `${runtimeConflictScenario.id}: persisted runtime must belong to the selected target`, + ); + + const freshCloneScenario = managedStackContractFixtures.find( + ({ id }) => id === "identity.fresh-clone-creates-project-and-checkout", + ); + if (freshCloneScenario === undefined) { + throw new Error("identity.fresh-clone-creates-project-and-checkout fixture is required"); + } + const cloneWithoutGitState = { + ...freshCloneScenario, + given: freshCloneScenario.given.filter(({ kind }) => kind !== "git-state"), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([cloneWithoutGitState])).toContain( + `${freshCloneScenario.id}: creating a Git context requires Git state for the workspace`, + ); + + const retryScenario = managedStackContractFixtures.find( + ({ id }) => id === "bootstrap.retry-after-failed-copy-succeeds", + ); + if (retryScenario === undefined) { + throw new Error("bootstrap.retry-after-failed-copy-succeeds fixture is required"); + } + const retryWithoutRollback = { + ...retryScenario, + given: retryScenario.given.filter(({ kind }) => kind !== "operation-result"), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([retryWithoutRollback])).toContain( + `${retryScenario.id}: bootstrap retry requires a rolled-back prior attempt`, + ); + + const configuredCredentialsScenario: ManagedStackContractScenario | undefined = + managedStackContractFixtures.find( + ({ id }) => id === "credentials.configured-values-are-authoritative", + ); + if (configuredCredentialsScenario === undefined) { + throw new Error("credentials.configured-values-are-authoritative fixture is required"); + } + const globallyPersistedPlaintext = { + ...configuredCredentialsScenario, + expected: { + ...configuredCredentialsScenario.expected, + details: { + ...configuredCredentialsScenario.expected.details, + plaintext_secrets_in_global_state: true, + }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([globallyPersistedPlaintext])).toContain( + `${configuredCredentialsScenario.id}: configured credentials must not persist plaintext globally`, + ); + + const pruneScenario = managedStackContractFixtures.find( + ({ id }) => id === "reclamation.prune-removes-metadata-only", + ); + if (pruneScenario === undefined) { + throw new Error("reclamation.prune-removes-metadata-only fixture is required"); + } + const pruneWithoutMutableData = { + ...pruneScenario, + given: pruneScenario.given.filter(({ kind }) => kind !== "stack"), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([pruneWithoutMutableData])).toContain( + `${pruneScenario.id}: data-preserving prune must declare mutable stack data`, + ); + + const deleteScenario = managedStackContractFixtures.find( + ({ id }) => id === "reclamation.delete-orphan-by-stack-id", + ); + if (deleteScenario === undefined) { + throw new Error("reclamation.delete-orphan-by-stack-id fixture is required"); + } + const runtimeMetadataOnlyDelete = { + ...deleteScenario, + expected: { + ...deleteScenario.expected, + writes: deleteScenario.expected.writes.filter((write) => write.target !== "managed-state"), + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([runtimeMetadataOnlyDelete])).toContain( + `${deleteScenario.id}: delete runtime effect requires a matching state write`, + ); }); it("covers the approved identity journeys through public commands and APIs", () => { @@ -502,10 +614,11 @@ describe("managed stack acceptance contract", () => { [ "bootstrap.absent-legacy-starts-fresh", "bootstrap.existing-managed-target-ignores-legacy", - "bootstrap.failed-copy-rolls-back-and-retries", + "bootstrap.failed-copy-rolls-back", "bootstrap.first-start-copies-compatible-legacy-state", "bootstrap.incompatible-legacy-starts-fresh", "bootstrap.managed-and-legacy-diverge-after-copy", + "bootstrap.retry-after-failed-copy-succeeds", "bootstrap.running-legacy-source-fails-without-mutation", ].sort(), ); @@ -523,7 +636,6 @@ describe("managed stack acceptance contract", () => { "credentials.configured-values-are-authoritative", "credentials.explicit-change-applies-after-stop", "credentials.omitted-values-use-stable-defaults", - "credentials.plaintext-secrets-stay-out-of-global-state", "credentials.running-change-reports-drift", "credentials.unchanged-values-survive-restart", ].sort(), @@ -773,7 +885,7 @@ describe("managed stack acceptance contract", () => { expect(scenario).toMatchObject({ area: "runtime", - given: [ + given: expect.arrayContaining([ { kind: "persisted-runtime", stackId: "stack-main-default", @@ -784,7 +896,7 @@ describe("managed stack acceptance contract", () => { source: "cli", runtime: "native", }, - ], + ]), when: { interface: "cli", argv: ["start", "--experimental", "--runtime", "native"], diff --git a/packages/stack/src/managed-stack-contract.ts b/packages/stack/src/managed-stack-contract.ts index 8a4777b426..9e13d87a00 100644 --- a/packages/stack/src/managed-stack-contract.ts +++ b/packages/stack/src/managed-stack-contract.ts @@ -72,6 +72,12 @@ export type ManagedStackContractFact = readonly target: string; readonly contenders: number; } + | { + readonly kind: "operation-result"; + readonly operation: "legacy-bootstrap"; + readonly stackId: string; + readonly outcome: "rolled-back"; + } | { readonly kind: "stack-names"; readonly names: ReadonlyArray; @@ -161,7 +167,6 @@ export type ManagedStackContractFact = readonly source: "configured" | "legacy" | "local-default" | "persisted"; readonly valuesId: string; readonly previousValuesId?: string; - readonly plaintextPresentInGlobalState?: boolean; } | { readonly kind: "direct-stack-options"; @@ -456,6 +461,9 @@ export const validateManagedStackContractFixtures = ( case "persisted-runtime": declaredIds.add(fact.stackId); break; + case "operation-result": + declaredIds.add(fact.stackId); + break; case "occupied-port": if (fact.ownerId !== undefined) { declaredIds.add(fact.ownerId); @@ -640,6 +648,33 @@ export const validateManagedStackContractFixtures = ( } } } + + const createsSelectedContext = scenario.expected.writes.some( + (write) => + write.target === "git-config" && + write.operation === "create" && + write.id === selection.contextId, + ); + const derivesContextFromGit = scenario.given.some( + (fact) => + fact.kind === "identity-transition" && + (fact.operation === "clone" || fact.operation === "folder-to-git"), + ); + const contextAlreadyDeclaredByBranch = scenario.given.some( + (fact) => + fact.kind === "branch" && fact.contextId === selection.contextId && fact.checkedOut, + ); + if ( + createsSelectedContext && + derivesContextFromGit && + !contextAlreadyDeclaredByBranch && + (actionCwd === undefined || + !scenario.given.some( + (fact) => fact.kind === "git-state" && fact.workspacePath === actionCwd, + )) + ) { + errors.push(`${scenario.id}: creating a Git context requires Git state for the workspace`); + } } if ( @@ -662,6 +697,94 @@ export const validateManagedStackContractFixtures = ( } } + if (scenario.expected.error?.code === "sticky_port_occupied") { + if (selection === undefined) { + errors.push(`${scenario.id}: sticky port conflict requires a selected target`); + } else { + if ( + !scenario.given.some( + (fact) => fact.kind === "port-assignment" && fact.stackId === selection.stackId, + ) + ) { + errors.push(`${scenario.id}: sticky port assignment must belong to the selected target`); + } + if ( + !scenario.given.some( + (fact) => + fact.kind === "stack" && + fact.stackId === selection.stackId && + fact.lifecycle === "stopped", + ) + ) { + errors.push(`${scenario.id}: sticky port conflict requires a stopped selected stack`); + } + } + } + + if (scenario.expected.error?.code === "runtime_conflicts_with_persisted_stack") { + if (selection === undefined) { + errors.push(`${scenario.id}: persisted runtime conflict requires a selected target`); + } else if ( + !scenario.given.some( + (fact) => fact.kind === "persisted-runtime" && fact.stackId === selection.stackId, + ) + ) { + errors.push(`${scenario.id}: persisted runtime must belong to the selected target`); + } + } + + if ( + scenario.given.some( + (fact) => fact.kind === "credential-state" && fact.source === "configured", + ) && + scenario.expected.writes.some( + (write) => write.target === "managed-state" && write.operation === "create", + ) && + scenario.expected.details?.plaintext_secrets_in_global_state !== false + ) { + errors.push(`${scenario.id}: configured credentials must not persist plaintext globally`); + } + + if (scenario.expected.details?.retry_after_rollback === true) { + const retryStackId = + scenario.when.interface === "managed-api" && scenario.when.method === "startStack" + ? scenario.when.input.stackId + : undefined; + if ( + typeof retryStackId !== "string" || + !scenario.given.some( + (fact) => + fact.kind === "operation-result" && + fact.operation === "legacy-bootstrap" && + fact.stackId === retryStackId && + fact.outcome === "rolled-back", + ) + ) { + errors.push(`${scenario.id}: bootstrap retry requires a rolled-back prior attempt`); + } + } + + if ( + scenario.when.interface === "cli" && + scenario.when.argv[0] === "stack" && + scenario.when.argv[1] === "prune" && + scenario.expected.details?.mutable_data_deleted === false + ) { + for (const write of scenario.expected.writes) { + if (write.target !== "registry" || write.operation !== "delete" || write.id === undefined) { + continue; + } + const mutableDataExists = scenario.given.some( + (fact) => + (fact.kind === "stack" && fact.stackId === write.id) || + (fact.kind === "managed-target" && fact.stackId === write.id && fact.exists), + ); + if (!mutableDataExists) { + errors.push(`${scenario.id}: data-preserving prune must declare mutable stack data`); + } + } + } + for (const write of scenario.expected.writes) { if (write.target !== "git-config") { continue; @@ -694,10 +817,7 @@ export const validateManagedStackContractFixtures = ( case "copy": return write.target === "managed-state" && write.operation === "copy"; case "delete": - return ( - (write.target === "managed-state" || write.target === "runtime-state") && - write.operation === "delete" - ); + return write.target === "managed-state" && write.operation === "delete"; case "start": return write.target === "runtime-state" && write.operation === "start"; case "stop": @@ -1559,6 +1679,15 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ area: "identity", given: [ { kind: "workspace", mode: "git", path: "/clone/project-a", clonedFrom: "/work/project-a" }, + { + kind: "git-state", + workspacePath: "/clone/project-a", + commonDirectory: "/clone/project-a/.git", + gitDirectory: "/clone/project-a/.git", + head: "branch", + branch: "main", + commit: "clone-commit", + }, { kind: "identity-transition", operation: "clone", @@ -2121,6 +2250,15 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ to: "git", }, { kind: "workspace", mode: "git", path: "/work/project-a", canonicalPath: "/work/project-a" }, + { + kind: "git-state", + workspacePath: "/work/project-a", + commonDirectory: "/work/project-a/.git", + gitDirectory: "/work/project-a/.git", + head: "branch", + branch: "main", + commit: "commit-a", + }, { kind: "identity-claim", scope: "project", id: "project-folder", status: "absent" }, ], when: { interface: "cli", argv: ["start", "--experimental"], cwd: "/work/project-a" }, @@ -2474,6 +2612,15 @@ const additionalPortContractFixtures = defineManagedStackContractFixtures([ title: "A later collision on a sticky automatic port fails without relocation", area: "ports", given: [ + { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, + { kind: "branch", name: "feat-a", contextId: "context-feat", checkedOut: true }, + { + kind: "stack", + name: "default", + stackId: "stack-feat-default", + contextId: "context-feat", + lifecycle: "stopped", + }, { kind: "port-assignment", stackId: "stack-feat-default", @@ -2486,6 +2633,13 @@ const additionalPortContractFixtures = defineManagedStackContractFixtures([ when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, expected: { outcome: "error", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-feat", + stackId: "stack-feat-default", + stackName: "default", + }, error: { code: "sticky_port_occupied", message: "stack-feat-default owns sticky api.port 55421, but it is in use", @@ -3356,8 +3510,8 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ }, }, { - id: "bootstrap.failed-copy-rolls-back-and-retries", - title: "A failed bootstrap leaves no active target and the same start retries safely", + id: "bootstrap.failed-copy-rolls-back", + title: "A failed bootstrap removes partial managed state before publication", area: "bootstrap", given: [ { kind: "managed-target", stackId: "stack-main-default", exists: false }, @@ -3387,7 +3541,6 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ active_target_exists: false, registry_record_published: false, legacy_state_mutated: false, - retry_is_same_command: true, }, output: { api: { @@ -3400,6 +3553,56 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ }, }, }, + { + id: "bootstrap.retry-after-failed-copy-succeeds", + title: "The same start succeeds after a failed bootstrap was rolled back", + area: "bootstrap", + given: [ + { + kind: "operation-result", + operation: "legacy-bootstrap", + stackId: "stack-main-default", + outcome: "rolled-back", + }, + { kind: "managed-target", stackId: "stack-main-default", exists: false }, + { + kind: "legacy-state", + lifecycle: "stopped", + database: "compatible", + storage: "compatible", + credentials: "compatible", + }, + ], + when: { + interface: "managed-api", + method: "startStack", + input: { stackId: "stack-main-default" }, + }, + expected: { + outcome: "create", + writes: [ + { target: "managed-state", operation: "copy", id: "stack-main-default" }, + { target: "registry", operation: "publish", id: "stack-main-default" }, + { target: "runtime-state", operation: "start", id: "stack-main-default" }, + ], + runtimeEffects: [ + { operation: "copy", stackId: "stack-main-default" }, + { operation: "start", stackId: "stack-main-default" }, + ], + details: { + retry_after_rollback: true, + same_start_request: true, + legacy_state_mutated: false, + }, + output: { + api: { + outcome: "create", + stackId: "stack-main-default", + bootstrap: "copied", + }, + }, + }, + }, { id: "bootstrap.managed-and-legacy-diverge-after-copy", title: "Managed starts never reread legacy state after a successful bootstrap", @@ -3439,7 +3642,7 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ }, { id: "credentials.configured-values-are-authoritative", - title: "Configured auth values are authoritative for a new managed stack", + title: "Configured auth values are authoritative and persist globally only by reference", area: "credentials", given: [{ kind: "credential-state", source: "configured", valuesId: "configured-auth-v1" }], when: { @@ -3455,7 +3658,12 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ { target: "runtime-state", operation: "start", id: "stack-main-default" }, ], runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], - details: { credential_values_id: "configured-auth-v1", source: "configured" }, + details: { + credential_values_id: "configured-auth-v1", + source: "configured", + global_credentials_reference: "configured-auth-v1", + plaintext_secrets_in_global_state: false, + }, output: { api: { stackId: "stack-main-default", @@ -3643,38 +3851,6 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ }, }, }, - { - id: "credentials.plaintext-secrets-stay-out-of-global-state", - title: "Resolved plaintext secrets are absent from the global managed registry", - area: "credentials", - given: [ - { - kind: "credential-state", - source: "persisted", - valuesId: "configured-auth-v1", - plaintextPresentInGlobalState: false, - }, - { kind: "managed-record", stackId: "stack-main-default", status: "active" }, - ], - when: { - interface: "managed-api", - method: "inspectGlobalRecord", - input: { stackId: "stack-main-default" }, - }, - expected: { - outcome: "report", - writes: [], - runtimeEffects: [], - details: { plaintext_secrets_present: false }, - output: { - api: { - stackId: "stack-main-default", - credentialsReference: "configured-auth-v1", - plaintextSecrets: [], - }, - }, - }, - }, { id: "reclamation.default-stop-preserves-data", title: "Default experimental stop preserves managed data", @@ -3751,7 +3927,17 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ id: "reclamation.prune-removes-metadata-only", title: "Prune removes orphan metadata without deleting mutable stack data", area: "reclamation", - given: [{ kind: "managed-record", stackId: "stack-orphan", status: "orphaned" }], + given: [ + { kind: "managed-record", stackId: "stack-orphan", status: "orphaned" }, + { + kind: "stack", + name: "default", + stackId: "stack-orphan", + contextId: "context-orphan", + lifecycle: "stopped", + orphaned: true, + }, + ], when: { interface: "cli", argv: ["stack", "prune", "--experimental"], cwd: "checkout-a" }, expected: { outcome: "update", @@ -4362,6 +4548,15 @@ export const managedStackContractFixtures = defineManagedStackContractFixtures([ title: "An existing stack cannot be switched to another runtime by start", area: "runtime", given: [ + { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, + { kind: "branch", name: "main", contextId: "context-main", checkedOut: true }, + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + contextId: "context-main", + lifecycle: "stopped", + }, { kind: "persisted-runtime", stackId: "stack-main-default", @@ -4380,6 +4575,13 @@ export const managedStackContractFixtures = defineManagedStackContractFixtures([ }, expected: { outcome: "error", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-main", + stackId: "stack-main-default", + stackName: "default", + }, error: { code: "runtime_conflicts_with_persisted_stack", message: "stack-main-default uses docker, but start requested native", From 3bce8a11c2e23a42134b96161cae479e0d0aa55f Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 10 Aug 2026 18:46:25 +0200 Subject: [PATCH 11/41] test(stack): cover credential persistence paths --- .../0015-managed-stack-contract-fixtures.md | 5 ++- ...managed-stack-contract.integration.test.ts | 41 +++++++++++-------- packages/stack/src/managed-stack-contract.ts | 25 +++++++---- 3 files changed, 44 insertions(+), 27 deletions(-) diff --git a/docs/adr/0015-managed-stack-contract-fixtures.md b/docs/adr/0015-managed-stack-contract-fixtures.md index 93f6cf01c1..d3b34378d3 100644 --- a/docs/adr/0015-managed-stack-contract-fixtures.md +++ b/docs/adr/0015-managed-stack-contract-fixtures.md @@ -87,8 +87,9 @@ correct common or worktree scope and cannot recreate an identity already declare new Git-derived contexts and selected linked worktrees must declare the relevant Git state; managed and sticky port conflicts and persisted-runtime conflicts must identify their actual target; persisted-runtime preflight failures must identify a stopped stack; a successful bootstrap retry -must follow an explicit rolled-back attempt; configured-credential creation must prove that global -state contains references rather than plaintext; data-preserving prune must begin with mutable data; +must follow an explicit rolled-back attempt; credential create, update, and copy operations must +prove that global state contains references rather than plaintext; data-preserving prune must begin +with mutable data; tracked identity markers must remain untouched; native qualification facts must partition the service matrix; status operations must remain read-only reports; portable runtime projections must match their referenced scenario; destructive runtime effects must map to mutable-state deletion; diff --git a/packages/stack/src/managed-stack-contract.integration.test.ts b/packages/stack/src/managed-stack-contract.integration.test.ts index 47fdd0f12b..8ffdcecbe8 100644 --- a/packages/stack/src/managed-stack-contract.integration.test.ts +++ b/packages/stack/src/managed-stack-contract.integration.test.ts @@ -433,26 +433,31 @@ describe("managed stack acceptance contract", () => { `${retryScenario.id}: bootstrap retry requires a rolled-back prior attempt`, ); - const configuredCredentialsScenario: ManagedStackContractScenario | undefined = - managedStackContractFixtures.find( - ({ id }) => id === "credentials.configured-values-are-authoritative", + for (const scenarioId of [ + "credentials.configured-values-are-authoritative", + "credentials.explicit-change-applies-after-stop", + "credentials.omitted-values-use-stable-defaults", + "credentials.compatible-legacy-auth-is-retained", + ]) { + const credentialPersistenceScenario: ManagedStackContractScenario | undefined = + managedStackContractFixtures.find(({ id }) => id === scenarioId); + if (credentialPersistenceScenario === undefined) { + throw new Error(`${scenarioId} fixture is required`); + } + const globallyPersistedPlaintext = { + ...credentialPersistenceScenario, + expected: { + ...credentialPersistenceScenario.expected, + details: { + ...credentialPersistenceScenario.expected.details, + plaintext_secrets_in_global_state: true, + }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([globallyPersistedPlaintext])).toContain( + `${credentialPersistenceScenario.id}: credential persistence must not expose plaintext globally`, ); - if (configuredCredentialsScenario === undefined) { - throw new Error("credentials.configured-values-are-authoritative fixture is required"); } - const globallyPersistedPlaintext = { - ...configuredCredentialsScenario, - expected: { - ...configuredCredentialsScenario.expected, - details: { - ...configuredCredentialsScenario.expected.details, - plaintext_secrets_in_global_state: true, - }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([globallyPersistedPlaintext])).toContain( - `${configuredCredentialsScenario.id}: configured credentials must not persist plaintext globally`, - ); const pruneScenario = managedStackContractFixtures.find( ({ id }) => id === "reclamation.prune-removes-metadata-only", diff --git a/packages/stack/src/managed-stack-contract.ts b/packages/stack/src/managed-stack-contract.ts index 9e13d87a00..e4c99fa251 100644 --- a/packages/stack/src/managed-stack-contract.ts +++ b/packages/stack/src/managed-stack-contract.ts @@ -734,15 +734,17 @@ export const validateManagedStackContractFixtures = ( } if ( - scenario.given.some( - (fact) => fact.kind === "credential-state" && fact.source === "configured", - ) && + scenario.given.some((fact) => fact.kind === "credential-state") && scenario.expected.writes.some( - (write) => write.target === "managed-state" && write.operation === "create", + (write) => + write.target === "managed-state" && + (write.operation === "copy" || + write.operation === "create" || + write.operation === "update"), ) && scenario.expected.details?.plaintext_secrets_in_global_state !== false ) { - errors.push(`${scenario.id}: configured credentials must not persist plaintext globally`); + errors.push(`${scenario.id}: credential persistence must not expose plaintext globally`); } if (scenario.expected.details?.retry_after_rollback === true) { @@ -3689,7 +3691,11 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ { target: "runtime-state", operation: "start", id: "stack-main-default" }, ], runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], - details: { credential_values_id: "stable-local-defaults-v1", generated_per_start: false }, + details: { + credential_values_id: "stable-local-defaults-v1", + generated_per_start: false, + plaintext_secrets_in_global_state: false, + }, output: { json: { stack_id: "stack-main-default", @@ -3751,6 +3757,7 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ { target: "runtime-state", operation: "start", id: "stack-main-default" }, ], runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], + details: { plaintext_secrets_in_global_state: false }, output: { json: { outcome: "update", @@ -3841,7 +3848,11 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ { operation: "copy", stackId: "stack-main-default" }, { operation: "start", stackId: "stack-main-default" }, ], - details: { credential_values_id: "legacy-auth-v1", legacy_state_mutated: false }, + details: { + credential_values_id: "legacy-auth-v1", + legacy_state_mutated: false, + plaintext_secrets_in_global_state: false, + }, output: { api: { stackId: "stack-main-default", From 258d6cc8f75905a2ec19a0b2569814a29227120c Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 10 Aug 2026 19:00:13 +0200 Subject: [PATCH 12/41] test(stack): close contract validation gaps --- .../0015-managed-stack-contract-fixtures.md | 20 +-- ...managed-stack-contract.integration.test.ts | 144 ++++++++++++++++- packages/stack/src/managed-stack-contract.ts | 145 ++++++++++++++---- 3 files changed, 268 insertions(+), 41 deletions(-) diff --git a/docs/adr/0015-managed-stack-contract-fixtures.md b/docs/adr/0015-managed-stack-contract-fixtures.md index d3b34378d3..a34758203a 100644 --- a/docs/adr/0015-managed-stack-contract-fixtures.md +++ b/docs/adr/0015-managed-stack-contract-fixtures.md @@ -86,17 +86,19 @@ state creation and registry publication must imply each other; Git identity writ correct common or worktree scope and cannot recreate an identity already declared by a checkout; new Git-derived contexts and selected linked worktrees must declare the relevant Git state; managed and sticky port conflicts and persisted-runtime conflicts must identify their actual target; -persisted-runtime preflight failures must identify a stopped stack; a successful bootstrap retry -must follow an explicit rolled-back attempt; credential create, update, and copy operations must -prove that global state contains references rather than plaintext; data-preserving prune must begin -with mutable data; +sticky reuse must bind the assignment to the selected target; persisted-runtime preflight failures +must identify a stopped stack; a successful bootstrap retry must follow an explicit rolled-back +attempt; credential create, update, and copy operations must prove that global state contains +references rather than plaintext; data-preserving prune must begin with mutable data; tracked identity markers must remain untouched; native qualification facts must partition the service matrix; status operations must remain read-only reports; portable runtime projections must -match their referenced scenario; destructive runtime effects must map to mutable-state deletion; -other runtime effects must agree with permitted state writes; and human/API/JSON projections cannot -contradict the managed result. We deliberately do not introduce a parallel test-only identity -resolver; it would duplicate product policy before the real managed surface exists and could pass -while the production implementation drifts. +match their referenced scenario; repository adapter projections must reference and match a declared +scenario; every invalid stack name must be exercised through a public action; destructive runtime +effects must map to mutable-state deletion and runtime-state deletion must stop the running target; +other runtime effects must agree with permitted state writes; and stable identity and recovery +fields in human/API/JSON projections cannot contradict the managed result. We deliberately do not +introduce a parallel test-only identity resolver; it would duplicate product policy before the real +managed surface exists and could pass while the production implementation drifts. ## Implementation Handoff diff --git a/packages/stack/src/managed-stack-contract.integration.test.ts b/packages/stack/src/managed-stack-contract.integration.test.ts index 8ffdcecbe8..8433d833c7 100644 --- a/packages/stack/src/managed-stack-contract.integration.test.ts +++ b/packages/stack/src/managed-stack-contract.integration.test.ts @@ -23,7 +23,11 @@ describe("managed stack acceptance contract", () => { if (scenario === undefined) { throw new Error("identity.return-to-branch-reuses-stack fixture is required"); } - if (scenario.expected.selection === undefined || scenario.expected.output.json === undefined) { + if ( + scenario.expected.selection === undefined || + scenario.expected.output.human === undefined || + scenario.expected.output.json === undefined + ) { throw new Error("identity.return-to-branch-reuses-stack must select and project a stack"); } @@ -72,6 +76,23 @@ describe("managed stack acceptance contract", () => { `${scenario.id}: projected outcome disagrees with the managed result`, ); + const divergentHumanProjection = { + ...scenario, + expected: { + ...scenario.expected, + output: { + ...scenario.expected.output, + human: { + ...scenario.expected.output.human, + fields: { ...scenario.expected.output.human.fields, stackId: "stack-other" }, + }, + }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([divergentHumanProjection])).toContain( + `${scenario.id}: projected stackId disagrees with the managed result`, + ); + const existingTarget: ManagedStackContractFact = { kind: "managed-target", stackId: "stack-main-default", @@ -489,6 +510,97 @@ describe("managed stack acceptance contract", () => { expect(validateManagedStackContractFixtures([runtimeMetadataOnlyDelete])).toContain( `${deleteScenario.id}: delete runtime effect requires a matching state write`, ); + + const deleteWithoutStop = { + ...deleteScenario, + expected: { + ...deleteScenario.expected, + runtimeEffects: deleteScenario.expected.runtimeEffects.filter( + ({ operation }) => operation !== "stop", + ), + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([deleteWithoutStop])).toContain( + `${deleteScenario.id}: runtime-state delete requires a matching runtime effect`, + ); + + const stickyReuseScenario = managedStackContractFixtures.find( + ({ id }) => id === "ports.sticky-ports-reuse-on-return", + ); + if (stickyReuseScenario === undefined) { + throw new Error("ports.sticky-ports-reuse-on-return fixture is required"); + } + const unboundStickyReuse = { + ...stickyReuseScenario, + expected: { ...stickyReuseScenario.expected, selection: undefined }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([unboundStickyReuse])).toContain( + `${stickyReuseScenario.id}: sticky port reuse requires a selected target`, + ); + + const repositoryContractScenario: ManagedStackContractScenario | undefined = + managedStackContractFixtures.find( + ({ id }) => id === "api-boundary.repository-contract-is-storage-agnostic", + ); + if ( + repositoryContractScenario === undefined || + repositoryContractScenario.when.interface !== "managed-api" + ) { + throw new Error("api-boundary.repository-contract-is-storage-agnostic fixture is required"); + } + const repositoryApiOutput = repositoryContractScenario.expected.output.api; + if (repositoryApiOutput === undefined) { + throw new Error("repository contract API output is required"); + } + const unknownRepositoryReference = { + ...repositoryContractScenario, + when: { + ...repositoryContractScenario.when, + input: { ...repositoryContractScenario.when.input, scenarioId: "identity.unknown" }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([unknownRepositoryReference])).toContain( + `${repositoryContractScenario.id}: repository contract must reference a declared scenario`, + ); + + const staleRepositoryOutcome = { + ...repositoryContractScenario, + expected: { + ...repositoryContractScenario.expected, + output: { + ...repositoryContractScenario.expected.output, + api: { + ...repositoryApiOutput, + "in-memory": { outcome: "create", stackId: "stack-main-default" }, + }, + }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([scenario, staleRepositoryOutcome])).toContain( + `${repositoryContractScenario.id}: repository in-memory outcome must match ${scenario.id}`, + ); + + const invalidNameScenario: ManagedStackContractScenario | undefined = + managedStackContractFixtures.find( + ({ id }) => id === "identity.invalid-stack-name-uppercase-underscore-fails", + ); + const invalidNameHumanOutput = invalidNameScenario?.expected.output.human; + if (invalidNameScenario === undefined || invalidNameHumanOutput === undefined) { + throw new Error("invalid stack name fixture with human recovery is required"); + } + const divergentHumanRecovery = { + ...invalidNameScenario, + expected: { + ...invalidNameScenario.expected, + output: { + ...invalidNameScenario.expected.output, + human: { ...invalidNameHumanOutput, recovery: ["Try again"] }, + }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([divergentHumanRecovery])).toContain( + `${invalidNameScenario.id}: human recovery disagrees with the managed result`, + ); }); it("covers the approved identity journeys through public commands and APIs", () => { @@ -517,7 +629,9 @@ describe("managed stack acceptance contract", () => { "identity.fresh-clone-creates-project-and-checkout", "identity.fresh-clone-ignores-tracked-marker", "identity.inaccessible-previous-path-fails", - "identity.invalid-stack-name-fails", + "identity.invalid-stack-name-leading-hyphen-fails", + "identity.invalid-stack-name-repeated-dot-fails", + "identity.invalid-stack-name-uppercase-underscore-fails", "identity.linked-worktrees-share-project-not-checkout", "identity.manual-ref-replacement-orphans-context", "identity.missing-previous-path-rebinds-checkout", @@ -538,6 +652,32 @@ describe("managed stack acceptance contract", () => { ); }); + it("executes every invalid stack name through the public CLI action", () => { + const invalidNameScenarios = managedStackContractFixtures.filter(({ id }) => + id.startsWith("identity.invalid-stack-name-"), + ); + + expect( + invalidNameScenarios.map((invalidNameScenario) => ({ + action: + invalidNameScenario.when.interface === "cli" ? invalidNameScenario.when.argv : undefined, + names: invalidNameScenario.given.flatMap((fact) => + fact.kind === "stack-names" ? fact.names : [], + ), + })), + ).toEqual([ + { + action: ["start", "--experimental", "--stack", "Feature_A"], + names: ["Feature_A"], + }, + { action: ["start", "--experimental", "--stack", "-review"], names: ["-review"] }, + { + action: ["start", "--experimental", "--stack", "review..two"], + names: ["review..two"], + }, + ]); + }); + it("covers exact declarative ports and sticky automatic allocation", () => { expect( managedStackContractFixtures diff --git a/packages/stack/src/managed-stack-contract.ts b/packages/stack/src/managed-stack-contract.ts index e4c99fa251..c7e9798352 100644 --- a/packages/stack/src/managed-stack-contract.ts +++ b/packages/stack/src/managed-stack-contract.ts @@ -398,6 +398,35 @@ export const validateManagedStackContractFixtures = ( } } + if ( + scenario.when.interface === "managed-api" && + scenario.when.method === "runRepositoryContract" + ) { + const referencedId = scenario.when.input.scenarioId; + const referencedScenario = + typeof referencedId === "string" ? fixturesById.get(referencedId) : undefined; + if (referencedScenario === undefined) { + errors.push(`${scenario.id}: repository contract must reference a declared scenario`); + } else { + const adapters = scenario.when.input.adapters; + if (!Array.isArray(adapters) || !adapters.every((adapter) => typeof adapter === "string")) { + errors.push(`${scenario.id}: repository contract must declare its adapters`); + } else { + for (const adapter of adapters) { + const adapterResult = output.api?.[adapter]; + if ( + !isManagedStackContractRecord(adapterResult) || + adapterResult.outcome !== referencedScenario.expected.outcome + ) { + errors.push( + `${scenario.id}: repository ${adapter} outcome must match ${referencedScenario.id}`, + ); + } + } + } + } + } + if (scenario.expected.outcome === "error") { if (scenario.expected.error === undefined) { errors.push(`${scenario.id}: error outcome requires structured error metadata`); @@ -721,6 +750,18 @@ export const validateManagedStackContractFixtures = ( } } + if (scenario.expected.output.json?.sticky === true) { + if (selection === undefined) { + errors.push(`${scenario.id}: sticky port reuse requires a selected target`); + } else if ( + !scenario.given.some( + (fact) => fact.kind === "port-assignment" && fact.stackId === selection.stackId, + ) + ) { + errors.push(`${scenario.id}: reused sticky port must belong to the selected target`); + } + } + if (scenario.expected.error?.code === "runtime_conflicts_with_persisted_stack") { if (selection === undefined) { errors.push(`${scenario.id}: persisted runtime conflict requires a selected target`); @@ -868,7 +909,8 @@ export const validateManagedStackContractFixtures = ( const requiredRuntimeOperation = write.target === "runtime-state" && write.operation === "start" ? "start" - : write.target === "runtime-state" && write.operation === "update" + : write.target === "runtime-state" && + (write.operation === "delete" || write.operation === "update") ? "stop" : write.target === "managed-state" && write.operation === "copy" ? "copy" @@ -917,6 +959,33 @@ export const validateManagedStackContractFixtures = ( checkProjection(scenario.expected.output.api, "contextId", selection.contextId); checkProjection(scenario.expected.output.api, "stackId", selection.stackId); checkProjection(scenario.expected.output.api, "stackName", selection.stackName); + checkProjection(scenario.expected.output.human?.fields, "projectId", selection.projectId); + checkProjection(scenario.expected.output.human?.fields, "checkoutId", selection.checkoutId); + checkProjection(scenario.expected.output.human?.fields, "contextId", selection.contextId); + checkProjection(scenario.expected.output.human?.fields, "stackId", selection.stackId); + checkProjection(scenario.expected.output.human?.fields, "stack", selection.stackName); + checkProjection(scenario.expected.output.human?.fields, "stackName", selection.stackName); + + const selectedBranch = scenario.given.find( + (fact) => + fact.kind === "branch" && fact.checkedOut && fact.contextId === selection.contextId, + ); + if (selectedBranch?.kind === "branch") { + checkProjection(scenario.expected.output.human?.fields, "branch", selectedBranch.name); + } + } + + const expectedRecovery = + scenario.expected.error?.recovery ?? scenario.expected.warning?.recovery; + const humanOutput = scenario.expected.output.human; + if ( + humanOutput !== undefined && + expectedRecovery !== undefined && + (humanOutput.recovery === undefined || + humanOutput.recovery.length !== expectedRecovery.length || + humanOutput.recovery.some((step, index) => step !== expectedRecovery[index])) + ) { + errors.push(`${scenario.id}: human recovery disagrees with the managed result`); } } @@ -962,6 +1031,39 @@ const branchHistoryFixture = ( }, }); +const invalidStackNameFixture = ( + label: "leading-hyphen" | "repeated-dot" | "uppercase-underscore", + stackName: string, +): ManagedStackContractScenario => ({ + id: `identity.invalid-stack-name-${label}-fails`, + title: `The invalid stack name ${stackName} fails before registration`, + area: "identity", + given: [{ kind: "stack-names", names: [stackName] }], + when: { + interface: "cli", + argv: ["start", "--experimental", "--stack", stackName], + cwd: "checkout-a", + }, + expected: { + outcome: "error", + error: { + code: "invalid_stack_name", + message: `${stackName} is not a lowercase DNS-label name`, + recovery: ["Use default or a lowercase DNS-label name such as feature-a"], + }, + writes: [], + runtimeEffects: [], + output: { + human: { + summary: `Invalid stack name: ${stackName}`, + fields: { stack: stackName }, + recovery: ["Use default or a lowercase DNS-label name such as feature-a"], + }, + json: { outcome: "error", code: "invalid_stack_name", stack_name: stackName }, + }, + }, +}); + const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ { id: "identity.same-checkout-branch-and-name-reuses-stack", @@ -1881,35 +1983,9 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ }, }, }, - { - id: "identity.invalid-stack-name-fails", - title: "Invalid stack names fail before registration", - area: "identity", - given: [{ kind: "stack-names", names: ["Feature_A", "-review", "review..two"] }], - when: { - interface: "cli", - argv: ["start", "--experimental", "--stack", "Feature_A"], - cwd: "checkout-a", - }, - expected: { - outcome: "error", - error: { - code: "invalid_stack_name", - message: "Feature_A is not a lowercase DNS-label name", - recovery: ["Use default or a lowercase DNS-label name such as feature-a"], - }, - writes: [], - runtimeEffects: [], - output: { - human: { - summary: "Invalid stack name: Feature_A", - fields: { stack: "Feature_A" }, - recovery: ["Use default or a lowercase DNS-label name such as feature-a"], - }, - json: { outcome: "error", code: "invalid_stack_name", stack_name: "Feature_A" }, - }, - }, - }, + invalidStackNameFixture("uppercase-underscore", "Feature_A"), + invalidStackNameFixture("leading-hyphen", "-review"), + invalidStackNameFixture("repeated-dot", "review..two"), { id: "identity.valid-stack-names-resolve-deterministically", title: "Default and lowercase DNS-label stack names resolve deterministically", @@ -2577,6 +2653,8 @@ const additionalPortContractFixtures = defineManagedStackContractFixtures([ title: "Returning to an existing target reuses its sticky automatic ports", area: "ports", given: [ + { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, + { kind: "branch", name: "feat-a", contextId: "context-feat", checkedOut: true }, { kind: "stack", name: "default", @@ -2596,6 +2674,13 @@ const additionalPortContractFixtures = defineManagedStackContractFixtures([ when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, expected: { outcome: "reuse", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-feat", + stackId: "stack-feat-default", + stackName: "default", + }, writes: [{ target: "runtime-state", operation: "start", id: "stack-feat-default" }], runtimeEffects: [{ operation: "start", stackId: "stack-feat-default" }], output: { From 7fd8bd2a2ea3277a03cb9fd05e18c9010bebfa51 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 10 Aug 2026 19:26:46 +0200 Subject: [PATCH 13/41] test(stack): bind remaining contract transitions --- .../0015-managed-stack-contract-fixtures.md | 44 +- ...managed-stack-contract.integration.test.ts | 203 +++++++- packages/stack/src/managed-stack-contract.ts | 447 +++++++++++++++++- 3 files changed, 648 insertions(+), 46 deletions(-) diff --git a/docs/adr/0015-managed-stack-contract-fixtures.md b/docs/adr/0015-managed-stack-contract-fixtures.md index a34758203a..0387543e9d 100644 --- a/docs/adr/0015-managed-stack-contract-fixtures.md +++ b/docs/adr/0015-managed-stack-contract-fixtures.md @@ -45,9 +45,11 @@ selection, port, runtime, or lifecycle decision path. Git workspaces store project, checkout, and context identities in Git-local metadata, using common or worktree scope as appropriate. Contract effects record that scope explicitly: project identity -uses common Git config, while checkout and context identities use worktree-local config. A tracked -working-tree identity marker is inert: discovery never trusts or rewrites it. Ordinary non-Git -folders may use an untracked local identity marker. +uses common Git config, while checkout and context identities use worktree-local config. Context +writes also declare their owning branch so storage adapters cannot persist an unbound context. A +tracked working-tree identity marker is inert: discovery never trusts or rewrites it. Ordinary +non-Git folders persist a project-local, untracked identity marker on first start and recover that +same project, checkout, and context identity on later starts. Read-only status remains a successful `report` when it can identify a running stack but finds unapplied port, credential, or runtime configuration. The report includes a structured warning and @@ -82,23 +84,27 @@ test is not evidence that an unimplemented command already satisfies the behavio The fixture validator therefore checks more than catalog shape: selected, written, and effected identities must be declared; starts of existing stacks must declare a stopped lifecycle; managed -state creation and registry publication must imply each other; Git identity writes must use the -correct common or worktree scope and cannot recreate an identity already declared by a checkout; -new Git-derived contexts and selected linked worktrees must declare the relevant Git state; managed -and sticky port conflicts and persisted-runtime conflicts must identify their actual target; -sticky reuse must bind the assignment to the selected target; persisted-runtime preflight failures -must identify a stopped stack; a successful bootstrap retry must follow an explicit rolled-back -attempt; credential create, update, and copy operations must prove that global state contains -references rather than plaintext; data-preserving prune must begin with mutable data; -tracked identity markers must remain untouched; native qualification facts must partition the -service matrix; status operations must remain read-only reports; portable runtime projections must -match their referenced scenario; repository adapter projections must reference and match a declared -scenario; every invalid stack name must be exercised through a public action; destructive runtime +state creation and registry publication must imply each other, as must managed-state deletion and +registry tombstoning; contextual CLI stack results must bind their output to a selected target; Git +identity writes must use the correct common or worktree scope, context writes must name the active +branch as owner, and adapters cannot recreate an identity already declared by a checkout; new +Git-derived contexts and selected linked worktrees must declare the relevant Git state; ordinary +folders must write their full untracked identity marker on creation and resolve it on reuse; managed +and sticky port conflicts and persisted-runtime conflicts must identify their actual target; sticky +reuse must bind the assignment to the selected target; persisted-runtime preflight failures must +identify a stopped stack; a successful bootstrap retry must follow an explicit rolled-back attempt; +credential create, update, and copy operations must prove that global state contains references +rather than plaintext; data-preserving prune must begin with mutable data; tracked identity markers +must remain untouched; native qualification facts must partition the service matrix, use a declared +platform, and match the platform passed to preflight; status operations must remain read-only +reports; portable runtime projections must match their referenced scenario; repository adapter +projections must reference a declared scenario and agree on both identity and their complete +decision; every invalid stack name must be exercised through a public action; destructive runtime effects must map to mutable-state deletion and runtime-state deletion must stop the running target; -other runtime effects must agree with permitted state writes; and stable identity and recovery -fields in human/API/JSON projections cannot contradict the managed result. We deliberately do not -introduce a parallel test-only identity resolver; it would duplicate product policy before the real -managed surface exists and could pass while the production implementation drifts. +other runtime effects must agree with permitted state writes; and stable identity plus exact human +and JSON recovery fields cannot contradict the managed result. We deliberately do not introduce a +parallel test-only identity resolver; it would duplicate product policy before the real managed +surface exists and could pass while the production implementation drifts. ## Implementation Handoff diff --git a/packages/stack/src/managed-stack-contract.integration.test.ts b/packages/stack/src/managed-stack-contract.integration.test.ts index 8433d833c7..31051caa1f 100644 --- a/packages/stack/src/managed-stack-contract.integration.test.ts +++ b/packages/stack/src/managed-stack-contract.integration.test.ts @@ -115,7 +115,11 @@ describe("managed stack acceptance contract", () => { const identityMarkerWrite = { target: "identity-marker", operation: "create", - id: "project-clone", + id: "marker-project-a", + storage: "project-local-untracked", + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-main", } satisfies ManagedStackContractScenario["expected"]["writes"][number]; const trackedMarkerMutation = { ...trackedMarkerScenario, @@ -239,6 +243,26 @@ describe("managed stack acceptance contract", () => { `${qualificationScenario.id}: native qualification omits service postgres`, ); + const qualificationForDifferentPlatform = { + ...qualificationScenario, + given: qualificationScenario.given.map((fact) => + fact.kind === "native-qualification" ? { ...fact, platform: "linux-amd64" } : fact, + ), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([qualificationForDifferentPlatform])).toContain( + `${qualificationScenario.id}: native qualification platform must match the preflight action`, + ); + + const qualificationForUnknownPlatform = { + ...qualificationScenario, + given: qualificationScenario.given.map((fact) => + fact.kind === "native-qualification" ? { ...fact, platform: "solaris-sparc" } : fact, + ), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([qualificationForUnknownPlatform])).toContain( + `${qualificationScenario.id}: native qualification uses unknown platform solaris-sparc`, + ); + const statusScenario: ManagedStackContractScenario | undefined = managedStackContractFixtures.find( ({ id }) => id === "identity.symlink-alias-reuses-checkout", @@ -409,6 +433,20 @@ describe("managed stack acceptance contract", () => { `${stickyPortScenario.id}: sticky port conflict requires a stopped selected stack`, ); + const portUpdateScenario = managedStackContractFixtures.find( + ({ id }) => id === "ports.config-change-on-stopped-stack-applies", + ); + if (portUpdateScenario === undefined) { + throw new Error("ports.config-change-on-stopped-stack-applies fixture is required"); + } + const unboundPortUpdate = { + ...portUpdateScenario, + expected: { ...portUpdateScenario.expected, selection: undefined }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([unboundPortUpdate])).toContain( + `${portUpdateScenario.id}: contextual CLI stack result requires a selected target`, + ); + const runtimeConflictScenario: ManagedStackContractScenario | undefined = managedStackContractFixtures.find( ({ id }) => id === "runtime.persisted-runtime-conflict-fails", @@ -524,6 +562,19 @@ describe("managed stack acceptance contract", () => { `${deleteScenario.id}: runtime-state delete requires a matching runtime effect`, ); + const deleteWithoutTombstone = { + ...deleteScenario, + expected: { + ...deleteScenario.expected, + writes: deleteScenario.expected.writes.filter( + (write) => write.target !== "registry" || write.operation !== "tombstone", + ), + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([deleteWithoutTombstone])).toContain( + `${deleteScenario.id}: managed-state deletion requires a registry tombstone`, + ); + const stickyReuseScenario = managedStackContractFixtures.find( ({ id }) => id === "ports.sticky-ports-reuse-on-return", ); @@ -580,6 +631,30 @@ describe("managed stack acceptance contract", () => { `${repositoryContractScenario.id}: repository in-memory outcome must match ${scenario.id}`, ); + const divergentRepositoryIdentity = { + ...repositoryContractScenario, + expected: { + ...repositoryContractScenario.expected, + output: { + ...repositoryContractScenario.expected.output, + api: { + ...repositoryApiOutput, + "persistent-adapter": { outcome: "reuse", stackId: "stack-other" }, + }, + }, + }, + } satisfies ManagedStackContractScenario; + const divergentRepositoryErrors = validateManagedStackContractFixtures([ + scenario, + divergentRepositoryIdentity, + ]); + expect(divergentRepositoryErrors).toContain( + `${repositoryContractScenario.id}: repository persistent-adapter stackId must match ${scenario.id}`, + ); + expect(divergentRepositoryErrors).toContain( + `${repositoryContractScenario.id}: repository adapter decisions must be identical`, + ); + const invalidNameScenario: ManagedStackContractScenario | undefined = managedStackContractFixtures.find( ({ id }) => id === "identity.invalid-stack-name-uppercase-underscore-fails", @@ -601,6 +676,74 @@ describe("managed stack acceptance contract", () => { expect(validateManagedStackContractFixtures([divergentHumanRecovery])).toContain( `${invalidNameScenario.id}: human recovery disagrees with the managed result`, ); + + const divergentJsonRecovery = { + ...invalidNameScenario, + expected: { + ...invalidNameScenario.expected, + output: { + ...invalidNameScenario.expected.output, + json: { ...invalidNameScenario.expected.output.json, recovery: ["Try again"] }, + }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([divergentJsonRecovery])).toContain( + `${invalidNameScenario.id}: JSON recovery disagrees with the managed result`, + ); + + const newBranchScenario = managedStackContractFixtures.find( + ({ id }) => id === "identity.new-branch-first-start-creates-stack", + ); + if (newBranchScenario === undefined) { + throw new Error("identity.new-branch-first-start-creates-stack fixture is required"); + } + const contextOwnedByDifferentBranch = { + ...newBranchScenario, + expected: { + ...newBranchScenario.expected, + writes: newBranchScenario.expected.writes.map((write) => + write.target === "git-config" && write.id === "context-feat-a" + ? { ...write, owner: "main" } + : write, + ), + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([contextOwnedByDifferentBranch])).toContain( + `${newBranchScenario.id}: Git context context-feat-a must belong to branch feat-a`, + ); + + const firstOrdinaryFolderStart = managedStackContractFixtures.find( + ({ id }) => id === "identity.non-git-folder-first-start-persists-identity", + ); + if (firstOrdinaryFolderStart === undefined) { + throw new Error("identity.non-git-folder-first-start-persists-identity fixture is required"); + } + const ordinaryFolderWithoutMarkerWrite = { + ...firstOrdinaryFolderStart, + expected: { + ...firstOrdinaryFolderStart.expected, + writes: firstOrdinaryFolderStart.expected.writes.filter( + (write) => write.target !== "identity-marker", + ), + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([ordinaryFolderWithoutMarkerWrite])).toContain( + `${firstOrdinaryFolderStart.id}: ordinary-folder creation must persist its identity marker`, + ); + + const laterOrdinaryFolderStart = managedStackContractFixtures.find( + ({ id }) => id === "identity.non-git-folder-recovers-persisted-identity", + ); + if (laterOrdinaryFolderStart === undefined) { + throw new Error("identity.non-git-folder-recovers-persisted-identity fixture is required"); + } + const ordinaryFolderWithoutMarkerFact = { + ...laterOrdinaryFolderStart, + given: laterOrdinaryFolderStart.given.filter((fact) => fact.kind !== "identity-marker"), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([ordinaryFolderWithoutMarkerFact])).toContain( + `${laterOrdinaryFolderStart.id}: ordinary-folder reuse must resolve its identity marker`, + ); }); it("covers the approved identity journeys through public commands and APIs", () => { @@ -638,7 +781,8 @@ describe("managed stack acceptance contract", () => { "identity.moved-checkout-reuses-identity", "identity.named-stacks-are-context-scoped", "identity.new-branch-first-start-creates-stack", - "identity.non-git-folder-reuses-workspace-context", + "identity.non-git-folder-first-start-persists-identity", + "identity.non-git-folder-recovers-persisted-identity", "identity.original-gone-turns-copy-into-rename", "identity.read-only-unregistered-checkout-does-not-write", "identity.return-to-branch-reuses-stack", @@ -652,6 +796,61 @@ describe("managed stack acceptance contract", () => { ); }); + it("persists and recovers ordinary-folder identity across starts", () => { + const firstStart = managedStackContractFixtures.find( + ({ id }) => id === "identity.non-git-folder-first-start-persists-identity", + ); + const laterStart = managedStackContractFixtures.find( + ({ id }) => id === "identity.non-git-folder-recovers-persisted-identity", + ); + + expect(firstStart).toMatchObject({ + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "/work/project-a" }, + expected: { + outcome: "create", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-workspace", + stackId: "stack-workspace-default", + }, + writes: expect.arrayContaining([ + { + target: "identity-marker", + operation: "create", + id: "marker-project-a", + storage: "project-local-untracked", + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-workspace", + }, + ]), + }, + }); + expect(laterStart).toMatchObject({ + given: expect.arrayContaining([ + { + kind: "identity-marker", + markerId: "marker-project-a", + workspacePath: "/work/project-a", + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-workspace", + tracked: false, + }, + ]), + expected: { + outcome: "reuse", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-workspace", + stackId: "stack-workspace-default", + }, + }, + }); + }); + it("executes every invalid stack name through the public CLI action", () => { const invalidNameScenarios = managedStackContractFixtures.filter(({ id }) => id.startsWith("identity.invalid-stack-name-"), diff --git a/packages/stack/src/managed-stack-contract.ts b/packages/stack/src/managed-stack-contract.ts index c7e9798352..b0bb350a88 100644 --- a/packages/stack/src/managed-stack-contract.ts +++ b/packages/stack/src/managed-stack-contract.ts @@ -168,6 +168,15 @@ export type ManagedStackContractFact = readonly valuesId: string; readonly previousValuesId?: string; } + | { + readonly kind: "identity-marker"; + readonly markerId: string; + readonly workspacePath: string; + readonly projectId: string; + readonly checkoutId: string; + readonly contextId: string; + readonly tracked: false; + } | { readonly kind: "direct-stack-options"; readonly roots: "explicit" | "omitted"; @@ -195,14 +204,19 @@ type ManagedStackContractWrite = readonly operation: "create" | "update"; readonly id: string; readonly scope: "common" | "worktree"; + readonly owner?: string; } | { - readonly target: - | "ephemeral-state" - | "identity-marker" - | "managed-state" - | "registry" - | "runtime-state"; + readonly target: "identity-marker"; + readonly operation: "create" | "update"; + readonly id: string; + readonly storage: "project-local-untracked"; + readonly projectId: string; + readonly checkoutId: string; + readonly contextId: string; + } + | { + readonly target: "ephemeral-state" | "managed-state" | "registry" | "runtime-state"; readonly operation: | "copy" | "create" @@ -313,6 +327,35 @@ const isManagedStackContractRecord = ( ): value is Readonly> => typeof value === "object" && value !== null && !Array.isArray(value); +const managedStackContractJsonEquals = ( + left: ManagedStackContractJson, + right: ManagedStackContractJson, +): boolean => { + if (Object.is(left, right)) { + return true; + } + if (Array.isArray(left) && Array.isArray(right)) { + return ( + left.length === right.length && + left.every((value, index) => managedStackContractJsonEquals(value, right[index] ?? null)) + ); + } + if (isManagedStackContractRecord(left) && isManagedStackContractRecord(right)) { + const leftKeys = Object.keys(left).sort(); + const rightKeys = Object.keys(right).sort(); + return ( + leftKeys.length === rightKeys.length && + leftKeys.every( + (key, index) => + key === rightKeys[index] && + right[key] !== undefined && + managedStackContractJsonEquals(left[key] ?? null, right[key]), + ) + ); + } + return false; +}; + export const validateManagedStackContractFixtures = ( fixtures: ReadonlyArray, ): ReadonlyArray => { @@ -374,6 +417,18 @@ export const validateManagedStackContractFixtures = ( errors.push(`${scenario.id}: at least one observable output is required`); } + if ( + scenario.when.interface === "cli" && + (scenario.when.argv[0] === "start" || + scenario.when.argv[0] === "status" || + scenario.when.argv[0] === "stop") && + !scenario.when.argv.includes("--stack-id") && + typeof output.json?.stack_id === "string" && + scenario.expected.selection === undefined + ) { + errors.push(`${scenario.id}: contextual CLI stack result requires a selected target`); + } + if ( scenario.when.interface === "managed-api" && scenario.when.method === "runPortableContract" @@ -412,6 +467,7 @@ export const validateManagedStackContractFixtures = ( if (!Array.isArray(adapters) || !adapters.every((adapter) => typeof adapter === "string")) { errors.push(`${scenario.id}: repository contract must declare its adapters`); } else { + let firstAdapterResult: Readonly> | undefined; for (const adapter of adapters) { const adapterResult = output.api?.[adapter]; if ( @@ -421,6 +477,20 @@ export const validateManagedStackContractFixtures = ( errors.push( `${scenario.id}: repository ${adapter} outcome must match ${referencedScenario.id}`, ); + continue; + } + if ( + referencedScenario.expected.selection !== undefined && + adapterResult.stackId !== referencedScenario.expected.selection.stackId + ) { + errors.push( + `${scenario.id}: repository ${adapter} stackId must match ${referencedScenario.id}`, + ); + } + if (firstAdapterResult === undefined) { + firstAdapterResult = adapterResult; + } else if (!managedStackContractJsonEquals(firstAdapterResult, adapterResult)) { + errors.push(`${scenario.id}: repository adapter decisions must be identical`); } } } @@ -485,6 +555,17 @@ export const validateManagedStackContractFixtures = ( break; } break; + case "identity-marker": + declaredIds.add(fact.markerId); + declaredIds.add(fact.projectId); + declaredIds.add(fact.checkoutId); + declaredIds.add(fact.contextId); + projectIds.add(fact.projectId); + checkoutIds.add(fact.checkoutId); + contextIds.add(fact.contextId); + existingCheckoutIdentityIds.add(fact.projectId); + existingCheckoutIdentityIds.add(fact.checkoutId); + break; case "managed-record": case "managed-target": case "persisted-runtime": @@ -535,6 +616,23 @@ export const validateManagedStackContractFixtures = ( continue; } + if ( + !managedNativeServiceMatrix.targetPlatforms.includes(fact.platform) && + !managedNativeServiceMatrix.unsupportedPlatforms.includes(fact.platform) + ) { + errors.push(`${scenario.id}: native qualification uses unknown platform ${fact.platform}`); + } + if ( + scenario.when.interface === "managed-api" && + scenario.when.method === "preflightNative" && + typeof scenario.when.input.platform === "string" && + scenario.when.input.platform !== fact.platform + ) { + errors.push( + `${scenario.id}: native qualification platform must match the preflight action`, + ); + } + const qualified = new Set(); const failed = new Set(); for (const service of fact.qualifiedServices) { @@ -629,6 +727,14 @@ export const validateManagedStackContractFixtures = ( ) { declaredIds.add(write.id); } + if (write.target === "identity-marker") { + declaredIds.add(write.projectId); + declaredIds.add(write.checkoutId); + declaredIds.add(write.contextId); + projectIds.add(write.projectId); + checkoutIds.add(write.checkoutId); + contextIds.add(write.contextId); + } } for (const write of scenario.expected.writes) { if ( @@ -704,6 +810,41 @@ export const validateManagedStackContractFixtures = ( ) { errors.push(`${scenario.id}: creating a Git context requires Git state for the workspace`); } + + const ordinaryWorkspace = scenario.given.find( + (fact) => + fact.kind === "workspace" && + fact.mode === "ordinary-folder" && + (fact.path === actionCwd || fact.canonicalPath === actionCwd), + ); + if (ordinaryWorkspace?.kind === "workspace") { + if (scenario.expected.outcome === "create") { + if ( + !scenario.expected.writes.some( + (write) => + write.target === "identity-marker" && + write.projectId === selection.projectId && + write.checkoutId === selection.checkoutId && + write.contextId === selection.contextId, + ) + ) { + errors.push( + `${scenario.id}: ordinary-folder creation must persist its identity marker`, + ); + } + } else if ( + !scenario.given.some( + (fact) => + fact.kind === "identity-marker" && + fact.workspacePath === actionCwd && + fact.projectId === selection.projectId && + fact.checkoutId === selection.checkoutId && + fact.contextId === selection.contextId, + ) + ) { + errors.push(`${scenario.id}: ordinary-folder reuse must resolve its identity marker`); + } + } } if ( @@ -828,6 +969,19 @@ export const validateManagedStackContractFixtures = ( } } + const actionGitState = scenario.given.find( + (fact) => fact.kind === "git-state" && fact.workspacePath === actionCwd, + ); + const checkedOutBranch = scenario.given.find( + (fact) => fact.kind === "branch" && fact.checkedOut, + ); + const activeBranchName = + actionGitState?.kind === "git-state" && actionGitState.head === "branch" + ? actionGitState.branch + : checkedOutBranch?.kind === "branch" + ? checkedOutBranch.name + : undefined; + for (const write of scenario.expected.writes) { if (write.target !== "git-config") { continue; @@ -845,6 +999,15 @@ export const validateManagedStackContractFixtures = ( `${scenario.id}: Git identity ${write.id} must use ${expectedScope} config scope`, ); } + if (contextIds.has(write.id)) { + if (write.owner === undefined) { + errors.push(`${scenario.id}: Git context ${write.id} must declare its branch owner`); + } else if (activeBranchName !== undefined && write.owner !== activeBranchName) { + errors.push( + `${scenario.id}: Git context ${write.id} must belong to branch ${activeBranchName}`, + ); + } + } } for (const effect of scenario.expected.runtimeEffects) { @@ -906,6 +1069,33 @@ export const validateManagedStackContractFixtures = ( errors.push(`${scenario.id}: registry publication requires managed-state creation or copy`); } + if ( + scenario.expected.outcome === "delete" && + write.target === "managed-state" && + write.operation === "delete" && + !scenario.expected.writes.some( + (candidate) => + candidate.target === "registry" && + candidate.operation === "tombstone" && + candidate.id === write.id, + ) + ) { + errors.push(`${scenario.id}: managed-state deletion requires a registry tombstone`); + } + + if ( + write.target === "registry" && + write.operation === "tombstone" && + !scenario.expected.writes.some( + (candidate) => + candidate.target === "managed-state" && + candidate.operation === "delete" && + candidate.id === write.id, + ) + ) { + errors.push(`${scenario.id}: registry tombstone requires managed-state deletion`); + } + const requiredRuntimeOperation = write.target === "runtime-state" && write.operation === "start" ? "start" @@ -987,6 +1177,17 @@ export const validateManagedStackContractFixtures = ( ) { errors.push(`${scenario.id}: human recovery disagrees with the managed result`); } + const jsonOutput = scenario.expected.output.json; + const jsonRecovery = jsonOutput?.recovery; + if ( + jsonOutput !== undefined && + expectedRecovery !== undefined && + (!Array.isArray(jsonRecovery) || + jsonRecovery.length !== expectedRecovery.length || + jsonRecovery.some((step, index) => step !== expectedRecovery[index])) + ) { + errors.push(`${scenario.id}: JSON recovery disagrees with the managed result`); + } } return errors; @@ -1059,11 +1260,29 @@ const invalidStackNameFixture = ( fields: { stack: stackName }, recovery: ["Use default or a lowercase DNS-label name such as feature-a"], }, - json: { outcome: "error", code: "invalid_stack_name", stack_name: stackName }, + json: { + outcome: "error", + code: "invalid_stack_name", + stack_name: stackName, + recovery: ["Use default or a lowercase DNS-label name such as feature-a"], + }, }, }, }); +const mainCheckoutContextFacts = [ + { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, + { kind: "branch", name: "main", contextId: "context-main", checkedOut: true }, +] satisfies ReadonlyArray; + +const mainDefaultSelection = { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-main", + stackId: "stack-main-default", + stackName: "default", +}; + const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ { id: "identity.same-checkout-branch-and-name-reuses-stack", @@ -1167,7 +1386,13 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ stackName: "default", }, writes: [ - { target: "git-config", operation: "create", id: "context-feat-a", scope: "worktree" }, + { + target: "git-config", + operation: "create", + id: "context-feat-a", + scope: "worktree", + owner: "feat-a", + }, { target: "registry", operation: "publish", id: "stack-feat-a-default" }, { target: "managed-state", operation: "create", id: "stack-feat-a-default" }, { target: "runtime-state", operation: "start", id: "stack-feat-a-default" }, @@ -1217,7 +1442,13 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ stackName: "default", }, writes: [ - { target: "git-config", operation: "update", id: "context-feat", scope: "worktree" }, + { + target: "git-config", + operation: "update", + id: "context-feat", + scope: "worktree", + owner: "feat-a", + }, { target: "runtime-state", operation: "start", id: "stack-feat-default" }, ], runtimeEffects: [{ operation: "start", stackId: "stack-feat-default" }], @@ -1257,7 +1488,13 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ stackName: "default", }, writes: [ - { target: "git-config", operation: "create", id: "context-new", scope: "worktree" }, + { + target: "git-config", + operation: "create", + id: "context-new", + scope: "worktree", + owner: "feat-a", + }, { target: "registry", operation: "publish", id: "stack-new-default" }, { target: "managed-state", operation: "create", id: "stack-new-default" }, { target: "runtime-state", operation: "start", id: "stack-new-default" }, @@ -1359,7 +1596,13 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ stackName: "default", }, writes: [ - { target: "git-config", operation: "create", id: "context-new", scope: "worktree" }, + { + target: "git-config", + operation: "create", + id: "context-new", + scope: "worktree", + owner: "feat-a", + }, { target: "registry", operation: "publish", id: "stack-new-default" }, { target: "managed-state", operation: "create", id: "stack-new-default" }, { target: "runtime-state", operation: "start", id: "stack-new-default" }, @@ -1421,8 +1664,8 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ }, }, { - id: "identity.non-git-folder-reuses-workspace-context", - title: "A non-Git folder reuses one workspace-scoped context", + id: "identity.non-git-folder-first-start-persists-identity", + title: "First start in a non-Git folder persists an untracked local identity", area: "identity", given: [ { @@ -1431,13 +1674,64 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ path: "project-a", canonicalPath: "/work/project-a", }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "/work/project-a" }, + expected: { + outcome: "create", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-workspace", + stackId: "stack-workspace-default", + stackName: "default", + }, + writes: [ + { + target: "identity-marker", + operation: "create", + id: "marker-project-a", + storage: "project-local-untracked", + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-workspace", + }, + { target: "registry", operation: "publish", id: "stack-workspace-default" }, + { target: "managed-state", operation: "create", id: "stack-workspace-default" }, + { target: "runtime-state", operation: "start", id: "stack-workspace-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-workspace-default" }], + details: { identity_marker_tracked: false }, + output: { + json: { + outcome: "create", + project_id: "project-a", + checkout_id: "checkout-a", + context_id: "context-workspace", + stack_id: "stack-workspace-default", + }, + }, + }, + }, + { + id: "identity.non-git-folder-recovers-persisted-identity", + title: "A later start in a non-Git folder recovers its persisted local identity", + area: "identity", + given: [ { - kind: "checkout", - path: "/work/project-a", + kind: "workspace", + mode: "ordinary-folder", + path: "project-a", + canonicalPath: "/work/project-a", + }, + { + kind: "identity-marker", + markerId: "marker-project-a", + workspacePath: "/work/project-a", projectId: "project-a", checkoutId: "checkout-a", + contextId: "context-workspace", + tracked: false, }, - { kind: "branch", name: "(workspace)", contextId: "context-workspace", checkedOut: true }, { kind: "stack", name: "default", @@ -1501,7 +1795,13 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ stackName: "default", }, writes: [ - { target: "git-config", operation: "create", id: "context-b-main", scope: "worktree" }, + { + target: "git-config", + operation: "create", + id: "context-b-main", + scope: "worktree", + owner: "main", + }, { target: "registry", operation: "publish", id: "stack-b-main-default" }, { target: "managed-state", operation: "create", id: "stack-b-main-default" }, { target: "runtime-state", operation: "start", id: "stack-b-main-default" }, @@ -1818,6 +2118,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ operation: "create", id: "context-clone-main", scope: "worktree", + owner: "main", }, { target: "registry", operation: "publish", id: "stack-clone-main-default" }, { target: "managed-state", operation: "create", id: "stack-clone-main-default" }, @@ -1934,7 +2235,15 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ "Explicitly adopt checkout-a for /new/project-a", ], }, - json: { outcome: "error", code: "checkout_path_inaccessible", checkout_id: "checkout-a" }, + json: { + outcome: "error", + code: "checkout_path_inaccessible", + checkout_id: "checkout-a", + recovery: [ + "Restore access to /mnt/project-a and retry", + "Explicitly adopt checkout-a for /new/project-a", + ], + }, }, }, }, @@ -2069,7 +2378,13 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ stackName: "default", }, writes: [ - { target: "git-config", operation: "update", id: "context-copy", scope: "worktree" }, + { + target: "git-config", + operation: "update", + id: "context-copy", + scope: "worktree", + owner: "feat-copy", + }, { target: "registry", operation: "publish", id: "stack-copy-default" }, { target: "managed-state", operation: "create", id: "stack-copy-default" }, { target: "runtime-state", operation: "start", id: "stack-copy-default" }, @@ -2179,7 +2494,13 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ stackName: "default", }, writes: [ - { target: "git-config", operation: "update", id: "context-main", scope: "worktree" }, + { + target: "git-config", + operation: "update", + id: "context-main", + scope: "worktree", + owner: "renamed", + }, { target: "runtime-state", operation: "start", id: "stack-main-default" }, ], runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], @@ -2229,6 +2550,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ operation: "create", id: "context-clone-main", scope: "worktree", + owner: "main", }, { target: "registry", operation: "publish", id: "stack-clone-main-default" }, { target: "managed-state", operation: "create", id: "stack-clone-main-default" }, @@ -2301,7 +2623,13 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ writes: [ { target: "git-config", operation: "create", id: "project-a", scope: "common" }, { target: "git-config", operation: "create", id: "checkout-a", scope: "worktree" }, - { target: "git-config", operation: "create", id: "context-main", scope: "worktree" }, + { + target: "git-config", + operation: "create", + id: "context-main", + scope: "worktree", + owner: "main", + }, { target: "runtime-state", operation: "start", id: "stack-main-default" }, ], runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], @@ -2352,7 +2680,13 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ writes: [ { target: "git-config", operation: "create", id: "project-git", scope: "common" }, { target: "git-config", operation: "create", id: "checkout-git", scope: "worktree" }, - { target: "git-config", operation: "create", id: "context-git-main", scope: "worktree" }, + { + target: "git-config", + operation: "create", + id: "context-git-main", + scope: "worktree", + owner: "main", + }, { target: "registry", operation: "publish", id: "stack-git-default" }, { target: "managed-state", operation: "create", id: "stack-git-default" }, { target: "runtime-state", operation: "start", id: "stack-git-default" }, @@ -2442,7 +2776,13 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ stackName: "default", }, writes: [ - { target: "git-config", operation: "create", id: "context-b-main", scope: "worktree" }, + { + target: "git-config", + operation: "create", + id: "context-b-main", + scope: "worktree", + owner: "main", + }, { target: "registry", operation: "publish", id: "stack-b-main-default" }, { target: "managed-state", operation: "create", id: "stack-b-main-default" }, { target: "runtime-state", operation: "start", id: "stack-b-main-default" }, @@ -2745,6 +3085,10 @@ const additionalPortContractFixtures = defineManagedStackContractFixtures([ port: 55421, config_key: "api.port", relocated: false, + recovery: [ + "Stop the process using port 55421", + "Delete and recreate the stack to allocate new automatic ports", + ], }, }, }, @@ -2754,6 +3098,7 @@ const additionalPortContractFixtures = defineManagedStackContractFixtures([ title: "Changing an exact port on a stopped stack applies on next start", area: "ports", given: [ + ...mainCheckoutContextFacts, { kind: "stack", name: "default", @@ -2780,6 +3125,7 @@ const additionalPortContractFixtures = defineManagedStackContractFixtures([ when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, expected: { outcome: "update", + selection: mainDefaultSelection, writes: [ { target: "managed-state", operation: "update", id: "stack-main-default" }, { target: "runtime-state", operation: "start", id: "stack-main-default" }, @@ -2801,6 +3147,7 @@ const additionalPortContractFixtures = defineManagedStackContractFixtures([ title: "Changing an exact port on a running stack reports drift", area: "ports", given: [ + ...mainCheckoutContextFacts, { kind: "stack", name: "default", @@ -2831,6 +3178,7 @@ const additionalPortContractFixtures = defineManagedStackContractFixtures([ }, expected: { outcome: "report", + selection: mainDefaultSelection, warning: { code: "running_stack_config_drift", message: "api.port is running on 54321 but config requires 55321", @@ -2946,6 +3294,7 @@ const additionalPortContractFixtures = defineManagedStackContractFixtures([ port: 54321, config_key: "api.port", allocation_attempted: false, + recovery: ["Stop the legacy stack, then retry supabase start --experimental"], }, }, }, @@ -2988,6 +3337,7 @@ const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ title: "A config runtime overrides automatic selection when no explicit override exists", area: "runtime", given: [ + ...mainCheckoutContextFacts, { kind: "runtime-request", source: "config", runtime: "native" }, { kind: "runtime-request", source: "default", runtime: "auto" }, { kind: "runtime-availability", runtime: "native", available: true }, @@ -2995,6 +3345,7 @@ const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, expected: { outcome: "create", + selection: mainDefaultSelection, writes: [ { target: "managed-state", operation: "create", id: "stack-main-default" }, { target: "registry", operation: "publish", id: "stack-main-default" }, @@ -3161,6 +3512,10 @@ const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ code: "no_runtime_available", docker_reason: "daemon unavailable", native_reason: "platform graph not qualified", + recovery: [ + "Start or install Docker", + "Use a platform with a fully qualified native service graph", + ], }, }, }, @@ -3200,6 +3555,7 @@ const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ code: "docker_unavailable", requested_runtime: "docker", fallback_attempted: false, + recovery: ["Start Docker", "Remove --runtime docker to use automatic selection"], }, }, }, @@ -3209,6 +3565,7 @@ const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ title: "An existing stack reuses its persisted runtime for omitted or automatic selection", area: "runtime", given: [ + ...mainCheckoutContextFacts, { kind: "persisted-runtime", stackId: "stack-main-default", runtime: "native" }, { kind: "stack", @@ -3224,6 +3581,7 @@ const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, expected: { outcome: "reuse", + selection: mainDefaultSelection, writes: [{ target: "runtime-state", operation: "start", id: "stack-main-default" }], runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], details: { runtime: "native", auto_re_evaluated: false }, @@ -3242,6 +3600,7 @@ const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ title: "A missing prerequisite for the persisted runtime fails without switching", area: "runtime", given: [ + ...mainCheckoutContextFacts, { kind: "persisted-runtime", stackId: "stack-main-default", runtime: "native" }, { kind: "stack", @@ -3262,6 +3621,7 @@ const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, expected: { outcome: "error", + selection: mainDefaultSelection, error: { code: "persisted_runtime_unavailable", message: "stack-main-default uses native, but a required artifact is missing", @@ -3281,6 +3641,11 @@ const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ stack_id: "stack-main-default", runtime: "native", reason: "artifact missing", + recovery: [ + "Restore the native prerequisite", + "Create a new Docker named stack", + "Delete and recreate this stack", + ], }, }, }, @@ -3290,6 +3655,7 @@ const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ title: "Status reports one persisted stack-wide runtime and any drift", area: "runtime", given: [ + ...mainCheckoutContextFacts, { kind: "stack", name: "default", @@ -3307,6 +3673,7 @@ const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ }, expected: { outcome: "report", + selection: mainDefaultSelection, warning: { code: "running_stack_runtime_drift", message: "stack-main-default runs with docker but config requests native", @@ -3440,6 +3807,7 @@ const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ code: "native_platform_unsupported", platform: "darwin-x64", supported_platforms: ["darwin-arm64", "linux-amd64", "linux-arm64"], + recovery: ["Use Docker", "Use darwin-arm64, linux-amd64, or linux-arm64"], }, }, }, @@ -3488,6 +3856,7 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ title: "A first start with incompatible stopped legacy state creates a fresh managed target", area: "bootstrap", given: [ + ...mainCheckoutContextFacts, { kind: "managed-target", stackId: "stack-main-default", exists: false }, { kind: "legacy-state", @@ -3500,6 +3869,7 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, expected: { outcome: "create", + selection: mainDefaultSelection, writes: [ { target: "managed-state", operation: "create", id: "stack-main-default" }, { target: "registry", operation: "publish", id: "stack-main-default" }, @@ -3527,6 +3897,7 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ title: "A first start without legacy state creates a fresh managed target", area: "bootstrap", given: [ + ...mainCheckoutContextFacts, { kind: "managed-target", stackId: "stack-main-default", exists: false }, { kind: "legacy-state", @@ -3539,6 +3910,7 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, expected: { outcome: "create", + selection: mainDefaultSelection, writes: [ { target: "managed-state", operation: "create", id: "stack-main-default" }, { target: "registry", operation: "publish", id: "stack-main-default" }, @@ -3592,6 +3964,7 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ code: "legacy_source_running", legacy_source_stopped: false, managed_target_published: false, + recovery: ["Stop the legacy stack", "Retry supabase start --experimental"], }, }, }, @@ -3695,6 +4068,7 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ title: "Managed starts never reread legacy state after a successful bootstrap", area: "bootstrap", given: [ + ...mainCheckoutContextFacts, { kind: "managed-target", stackId: "stack-main-default", exists: true }, { kind: "legacy-state", @@ -3714,6 +4088,7 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, expected: { outcome: "reuse", + selection: mainDefaultSelection, writes: [{ target: "runtime-state", operation: "start", id: "stack-main-default" }], runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], details: { legacy_state_read: false, legacy_state_mutated: false, timelines_diverged: true }, @@ -3765,11 +4140,13 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ title: "Omitted auth values use stable local defaults", area: "credentials", given: [ + ...mainCheckoutContextFacts, { kind: "credential-state", source: "local-default", valuesId: "stable-local-defaults-v1" }, ], when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, expected: { outcome: "create", + selection: mainDefaultSelection, writes: [ { target: "managed-state", operation: "create", id: "stack-main-default" }, { target: "registry", operation: "publish", id: "stack-main-default" }, @@ -3795,6 +4172,7 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ title: "Unchanged credential values remain valid across restart", area: "credentials", given: [ + ...mainCheckoutContextFacts, { kind: "stack", name: "default", @@ -3807,6 +4185,7 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, expected: { outcome: "reuse", + selection: mainDefaultSelection, writes: [{ target: "runtime-state", operation: "start", id: "stack-main-default" }], runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], details: { credential_values_id: "stable-local-defaults-v1", credentials_rotated: false }, @@ -3820,6 +4199,7 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ title: "An explicit auth change applies to a stopped stack on next start", area: "credentials", given: [ + ...mainCheckoutContextFacts, { kind: "stack", name: "default", @@ -3837,6 +4217,7 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, expected: { outcome: "update", + selection: mainDefaultSelection, writes: [ { target: "managed-state", operation: "update", id: "stack-main-default" }, { target: "runtime-state", operation: "start", id: "stack-main-default" }, @@ -3858,6 +4239,7 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ title: "An auth change on a running stack reports unapplied drift", area: "credentials", given: [ + ...mainCheckoutContextFacts, { kind: "stack", name: "default", @@ -3879,6 +4261,7 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ }, expected: { outcome: "report", + selection: mainDefaultSelection, warning: { code: "running_stack_credentials_drift", message: "Configured auth values differ from the running stack", @@ -3952,6 +4335,7 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ title: "Default experimental stop preserves managed data", area: "reclamation", given: [ + ...mainCheckoutContextFacts, { kind: "stack", name: "default", @@ -3963,6 +4347,7 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ when: { interface: "cli", argv: ["stop", "--experimental"], cwd: "checkout-a" }, expected: { outcome: "update", + selection: mainDefaultSelection, writes: [{ target: "runtime-state", operation: "update", id: "stack-main-default" }], runtimeEffects: [{ operation: "stop", stackId: "stack-main-default" }], details: { data_preserved: true, registry_record_preserved: true }, @@ -4079,7 +4464,11 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ fields: { selectors: "--stack, --stack-id, --all" }, recovery: ["Remove all but one stack selector"], }, - json: { outcome: "error", code: "mutually_exclusive_stack_selectors" }, + json: { + outcome: "error", + code: "mutually_exclusive_stack_selectors", + recovery: ["Remove all but one stack selector"], + }, }, }, }, @@ -4088,6 +4477,7 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ title: "Experimental stop affects the selected managed stack and never the legacy engine", area: "reclamation", given: [ + ...mainCheckoutContextFacts, { kind: "stack", name: "default", @@ -4106,6 +4496,7 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ when: { interface: "cli", argv: ["stop", "--experimental"], cwd: "checkout-a" }, expected: { outcome: "update", + selection: mainDefaultSelection, writes: [{ target: "runtime-state", operation: "update", id: "stack-main-default" }], runtimeEffects: [{ operation: "stop", stackId: "stack-main-default" }], details: { @@ -4206,7 +4597,13 @@ const additionalApiBoundaryContractFixtures = defineManagedStackContractFixtures writes: [ { target: "git-config", operation: "create", id: "project-a", scope: "common" }, { target: "git-config", operation: "create", id: "checkout-a", scope: "worktree" }, - { target: "git-config", operation: "create", id: "context-main", scope: "worktree" }, + { + target: "git-config", + operation: "create", + id: "context-main", + scope: "worktree", + owner: "main", + }, { target: "registry", operation: "publish", id: "stack-main-default" }, { target: "managed-state", operation: "create", id: "stack-main-default" }, ], From 6e313937f79f143f97e6a8c6f683084f0f8f6d67 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 10 Aug 2026 19:48:45 +0200 Subject: [PATCH 14/41] test(stack): close transition and target gaps --- .../0015-managed-stack-contract-fixtures.md | 39 +++-- packages/stack/src/createStack.unit.test.ts | 33 +++- ...managed-stack-contract.integration.test.ts | 140 +++++++++++++++- packages/stack/src/managed-stack-contract.ts | 156 +++++++++++++++--- 4 files changed, 325 insertions(+), 43 deletions(-) diff --git a/docs/adr/0015-managed-stack-contract-fixtures.md b/docs/adr/0015-managed-stack-contract-fixtures.md index 0387543e9d..2169918e89 100644 --- a/docs/adr/0015-managed-stack-contract-fixtures.md +++ b/docs/adr/0015-managed-stack-contract-fixtures.md @@ -33,8 +33,8 @@ and links to implementation work; it is not a second source of executable truth. `@supabase/stack` has two distinct public responsibilities: 1. Direct `createStack(config)` creates one caller-controlled stack. Omitted stack and runtime roots - are temporary. It does not inspect Git, create identity markers, or mutate a global managed - registry. + are disposable temporary directories and are removed on disposal. It does not inspect Git, + create identity markers, or mutate a global managed registry. 2. The explicit managed surface owns system-aware discovery, identity, stack selection, ports, runtime persistence, bootstrap, and reclamation. It accepts an isolated state root or injected repository so applications and tests can use it without the CLI. @@ -85,26 +85,29 @@ test is not evidence that an unimplemented command already satisfies the behavio The fixture validator therefore checks more than catalog shape: selected, written, and effected identities must be declared; starts of existing stacks must declare a stopped lifecycle; managed state creation and registry publication must imply each other, as must managed-state deletion and -registry tombstoning; contextual CLI stack results must bind their output to a selected target; Git -identity writes must use the correct common or worktree scope, context writes must name the active -branch as owner, and adapters cannot recreate an identity already declared by a checkout; new -Git-derived contexts and selected linked worktrees must declare the relevant Git state; ordinary -folders must write their full untracked identity marker on creation and resolve it on reuse; managed -and sticky port conflicts and persisted-runtime conflicts must identify their actual target; sticky -reuse must bind the assignment to the selected target; persisted-runtime preflight failures must -identify a stopped stack; a successful bootstrap retry must follow an explicit rolled-back attempt; +registry tombstoning; every state write and runtime effect must identify its target; contextual CLI +stack results must bind their output to a selected target; Git identity writes must use the correct +common or worktree scope, context writes must name the active branch as owner, and adapters cannot +recreate an identity already declared by a checkout; new Git-derived contexts, manual ref +replacement, detached-commit reuse, and selected linked worktrees must declare the relevant Git +state or transition; ordinary folders must write their full untracked identity marker on creation +and resolve it on reuse; managed and sticky port conflicts and persisted-runtime conflicts must +identify their actual target; sticky reuse must bind the assignment to the selected target; +runtime-selection creation fixtures must establish both an absent managed target and the legacy +state decision; persisted-runtime preflight failures must identify a stopped stack; a successful +bootstrap retry must follow an explicit rolled-back attempt; credential create, update, and copy operations must prove that global state contains references rather than plaintext; data-preserving prune must begin with mutable data; tracked identity markers must remain untouched; native qualification facts must partition the service matrix, use a declared platform, and match the platform passed to preflight; status operations must remain read-only -reports; portable runtime projections must match their referenced scenario; repository adapter -projections must reference a declared scenario and agree on both identity and their complete -decision; every invalid stack name must be exercised through a public action; destructive runtime -effects must map to mutable-state deletion and runtime-state deletion must stop the running target; -other runtime effects must agree with permitted state writes; and stable identity plus exact human -and JSON recovery fields cannot contradict the managed result. We deliberately do not introduce a -parallel test-only identity resolver; it would duplicate product policy before the real managed -surface exists and could pass while the production implementation drifts. +reports; repository adapter and portable runtime projections must reference a declared scenario, +match its identity, and agree on their complete decision; every invalid stack name must be exercised +through a public action; structured JSON projections must always name their outcome; destructive +runtime effects must map to mutable-state deletion and runtime-state deletion must stop the running +target; other runtime effects must agree with permitted state writes; and stable identity plus exact +human and JSON recovery fields cannot contradict the managed result. We deliberately do not +introduce a parallel test-only identity resolver; it would duplicate product policy before the real +managed surface exists and could pass while the production implementation drifts. ## Implementation Handoff diff --git a/packages/stack/src/createStack.unit.test.ts b/packages/stack/src/createStack.unit.test.ts index a8d7c72a3e..f6ffb62778 100644 --- a/packages/stack/src/createStack.unit.test.ts +++ b/packages/stack/src/createStack.unit.test.ts @@ -1,13 +1,17 @@ import { describe, expect, it } from "vitest"; -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { candidateCleanupTargets } from "./cleanup.ts"; +import { basename, dirname, join } from "node:path"; +import { candidateCleanupTargets, cleanupAutoManagedPaths } from "./cleanup.ts"; import { dockerContainerName } from "./CleanupTargets.ts"; import { runForegroundOperation, type StackHandle } from "./createStack.ts"; import { StackReadinessError } from "./errors.ts"; import type { AllocatedPorts } from "./PortAllocator.ts"; -import { DEFAULT_MANAGED_STACK_NAME, projectKeyForProjectDir } from "./paths.ts"; +import { + DEFAULT_MANAGED_STACK_NAME, + projectKeyForProjectDir, + shortTempPrefixRoot, +} from "./paths.ts"; import { stackMetadata } from "./StackMetadata.ts"; import type { AuthConfig, @@ -354,6 +358,27 @@ describe("resolveConfig startup mode", () => { }); }); +describe("resolveConfig state roots", () => { + it("uses disposable temporary roots when direct callers omit them", async () => { + const config = await resolveConfig({ startupMode: "lazy" }); + + try { + expect(config.autoManagedPaths).toEqual([config.stackRoot, config.runtimeRoot]); + expect(dirname(config.stackRoot)).toBe(shortTempPrefixRoot()); + expect(dirname(config.runtimeRoot)).toBe(shortTempPrefixRoot()); + expect(basename(config.stackRoot)).toMatch(/^sb-stack-/); + expect(basename(config.runtimeRoot)).toMatch(/^sb-run-/); + expect(existsSync(config.stackRoot)).toBe(true); + expect(existsSync(config.runtimeRoot)).toBe(true); + } finally { + cleanupAutoManagedPaths(config); + } + + expect(existsSync(config.stackRoot)).toBe(false); + expect(existsSync(config.runtimeRoot)).toBe(false); + }); +}); + describe("resolveConfig readiness policy", () => { it("uses a finite package default", async () => { const config = await resolveConfig(); diff --git a/packages/stack/src/managed-stack-contract.integration.test.ts b/packages/stack/src/managed-stack-contract.integration.test.ts index 31051caa1f..8a688eda80 100644 --- a/packages/stack/src/managed-stack-contract.integration.test.ts +++ b/packages/stack/src/managed-stack-contract.integration.test.ts @@ -1,4 +1,12 @@ -import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + existsSync, + mkdtempSync, + mkdirSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; @@ -179,6 +187,35 @@ describe("managed stack acceptance contract", () => { `${absentLegacyScenario.id}: registry publication requires managed-state creation or copy`, ); + const targetlessStateWrites = { + ...absentLegacyScenario, + expected: { + ...absentLegacyScenario.expected, + writes: absentLegacyScenario.expected.writes.map((write) => + write.target === "managed-state" || write.target === "registry" + ? { ...write, id: "" } + : write, + ), + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([targetlessStateWrites])).toContain( + `${absentLegacyScenario.id}: managed-state write requires a target ID`, + ); + + const targetlessRuntimeEffect = { + ...absentLegacyScenario, + expected: { + ...absentLegacyScenario.expected, + runtimeEffects: absentLegacyScenario.expected.runtimeEffects.map((effect) => ({ + ...effect, + stackId: "", + })), + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([targetlessRuntimeEffect])).toContain( + `${absentLegacyScenario.id}: start runtime effect requires a stack ID`, + ); + const stoppedStackScenario = managedStackContractFixtures.find( ({ id }) => id === "reclamation.default-stop-preserves-data", ); @@ -397,6 +434,31 @@ describe("managed stack acceptance contract", () => { `${portabilityScenario.id}: portable node outcome must match ${apiStatusScenario.id}`, ); + const divergentPortableIdentity = { + ...portabilityScenario, + expected: { + ...portabilityScenario.expected, + output: { + ...portabilityScenario.expected.output, + api: { + node: { outcome: "report", stackId: "stack-main-default" }, + bun: { outcome: "report", stackId: "stack-other" }, + equal: true, + }, + }, + }, + } satisfies ManagedStackContractScenario; + const divergentPortableIdentityErrors = validateManagedStackContractFixtures([ + apiStatusScenario, + divergentPortableIdentity, + ]); + expect(divergentPortableIdentityErrors).toContain( + `${portabilityScenario.id}: portable bun stackId must match ${apiStatusScenario.id}`, + ); + expect(divergentPortableIdentityErrors).toContain( + `${portabilityScenario.id}: portable runtime decisions must be identical`, + ); + const siblingPortScenario: ManagedStackContractScenario | undefined = managedStackContractFixtures.find( ({ id }) => id === "ports.explicit-port-conflict-with-sibling-fails", @@ -478,6 +540,36 @@ describe("managed stack acceptance contract", () => { `${freshCloneScenario.id}: creating a Git context requires Git state for the workspace`, ); + const refReplacementScenario = managedStackContractFixtures.find( + ({ id }) => id === "identity.manual-ref-replacement-orphans-context", + ); + if (refReplacementScenario === undefined) { + throw new Error("identity.manual-ref-replacement-orphans-context fixture is required"); + } + const refReplacementWithoutGitState = { + ...refReplacementScenario, + given: refReplacementScenario.given.filter((fact) => fact.kind !== "git-state"), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([refReplacementWithoutGitState])).toContain( + `${refReplacementScenario.id}: creating a Git context requires Git state for the workspace`, + ); + + const detachedCommitScenario = managedStackContractFixtures.find( + ({ id }) => id === "identity.detached-commits-reuse-checkout-context", + ); + if (detachedCommitScenario === undefined) { + throw new Error("identity.detached-commits-reuse-checkout-context fixture is required"); + } + const detachedReuseWithoutTransition = { + ...detachedCommitScenario, + given: detachedCommitScenario.given.filter( + (fact) => fact.kind !== "identity-transition" || fact.operation !== "detached-commit", + ), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([detachedReuseWithoutTransition])).toContain( + `${detachedCommitScenario.id}: detached reuse must declare the commit transition`, + ); + const retryScenario = managedStackContractFixtures.find( ({ id }) => id === "bootstrap.retry-after-failed-copy-succeeds", ); @@ -691,6 +783,27 @@ describe("managed stack acceptance contract", () => { `${invalidNameScenario.id}: JSON recovery disagrees with the managed result`, ); + const credentialDefaultsScenario: ManagedStackContractScenario | undefined = + managedStackContractFixtures.find( + ({ id }) => id === "credentials.omitted-values-use-stable-defaults", + ); + const credentialDefaultsJson = credentialDefaultsScenario?.expected.output.json; + if (credentialDefaultsScenario === undefined || credentialDefaultsJson === undefined) { + throw new Error("credentials.omitted-values-use-stable-defaults JSON fixture is required"); + } + const { outcome: omittedOutcome, ...jsonWithoutOutcome } = credentialDefaultsJson; + expect(omittedOutcome).toBe("create"); + const credentialProjectionWithoutOutcome = { + ...credentialDefaultsScenario, + expected: { + ...credentialDefaultsScenario.expected, + output: { ...credentialDefaultsScenario.expected.output, json: jsonWithoutOutcome }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([credentialProjectionWithoutOutcome])).toContain( + `${credentialDefaultsScenario.id}: JSON projection requires an outcome`, + ); + const newBranchScenario = managedStackContractFixtures.find( ({ id }) => id === "identity.new-branch-first-start-creates-stack", ); @@ -744,6 +857,28 @@ describe("managed stack acceptance contract", () => { expect(validateManagedStackContractFixtures([ordinaryFolderWithoutMarkerFact])).toContain( `${laterOrdinaryFolderStart.id}: ordinary-folder reuse must resolve its identity marker`, ); + + const runtimeCreationScenario = managedStackContractFixtures.find( + ({ id }) => id === "runtime.explicit-api-overrides-auto", + ); + if (runtimeCreationScenario === undefined) { + throw new Error("runtime.explicit-api-overrides-auto fixture is required"); + } + const runtimeCreationWithoutAbsentTarget = { + ...runtimeCreationScenario, + given: runtimeCreationScenario.given.filter((fact) => fact.kind !== "managed-target"), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([runtimeCreationWithoutAbsentTarget])).toContain( + `${runtimeCreationScenario.id}: runtime creation must declare absent target stack-main-default`, + ); + + const runtimeCreationWithoutLegacyState = { + ...runtimeCreationScenario, + given: runtimeCreationScenario.given.filter((fact) => fact.kind !== "legacy-state"), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([runtimeCreationWithoutLegacyState])).toContain( + `${runtimeCreationScenario.id}: runtime creation must declare legacy state absent`, + ); }); it("covers the approved identity journeys through public commands and APIs", () => { @@ -1053,6 +1188,9 @@ describe("managed stack acceptance contract", () => { expect(readFileSync(identityMarker, "utf8")).toBe('{"sentinel":true}\n'); expect(readFileSync(registrySentinel, "utf8")).toBe('{"sentinel":true}\n'); expect(existsSync(join(cacheRoot, "projects"))).toBe(false); + expect(readdirSync(cacheRoot).sort()).toEqual(["managed-registry.json"]); + expect(readdirSync(projectDir).sort()).toEqual([".git", ".supabase"]); + expect(readdirSync(join(projectDir, ".supabase")).sort()).toEqual(["identity.json"]); } finally { rmSync(testRoot, { recursive: true, force: true }); } diff --git a/packages/stack/src/managed-stack-contract.ts b/packages/stack/src/managed-stack-contract.ts index b0bb350a88..e72a0902bb 100644 --- a/packages/stack/src/managed-stack-contract.ts +++ b/packages/stack/src/managed-stack-contract.ts @@ -59,6 +59,7 @@ export type ManagedStackContractFact = | "checkout-copy" | "checkout-move" | "clone" + | "detached-commit" | "folder-to-git" | "ref-replacement" | "symlink-alias"; @@ -216,23 +217,31 @@ type ManagedStackContractWrite = readonly contextId: string; } | { - readonly target: "ephemeral-state" | "managed-state" | "registry" | "runtime-state"; - readonly operation: - | "copy" - | "create" - | "delete" - | "publish" - | "start" - | "tombstone" - | "update"; - readonly id?: string; + readonly target: "ephemeral-state"; + readonly operation: "create"; + readonly id: string; + } + | { + readonly target: "managed-state"; + readonly operation: "copy" | "create" | "delete" | "update"; + readonly id: string; + } + | { + readonly target: "registry"; + readonly operation: "delete" | "publish" | "tombstone" | "update"; + readonly id: string; + } + | { + readonly target: "runtime-state"; + readonly operation: "delete" | "start" | "update"; + readonly id: string; }; export interface ManagedStackContractEffects { readonly writes: ReadonlyArray; readonly runtimeEffects: ReadonlyArray<{ readonly operation: "copy" | "delete" | "start" | "stop"; - readonly stackId?: string; + readonly stackId: string; }>; readonly output: ManagedStackContractOutput; } @@ -416,6 +425,16 @@ export const validateManagedStackContractFixtures = ( if (output.human === undefined && output.json === undefined && output.api === undefined) { errors.push(`${scenario.id}: at least one observable output is required`); } + for (const write of scenario.expected.writes) { + if (write.id.trim().length === 0) { + errors.push(`${scenario.id}: ${write.target} write requires a target ID`); + } + } + for (const effect of scenario.expected.runtimeEffects) { + if (effect.stackId.trim().length === 0) { + errors.push(`${scenario.id}: ${effect.operation} runtime effect requires a stack ID`); + } + } if ( scenario.when.interface === "cli" && @@ -439,6 +458,7 @@ export const validateManagedStackContractFixtures = ( if (referencedScenario === undefined) { errors.push(`${scenario.id}: portable contract must reference a declared scenario`); } else { + let firstRuntimeResult: Readonly> | undefined; for (const runtime of ["node", "bun"]) { const runtimeResult = output.api?.[runtime]; if ( @@ -448,6 +468,20 @@ export const validateManagedStackContractFixtures = ( errors.push( `${scenario.id}: portable ${runtime} outcome must match ${referencedScenario.id}`, ); + continue; + } + if ( + referencedScenario.expected.selection !== undefined && + runtimeResult.stackId !== referencedScenario.expected.selection.stackId + ) { + errors.push( + `${scenario.id}: portable ${runtime} stackId must match ${referencedScenario.id}`, + ); + } + if (firstRuntimeResult === undefined) { + firstRuntimeResult = runtimeResult; + } else if (!managedStackContractJsonEquals(firstRuntimeResult, runtimeResult)) { + errors.push(`${scenario.id}: portable runtime decisions must be identical`); } } } @@ -611,6 +645,23 @@ export const validateManagedStackContractFixtures = ( errors.push(`${scenario.id}: resolving worktree ${actionCwd} requires its Git state`); } + const detachedGitState = scenario.given.find( + (fact) => + fact.kind === "git-state" && fact.workspacePath === actionCwd && fact.head === "detached", + ); + if ( + detachedGitState?.kind === "git-state" && + scenario.expected.outcome === "reuse" && + !scenario.given.some( + (fact) => + fact.kind === "identity-transition" && + fact.operation === "detached-commit" && + fact.to === detachedGitState.commit, + ) + ) { + errors.push(`${scenario.id}: detached reuse must declare the commit transition`); + } + for (const fact of scenario.given) { if (fact.kind !== "native-qualification") { continue; @@ -682,7 +733,7 @@ export const validateManagedStackContractFixtures = ( if (scenario.expected.outcome !== "create") { for (const effect of scenario.expected.runtimeEffects) { - if (effect.operation !== "start" || effect.stackId === undefined) { + if (effect.operation !== "start") { continue; } const explicitlyStopped = scenario.given.some( @@ -699,6 +750,34 @@ export const validateManagedStackContractFixtures = ( } } + if (scenario.area === "runtime" && scenario.expected.outcome === "create") { + const createdStackIds = scenario.expected.writes.flatMap((write) => + write.target === "managed-state" && write.operation === "create" ? [write.id] : [], + ); + for (const stackId of createdStackIds) { + if ( + !scenario.given.some( + (fact) => fact.kind === "managed-target" && fact.stackId === stackId && !fact.exists, + ) + ) { + errors.push(`${scenario.id}: runtime creation must declare absent target ${stackId}`); + } + } + if ( + createdStackIds.length > 0 && + !scenario.given.some( + (fact) => + fact.kind === "legacy-state" && + fact.lifecycle === "absent" && + fact.database === "absent" && + fact.storage === "absent" && + fact.credentials === "absent", + ) + ) { + errors.push(`${scenario.id}: runtime creation must declare legacy state absent`); + } + } + if (scenario.expected.error?.code === "persisted_runtime_unavailable") { for (const fact of scenario.given) { if (fact.kind !== "persisted-runtime") { @@ -720,10 +799,9 @@ export const validateManagedStackContractFixtures = ( for (const write of scenario.expected.writes) { if ( - write.id !== undefined && - (write.operation === "copy" || - write.operation === "create" || - write.operation === "publish") + write.operation === "copy" || + write.operation === "create" || + write.operation === "publish" ) { declaredIds.add(write.id); } @@ -738,7 +816,6 @@ export const validateManagedStackContractFixtures = ( } for (const write of scenario.expected.writes) { if ( - write.id !== undefined && write.operation !== "copy" && write.operation !== "create" && write.operation !== "publish" && @@ -793,7 +870,9 @@ export const validateManagedStackContractFixtures = ( const derivesContextFromGit = scenario.given.some( (fact) => fact.kind === "identity-transition" && - (fact.operation === "clone" || fact.operation === "folder-to-git"), + (fact.operation === "clone" || + fact.operation === "folder-to-git" || + fact.operation === "ref-replacement"), ); const contextAlreadyDeclaredByBranch = scenario.given.some( (fact) => @@ -955,7 +1034,7 @@ export const validateManagedStackContractFixtures = ( scenario.expected.details?.mutable_data_deleted === false ) { for (const write of scenario.expected.writes) { - if (write.target !== "registry" || write.operation !== "delete" || write.id === undefined) { + if (write.target !== "registry" || write.operation !== "delete") { continue; } const mutableDataExists = scenario.given.some( @@ -1011,7 +1090,7 @@ export const validateManagedStackContractFixtures = ( } for (const effect of scenario.expected.runtimeEffects) { - if (effect.stackId !== undefined && !declaredIds.has(effect.stackId)) { + if (!declaredIds.has(effect.stackId)) { errors.push(`${scenario.id}: runtime effect references undeclared ID ${effect.stackId}`); } @@ -1129,6 +1208,12 @@ export const validateManagedStackContractFixtures = ( } }; + if ( + scenario.expected.output.json !== undefined && + scenario.expected.output.json.outcome === undefined + ) { + errors.push(`${scenario.id}: JSON projection requires an outcome`); + } for (const projection of [scenario.expected.output.json, scenario.expected.output.api]) { checkProjection(projection, "outcome", scenario.expected.outcome); if (scenario.expected.error !== undefined) { @@ -1275,6 +1360,17 @@ const mainCheckoutContextFacts = [ { kind: "branch", name: "main", contextId: "context-main", checkedOut: true }, ] satisfies ReadonlyArray; +const absentMainManagedStateFacts = [ + { kind: "managed-target", stackId: "stack-main-default", exists: false }, + { + kind: "legacy-state", + lifecycle: "absent", + database: "absent", + storage: "absent", + credentials: "absent", + }, +] satisfies ReadonlyArray; + const mainDefaultSelection = { projectId: "project-a", checkoutId: "checkout-a", @@ -1577,6 +1673,15 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ from: "commit-a", to: "commit-b", }, + { + kind: "git-state", + workspacePath: "checkout-a", + commonDirectory: "repo/.git", + gitDirectory: "repo/.git", + head: "branch", + branch: "feat-a", + commit: "commit-b", + }, { kind: "identity-claim", scope: "context", @@ -1626,6 +1731,12 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ given: [ { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, { kind: "branch", name: "(detached)", contextId: "context-detached", checkedOut: true }, + { + kind: "identity-transition", + operation: "detached-commit", + from: "commit-a", + to: "commit-b", + }, { kind: "git-state", workspacePath: "checkout-a", @@ -3309,6 +3420,7 @@ const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ title: "An explicit managed-API runtime overrides the default automatic selection", area: "runtime", given: [ + ...absentMainManagedStateFacts, { kind: "runtime-request", source: "managed-api", runtime: "native" }, { kind: "runtime-request", source: "default", runtime: "auto" }, { kind: "runtime-availability", runtime: "native", available: true }, @@ -3338,6 +3450,7 @@ const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ area: "runtime", given: [ ...mainCheckoutContextFacts, + ...absentMainManagedStateFacts, { kind: "runtime-request", source: "config", runtime: "native" }, { kind: "runtime-request", source: "default", runtime: "auto" }, { kind: "runtime-availability", runtime: "native", available: true }, @@ -3405,6 +3518,7 @@ const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ title: "Automatic selection prefers usable Docker", area: "runtime", given: [ + ...absentMainManagedStateFacts, { kind: "runtime-request", source: "default", runtime: "auto" }, { kind: "runtime-availability", runtime: "docker", available: true }, { kind: "runtime-availability", runtime: "native", available: true }, @@ -3432,6 +3546,7 @@ const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ "Automatic selection uses native only when Docker is unusable and the full graph qualifies", area: "runtime", given: [ + ...absentMainManagedStateFacts, { kind: "runtime-request", source: "default", runtime: "auto" }, { kind: "runtime-availability", @@ -4160,6 +4275,7 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ }, output: { json: { + outcome: "create", stack_id: "stack-main-default", credentials_source: "local-default", credentials_stable: true, From 3907ab19b1e69e709eb1d6929adedced530116ae Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 10 Aug 2026 20:10:48 +0200 Subject: [PATCH 15/41] test(stack): exercise remaining contract matrices --- .../0015-managed-stack-contract-fixtures.md | 22 +- ...managed-stack-contract.integration.test.ts | 183 ++++++++++++- packages/stack/src/managed-stack-contract.ts | 252 +++++++++++++----- 3 files changed, 375 insertions(+), 82 deletions(-) diff --git a/docs/adr/0015-managed-stack-contract-fixtures.md b/docs/adr/0015-managed-stack-contract-fixtures.md index 2169918e89..f9cdb20ab8 100644 --- a/docs/adr/0015-managed-stack-contract-fixtures.md +++ b/docs/adr/0015-managed-stack-contract-fixtures.md @@ -83,26 +83,32 @@ fixtures. CLI integration coverage begins when a real command boundary exists; a test is not evidence that an unimplemented command already satisfies the behavior. The fixture validator therefore checks more than catalog shape: selected, written, and effected -identities must be declared; starts of existing stacks must declare a stopped lifecycle; managed +identities must be declared, and a selected stack's context and name must match its declared stack +fact; starts of existing stacks must declare a stopped lifecycle; managed state creation and registry publication must imply each other, as must managed-state deletion and registry tombstoning; every state write and runtime effect must identify its target; contextual CLI stack results must bind their output to a selected target; Git identity writes must use the correct common or worktree scope, context writes must name the active branch as owner, and adapters cannot recreate an identity already declared by a checkout; new Git-derived contexts, manual ref -replacement, detached-commit reuse, and selected linked worktrees must declare the relevant Git -state or transition; ordinary folders must write their full untracked identity marker on creation +replacement, branch deletion and recreation, detached-commit reuse, and selected linked worktrees +must declare the relevant Git state or transition; ordinary folders must write their full untracked identity marker on creation and resolve it on reuse; managed and sticky port conflicts and persisted-runtime conflicts must identify their actual target; sticky reuse must bind the assignment to the selected target; -runtime-selection creation fixtures must establish both an absent managed target and the legacy -state decision; persisted-runtime preflight failures must identify a stopped stack; a successful +runtime-selection and credential creation fixtures must establish both an absent managed target and +the legacy state decision; a sibling automatic-port fixture must allocate a new target through the +public managed start action without reusing sibling-owned ports; persisted-runtime preflight +failures must identify a stopped stack; a successful bootstrap retry must follow an explicit rolled-back attempt; credential create, update, and copy operations must prove that global state contains references rather than plaintext; data-preserving prune must begin with mutable data; tracked identity markers must remain untouched; native qualification facts must partition the service matrix, use a declared platform, and match the platform passed to preflight; status operations must remain read-only -reports; repository adapter and portable runtime projections must reference a declared scenario, -match its identity, and agree on their complete decision; every invalid stack name must be exercised -through a public action; structured JSON projections must always name their outcome; destructive +reports; repository adapter matrices must be non-empty, unique, and match their declared repository +facts, while repository adapter and portable runtime projections must reference a declared scenario, +match its identity, and agree on their complete decision; every invalid stack name and every pair of +mutually exclusive stop selectors must be exercised through a public action; structured JSON +projections must always name their outcome and include the matching structured error or warning +code; destructive runtime effects must map to mutable-state deletion and runtime-state deletion must stop the running target; other runtime effects must agree with permitted state writes; and stable identity plus exact human and JSON recovery fields cannot contradict the managed result. We deliberately do not diff --git a/packages/stack/src/managed-stack-contract.integration.test.ts b/packages/stack/src/managed-stack-contract.integration.test.ts index 8a688eda80..d8f8e7b2b5 100644 --- a/packages/stack/src/managed-stack-contract.integration.test.ts +++ b/packages/stack/src/managed-stack-contract.integration.test.ts @@ -58,6 +58,53 @@ describe("managed stack acceptance contract", () => { `${scenario.id}: selection references undeclared ID stack-undeclared`, ); + const independentBranchesScenario: ManagedStackContractScenario | undefined = + managedStackContractFixtures.find( + ({ id }) => id === "identity.same-commit-different-branches-are-independent", + ); + if ( + independentBranchesScenario === undefined || + independentBranchesScenario.expected.selection === undefined + ) { + throw new Error( + "identity.same-commit-different-branches-are-independent selection is required", + ); + } + const selectionWithWrongContext = { + ...independentBranchesScenario, + expected: { + ...independentBranchesScenario.expected, + selection: { + ...independentBranchesScenario.expected.selection, + contextId: "context-main", + }, + output: { + ...independentBranchesScenario.expected.output, + api: { + ...independentBranchesScenario.expected.output.api, + contextId: "context-main", + }, + }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([selectionWithWrongContext])).toContain( + `${independentBranchesScenario.id}: selected stack stack-feat-default belongs to context context-feat, not context-main`, + ); + + const selectionWithWrongName = { + ...independentBranchesScenario, + expected: { + ...independentBranchesScenario.expected, + selection: { + ...independentBranchesScenario.expected.selection, + stackName: "review", + }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([selectionWithWrongName])).toContain( + `${independentBranchesScenario.id}: selected stack stack-feat-default is named default, not review`, + ); + const undeclaredWrite = { ...scenario, expected: { @@ -480,6 +527,33 @@ describe("managed stack acceptance contract", () => { `${siblingPortScenario.id}: managed sibling port owner must differ from the selected target`, ); + const siblingAllocationScenario: ManagedStackContractScenario | undefined = + managedStackContractFixtures.find( + ({ id }) => id === "ports.sibling-targets-allocate-independent-ports", + ); + if ( + siblingAllocationScenario === undefined || + siblingAllocationScenario.expected.output.api === undefined + ) { + throw new Error("ports.sibling-targets-allocate-independent-ports API fixture is required"); + } + const siblingAllocationCollision = { + ...siblingAllocationScenario, + expected: { + ...siblingAllocationScenario.expected, + output: { + ...siblingAllocationScenario.expected.output, + api: { + ...siblingAllocationScenario.expected.output.api, + ports: { api: 55421, db: 55424 }, + }, + }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([siblingAllocationCollision])).toContain( + `${siblingAllocationScenario.id}: allocated port 55421 conflicts with a sibling target`, + ); + const stickyPortScenario: ManagedStackContractScenario | undefined = managedStackContractFixtures.find( ({ id }) => id === "ports.later-sticky-port-collision-fails", @@ -695,6 +769,42 @@ describe("managed stack acceptance contract", () => { if (repositoryApiOutput === undefined) { throw new Error("repository contract API output is required"); } + const emptyRepositoryMatrix = { + ...repositoryContractScenario, + when: { + ...repositoryContractScenario.when, + input: { ...repositoryContractScenario.when.input, adapters: [] }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([scenario, emptyRepositoryMatrix])).toContain( + `${repositoryContractScenario.id}: repository contract must declare its adapters`, + ); + + const duplicateRepositoryMatrix = { + ...repositoryContractScenario, + when: { + ...repositoryContractScenario.when, + input: { + ...repositoryContractScenario.when.input, + adapters: ["in-memory", "in-memory"], + }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([scenario, duplicateRepositoryMatrix])).toContain( + `${repositoryContractScenario.id}: repository contract adapters must be unique`, + ); + + const repositoryMatrixMissingFact = { + ...repositoryContractScenario, + when: { + ...repositoryContractScenario.when, + input: { ...repositoryContractScenario.when.input, adapters: ["in-memory"] }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([scenario, repositoryMatrixMissingFact])).toContain( + `${repositoryContractScenario.id}: repository adapters must match declared repository facts`, + ); + const unknownRepositoryReference = { ...repositoryContractScenario, when: { @@ -783,6 +893,23 @@ describe("managed stack acceptance contract", () => { `${invalidNameScenario.id}: JSON recovery disagrees with the managed result`, ); + const invalidNameJson = invalidNameScenario.expected.output.json; + if (invalidNameJson === undefined) { + throw new Error("invalid stack name JSON fixture is required"); + } + const { code: omittedCode, ...jsonWithoutCode } = invalidNameJson; + expect(omittedCode).toBe("invalid_stack_name"); + const jsonProjectionWithoutCode = { + ...invalidNameScenario, + expected: { + ...invalidNameScenario.expected, + output: { ...invalidNameScenario.expected.output, json: jsonWithoutCode }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([jsonProjectionWithoutCode])).toContain( + `${invalidNameScenario.id}: JSON projection requires a code`, + ); + const credentialDefaultsScenario: ManagedStackContractScenario | undefined = managedStackContractFixtures.find( ({ id }) => id === "credentials.omitted-values-use-stable-defaults", @@ -825,6 +952,20 @@ describe("managed stack acceptance contract", () => { `${newBranchScenario.id}: Git context context-feat-a must belong to branch feat-a`, ); + const recreatedBranchScenario = managedStackContractFixtures.find( + ({ id }) => id === "identity.branch-delete-recreate-creates-context", + ); + if (recreatedBranchScenario === undefined) { + throw new Error("identity.branch-delete-recreate-creates-context fixture is required"); + } + const recreatedBranchWithoutGitState = { + ...recreatedBranchScenario, + given: recreatedBranchScenario.given.filter((fact) => fact.kind !== "git-state"), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([recreatedBranchWithoutGitState])).toContain( + `${recreatedBranchScenario.id}: creating a Git context requires Git state for the workspace`, + ); + const firstOrdinaryFolderStart = managedStackContractFixtures.find( ({ id }) => id === "identity.non-git-folder-first-start-persists-identity", ); @@ -869,7 +1010,7 @@ describe("managed stack acceptance contract", () => { given: runtimeCreationScenario.given.filter((fact) => fact.kind !== "managed-target"), } satisfies ManagedStackContractScenario; expect(validateManagedStackContractFixtures([runtimeCreationWithoutAbsentTarget])).toContain( - `${runtimeCreationScenario.id}: runtime creation must declare absent target stack-main-default`, + `${runtimeCreationScenario.id}: managed creation must declare absent target stack-main-default`, ); const runtimeCreationWithoutLegacyState = { @@ -877,7 +1018,29 @@ describe("managed stack acceptance contract", () => { given: runtimeCreationScenario.given.filter((fact) => fact.kind !== "legacy-state"), } satisfies ManagedStackContractScenario; expect(validateManagedStackContractFixtures([runtimeCreationWithoutLegacyState])).toContain( - `${runtimeCreationScenario.id}: runtime creation must declare legacy state absent`, + `${runtimeCreationScenario.id}: managed creation must declare legacy state absent`, + ); + + const credentialCreationScenario = managedStackContractFixtures.find( + ({ id }) => id === "credentials.configured-values-are-authoritative", + ); + if (credentialCreationScenario === undefined) { + throw new Error("credentials.configured-values-are-authoritative fixture is required"); + } + const credentialCreationWithoutAbsentTarget = { + ...credentialCreationScenario, + given: credentialCreationScenario.given.filter((fact) => fact.kind !== "managed-target"), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([credentialCreationWithoutAbsentTarget])).toContain( + `${credentialCreationScenario.id}: managed creation must declare absent target stack-main-default`, + ); + + const credentialCreationWithoutLegacyState = { + ...credentialCreationScenario, + given: credentialCreationScenario.given.filter((fact) => fact.kind !== "legacy-state"), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([credentialCreationWithoutLegacyState])).toContain( + `${credentialCreationScenario.id}: managed creation must declare legacy state absent`, ); }); @@ -1134,12 +1297,26 @@ describe("managed stack acceptance contract", () => { "reclamation.delete-orphan-by-stack-id", "reclamation.delete-repeat-is-idempotent", "reclamation.prune-removes-metadata-only", - "reclamation.selectors-are-mutually-exclusive", + "reclamation.selectors-stack-and-all-conflict", + "reclamation.selectors-stack-and-stack-id-conflict", + "reclamation.selectors-stack-id-and-all-conflict", "reclamation.stop-is-engine-scoped", ].sort(), ); }); + it("rejects every pair of explicit stop selectors through the public CLI action", () => { + expect( + managedStackContractFixtures + .filter(({ id }) => id.startsWith("reclamation.selectors-")) + .map((scenario) => (scenario.when.interface === "cli" ? scenario.when.argv : undefined)), + ).toEqual([ + ["stop", "--experimental", "--stack", "review", "--stack-id", "stack-main-default"], + ["stop", "--experimental", "--stack", "review", "--all"], + ["stop", "--experimental", "--stack-id", "stack-main-default", "--all"], + ]); + }); + it("freezes the direct, managed, repository, CLI, and portable runtime boundaries", () => { expect( managedStackContractFixtures diff --git a/packages/stack/src/managed-stack-contract.ts b/packages/stack/src/managed-stack-contract.ts index e72a0902bb..2352f421f5 100644 --- a/packages/stack/src/managed-stack-contract.ts +++ b/packages/stack/src/managed-stack-contract.ts @@ -498,9 +498,28 @@ export const validateManagedStackContractFixtures = ( errors.push(`${scenario.id}: repository contract must reference a declared scenario`); } else { const adapters = scenario.when.input.adapters; - if (!Array.isArray(adapters) || !adapters.every((adapter) => typeof adapter === "string")) { + if ( + !Array.isArray(adapters) || + adapters.length === 0 || + !adapters.every((adapter) => typeof adapter === "string" && adapter.length > 0) + ) { errors.push(`${scenario.id}: repository contract must declare its adapters`); } else { + if (new Set(adapters).size !== adapters.length) { + errors.push(`${scenario.id}: repository contract adapters must be unique`); + } + const repositoryFacts = scenario.given.flatMap((fact) => + fact.kind === "managed-api-options" ? [fact.repository] : [], + ); + const declaredRepositorySet = new Set(adapters); + const repositoryFactSet = new Set(repositoryFacts); + if ( + declaredRepositorySet.size !== repositoryFactSet.size || + [...declaredRepositorySet].some((adapter) => !repositoryFactSet.has(adapter)) + ) { + errors.push(`${scenario.id}: repository adapters must match declared repository facts`); + } + let firstAdapterResult: Readonly> | undefined; for (const adapter of adapters) { const adapterResult = output.api?.[adapter]; @@ -750,7 +769,10 @@ export const validateManagedStackContractFixtures = ( } } - if (scenario.area === "runtime" && scenario.expected.outcome === "create") { + if ( + (scenario.area === "credentials" || scenario.area === "runtime") && + scenario.expected.outcome === "create" + ) { const createdStackIds = scenario.expected.writes.flatMap((write) => write.target === "managed-state" && write.operation === "create" ? [write.id] : [], ); @@ -760,7 +782,7 @@ export const validateManagedStackContractFixtures = ( (fact) => fact.kind === "managed-target" && fact.stackId === stackId && !fact.exists, ) ) { - errors.push(`${scenario.id}: runtime creation must declare absent target ${stackId}`); + errors.push(`${scenario.id}: managed creation must declare absent target ${stackId}`); } } if ( @@ -774,7 +796,7 @@ export const validateManagedStackContractFixtures = ( fact.credentials === "absent", ) ) { - errors.push(`${scenario.id}: runtime creation must declare legacy state absent`); + errors.push(`${scenario.id}: managed creation must declare legacy state absent`); } } @@ -843,6 +865,22 @@ export const validateManagedStackContractFixtures = ( } } + const selectedStack = scenario.given.find( + (fact) => fact.kind === "stack" && fact.stackId === selection.stackId, + ); + if (selectedStack?.kind === "stack") { + if (selectedStack.contextId !== selection.contextId) { + errors.push( + `${scenario.id}: selected stack ${selection.stackId} belongs to context ${selectedStack.contextId}, not ${selection.contextId}`, + ); + } + if (selectedStack.name !== selection.stackName) { + errors.push( + `${scenario.id}: selected stack ${selection.stackId} is named ${selectedStack.name}, not ${selection.stackName}`, + ); + } + } + if ( scenario.given.some( (fact) => fact.kind === "identity-transition" && fact.operation === "folder-to-git", @@ -871,6 +909,7 @@ export const validateManagedStackContractFixtures = ( (fact) => fact.kind === "identity-transition" && (fact.operation === "clone" || + fact.operation === "branch-delete-recreate" || fact.operation === "folder-to-git" || fact.operation === "ref-replacement"), ); @@ -982,6 +1021,35 @@ export const validateManagedStackContractFixtures = ( } } + if ( + scenario.area === "ports" && + scenario.when.interface === "managed-api" && + scenario.when.method === "startStack" && + scenario.expected.outcome === "create" + ) { + const targetStackId = scenario.when.input.stackId; + const siblingAssignments = scenario.given.flatMap((fact) => + fact.kind === "port-assignment" && + typeof targetStackId === "string" && + fact.stackId !== targetStackId + ? [fact] + : [], + ); + if (siblingAssignments.length > 0) { + const projectedPorts = scenario.expected.output.api?.ports; + if (!isManagedStackContractRecord(projectedPorts)) { + errors.push(`${scenario.id}: sibling allocation must project its allocated ports`); + } else { + const occupiedSiblingPorts = new Set(siblingAssignments.map(({ port }) => port)); + for (const port of Object.values(projectedPorts)) { + if (typeof port === "number" && occupiedSiblingPorts.has(port)) { + errors.push(`${scenario.id}: allocated port ${port} conflicts with a sibling target`); + } + } + } + } + } + if (scenario.expected.error?.code === "runtime_conflicts_with_persisted_stack") { if (selection === undefined) { errors.push(`${scenario.id}: persisted runtime conflict requires a selected target`); @@ -1214,6 +1282,14 @@ export const validateManagedStackContractFixtures = ( ) { errors.push(`${scenario.id}: JSON projection requires an outcome`); } + const structuredCode = scenario.expected.error?.code ?? scenario.expected.warning?.code; + if ( + scenario.expected.output.json !== undefined && + structuredCode !== undefined && + scenario.expected.output.json.code === undefined + ) { + errors.push(`${scenario.id}: JSON projection requires a code`); + } for (const projection of [scenario.expected.output.json, scenario.expected.output.api]) { checkProjection(projection, "outcome", scenario.expected.outcome); if (scenario.expected.error !== undefined) { @@ -1559,6 +1635,15 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ area: "identity", given: [ { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, + { + kind: "git-state", + workspacePath: "checkout-a", + commonDirectory: "repo/.git", + gitDirectory: "repo/.git", + head: "branch", + branch: "feat-a", + commit: "commit-new", + }, { kind: "identity-transition", operation: "branch-delete-recreate", @@ -3052,9 +3137,12 @@ const additionalPortContractFixtures = defineManagedStackContractFixtures([ }, { id: "ports.sibling-targets-allocate-independent-ports", - title: "Sibling branches, worktrees, and named stacks allocate independent automatic ports", + title: "A new sibling target allocates around existing host-wide port ownership", area: "ports", given: [ + { kind: "managed-target", stackId: "stack-feat-default", exists: false }, + { kind: "config-port", key: "api.port", intent: "automatic", source: "omitted" }, + { kind: "config-port", key: "db.port", intent: "automatic", source: "omitted" }, { kind: "port-assignment", stackId: "stack-main-default", @@ -3062,39 +3150,41 @@ const additionalPortContractFixtures = defineManagedStackContractFixtures([ port: 55421, intent: "automatic", }, - { - kind: "port-assignment", - stackId: "stack-feat-default", - key: "api.port", - port: 55422, - intent: "automatic", - }, - { - kind: "port-assignment", - stackId: "stack-worktree-default", - key: "api.port", - port: 55423, - intent: "automatic", - }, { kind: "port-assignment", stackId: "stack-main-review", - key: "api.port", - port: 55424, + key: "db.port", + port: 55422, intent: "automatic", }, ], - when: { interface: "managed-api", method: "listStackPorts", input: { projectId: "project-a" } }, + when: { + interface: "managed-api", + method: "startStack", + input: { + stackId: "stack-feat-default", + portIntents: { "api.port": "automatic", "db.port": "automatic" }, + }, + }, expected: { - outcome: "report", - writes: [], - runtimeEffects: [], + outcome: "create", + writes: [ + { target: "managed-state", operation: "create", id: "stack-feat-default" }, + { target: "registry", operation: "publish", id: "stack-feat-default" }, + { target: "runtime-state", operation: "start", id: "stack-feat-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-feat-default" }], + details: { + host_wide: true, + sticky: true, + avoided_sibling_stack_ids: ["stack-main-default", "stack-main-review"], + }, output: { api: { - "stack-main-default": { api: 55421 }, - "stack-feat-default": { api: 55422 }, - "stack-worktree-default": { api: 55423 }, - "stack-main-review": { api: 55424 }, + outcome: "create", + stackId: "stack-feat-default", + ports: { api: 55423, db: 55424 }, + intents: { api: "automatic", db: "automatic" }, }, }, }, @@ -3929,6 +4019,45 @@ const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ }, ]); +const selectorConflictFixture = ( + id: string, + title: string, + selectors: ReadonlyArray, + selectorSummary: string, +): ManagedStackContractScenario => ({ + id, + title, + area: "reclamation", + given: [{ kind: "managed-record", stackId: "stack-main-default", status: "active" }], + when: { + interface: "cli", + argv: ["stop", "--experimental", ...selectors], + cwd: "checkout-a", + }, + expected: { + outcome: "error", + error: { + code: "mutually_exclusive_stack_selectors", + message: "Choose exactly one of contextual, --stack, --stack-id, or --all selection", + recovery: ["Remove all but one stack selector"], + }, + writes: [], + runtimeEffects: [], + output: { + human: { + summary: "Stack selectors cannot be combined", + fields: { selectors: selectorSummary }, + recovery: ["Remove all but one stack selector"], + }, + json: { + outcome: "error", + code: "mutually_exclusive_stack_selectors", + recovery: ["Remove all but one stack selector"], + }, + }, + }, +}); + const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ { id: "bootstrap.existing-managed-target-ignores-legacy", @@ -4221,7 +4350,10 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ id: "credentials.configured-values-are-authoritative", title: "Configured auth values are authoritative and persist globally only by reference", area: "credentials", - given: [{ kind: "credential-state", source: "configured", valuesId: "configured-auth-v1" }], + given: [ + ...absentMainManagedStateFacts, + { kind: "credential-state", source: "configured", valuesId: "configured-auth-v1" }, + ], when: { interface: "managed-api", method: "startStack", @@ -4256,6 +4388,7 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ area: "credentials", given: [ ...mainCheckoutContextFacts, + ...absentMainManagedStateFacts, { kind: "credential-state", source: "local-default", valuesId: "stable-local-defaults-v1" }, ], when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, @@ -4547,47 +4680,24 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ }, }, }, - { - id: "reclamation.selectors-are-mutually-exclusive", - title: "Contextual, named, global-ID, and all-stack selectors cannot be combined", - area: "reclamation", - given: [{ kind: "managed-record", stackId: "stack-main-default", status: "active" }], - when: { - interface: "cli", - argv: [ - "stop", - "--experimental", - "--stack", - "review", - "--stack-id", - "stack-main-default", - "--all", - ], - cwd: "checkout-a", - }, - expected: { - outcome: "error", - error: { - code: "mutually_exclusive_stack_selectors", - message: "Choose exactly one of contextual, --stack, --stack-id, or --all selection", - recovery: ["Remove all but one stack selector"], - }, - writes: [], - runtimeEffects: [], - output: { - human: { - summary: "Stack selectors cannot be combined", - fields: { selectors: "--stack, --stack-id, --all" }, - recovery: ["Remove all but one stack selector"], - }, - json: { - outcome: "error", - code: "mutually_exclusive_stack_selectors", - recovery: ["Remove all but one stack selector"], - }, - }, - }, - }, + selectorConflictFixture( + "reclamation.selectors-stack-and-stack-id-conflict", + "Named and global-ID stack selectors cannot be combined", + ["--stack", "review", "--stack-id", "stack-main-default"], + "--stack, --stack-id", + ), + selectorConflictFixture( + "reclamation.selectors-stack-and-all-conflict", + "Named and all-stack selectors cannot be combined", + ["--stack", "review", "--all"], + "--stack, --all", + ), + selectorConflictFixture( + "reclamation.selectors-stack-id-and-all-conflict", + "Global-ID and all-stack selectors cannot be combined", + ["--stack-id", "stack-main-default", "--all"], + "--stack-id, --all", + ), { id: "reclamation.stop-is-engine-scoped", title: "Experimental stop affects the selected managed stack and never the legacy engine", From fd766c1dcdf708d819bfa82cbb083563afd9397e Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 10 Aug 2026 20:39:40 +0200 Subject: [PATCH 16/41] test(stack): bind destructive targets and matrices --- .../0015-managed-stack-contract-fixtures.md | 49 ++-- ...managed-stack-contract.integration.test.ts | 128 +++++++++- packages/stack/src/managed-stack-contract.ts | 219 +++++++++++++++--- 3 files changed, 329 insertions(+), 67 deletions(-) diff --git a/docs/adr/0015-managed-stack-contract-fixtures.md b/docs/adr/0015-managed-stack-contract-fixtures.md index f9cdb20ab8..bafb3657ca 100644 --- a/docs/adr/0015-managed-stack-contract-fixtures.md +++ b/docs/adr/0015-managed-stack-contract-fixtures.md @@ -84,36 +84,39 @@ test is not evidence that an unimplemented command already satisfies the behavio The fixture validator therefore checks more than catalog shape: selected, written, and effected identities must be declared, and a selected stack's context and name must match its declared stack -fact; starts of existing stacks must declare a stopped lifecycle; managed -state creation and registry publication must imply each other, as must managed-state deletion and -registry tombstoning; every state write and runtime effect must identify its target; contextual CLI -stack results must bind their output to a selected target; Git identity writes must use the correct -common or worktree scope, context writes must name the active branch as owner, and adapters cannot -recreate an identity already declared by a checkout; new Git-derived contexts, manual ref -replacement, branch deletion and recreation, detached-commit reuse, and selected linked worktrees -must declare the relevant Git state or transition; ordinary folders must write their full untracked identity marker on creation -and resolve it on reuse; managed and sticky port conflicts and persisted-runtime conflicts must -identify their actual target; sticky reuse must bind the assignment to the selected target; -runtime-selection and credential creation fixtures must establish both an absent managed target and -the legacy state decision; a sibling automatic-port fixture must allocate a new target through the -public managed start action without reusing sibling-owned ports; persisted-runtime preflight -failures must identify a stopped stack; a successful -bootstrap retry must follow an explicit rolled-back attempt; +fact; explicit CLI and API stack IDs must match every selected, mutated, effected, and projected +stack target; starts of existing stacks must declare a stopped lifecycle; managed state creation and +registry publication must imply each other, as must managed-state deletion and registry tombstoning; +every state write and runtime effect must identify its target; contextual CLI stack results must +bind their output to a selected target; Git identity writes must use the correct common or worktree +scope, context writes must name the active branch as owner, and adapters cannot recreate an identity +already declared by a checkout; new Git-derived contexts, manual ref replacement, branch deletion +and recreation, detached-commit reuse, and selected linked worktrees must declare the relevant Git +state or transition; ordinary folders must write their full untracked identity marker on creation +and resolve it on reuse; branch deletion must bind the deleted ref to its checkout, Git state, +context, and orphaned stack; managed and sticky port conflicts and persisted-runtime conflicts must +identify their actual target; managed port ownership requires an owner stack ID that agrees with +every projection; sticky reuse must bind the assignment to the selected target; runtime-selection +and credential creation fixtures must establish both an absent managed target and the legacy state +decision; a sibling automatic-port fixture must allocate a new target through the public managed +start action without reusing sibling-owned ports; persisted-runtime preflight failures must identify +a stopped stack; a successful bootstrap retry must follow an explicit rolled-back attempt; credential create, update, and copy operations must prove that global state contains references rather than plaintext; data-preserving prune must begin with mutable data; tracked identity markers must remain untouched; native qualification facts must partition the service matrix, use a declared platform, and match the platform passed to preflight; status operations must remain read-only reports; repository adapter matrices must be non-empty, unique, and match their declared repository -facts, while repository adapter and portable runtime projections must reference a declared scenario, -match its identity, and agree on their complete decision; every invalid stack name and every pair of +facts, and portable runtime matrices must satisfy the same rules against runtime facts, while +repository adapter and portable runtime projections must reference a declared scenario, match its +identity, and agree on their complete decision; every invalid stack name and every pair of mutually exclusive stop selectors must be exercised through a public action; structured JSON projections must always name their outcome and include the matching structured error or warning -code; destructive -runtime effects must map to mutable-state deletion and runtime-state deletion must stop the running -target; other runtime effects must agree with permitted state writes; and stable identity plus exact -human and JSON recovery fields cannot contradict the managed result. We deliberately do not -introduce a parallel test-only identity resolver; it would duplicate product policy before the real -managed surface exists and could pass while the production implementation drifts. +code; destructive runtime effects must map to mutable-state deletion and runtime-state deletion must +stop the running target; other runtime effects must agree with permitted state writes; and stable +identity plus exact human and JSON recovery fields cannot contradict the managed result. We +deliberately do not introduce a parallel test-only identity resolver; it would duplicate product +policy before the real managed surface exists and could pass while the production implementation +drifts. ## Implementation Handoff diff --git a/packages/stack/src/managed-stack-contract.integration.test.ts b/packages/stack/src/managed-stack-contract.integration.test.ts index d8f8e7b2b5..71b0f82de8 100644 --- a/packages/stack/src/managed-stack-contract.integration.test.ts +++ b/packages/stack/src/managed-stack-contract.integration.test.ts @@ -455,12 +455,46 @@ describe("managed stack acceptance contract", () => { `${unavailableRuntimeScenario.id}: persisted runtime failure for stack-main-default requires an explicit stopped lifecycle`, ); - const portabilityScenario = managedStackContractFixtures.find( - ({ id }) => id === "api-boundary.managed-surface-is-node-and-bun-portable", - ); - if (portabilityScenario === undefined) { + const portabilityScenario: ManagedStackContractScenario | undefined = + managedStackContractFixtures.find( + ({ id }) => id === "api-boundary.managed-surface-is-node-and-bun-portable", + ); + if (portabilityScenario === undefined || portabilityScenario.when.interface !== "managed-api") { throw new Error("api-boundary.managed-surface-is-node-and-bun-portable fixture is required"); } + const emptyRuntimeMatrix = { + ...portabilityScenario, + when: { + ...portabilityScenario.when, + input: { ...portabilityScenario.when.input, runtimes: [] }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([apiStatusScenario, emptyRuntimeMatrix])).toContain( + `${portabilityScenario.id}: portable contract must declare its runtimes`, + ); + + const duplicateRuntimeMatrix = { + ...portabilityScenario, + when: { + ...portabilityScenario.when, + input: { ...portabilityScenario.when.input, runtimes: ["node", "node"] }, + }, + } satisfies ManagedStackContractScenario; + expect( + validateManagedStackContractFixtures([apiStatusScenario, duplicateRuntimeMatrix]), + ).toContain(`${portabilityScenario.id}: portable contract runtimes must be unique`); + + const runtimeMatrixMissingFact = { + ...portabilityScenario, + when: { + ...portabilityScenario.when, + input: { ...portabilityScenario.when.input, runtimes: ["node"] }, + }, + } satisfies ManagedStackContractScenario; + expect( + validateManagedStackContractFixtures([apiStatusScenario, runtimeMatrixMissingFact]), + ).toContain(`${portabilityScenario.id}: portable runtimes must match declared runtime facts`); + const divergentPortableResult = { ...portabilityScenario, expected: { @@ -527,6 +561,24 @@ describe("managed stack acceptance contract", () => { `${siblingPortScenario.id}: managed sibling port owner must differ from the selected target`, ); + const siblingJsonOutput = siblingPortScenario.expected.output.json; + if (siblingJsonOutput === undefined) { + throw new Error("ports.explicit-port-conflict-with-sibling-fails JSON fixture is required"); + } + const projectedWrongPortOwner = { + ...siblingPortScenario, + expected: { + ...siblingPortScenario.expected, + output: { + ...siblingPortScenario.expected.output, + json: { ...siblingJsonOutput, owner_stack_id: "stack-other" }, + }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([projectedWrongPortOwner])).toContain( + `${siblingPortScenario.id}: projected managed port owner must match stack-main-default`, + ); + const siblingAllocationScenario: ManagedStackContractScenario | undefined = managedStackContractFixtures.find( ({ id }) => id === "ports.sibling-targets-allocate-independent-ports", @@ -698,12 +750,74 @@ describe("managed stack acceptance contract", () => { `${pruneScenario.id}: data-preserving prune must declare mutable stack data`, ); - const deleteScenario = managedStackContractFixtures.find( - ({ id }) => id === "reclamation.delete-orphan-by-stack-id", + const branchDeletionScenario = managedStackContractFixtures.find( + ({ id }) => id === "reclamation.branch-delete-does-not-delete-data", + ); + if (branchDeletionScenario === undefined) { + throw new Error("reclamation.branch-delete-does-not-delete-data fixture is required"); + } + const deletionWithUnboundBranch = { + ...branchDeletionScenario, + given: branchDeletionScenario.given.map((fact) => + fact.kind === "branch" && fact.name === "feat-a" + ? { ...fact, contextId: "context-other" } + : fact, + ), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([deletionWithUnboundBranch])).toContain( + `${branchDeletionScenario.id}: branch deletion must bind its branch to an affected managed stack`, + ); + + const deletionWithoutCheckoutGitState = { + ...branchDeletionScenario, + given: branchDeletionScenario.given.filter( + (fact) => fact.kind !== "checkout" && fact.kind !== "git-state", + ), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([deletionWithoutCheckoutGitState])).toContain( + `${branchDeletionScenario.id}: branch deletion must declare checkout Git state`, + ); + + const deletionWithoutPreservationResult = { + ...branchDeletionScenario, + expected: { ...branchDeletionScenario.expected, details: undefined }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([deletionWithoutPreservationResult])).toContain( + `${branchDeletionScenario.id}: branch deletion must preserve and orphan managed stack data`, ); - if (deleteScenario === undefined) { + + const deleteScenario: ManagedStackContractScenario | undefined = + managedStackContractFixtures.find(({ id }) => id === "reclamation.delete-orphan-by-stack-id"); + if (deleteScenario === undefined || deleteScenario.when.interface !== "cli") { throw new Error("reclamation.delete-orphan-by-stack-id fixture is required"); } + const deleteWithMismatchedActionTarget = { + ...deleteScenario, + when: { + ...deleteScenario.when, + argv: deleteScenario.when.argv.map((arg) => (arg === "stack-orphan" ? "stack-other" : arg)), + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([deleteWithMismatchedActionTarget])).toContain( + `${deleteScenario.id}: explicit action target stack-other disagrees with expected stack stack-orphan`, + ); + + const failedCopyScenario: ManagedStackContractScenario | undefined = + managedStackContractFixtures.find(({ id }) => id === "bootstrap.failed-copy-rolls-back"); + if (failedCopyScenario === undefined || failedCopyScenario.when.interface !== "managed-api") { + throw new Error("bootstrap.failed-copy-rolls-back managed API fixture is required"); + } + const failedCopyWithMismatchedActionTarget = { + ...failedCopyScenario, + when: { + ...failedCopyScenario.when, + input: { ...failedCopyScenario.when.input, stackId: "stack-other" }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([failedCopyWithMismatchedActionTarget])).toContain( + `${failedCopyScenario.id}: explicit action target stack-other disagrees with expected stack stack-main-default`, + ); + const runtimeMetadataOnlyDelete = { ...deleteScenario, expected: { diff --git a/packages/stack/src/managed-stack-contract.ts b/packages/stack/src/managed-stack-contract.ts index 2352f421f5..b4d109d1f5 100644 --- a/packages/stack/src/managed-stack-contract.ts +++ b/packages/stack/src/managed-stack-contract.ts @@ -121,7 +121,13 @@ export type ManagedStackContractFact = | { readonly kind: "occupied-port"; readonly port: number; - readonly owner: "external-process" | "legacy-stack" | "managed-stack"; + readonly owner: "managed-stack"; + readonly ownerId: string; + } + | { + readonly kind: "occupied-port"; + readonly port: number; + readonly owner: "external-process" | "legacy-stack"; readonly ownerId?: string; } | { @@ -436,6 +442,50 @@ export const validateManagedStackContractFixtures = ( } } + const cliStackIdIndex = + scenario.when.interface === "cli" ? scenario.when.argv.indexOf("--stack-id") : -1; + const explicitActionStackId = + scenario.when.interface === "cli" && cliStackIdIndex >= 0 + ? scenario.when.argv[cliStackIdIndex + 1] + : (scenario.when.interface === "managed-api" || scenario.when.interface === "stack-api") && + typeof scenario.when.input.stackId === "string" + ? scenario.when.input.stackId + : undefined; + if (explicitActionStackId !== undefined) { + const expectedStackIds = new Set(); + if (scenario.expected.selection !== undefined) { + expectedStackIds.add(scenario.expected.selection.stackId); + } + for (const write of scenario.expected.writes) { + if ( + write.target === "managed-state" || + write.target === "registry" || + write.target === "runtime-state" + ) { + expectedStackIds.add(write.id); + } + } + for (const effect of scenario.expected.runtimeEffects) { + expectedStackIds.add(effect.stackId); + } + for (const projectedStackId of [ + output.api?.stackId, + output.json?.stack_id, + output.human?.fields.stackId, + ]) { + if (typeof projectedStackId === "string") { + expectedStackIds.add(projectedStackId); + } + } + for (const expectedStackId of expectedStackIds) { + if (expectedStackId !== explicitActionStackId) { + errors.push( + `${scenario.id}: explicit action target ${explicitActionStackId} disagrees with expected stack ${expectedStackId}`, + ); + } + } + } + if ( scenario.when.interface === "cli" && (scenario.when.argv[0] === "start" || @@ -458,30 +508,54 @@ export const validateManagedStackContractFixtures = ( if (referencedScenario === undefined) { errors.push(`${scenario.id}: portable contract must reference a declared scenario`); } else { - let firstRuntimeResult: Readonly> | undefined; - for (const runtime of ["node", "bun"]) { - const runtimeResult = output.api?.[runtime]; - if ( - !isManagedStackContractRecord(runtimeResult) || - runtimeResult.outcome !== referencedScenario.expected.outcome - ) { - errors.push( - `${scenario.id}: portable ${runtime} outcome must match ${referencedScenario.id}`, - ); - continue; + const runtimes = scenario.when.input.runtimes; + if ( + !Array.isArray(runtimes) || + runtimes.length === 0 || + !runtimes.every((runtime) => typeof runtime === "string" && runtime.length > 0) + ) { + errors.push(`${scenario.id}: portable contract must declare its runtimes`); + } else { + if (new Set(runtimes).size !== runtimes.length) { + errors.push(`${scenario.id}: portable contract runtimes must be unique`); } + const runtimeFacts = scenario.given.flatMap((fact) => + fact.kind === "managed-api-options" ? [fact.runtime] : [], + ); + const declaredRuntimeSet = new Set(runtimes); + const runtimeFactSet = new Set(runtimeFacts); if ( - referencedScenario.expected.selection !== undefined && - runtimeResult.stackId !== referencedScenario.expected.selection.stackId + declaredRuntimeSet.size !== runtimeFactSet.size || + [...declaredRuntimeSet].some((runtime) => !runtimeFactSet.has(runtime)) ) { - errors.push( - `${scenario.id}: portable ${runtime} stackId must match ${referencedScenario.id}`, - ); + errors.push(`${scenario.id}: portable runtimes must match declared runtime facts`); } - if (firstRuntimeResult === undefined) { - firstRuntimeResult = runtimeResult; - } else if (!managedStackContractJsonEquals(firstRuntimeResult, runtimeResult)) { - errors.push(`${scenario.id}: portable runtime decisions must be identical`); + + let firstRuntimeResult: Readonly> | undefined; + for (const runtime of runtimes) { + const runtimeResult = output.api?.[runtime]; + if ( + !isManagedStackContractRecord(runtimeResult) || + runtimeResult.outcome !== referencedScenario.expected.outcome + ) { + errors.push( + `${scenario.id}: portable ${runtime} outcome must match ${referencedScenario.id}`, + ); + continue; + } + if ( + referencedScenario.expected.selection !== undefined && + runtimeResult.stackId !== referencedScenario.expected.selection.stackId + ) { + errors.push( + `${scenario.id}: portable ${runtime} stackId must match ${referencedScenario.id}`, + ); + } + if (firstRuntimeResult === undefined) { + firstRuntimeResult = runtimeResult; + } else if (!managedStackContractJsonEquals(firstRuntimeResult, runtimeResult)) { + errors.push(`${scenario.id}: portable runtime decisions must be identical`); + } } } } @@ -651,6 +725,55 @@ export const validateManagedStackContractFixtures = ( : typeof scenario.when.input.cwd === "string" ? scenario.when.input.cwd : undefined; + if ( + scenario.when.interface === "git" && + scenario.when.argv[0] === "branch" && + (scenario.when.argv[1] === "-D" || scenario.when.argv[1] === "-d") + ) { + if ( + scenario.expected.details?.stack_orphaned !== true || + scenario.expected.details.stack_data_preserved !== true + ) { + errors.push(`${scenario.id}: branch deletion must preserve and orphan managed stack data`); + } + const deletedBranchName = scenario.when.argv[2]; + const deletedBranch = scenario.given.find( + (fact) => fact.kind === "branch" && fact.name === deletedBranchName, + ); + const affectedStack = + deletedBranch?.kind === "branch" + ? scenario.given.find( + (fact) => fact.kind === "stack" && fact.contextId === deletedBranch.contextId, + ) + : undefined; + if ( + deletedBranchName === undefined || + deletedBranch?.kind !== "branch" || + affectedStack?.kind !== "stack" + ) { + errors.push( + `${scenario.id}: branch deletion must bind its branch to an affected managed stack`, + ); + } else { + if (deletedBranch.checkedOut) { + errors.push(`${scenario.id}: deleted branch ${deletedBranchName} cannot be checked out`); + } + if (scenario.expected.details?.orphaned_stack_id !== affectedStack.stackId) { + errors.push( + `${scenario.id}: orphaned stack must be ${affectedStack.stackId} for branch ${deletedBranchName}`, + ); + } + } + if ( + actionCwd === undefined || + !scenario.given.some((fact) => fact.kind === "checkout" && fact.path === actionCwd) || + !scenario.given.some( + (fact) => fact.kind === "git-state" && fact.workspacePath === actionCwd, + ) + ) { + errors.push(`${scenario.id}: branch deletion must declare checkout Git state`); + } + } if ( actionCwd !== undefined && scenario.given.some( @@ -965,23 +1088,29 @@ export const validateManagedStackContractFixtures = ( } } - if ( - scenario.expected.error?.code === "exact_port_occupied" && - scenario.given.some((fact) => fact.kind === "occupied-port" && fact.owner === "managed-stack") - ) { + const managedPortOwners = scenario.given.flatMap((fact) => + fact.kind === "occupied-port" && fact.owner === "managed-stack" ? [fact] : [], + ); + if (scenario.expected.error?.code === "exact_port_occupied" && managedPortOwners.length > 0) { if (selection === undefined) { errors.push(`${scenario.id}: managed sibling port conflict requires a selected target`); - } else if ( - scenario.given.some( - (fact) => - fact.kind === "occupied-port" && - fact.owner === "managed-stack" && - fact.ownerId === selection.stackId, - ) - ) { - errors.push( - `${scenario.id}: managed sibling port owner must differ from the selected target`, - ); + } + for (const owner of managedPortOwners) { + if (owner.ownerId.trim().length === 0) { + errors.push(`${scenario.id}: managed sibling port owner requires a stack ID`); + } + if (owner.ownerId === selection?.stackId) { + errors.push( + `${scenario.id}: managed sibling port owner must differ from the selected target`, + ); + } + if ( + (output.json !== undefined && output.json.owner_stack_id !== owner.ownerId) || + (output.api !== undefined && output.api.ownerStackId !== owner.ownerId) || + (output.human !== undefined && output.human.fields.ownerStackId !== owner.ownerId) + ) { + errors.push(`${scenario.id}: projected managed port owner must match ${owner.ownerId}`); + } } } @@ -4636,6 +4765,18 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ title: "Deleting a Git branch alone never deletes its mutable stack data", area: "reclamation", given: [ + { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, + { kind: "branch", name: "main", contextId: "context-main", checkedOut: true }, + { kind: "branch", name: "feat-a", contextId: "context-feat", checkedOut: false }, + { + kind: "git-state", + workspacePath: "checkout-a", + commonDirectory: "repo/.git", + gitDirectory: "repo/.git", + head: "branch", + branch: "main", + commit: "commit-main", + }, { kind: "stack", name: "default", @@ -4649,7 +4790,11 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ outcome: "no-op", writes: [], runtimeEffects: [], - details: { stack_data_preserved: true, stack_orphaned: true }, + details: { + stack_data_preserved: true, + stack_orphaned: true, + orphaned_stack_id: "stack-feat-default", + }, output: { human: { summary: "Deleted branch feat-a", fields: {} } }, }, }, From 91a1d8d391816e82c38ebc0d912bc78456162a4e Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 10 Aug 2026 21:02:26 +0200 Subject: [PATCH 17/41] test(stack): bind start and concurrency preconditions --- .../0015-managed-stack-contract-fixtures.md | 20 +- ...managed-stack-contract.integration.test.ts | 159 ++++++++++++++- packages/stack/src/managed-stack-contract.ts | 183 +++++++++++++++--- 3 files changed, 318 insertions(+), 44 deletions(-) diff --git a/docs/adr/0015-managed-stack-contract-fixtures.md b/docs/adr/0015-managed-stack-contract-fixtures.md index bafb3657ca..e6faf9110a 100644 --- a/docs/adr/0015-managed-stack-contract-fixtures.md +++ b/docs/adr/0015-managed-stack-contract-fixtures.md @@ -83,10 +83,13 @@ fixtures. CLI integration coverage begins when a real command boundary exists; a test is not evidence that an unimplemented command already satisfies the behavior. The fixture validator therefore checks more than catalog shape: selected, written, and effected -identities must be declared, and a selected stack's context and name must match its declared stack +identities must be declared rather than relying on absent claims, a selection must belong to the +checkout at the action path, and a selected stack's context and name must match its declared stack fact; explicit CLI and API stack IDs must match every selected, mutated, effected, and projected -stack target; starts of existing stacks must declare a stopped lifecycle; managed state creation and -registry publication must imply each other, as must managed-state deletion and registry tombstoning; +stack target; starts of existing stacks must declare a stopped lifecycle, while every fresh managed +start must declare both an absent target and legacy state that is explicitly absent or incompatible; +managed state creation and registry publication must imply each other, as must managed-state +deletion and registry tombstoning; every state write and runtime effect must identify its target; contextual CLI stack results must bind their output to a selected target; Git identity writes must use the correct common or worktree scope, context writes must name the active branch as owner, and adapters cannot recreate an identity @@ -96,11 +99,12 @@ state or transition; ordinary folders must write their full untracked identity m and resolve it on reuse; branch deletion must bind the deleted ref to its checkout, Git state, context, and orphaned stack; managed and sticky port conflicts and persisted-runtime conflicts must identify their actual target; managed port ownership requires an owner stack ID that agrees with -every projection; sticky reuse must bind the assignment to the selected target; runtime-selection -and credential creation fixtures must establish both an absent managed target and the legacy state -decision; a sibling automatic-port fixture must allocate a new target through the public managed -start action without reusing sibling-owned ports; persisted-runtime preflight failures must identify -a stopped stack; a successful bootstrap retry must follow an explicit rolled-back attempt; +every projection; sticky reuse must bind the assignment to the selected target; a sibling automatic +port allocation fixture must use unique service ports through the public managed start action without +reusing sibling-owned ports; concurrent creation must bind its action target, contender count, +result cardinality, and single-publication outcome to the declared race; persisted-runtime preflight +failures must identify a stopped stack; a successful bootstrap retry must follow an explicit failed +attempt that was rolled back; credential create, update, and copy operations must prove that global state contains references rather than plaintext; data-preserving prune must begin with mutable data; tracked identity markers must remain untouched; native qualification facts must partition the service matrix, use a declared diff --git a/packages/stack/src/managed-stack-contract.integration.test.ts b/packages/stack/src/managed-stack-contract.integration.test.ts index 71b0f82de8..f58ee9f5d4 100644 --- a/packages/stack/src/managed-stack-contract.integration.test.ts +++ b/packages/stack/src/managed-stack-contract.integration.test.ts @@ -105,6 +105,31 @@ describe("managed stack acceptance contract", () => { `${independentBranchesScenario.id}: selected stack stack-feat-default is named default, not review`, ); + const linkedWorktreeScenario: ManagedStackContractScenario | undefined = + managedStackContractFixtures.find( + ({ id }) => id === "identity.linked-worktrees-share-project-not-checkout", + ); + if ( + linkedWorktreeScenario?.expected.selection === undefined || + linkedWorktreeScenario.expected.output.api === undefined + ) { + throw new Error("identity.linked-worktrees-share-project-not-checkout fixture is required"); + } + const siblingCheckoutSelection = { + ...linkedWorktreeScenario, + expected: { + ...linkedWorktreeScenario.expected, + selection: { ...linkedWorktreeScenario.expected.selection, checkoutId: "checkout-a" }, + output: { + ...linkedWorktreeScenario.expected.output, + api: { ...linkedWorktreeScenario.expected.output.api, checkoutId: "checkout-a" }, + }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([siblingCheckoutSelection])).toContain( + `${linkedWorktreeScenario.id}: selection must use checkout checkout-b for worktree-b`, + ); + const undeclaredWrite = { ...scenario, expected: { @@ -187,9 +212,10 @@ describe("managed stack acceptance contract", () => { `${trackedMarkerScenario.id}: a tracked identity marker must remain untouched`, ); - const gitWorkspaceScenario = managedStackContractFixtures.find( - ({ id }) => id === "identity.fresh-clone-creates-project-and-checkout", - ); + const gitWorkspaceScenario: ManagedStackContractScenario | undefined = + managedStackContractFixtures.find( + ({ id }) => id === "identity.fresh-clone-creates-project-and-checkout", + ); if (gitWorkspaceScenario === undefined) { throw new Error("identity.fresh-clone-creates-project-and-checkout fixture is required"); } @@ -204,6 +230,27 @@ describe("managed stack acceptance contract", () => { `${gitWorkspaceScenario.id}: Git workspace identity must use Git-local metadata`, ); + if ( + gitWorkspaceScenario.expected.selection === undefined || + gitWorkspaceScenario.expected.output.json === undefined + ) { + throw new Error("identity.fresh-clone-creates-project-and-checkout selection is required"); + } + const selectionUsingAbsentProject = { + ...gitWorkspaceScenario, + expected: { + ...gitWorkspaceScenario.expected, + selection: { ...gitWorkspaceScenario.expected.selection, projectId: "project-a" }, + output: { + ...gitWorkspaceScenario.expected.output, + json: { ...gitWorkspaceScenario.expected.output.json, project_id: "project-a" }, + }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([selectionUsingAbsentProject])).toContain( + `${gitWorkspaceScenario.id}: selection references undeclared ID project-a`, + ); + const absentLegacyScenario = managedStackContractFixtures.find( ({ id }) => id === "bootstrap.absent-legacy-starts-fresh", ); @@ -606,6 +653,23 @@ describe("managed stack acceptance contract", () => { `${siblingAllocationScenario.id}: allocated port 55421 conflicts with a sibling target`, ); + const duplicateSiblingAllocation = { + ...siblingAllocationScenario, + expected: { + ...siblingAllocationScenario.expected, + output: { + ...siblingAllocationScenario.expected.output, + api: { + ...siblingAllocationScenario.expected.output.api, + ports: { api: 55424, db: 55424 }, + }, + }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([duplicateSiblingAllocation])).toContain( + `${siblingAllocationScenario.id}: allocated port 55424 is assigned more than once`, + ); + const stickyPortScenario: ManagedStackContractScenario | undefined = managedStackContractFixtures.find( ({ id }) => id === "ports.later-sticky-port-collision-fails", @@ -1132,7 +1196,7 @@ describe("managed stack acceptance contract", () => { given: runtimeCreationScenario.given.filter((fact) => fact.kind !== "legacy-state"), } satisfies ManagedStackContractScenario; expect(validateManagedStackContractFixtures([runtimeCreationWithoutLegacyState])).toContain( - `${runtimeCreationScenario.id}: managed creation must declare legacy state absent`, + `${runtimeCreationScenario.id}: managed creation must declare legacy state absent or incompatible`, ); const credentialCreationScenario = managedStackContractFixtures.find( @@ -1154,7 +1218,92 @@ describe("managed stack acceptance contract", () => { given: credentialCreationScenario.given.filter((fact) => fact.kind !== "legacy-state"), } satisfies ManagedStackContractScenario; expect(validateManagedStackContractFixtures([credentialCreationWithoutLegacyState])).toContain( - `${credentialCreationScenario.id}: managed creation must declare legacy state absent`, + `${credentialCreationScenario.id}: managed creation must declare legacy state absent or incompatible`, + ); + + const portCreationScenario = managedStackContractFixtures.find( + ({ id }) => id === "ports.new-target-allocates-and-persists-omitted-ports", + ); + if (portCreationScenario === undefined) { + throw new Error("ports.new-target-allocates-and-persists-omitted-ports fixture is required"); + } + const portCreationWithoutLegacyState = { + ...portCreationScenario, + given: portCreationScenario.given.filter((fact) => fact.kind !== "legacy-state"), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([portCreationWithoutLegacyState])).toContain( + `${portCreationScenario.id}: managed creation must declare legacy state absent or incompatible`, + ); + const portCreationWithCopyableLegacyState = { + ...portCreationScenario, + given: portCreationScenario.given.map((fact) => + fact.kind === "legacy-state" + ? { + ...fact, + lifecycle: "stopped", + database: "compatible", + storage: "compatible", + credentials: "compatible", + } + : fact, + ), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([portCreationWithCopyableLegacyState])).toContain( + `${portCreationScenario.id}: managed creation must declare legacy state absent or incompatible`, + ); + + const concurrencyScenario: ManagedStackContractScenario | undefined = + managedStackContractFixtures.find( + ({ id }) => id === "identity.concurrent-create-publishes-once", + ); + if (concurrencyScenario === undefined || concurrencyScenario.when.interface !== "managed-api") { + throw new Error("identity.concurrent-create-publishes-once fixture is required"); + } + const singleContenderAction = { + ...concurrencyScenario, + when: { + ...concurrencyScenario.when, + input: { ...concurrencyScenario.when.input, contenders: 1 }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([singleContenderAction])).toContain( + `${concurrencyScenario.id}: concurrent action contenders must match the declared race of 2`, + ); + + const concurrencyForDifferentTarget = { + ...concurrencyScenario, + when: { + ...concurrencyScenario.when, + input: { ...concurrencyScenario.when.input, stackName: "review" }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([concurrencyForDifferentTarget])).toContain( + `${concurrencyScenario.id}: concurrent action target must match context-feat/default`, + ); + + const incompleteConcurrentResults = { + ...concurrencyScenario, + expected: { + ...concurrencyScenario.expected, + details: { ...concurrencyScenario.expected.details, contender_results: ["create"] }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([incompleteConcurrentResults])).toContain( + `${concurrencyScenario.id}: concurrent details results must cover 2 contenders`, + ); + + const duplicateConcurrentCreation = { + ...concurrencyScenario, + expected: { + ...concurrencyScenario.expected, + details: { + ...concurrencyScenario.expected.details, + contender_results: ["create", "create"], + }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([duplicateConcurrentCreation])).toContain( + `${concurrencyScenario.id}: concurrent race must create once and reuse thereafter`, ); }); diff --git a/packages/stack/src/managed-stack-contract.ts b/packages/stack/src/managed-stack-contract.ts index b4d109d1f5..cea8fe9b66 100644 --- a/packages/stack/src/managed-stack-contract.ts +++ b/packages/stack/src/managed-stack-contract.ts @@ -371,6 +371,13 @@ const managedStackContractJsonEquals = ( return false; }; +const isManagedStartAction = (action: ManagedStackContractAction): boolean => + (action.interface === "cli" && action.argv[0] === "start") || + (action.interface === "managed-api" && + (action.method === "startStack" || + action.method === "startConcurrently" || + action.input.operation === "start")); + export const validateManagedStackContractFixtures = ( fixtures: ReadonlyArray, ): ReadonlyArray => { @@ -669,7 +676,9 @@ export const validateManagedStackContractFixtures = ( } break; case "identity-claim": - declaredIds.add(fact.id); + if (fact.status !== "absent") { + declaredIds.add(fact.id); + } switch (fact.scope) { case "checkout": checkoutIds.add(fact.id); @@ -892,10 +901,7 @@ export const validateManagedStackContractFixtures = ( } } - if ( - (scenario.area === "credentials" || scenario.area === "runtime") && - scenario.expected.outcome === "create" - ) { + if (isManagedStartAction(scenario.when) && scenario.expected.outcome === "create") { const createdStackIds = scenario.expected.writes.flatMap((write) => write.target === "managed-state" && write.operation === "create" ? [write.id] : [], ); @@ -908,18 +914,21 @@ export const validateManagedStackContractFixtures = ( errors.push(`${scenario.id}: managed creation must declare absent target ${stackId}`); } } - if ( - createdStackIds.length > 0 && - !scenario.given.some( - (fact) => - fact.kind === "legacy-state" && - fact.lifecycle === "absent" && - fact.database === "absent" && - fact.storage === "absent" && - fact.credentials === "absent", - ) - ) { - errors.push(`${scenario.id}: managed creation must declare legacy state absent`); + const legacyState = scenario.given.find((fact) => fact.kind === "legacy-state"); + const legacyAllowsFreshCreation = + legacyState?.kind === "legacy-state" && + ((legacyState.lifecycle === "absent" && + legacyState.database === "absent" && + legacyState.storage === "absent" && + legacyState.credentials === "absent") || + (legacyState.lifecycle === "stopped" && + (legacyState.database === "incompatible" || + legacyState.storage === "incompatible" || + legacyState.credentials === "incompatible"))); + if (createdStackIds.length > 0 && !legacyAllowsFreshCreation) { + errors.push( + `${scenario.id}: managed creation must declare legacy state absent or incompatible`, + ); } } @@ -1004,6 +1013,19 @@ export const validateManagedStackContractFixtures = ( } } + const actionCheckout = scenario.given.find( + (fact) => fact.kind === "checkout" && fact.path === actionCwd, + ); + if ( + actionCheckout?.kind === "checkout" && + (selection.projectId !== actionCheckout.projectId || + selection.checkoutId !== actionCheckout.checkoutId) + ) { + errors.push( + `${scenario.id}: selection must use checkout ${actionCheckout.checkoutId} for ${actionCwd}`, + ); + } + if ( scenario.given.some( (fact) => fact.kind === "identity-transition" && fact.operation === "folder-to-git", @@ -1088,6 +1110,81 @@ export const validateManagedStackContractFixtures = ( } } + if (scenario.when.interface === "managed-api" && scenario.when.method === "startConcurrently") { + const concurrencyFacts = scenario.given.filter( + (fact) => fact.kind === "concurrent-operation" && fact.operation === "create-stack", + ); + const concurrencyFact = concurrencyFacts.length === 1 ? concurrencyFacts[0] : undefined; + if (concurrencyFact?.kind !== "concurrent-operation") { + errors.push(`${scenario.id}: concurrent start requires exactly one create-stack fact`); + } else { + const actionContenders = scenario.when.input.contenders; + if ( + typeof actionContenders !== "number" || + !Number.isInteger(actionContenders) || + actionContenders < 2 || + actionContenders !== concurrencyFact.contenders + ) { + errors.push( + `${scenario.id}: concurrent action contenders must match the declared race of ${concurrencyFact.contenders}`, + ); + } + + const actionStackName = scenario.when.input.stackName; + const actionTarget = + selection !== undefined && typeof actionStackName === "string" + ? `${selection.contextId}/${actionStackName}` + : undefined; + if (actionTarget !== concurrencyFact.target) { + errors.push( + `${scenario.id}: concurrent action target must match ${concurrencyFact.target}`, + ); + } + + const contenderResults = [ + { + projection: "details", + results: scenario.expected.details?.contender_results, + }, + { + projection: "API", + results: scenario.expected.output.api?.contenderResults, + }, + ]; + for (const { projection, results } of contenderResults) { + if (!Array.isArray(results) || results.length !== concurrencyFact.contenders) { + errors.push( + `${scenario.id}: concurrent ${projection} results must cover ${concurrencyFact.contenders} contenders`, + ); + } + } + const detailResults = scenario.expected.details?.contender_results; + const apiResults = scenario.expected.output.api?.contenderResults; + if ( + Array.isArray(detailResults) && + Array.isArray(apiResults) && + !managedStackContractJsonEquals(detailResults, apiResults) + ) { + errors.push(`${scenario.id}: concurrent result projections must agree`); + } + if ( + Array.isArray(detailResults) && + (detailResults.filter((result) => result === "create").length !== 1 || + detailResults.some((result) => result !== "create" && result !== "reuse")) + ) { + errors.push(`${scenario.id}: concurrent race must create once and reuse thereafter`); + } + if ( + scenario.expected.details?.published_stack_count !== 1 || + scenario.expected.output.api?.publishedStackCount !== 1 || + scenario.expected.details?.alias_count !== 0 || + scenario.expected.output.api?.aliasCount !== 0 + ) { + errors.push(`${scenario.id}: concurrent race must publish one stack without aliases`); + } + } + } + const managedPortOwners = scenario.given.flatMap((fact) => fact.kind === "occupied-port" && fact.owner === "managed-stack" ? [fact] : [], ); @@ -1170,9 +1267,18 @@ export const validateManagedStackContractFixtures = ( errors.push(`${scenario.id}: sibling allocation must project its allocated ports`); } else { const occupiedSiblingPorts = new Set(siblingAssignments.map(({ port }) => port)); + const allocatedPorts = new Set(); for (const port of Object.values(projectedPorts)) { - if (typeof port === "number" && occupiedSiblingPorts.has(port)) { - errors.push(`${scenario.id}: allocated port ${port} conflicts with a sibling target`); + if (typeof port === "number") { + if (allocatedPorts.has(port)) { + errors.push(`${scenario.id}: allocated port ${port} is assigned more than once`); + } + allocatedPorts.add(port); + if (occupiedSiblingPorts.has(port)) { + errors.push( + `${scenario.id}: allocated port ${port} conflicts with a sibling target`, + ); + } } } } @@ -1565,8 +1671,8 @@ const mainCheckoutContextFacts = [ { kind: "branch", name: "main", contextId: "context-main", checkedOut: true }, ] satisfies ReadonlyArray; -const absentMainManagedStateFacts = [ - { kind: "managed-target", stackId: "stack-main-default", exists: false }, +const freshManagedStartFacts = (stackId: string): ReadonlyArray => [ + { kind: "managed-target", stackId, exists: false }, { kind: "legacy-state", lifecycle: "absent", @@ -1574,7 +1680,9 @@ const absentMainManagedStateFacts = [ storage: "absent", credentials: "absent", }, -] satisfies ReadonlyArray; +]; + +const freshMainManagedStartFacts = freshManagedStartFacts("stack-main-default"); const mainDefaultSelection = { projectId: "project-a", @@ -1664,6 +1772,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ title: "First start on a new branch creates an independent context and stack", area: "identity", given: [ + ...freshManagedStartFacts("stack-feat-a-default"), { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, { kind: "branch", name: "main", contextId: "context-main", checkedOut: false }, { @@ -1763,6 +1872,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ title: "Deleting and recreating a branch name creates a new context", area: "identity", given: [ + ...freshManagedStartFacts("stack-new-default"), { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, { kind: "git-state", @@ -1880,6 +1990,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ title: "Replacing a branch ref manually creates a new context and orphans the old one", area: "identity", given: [ + ...freshManagedStartFacts("stack-new-default"), { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, { kind: "identity-transition", @@ -1993,6 +2104,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ title: "First start in a non-Git folder persists an untracked local identity", area: "identity", given: [ + ...freshManagedStartFacts("stack-workspace-default"), { kind: "workspace", mode: "ordinary-folder", @@ -2091,6 +2203,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ title: "Sibling linked worktrees share a project and use independent checkouts", area: "identity", given: [ + ...freshManagedStartFacts("stack-b-main-default"), { kind: "workspace", mode: "linked-worktree", path: "worktree-a" }, { kind: "workspace", mode: "linked-worktree", path: "worktree-b" }, { @@ -2142,6 +2255,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ title: "The same branch forced into two worktrees remains checkout-isolated", area: "identity", given: [ + ...freshManagedStartFacts("stack-b-main-default"), { kind: "checkout", path: "worktree-a", projectId: "project-a", checkoutId: "checkout-a" }, { kind: "checkout", path: "worktree-b", projectId: "project-a", checkoutId: "checkout-b" }, { kind: "branch", name: "main", contextId: "context-a-main", checkedOut: true }, @@ -2187,6 +2301,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ title: "Named stacks are scoped inside the active branch context", area: "identity", given: [ + ...freshManagedStartFacts("stack-feat-review"), { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, { kind: "branch", name: "feat-a", contextId: "context-feat", checkedOut: true }, { @@ -2407,6 +2522,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ title: "A fresh clone receives new project and checkout identities", area: "identity", given: [ + ...freshManagedStartFacts("stack-clone-main-default"), { kind: "workspace", mode: "git", path: "/clone/project-a", clonedFrom: "/work/project-a" }, { kind: "git-state", @@ -2577,6 +2693,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ title: "Concurrent creation publishes one stack without aliases", area: "identity", given: [ + ...freshManagedStartFacts("stack-feat-default"), { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, { kind: "branch", name: "feat-a", contextId: "context-feat", checkedOut: true }, { @@ -2673,6 +2790,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ title: "A copied branch with a known owner gets a new context on first mutation", area: "identity", given: [ + ...freshManagedStartFacts("stack-copy-default"), { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, { kind: "identity-transition", @@ -2705,7 +2823,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ writes: [ { target: "git-config", - operation: "update", + operation: "create", id: "context-copy", scope: "worktree", owner: "feat-copy", @@ -2844,6 +2962,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ title: "A tracked non-Git identity marker is inert in a fresh Git clone", area: "identity", given: [ + ...freshManagedStartFacts("stack-clone-main-default"), { kind: "workspace", mode: "git", path: "/clone/project-a", clonedFrom: "/work/project-a" }, { kind: "git-state", @@ -2974,6 +3093,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ title: "Folder-to-Git conversion without a live claim creates Git-owned identities", area: "identity", given: [ + ...freshManagedStartFacts("stack-git-default"), { kind: "identity-transition", operation: "folder-to-git", @@ -3072,6 +3192,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ title: "Bare-repository worktrees share common project identity without a primary worktree", area: "identity", given: [ + ...freshManagedStartFacts("stack-b-main-default"), { kind: "workspace", mode: "bare-worktree", path: "worktree-a" }, { kind: "workspace", mode: "bare-worktree", path: "worktree-b" }, { @@ -3233,7 +3354,7 @@ const additionalPortContractFixtures = defineManagedStackContractFixtures([ title: "A new target allocates and persists host-wide ports for omitted keys", area: "ports", given: [ - { kind: "managed-target", stackId: "stack-feat-default", exists: false }, + ...freshManagedStartFacts("stack-feat-default"), { kind: "config-port", key: "api.port", intent: "automatic", source: "omitted" }, { kind: "config-port", key: "db.port", intent: "automatic", source: "omitted" }, ], @@ -3269,7 +3390,7 @@ const additionalPortContractFixtures = defineManagedStackContractFixtures([ title: "A new sibling target allocates around existing host-wide port ownership", area: "ports", given: [ - { kind: "managed-target", stackId: "stack-feat-default", exists: false }, + ...freshManagedStartFacts("stack-feat-default"), { kind: "config-port", key: "api.port", intent: "automatic", source: "omitted" }, { kind: "config-port", key: "db.port", intent: "automatic", source: "omitted" }, { @@ -3639,7 +3760,7 @@ const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ title: "An explicit managed-API runtime overrides the default automatic selection", area: "runtime", given: [ - ...absentMainManagedStateFacts, + ...freshMainManagedStartFacts, { kind: "runtime-request", source: "managed-api", runtime: "native" }, { kind: "runtime-request", source: "default", runtime: "auto" }, { kind: "runtime-availability", runtime: "native", available: true }, @@ -3669,7 +3790,7 @@ const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ area: "runtime", given: [ ...mainCheckoutContextFacts, - ...absentMainManagedStateFacts, + ...freshMainManagedStartFacts, { kind: "runtime-request", source: "config", runtime: "native" }, { kind: "runtime-request", source: "default", runtime: "auto" }, { kind: "runtime-availability", runtime: "native", available: true }, @@ -3737,7 +3858,7 @@ const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ title: "Automatic selection prefers usable Docker", area: "runtime", given: [ - ...absentMainManagedStateFacts, + ...freshMainManagedStartFacts, { kind: "runtime-request", source: "default", runtime: "auto" }, { kind: "runtime-availability", runtime: "docker", available: true }, { kind: "runtime-availability", runtime: "native", available: true }, @@ -3765,7 +3886,7 @@ const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ "Automatic selection uses native only when Docker is unusable and the full graph qualifies", area: "runtime", given: [ - ...absentMainManagedStateFacts, + ...freshMainManagedStartFacts, { kind: "runtime-request", source: "default", runtime: "auto" }, { kind: "runtime-availability", @@ -4480,7 +4601,7 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ title: "Configured auth values are authoritative and persist globally only by reference", area: "credentials", given: [ - ...absentMainManagedStateFacts, + ...freshMainManagedStartFacts, { kind: "credential-state", source: "configured", valuesId: "configured-auth-v1" }, ], when: { @@ -4517,7 +4638,7 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ area: "credentials", given: [ ...mainCheckoutContextFacts, - ...absentMainManagedStateFacts, + ...freshMainManagedStartFacts, { kind: "credential-state", source: "local-default", valuesId: "stable-local-defaults-v1" }, ], when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, From 480a170471b69ba9a2ebe18da151a1086a45f6ed Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 10 Aug 2026 21:28:16 +0200 Subject: [PATCH 18/41] test(stack): bind managed inputs to outcomes --- .../0015-managed-stack-contract-fixtures.md | 13 +- ...managed-stack-contract.integration.test.ts | 153 ++++++++++ packages/stack/src/managed-stack-contract.ts | 270 +++++++++++++++++- 3 files changed, 416 insertions(+), 20 deletions(-) diff --git a/docs/adr/0015-managed-stack-contract-fixtures.md b/docs/adr/0015-managed-stack-contract-fixtures.md index e6faf9110a..189c42e659 100644 --- a/docs/adr/0015-managed-stack-contract-fixtures.md +++ b/docs/adr/0015-managed-stack-contract-fixtures.md @@ -86,10 +86,12 @@ The fixture validator therefore checks more than catalog shape: selected, writte identities must be declared rather than relying on absent claims, a selection must belong to the checkout at the action path, and a selected stack's context and name must match its declared stack fact; explicit CLI and API stack IDs must match every selected, mutated, effected, and projected -stack target; starts of existing stacks must declare a stopped lifecycle, while every fresh managed -start must declare both an absent target and legacy state that is explicitly absent or incompatible; -managed state creation and registry publication must imply each other, as must managed-state -deletion and registry tombstoning; +stack target, and explicit stack names, exact ports, runtime overrides, credential references, and +isolated state roots must agree with their facts and projections; starts of existing stacks must +declare a stopped lifecycle, every managed creation must declare an absent target, every fresh start +must declare legacy state that is explicitly absent or incompatible, and every bootstrap copy must +declare an absent target plus fully compatible stopped legacy state; managed state creation and +registry publication must imply each other, as must managed-state deletion and registry tombstoning; every state write and runtime effect must identify its target; contextual CLI stack results must bind their output to a selected target; Git identity writes must use the correct common or worktree scope, context writes must name the active branch as owner, and adapters cannot recreate an identity @@ -104,7 +106,8 @@ port allocation fixture must use unique service ports through the public managed reusing sibling-owned ports; concurrent creation must bind its action target, contender count, result cardinality, and single-publication outcome to the declared race; persisted-runtime preflight failures must identify a stopped stack; a successful bootstrap retry must follow an explicit failed -attempt that was rolled back; +attempt that was rolled back; native preflight results must agree with the complete qualified and +failed service partitions; credential create, update, and copy operations must prove that global state contains references rather than plaintext; data-preserving prune must begin with mutable data; tracked identity markers must remain untouched; native qualification facts must partition the service matrix, use a declared diff --git a/packages/stack/src/managed-stack-contract.integration.test.ts b/packages/stack/src/managed-stack-contract.integration.test.ts index f58ee9f5d4..ba478ac749 100644 --- a/packages/stack/src/managed-stack-contract.integration.test.ts +++ b/packages/stack/src/managed-stack-contract.integration.test.ts @@ -130,6 +130,24 @@ describe("managed stack acceptance contract", () => { `${linkedWorktreeScenario.id}: selection must use checkout checkout-b for worktree-b`, ); + const namedStackScenario: ManagedStackContractScenario | undefined = + managedStackContractFixtures.find( + ({ id }) => id === "identity.named-stacks-are-context-scoped", + ); + if (namedStackScenario === undefined || namedStackScenario.when.interface !== "cli") { + throw new Error("identity.named-stacks-are-context-scoped fixture is required"); + } + const actionSelectingDefaultStack = { + ...namedStackScenario, + when: { + ...namedStackScenario.when, + argv: namedStackScenario.when.argv.map((arg) => (arg === "review" ? "default" : arg)), + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([actionSelectingDefaultStack])).toContain( + `${namedStackScenario.id}: explicit stack name default disagrees with selected stack review`, + ); + const undeclaredWrite = { ...scenario, expected: { @@ -394,6 +412,33 @@ describe("managed stack acceptance contract", () => { `${qualificationScenario.id}: native qualification uses unknown platform solaris-sparc`, ); + const failedQualificationScenario: ManagedStackContractScenario | undefined = + managedStackContractFixtures.find( + ({ id }) => id === "native-qualification.one-service-failure-disables-platform", + ); + if (failedQualificationScenario === undefined) { + throw new Error( + "native-qualification.one-service-failure-disables-platform fixture is required", + ); + } + const qualificationPartitionsContradictResult = { + ...failedQualificationScenario, + given: failedQualificationScenario.given.map((fact) => + fact.kind === "native-qualification" + ? { + ...fact, + qualifiedServices: managedNativeServiceMatrix.services.map(([service]) => service), + failedServices: [], + } + : fact, + ), + } satisfies ManagedStackContractScenario; + expect( + validateManagedStackContractFixtures([qualificationPartitionsContradictResult]), + ).toContain( + `${failedQualificationScenario.id}: native preflight decision must match its qualification partitions`, + ); + const statusScenario: ManagedStackContractScenario | undefined = managedStackContractFixtures.find( ({ id }) => id === "identity.symlink-alias-reuses-checkout", @@ -670,6 +715,28 @@ describe("managed stack acceptance contract", () => { `${siblingAllocationScenario.id}: allocated port 55424 is assigned more than once`, ); + const exactPortScenario: ManagedStackContractScenario | undefined = + managedStackContractFixtures.find(({ id }) => id === "ports.explicit-free-port-is-used"); + if (exactPortScenario === undefined || exactPortScenario.when.interface !== "managed-api") { + throw new Error("ports.explicit-free-port-is-used fixture is required"); + } + const actionRequestingDifferentExactPort = { + ...exactPortScenario, + when: { + ...exactPortScenario.when, + input: { + ...exactPortScenario.when.input, + portIntents: { "api.port": { intent: "exact", port: 54322 } }, + }, + }, + } satisfies ManagedStackContractScenario; + const exactPortErrors = validateManagedStackContractFixtures([ + actionRequestingDifferentExactPort, + ]); + expect(exactPortErrors).toContain( + `${exactPortScenario.id}: exact port request api.port must match its config fact`, + ); + const stickyPortScenario: ManagedStackContractScenario | undefined = managedStackContractFixtures.find( ({ id }) => id === "ports.later-sticky-port-collision-fails", @@ -1198,6 +1265,19 @@ describe("managed stack acceptance contract", () => { expect(validateManagedStackContractFixtures([runtimeCreationWithoutLegacyState])).toContain( `${runtimeCreationScenario.id}: managed creation must declare legacy state absent or incompatible`, ); + if (runtimeCreationScenario.when.interface !== "managed-api") { + throw new Error("runtime.explicit-api-overrides-auto managed API fixture is required"); + } + const runtimeActionDisagreesWithRequest = { + ...runtimeCreationScenario, + when: { + ...runtimeCreationScenario.when, + input: { ...runtimeCreationScenario.when.input, runtime: "docker" }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([runtimeActionDisagreesWithRequest])).toContain( + `${runtimeCreationScenario.id}: explicit runtime docker must match its managed-api request fact`, + ); const credentialCreationScenario = managedStackContractFixtures.find( ({ id }) => id === "credentials.configured-values-are-authoritative", @@ -1220,6 +1300,23 @@ describe("managed stack acceptance contract", () => { expect(validateManagedStackContractFixtures([credentialCreationWithoutLegacyState])).toContain( `${credentialCreationScenario.id}: managed creation must declare legacy state absent or incompatible`, ); + if (credentialCreationScenario.when.interface !== "managed-api") { + throw new Error( + "credentials.configured-values-are-authoritative managed API fixture is required", + ); + } + const credentialActionUsingDifferentReference = { + ...credentialCreationScenario, + when: { + ...credentialCreationScenario.when, + input: { ...credentialCreationScenario.when.input, auth: "configured-auth-v2" }, + }, + } satisfies ManagedStackContractScenario; + expect( + validateManagedStackContractFixtures([credentialActionUsingDifferentReference]), + ).toContain( + `${credentialCreationScenario.id}: configured credential input configured-auth-v2 must match persisted references`, + ); const portCreationScenario = managedStackContractFixtures.find( ({ id }) => id === "ports.new-target-allocates-and-persists-omitted-ports", @@ -1305,6 +1402,62 @@ describe("managed stack acceptance contract", () => { expect(validateManagedStackContractFixtures([duplicateConcurrentCreation])).toContain( `${concurrencyScenario.id}: concurrent race must create once and reuse thereafter`, ); + + const isolatedStateRootScenario: ManagedStackContractScenario | undefined = + managedStackContractFixtures.find( + ({ id }) => id === "api-boundary.managed-api-accepts-isolated-state-root", + ); + if ( + isolatedStateRootScenario === undefined || + isolatedStateRootScenario.when.interface !== "managed-api" + ) { + throw new Error("api-boundary.managed-api-accepts-isolated-state-root fixture is required"); + } + const actionUsingDifferentStateRoot = { + ...isolatedStateRootScenario, + when: { + ...isolatedStateRootScenario.when, + input: { ...isolatedStateRootScenario.when.input, stateRoot: "/tmp/other-root" }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([actionUsingDifferentStateRoot])).toContain( + `${isolatedStateRootScenario.id}: isolated state root input must match its options and observed boundary`, + ); + + const isolatedResolutionWithoutAbsentTarget = { + ...isolatedStateRootScenario, + given: isolatedStateRootScenario.given.filter((fact) => fact.kind !== "managed-target"), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([isolatedResolutionWithoutAbsentTarget])).toContain( + `${isolatedStateRootScenario.id}: managed creation must declare absent target stack-main-default`, + ); + + const isolatedResolutionWithoutIdentityClaims = { + ...isolatedStateRootScenario, + given: isolatedStateRootScenario.given.filter((fact) => fact.kind !== "identity-claim"), + } satisfies ManagedStackContractScenario; + expect( + validateManagedStackContractFixtures([isolatedResolutionWithoutIdentityClaims]), + ).toContain( + `${isolatedStateRootScenario.id}: creating Git identity project-a requires an absent project claim`, + ); + + const bootstrapCopyScenario: ManagedStackContractScenario | undefined = + managedStackContractFixtures.find( + ({ id }) => id === "bootstrap.first-start-copies-compatible-legacy-state", + ); + if (bootstrapCopyScenario === undefined) { + throw new Error("bootstrap.first-start-copies-compatible-legacy-state fixture is required"); + } + const copyFromRunningLegacyState = { + ...bootstrapCopyScenario, + given: bootstrapCopyScenario.given.map((fact) => + fact.kind === "legacy-state" ? { ...fact, lifecycle: "running" } : fact, + ), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([copyFromRunningLegacyState])).toContain( + `${bootstrapCopyScenario.id}: bootstrap copy requires absent target stack-main-default and compatible stopped legacy state`, + ); }); it("covers the approved identity journeys through public commands and APIs", () => { diff --git a/packages/stack/src/managed-stack-contract.ts b/packages/stack/src/managed-stack-contract.ts index cea8fe9b66..09977b9611 100644 --- a/packages/stack/src/managed-stack-contract.ts +++ b/packages/stack/src/managed-stack-contract.ts @@ -191,6 +191,7 @@ export type ManagedStackContractFact = | { readonly kind: "managed-api-options"; readonly stateRoot: "default" | "isolated"; + readonly stateRootPath?: string; readonly repository: "in-memory" | "injected" | "persistent-adapter"; readonly runtime: "bun" | "node"; }; @@ -493,6 +494,100 @@ export const validateManagedStackContractFixtures = ( } } + const cliStackNameIndex = + scenario.when.interface === "cli" ? scenario.when.argv.indexOf("--stack") : -1; + const explicitActionStackName = + scenario.when.interface === "cli" && cliStackNameIndex >= 0 + ? scenario.when.argv[cliStackNameIndex + 1] + : (scenario.when.interface === "managed-api" || scenario.when.interface === "stack-api") && + typeof scenario.when.input.stackName === "string" + ? scenario.when.input.stackName + : undefined; + if ( + explicitActionStackName !== undefined && + scenario.expected.selection !== undefined && + explicitActionStackName !== scenario.expected.selection.stackName + ) { + errors.push( + `${scenario.id}: explicit stack name ${explicitActionStackName} disagrees with selected stack ${scenario.expected.selection.stackName}`, + ); + } + + const cliRuntimeIndex = + scenario.when.interface === "cli" ? scenario.when.argv.indexOf("--runtime") : -1; + const explicitRuntime = + scenario.when.interface === "cli" && cliRuntimeIndex >= 0 + ? scenario.when.argv[cliRuntimeIndex + 1] + : scenario.when.interface === "managed-api" && + typeof scenario.when.input.runtime === "string" + ? scenario.when.input.runtime + : undefined; + if (explicitRuntime !== undefined) { + const actionRequestSource = scenario.when.interface === "cli" ? "cli" : "managed-api"; + const runtimeRequest = scenario.given.find( + (fact) => + fact.kind === "runtime-request" && + fact.runtime === explicitRuntime && + (explicitRuntime === "auto" || fact.source === actionRequestSource), + ); + if (runtimeRequest?.kind !== "runtime-request") { + errors.push( + `${scenario.id}: explicit runtime ${explicitRuntime} must match its ${actionRequestSource} request fact`, + ); + } + if (explicitRuntime !== "auto") { + for (const projectedRuntime of [ + scenario.expected.details?.resolved_runtime, + output.api?.runtime, + output.json?.runtime, + output.human?.fields.runtime, + ]) { + if (projectedRuntime !== undefined && projectedRuntime !== explicitRuntime) { + errors.push( + `${scenario.id}: resolved runtime must match explicit request ${explicitRuntime}`, + ); + } + } + } + } + + if (scenario.when.interface === "managed-api" && typeof scenario.when.input.auth === "string") { + const authReference = scenario.when.input.auth; + const configuredCredentials = scenario.given.find( + (fact) => fact.kind === "credential-state" && fact.source === "configured", + ); + if ( + configuredCredentials?.kind !== "credential-state" || + configuredCredentials.valuesId !== authReference || + scenario.expected.details?.credential_values_id !== authReference || + scenario.expected.details.global_credentials_reference !== authReference || + output.api?.credentialsValuesId !== authReference + ) { + errors.push( + `${scenario.id}: configured credential input ${authReference} must match persisted references`, + ); + } + } + + if (scenario.when.interface === "managed-api" && scenario.when.method === "resolveStack") { + const stateRoot = scenario.when.input.stateRoot; + const isolatedOptions = scenario.given.find( + (fact) => fact.kind === "managed-api-options" && fact.stateRoot === "isolated", + ); + if (isolatedOptions?.kind === "managed-api-options") { + if ( + typeof stateRoot !== "string" || + isolatedOptions.stateRootPath !== stateRoot || + scenario.expected.details?.state_root !== stateRoot || + scenario.expected.details.default_system_state_mutated !== false + ) { + errors.push( + `${scenario.id}: isolated state root input must match its options and observed boundary`, + ); + } + } + } + if ( scenario.when.interface === "cli" && (scenario.when.argv[0] === "start" || @@ -863,6 +958,36 @@ export const validateManagedStackContractFixtures = ( errors.push(`${scenario.id}: native qualification omits service ${service}`); } } + if (scenario.when.interface === "managed-api" && scenario.when.method === "preflightNative") { + const platformQualified = failed.size === 0 && qualified.size === nativeServices.length; + if ( + scenario.expected.outcome !== (platformQualified ? "report" : "error") || + scenario.expected.details?.qualified !== platformQualified || + scenario.expected.details.qualified_service_count !== qualified.size || + scenario.expected.details.failed_service_count !== failed.size || + scenario.expected.output.api?.qualified !== platformQualified + ) { + errors.push( + `${scenario.id}: native preflight decision must match its qualification partitions`, + ); + } + const projectedServices = scenario.expected.output.api?.services; + if ( + platformQualified && + (!Array.isArray(projectedServices) || + !managedStackContractJsonEquals(projectedServices, fact.qualifiedServices)) + ) { + errors.push(`${scenario.id}: native preflight services must match qualified services`); + } + const projectedFailures = scenario.expected.output.api?.failedServices; + if ( + !platformQualified && + (!Array.isArray(projectedFailures) || + !managedStackContractJsonEquals(projectedFailures, fact.failedServices)) + ) { + errors.push(`${scenario.id}: native preflight failures must match failed services`); + } + } } const writesIdentityMarker = scenario.expected.writes.some( @@ -901,19 +1026,23 @@ export const validateManagedStackContractFixtures = ( } } - if (isManagedStartAction(scenario.when) && scenario.expected.outcome === "create") { - const createdStackIds = scenario.expected.writes.flatMap((write) => - write.target === "managed-state" && write.operation === "create" ? [write.id] : [], - ); - for (const stackId of createdStackIds) { - if ( - !scenario.given.some( - (fact) => fact.kind === "managed-target" && fact.stackId === stackId && !fact.exists, - ) - ) { - errors.push(`${scenario.id}: managed creation must declare absent target ${stackId}`); - } + const createdStackIds = scenario.expected.writes.flatMap((write) => + write.target === "managed-state" && write.operation === "create" ? [write.id] : [], + ); + for (const stackId of createdStackIds) { + if ( + !scenario.given.some( + (fact) => fact.kind === "managed-target" && fact.stackId === stackId && !fact.exists, + ) + ) { + errors.push(`${scenario.id}: managed creation must declare absent target ${stackId}`); } + } + if ( + isManagedStartAction(scenario.when) && + scenario.expected.outcome === "create" && + createdStackIds.length > 0 + ) { const legacyState = scenario.given.find((fact) => fact.kind === "legacy-state"); const legacyAllowsFreshCreation = legacyState?.kind === "legacy-state" && @@ -925,13 +1054,45 @@ export const validateManagedStackContractFixtures = ( (legacyState.database === "incompatible" || legacyState.storage === "incompatible" || legacyState.credentials === "incompatible"))); - if (createdStackIds.length > 0 && !legacyAllowsFreshCreation) { + if (!legacyAllowsFreshCreation) { errors.push( `${scenario.id}: managed creation must declare legacy state absent or incompatible`, ); } } + const copiedStackIds = new Set( + scenario.expected.writes.flatMap((write) => + write.target === "managed-state" && write.operation === "copy" ? [write.id] : [], + ), + ); + for (const effect of scenario.expected.runtimeEffects) { + if (effect.operation === "copy") { + copiedStackIds.add(effect.stackId); + } + } + if (copiedStackIds.size > 0) { + const legacyState = scenario.given.find((fact) => fact.kind === "legacy-state"); + const legacyIsCopyable = + legacyState?.kind === "legacy-state" && + legacyState.lifecycle === "stopped" && + legacyState.database === "compatible" && + legacyState.storage === "compatible" && + legacyState.credentials === "compatible"; + for (const stackId of copiedStackIds) { + if ( + !scenario.given.some( + (fact) => fact.kind === "managed-target" && fact.stackId === stackId && !fact.exists, + ) || + !legacyIsCopyable + ) { + errors.push( + `${scenario.id}: bootstrap copy requires absent target ${stackId} and compatible stopped legacy state`, + ); + } + } + } + if (scenario.expected.error?.code === "persisted_runtime_unavailable") { for (const fact of scenario.given) { if (fact.kind !== "persisted-runtime") { @@ -1026,6 +1187,36 @@ export const validateManagedStackContractFixtures = ( ); } + if (scenario.when.interface === "managed-api" && scenario.when.method === "resolveStack") { + for (const write of scenario.expected.writes) { + if (write.target !== "git-config" || write.operation !== "create") { + continue; + } + const scope = + write.id === selection.projectId + ? "project" + : write.id === selection.checkoutId + ? "checkout" + : write.id === selection.contextId + ? "context" + : undefined; + if ( + scope !== undefined && + !scenario.given.some( + (fact) => + fact.kind === "identity-claim" && + fact.scope === scope && + fact.id === write.id && + fact.status === "absent", + ) + ) { + errors.push( + `${scenario.id}: creating Git identity ${write.id} requires an absent ${scope} claim`, + ); + } + } + } + if ( scenario.given.some( (fact) => fact.kind === "identity-transition" && fact.operation === "folder-to-git", @@ -1285,6 +1476,41 @@ export const validateManagedStackContractFixtures = ( } } + if ( + scenario.when.interface === "managed-api" && + scenario.when.method === "startStack" && + isManagedStackContractRecord(scenario.when.input.portIntents) + ) { + const projectedPorts = scenario.expected.output.api?.ports; + for (const [key, intent] of Object.entries(scenario.when.input.portIntents)) { + if (!isManagedStackContractRecord(intent) || intent.intent !== "exact") { + continue; + } + const requestedPort = intent.port; + const configPort = scenario.given.find( + (fact) => fact.kind === "config-port" && fact.key === key, + ); + if ( + typeof requestedPort !== "number" || + configPort?.kind !== "config-port" || + configPort.intent !== "exact" || + configPort.value !== requestedPort + ) { + errors.push(`${scenario.id}: exact port request ${key} must match its config fact`); + continue; + } + const service = key.endsWith(".port") ? key.slice(0, -".port".length) : key; + if ( + !isManagedStackContractRecord(projectedPorts) || + projectedPorts[service] !== requestedPort + ) { + errors.push( + `${scenario.id}: projected exact port ${key} must match request ${requestedPort}`, + ); + } + } + } + if (scenario.expected.error?.code === "runtime_conflicts_with_persisted_stack") { if (selection === undefined) { errors.push(`${scenario.id}: persisted runtime conflict requires a selected target`); @@ -2217,6 +2443,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ }, { kind: "checkout", path: "worktree-a", projectId: "project-a", checkoutId: "checkout-a" }, { kind: "checkout", path: "worktree-b", projectId: "project-a", checkoutId: "checkout-b" }, + { kind: "identity-claim", scope: "context", id: "context-b-main", status: "absent" }, ], when: { interface: "managed-api", @@ -3206,6 +3433,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ }, { kind: "checkout", path: "worktree-a", projectId: "project-bare", checkoutId: "checkout-a" }, { kind: "checkout", path: "worktree-b", projectId: "project-bare", checkoutId: "checkout-b" }, + { kind: "identity-claim", scope: "context", id: "context-b-main", status: "absent" }, ], when: { interface: "managed-api", @@ -4189,7 +4417,7 @@ const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ outcome: "report", writes: [], runtimeEffects: [], - details: { qualified: true, qualified_service_count: 13 }, + details: { qualified: true, qualified_service_count: 13, failed_service_count: 0 }, output: { api: { platform: "darwin-arm64", qualified: true, services: nativeServiceNames } }, }, }, @@ -4219,7 +4447,13 @@ const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ }, writes: [], runtimeEffects: [], - details: { reduced_graph: false, docker_fallback_per_service: false }, + details: { + qualified: false, + qualified_service_count: 12, + failed_service_count: 1, + reduced_graph: false, + docker_fallback_per_service: false, + }, output: { api: { platform: "linux-amd64", @@ -4235,6 +4469,7 @@ const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ title: "An unsupported native platform fails deterministic preflight", area: "native-qualification", given: [ + { kind: "runtime-request", source: "cli", runtime: "native" }, { kind: "native-qualification", platform: "darwin-x64", @@ -5055,6 +5290,10 @@ const additionalApiBoundaryContractFixtures = defineManagedStackContractFixtures title: "The managed API can run against an isolated caller-provided state root", area: "api-boundary", given: [ + { kind: "managed-target", stackId: "stack-main-default", exists: false }, + { kind: "identity-claim", scope: "project", id: "project-a", status: "absent" }, + { kind: "identity-claim", scope: "checkout", id: "checkout-a", status: "absent" }, + { kind: "identity-claim", scope: "context", id: "context-main", status: "absent" }, { kind: "workspace", mode: "git", path: "checkout-a" }, { kind: "git-state", @@ -5068,6 +5307,7 @@ const additionalApiBoundaryContractFixtures = defineManagedStackContractFixtures { kind: "managed-api-options", stateRoot: "isolated", + stateRootPath: "/tmp/managed-contract", repository: "in-memory", runtime: "bun", }, From e05e8252ae08a4c4ea4c04d258ab5cb0bda1b1b1 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 10 Aug 2026 22:00:26 +0200 Subject: [PATCH 19/41] test(stack): bind public actions to contract results --- .../0015-managed-stack-contract-fixtures.md | 27 +- ...managed-stack-contract.integration.test.ts | 270 +++++++++++ packages/stack/src/managed-stack-contract.ts | 455 +++++++++++++++++- 3 files changed, 722 insertions(+), 30 deletions(-) diff --git a/docs/adr/0015-managed-stack-contract-fixtures.md b/docs/adr/0015-managed-stack-contract-fixtures.md index 189c42e659..07edfddf51 100644 --- a/docs/adr/0015-managed-stack-contract-fixtures.md +++ b/docs/adr/0015-managed-stack-contract-fixtures.md @@ -86,8 +86,9 @@ The fixture validator therefore checks more than catalog shape: selected, writte identities must be declared rather than relying on absent claims, a selection must belong to the checkout at the action path, and a selected stack's context and name must match its declared stack fact; explicit CLI and API stack IDs must match every selected, mutated, effected, and projected -stack target, and explicit stack names, exact ports, runtime overrides, credential references, and -isolated state roots must agree with their facts and projections; starts of existing stacks must +stack target, and requested stack-name sets, exact and automatic ports, runtime overrides, +credential references, injected repositories, and isolated state roots must agree with their facts +and projections; starts of existing stacks must declare a stopped lifecycle, every managed creation must declare an absent target, every fresh start must declare legacy state that is explicitly absent or incompatible, and every bootstrap copy must declare an absent target plus fully compatible stopped legacy state; managed state creation and @@ -97,17 +98,21 @@ bind their output to a selected target; Git identity writes must use the correct scope, context writes must name the active branch as owner, and adapters cannot recreate an identity already declared by a checkout; new Git-derived contexts, manual ref replacement, branch deletion and recreation, detached-commit reuse, and selected linked worktrees must declare the relevant Git -state or transition; ordinary folders must write their full untracked identity marker on creation -and resolve it on reuse; branch deletion must bind the deleted ref to its checkout, Git state, -context, and orphaned stack; managed and sticky port conflicts and persisted-runtime conflicts must +state or transition; selected contexts must agree with the active Git branch or an explicit +checkout-scoped claim; ordinary folders must write their full untracked identity marker to the +action workspace on creation and resolve it on reuse; branch deletion must bind the deleted ref to +its checkout, Git state, context, and orphaned stack; managed and sticky port conflicts and +persisted-runtime conflicts must identify their actual target; managed port ownership requires an owner stack ID that agrees with every projection; sticky reuse must bind the assignment to the selected target; a sibling automatic port allocation fixture must use unique service ports through the public managed start action without reusing sibling-owned ports; concurrent creation must bind its action target, contender count, result cardinality, and single-publication outcome to the declared race; persisted-runtime preflight failures must identify a stopped stack; a successful bootstrap retry must follow an explicit failed -attempt that was rolled back; native preflight results must agree with the complete qualified and -failed service partitions; +attempt that was rolled back; failed-copy rollback requires explicit failure injection; automatic +runtime selection must reuse persisted state or follow Docker-then-qualified-native availability; +native preflight results must agree with the action platform and complete qualified and failed +service partitions; credential create, update, and copy operations must prove that global state contains references rather than plaintext; data-preserving prune must begin with mutable data; tracked identity markers must remain untouched; native qualification facts must partition the service matrix, use a declared @@ -115,11 +120,13 @@ platform, and match the platform passed to preflight; status operations must rem reports; repository adapter matrices must be non-empty, unique, and match their declared repository facts, and portable runtime matrices must satisfy the same rules against runtime facts, while repository adapter and portable runtime projections must reference a declared scenario, match its -identity, and agree on their complete decision; every invalid stack name and every pair of +identity, agree on their complete decision, and publish equality flags derived from that comparison; +every invalid stack name and every pair of mutually exclusive stop selectors must be exercised through a public action; structured JSON projections must always name their outcome and include the matching structured error or warning -code; destructive runtime effects must map to mutable-state deletion and runtime-state deletion must -stop the running target; other runtime effects must agree with permitted state writes; and stable +code; destructive stop deletion requires `--no-backup`; destructive runtime effects must map to +mutable-state deletion and runtime-state deletion must stop the running target; other runtime effects +must agree with permitted state writes; and stable identity plus exact human and JSON recovery fields cannot contradict the managed result. We deliberately do not introduce a parallel test-only identity resolver; it would duplicate product policy before the real managed surface exists and could pass while the production implementation diff --git a/packages/stack/src/managed-stack-contract.integration.test.ts b/packages/stack/src/managed-stack-contract.integration.test.ts index ba478ac749..6942715fe8 100644 --- a/packages/stack/src/managed-stack-contract.integration.test.ts +++ b/packages/stack/src/managed-stack-contract.integration.test.ts @@ -215,6 +215,7 @@ describe("managed stack acceptance contract", () => { operation: "create", id: "marker-project-a", storage: "project-local-untracked", + workspacePath: "checkout-a", projectId: "project-a", checkoutId: "checkout-a", contextId: "context-main", @@ -1460,6 +1461,274 @@ describe("managed stack acceptance contract", () => { ); }); + it("binds public action inputs and state facts to observable results", () => { + const findScenario = (id: string): ManagedStackContractScenario => { + const scenario = managedStackContractFixtures.find((candidate) => candidate.id === id); + if (scenario === undefined) { + throw new Error(`${id} fixture is required`); + } + return scenario; + }; + + const stackNamesScenario = findScenario("identity.valid-stack-names-resolve-deterministically"); + if (stackNamesScenario.when.interface !== "managed-api") { + throw new Error("stack-name resolution must use the managed API"); + } + const missingRequestedStackName = { + ...stackNamesScenario, + when: { + ...stackNamesScenario.when, + input: { ...stackNamesScenario.when.input, stackNames: ["default"] }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([missingRequestedStackName])).toContain( + `${stackNamesScenario.id}: requested stack names must match their fact and projected results`, + ); + + const automaticPortsScenario = findScenario( + "ports.new-target-allocates-and-persists-omitted-ports", + ); + if (automaticPortsScenario.when.interface !== "managed-api") { + throw new Error("automatic port allocation must use the managed API"); + } + const missingAutomaticPortRequest = { + ...automaticPortsScenario, + when: { + ...automaticPortsScenario.when, + input: { + ...automaticPortsScenario.when.input, + portIntents: { "api.port": "automatic" }, + }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([missingAutomaticPortRequest])).toContain( + `${automaticPortsScenario.id}: requested port keys must match their config facts`, + ); + const projectedAutomaticIntentDisagrees = { + ...automaticPortsScenario, + expected: { + ...automaticPortsScenario.expected, + output: { + ...automaticPortsScenario.expected.output, + api: { + ...automaticPortsScenario.expected.output.api, + intents: { api: "automatic", db: "exact" }, + }, + }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([projectedAutomaticIntentDisagrees])).toContain( + `${automaticPortsScenario.id}: automatic port request db.port must match its fact and projected allocation`, + ); + + const autoRuntimeScenario = findScenario("runtime.auto-prefers-docker"); + const unavailablePreferredRuntime = { + ...autoRuntimeScenario, + given: autoRuntimeScenario.given.map((fact) => + fact.kind === "runtime-availability" && fact.runtime === "docker" + ? { ...fact, available: false } + : fact, + ), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([unavailablePreferredRuntime])).toContain( + `${autoRuntimeScenario.id}: automatic runtime must fail when no runtime is usable`, + ); + + const injectedRepositoryScenario = findScenario( + "api-boundary.managed-api-accepts-injected-repository", + ); + if (injectedRepositoryScenario.when.interface !== "managed-api") { + throw new Error("injected repository boundary must use the managed API"); + } + const differentInjectedRepository = { + ...injectedRepositoryScenario, + when: { + ...injectedRepositoryScenario.when, + input: { ...injectedRepositoryScenario.when.input, repository: "other-repository" }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([differentInjectedRepository])).toContain( + `${injectedRepositoryScenario.id}: injected repository and state root must match the observed managed service`, + ); + + const nativePreflightScenario = findScenario( + "native-qualification.all-services-qualify-platform", + ); + if (nativePreflightScenario.when.interface !== "managed-api") { + throw new Error("native preflight must use the managed API"); + } + const preflightWithoutPlatform = { + ...nativePreflightScenario, + when: { ...nativePreflightScenario.when, input: {} }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([preflightWithoutPlatform])).toContain( + `${nativePreflightScenario.id}: native qualification platform must match the preflight action`, + ); + + const persistedRuntimeConflictScenario = findScenario( + "runtime.persisted-runtime-conflict-fails", + ); + const matchingPersistedRuntime = { + ...persistedRuntimeConflictScenario, + given: persistedRuntimeConflictScenario.given.map((fact) => + fact.kind === "persisted-runtime" ? { ...fact, runtime: "native" } : fact, + ), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([matchingPersistedRuntime])).toContain( + `${persistedRuntimeConflictScenario.id}: persisted runtime conflict must bind persisted and requested values`, + ); + + const destructiveStopScenario = findScenario("reclamation.delete-orphan-by-stack-id"); + if (destructiveStopScenario.when.interface !== "cli") { + throw new Error("destructive orphan deletion must use the CLI"); + } + const destructiveStopWithoutFlag = { + ...destructiveStopScenario, + when: { + ...destructiveStopScenario.when, + argv: destructiveStopScenario.when.argv.filter((arg) => arg !== "--no-backup"), + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([destructiveStopWithoutFlag])).toContain( + `${destructiveStopScenario.id}: destructive stop requires --no-backup`, + ); + + const failedCopyScenario = findScenario("bootstrap.failed-copy-rolls-back"); + if (failedCopyScenario.when.interface !== "managed-api") { + throw new Error("failed bootstrap copy must use the managed API"); + } + const rollbackWithoutInjectedFailure = { + ...failedCopyScenario, + when: { + ...failedCopyScenario.when, + input: { ...failedCopyScenario.when.input, injectCopyFailure: false }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([rollbackWithoutInjectedFailure])).toContain( + `${failedCopyScenario.id}: bootstrap rollback requires enabled copy-failure injection`, + ); + + const branchSelectionScenario = findScenario( + "identity.same-commit-different-branches-are-independent", + ); + if (branchSelectionScenario.when.interface !== "managed-api") { + throw new Error("branch selection must use the managed API"); + } + const mismatchedActiveGitBranch = { + ...branchSelectionScenario, + given: branchSelectionScenario.given.map((fact) => + fact.kind === "git-state" ? { ...fact, branch: "main" } : fact, + ), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([mismatchedActiveGitBranch])).toContain( + `${branchSelectionScenario.id}: selected context must match the active Git branch and checked-out branch fact`, + ); + + const ordinaryFolderScenario = findScenario( + "identity.non-git-folder-first-start-persists-identity", + ); + const markerWrittenToDifferentWorkspace = { + ...ordinaryFolderScenario, + expected: { + ...ordinaryFolderScenario.expected, + writes: ordinaryFolderScenario.expected.writes.map((write) => + write.target === "identity-marker" + ? { ...write, workspacePath: "/work/other-project" } + : write, + ), + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([markerWrittenToDifferentWorkspace])).toContain( + `${ordinaryFolderScenario.id}: ordinary-folder creation must persist its identity marker`, + ); + + const repositoryEqualityScenario = findScenario( + "api-boundary.repository-contract-is-storage-agnostic", + ); + const falseRepositoryEqualityFlag = { + ...repositoryEqualityScenario, + expected: { + ...repositoryEqualityScenario.expected, + output: { + ...repositoryEqualityScenario.expected.output, + api: { ...repositoryEqualityScenario.expected.output.api, equal: false }, + }, + }, + } satisfies ManagedStackContractScenario; + const referencedRepositoryScenario = findScenario("identity.return-to-branch-reuses-stack"); + expect( + validateManagedStackContractFixtures([ + falseRepositoryEqualityFlag, + referencedRepositoryScenario, + ]), + ).toContain( + `${repositoryEqualityScenario.id}: repository equality flags must match compared decisions`, + ); + + const portableEqualityScenario = findScenario( + "api-boundary.managed-surface-is-node-and-bun-portable", + ); + const falsePortableEqualityFlag = { + ...portableEqualityScenario, + expected: { + ...portableEqualityScenario.expected, + output: { + ...portableEqualityScenario.expected.output, + api: { ...portableEqualityScenario.expected.output.api, equal: false }, + }, + }, + } satisfies ManagedStackContractScenario; + const referencedPortableScenario = findScenario( + "identity.same-checkout-branch-and-name-reuses-stack", + ); + expect( + validateManagedStackContractFixtures([falsePortableEqualityFlag, referencedPortableScenario]), + ).toContain( + `${portableEqualityScenario.id}: portable equality flags must match compared results`, + ); + + const actionFromUndeclaredWorkspace = { + ...branchSelectionScenario, + when: { + ...branchSelectionScenario.when, + input: { ...branchSelectionScenario.when.input, cwd: "undeclared-workspace" }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([actionFromUndeclaredWorkspace])).toContain( + `${branchSelectionScenario.id}: managed action cwd must match a declared workspace`, + ); + const actionWithUnknownOperation = { + ...branchSelectionScenario, + when: { + ...branchSelectionScenario.when, + input: { ...branchSelectionScenario.when.input, operation: "unknown" }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([actionWithUnknownOperation])).toContain( + `${branchSelectionScenario.id}: resolveStack operation must be start or status`, + ); + + const portResolutionScenario = findScenario( + "ports.exact-default-value-differs-from-omitted-default", + ); + if (portResolutionScenario.when.interface !== "managed-api") { + throw new Error("port-intent resolution must use the managed API"); + } + const changedExplicitConfigPort = { + ...portResolutionScenario, + when: { + ...portResolutionScenario.when, + input: { + ...portResolutionScenario.when.input, + config: { "api.port": 54322 }, + }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([changedExplicitConfigPort])).toContain( + `${portResolutionScenario.id}: resolved port api.port must match its input and fact`, + ); + }); + it("covers the approved identity journeys through public commands and APIs", () => { expect( managedStackContractFixtures @@ -1534,6 +1803,7 @@ describe("managed stack acceptance contract", () => { operation: "create", id: "marker-project-a", storage: "project-local-untracked", + workspacePath: "/work/project-a", projectId: "project-a", checkoutId: "checkout-a", contextId: "context-workspace", diff --git a/packages/stack/src/managed-stack-contract.ts b/packages/stack/src/managed-stack-contract.ts index 09977b9611..7c91d8c388 100644 --- a/packages/stack/src/managed-stack-contract.ts +++ b/packages/stack/src/managed-stack-contract.ts @@ -193,6 +193,7 @@ export type ManagedStackContractFact = readonly stateRoot: "default" | "isolated"; readonly stateRootPath?: string; readonly repository: "in-memory" | "injected" | "persistent-adapter"; + readonly repositoryId?: string; readonly runtime: "bun" | "node"; }; @@ -219,6 +220,7 @@ type ManagedStackContractWrite = readonly operation: "create" | "update"; readonly id: string; readonly storage: "project-local-untracked"; + readonly workspacePath: string; readonly projectId: string; readonly checkoutId: string; readonly contextId: string; @@ -372,6 +374,20 @@ const managedStackContractJsonEquals = ( return false; }; +const managedStackContractStringSetEquals = ( + left: ReadonlyArray, + right: ReadonlyArray, +): boolean => { + const leftSet = new Set(left); + const rightSet = new Set(right); + return ( + leftSet.size === left.length && + rightSet.size === right.length && + leftSet.size === rightSet.size && + [...leftSet].every((value) => rightSet.has(value)) + ); +}; + const isManagedStartAction = (action: ManagedStackContractAction): boolean => (action.interface === "cli" && action.argv[0] === "start") || (action.interface === "managed-api" && @@ -417,6 +433,72 @@ export const validateManagedStackContractFixtures = ( } } + if (scenario.when.interface === "cli") { + const [command, subcommand] = scenario.when.argv; + const valueFlagsByCommand: Readonly>> = { + start: new Set(["--runtime", "--stack"]), + status: new Set(["--output"]), + stop: new Set(["--stack", "--stack-id"]), + }; + const switchFlagsByCommand: Readonly>> = { + start: new Set(["--experimental"]), + status: new Set(["--experimental"]), + stop: new Set(["--all", "--experimental", "--no-backup"]), + }; + let validShape = command === "start" || command === "status" || command === "stop"; + if (command === "stack") { + validShape = subcommand === "prune"; + } + let index = command === "stack" ? 2 : 1; + while (validShape && index < scenario.when.argv.length) { + const argument = scenario.when.argv[index]; + if (argument === undefined) { + validShape = false; + break; + } + if (command === "stack" && argument === "--experimental") { + index += 1; + continue; + } + if (command !== undefined && switchFlagsByCommand[command]?.has(argument)) { + index += 1; + continue; + } + if (command !== undefined && valueFlagsByCommand[command]?.has(argument)) { + const value = scenario.when.argv[index + 1]; + validShape = value !== undefined; + if ( + (argument === "--output" && value !== "json") || + (argument === "--runtime" && value !== "docker" && value !== "native") + ) { + validShape = false; + } + index += 2; + continue; + } + validShape = false; + } + if (!scenario.when.argv.includes("--experimental") || !validShape) { + errors.push(`${scenario.id}: CLI action must use a declared experimental command shape`); + } + } + + if (scenario.when.interface === "git") { + const [command, option, branchName] = scenario.when.argv; + const validShape = + typeof branchName === "string" && + ((command === "switch" && option === "-c") || + (command === "branch" && (option === "-D" || option === "-d"))); + if (!validShape) { + errors.push(`${scenario.id}: Git action must use a declared branch command shape`); + } else if ( + command === "switch" && + scenario.expected.output.human?.summary !== `Switched to a new branch '${branchName}'` + ) { + errors.push(`${scenario.id}: Git switch output must match its requested branch`); + } + } + const isStatusOperation = (scenario.when.interface === "cli" && scenario.when.argv[0] === "status") || ((scenario.when.interface === "managed-api" || scenario.when.interface === "stack-api") && @@ -551,6 +633,91 @@ export const validateManagedStackContractFixtures = ( } } + const runtimeRequests = scenario.given.filter((fact) => fact.kind === "runtime-request"); + const effectiveRuntimeRequest = + runtimeRequests.find((fact) => fact.source === "cli" || fact.source === "managed-api") ?? + runtimeRequests.find((fact) => fact.source === "config") ?? + runtimeRequests.find((fact) => fact.source === "default"); + if ( + isManagedStartAction(scenario.when) && + effectiveRuntimeRequest?.kind === "runtime-request" && + effectiveRuntimeRequest.runtime === "auto" + ) { + const persistedRuntime = scenario.given.find((fact) => fact.kind === "persisted-runtime"); + const dockerAvailability = scenario.given.find( + (fact) => fact.kind === "runtime-availability" && fact.runtime === "docker", + ); + const nativeAvailability = scenario.given.find( + (fact) => fact.kind === "runtime-availability" && fact.runtime === "native", + ); + const nativeQualification = scenario.given.find( + (fact) => fact.kind === "native-qualification", + ); + const nativeQualified = + nativeQualification?.kind === "native-qualification" && + nativeQualification.failedServices.length === 0 && + nativeQualification.qualifiedServices.length === nativeServices.length; + const persistedAvailability = + persistedRuntime?.kind === "persisted-runtime" + ? scenario.given.find( + (fact) => + fact.kind === "runtime-availability" && fact.runtime === persistedRuntime.runtime, + ) + : undefined; + const resolvedRuntime = + persistedRuntime?.kind === "persisted-runtime" && + persistedAvailability?.kind === "runtime-availability" && + !persistedAvailability.available + ? undefined + : persistedRuntime?.kind === "persisted-runtime" + ? persistedRuntime.runtime + : dockerAvailability?.kind === "runtime-availability" && dockerAvailability.available + ? "docker" + : nativeAvailability?.kind === "runtime-availability" && + nativeAvailability.available && + nativeQualified + ? "native" + : undefined; + if ( + persistedRuntime?.kind === "persisted-runtime" && + persistedAvailability?.kind === "runtime-availability" && + !persistedAvailability.available + ) { + if ( + scenario.expected.outcome !== "error" || + scenario.expected.error?.code !== "persisted_runtime_unavailable" || + output.json?.runtime !== persistedRuntime.runtime || + scenario.expected.runtimeEffects.some((effect) => effect.operation === "start") + ) { + errors.push(`${scenario.id}: unavailable persisted runtime must fail without switching`); + } + } else if (resolvedRuntime === undefined) { + if ( + scenario.expected.outcome !== "error" || + scenario.expected.error?.code !== "no_runtime_available" || + scenario.expected.runtimeEffects.some((effect) => effect.operation === "start") + ) { + errors.push(`${scenario.id}: automatic runtime must fail when no runtime is usable`); + } + } else { + const projectedRuntimes = [ + scenario.expected.details?.resolved_runtime, + output.api?.runtime, + output.json?.runtime, + output.human?.fields.runtime, + ].filter((runtime) => runtime !== undefined); + if ( + scenario.expected.outcome === "error" || + projectedRuntimes.length === 0 || + projectedRuntimes.some((runtime) => runtime !== resolvedRuntime) + ) { + errors.push( + `${scenario.id}: automatic runtime must resolve from persisted state or declared availability`, + ); + } + } + } + if (scenario.when.interface === "managed-api" && typeof scenario.when.input.auth === "string") { const authReference = scenario.when.input.auth; const configuredCredentials = scenario.given.find( @@ -588,6 +755,91 @@ export const validateManagedStackContractFixtures = ( } } + if ( + scenario.when.interface === "managed-api" && + scenario.when.method === "createManagedStackService" + ) { + const repositoryId = scenario.when.input.repository; + const stateRootPath = scenario.when.input.stateRoot; + const injectedOptions = scenario.given.find( + (fact) => fact.kind === "managed-api-options" && fact.repository === "injected", + ); + if ( + typeof repositoryId !== "string" || + typeof stateRootPath !== "string" || + injectedOptions?.kind !== "managed-api-options" || + injectedOptions.repositoryId !== repositoryId || + injectedOptions.stateRoot !== "isolated" || + injectedOptions.stateRootPath !== stateRootPath || + !scenario.expected.writes.some( + (write) => + write.target === "ephemeral-state" && + write.operation === "create" && + write.id === repositoryId, + ) || + output.api?.repository !== repositoryId + ) { + errors.push( + `${scenario.id}: injected repository and state root must match the observed managed service`, + ); + } + } + + if (scenario.when.interface === "managed-api" && scenario.when.method === "resolveStackNames") { + const requestedNames = scenario.when.input.stackNames; + const declaredNames = scenario.given.find((fact) => fact.kind === "stack-names"); + const detailKeys = Object.keys(scenario.expected.details ?? {}); + const apiKeys = Object.keys(output.api ?? {}); + if ( + !Array.isArray(requestedNames) || + !requestedNames.every((name) => typeof name === "string") || + declaredNames?.kind !== "stack-names" || + !managedStackContractStringSetEquals(requestedNames, declaredNames.names) || + !managedStackContractStringSetEquals(requestedNames, detailKeys) || + !managedStackContractStringSetEquals(requestedNames, apiKeys) + ) { + errors.push( + `${scenario.id}: requested stack names must match their fact and projected results`, + ); + } + } + + if ( + scenario.when.interface === "managed-api" && + scenario.when.method === "resolvePortIntents" + ) { + const configFacts = scenario.given.filter((fact) => fact.kind === "config-port"); + const projectedKeys = Object.keys(output.api ?? {}); + if ( + !managedStackContractStringSetEquals( + configFacts.map(({ key }) => key), + projectedKeys, + ) + ) { + errors.push(`${scenario.id}: resolved port keys must match their config facts`); + } + for (const fact of configFacts) { + const projection = output.api?.[fact.key]; + const effectiveConfig = isManagedStackContractRecord(scenario.when.input.effectiveConfig) + ? scenario.when.input.effectiveConfig + : undefined; + const localConfig = isManagedStackContractRecord(scenario.when.input.config) + ? scenario.when.input.config + : undefined; + const actionValue = effectiveConfig?.[fact.key] ?? localConfig?.[fact.key]; + if ( + !isManagedStackContractRecord(projection) || + projection.intent !== fact.intent || + projection.source !== fact.source || + (fact.intent === "exact" && + (actionValue !== fact.value || projection.port !== fact.value)) || + (fact.intent === "automatic" && localConfig?.[fact.key] !== undefined) + ) { + errors.push(`${scenario.id}: resolved port ${fact.key} must match its input and fact`); + } + } + } + if ( scenario.when.interface === "cli" && (scenario.when.argv[0] === "start" || @@ -634,12 +886,14 @@ export const validateManagedStackContractFixtures = ( } let firstRuntimeResult: Readonly> | undefined; + let runtimeResultsEqual = true; for (const runtime of runtimes) { const runtimeResult = output.api?.[runtime]; if ( !isManagedStackContractRecord(runtimeResult) || runtimeResult.outcome !== referencedScenario.expected.outcome ) { + runtimeResultsEqual = false; errors.push( `${scenario.id}: portable ${runtime} outcome must match ${referencedScenario.id}`, ); @@ -656,9 +910,16 @@ export const validateManagedStackContractFixtures = ( if (firstRuntimeResult === undefined) { firstRuntimeResult = runtimeResult; } else if (!managedStackContractJsonEquals(firstRuntimeResult, runtimeResult)) { + runtimeResultsEqual = false; errors.push(`${scenario.id}: portable runtime decisions must be identical`); } } + if ( + scenario.expected.details?.results_equal !== runtimeResultsEqual || + output.api?.equal !== runtimeResultsEqual + ) { + errors.push(`${scenario.id}: portable equality flags must match compared results`); + } } } } @@ -697,12 +958,14 @@ export const validateManagedStackContractFixtures = ( } let firstAdapterResult: Readonly> | undefined; + let adapterResultsEqual = true; for (const adapter of adapters) { const adapterResult = output.api?.[adapter]; if ( !isManagedStackContractRecord(adapterResult) || adapterResult.outcome !== referencedScenario.expected.outcome ) { + adapterResultsEqual = false; errors.push( `${scenario.id}: repository ${adapter} outcome must match ${referencedScenario.id}`, ); @@ -719,9 +982,16 @@ export const validateManagedStackContractFixtures = ( if (firstAdapterResult === undefined) { firstAdapterResult = adapterResult; } else if (!managedStackContractJsonEquals(firstAdapterResult, adapterResult)) { + adapterResultsEqual = false; errors.push(`${scenario.id}: repository adapter decisions must be identical`); } } + if ( + scenario.expected.details?.decisions_equal !== adapterResultsEqual || + output.api?.equal !== adapterResultsEqual + ) { + errors.push(`${scenario.id}: repository equality flags must match compared decisions`); + } } } } @@ -829,6 +1099,49 @@ export const validateManagedStackContractFixtures = ( : typeof scenario.when.input.cwd === "string" ? scenario.when.input.cwd : undefined; + if ( + scenario.when.interface === "managed-api" && + scenario.when.input.cwd !== undefined && + (actionCwd === undefined || + !scenario.given.some( + (fact) => + (fact.kind === "checkout" && fact.path === actionCwd) || + (fact.kind === "workspace" && + (fact.path === actionCwd || fact.canonicalPath === actionCwd)) || + (fact.kind === "git-state" && fact.workspacePath === actionCwd), + )) + ) { + errors.push(`${scenario.id}: managed action cwd must match a declared workspace`); + } + if (scenario.when.interface === "cli") { + const declaredActionPaths = scenario.given.flatMap((fact) => { + if (fact.kind === "checkout" || fact.kind === "git-state") { + return [fact.kind === "checkout" ? fact.path : fact.workspacePath]; + } + if (fact.kind === "workspace") { + return fact.canonicalPath === undefined ? [fact.path] : [fact.path, fact.canonicalPath]; + } + return []; + }); + if (declaredActionPaths.length > 0 && !declaredActionPaths.includes(scenario.when.cwd)) { + errors.push(`${scenario.id}: CLI action cwd must match a declared workspace`); + } + } + if ( + scenario.when.interface === "managed-api" && + scenario.when.method === "resolveStack" && + scenario.when.input.operation !== undefined && + scenario.when.input.operation !== "start" && + scenario.when.input.operation !== "status" + ) { + errors.push(`${scenario.id}: resolveStack operation must be start or status`); + } + const actionGitState = scenario.given.find( + (fact) => fact.kind === "git-state" && fact.workspacePath === actionCwd, + ); + const checkedOutBranch = scenario.given.find( + (fact) => fact.kind === "branch" && fact.checkedOut, + ); if ( scenario.when.interface === "git" && scenario.when.argv[0] === "branch" && @@ -922,8 +1235,9 @@ export const validateManagedStackContractFixtures = ( if ( scenario.when.interface === "managed-api" && scenario.when.method === "preflightNative" && - typeof scenario.when.input.platform === "string" && - scenario.when.input.platform !== fact.platform + (typeof scenario.when.input.platform !== "string" || + scenario.when.input.platform !== fact.platform || + scenario.expected.output.api?.platform !== fact.platform) ) { errors.push( `${scenario.id}: native qualification platform must match the preflight action`, @@ -1187,6 +1501,36 @@ export const validateManagedStackContractFixtures = ( ); } + if (checkedOutBranch?.kind === "branch") { + const createsSelectedContext = scenario.expected.writes.some( + (write) => + write.target === "git-config" && + write.operation === "create" && + write.id === selection.contextId, + ); + const hasCheckoutScopedContextClaim = scenario.given.some( + (fact) => + fact.kind === "identity-claim" && + fact.scope === "context" && + fact.id === selection.contextId && + fact.status === "exact" && + (fact.owner === checkedOutBranch.name || + fact.owner === `${selection.checkoutId}/${checkedOutBranch.name}`), + ); + if ( + (actionGitState?.kind === "git-state" && + actionGitState.head === "branch" && + actionGitState.branch !== checkedOutBranch.name) || + (selection.contextId !== checkedOutBranch.contextId && + !createsSelectedContext && + !hasCheckoutScopedContextClaim) + ) { + errors.push( + `${scenario.id}: selected context must match the active Git branch and checked-out branch fact`, + ); + } + } + if (scenario.when.interface === "managed-api" && scenario.when.method === "resolveStack") { for (const write of scenario.expected.writes) { if (write.target !== "git-config" || write.operation !== "create") { @@ -1277,6 +1621,7 @@ export const validateManagedStackContractFixtures = ( !scenario.expected.writes.some( (write) => write.target === "identity-marker" && + write.workspacePath === actionCwd && write.projectId === selection.projectId && write.checkoutId === selection.checkoutId && write.contextId === selection.contextId, @@ -1482,14 +1827,42 @@ export const validateManagedStackContractFixtures = ( isManagedStackContractRecord(scenario.when.input.portIntents) ) { const projectedPorts = scenario.expected.output.api?.ports; - for (const [key, intent] of Object.entries(scenario.when.input.portIntents)) { + const projectedIntents = scenario.expected.output.api?.intents; + const requestedEntries = Object.entries(scenario.when.input.portIntents); + const configPorts = scenario.given.filter((fact) => fact.kind === "config-port"); + if ( + !managedStackContractStringSetEquals( + requestedEntries.map(([key]) => key), + configPorts.map(({ key }) => key), + ) + ) { + errors.push(`${scenario.id}: requested port keys must match their config facts`); + } + for (const [key, intent] of requestedEntries) { + const configPort = scenario.given.find( + (fact) => fact.kind === "config-port" && fact.key === key, + ); + const service = key.endsWith(".port") ? key.slice(0, -".port".length) : key; + if (intent === "automatic") { + if ( + configPort?.kind !== "config-port" || + configPort.intent !== "automatic" || + !isManagedStackContractRecord(projectedPorts) || + typeof projectedPorts[service] !== "number" || + !isManagedStackContractRecord(projectedIntents) || + projectedIntents[service] !== "automatic" + ) { + errors.push( + `${scenario.id}: automatic port request ${key} must match its fact and projected allocation`, + ); + } + continue; + } if (!isManagedStackContractRecord(intent) || intent.intent !== "exact") { + errors.push(`${scenario.id}: port request ${key} has an invalid intent`); continue; } const requestedPort = intent.port; - const configPort = scenario.given.find( - (fact) => fact.kind === "config-port" && fact.key === key, - ); if ( typeof requestedPort !== "number" || configPort?.kind !== "config-port" || @@ -1497,10 +1870,7 @@ export const validateManagedStackContractFixtures = ( configPort.value !== requestedPort ) { errors.push(`${scenario.id}: exact port request ${key} must match its config fact`); - continue; - } - const service = key.endsWith(".port") ? key.slice(0, -".port".length) : key; - if ( + } else if ( !isManagedStackContractRecord(projectedPorts) || projectedPorts[service] !== requestedPort ) { @@ -1514,15 +1884,63 @@ export const validateManagedStackContractFixtures = ( if (scenario.expected.error?.code === "runtime_conflicts_with_persisted_stack") { if (selection === undefined) { errors.push(`${scenario.id}: persisted runtime conflict requires a selected target`); - } else if ( - !scenario.given.some( + } else { + const persistedRuntime = scenario.given.find( (fact) => fact.kind === "persisted-runtime" && fact.stackId === selection.stackId, - ) + ); + const requestedRuntime = + explicitRuntime === "docker" || explicitRuntime === "native" + ? explicitRuntime + : runtimeRequests.find( + (fact) => + (fact.source === "cli" || fact.source === "managed-api") && + fact.runtime !== "auto", + )?.runtime; + if (persistedRuntime?.kind !== "persisted-runtime") { + errors.push(`${scenario.id}: persisted runtime must belong to the selected target`); + } else if ( + requestedRuntime === undefined || + persistedRuntime.runtime === requestedRuntime || + output.human?.fields.persistedRuntime !== persistedRuntime.runtime || + output.human?.fields.requestedRuntime !== requestedRuntime || + output.json?.persisted_runtime !== persistedRuntime.runtime || + output.json?.requested_runtime !== requestedRuntime || + output.json?.code !== scenario.expected.error.code + ) { + errors.push( + `${scenario.id}: persisted runtime conflict must bind persisted and requested values`, + ); + } + } + } + + if (scenario.expected.error?.code === "legacy_bootstrap_failed") { + if ( + scenario.when.interface !== "managed-api" || + scenario.when.method !== "startStack" || + scenario.when.input.injectCopyFailure !== true || + !scenario.expected.writes.some( + (write) => write.target === "managed-state" && write.operation === "delete", + ) || + !scenario.expected.runtimeEffects.some((effect) => effect.operation === "delete") ) { - errors.push(`${scenario.id}: persisted runtime must belong to the selected target`); + errors.push(`${scenario.id}: bootstrap rollback requires enabled copy-failure injection`); } } + const destructivelyDeletesManagedState = + scenario.expected.writes.some( + (write) => write.target === "managed-state" && write.operation === "delete", + ) || scenario.expected.runtimeEffects.some((effect) => effect.operation === "delete"); + if ( + destructivelyDeletesManagedState && + scenario.when.interface === "cli" && + scenario.when.argv[0] === "stop" && + !scenario.when.argv.includes("--no-backup") + ) { + errors.push(`${scenario.id}: destructive stop requires --no-backup`); + } + if ( scenario.given.some((fact) => fact.kind === "credential-state") && scenario.expected.writes.some( @@ -1577,12 +1995,6 @@ export const validateManagedStackContractFixtures = ( } } - const actionGitState = scenario.given.find( - (fact) => fact.kind === "git-state" && fact.workspacePath === actionCwd, - ); - const checkedOutBranch = scenario.given.find( - (fact) => fact.kind === "branch" && fact.checkedOut, - ); const activeBranchName = actionGitState?.kind === "git-state" && actionGitState.head === "branch" ? actionGitState.branch @@ -2354,6 +2766,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ operation: "create", id: "marker-project-a", storage: "project-local-untracked", + workspacePath: "/work/project-a", projectId: "project-a", checkoutId: "checkout-a", contextId: "context-workspace", @@ -5262,7 +5675,9 @@ const additionalApiBoundaryContractFixtures = defineManagedStackContractFixtures { kind: "managed-api-options", stateRoot: "isolated", + stateRootPath: "/tmp/managed-contract", repository: "injected", + repositoryId: "test-repository", runtime: "node", }, ], From 1a0ec2d4e0595de473b9bdd9f67c53bae3d1cc60 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 10 Aug 2026 22:19:23 +0200 Subject: [PATCH 20/41] test(stack): enforce lifecycle preconditions --- .../0015-managed-stack-contract-fixtures.md | 16 +- ...managed-stack-contract.integration.test.ts | 171 +++++++++ packages/stack/src/managed-stack-contract.ts | 325 ++++++++++++++++++ 3 files changed, 508 insertions(+), 4 deletions(-) diff --git a/docs/adr/0015-managed-stack-contract-fixtures.md b/docs/adr/0015-managed-stack-contract-fixtures.md index 07edfddf51..c59005ea76 100644 --- a/docs/adr/0015-managed-stack-contract-fixtures.md +++ b/docs/adr/0015-managed-stack-contract-fixtures.md @@ -83,7 +83,8 @@ fixtures. CLI integration coverage begins when a real command boundary exists; a test is not evidence that an unimplemented command already satisfies the behavior. The fixture validator therefore checks more than catalog shape: selected, written, and effected -identities must be declared rather than relying on absent claims, a selection must belong to the +identities must be declared rather than relying on absent claims, every API action must use a +declared method with its required public inputs, a selection must belong to the checkout at the action path, and a selected stack's context and name must match its declared stack fact; explicit CLI and API stack IDs must match every selected, mutated, effected, and projected stack target, and requested stack-name sets, exact and automatic ports, runtime overrides, @@ -93,6 +94,8 @@ declare a stopped lifecycle, every managed creation must declare an absent targe must declare legacy state that is explicitly absent or incompatible, and every bootstrap copy must declare an absent target plus fully compatible stopped legacy state; managed state creation and registry publication must imply each other, as must managed-state deletion and registry tombstoning; +reuse must begin from an existing target, runtime stop effects must begin from a running stack, and +target-existence facts cannot contradict stack facts; every state write and runtime effect must identify its target; contextual CLI stack results must bind their output to a selected target; Git identity writes must use the correct common or worktree scope, context writes must name the active branch as owner, and adapters cannot recreate an identity @@ -104,7 +107,8 @@ action workspace on creation and resolve it on reuse; branch deletion must bind its checkout, Git state, context, and orphaned stack; managed and sticky port conflicts and persisted-runtime conflicts must identify their actual target; managed port ownership requires an owner stack ID that agrees with -every projection; sticky reuse must bind the assignment to the selected target; a sibling automatic +every projection; exact-port conflicts must bind the same configured, occupied, and projected port; +sticky reuse must bind the assignment to the selected target; a sibling automatic port allocation fixture must use unique service ports through the public managed start action without reusing sibling-owned ports; concurrent creation must bind its action target, contender count, result cardinality, and single-publication outcome to the declared race; persisted-runtime preflight @@ -114,7 +118,9 @@ runtime selection must reuse persisted state or follow Docker-then-qualified-nat native preflight results must agree with the action platform and complete qualified and failed service partitions; credential create, update, and copy operations must prove that global state contains references -rather than plaintext; data-preserving prune must begin with mutable data; tracked identity markers +instead of plaintext, and credential changes must bind distinct old and new references; +data-preserving prune must begin with mutable data and delete metadata only for an orphaned record +with matching orphaned stack state; tracked identity markers must remain untouched; native qualification facts must partition the service matrix, use a declared platform, and match the platform passed to preflight; status operations must remain read-only reports; repository adapter matrices must be non-empty, unique, and match their declared repository @@ -126,7 +132,9 @@ mutually exclusive stop selectors must be exercised through a public action; str projections must always name their outcome and include the matching structured error or warning code; destructive stop deletion requires `--no-backup`; destructive runtime effects must map to mutable-state deletion and runtime-state deletion must stop the running target; other runtime effects -must agree with permitted state writes; and stable +must agree with permitted state writes; duplicate checkout and inaccessible-path failures must bind +their exact claims and paths; explicit runtime failures must bind an unavailable requested runtime, +and unsupported-native failures must use the declared unsupported-platform set; and stable identity plus exact human and JSON recovery fields cannot contradict the managed result. We deliberately do not introduce a parallel test-only identity resolver; it would duplicate product policy before the real managed surface exists and could pass while the production implementation diff --git a/packages/stack/src/managed-stack-contract.integration.test.ts b/packages/stack/src/managed-stack-contract.integration.test.ts index 6942715fe8..2d0ef7fecf 100644 --- a/packages/stack/src/managed-stack-contract.integration.test.ts +++ b/packages/stack/src/managed-stack-contract.integration.test.ts @@ -1729,6 +1729,177 @@ describe("managed stack acceptance contract", () => { ); }); + it("binds failure and lifecycle decisions to their declared preconditions", () => { + const findScenario = (id: string): ManagedStackContractScenario => { + const scenario = managedStackContractFixtures.find((candidate) => candidate.id === id); + if (scenario === undefined) { + throw new Error(`${id} fixture is required`); + } + return scenario; + }; + + const managedStartScenario = findScenario("ports.explicit-free-port-is-used"); + if (managedStartScenario.when.interface !== "managed-api") { + throw new Error("explicit managed port start must use the managed API"); + } + const actionUsingWrongManagedMethod = { + ...managedStartScenario, + when: { ...managedStartScenario.when, method: "stopStack" }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([actionUsingWrongManagedMethod])).toContain( + `${managedStartScenario.id}: managed action must use a declared public method`, + ); + + const runtimeConflictScenario = findScenario("runtime.explicit-and-config-conflict-fails"); + const matchingRuntimeRequests = { + ...runtimeConflictScenario, + given: runtimeConflictScenario.given.map((fact) => + fact.kind === "runtime-request" && fact.source === "config" + ? { ...fact, runtime: "docker" } + : fact, + ), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([matchingRuntimeRequests])).toContain( + `${runtimeConflictScenario.id}: runtime conflict must bind different explicit and configured runtimes`, + ); + + const exactPortConflictScenario = findScenario("ports.explicit-port-conflict-fails"); + const differentConfiguredConflictPort = { + ...exactPortConflictScenario, + given: exactPortConflictScenario.given.map((fact) => + fact.kind === "config-port" ? { ...fact, value: 54322 } : fact, + ), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([differentConfiguredConflictPort])).toContain( + `${exactPortConflictScenario.id}: exact port conflict must bind config, occupancy, and projections`, + ); + + const changedCredentialsScenario = findScenario( + "credentials.explicit-change-applies-after-stop", + ); + const unchangedCredentialUpdate = { + ...changedCredentialsScenario, + given: changedCredentialsScenario.given.map((fact) => + fact.kind === "credential-state" + ? { ...fact, valuesId: fact.previousValuesId ?? fact.valuesId } + : fact, + ), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([unchangedCredentialUpdate])).toContain( + `${changedCredentialsScenario.id}: credential change requires different old and new values`, + ); + + const decodedDefaultsScenario = findScenario( + "ports.exact-default-value-differs-from-omitted-default", + ); + if (decodedDefaultsScenario.when.interface !== "managed-api") { + throw new Error("decoded defaults must use the managed API"); + } + const missingDecodedDefault = { + ...decodedDefaultsScenario, + when: { + ...decodedDefaultsScenario.when, + input: { + ...decodedDefaultsScenario.when.input, + decodedDefaults: { "api.port": 54321 }, + }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([missingDecodedDefault])).toContain( + `${decodedDefaultsScenario.id}: decoded default keys must cover resolved port facts`, + ); + + const existingTargetScenario = findScenario("bootstrap.existing-managed-target-ignores-legacy"); + const absentExistingTarget = { + ...existingTargetScenario, + given: existingTargetScenario.given.map((fact) => + fact.kind === "managed-target" ? { ...fact, exists: false } : fact, + ), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([absentExistingTarget])).toContain( + `${existingTargetScenario.id}: absent managed target stack-main-default contradicts an existing stack`, + ); + + const duplicateClaimScenario = findScenario("identity.copied-checkout-reports-duplicate-claim"); + const exactDuplicateClaim = { + ...duplicateClaimScenario, + given: duplicateClaimScenario.given.map((fact) => + fact.kind === "identity-claim" ? { ...fact, status: "exact" } : fact, + ), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([exactDuplicateClaim])).toContain( + `${duplicateClaimScenario.id}: duplicate checkout error must bind both conflicting live paths`, + ); + + const inaccessiblePathScenario = findScenario("identity.inaccessible-previous-path-fails"); + const missingPreviousPath = { + ...inaccessiblePathScenario, + given: inaccessiblePathScenario.given.map((fact) => + fact.kind === "workspace" ? { ...fact, previousPathAccess: "missing" } : fact, + ), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([missingPreviousPath])).toContain( + `${inaccessiblePathScenario.id}: inaccessible checkout error must bind path access and ambiguous claim`, + ); + + const stopScenario = findScenario("reclamation.default-stop-preserves-data"); + const stopAlreadyStoppedStack = { + ...stopScenario, + given: stopScenario.given.map((fact) => + fact.kind === "stack" ? { ...fact, lifecycle: "stopped" } : fact, + ), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([stopAlreadyStoppedStack])).toContain( + `${stopScenario.id}: stopping stack stack-main-default requires a running lifecycle`, + ); + + const pruneScenario = findScenario("reclamation.prune-removes-metadata-only"); + const pruneActiveRecord = { + ...pruneScenario, + given: pruneScenario.given.map((fact) => + fact.kind === "managed-record" ? { ...fact, status: "active" } : fact, + ), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([pruneActiveRecord])).toContain( + `${pruneScenario.id}: prune may delete only orphaned registry metadata`, + ); + const pruneNonOrphanedStack = { + ...pruneScenario, + given: pruneScenario.given.map((fact) => + fact.kind === "stack" ? { ...fact, orphaned: false } : fact, + ), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([pruneNonOrphanedStack])).toContain( + `${pruneScenario.id}: prune may delete only orphaned registry metadata`, + ); + + const strictRuntimeScenario = findScenario("runtime.explicit-runtime-is-strict"); + const requestedRuntimeIsAvailable = { + ...strictRuntimeScenario, + given: strictRuntimeScenario.given.map((fact) => + fact.kind === "runtime-availability" && fact.runtime === "docker" + ? { ...fact, available: true } + : fact, + ), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([requestedRuntimeIsAvailable])).toContain( + `${strictRuntimeScenario.id}: explicit runtime error must bind an unavailable requested runtime`, + ); + + const unsupportedPlatformScenario = findScenario( + "native-qualification.unsupported-platform-fails-preflight", + ); + const supportedPlatformReportedUnsupported = { + ...unsupportedPlatformScenario, + given: unsupportedPlatformScenario.given.map((fact) => + fact.kind === "native-qualification" ? { ...fact, platform: "darwin-arm64" } : fact, + ), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([supportedPlatformReportedUnsupported])).toContain( + `${unsupportedPlatformScenario.id}: unsupported native error must bind an unsupported platform`, + ); + }); + it("covers the approved identity journeys through public commands and APIs", () => { expect( managedStackContractFixtures diff --git a/packages/stack/src/managed-stack-contract.ts b/packages/stack/src/managed-stack-contract.ts index 7c91d8c388..69c6819b6e 100644 --- a/packages/stack/src/managed-stack-contract.ts +++ b/packages/stack/src/managed-stack-contract.ts @@ -433,6 +433,65 @@ export const validateManagedStackContractFixtures = ( } } + if (scenario.when.interface === "managed-api") { + const managedMethods = new Set([ + "createManagedStackService", + "preflightNative", + "resolvePortIntents", + "resolveStack", + "resolveStackNames", + "runPortableContract", + "runRepositoryContract", + "startConcurrently", + "startStack", + ]); + const { input, method } = scenario.when; + const allowedInputKeys: Readonly>> = { + createManagedStackService: new Set(["repository", "stateRoot"]), + preflightNative: new Set(["platform"]), + resolvePortIntents: new Set(["config", "decodedDefaults", "effectiveConfig"]), + resolveStack: new Set(["cwd", "operation", "stackName", "stateRoot"]), + resolveStackNames: new Set(["cwd", "stackNames"]), + runPortableContract: new Set(["runtimes", "scenarioId"]), + runRepositoryContract: new Set(["adapters", "scenarioId"]), + startConcurrently: new Set(["contenders", "cwd", "stackName"]), + startStack: new Set(["auth", "injectCopyFailure", "portIntents", "runtime", "stackId"]), + }; + const inputMatchesMethod = + (method === "createManagedStackService" && + typeof input.repository === "string" && + typeof input.stateRoot === "string") || + (method === "preflightNative" && typeof input.platform === "string") || + (method === "resolvePortIntents" && + (isManagedStackContractRecord(input.config) || + isManagedStackContractRecord(input.effectiveConfig))) || + (method === "resolveStack" && + typeof input.cwd === "string" && + typeof input.stackName === "string") || + (method === "resolveStackNames" && + typeof input.cwd === "string" && + Array.isArray(input.stackNames)) || + (method === "runPortableContract" && + Array.isArray(input.runtimes) && + typeof input.scenarioId === "string") || + (method === "runRepositoryContract" && + Array.isArray(input.adapters) && + typeof input.scenarioId === "string") || + (method === "startConcurrently" && + typeof input.cwd === "string" && + typeof input.stackName === "string" && + typeof input.contenders === "number") || + (method === "startStack" && typeof input.stackId === "string"); + const inputUsesOnlyDeclaredKeys = Object.keys(input).every((key) => + allowedInputKeys[method]?.has(key), + ); + if (!managedMethods.has(method) || !inputMatchesMethod || !inputUsesOnlyDeclaredKeys) { + errors.push(`${scenario.id}: managed action must use a declared public method`); + } + } else if (scenario.when.interface === "stack-api" && scenario.when.method !== "createStack") { + errors.push(`${scenario.id}: direct stack action must use createStack`); + } + if (scenario.when.interface === "cli") { const [command, subcommand] = scenario.when.argv; const valueFlagsByCommand: Readonly>> = { @@ -818,6 +877,18 @@ export const validateManagedStackContractFixtures = ( ) { errors.push(`${scenario.id}: resolved port keys must match their config facts`); } + if (isManagedStackContractRecord(scenario.when.input.config)) { + const decodedDefaults = scenario.when.input.decodedDefaults; + if ( + !isManagedStackContractRecord(decodedDefaults) || + !managedStackContractStringSetEquals( + configFacts.map(({ key }) => key), + Object.keys(decodedDefaults), + ) + ) { + errors.push(`${scenario.id}: decoded default keys must cover resolved port facts`); + } + } for (const fact of configFacts) { const projection = output.api?.[fact.key]; const effectiveConfig = isManagedStackContractRecord(scenario.when.input.effectiveConfig) @@ -1221,6 +1292,82 @@ export const validateManagedStackContractFixtures = ( errors.push(`${scenario.id}: detached reuse must declare the commit transition`); } + if (scenario.expected.error?.code === "duplicate_checkout_claim") { + const copiedWorkspace = scenario.given.find( + (fact) => fact.kind === "workspace" && fact.path === actionCwd, + ); + const duplicateClaim = scenario.given.find( + (fact) => fact.kind === "identity-claim" && fact.scope === "checkout", + ); + const copyTransition = scenario.given.find( + (fact) => fact.kind === "identity-transition" && fact.operation === "checkout-copy", + ); + const projectedPaths = output.json?.paths; + if ( + copiedWorkspace?.kind !== "workspace" || + copiedWorkspace.copiedFrom === undefined || + duplicateClaim?.kind !== "identity-claim" || + duplicateClaim.status !== "duplicate" || + duplicateClaim.path !== copiedWorkspace.copiedFrom || + copyTransition?.kind !== "identity-transition" || + copyTransition.from !== copiedWorkspace.copiedFrom || + copyTransition.to !== copiedWorkspace.path || + output.json?.checkout_id !== duplicateClaim.id || + !Array.isArray(projectedPaths) || + projectedPaths.length !== 2 || + !projectedPaths.includes(copiedWorkspace.path) || + !projectedPaths.includes(copiedWorkspace.copiedFrom) + ) { + errors.push( + `${scenario.id}: duplicate checkout error must bind both conflicting live paths`, + ); + } + } + + if (scenario.expected.error?.code === "checkout_path_inaccessible") { + const movedWorkspace = scenario.given.find( + (fact) => fact.kind === "workspace" && fact.path === actionCwd, + ); + const ambiguousClaim = scenario.given.find( + (fact) => fact.kind === "identity-claim" && fact.scope === "checkout", + ); + if ( + movedWorkspace?.kind !== "workspace" || + movedWorkspace.previousPathAccess !== "inaccessible" || + movedWorkspace.previousPath === undefined || + ambiguousClaim?.kind !== "identity-claim" || + ambiguousClaim.status !== "ambiguous" || + ambiguousClaim.path !== movedWorkspace.previousPath || + output.human?.fields.previousPath !== movedWorkspace.previousPath || + output.human?.fields.currentPath !== movedWorkspace.path || + output.json?.checkout_id !== ambiguousClaim.id + ) { + errors.push( + `${scenario.id}: inaccessible checkout error must bind path access and ambiguous claim`, + ); + } + } + + if (output.api?.rebound === true) { + const movedWorkspace = scenario.given.find( + (fact) => fact.kind === "workspace" && fact.path === actionCwd, + ); + const exactClaim = scenario.given.find( + (fact) => fact.kind === "identity-claim" && fact.scope === "checkout", + ); + if ( + movedWorkspace?.kind !== "workspace" || + movedWorkspace.previousPathAccess !== "missing" || + movedWorkspace.previousPath === undefined || + exactClaim?.kind !== "identity-claim" || + exactClaim.status !== "exact" || + exactClaim.path !== movedWorkspace.previousPath || + output.api?.checkoutId !== exactClaim.id + ) { + errors.push(`${scenario.id}: automatic checkout rebind requires a missing previous path`); + } + } + for (const fact of scenario.given) { if (fact.kind !== "native-qualification") { continue; @@ -1304,6 +1451,24 @@ export const validateManagedStackContractFixtures = ( } } + if (scenario.expected.error?.code === "native_platform_unsupported") { + const qualification = scenario.given.find((fact) => fact.kind === "native-qualification"); + const projectedPlatforms = output.json?.supported_platforms; + if ( + qualification?.kind !== "native-qualification" || + !managedNativeServiceMatrix.unsupportedPlatforms.includes(qualification.platform) || + output.json?.platform !== qualification.platform || + !Array.isArray(projectedPlatforms) || + projectedPlatforms.length !== managedNativeServiceMatrix.targetPlatforms.length || + !managedNativeServiceMatrix.targetPlatforms.every((platform) => + projectedPlatforms.includes(platform), + ) || + explicitRuntime !== "native" + ) { + errors.push(`${scenario.id}: unsupported native error must bind an unsupported platform`); + } + } + const writesIdentityMarker = scenario.expected.writes.some( (write) => write.target === "identity-marker", ); @@ -1340,6 +1505,18 @@ export const validateManagedStackContractFixtures = ( } } + for (const target of scenario.given) { + if ( + target.kind === "managed-target" && + !target.exists && + scenario.given.some((fact) => fact.kind === "stack" && fact.stackId === target.stackId) + ) { + errors.push( + `${scenario.id}: absent managed target ${target.stackId} contradicts an existing stack`, + ); + } + } + const createdStackIds = scenario.expected.writes.flatMap((write) => write.target === "managed-state" && write.operation === "create" ? [write.id] : [], ); @@ -1407,6 +1584,28 @@ export const validateManagedStackContractFixtures = ( } } + for (const effect of scenario.expected.runtimeEffects) { + if (effect.operation !== "start") { + continue; + } + const createsTarget = scenario.expected.writes.some( + (write) => + write.target === "managed-state" && + (write.operation === "create" || write.operation === "copy") && + write.id === effect.stackId, + ); + const targetExists = scenario.given.some( + (fact) => + (fact.kind === "managed-target" && fact.stackId === effect.stackId && fact.exists) || + (fact.kind === "stack" && fact.stackId === effect.stackId), + ); + if (!createsTarget && !targetExists) { + errors.push( + `${scenario.id}: starting existing stack ${effect.stackId} requires an existing managed target`, + ); + } + } + if (scenario.expected.error?.code === "persisted_runtime_unavailable") { for (const fact of scenario.given) { if (fact.kind !== "persisted-runtime") { @@ -1721,6 +1920,33 @@ export const validateManagedStackContractFixtures = ( } } + if (scenario.expected.error?.code === "exact_port_occupied") { + const configuredPort = scenario.given.find( + (fact) => fact.kind === "config-port" && fact.intent === "exact", + ); + const occupiedPort = + configuredPort?.kind === "config-port" + ? scenario.given.find( + (fact) => fact.kind === "occupied-port" && fact.port === configuredPort.value, + ) + : undefined; + if ( + configuredPort?.kind !== "config-port" || + typeof configuredPort.value !== "number" || + occupiedPort?.kind !== "occupied-port" || + output.human?.fields.port !== String(configuredPort.value) || + output.human?.fields.configKey !== configuredPort.key || + output.human?.fields.owner !== occupiedPort.owner || + output.json?.port !== configuredPort.value || + output.json?.config_key !== configuredPort.key || + output.json?.owner !== occupiedPort.owner + ) { + errors.push( + `${scenario.id}: exact port conflict must bind config, occupancy, and projections`, + ); + } + } + const managedPortOwners = scenario.given.flatMap((fact) => fact.kind === "occupied-port" && fact.owner === "managed-stack" ? [fact] : [], ); @@ -1914,6 +2140,58 @@ export const validateManagedStackContractFixtures = ( } } + if (scenario.expected.error?.code === "runtime_selection_conflict") { + const explicitRequest = runtimeRequests.find( + (fact) => fact.source === "cli" || fact.source === "managed-api", + ); + const configRequest = runtimeRequests.find((fact) => fact.source === "config"); + if ( + explicitRequest?.kind !== "runtime-request" || + configRequest?.kind !== "runtime-request" || + explicitRequest.runtime === "auto" || + configRequest.runtime === "auto" || + explicitRequest.runtime === configRequest.runtime || + output.json?.cli_runtime !== explicitRequest.runtime || + output.json?.config_runtime !== configRequest.runtime || + output.json?.code !== scenario.expected.error.code + ) { + errors.push( + `${scenario.id}: runtime conflict must bind different explicit and configured runtimes`, + ); + } + } + + if ( + scenario.expected.error?.code === "docker_unavailable" || + scenario.expected.error?.code === "native_unavailable" + ) { + const unavailableRuntime = scenario.expected.error.code.startsWith("docker") + ? "docker" + : "native"; + const explicitRequest = runtimeRequests.find( + (fact) => + (fact.source === "cli" || fact.source === "managed-api") && + fact.runtime === unavailableRuntime, + ); + const availability = scenario.given.find( + (fact) => fact.kind === "runtime-availability" && fact.runtime === unavailableRuntime, + ); + if ( + explicitRequest?.kind !== "runtime-request" || + availability?.kind !== "runtime-availability" || + availability.available || + availability.reason === undefined || + output.json?.requested_runtime !== unavailableRuntime || + output.json?.fallback_attempted !== false || + scenario.expected.details?.fallback_attempted !== false || + scenario.expected.runtimeEffects.some((effect) => effect.operation === "start") + ) { + errors.push( + `${scenario.id}: explicit runtime error must bind an unavailable requested runtime`, + ); + } + } + if (scenario.expected.error?.code === "legacy_bootstrap_failed") { if ( scenario.when.interface !== "managed-api" || @@ -1955,6 +2233,27 @@ export const validateManagedStackContractFixtures = ( errors.push(`${scenario.id}: credential persistence must not expose plaintext globally`); } + const changedCredentials = scenario.given.find( + (fact) => fact.kind === "credential-state" && fact.previousValuesId !== undefined, + ); + if ( + changedCredentials?.kind === "credential-state" && + changedCredentials.previousValuesId === changedCredentials.valuesId + ) { + errors.push(`${scenario.id}: credential change requires different old and new values`); + } + if ( + changedCredentials?.kind === "credential-state" && + scenario.expected.outcome === "update" && + (output.json?.previous_credentials_values_id !== changedCredentials.previousValuesId || + output.json?.credentials_values_id !== changedCredentials.valuesId || + !scenario.expected.writes.some( + (write) => write.target === "managed-state" && write.operation === "update", + )) + ) { + errors.push(`${scenario.id}: credential update must bind old and new persisted references`); + } + if (scenario.expected.details?.retry_after_rollback === true) { const retryStackId = scenario.when.interface === "managed-api" && scenario.when.method === "startStack" @@ -1992,6 +2291,18 @@ export const validateManagedStackContractFixtures = ( if (!mutableDataExists) { errors.push(`${scenario.id}: data-preserving prune must declare mutable stack data`); } + const orphanedRecord = scenario.given.some( + (fact) => + fact.kind === "managed-record" && + fact.stackId === write.id && + fact.status === "orphaned", + ); + const orphanedStack = scenario.given.some( + (fact) => fact.kind === "stack" && fact.stackId === write.id && fact.orphaned === true, + ); + if (!orphanedRecord || !orphanedStack) { + errors.push(`${scenario.id}: prune may delete only orphaned registry metadata`); + } } } @@ -2035,6 +2346,20 @@ export const validateManagedStackContractFixtures = ( errors.push(`${scenario.id}: runtime effect references undeclared ID ${effect.stackId}`); } + if ( + effect.operation === "stop" && + !scenario.given.some( + (fact) => + fact.kind === "stack" && + fact.stackId === effect.stackId && + fact.lifecycle === "running", + ) + ) { + errors.push( + `${scenario.id}: stopping stack ${effect.stackId} requires a running lifecycle`, + ); + } + const hasWrite = scenario.expected.writes.some((write) => { if (write.id !== effect.stackId) { return false; From 1d81e8d172aa8c1348527e067a36be68159667ef Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 10 Aug 2026 22:23:59 +0200 Subject: [PATCH 21/41] test(stack): project runtime unavailability reason --- .../src/managed-stack-contract.integration.test.ts | 11 +++++++++++ packages/stack/src/managed-stack-contract.ts | 2 ++ 2 files changed, 13 insertions(+) diff --git a/packages/stack/src/managed-stack-contract.integration.test.ts b/packages/stack/src/managed-stack-contract.integration.test.ts index 2d0ef7fecf..1234fce793 100644 --- a/packages/stack/src/managed-stack-contract.integration.test.ts +++ b/packages/stack/src/managed-stack-contract.integration.test.ts @@ -1885,6 +1885,17 @@ describe("managed stack acceptance contract", () => { expect(validateManagedStackContractFixtures([requestedRuntimeIsAvailable])).toContain( `${strictRuntimeScenario.id}: explicit runtime error must bind an unavailable requested runtime`, ); + const differentUnavailableReason = { + ...strictRuntimeScenario, + given: strictRuntimeScenario.given.map((fact) => + fact.kind === "runtime-availability" && fact.runtime === "docker" + ? { ...fact, reason: "socket unavailable" } + : fact, + ), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([differentUnavailableReason])).toContain( + `${strictRuntimeScenario.id}: explicit runtime error must bind an unavailable requested runtime`, + ); const unsupportedPlatformScenario = findScenario( "native-qualification.unsupported-platform-fails-preflight", diff --git a/packages/stack/src/managed-stack-contract.ts b/packages/stack/src/managed-stack-contract.ts index 69c6819b6e..2e148d1346 100644 --- a/packages/stack/src/managed-stack-contract.ts +++ b/packages/stack/src/managed-stack-contract.ts @@ -2182,6 +2182,7 @@ export const validateManagedStackContractFixtures = ( availability.available || availability.reason === undefined || output.json?.requested_runtime !== unavailableRuntime || + output.json?.reason !== availability.reason || output.json?.fallback_attempted !== false || scenario.expected.details?.fallback_attempted !== false || scenario.expected.runtimeEffects.some((effect) => effect.operation === "start") @@ -4975,6 +4976,7 @@ const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ outcome: "error", code: "docker_unavailable", requested_runtime: "docker", + reason: "daemon unavailable", fallback_attempted: false, recovery: ["Start Docker", "Remove --runtime docker to use automatic selection"], }, From 336cba8d56ea5504e15e3c345bd8a7884110c5f4 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 10 Aug 2026 22:53:34 +0200 Subject: [PATCH 22/41] test(stack): bind lifecycle evidence to outcomes --- .../0015-managed-stack-contract-fixtures.md | 14 +- ...managed-stack-contract.integration.test.ts | 192 +++++++++++ packages/stack/src/managed-stack-contract.ts | 309 +++++++++++++++++- 3 files changed, 506 insertions(+), 9 deletions(-) diff --git a/docs/adr/0015-managed-stack-contract-fixtures.md b/docs/adr/0015-managed-stack-contract-fixtures.md index c59005ea76..156f638714 100644 --- a/docs/adr/0015-managed-stack-contract-fixtures.md +++ b/docs/adr/0015-managed-stack-contract-fixtures.md @@ -96,6 +96,10 @@ declare an absent target plus fully compatible stopped legacy state; managed sta registry publication must imply each other, as must managed-state deletion and registry tombstoning; reuse must begin from an existing target, runtime stop effects must begin from a running stack, and target-existence facts cannot contradict stack facts; +running-source and credential-drift reports must begin from running sources, idempotent deletion +must begin from a tombstone, orphan deletion must target orphaned state, and failed-copy cleanup may +delete only a target proven absent before the attempt; direct-stack root facts must agree with +caller-supplied root inputs and temporary-state behavior; every state write and runtime effect must identify its target; contextual CLI stack results must bind their output to a selected target; Git identity writes must use the correct common or worktree scope, context writes must name the active branch as owner, and adapters cannot recreate an identity @@ -108,7 +112,8 @@ its checkout, Git state, context, and orphaned stack; managed and sticky port co persisted-runtime conflicts must identify their actual target; managed port ownership requires an owner stack ID that agrees with every projection; exact-port conflicts must bind the same configured, occupied, and projected port; -sticky reuse must bind the assignment to the selected target; a sibling automatic +sticky reuse and collision must bind the assignment key and port to the selected target, while an +exact-port change must bind the previous assignment and newly configured value; a sibling automatic port allocation fixture must use unique service ports through the public managed start action without reusing sibling-owned ports; concurrent creation must bind its action target, contender count, result cardinality, and single-publication outcome to the declared race; persisted-runtime preflight @@ -118,13 +123,16 @@ runtime selection must reuse persisted state or follow Docker-then-qualified-nat native preflight results must agree with the action platform and complete qualified and failed service partitions; credential create, update, and copy operations must prove that global state contains references -instead of plaintext, and credential changes must bind distinct old and new references; +instead of plaintext, credential changes must bind distinct old and new references, and copied +legacy credentials must retain their declared reference; data-preserving prune must begin with mutable data and delete metadata only for an orphaned record with matching orphaned stack state; tracked identity markers must remain untouched; native qualification facts must partition the service matrix, use a declared platform, and match the platform passed to preflight; status operations must remain read-only reports; repository adapter matrices must be non-empty, unique, and match their declared repository -facts, and portable runtime matrices must satisfy the same rules against runtime facts, while +facts while holding runtime and state-root options constant, and portable runtime matrices must +satisfy the same rules against runtime facts while holding repository and state-root options +constant, while repository adapter and portable runtime projections must reference a declared scenario, match its identity, agree on their complete decision, and publish equality flags derived from that comparison; every invalid stack name and every pair of diff --git a/packages/stack/src/managed-stack-contract.integration.test.ts b/packages/stack/src/managed-stack-contract.integration.test.ts index 1234fce793..bcc3766eb4 100644 --- a/packages/stack/src/managed-stack-contract.integration.test.ts +++ b/packages/stack/src/managed-stack-contract.integration.test.ts @@ -1911,6 +1911,198 @@ describe("managed stack acceptance contract", () => { ); }); + it("rejects lifecycle, ownership, and comparison results with contradictory evidence", () => { + const findScenario = (id: string): ManagedStackContractScenario => { + const scenario = managedStackContractFixtures.find((candidate) => candidate.id === id); + if (scenario === undefined) { + throw new Error(`${id} fixture is required`); + } + return scenario; + }; + + const runningLegacyScenario = findScenario( + "bootstrap.running-legacy-source-fails-without-mutation", + ); + const stoppedLegacyReportedRunning = { + ...runningLegacyScenario, + given: runningLegacyScenario.given.map((fact) => + fact.kind === "legacy-state" ? { ...fact, lifecycle: "stopped" } : fact, + ), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([stoppedLegacyReportedRunning])).toContain( + `${runningLegacyScenario.id}: running legacy error requires a running source`, + ); + + const directStackScenario = findScenario("api-boundary.direct-create-stack-is-ephemeral"); + const explicitRootsReportedTemporary = { + ...directStackScenario, + given: directStackScenario.given.map((fact) => + fact.kind === "direct-stack-options" ? { ...fact, roots: "explicit" } : fact, + ), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([explicitRootsReportedTemporary])).toContain( + `${directStackScenario.id}: direct stack root inputs must agree with temporary-state behavior`, + ); + + const failedCopyScenario = findScenario("bootstrap.failed-copy-rolls-back"); + const rollbackAgainstExistingTarget = { + ...failedCopyScenario, + given: failedCopyScenario.given.map((fact) => + fact.kind === "managed-target" ? { ...fact, exists: true } : fact, + ), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([rollbackAgainstExistingTarget])).toContain( + `${failedCopyScenario.id}: bootstrap rollback requires failure injection against an absent target`, + ); + + const stickyCollisionScenario = findScenario("ports.later-sticky-port-collision-fails"); + const unrelatedStickyAssignment = { + ...stickyCollisionScenario, + given: stickyCollisionScenario.given.map((fact) => + fact.kind === "port-assignment" ? { ...fact, port: 55422 } : fact, + ), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([unrelatedStickyAssignment])).toContain( + `${stickyCollisionScenario.id}: sticky port conflict must bind assignment, occupancy, and projections`, + ); + + const exactPortChangeScenario = findScenario("ports.config-change-on-stopped-stack-applies"); + const ignoredExactPortChange = { + ...exactPortChangeScenario, + given: exactPortChangeScenario.given.map((fact) => + fact.kind === "config-port" ? { ...fact, value: 55322 } : fact, + ), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([ignoredExactPortChange])).toContain( + `${exactPortChangeScenario.id}: exact port change must bind previous assignment and requested value`, + ); + const automaticIntentReportedAsExactChange = { + ...exactPortChangeScenario, + given: exactPortChangeScenario.given.map((fact) => + fact.kind === "config-port" ? { ...fact, intent: "automatic" } : fact, + ), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([automaticIntentReportedAsExactChange])).toContain( + `${exactPortChangeScenario.id}: exact port change must bind previous assignment and requested value`, + ); + + const explicitRuntimeScenario = findScenario("runtime.explicit-api-overrides-auto"); + const unavailableExplicitRuntimeStarts = { + ...explicitRuntimeScenario, + given: explicitRuntimeScenario.given.map((fact) => + fact.kind === "runtime-availability" && fact.runtime === "native" + ? { ...fact, available: false } + : fact, + ), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([unavailableExplicitRuntimeStarts])).toContain( + `${explicitRuntimeScenario.id}: successful explicit runtime requires matching availability`, + ); + + const orphanDeletionScenario = findScenario("reclamation.delete-orphan-by-stack-id"); + const activeStackReportedOrphaned = { + ...orphanDeletionScenario, + given: orphanDeletionScenario.given.map((fact) => + fact.kind === "stack" ? { ...fact, orphaned: false } : fact, + ), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([activeStackReportedOrphaned])).toContain( + `${orphanDeletionScenario.id}: global orphan deletion requires an orphaned target`, + ); + + const legacyCredentialsScenario = findScenario( + "credentials.compatible-legacy-auth-is-retained", + ); + const differentLegacyCredentialReference = { + ...legacyCredentialsScenario, + given: legacyCredentialsScenario.given.map((fact) => + fact.kind === "credential-state" ? { ...fact, valuesId: "legacy-auth-v2" } : fact, + ), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([differentLegacyCredentialReference])).toContain( + `${legacyCredentialsScenario.id}: copied legacy credentials must bind their persisted reference`, + ); + + const credentialDriftScenario = findScenario("credentials.running-change-reports-drift"); + const stoppedStackReportedRunningCredentialDrift = { + ...credentialDriftScenario, + given: credentialDriftScenario.given.map((fact) => + fact.kind === "stack" ? { ...fact, lifecycle: "stopped" } : fact, + ), + } satisfies ManagedStackContractScenario; + expect( + validateManagedStackContractFixtures([stoppedStackReportedRunningCredentialDrift]), + ).toContain( + `${credentialDriftScenario.id}: credential drift report requires a running selected stack`, + ); + + const idempotentDeletionScenario = findScenario("reclamation.delete-repeat-is-idempotent"); + const activeRecordReportedDeleted = { + ...idempotentDeletionScenario, + given: idempotentDeletionScenario.given.map((fact) => + fact.kind === "managed-record" ? { ...fact, status: "active" } : fact, + ), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([activeRecordReportedDeleted])).toContain( + `${idempotentDeletionScenario.id}: idempotent deletion requires a tombstoned target`, + ); + + const folderReuseScenario = findScenario( + "identity.folder-to-git-exact-claim-preserves-identity", + ); + const ambiguousProjectClaimReused = { + ...folderReuseScenario, + given: folderReuseScenario.given.map((fact) => + fact.kind === "identity-claim" && fact.scope === "project" + ? { ...fact, status: "ambiguous" } + : fact, + ), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([ambiguousProjectClaimReused])).toContain( + `${folderReuseScenario.id}: folder-to-Git reuse requires exact project and checkout claims`, + ); + + const repositoryMatrixScenario = findScenario( + "api-boundary.repository-contract-is-storage-agnostic", + ); + const repositoryMatrixChangesRuntime = { + ...repositoryMatrixScenario, + given: repositoryMatrixScenario.given.map((fact) => + fact.kind === "managed-api-options" && fact.repository === "in-memory" + ? { ...fact, runtime: "bun" } + : fact, + ), + } satisfies ManagedStackContractScenario; + expect( + validateManagedStackContractFixtures([ + repositoryMatrixChangesRuntime, + findScenario("identity.return-to-branch-reuses-stack"), + ]), + ).toContain( + `${repositoryMatrixScenario.id}: repository comparison must hold runtime and state root constant`, + ); + + const portableMatrixScenario = findScenario( + "api-boundary.managed-surface-is-node-and-bun-portable", + ); + const portableMatrixChangesRepository = { + ...portableMatrixScenario, + given: portableMatrixScenario.given.map((fact) => + fact.kind === "managed-api-options" && fact.runtime === "bun" + ? { ...fact, repository: "persistent-adapter" } + : fact, + ), + } satisfies ManagedStackContractScenario; + expect( + validateManagedStackContractFixtures([ + portableMatrixChangesRepository, + findScenario("identity.same-checkout-branch-and-name-reuses-stack"), + ]), + ).toContain( + `${portableMatrixScenario.id}: portable comparison must hold repository and state root constant`, + ); + }); + it("covers the approved identity journeys through public commands and APIs", () => { expect( managedStackContractFixtures diff --git a/packages/stack/src/managed-stack-contract.ts b/packages/stack/src/managed-stack-contract.ts index 2e148d1346..a4ea545e64 100644 --- a/packages/stack/src/managed-stack-contract.ts +++ b/packages/stack/src/managed-stack-contract.ts @@ -580,6 +580,32 @@ export const validateManagedStackContractFixtures = ( if (output.human === undefined && output.json === undefined && output.api === undefined) { errors.push(`${scenario.id}: at least one observable output is required`); } + + if (scenario.when.interface === "stack-api" && scenario.when.method === "createStack") { + const directOptions = scenario.given.filter((fact) => fact.kind === "direct-stack-options"); + const explicitRootKeys = ["cacheRoot", "projectDir", "runtimeRoot", "stackRoot"]; + const directInput = scenario.when.input; + const hasExplicitRoot = explicitRootKeys.some((key) => typeof directInput[key] === "string"); + const rootMode = hasExplicitRoot ? "explicit" : "omitted"; + const hasTemporaryDetails = scenario.expected.details?.state_root === "temporary"; + const hasTemporaryProjection = output.api?.state_root === "temporary"; + const hasEphemeralWrite = scenario.expected.writes.some( + (write) => write.target === "ephemeral-state" && write.operation === "create", + ); + const usesTemporaryState = hasTemporaryDetails && hasTemporaryProjection && hasEphemeralWrite; + const exposesTemporaryState = + hasTemporaryDetails || hasTemporaryProjection || hasEphemeralWrite; + if ( + directOptions.length !== 1 || + directOptions[0]?.roots !== rootMode || + (rootMode === "omitted" && !usesTemporaryState) || + (rootMode === "explicit" && exposesTemporaryState) + ) { + errors.push( + `${scenario.id}: direct stack root inputs must agree with temporary-state behavior`, + ); + } + } for (const write of scenario.expected.writes) { if (write.id.trim().length === 0) { errors.push(`${scenario.id}: ${write.target} write requires a target ID`); @@ -689,6 +715,16 @@ export const validateManagedStackContractFixtures = ( ); } } + if (isManagedStartAction(scenario.when) && scenario.expected.outcome !== "error") { + const availability = scenario.given.find( + (fact) => fact.kind === "runtime-availability" && fact.runtime === explicitRuntime, + ); + if (availability?.kind !== "runtime-availability" || availability.available !== true) { + errors.push( + `${scenario.id}: successful explicit runtime requires matching availability`, + ); + } + } } } @@ -955,6 +991,29 @@ export const validateManagedStackContractFixtures = ( ) { errors.push(`${scenario.id}: portable runtimes must match declared runtime facts`); } + const runtimeOptions = scenario.given.filter( + (fact) => fact.kind === "managed-api-options", + ); + const firstRuntimeOptions = runtimeOptions[0]; + if ( + firstRuntimeOptions === undefined || + runtimeOptions.length !== runtimes.length || + runtimes.some( + (runtime) => + runtimeOptions.filter((options) => options.runtime === runtime).length !== 1, + ) || + runtimeOptions.some( + (options) => + options.repository !== firstRuntimeOptions.repository || + options.repositoryId !== firstRuntimeOptions.repositoryId || + options.stateRoot !== firstRuntimeOptions.stateRoot || + options.stateRootPath !== firstRuntimeOptions.stateRootPath, + ) + ) { + errors.push( + `${scenario.id}: portable comparison must hold repository and state root constant`, + ); + } let firstRuntimeResult: Readonly> | undefined; let runtimeResultsEqual = true; @@ -1027,6 +1086,28 @@ export const validateManagedStackContractFixtures = ( ) { errors.push(`${scenario.id}: repository adapters must match declared repository facts`); } + const repositoryOptions = scenario.given.filter( + (fact) => fact.kind === "managed-api-options", + ); + const firstRepositoryOptions = repositoryOptions[0]; + if ( + firstRepositoryOptions === undefined || + repositoryOptions.length !== adapters.length || + adapters.some( + (adapter) => + repositoryOptions.filter((options) => options.repository === adapter).length !== 1, + ) || + repositoryOptions.some( + (options) => + options.runtime !== firstRepositoryOptions.runtime || + options.stateRoot !== firstRepositoryOptions.stateRoot || + options.stateRootPath !== firstRepositoryOptions.stateRootPath, + ) + ) { + errors.push( + `${scenario.id}: repository comparison must hold runtime and state root constant`, + ); + } let firstAdapterResult: Readonly> | undefined; let adapterResultsEqual = true; @@ -1776,6 +1857,29 @@ export const validateManagedStackContractFixtures = ( ); } } + if ( + scenario.expected.outcome === "reuse" && + (!scenario.given.some( + (fact) => + fact.kind === "identity-claim" && + fact.scope === "project" && + fact.id === selection.projectId && + fact.path === actionCwd && + fact.status === "exact", + ) || + !scenario.given.some( + (fact) => + fact.kind === "identity-claim" && + fact.scope === "checkout" && + fact.id === selection.checkoutId && + fact.path === actionCwd && + fact.status === "exact", + )) + ) { + errors.push( + `${scenario.id}: folder-to-Git reuse requires exact project and checkout claims`, + ); + } } const createsSelectedContext = scenario.expected.writes.some( @@ -1977,12 +2081,28 @@ export const validateManagedStackContractFixtures = ( if (selection === undefined) { errors.push(`${scenario.id}: sticky port conflict requires a selected target`); } else { + const assignment = scenario.given.find( + (fact) => + fact.kind === "port-assignment" && + fact.stackId === selection.stackId && + fact.intent === "automatic", + ); + const occupiedPort = + assignment?.kind === "port-assignment" + ? scenario.given.find( + (fact) => fact.kind === "occupied-port" && fact.port === assignment.port, + ) + : undefined; if ( - !scenario.given.some( - (fact) => fact.kind === "port-assignment" && fact.stackId === selection.stackId, - ) + assignment?.kind !== "port-assignment" || + occupiedPort?.kind !== "occupied-port" || + output.json?.port !== assignment.port || + output.json?.config_key !== assignment.key || + output.json?.relocated !== false ) { - errors.push(`${scenario.id}: sticky port assignment must belong to the selected target`); + errors.push( + `${scenario.id}: sticky port conflict must bind assignment, occupancy, and projections`, + ); } if ( !scenario.given.some( @@ -1997,6 +2117,65 @@ export const validateManagedStackContractFixtures = ( } } + const changedExactPort = scenario.given.find( + (fact) => + fact.kind === "config-port" && + fact.intent === "exact" && + typeof fact.value === "number" && + typeof fact.previousValue === "number", + ); + const expectsExactPortChange = + (scenario.expected.outcome === "update" && + typeof output.json?.previous_port === "number" && + typeof output.json?.port === "number") || + scenario.expected.warning?.code === "running_stack_config_drift"; + if (expectsExactPortChange) { + let exactPortChangeMatches = false; + if ( + changedExactPort?.kind === "config-port" && + typeof changedExactPort.value === "number" && + typeof changedExactPort.previousValue === "number" + ) { + const previousAssignment = scenario.given.find( + (fact) => + fact.kind === "port-assignment" && + fact.stackId === selection?.stackId && + fact.key === changedExactPort.key && + fact.port === changedExactPort.previousValue, + ); + const updateProjectionMatches = + scenario.expected.outcome !== "update" || + (output.json?.previous_port === changedExactPort.previousValue && + output.json?.port === changedExactPort.value && + output.human?.fields.apiUrl === `http://127.0.0.1:${changedExactPort.value}`); + const driftProjectionMatches = + scenario.expected.warning?.code !== "running_stack_config_drift" || + (scenario.given.some( + (fact) => + fact.kind === "stack" && + fact.stackId === selection?.stackId && + fact.lifecycle === "running", + ) && + output.json?.config_key === changedExactPort.key && + output.json?.running_port === changedExactPort.previousValue && + output.json?.requested_port === changedExactPort.value && + output.human?.fields.configKey === changedExactPort.key && + output.human?.fields.runningPort === String(changedExactPort.previousValue) && + output.human?.fields.configuredPort === String(changedExactPort.value)); + exactPortChangeMatches = + previousAssignment?.kind === "port-assignment" && + previousAssignment.intent === "exact" && + changedExactPort.value !== changedExactPort.previousValue && + updateProjectionMatches && + driftProjectionMatches; + } + if (!exactPortChangeMatches) { + errors.push( + `${scenario.id}: exact port change must bind previous assignment and requested value`, + ); + } + } + if (scenario.expected.output.json?.sticky === true) { if (selection === undefined) { errors.push(`${scenario.id}: sticky port reuse requires a selected target`); @@ -2193,18 +2372,53 @@ export const validateManagedStackContractFixtures = ( } } + if (scenario.expected.error?.code === "legacy_source_running") { + const legacySource = scenario.given.find((fact) => fact.kind === "legacy-state"); + if ( + legacySource?.kind !== "legacy-state" || + legacySource.lifecycle !== "running" || + scenario.expected.writes.length > 0 || + scenario.expected.runtimeEffects.length > 0 + ) { + errors.push(`${scenario.id}: running legacy error requires a running source`); + } + } + if (scenario.expected.error?.code === "legacy_bootstrap_failed") { + const rollbackStackId = + scenario.when.interface === "managed-api" && scenario.when.method === "startStack" + ? scenario.when.input.stackId + : undefined; if ( scenario.when.interface !== "managed-api" || scenario.when.method !== "startStack" || scenario.when.input.injectCopyFailure !== true || !scenario.expected.writes.some( - (write) => write.target === "managed-state" && write.operation === "delete", + (write) => + write.target === "managed-state" && + write.operation === "delete" && + write.id === rollbackStackId, ) || - !scenario.expected.runtimeEffects.some((effect) => effect.operation === "delete") + !scenario.expected.runtimeEffects.some( + (effect) => effect.operation === "delete" && effect.stackId === rollbackStackId, + ) ) { errors.push(`${scenario.id}: bootstrap rollback requires enabled copy-failure injection`); } + if ( + typeof rollbackStackId !== "string" || + !scenario.given.some( + (fact) => + fact.kind === "managed-target" && + fact.stackId === rollbackStackId && + fact.exists === false, + ) || + scenario.given.some((fact) => fact.kind === "stack" && fact.stackId === rollbackStackId) + ) { + errors.push( + `${scenario.id}: bootstrap rollback requires failure injection against an absent target`, + ); + } } const destructivelyDeletesManagedState = @@ -2220,6 +2434,49 @@ export const validateManagedStackContractFixtures = ( errors.push(`${scenario.id}: destructive stop requires --no-backup`); } + if (scenario.expected.details?.idempotent === true || output.json?.already_deleted === true) { + if ( + typeof explicitActionStackId !== "string" || + !scenario.given.some( + (fact) => + fact.kind === "managed-record" && + fact.stackId === explicitActionStackId && + fact.status === "tombstoned", + ) || + scenario.expected.outcome !== "no-op" || + scenario.expected.details?.tombstoned !== true || + scenario.expected.details?.idempotent !== true || + output.json?.tombstoned !== true || + scenario.expected.writes.length > 0 || + scenario.expected.runtimeEffects.length > 0 + ) { + errors.push(`${scenario.id}: idempotent deletion requires a tombstoned target`); + } + } + + const globallyTargetedStack = scenario.given.find( + (fact) => fact.kind === "stack" && fact.stackId === explicitActionStackId, + ); + if ( + scenario.when.interface === "cli" && + scenario.when.argv[0] === "stop" && + scenario.when.argv.includes("--stack-id") && + scenario.expected.outcome === "delete" && + globallyTargetedStack?.kind === "stack" && + (globallyTargetedStack.orphaned !== undefined || + output.json?.orphaned !== undefined || + output.human?.fields.orphaned !== undefined) + ) { + if ( + typeof explicitActionStackId !== "string" || + globallyTargetedStack.orphaned !== true || + output.json?.orphaned !== true || + output.human?.fields.orphaned !== "true" + ) { + errors.push(`${scenario.id}: global orphan deletion requires an orphaned target`); + } + } + if ( scenario.given.some((fact) => fact.kind === "credential-state") && scenario.expected.writes.some( @@ -2255,6 +2512,46 @@ export const validateManagedStackContractFixtures = ( errors.push(`${scenario.id}: credential update must bind old and new persisted references`); } + if (scenario.expected.warning?.code === "running_stack_credentials_drift") { + if ( + selection === undefined || + changedCredentials?.kind !== "credential-state" || + !scenario.given.some( + (fact) => + fact.kind === "stack" && + fact.stackId === selection.stackId && + fact.lifecycle === "running", + ) || + scenario.expected.outcome !== "report" || + scenario.expected.writes.length > 0 || + scenario.expected.runtimeEffects.length > 0 || + output.json?.stack_id !== selection.stackId || + output.json?.drift !== true || + output.human?.fields.stackId !== selection.stackId || + output.human?.fields.drift !== "true" + ) { + errors.push(`${scenario.id}: credential drift report requires a running selected stack`); + } + } + + const legacyCredentials = scenario.given.find( + (fact) => fact.kind === "credential-state" && fact.source === "legacy", + ); + const copiesManagedCredentials = scenario.expected.writes.some( + (write) => write.target === "managed-state" && write.operation === "copy", + ); + if ( + copiesManagedCredentials && + (legacyCredentials?.kind === "credential-state" || + typeof scenario.expected.details?.credential_values_id === "string" || + typeof output.api?.credentialsValuesId === "string") && + (legacyCredentials?.kind !== "credential-state" || + scenario.expected.details?.credential_values_id !== legacyCredentials.valuesId || + output.api?.credentialsValuesId !== legacyCredentials.valuesId) + ) { + errors.push(`${scenario.id}: copied legacy credentials must bind their persisted reference`); + } + if (scenario.expected.details?.retry_after_rollback === true) { const retryStackId = scenario.when.interface === "managed-api" && scenario.when.method === "startStack" From 9ffa31b81f7cf12783d8fc0c2b2d36e9837389f5 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 10 Aug 2026 23:17:24 +0200 Subject: [PATCH 23/41] test(stack): bind contract evidence to results --- .../0015-managed-stack-contract-fixtures.md | 39 +- ...managed-stack-contract.integration.test.ts | 245 +++++++++++- packages/stack/src/managed-stack-contract.ts | 355 +++++++++++++++++- 3 files changed, 610 insertions(+), 29 deletions(-) diff --git a/docs/adr/0015-managed-stack-contract-fixtures.md b/docs/adr/0015-managed-stack-contract-fixtures.md index 156f638714..68a078a3c1 100644 --- a/docs/adr/0015-managed-stack-contract-fixtures.md +++ b/docs/adr/0015-managed-stack-contract-fixtures.md @@ -107,29 +107,40 @@ already declared by a checkout; new Git-derived contexts, manual ref replacement and recreation, detached-commit reuse, and selected linked worktrees must declare the relevant Git state or transition; selected contexts must agree with the active Git branch or an explicit checkout-scoped claim; ordinary folders must write their full untracked identity marker to the -action workspace on creation and resolve it on reuse; branch deletion must bind the deleted ref to -its checkout, Git state, context, and orphaned stack; managed and sticky port conflicts and +action workspace on creation and resolve it on reuse, while Git workspaces cannot trust that local +marker; copied-branch evidence must agree with whether the original branch still exists, read-only +unregistered results require an absent checkout claim, and named-stack API entries must agree with +their deterministic context and stack-ID results; branch deletion must bind the deleted ref to its +checkout, Git state, context, and orphaned stack; managed and sticky port conflicts and persisted-runtime conflicts must identify their actual target; managed port ownership requires an owner stack ID that agrees with every projection; exact-port conflicts must bind the same configured, occupied, and projected port; -sticky reuse and collision must bind the assignment key and port to the selected target, while an -exact-port change must bind the previous assignment and newly configured value; a sibling automatic -port allocation fixture must use unique service ports through the public managed start action without -reusing sibling-owned ports; concurrent creation must bind its action target, contender count, -result cardinality, and single-publication outcome to the declared race; persisted-runtime preflight +sticky reuse and collision must bind automatic config intent, assignment key, assignment port, and +the selected target, while an exact-port change must bind the previous assignment and newly +configured value, including the transition from a removed exact key to sticky automatic state; a +sibling automatic port allocation fixture must use unique service ports through the public managed +start action without reusing sibling-owned ports; concurrent creation must bind its action target, +contender count, result cardinality, and single-publication outcome to the declared race; persisted-runtime preflight failures must identify a stopped stack; a successful bootstrap retry must follow an explicit failed -attempt that was rolled back; failed-copy rollback requires explicit failure injection; automatic -runtime selection must reuse persisted state or follow Docker-then-qualified-native availability; +attempt that was rolled back; failed-copy rollback requires explicit failure injection against an +absent target and a compatible stopped legacy source; automatic runtime selection must reuse +persisted state owned by the selected stack or follow Docker-then-qualified-native availability, +and total automatic failure must project both declared unavailability reasons; successful explicit +or configured runtime requests must match availability and every runtime/source projection, while +runtime-drift reports must bind a running stack, its persisted runtime, the distinct configured +runtime, and every service projection; native preflight results must agree with the action platform and complete qualified and failed service partitions; credential create, update, and copy operations must prove that global state contains references -instead of plaintext, credential changes must bind distinct old and new references, and copied -legacy credentials must retain their declared reference; +instead of plaintext, credential changes must bind distinct old and new references, and local, +persisted, and copied-legacy credentials must retain their declared reference and source; data-preserving prune must begin with mutable data and delete metadata only for an orphaned record with matching orphaned stack state; tracked identity markers -must remain untouched; native qualification facts must partition the service matrix, use a declared -platform, and match the platform passed to preflight; status operations must remain read-only -reports; repository adapter matrices must be non-empty, unique, and match their declared repository +must remain untouched; caller-provided state roots must agree with isolated managed options and the +observed no-default-state boundary, and a CLI-projected managed status must begin from an active +record, running stack, and matching persisted runtime; native qualification facts must partition the +service matrix, use a declared platform, and match the platform passed to preflight; status +operations must remain read-only reports; repository adapter matrices must be non-empty, unique, and match their declared repository facts while holding runtime and state-root options constant, and portable runtime matrices must satisfy the same rules against runtime facts while holding repository and state-root options constant, while diff --git a/packages/stack/src/managed-stack-contract.integration.test.ts b/packages/stack/src/managed-stack-contract.integration.test.ts index bcc3766eb4..41dcfba463 100644 --- a/packages/stack/src/managed-stack-contract.integration.test.ts +++ b/packages/stack/src/managed-stack-contract.integration.test.ts @@ -1531,7 +1531,7 @@ describe("managed stack acceptance contract", () => { ), } satisfies ManagedStackContractScenario; expect(validateManagedStackContractFixtures([unavailablePreferredRuntime])).toContain( - `${autoRuntimeScenario.id}: automatic runtime must fail when no runtime is usable`, + `${autoRuntimeScenario.id}: automatic runtime failure must bind both unavailability reasons`, ); const injectedRepositoryScenario = findScenario( @@ -1930,7 +1930,7 @@ describe("managed stack acceptance contract", () => { ), } satisfies ManagedStackContractScenario; expect(validateManagedStackContractFixtures([stoppedLegacyReportedRunning])).toContain( - `${runningLegacyScenario.id}: running legacy error requires a running source`, + `${runningLegacyScenario.id}: running legacy error requires a running source and absent target`, ); const directStackScenario = findScenario("api-boundary.direct-create-stack-is-ephemeral"); @@ -2103,6 +2103,247 @@ describe("managed stack acceptance contract", () => { ); }); + it("rejects observable results whose identity, runtime, port, and credential evidence disagrees", () => { + const findScenario = (id: string): ManagedStackContractScenario => { + const scenario = managedStackContractFixtures.find((candidate) => candidate.id === id); + if (scenario === undefined) { + throw new Error(`${id} fixture is required`); + } + return scenario; + }; + + const copiedBranchScenario = findScenario( + "identity.branch-copy-known-owner-creates-context-on-mutation", + ); + const copiedBranchWithoutOriginal = { + ...copiedBranchScenario, + given: copiedBranchScenario.given.map((fact) => + fact.kind === "identity-transition" && fact.operation === "branch-copy" + ? { ...fact, originalExists: false } + : fact, + ), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([copiedBranchWithoutOriginal])).toContain( + `${copiedBranchScenario.id}: copied branch transition must match live original and checked-out branch facts`, + ); + + const configRuntimeScenario = findScenario("runtime.config-overrides-default-auto"); + const ignoredConfigRuntime = { + ...configRuntimeScenario, + given: configRuntimeScenario.given.map((fact) => + fact.kind === "runtime-request" && fact.source === "config" + ? { ...fact, runtime: "docker" } + : fact, + ), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([ignoredConfigRuntime])).toContain( + `${configRuntimeScenario.id}: effective runtime request must match availability and successful projections`, + ); + + const runtimeDriftScenario = findScenario("runtime.status-reports-one-stack-wide-runtime"); + const inventedRuntimeDrift = { + ...runtimeDriftScenario, + given: runtimeDriftScenario.given.map((fact) => + fact.kind === "persisted-runtime" ? { ...fact, runtime: "native" } : fact, + ), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([inventedRuntimeDrift])).toContain( + `${runtimeDriftScenario.id}: runtime drift must bind running stack, persisted runtime, config, and projections`, + ); + + const failedCopyScenario = findScenario("bootstrap.failed-copy-rolls-back"); + const runningLegacyCopyRolledBack = { + ...failedCopyScenario, + given: failedCopyScenario.given.map((fact) => + fact.kind === "legacy-state" ? { ...fact, lifecycle: "running" } : fact, + ), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([runningLegacyCopyRolledBack])).toContain( + `${failedCopyScenario.id}: bootstrap rollback requires a compatible stopped legacy source`, + ); + + const runningLegacyScenario = findScenario( + "bootstrap.running-legacy-source-fails-without-mutation", + ); + const existingManagedTargetReadsLegacy = { + ...runningLegacyScenario, + given: runningLegacyScenario.given.map((fact) => + fact.kind === "managed-target" ? { ...fact, exists: true } : fact, + ), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([existingManagedTargetReadsLegacy])).toContain( + `${runningLegacyScenario.id}: running legacy error requires a running source and absent target`, + ); + + const runningLegacyPortScenario = findScenario( + "ports.running-legacy-source-fails-before-allocation", + ); + const unrelatedLegacyPortConflict = { + ...runningLegacyPortScenario, + given: runningLegacyPortScenario.given.map((fact) => + fact.kind === "config-port" ? { ...fact, value: 54322 } : fact, + ), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([unrelatedLegacyPortConflict])).toContain( + `${runningLegacyPortScenario.id}: running legacy port failure must bind config, occupancy, and projections`, + ); + + const defaultCredentialsScenario = findScenario( + "credentials.omitted-values-use-stable-defaults", + ); + const unrelatedDefaultCredentials = { + ...defaultCredentialsScenario, + given: defaultCredentialsScenario.given.map((fact) => + fact.kind === "credential-state" ? { ...fact, valuesId: "other-local-defaults" } : fact, + ), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([unrelatedDefaultCredentials])).toContain( + `${defaultCredentialsScenario.id}: persisted credential reference and source must match declared values`, + ); + + const persistedCredentialsScenario = findScenario( + "credentials.unchanged-values-survive-restart", + ); + const unrelatedPersistedCredentials = { + ...persistedCredentialsScenario, + given: persistedCredentialsScenario.given.map((fact) => + fact.kind === "credential-state" ? { ...fact, valuesId: "other-persisted-values" } : fact, + ), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([unrelatedPersistedCredentials])).toContain( + `${persistedCredentialsScenario.id}: persisted credential reference and source must match declared values`, + ); + + const stickyPortScenario = findScenario("ports.sticky-ports-reuse-on-return"); + const exactAssignmentReportedSticky = { + ...stickyPortScenario, + given: stickyPortScenario.given.map((fact) => + fact.kind === "port-assignment" ? { ...fact, intent: "exact" } : fact, + ), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([exactAssignmentReportedSticky])).toContain( + `${stickyPortScenario.id}: sticky port reuse must bind automatic config, assignment, and projections`, + ); + + const removedExactPortScenario = findScenario( + "ports.removing-exact-key-keeps-current-port-sticky", + ); + const unrelatedExactPortReportedSticky = { + ...removedExactPortScenario, + given: removedExactPortScenario.given.map((fact) => + fact.kind === "config-port" ? { ...fact, previousValue: 54322 } : fact, + ), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([unrelatedExactPortReportedSticky])).toContain( + `${removedExactPortScenario.id}: exact-to-automatic port transition must preserve the previous assignment`, + ); + + const unregisteredScenario = findScenario( + "identity.read-only-unregistered-checkout-does-not-write", + ); + const registeredCheckoutReportedAbsent = { + ...unregisteredScenario, + given: unregisteredScenario.given.map((fact) => + fact.kind === "identity-claim" ? { ...fact, status: "exact" } : fact, + ), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([registeredCheckoutReportedAbsent])).toContain( + `${unregisteredScenario.id}: unregistered status requires an absent checkout claim`, + ); + + const markerRecoveryScenario = findScenario( + "identity.non-git-folder-recovers-persisted-identity", + ); + const gitWorkspaceTrustsLocalMarker = { + ...markerRecoveryScenario, + given: markerRecoveryScenario.given.map((fact) => + fact.kind === "workspace" ? { ...fact, mode: "git" } : fact, + ), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([gitWorkspaceTrustsLocalMarker])).toContain( + `${markerRecoveryScenario.id}: local identity marker recovery requires an ordinary folder`, + ); + + const persistedRuntimeScenario = findScenario("runtime.persisted-runtime-reused-for-auto"); + const siblingRuntimeReused = { + ...persistedRuntimeScenario, + given: persistedRuntimeScenario.given.map((fact) => + fact.kind === "persisted-runtime" ? { ...fact, stackId: "stack-other-default" } : fact, + ), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([siblingRuntimeReused])).toContain( + `${persistedRuntimeScenario.id}: automatic runtime must resolve from persisted state or declared availability`, + ); + + const isolatedRootScenario = findScenario( + "api-boundary.managed-api-accepts-isolated-state-root", + ); + const defaultOptionsUseIsolatedRoot = { + ...isolatedRootScenario, + given: isolatedRootScenario.given.map((fact) => + fact.kind === "managed-api-options" ? { ...fact, stateRoot: "default" } : fact, + ), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([defaultOptionsUseIsolatedRoot])).toContain( + `${isolatedRootScenario.id}: isolated state root input must match its options and observed boundary`, + ); + + const automaticRuntimeFailureScenario = findScenario( + "runtime.auto-fails-when-neither-runtime-is-available", + ); + const unrelatedAutomaticFailureReason = { + ...automaticRuntimeFailureScenario, + given: automaticRuntimeFailureScenario.given.map((fact) => + fact.kind === "runtime-availability" && fact.runtime === "docker" + ? { ...fact, reason: "unrelated failure" } + : fact, + ), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([unrelatedAutomaticFailureReason])).toContain( + `${automaticRuntimeFailureScenario.id}: automatic runtime failure must bind both unavailability reasons`, + ); + + const stackNamesScenario = findScenario("identity.valid-stack-names-resolve-deterministically"); + const wrongNamedStackResult = { + ...stackNamesScenario, + expected: { + ...stackNamesScenario.expected, + output: { + ...stackNamesScenario.expected.output, + api: { + ...stackNamesScenario.expected.output.api, + default: { contextId: "context-feat", stackId: "stack-unrelated" }, + }, + }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([wrongNamedStackResult])).toContain( + `${stackNamesScenario.id}: resolved stack name default must bind its context and stack ID`, + ); + + const projectedManagedResultScenario = findScenario( + "api-boundary.cli-projects-shared-managed-results", + ); + const tombstonedRecordReportedRunning = { + ...projectedManagedResultScenario, + given: projectedManagedResultScenario.given.map((fact) => + fact.kind === "managed-record" ? { ...fact, status: "tombstoned" } : fact, + ), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([tombstonedRecordReportedRunning])).toContain( + `${projectedManagedResultScenario.id}: projected managed status requires an active running record and persisted runtime`, + ); + const stoppedStackReportedRunning = { + ...projectedManagedResultScenario, + given: projectedManagedResultScenario.given.map((fact) => + fact.kind === "stack" ? { ...fact, lifecycle: "stopped" } : fact, + ), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([stoppedStackReportedRunning])).toContain( + `${projectedManagedResultScenario.id}: projected managed status requires an active running record and persisted runtime`, + ); + }); + it("covers the approved identity journeys through public commands and APIs", () => { expect( managedStackContractFixtures diff --git a/packages/stack/src/managed-stack-contract.ts b/packages/stack/src/managed-stack-contract.ts index a4ea545e64..1988b61a6e 100644 --- a/packages/stack/src/managed-stack-contract.ts +++ b/packages/stack/src/managed-stack-contract.ts @@ -733,12 +733,51 @@ export const validateManagedStackContractFixtures = ( runtimeRequests.find((fact) => fact.source === "cli" || fact.source === "managed-api") ?? runtimeRequests.find((fact) => fact.source === "config") ?? runtimeRequests.find((fact) => fact.source === "default"); + if ( + isManagedStartAction(scenario.when) && + scenario.expected.outcome !== "error" && + effectiveRuntimeRequest?.kind === "runtime-request" && + effectiveRuntimeRequest.runtime !== "auto" + ) { + const requestedRuntime = effectiveRuntimeRequest.runtime; + const availability = scenario.given.find( + (fact) => fact.kind === "runtime-availability" && fact.runtime === requestedRuntime, + ); + const projectedRuntimes = [ + scenario.expected.details?.resolved_runtime, + output.api?.runtime, + output.json?.runtime, + output.human?.fields.runtime, + ].filter((runtime) => runtime !== undefined); + const projectedSources = [ + scenario.expected.details?.source, + output.api?.runtimeSource, + output.json?.runtime_source, + ].filter((source) => source !== undefined); + if ( + availability?.kind !== "runtime-availability" || + availability.available !== true || + projectedRuntimes.length === 0 || + projectedRuntimes.some((runtime) => runtime !== requestedRuntime) || + projectedSources.some((source) => source !== effectiveRuntimeRequest.source) + ) { + errors.push( + `${scenario.id}: effective runtime request must match availability and successful projections`, + ); + } + } if ( isManagedStartAction(scenario.when) && effectiveRuntimeRequest?.kind === "runtime-request" && effectiveRuntimeRequest.runtime === "auto" ) { - const persistedRuntime = scenario.given.find((fact) => fact.kind === "persisted-runtime"); + const runtimeTargetStackId = scenario.expected.selection?.stackId ?? explicitActionStackId; + const persistedRuntime = scenario.given.find( + (fact) => + fact.kind === "persisted-runtime" && + runtimeTargetStackId !== undefined && + fact.stackId === runtimeTargetStackId, + ); const dockerAvailability = scenario.given.find( (fact) => fact.kind === "runtime-availability" && fact.runtime === "docker", ); @@ -787,12 +826,26 @@ export const validateManagedStackContractFixtures = ( errors.push(`${scenario.id}: unavailable persisted runtime must fail without switching`); } } else if (resolvedRuntime === undefined) { + const unavailableReasonsMatch = + dockerAvailability?.kind === "runtime-availability" && + dockerAvailability.available === false && + typeof dockerAvailability.reason === "string" && + nativeAvailability?.kind === "runtime-availability" && + nativeAvailability.available === false && + typeof nativeAvailability.reason === "string" && + output.human?.fields.docker === dockerAvailability.reason && + output.human.fields.native === nativeAvailability.reason && + output.json?.docker_reason === dockerAvailability.reason && + output.json.native_reason === nativeAvailability.reason; if ( scenario.expected.outcome !== "error" || scenario.expected.error?.code !== "no_runtime_available" || - scenario.expected.runtimeEffects.some((effect) => effect.operation === "start") + scenario.expected.runtimeEffects.some((effect) => effect.operation === "start") || + !unavailableReasonsMatch ) { - errors.push(`${scenario.id}: automatic runtime must fail when no runtime is usable`); + errors.push( + `${scenario.id}: automatic runtime failure must bind both unavailability reasons`, + ); } } else { const projectedRuntimes = [ @@ -833,12 +886,13 @@ export const validateManagedStackContractFixtures = ( if (scenario.when.interface === "managed-api" && scenario.when.method === "resolveStack") { const stateRoot = scenario.when.input.stateRoot; - const isolatedOptions = scenario.given.find( - (fact) => fact.kind === "managed-api-options" && fact.stateRoot === "isolated", - ); - if (isolatedOptions?.kind === "managed-api-options") { + const managedOptions = scenario.given.filter((fact) => fact.kind === "managed-api-options"); + const isolatedOptions = managedOptions.find((fact) => fact.stateRoot === "isolated"); + if (typeof stateRoot === "string" || isolatedOptions !== undefined) { if ( typeof stateRoot !== "string" || + managedOptions.length !== 1 || + isolatedOptions?.kind !== "managed-api-options" || isolatedOptions.stateRootPath !== stateRoot || scenario.expected.details?.state_root !== stateRoot || scenario.expected.details.default_system_state_mutated !== false @@ -883,6 +937,9 @@ export const validateManagedStackContractFixtures = ( if (scenario.when.interface === "managed-api" && scenario.when.method === "resolveStackNames") { const requestedNames = scenario.when.input.stackNames; const declaredNames = scenario.given.find((fact) => fact.kind === "stack-names"); + const activeContext = scenario.given.find( + (fact) => fact.kind === "branch" && fact.checkedOut, + ); const detailKeys = Object.keys(scenario.expected.details ?? {}); const apiKeys = Object.keys(output.api ?? {}); if ( @@ -897,6 +954,26 @@ export const validateManagedStackContractFixtures = ( `${scenario.id}: requested stack names must match their fact and projected results`, ); } + if (Array.isArray(requestedNames)) { + for (const name of requestedNames) { + if (typeof name !== "string") { + continue; + } + const detailStackId = scenario.expected.details?.[name]; + const apiResult = output.api?.[name]; + if ( + typeof detailStackId !== "string" || + !isManagedStackContractRecord(apiResult) || + activeContext?.kind !== "branch" || + apiResult.contextId !== activeContext.contextId || + apiResult.stackId !== detailStackId + ) { + errors.push( + `${scenario.id}: resolved stack name ${name} must bind its context and stack ID`, + ); + } + } + } } if ( @@ -1279,6 +1356,39 @@ export const validateManagedStackContractFixtures = ( errors.push(`${scenario.id}: CLI action cwd must match a declared workspace`); } } + if ( + isStatusOperation && + (scenario.expected.details?.registered === false || output.json?.registered === false) && + !scenario.given.some( + (fact) => + fact.kind === "identity-claim" && fact.scope === "checkout" && fact.status === "absent", + ) + ) { + errors.push(`${scenario.id}: unregistered status requires an absent checkout claim`); + } + + const branchCopy = scenario.given.find( + (fact) => fact.kind === "identity-transition" && fact.operation === "branch-copy", + ); + if (branchCopy?.kind === "identity-transition") { + const originalBranchExists = scenario.given.some( + (fact) => fact.kind === "branch" && fact.name === branchCopy.from, + ); + const copiedBranch = scenario.given.find( + (fact) => fact.kind === "branch" && fact.name === branchCopy.to, + ); + if ( + typeof branchCopy.from !== "string" || + typeof branchCopy.to !== "string" || + branchCopy.originalExists !== originalBranchExists || + copiedBranch?.kind !== "branch" || + copiedBranch.checkedOut !== true + ) { + errors.push( + `${scenario.id}: copied branch transition must match live original and checked-out branch facts`, + ); + } + } if ( scenario.when.interface === "managed-api" && scenario.when.method === "resolveStack" && @@ -1912,6 +2022,26 @@ export const validateManagedStackContractFixtures = ( errors.push(`${scenario.id}: creating a Git context requires Git state for the workspace`); } + const recoveredIdentityMarker = scenario.given.find( + (fact) => + fact.kind === "identity-marker" && + fact.workspacePath === actionCwd && + fact.projectId === selection.projectId && + fact.checkoutId === selection.checkoutId && + fact.contextId === selection.contextId, + ); + if ( + recoveredIdentityMarker?.kind === "identity-marker" && + !scenario.given.some( + (fact) => + fact.kind === "workspace" && + fact.mode === "ordinary-folder" && + (fact.path === actionCwd || fact.canonicalPath === actionCwd), + ) + ) { + errors.push(`${scenario.id}: local identity marker recovery requires an ordinary folder`); + } + const ordinaryWorkspace = scenario.given.find( (fact) => fact.kind === "workspace" && @@ -2177,14 +2307,79 @@ export const validateManagedStackContractFixtures = ( } if (scenario.expected.output.json?.sticky === true) { + const projectedPorts = scenario.expected.output.json.ports; if (selection === undefined) { errors.push(`${scenario.id}: sticky port reuse requires a selected target`); - } else if ( - !scenario.given.some( - (fact) => fact.kind === "port-assignment" && fact.stackId === selection.stackId, - ) + } else if (!isManagedStackContractRecord(projectedPorts)) { + errors.push(`${scenario.id}: sticky port reuse must project its assigned ports`); + } else { + for (const [service, port] of Object.entries(projectedPorts)) { + const key = `${service}.port`; + if ( + typeof port !== "number" || + !scenario.given.some( + (fact) => + fact.kind === "config-port" && + fact.key === key && + fact.intent === "automatic" && + fact.source === "omitted", + ) || + !scenario.given.some( + (fact) => + fact.kind === "port-assignment" && + fact.stackId === selection.stackId && + fact.key === key && + fact.port === port && + fact.intent === "automatic", + ) || + (service === "api" && + output.human !== undefined && + output.human.fields.apiUrl !== `http://127.0.0.1:${port}`) + ) { + errors.push( + `${scenario.id}: sticky port reuse must bind automatic config, assignment, and projections`, + ); + } + } + } + } + + if (output.api?.sticky === true && scenario.expected.outcome === "update") { + const targetStackId = selection?.stackId ?? explicitActionStackId; + const projectedPorts = output.api.ports; + const projectedIntents = output.api.intents; + if ( + typeof targetStackId !== "string" || + !isManagedStackContractRecord(projectedPorts) || + !isManagedStackContractRecord(projectedIntents) ) { - errors.push(`${scenario.id}: reused sticky port must belong to the selected target`); + errors.push(`${scenario.id}: exact-to-automatic port transition requires projections`); + } else { + for (const [service, port] of Object.entries(projectedPorts)) { + const key = `${service}.port`; + const configPort = scenario.given.find( + (fact) => fact.kind === "config-port" && fact.key === key, + ); + const previousAssignment = scenario.given.find( + (fact) => + fact.kind === "port-assignment" && fact.stackId === targetStackId && fact.key === key, + ); + if ( + typeof port !== "number" || + configPort?.kind !== "config-port" || + configPort.intent !== "automatic" || + configPort.source !== "omitted" || + configPort.previousValue !== port || + previousAssignment?.kind !== "port-assignment" || + previousAssignment.intent !== "exact" || + previousAssignment.port !== port || + projectedIntents[service] !== "automatic" + ) { + errors.push( + `${scenario.id}: exact-to-automatic port transition must preserve the previous assignment`, + ); + } + } } } @@ -2286,6 +2481,68 @@ export const validateManagedStackContractFixtures = ( } } + if (scenario.expected.warning?.code === "running_stack_runtime_drift") { + const persistedRuntime = scenario.given.find( + (fact) => fact.kind === "persisted-runtime" && fact.stackId === selection?.stackId, + ); + const configuredRuntime = runtimeRequests.find( + (fact) => fact.source === "config" && fact.runtime !== "auto", + ); + const projectedServices = output.json?.services; + if ( + selection === undefined || + !scenario.given.some( + (fact) => + fact.kind === "stack" && + fact.stackId === selection.stackId && + fact.lifecycle === "running", + ) || + persistedRuntime?.kind !== "persisted-runtime" || + configuredRuntime?.kind !== "runtime-request" || + persistedRuntime.runtime === configuredRuntime.runtime || + output.human?.fields.runtime !== persistedRuntime.runtime || + output.human.fields.configuredRuntime !== configuredRuntime.runtime || + output.human.fields.drift !== "true" || + output.json?.runtime !== persistedRuntime.runtime || + output.json.configured_runtime !== configuredRuntime.runtime || + output.json.drift !== true || + !isManagedStackContractRecord(projectedServices) || + projectedServices.runtime !== persistedRuntime.runtime + ) { + errors.push( + `${scenario.id}: runtime drift must bind running stack, persisted runtime, config, and projections`, + ); + } + } + + if (scenario.expected.details?.managed_result_projected === true) { + const managedRecord = scenario.given.find( + (fact) => fact.kind === "managed-record" && fact.stackId === selection?.stackId, + ); + const selectedStack = scenario.given.find( + (fact) => fact.kind === "stack" && fact.stackId === selection?.stackId, + ); + const persistedRuntime = scenario.given.find( + (fact) => fact.kind === "persisted-runtime" && fact.stackId === selection?.stackId, + ); + if ( + selection === undefined || + managedRecord?.kind !== "managed-record" || + managedRecord.status !== "active" || + selectedStack?.kind !== "stack" || + selectedStack.lifecycle !== "running" || + persistedRuntime?.kind !== "persisted-runtime" || + scenario.expected.details.identity_decisions_in_cli !== 0 || + output.human?.fields.stackId !== selection.stackId || + output.human.fields.runtime !== persistedRuntime.runtime || + output.json?.runtime !== persistedRuntime.runtime + ) { + errors.push( + `${scenario.id}: projected managed status requires an active running record and persisted runtime`, + ); + } + } + if (scenario.expected.error?.code === "runtime_conflicts_with_persisted_stack") { if (selection === undefined) { errors.push(`${scenario.id}: persisted runtime conflict requires a selected target`); @@ -2374,13 +2631,45 @@ export const validateManagedStackContractFixtures = ( if (scenario.expected.error?.code === "legacy_source_running") { const legacySource = scenario.given.find((fact) => fact.kind === "legacy-state"); + const absentTarget = scenario.given.find( + (fact) => fact.kind === "managed-target" && fact.exists === false, + ); if ( legacySource?.kind !== "legacy-state" || legacySource.lifecycle !== "running" || + absentTarget?.kind !== "managed-target" || + scenario.given.some( + (fact) => + fact.kind === "managed-target" && fact.stackId === absentTarget.stackId && fact.exists, + ) || scenario.expected.writes.length > 0 || scenario.expected.runtimeEffects.length > 0 ) { - errors.push(`${scenario.id}: running legacy error requires a running source`); + errors.push( + `${scenario.id}: running legacy error requires a running source and absent target`, + ); + } + + const configuredPort = scenario.given.find( + (fact) => fact.kind === "config-port" && fact.intent === "exact", + ); + const occupiedLegacyPort = scenario.given.find( + (fact) => fact.kind === "occupied-port" && fact.owner === "legacy-stack", + ); + if ( + (configuredPort !== undefined || occupiedLegacyPort !== undefined) && + (configuredPort?.kind !== "config-port" || + typeof configuredPort.value !== "number" || + occupiedLegacyPort?.kind !== "occupied-port" || + occupiedLegacyPort.port !== configuredPort.value || + output.json?.port !== configuredPort.value || + output.json.config_key !== configuredPort.key || + scenario.expected.details?.allocation_attempted !== false || + output.json.allocation_attempted !== false) + ) { + errors.push( + `${scenario.id}: running legacy port failure must bind config, occupancy, and projections`, + ); } } @@ -2419,6 +2708,18 @@ export const validateManagedStackContractFixtures = ( `${scenario.id}: bootstrap rollback requires failure injection against an absent target`, ); } + const legacySource = scenario.given.find((fact) => fact.kind === "legacy-state"); + if ( + legacySource?.kind !== "legacy-state" || + legacySource.lifecycle !== "stopped" || + legacySource.database !== "compatible" || + legacySource.storage !== "compatible" || + legacySource.credentials !== "compatible" + ) { + errors.push( + `${scenario.id}: bootstrap rollback requires a compatible stopped legacy source`, + ); + } } const destructivelyDeletesManagedState = @@ -2494,6 +2795,33 @@ export const validateManagedStackContractFixtures = ( const changedCredentials = scenario.given.find( (fact) => fact.kind === "credential-state" && fact.previousValuesId !== undefined, ); + const projectedCredentialReference = scenario.expected.details?.credential_values_id; + if ( + typeof projectedCredentialReference === "string" && + scenario.given.some((fact) => fact.kind === "credential-state") + ) { + const credentialState = scenario.given.find( + (fact) => + fact.kind === "credential-state" && fact.valuesId === projectedCredentialReference, + ); + const projectedSources = [ + scenario.expected.details?.source, + output.api?.credentialsSource, + output.json?.credentials_source, + ].filter((source) => source !== undefined); + if ( + credentialState?.kind !== "credential-state" || + (output.api?.credentialsValuesId !== undefined && + output.api.credentialsValuesId !== credentialState.valuesId) || + (output.json?.credentials_values_id !== undefined && + output.json.credentials_values_id !== credentialState.valuesId) || + projectedSources.some((source) => source !== credentialState.source) + ) { + errors.push( + `${scenario.id}: persisted credential reference and source must match declared values`, + ); + } + } if ( changedCredentials?.kind === "credential-state" && changedCredentials.previousValuesId === changedCredentials.valuesId @@ -4981,6 +5309,7 @@ const additionalPortContractFixtures = defineManagedStackContractFixtures([ title: "A running legacy source fails before bootstrap or port allocation", area: "ports", given: [ + { kind: "managed-target", stackId: "stack-main-default", exists: false }, { kind: "legacy-state", lifecycle: "running", From d9f26e44b208f514b37ae9294e868da420986e4c Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 10 Aug 2026 23:36:49 +0200 Subject: [PATCH 24/41] test(stack): tighten contract decision evidence --- ...managed-stack-contract.integration.test.ts | 109 +++++++++++++++++ packages/stack/src/managed-stack-contract.ts | 115 +++++++++++++++++- 2 files changed, 218 insertions(+), 6 deletions(-) diff --git a/packages/stack/src/managed-stack-contract.integration.test.ts b/packages/stack/src/managed-stack-contract.integration.test.ts index 41dcfba463..e95a900aaf 100644 --- a/packages/stack/src/managed-stack-contract.integration.test.ts +++ b/packages/stack/src/managed-stack-contract.integration.test.ts @@ -2342,6 +2342,115 @@ describe("managed stack acceptance contract", () => { expect(validateManagedStackContractFixtures([stoppedStackReportedRunning])).toContain( `${projectedManagedResultScenario.id}: projected managed status requires an active running record and persisted runtime`, ); + + const persistedRuntimeAvailabilityScenario = findScenario( + "runtime.persisted-runtime-reused-for-auto", + ); + const persistedRuntimeWithoutAvailability = { + ...persistedRuntimeAvailabilityScenario, + given: persistedRuntimeAvailabilityScenario.given.map((fact) => + fact.kind === "runtime-availability" && fact.runtime === "native" + ? { ...fact, runtime: "docker" } + : fact, + ), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([persistedRuntimeWithoutAvailability])).toContain( + `${persistedRuntimeAvailabilityScenario.id}: persisted automatic runtime requires matching availability evidence`, + ); + + const qualificationScenario = findScenario( + "native-qualification.one-service-failure-disables-platform", + ); + if (qualificationScenario.when.interface !== "managed-api") { + throw new Error("native qualification fixture must use the managed API"); + } + const unsupportedPlatformReportedAsUnqualified = { + ...qualificationScenario, + given: qualificationScenario.given.map((fact) => + fact.kind === "native-qualification" ? { ...fact, platform: "darwin-x64" } : fact, + ), + when: { + ...qualificationScenario.when, + input: { ...qualificationScenario.when.input, platform: "darwin-x64" }, + }, + expected: { + ...qualificationScenario.expected, + output: { + ...qualificationScenario.expected.output, + api: { ...qualificationScenario.expected.output.api, platform: "darwin-x64" }, + }, + }, + } satisfies ManagedStackContractScenario; + expect( + validateManagedStackContractFixtures([unsupportedPlatformReportedAsUnqualified]), + ).toContain( + `${qualificationScenario.id}: unsupported native platform must use the dedicated preflight error`, + ); + + const renameScenario = findScenario("identity.branch-rename-preserves-context"); + const renameToUnrelatedBranch = { + ...renameScenario, + given: renameScenario.given.map((fact) => + fact.kind === "identity-transition" && fact.operation === "branch-rename" + ? { ...fact, to: "unrelated" } + : fact, + ), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([renameToUnrelatedBranch])).toContain( + `${renameScenario.id}: branch rename must match the checked-out branch and updated context owner`, + ); + + const repositoryScenario = findScenario("api-boundary.repository-contract-is-storage-agnostic"); + const referencedRepositoryScenario = findScenario("identity.return-to-branch-reuses-stack"); + const adaptersAgreeOnWrongContext = { + ...repositoryScenario, + expected: { + ...repositoryScenario.expected, + output: { + ...repositoryScenario.expected.output, + api: { + ...repositoryScenario.expected.output.api, + "in-memory": { + outcome: "reuse", + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-unrelated", + stackId: "stack-main-default", + stackName: "default", + }, + "persistent-adapter": { + outcome: "reuse", + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-unrelated", + stackId: "stack-main-default", + stackName: "default", + }, + }, + }, + }, + } satisfies ManagedStackContractScenario; + expect( + validateManagedStackContractFixtures([ + referencedRepositoryScenario, + adaptersAgreeOnWrongContext, + ]), + ).toContain( + `${repositoryScenario.id}: repository in-memory decision must completely match ${referencedRepositoryScenario.id}`, + ); + + const ambiguousContextScenario = findScenario("identity.branch-copy-ambiguous-read-only"); + const independentBranchesReportedAmbiguous = { + ...ambiguousContextScenario, + given: ambiguousContextScenario.given.map((fact) => + fact.kind === "branch" && fact.name === "main" + ? { ...fact, contextId: "context-independent" } + : fact, + ), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([independentBranchesReportedAmbiguous])).toContain( + `${ambiguousContextScenario.id}: ambiguous context must bind at least two claiming branches to its projections`, + ); }); it("covers the approved identity journeys through public commands and APIs", () => { diff --git a/packages/stack/src/managed-stack-contract.ts b/packages/stack/src/managed-stack-contract.ts index 1988b61a6e..589fad0461 100644 --- a/packages/stack/src/managed-stack-contract.ts +++ b/packages/stack/src/managed-stack-contract.ts @@ -798,13 +798,22 @@ export const validateManagedStackContractFixtures = ( fact.kind === "runtime-availability" && fact.runtime === persistedRuntime.runtime, ) : undefined; + if ( + persistedRuntime?.kind === "persisted-runtime" && + persistedAvailability?.kind !== "runtime-availability" + ) { + errors.push( + `${scenario.id}: persisted automatic runtime requires matching availability evidence`, + ); + } const resolvedRuntime = persistedRuntime?.kind === "persisted-runtime" && - persistedAvailability?.kind === "runtime-availability" && - !persistedAvailability.available - ? undefined - : persistedRuntime?.kind === "persisted-runtime" + persistedAvailability?.kind === "runtime-availability" + ? persistedAvailability.available ? persistedRuntime.runtime + : undefined + : persistedRuntime?.kind === "persisted-runtime" + ? undefined : dockerAvailability?.kind === "runtime-availability" && dockerAvailability.available ? "docker" : nativeAvailability?.kind === "runtime-availability" && @@ -1141,6 +1150,17 @@ export const validateManagedStackContractFixtures = ( if (referencedScenario === undefined) { errors.push(`${scenario.id}: repository contract must reference a declared scenario`); } else { + const referencedDecision: Readonly> = + referencedScenario.expected.selection === undefined + ? { outcome: referencedScenario.expected.outcome } + : { + outcome: referencedScenario.expected.outcome, + projectId: referencedScenario.expected.selection.projectId, + checkoutId: referencedScenario.expected.selection.checkoutId, + contextId: referencedScenario.expected.selection.contextId, + stackId: referencedScenario.expected.selection.stackId, + stackName: referencedScenario.expected.selection.stackName, + }; const adapters = scenario.when.input.adapters; if ( !Array.isArray(adapters) || @@ -1208,6 +1228,11 @@ export const validateManagedStackContractFixtures = ( `${scenario.id}: repository ${adapter} stackId must match ${referencedScenario.id}`, ); } + if (!managedStackContractJsonEquals(adapterResult, referencedDecision)) { + errors.push( + `${scenario.id}: repository ${adapter} decision must completely match ${referencedScenario.id}`, + ); + } if (firstAdapterResult === undefined) { firstAdapterResult = adapterResult; } else if (!managedStackContractJsonEquals(firstAdapterResult, adapterResult)) { @@ -1404,6 +1429,62 @@ export const validateManagedStackContractFixtures = ( const checkedOutBranch = scenario.given.find( (fact) => fact.kind === "branch" && fact.checkedOut, ); + const branchRename = scenario.given.find( + (fact) => fact.kind === "identity-transition" && fact.operation === "branch-rename", + ); + if (branchRename?.kind === "identity-transition") { + const renamedContextWrite = scenario.expected.writes.find( + (write) => + write.target === "git-config" && + write.operation === "update" && + checkedOutBranch?.kind === "branch" && + write.id === checkedOutBranch.contextId, + ); + if ( + typeof branchRename.from !== "string" || + typeof branchRename.to !== "string" || + branchRename.from === branchRename.to || + checkedOutBranch?.kind !== "branch" || + checkedOutBranch.name !== branchRename.to || + renamedContextWrite?.target !== "git-config" || + renamedContextWrite.owner !== branchRename.to + ) { + errors.push( + `${scenario.id}: branch rename must match the checked-out branch and updated context owner`, + ); + } + } + if (scenario.expected.error?.code === "ambiguous_context_owner") { + const projectedContextId = output.json?.context_id; + const projectedBranches = output.json?.branches; + const projectedBranchNames = + Array.isArray(projectedBranches) && + projectedBranches.every((branch): branch is string => typeof branch === "string") + ? projectedBranches + : undefined; + const claimingBranchNames = + typeof projectedContextId === "string" + ? scenario.given.flatMap((fact) => + fact.kind === "branch" && fact.contextId === projectedContextId ? [fact.name] : [], + ) + : []; + const humanBranchNames = output.human?.fields.branches + ?.split(",") + .map((branch) => branch.trim()) + .filter((branch) => branch.length > 0); + if ( + typeof projectedContextId !== "string" || + new Set(claimingBranchNames).size < 2 || + projectedBranchNames === undefined || + !managedStackContractStringSetEquals(claimingBranchNames, projectedBranchNames) || + humanBranchNames === undefined || + !managedStackContractStringSetEquals(claimingBranchNames, humanBranchNames) + ) { + errors.push( + `${scenario.id}: ambiguous context must bind at least two claiming branches to its projections`, + ); + } + } if ( scenario.when.interface === "git" && scenario.when.argv[0] === "branch" && @@ -1611,6 +1692,14 @@ export const validateManagedStackContractFixtures = ( } } if (scenario.when.interface === "managed-api" && scenario.when.method === "preflightNative") { + const platformSupported = managedNativeServiceMatrix.targetPlatforms.includes( + fact.platform, + ); + if (!platformSupported && scenario.expected.error?.code !== "native_platform_unsupported") { + errors.push( + `${scenario.id}: unsupported native platform must use the dedicated preflight error`, + ); + } const platformQualified = failed.size === 0 && qualified.size === nativeServices.length; if ( scenario.expected.outcome !== (platformQualified ? "report" : "error") || @@ -6751,8 +6840,22 @@ const additionalApiBoundaryContractFixtures = defineManagedStackContractFixtures details: { decisions_equal: true, persistence_semantics_leaked: false }, output: { api: { - "in-memory": { outcome: "reuse", stackId: "stack-main-default" }, - "persistent-adapter": { outcome: "reuse", stackId: "stack-main-default" }, + "in-memory": { + outcome: "reuse", + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-main", + stackId: "stack-main-default", + stackName: "default", + }, + "persistent-adapter": { + outcome: "reuse", + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-main", + stackId: "stack-main-default", + stackName: "default", + }, equal: true, }, }, From 620f35b325d5a032dee5edd5f8bb162a4702e389 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 11 Aug 2026 00:00:08 +0200 Subject: [PATCH 25/41] test(stack): bind remaining contract projections --- ...managed-stack-contract.integration.test.ts | 186 +++++++++++++++++ packages/stack/src/managed-stack-contract.ts | 192 +++++++++++++++--- 2 files changed, 347 insertions(+), 31 deletions(-) diff --git a/packages/stack/src/managed-stack-contract.integration.test.ts b/packages/stack/src/managed-stack-contract.integration.test.ts index e95a900aaf..6448abe454 100644 --- a/packages/stack/src/managed-stack-contract.integration.test.ts +++ b/packages/stack/src/managed-stack-contract.integration.test.ts @@ -2451,6 +2451,192 @@ describe("managed stack acceptance contract", () => { expect(validateManagedStackContractFixtures([independentBranchesReportedAmbiguous])).toContain( `${ambiguousContextScenario.id}: ambiguous context must bind at least two claiming branches to its projections`, ); + + const invalidStackNameScenario = findScenario( + "identity.invalid-stack-name-uppercase-underscore-fails", + ); + if (invalidStackNameScenario.when.interface !== "cli") { + throw new Error("invalid stack name fixture must use the CLI"); + } + const validStackNameRejected = { + ...invalidStackNameScenario, + given: invalidStackNameScenario.given.map((fact) => + fact.kind === "stack-names" ? { ...fact, names: ["review"] } : fact, + ), + when: { + ...invalidStackNameScenario.when, + argv: invalidStackNameScenario.when.argv.map((argument) => + argument === "Feature_A" ? "review" : argument, + ), + }, + expected: { + ...invalidStackNameScenario.expected, + output: { + ...invalidStackNameScenario.expected.output, + human: { + ...invalidStackNameScenario.expected.output.human!, + fields: { stack: "review" }, + }, + json: { ...invalidStackNameScenario.expected.output.json, stack_name: "review" }, + }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([validStackNameRejected])).toContain( + `${invalidStackNameScenario.id}: invalid stack name error must bind a requested name outside the supported grammar`, + ); + + const automaticPortScenario = findScenario( + "ports.new-target-allocates-and-persists-omitted-ports", + ); + const duplicateAutomaticPort = { + ...automaticPortScenario, + expected: { + ...automaticPortScenario.expected, + output: { + ...automaticPortScenario.expected.output, + api: { + ...automaticPortScenario.expected.output.api, + ports: { api: 55421, db: 55421 }, + }, + }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([duplicateAutomaticPort])).toContain( + `${automaticPortScenario.id}: allocated port 55421 is assigned more than once`, + ); + + const refReplacementScenario = findScenario("identity.manual-ref-replacement-orphans-context"); + const refReplacementTargetsUnobservedCommit = { + ...refReplacementScenario, + given: refReplacementScenario.given.map((fact) => + fact.kind === "identity-transition" && fact.operation === "ref-replacement" + ? { ...fact, to: "commit-unrelated" } + : fact, + ), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([refReplacementTargetsUnobservedCommit])).toContain( + `${refReplacementScenario.id}: ref replacement target must match the action workspace commit`, + ); + + const selectorConflictScenario = findScenario( + "reclamation.selectors-stack-and-stack-id-conflict", + ); + if (selectorConflictScenario.when.interface !== "cli") { + throw new Error("selector conflict fixture must use the CLI"); + } + const singleSelectorReportedAsConflict = { + ...selectorConflictScenario, + when: { + ...selectorConflictScenario.when, + argv: ["stop", "--experimental", "--stack", "review"], + }, + expected: { + ...selectorConflictScenario.expected, + output: { + ...selectorConflictScenario.expected.output, + human: { + ...selectorConflictScenario.expected.output.human!, + fields: { selectors: "--stack" }, + }, + json: { ...selectorConflictScenario.expected.output.json, selectors: ["--stack"] }, + }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([singleSelectorReportedAsConflict])).toContain( + `${selectorConflictScenario.id}: selector conflict must bind at least two requested selection modes`, + ); + + const pruneScenario = findScenario("reclamation.prune-removes-metadata-only"); + const pruneReportsDifferentRecord = { + ...pruneScenario, + expected: { + ...pruneScenario.expected, + output: { + ...pruneScenario.expected.output, + json: { ...pruneScenario.expected.output.json, pruned_records: ["stack-other"] }, + }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([pruneReportsDifferentRecord])).toContain( + `${pruneScenario.id}: prune projections must match deleted registry records`, + ); + + const portableDecisionScenario = findScenario( + "api-boundary.managed-surface-is-node-and-bun-portable", + ); + const referencedPortableDecisionScenario = findScenario( + "identity.same-checkout-branch-and-name-reuses-stack", + ); + const portableRuntimesAgreeOnWrongContext = { + ...portableDecisionScenario, + expected: { + ...portableDecisionScenario.expected, + output: { + ...portableDecisionScenario.expected.output, + api: { + ...portableDecisionScenario.expected.output.api, + node: { + outcome: "report", + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-unrelated", + stackId: "stack-main-default", + stackName: "default", + }, + bun: { + outcome: "report", + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-unrelated", + stackId: "stack-main-default", + stackName: "default", + }, + }, + }, + }, + } satisfies ManagedStackContractScenario; + expect( + validateManagedStackContractFixtures([ + referencedPortableDecisionScenario, + portableRuntimesAgreeOnWrongContext, + ]), + ).toContain( + `${portableDecisionScenario.id}: portable node decision must completely match ${referencedPortableDecisionScenario.id}`, + ); + + const persistedRuntimeFailureScenario = findScenario( + "runtime.missing-persisted-prerequisite-fails", + ); + const persistedRuntimeReportsUnrelatedReason = { + ...persistedRuntimeFailureScenario, + given: persistedRuntimeFailureScenario.given.map((fact) => + fact.kind === "runtime-availability" && fact.runtime === "native" + ? { ...fact, reason: "unrelated failure" } + : fact, + ), + } satisfies ManagedStackContractScenario; + expect( + validateManagedStackContractFixtures([persistedRuntimeReportsUnrelatedReason]), + ).toContain( + `${persistedRuntimeFailureScenario.id}: unavailable persisted runtime must fail without switching`, + ); + + const reducedNativeGraph = { + ...qualificationScenario, + expected: { + ...qualificationScenario.expected, + output: { + ...qualificationScenario.expected.output, + api: { + ...qualificationScenario.expected.output.api, + availableServices: ["postgres"], + }, + }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([reducedNativeGraph])).toContain( + `${qualificationScenario.id}: failed native qualification must expose no reduced service graph`, + ); }); it("covers the approved identity journeys through public commands and APIs", () => { diff --git a/packages/stack/src/managed-stack-contract.ts b/packages/stack/src/managed-stack-contract.ts index 589fad0461..137bdc59e5 100644 --- a/packages/stack/src/managed-stack-contract.ts +++ b/packages/stack/src/managed-stack-contract.ts @@ -374,6 +374,20 @@ const managedStackContractJsonEquals = ( return false; }; +const managedStackContractDecision = ( + scenario: ManagedStackContractScenario, +): Readonly> => + scenario.expected.selection === undefined + ? { outcome: scenario.expected.outcome } + : { + outcome: scenario.expected.outcome, + projectId: scenario.expected.selection.projectId, + checkoutId: scenario.expected.selection.checkoutId, + contextId: scenario.expected.selection.contextId, + stackId: scenario.expected.selection.stackId, + stackName: scenario.expected.selection.stackName, + }; + const managedStackContractStringSetEquals = ( left: ReadonlyArray, right: ReadonlyArray, @@ -388,6 +402,8 @@ const managedStackContractStringSetEquals = ( ); }; +const managedStackNamePattern = /^(?=.{1,63}$)[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/; + const isManagedStartAction = (action: ManagedStackContractAction): boolean => (action.interface === "cli" && action.argv[0] === "start") || (action.interface === "managed-api" && @@ -580,6 +596,36 @@ export const validateManagedStackContractFixtures = ( if (output.human === undefined && output.json === undefined && output.api === undefined) { errors.push(`${scenario.id}: at least one observable output is required`); } + if (scenario.expected.error?.code === "mutually_exclusive_stack_selectors") { + const stopArgv = + scenario.when.interface === "cli" && scenario.when.argv[0] === "stop" + ? scenario.when.argv + : []; + const selectorModes = ["--stack", "--stack-id", "--all"].filter((selector) => + stopArgv.includes(selector), + ); + const jsonSelectors = output.json?.selectors; + const projectedJsonSelectors = + Array.isArray(jsonSelectors) && + jsonSelectors.every((selector): selector is string => typeof selector === "string") + ? jsonSelectors + : undefined; + const projectedHumanSelectors = output.human?.fields.selectors + ?.split(",") + .map((selector) => selector.trim()) + .filter((selector) => selector.length > 0); + if ( + selectorModes.length < 2 || + projectedJsonSelectors === undefined || + !managedStackContractStringSetEquals(selectorModes, projectedJsonSelectors) || + projectedHumanSelectors === undefined || + !managedStackContractStringSetEquals(selectorModes, projectedHumanSelectors) + ) { + errors.push( + `${scenario.id}: selector conflict must bind at least two requested selection modes`, + ); + } + } if (scenario.when.interface === "stack-api" && scenario.when.method === "createStack") { const directOptions = scenario.given.filter((fact) => fact.kind === "direct-stack-options"); @@ -679,6 +725,22 @@ export const validateManagedStackContractFixtures = ( `${scenario.id}: explicit stack name ${explicitActionStackName} disagrees with selected stack ${scenario.expected.selection.stackName}`, ); } + if (scenario.expected.error?.code === "invalid_stack_name") { + const declaredNames = scenario.given.find((fact) => fact.kind === "stack-names"); + if ( + explicitActionStackName === undefined || + managedStackNamePattern.test(explicitActionStackName) || + declaredNames?.kind !== "stack-names" || + declaredNames.names.length !== 1 || + declaredNames.names[0] !== explicitActionStackName || + output.human?.fields.stack !== explicitActionStackName || + output.json?.stack_name !== explicitActionStackName + ) { + errors.push( + `${scenario.id}: invalid stack name error must bind a requested name outside the supported grammar`, + ); + } + } const cliRuntimeIndex = scenario.when.interface === "cli" ? scenario.when.argv.indexOf("--runtime") : -1; @@ -829,7 +891,9 @@ export const validateManagedStackContractFixtures = ( if ( scenario.expected.outcome !== "error" || scenario.expected.error?.code !== "persisted_runtime_unavailable" || + typeof persistedAvailability.reason !== "string" || output.json?.runtime !== persistedRuntime.runtime || + output.json.reason !== persistedAvailability.reason || scenario.expected.runtimeEffects.some((effect) => effect.operation === "start") ) { errors.push(`${scenario.id}: unavailable persisted runtime must fail without switching`); @@ -1055,6 +1119,7 @@ export const validateManagedStackContractFixtures = ( if (referencedScenario === undefined) { errors.push(`${scenario.id}: portable contract must reference a declared scenario`); } else { + const referencedDecision = managedStackContractDecision(referencedScenario); const runtimes = scenario.when.input.runtimes; if ( !Array.isArray(runtimes) || @@ -1123,6 +1188,11 @@ export const validateManagedStackContractFixtures = ( `${scenario.id}: portable ${runtime} stackId must match ${referencedScenario.id}`, ); } + if (!managedStackContractJsonEquals(runtimeResult, referencedDecision)) { + errors.push( + `${scenario.id}: portable ${runtime} decision must completely match ${referencedScenario.id}`, + ); + } if (firstRuntimeResult === undefined) { firstRuntimeResult = runtimeResult; } else if (!managedStackContractJsonEquals(firstRuntimeResult, runtimeResult)) { @@ -1150,17 +1220,7 @@ export const validateManagedStackContractFixtures = ( if (referencedScenario === undefined) { errors.push(`${scenario.id}: repository contract must reference a declared scenario`); } else { - const referencedDecision: Readonly> = - referencedScenario.expected.selection === undefined - ? { outcome: referencedScenario.expected.outcome } - : { - outcome: referencedScenario.expected.outcome, - projectId: referencedScenario.expected.selection.projectId, - checkoutId: referencedScenario.expected.selection.checkoutId, - contextId: referencedScenario.expected.selection.contextId, - stackId: referencedScenario.expected.selection.stackId, - stackName: referencedScenario.expected.selection.stackName, - }; + const referencedDecision = managedStackContractDecision(referencedScenario); const adapters = scenario.when.input.adapters; if ( !Array.isArray(adapters) || @@ -1454,6 +1514,19 @@ export const validateManagedStackContractFixtures = ( ); } } + const refReplacement = scenario.given.find( + (fact) => fact.kind === "identity-transition" && fact.operation === "ref-replacement", + ); + if ( + refReplacement?.kind === "identity-transition" && + (typeof refReplacement.from !== "string" || + typeof refReplacement.to !== "string" || + refReplacement.from === refReplacement.to || + actionGitState?.kind !== "git-state" || + refReplacement.to !== actionGitState.commit) + ) { + errors.push(`${scenario.id}: ref replacement target must match the action workspace commit`); + } if (scenario.expected.error?.code === "ambiguous_context_owner") { const projectedContextId = output.json?.context_id; const projectedBranches = output.json?.branches; @@ -1728,6 +1801,18 @@ export const validateManagedStackContractFixtures = ( ) { errors.push(`${scenario.id}: native preflight failures must match failed services`); } + const projectedAvailableServices = scenario.expected.output.api?.availableServices; + if ( + !platformQualified && + (!Array.isArray(projectedAvailableServices) || + projectedAvailableServices.length !== 0 || + scenario.expected.details?.reduced_graph !== false || + scenario.expected.details.docker_fallback_per_service !== false) + ) { + errors.push( + `${scenario.id}: failed native qualification must expose no reduced service graph`, + ); + } } } @@ -2486,24 +2571,17 @@ export const validateManagedStackContractFixtures = ( ? [fact] : [], ); - if (siblingAssignments.length > 0) { - const projectedPorts = scenario.expected.output.api?.ports; - if (!isManagedStackContractRecord(projectedPorts)) { + const projectedPorts = scenario.expected.output.api?.ports; + if (!isManagedStackContractRecord(projectedPorts)) { + if (siblingAssignments.length > 0) { errors.push(`${scenario.id}: sibling allocation must project its allocated ports`); - } else { - const occupiedSiblingPorts = new Set(siblingAssignments.map(({ port }) => port)); - const allocatedPorts = new Set(); - for (const port of Object.values(projectedPorts)) { - if (typeof port === "number") { - if (allocatedPorts.has(port)) { - errors.push(`${scenario.id}: allocated port ${port} is assigned more than once`); - } - allocatedPorts.add(port); - if (occupiedSiblingPorts.has(port)) { - errors.push( - `${scenario.id}: allocated port ${port} conflicts with a sibling target`, - ); - } + } + } else { + const occupiedSiblingPorts = new Set(siblingAssignments.map(({ port }) => port)); + for (const port of Object.values(projectedPorts)) { + if (typeof port === "number") { + if (occupiedSiblingPorts.has(port)) { + errors.push(`${scenario.id}: allocated port ${port} conflicts with a sibling target`); } } } @@ -2519,6 +2597,18 @@ export const validateManagedStackContractFixtures = ( const projectedIntents = scenario.expected.output.api?.intents; const requestedEntries = Object.entries(scenario.when.input.portIntents); const configPorts = scenario.given.filter((fact) => fact.kind === "config-port"); + if (isManagedStackContractRecord(projectedPorts)) { + const allocatedPorts = new Set(); + for (const port of Object.values(projectedPorts)) { + if (typeof port !== "number") { + continue; + } + if (allocatedPorts.has(port)) { + errors.push(`${scenario.id}: allocated port ${port} is assigned more than once`); + } + allocatedPorts.add(port); + } + } if ( !managedStackContractStringSetEquals( requestedEntries.map(([key]) => key), @@ -3019,6 +3109,26 @@ export const validateManagedStackContractFixtures = ( errors.push(`${scenario.id}: prune may delete only orphaned registry metadata`); } } + const deletedRecordIds = scenario.expected.writes.flatMap((write) => + write.target === "registry" && write.operation === "delete" ? [write.id] : [], + ); + const projectedRecords = output.json?.pruned_records; + const projectedRecordIds = + Array.isArray(projectedRecords) && + projectedRecords.every((record): record is string => typeof record === "string") + ? projectedRecords + : undefined; + const deletedRecordCount = deletedRecordIds.length; + const expectedSummary = `Pruned ${deletedRecordCount} orphaned metadata record${deletedRecordCount === 1 ? "" : "s"}`; + if ( + deletedRecordCount === 0 || + projectedRecordIds === undefined || + !managedStackContractStringSetEquals(deletedRecordIds, projectedRecordIds) || + output.json?.pruned_count !== deletedRecordCount || + output.human?.summary !== expectedSummary + ) { + errors.push(`${scenario.id}: prune projections must match deleted registry records`); + } } const activeBranchName = @@ -5992,6 +6102,7 @@ const selectorConflictFixture = ( json: { outcome: "error", code: "mutually_exclusive_stack_selectors", + selectors: selectorSummary.split(", "), recovery: ["Remove all but one stack selector"], }, }, @@ -6632,7 +6743,12 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ details: { metadata_removed: true, mutable_data_deleted: false }, output: { human: { summary: "Pruned 1 orphaned metadata record", fields: { dataDeleted: "false" } }, - json: { outcome: "update", pruned_records: ["stack-orphan"], mutable_data_deleted: false }, + json: { + outcome: "update", + pruned_records: ["stack-orphan"], + pruned_count: 1, + mutable_data_deleted: false, + }, }, }, }, @@ -6950,8 +7066,22 @@ const additionalApiBoundaryContractFixtures = defineManagedStackContractFixtures details: { results_equal: true, bun_specific_state_api: false }, output: { api: { - node: { outcome: "report", stackId: "stack-main-default" }, - bun: { outcome: "report", stackId: "stack-main-default" }, + node: { + outcome: "report", + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-main", + stackId: "stack-main-default", + stackName: "default", + }, + bun: { + outcome: "report", + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-main", + stackId: "stack-main-default", + stackName: "default", + }, equal: true, }, }, From d35b51e1f56d3bc76ec2295b013c87bbc52e39bc Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 11 Aug 2026 00:23:24 +0200 Subject: [PATCH 26/41] test(stack): close contract evidence gaps --- ...managed-stack-contract.integration.test.ts | 138 ++++++++++++++ packages/stack/src/managed-stack-contract.ts | 177 ++++++++++++++++-- 2 files changed, 302 insertions(+), 13 deletions(-) diff --git a/packages/stack/src/managed-stack-contract.integration.test.ts b/packages/stack/src/managed-stack-contract.integration.test.ts index 6448abe454..68ec7c9bb7 100644 --- a/packages/stack/src/managed-stack-contract.integration.test.ts +++ b/packages/stack/src/managed-stack-contract.integration.test.ts @@ -2637,6 +2637,144 @@ describe("managed stack acceptance contract", () => { expect(validateManagedStackContractFixtures([reducedNativeGraph])).toContain( `${qualificationScenario.id}: failed native qualification must expose no reduced service graph`, ); + + const automaticNativeScenario = findScenario("runtime.auto-selects-fully-qualified-native"); + const automaticNativeWithoutDockerEvidence = { + ...automaticNativeScenario, + given: automaticNativeScenario.given.filter( + (fact) => fact.kind !== "runtime-availability" || fact.runtime !== "docker", + ), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([automaticNativeWithoutDockerEvidence])).toContain( + `${automaticNativeScenario.id}: automatic native fallback requires explicit Docker unavailability`, + ); + + if (qualificationScenario.expected.error === undefined) { + throw new Error("failed native qualification fixture must project an error"); + } + const qualificationUsesUnrelatedError = { + ...qualificationScenario, + expected: { + ...qualificationScenario.expected, + error: { ...qualificationScenario.expected.error, code: "unrelated_error" }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([qualificationUsesUnrelatedError])).toContain( + `${qualificationScenario.id}: supported unqualified native platform must use native_platform_not_qualified`, + ); + + const persistedRuntimeWithoutProvenance = { + ...persistedRuntimeAvailabilityScenario, + expected: { + ...persistedRuntimeAvailabilityScenario.expected, + output: { + ...persistedRuntimeAvailabilityScenario.expected.output, + json: { ...persistedRuntimeAvailabilityScenario.expected.output.json, persisted: false }, + }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([persistedRuntimeWithoutProvenance])).toContain( + `${persistedRuntimeAvailabilityScenario.id}: automatic persisted runtime reuse must report persisted provenance`, + ); + + const siblingPortScenario = findScenario("ports.sibling-targets-allocate-independent-ports"); + const omittedSiblingAssignment = { + ...siblingPortScenario, + given: siblingPortScenario.given.filter( + (fact) => fact.kind !== "port-assignment" || fact.stackId !== "stack-main-default", + ), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([omittedSiblingAssignment])).toContain( + `${siblingPortScenario.id}: sibling allocation must bind all avoided stack IDs`, + ); + + const nonDestructiveStopScenario = findScenario("reclamation.default-stop-preserves-data"); + const nonDestructiveStopReportsDataLoss = { + ...nonDestructiveStopScenario, + expected: { + ...nonDestructiveStopScenario.expected, + details: { + ...nonDestructiveStopScenario.expected.details, + data_preserved: false, + registry_record_preserved: false, + }, + output: { + ...nonDestructiveStopScenario.expected.output, + human: { + ...nonDestructiveStopScenario.expected.output.human!, + fields: { dataPreserved: "false" }, + }, + json: { + ...nonDestructiveStopScenario.expected.output.json, + data_preserved: false, + }, + }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([nonDestructiveStopReportsDataLoss])).toContain( + `${nonDestructiveStopScenario.id}: non-destructive stop must report preserved data and registry`, + ); + + const existingManagedTargetScenario = findScenario( + "bootstrap.existing-managed-target-ignores-legacy", + ); + const existingTargetReportsBootstrap = { + ...existingManagedTargetScenario, + expected: { + ...existingManagedTargetScenario.expected, + details: { ...existingManagedTargetScenario.expected.details, legacy_state_read: true }, + output: { + ...existingManagedTargetScenario.expected.output, + api: { + ...existingManagedTargetScenario.expected.output.api, + bootstrap: "copied", + legacyStateRead: true, + }, + }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([existingTargetReportsBootstrap])).toContain( + `${existingManagedTargetScenario.id}: existing managed target must not report legacy bootstrap`, + ); + + const checkoutRebindScenario = findScenario("identity.missing-previous-path-rebinds-checkout"); + const checkoutRebindWithoutRegistryUpdate = { + ...checkoutRebindScenario, + expected: { + ...checkoutRebindScenario.expected, + writes: checkoutRebindScenario.expected.writes.filter( + (write) => write.target !== "registry", + ), + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([checkoutRebindWithoutRegistryUpdate])).toContain( + `${checkoutRebindScenario.id}: automatic checkout rebind must persist the checkout registry update`, + ); + + const exactPortChangeScenario = findScenario("ports.config-change-on-stopped-stack-applies"); + const exactPortChangeWithoutPersistence = { + ...exactPortChangeScenario, + expected: { + ...exactPortChangeScenario.expected, + writes: exactPortChangeScenario.expected.writes.filter( + (write) => write.target !== "managed-state", + ), + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([exactPortChangeWithoutPersistence])).toContain( + `${exactPortChangeScenario.id}: exact port change must persist assignment before runtime start`, + ); + + const engineScopedStopScenario = findScenario("reclamation.stop-is-engine-scoped"); + const engineScopedStopWithoutRunningLegacy = { + ...engineScopedStopScenario, + given: engineScopedStopScenario.given.map((fact) => + fact.kind === "legacy-state" ? { ...fact, lifecycle: "stopped" } : fact, + ), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([engineScopedStopWithoutRunningLegacy])).toContain( + `${engineScopedStopScenario.id}: engine-scoped stop requires a simultaneously running legacy stack`, + ); }); it("covers the approved identity journeys through public commands and APIs", () => { diff --git a/packages/stack/src/managed-stack-contract.ts b/packages/stack/src/managed-stack-contract.ts index 137bdc59e5..cb12df4e87 100644 --- a/packages/stack/src/managed-stack-contract.ts +++ b/packages/stack/src/managed-stack-contract.ts @@ -883,6 +883,29 @@ export const validateManagedStackContractFixtures = ( nativeQualified ? "native" : undefined; + if ( + persistedRuntime?.kind === "persisted-runtime" && + persistedAvailability?.kind === "runtime-availability" && + persistedAvailability.available && + (scenario.expected.details?.runtime !== persistedRuntime.runtime || + scenario.expected.details.auto_re_evaluated !== false || + output.json?.persisted !== true) + ) { + errors.push( + `${scenario.id}: automatic persisted runtime reuse must report persisted provenance`, + ); + } + if ( + persistedRuntime === undefined && + resolvedRuntime === "native" && + (dockerAvailability?.kind !== "runtime-availability" || + dockerAvailability.available || + typeof dockerAvailability.reason !== "string") + ) { + errors.push( + `${scenario.id}: automatic native fallback requires explicit Docker unavailability`, + ); + } if ( persistedRuntime?.kind === "persisted-runtime" && persistedAvailability?.kind === "runtime-availability" && @@ -1693,24 +1716,46 @@ export const validateManagedStackContractFixtures = ( } } - if (output.api?.rebound === true) { - const movedWorkspace = scenario.given.find( - (fact) => fact.kind === "workspace" && fact.path === actionCwd, - ); - const exactClaim = scenario.given.find( - (fact) => fact.kind === "identity-claim" && fact.scope === "checkout", - ); + const movedWorkspace = scenario.given.find( + (fact) => fact.kind === "workspace" && fact.path === actionCwd, + ); + const exactCheckoutClaim = scenario.given.find( + (fact) => fact.kind === "identity-claim" && fact.scope === "checkout", + ); + if ( + movedWorkspace?.kind === "workspace" && + movedWorkspace.previousPathAccess === "missing" && + exactCheckoutClaim?.kind === "identity-claim" && + exactCheckoutClaim.status === "exact" && + scenario.expected.outcome === "reuse" + ) { + const apiRebindMatches = + output.api === undefined || + (output.api.rebound === true && output.api.checkoutId === exactCheckoutClaim.id); + const jsonRebindMatches = + output.json === undefined || + (output.json.rebound_from === movedWorkspace.previousPath && + output.json.checkout_id === exactCheckoutClaim.id); if ( - movedWorkspace?.kind !== "workspace" || - movedWorkspace.previousPathAccess !== "missing" || movedWorkspace.previousPath === undefined || - exactClaim?.kind !== "identity-claim" || - exactClaim.status !== "exact" || - exactClaim.path !== movedWorkspace.previousPath || - output.api?.checkoutId !== exactClaim.id + exactCheckoutClaim.path !== movedWorkspace.previousPath || + !apiRebindMatches || + !jsonRebindMatches ) { errors.push(`${scenario.id}: automatic checkout rebind requires a missing previous path`); } + if ( + !scenario.expected.writes.some( + (write) => + write.target === "registry" && + write.operation === "update" && + write.id === exactCheckoutClaim.id, + ) + ) { + errors.push( + `${scenario.id}: automatic checkout rebind must persist the checkout registry update`, + ); + } } for (const fact of scenario.given) { @@ -1774,6 +1819,15 @@ export const validateManagedStackContractFixtures = ( ); } const platformQualified = failed.size === 0 && qualified.size === nativeServices.length; + if ( + platformSupported && + !platformQualified && + scenario.expected.error?.code !== "native_platform_not_qualified" + ) { + errors.push( + `${scenario.id}: supported unqualified native platform must use native_platform_not_qualified`, + ); + } if ( scenario.expected.outcome !== (platformQualified ? "report" : "error") || scenario.expected.details?.qualified !== platformQualified || @@ -1881,6 +1935,25 @@ export const validateManagedStackContractFixtures = ( ); } } + if (isManagedStartAction(scenario.when) && scenario.expected.outcome === "reuse") { + const existingStartedTarget = scenario.given.find( + (fact) => + fact.kind === "managed-target" && + fact.exists && + scenario.expected.runtimeEffects.some( + (effect) => effect.operation === "start" && effect.stackId === fact.stackId, + ), + ); + if ( + existingStartedTarget?.kind === "managed-target" && + (scenario.expected.details?.legacy_state_read !== false || + (output.api !== undefined && + (output.api.bootstrap !== "not-attempted" || output.api.legacyStateRead !== false)) || + (output.json !== undefined && output.json.bootstrap !== "not-attempted")) + ) { + errors.push(`${scenario.id}: existing managed target must not report legacy bootstrap`); + } + } const createdStackIds = scenario.expected.writes.flatMap((write) => write.target === "managed-state" && write.operation === "create" ? [write.id] : [], @@ -2478,6 +2551,25 @@ export const validateManagedStackContractFixtures = ( `${scenario.id}: exact port change must bind previous assignment and requested value`, ); } + if (scenario.expected.outcome === "update" && selection !== undefined) { + const persistenceIndex = scenario.expected.writes.findIndex( + (write) => + write.target === "managed-state" && + write.operation === "update" && + write.id === selection.stackId, + ); + const runtimeStartIndex = scenario.expected.writes.findIndex( + (write) => + write.target === "runtime-state" && + write.operation === "start" && + write.id === selection.stackId, + ); + if (persistenceIndex < 0 || runtimeStartIndex <= persistenceIndex) { + errors.push( + `${scenario.id}: exact port change must persist assignment before runtime start`, + ); + } + } } if (scenario.expected.output.json?.sticky === true) { @@ -2571,6 +2663,20 @@ export const validateManagedStackContractFixtures = ( ? [fact] : [], ); + const siblingStackIds = [...new Set(siblingAssignments.map(({ stackId }) => stackId))]; + const projectedAvoidedStackIds = scenario.expected.details?.avoided_sibling_stack_ids; + const avoidedStackIds = + Array.isArray(projectedAvoidedStackIds) && + projectedAvoidedStackIds.every((stackId): stackId is string => typeof stackId === "string") + ? projectedAvoidedStackIds + : undefined; + if ( + (siblingStackIds.length > 0 || projectedAvoidedStackIds !== undefined) && + (avoidedStackIds === undefined || + !managedStackContractStringSetEquals(siblingStackIds, avoidedStackIds)) + ) { + errors.push(`${scenario.id}: sibling allocation must bind all avoided stack IDs`); + } const projectedPorts = scenario.expected.output.api?.ports; if (!isManagedStackContractRecord(projectedPorts)) { if (siblingAssignments.length > 0) { @@ -2905,6 +3011,47 @@ export const validateManagedStackContractFixtures = ( scenario.expected.writes.some( (write) => write.target === "managed-state" && write.operation === "delete", ) || scenario.expected.runtimeEffects.some((effect) => effect.operation === "delete"); + const nonDestructiveStop = + scenario.when.interface === "cli" && + scenario.when.argv[0] === "stop" && + scenario.expected.outcome === "update" && + scenario.expected.runtimeEffects.some((effect) => effect.operation === "stop") && + !destructivelyDeletesManagedState && + !scenario.expected.writes.some( + (write) => + write.target === "registry" && + (write.operation === "delete" || write.operation === "tombstone"), + ); + if ( + nonDestructiveStop && + (scenario.expected.details?.data_preserved !== true || + scenario.expected.details.registry_record_preserved !== true || + output.human?.fields.dataPreserved !== "true" || + output.json?.data_preserved !== true) + ) { + errors.push(`${scenario.id}: non-destructive stop must report preserved data and registry`); + } + if ( + scenario.expected.details?.legacy_stack_stopped !== undefined || + output.json?.legacy_stack_stopped !== undefined + ) { + const runningLegacyState = scenario.given.find( + (fact) => fact.kind === "legacy-state" && fact.lifecycle === "running", + ); + if ( + !nonDestructiveStop || + runningLegacyState?.kind !== "legacy-state" || + scenario.expected.details?.managed_stack_stopped !== true || + scenario.expected.details.legacy_stack_stopped !== false || + scenario.expected.details.legacy_state_mutated !== false || + output.json?.managed_stack_stopped !== true || + output.json.legacy_stack_stopped !== false + ) { + errors.push( + `${scenario.id}: engine-scoped stop requires a simultaneously running legacy stack`, + ); + } + } if ( destructivelyDeletesManagedState && scenario.when.interface === "cli" && @@ -6801,13 +6948,17 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ managed_stack_stopped: true, legacy_stack_stopped: false, legacy_state_mutated: false, + data_preserved: true, + registry_record_preserved: true, }, output: { + human: { summary: "Stopped main/default", fields: { dataPreserved: "true" } }, json: { outcome: "update", stack_id: "stack-main-default", managed_stack_stopped: true, legacy_stack_stopped: false, + data_preserved: true, }, }, }, From d1d1941af4bf5efd9c5eecaf9b7e06caeed60417 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 11 Aug 2026 00:48:05 +0200 Subject: [PATCH 27/41] test(stack): enforce remaining contract invariants --- ...managed-stack-contract.integration.test.ts | 206 ++++++++++++++++++ packages/stack/src/managed-stack-contract.ts | 135 +++++++++++- 2 files changed, 331 insertions(+), 10 deletions(-) diff --git a/packages/stack/src/managed-stack-contract.integration.test.ts b/packages/stack/src/managed-stack-contract.integration.test.ts index 68ec7c9bb7..3f4f3534b1 100644 --- a/packages/stack/src/managed-stack-contract.integration.test.ts +++ b/packages/stack/src/managed-stack-contract.integration.test.ts @@ -2775,6 +2775,212 @@ describe("managed stack acceptance contract", () => { expect(validateManagedStackContractFixtures([engineScopedStopWithoutRunningLegacy])).toContain( `${engineScopedStopScenario.id}: engine-scoped stop requires a simultaneously running legacy stack`, ); + + const bootstrapCopyScenario = findScenario( + "bootstrap.first-start-copies-compatible-legacy-state", + ); + const bootstrapCopyReportsLegacyMutation = { + ...bootstrapCopyScenario, + expected: { + ...bootstrapCopyScenario.expected, + details: { ...bootstrapCopyScenario.expected.details, legacy_state_mutated: true }, + output: { + ...bootstrapCopyScenario.expected.output, + json: { + ...bootstrapCopyScenario.expected.output.json, + legacy_state_mutated: true, + }, + }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([bootstrapCopyReportsLegacyMutation])).toContain( + `${bootstrapCopyScenario.id}: bootstrap copy must not mutate legacy state`, + ); + + const pruneReportsMutableDataDeletion = { + ...pruneScenario, + expected: { + ...pruneScenario.expected, + details: { ...pruneScenario.expected.details, mutable_data_deleted: true }, + output: { + ...pruneScenario.expected.output, + human: { + ...pruneScenario.expected.output.human!, + fields: { dataDeleted: "true" }, + }, + json: { ...pruneScenario.expected.output.json, mutable_data_deleted: true }, + }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([pruneReportsMutableDataDeletion])).toContain( + `${pruneScenario.id}: prune must preserve mutable stack data`, + ); + + const directCreateScenario = findScenario("api-boundary.direct-create-stack-is-ephemeral"); + const directCreateReportsManagedSideEffects = { + ...directCreateScenario, + expected: { + ...directCreateScenario.expected, + details: { + ...directCreateScenario.expected.details, + git_inspected: true, + identity_marker_created: true, + global_registry_mutated: true, + }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([directCreateReportsManagedSideEffects])).toContain( + `${directCreateScenario.id}: direct createStack must remain isolated from managed state`, + ); + + const contextualSelectionScenario = findScenario( + "identity.same-checkout-branch-and-name-reuses-stack", + ); + const contextualSelectionWithoutCheckedOutBranch = { + ...contextualSelectionScenario, + given: contextualSelectionScenario.given.map((fact) => + fact.kind === "branch" ? { ...fact, checkedOut: false } : fact, + ), + } satisfies ManagedStackContractScenario; + expect( + validateManagedStackContractFixtures([contextualSelectionWithoutCheckedOutBranch]), + ).toContain( + `${contextualSelectionScenario.id}: contextual Git selection requires exactly one checked-out branch`, + ); + + const failedBootstrapScenario = findScenario("bootstrap.failed-copy-rolls-back"); + const failedBootstrapReportsPublishedPartialTarget = { + ...failedBootstrapScenario, + expected: { + ...failedBootstrapScenario.expected, + details: { + ...failedBootstrapScenario.expected.details, + active_target_exists: true, + registry_record_published: true, + }, + output: { + ...failedBootstrapScenario.expected.output, + api: { + ...failedBootstrapScenario.expected.output.api, + activeTargetExists: true, + registryRecordPublished: true, + }, + }, + }, + } satisfies ManagedStackContractScenario; + expect( + validateManagedStackContractFixtures([failedBootstrapReportsPublishedPartialTarget]), + ).toContain( + `${failedBootstrapScenario.id}: bootstrap rollback must leave no published partial target`, + ); + + const stableCredentialsScenario = findScenario( + "credentials.omitted-values-use-stable-defaults", + ); + const omittedCredentialsRotatePerStart = { + ...stableCredentialsScenario, + expected: { + ...stableCredentialsScenario.expected, + details: { ...stableCredentialsScenario.expected.details, generated_per_start: true }, + output: { + ...stableCredentialsScenario.expected.output, + json: { ...stableCredentialsScenario.expected.output.json, credentials_stable: false }, + }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([omittedCredentialsRotatePerStart])).toContain( + `${stableCredentialsScenario.id}: omitted credentials must reuse stable local defaults`, + ); + + const trackedMarkerScenario = findScenario("identity.fresh-clone-ignores-tracked-marker"); + const trackedMarkerReportedMutable = { + ...trackedMarkerScenario, + expected: { + ...trackedMarkerScenario.expected, + details: { + ...trackedMarkerScenario.expected.details, + tracked_marker_ignored: false, + tracked_marker_mutated: true, + }, + output: { + ...trackedMarkerScenario.expected.output, + json: { + ...trackedMarkerScenario.expected.output.json, + tracked_marker_ignored: false, + }, + }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([trackedMarkerReportedMutable])).toContain( + `${trackedMarkerScenario.id}: tracked identity marker must remain ignored and unmodified`, + ); + + const runtimeStatusScenario = findScenario("runtime.status-reports-one-stack-wide-runtime"); + const runtimeStatusReportsMixedGraph = { + ...runtimeStatusScenario, + expected: { + ...runtimeStatusScenario.expected, + details: { ...runtimeStatusScenario.expected.details, mixed_runtime: true }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([runtimeStatusReportsMixedGraph])).toContain( + `${runtimeStatusScenario.id}: runtime drift must bind running stack, persisted runtime, config, and projections`, + ); + + const folderToGitAmbiguityScenario = findScenario( + "identity.folder-to-git-ambiguous-claim-fails", + ); + const exactFolderClaimReportedAmbiguous = { + ...folderToGitAmbiguityScenario, + given: folderToGitAmbiguityScenario.given.map((fact) => + fact.kind === "identity-claim" ? { ...fact, status: "exact" } : fact, + ), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([exactFolderClaimReportedAmbiguous])).toContain( + `${folderToGitAmbiguityScenario.id}: folder-to-Git ambiguity error requires an ambiguous live project claim`, + ); + + const strictRuntimeScenario = findScenario("runtime.explicit-runtime-is-strict"); + const strictRuntimeWithoutAvailableAlternative = { + ...strictRuntimeScenario, + given: strictRuntimeScenario.given.filter( + (fact) => fact.kind !== "runtime-availability" || fact.runtime !== "native", + ), + } satisfies ManagedStackContractScenario; + expect( + validateManagedStackContractFixtures([strictRuntimeWithoutAvailableAlternative]), + ).toContain( + `${strictRuntimeScenario.id}: explicit runtime error must bind an unavailable requested runtime`, + ); + + const repeatedDeletionScenario = findScenario("reclamation.delete-repeat-is-idempotent"); + const repeatedDeletionHidesIdempotency = { + ...repeatedDeletionScenario, + expected: { + ...repeatedDeletionScenario.expected, + details: { ...repeatedDeletionScenario.expected.details, idempotent: false }, + output: { + ...repeatedDeletionScenario.expected.output, + json: { ...repeatedDeletionScenario.expected.output.json, already_deleted: false }, + }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([repeatedDeletionHidesIdempotency])).toContain( + `${repeatedDeletionScenario.id}: idempotent deletion requires a tombstoned target`, + ); + + const standaloneGitScenario = findScenario("identity.branch-create-and-switch-is-no-op"); + const standaloneGitReportsManagedDeletion = { + ...standaloneGitScenario, + expected: { + ...standaloneGitScenario.expected, + outcome: "delete", + details: { ...standaloneGitScenario.expected.details, managed_command_ran: true }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([standaloneGitReportsManagedDeletion])).toContain( + `${standaloneGitScenario.id}: standalone Git action must remain outside managed lifecycle`, + ); }); it("covers the approved identity journeys through public commands and APIs", () => { diff --git a/packages/stack/src/managed-stack-contract.ts b/packages/stack/src/managed-stack-contract.ts index cb12df4e87..445ffb59a9 100644 --- a/packages/stack/src/managed-stack-contract.ts +++ b/packages/stack/src/managed-stack-contract.ts @@ -572,6 +572,14 @@ export const validateManagedStackContractFixtures = ( ) { errors.push(`${scenario.id}: Git switch output must match its requested branch`); } + if ( + scenario.expected.writes.length === 0 && + scenario.expected.runtimeEffects.length === 0 && + (scenario.expected.outcome !== "no-op" || + scenario.expected.details?.managed_command_ran !== false) + ) { + errors.push(`${scenario.id}: standalone Git action must remain outside managed lifecycle`); + } } const isStatusOperation = @@ -651,6 +659,15 @@ export const validateManagedStackContractFixtures = ( `${scenario.id}: direct stack root inputs must agree with temporary-state behavior`, ); } + if ( + scenario.expected.details?.git_inspected !== false || + scenario.expected.details.identity_marker_created !== false || + scenario.expected.details.global_registry_mutated !== false || + scenario.expected.writes.some((write) => write.target !== "ephemeral-state") || + scenario.expected.runtimeEffects.length > 0 + ) { + errors.push(`${scenario.id}: direct createStack must remain isolated from managed state`); + } } for (const write of scenario.expected.writes) { if (write.id.trim().length === 0) { @@ -1758,6 +1775,32 @@ export const validateManagedStackContractFixtures = ( } } + if (scenario.expected.error?.code === "ambiguous_folder_to_git_identity") { + const folderToGitTransition = scenario.given.find( + (fact) => fact.kind === "identity-transition" && fact.operation === "folder-to-git", + ); + const actionWorkspace = scenario.given.find( + (fact) => fact.kind === "workspace" && fact.path === actionCwd, + ); + const ambiguousProjectClaim = scenario.given.find( + (fact) => + fact.kind === "identity-claim" && + fact.scope === "project" && + fact.status === "ambiguous" && + fact.path === actionCwd, + ); + if ( + folderToGitTransition?.kind !== "identity-transition" || + actionWorkspace?.kind !== "workspace" || + actionWorkspace.mode !== "git" || + ambiguousProjectClaim?.kind !== "identity-claim" + ) { + errors.push( + `${scenario.id}: folder-to-Git ambiguity error requires an ambiguous live project claim`, + ); + } + } + for (const fact of scenario.given) { if (fact.kind !== "native-qualification") { continue; @@ -1891,6 +1934,19 @@ export const validateManagedStackContractFixtures = ( const writesIdentityMarker = scenario.expected.writes.some( (write) => write.target === "identity-marker", ); + const trackedIdentityMarker = scenario.given.find( + (fact) => fact.kind === "git-state" && fact.trackedIdentityMarker === true, + ); + if ( + trackedIdentityMarker?.kind === "git-state" && + (scenario.expected.details?.tracked_marker_ignored !== true || + scenario.expected.details.tracked_marker_mutated !== false || + scenario.expected.details.git_index_mutated !== false || + output.json?.tracked_marker_ignored !== true || + writesIdentityMarker) + ) { + errors.push(`${scenario.id}: tracked identity marker must remain ignored and unmodified`); + } if (writesIdentityMarker) { if ( scenario.given.some( @@ -2020,6 +2076,12 @@ export const validateManagedStackContractFixtures = ( ); } } + if ( + scenario.expected.details?.legacy_state_mutated !== false || + (output.json !== undefined && output.json.legacy_state_mutated !== false) + ) { + errors.push(`${scenario.id}: bootstrap copy must not mutate legacy state`); + } } for (const effect of scenario.expected.runtimeEffects) { @@ -2138,6 +2200,14 @@ export const validateManagedStackContractFixtures = ( ); } + const declaredBranches = scenario.given.filter((fact) => fact.kind === "branch"); + const checkedOutBranches = declaredBranches.filter((fact) => fact.checkedOut); + if (declaredBranches.length > 0 && checkedOutBranches.length !== 1) { + errors.push( + `${scenario.id}: contextual Git selection requires exactly one checked-out branch`, + ); + } + if (checkedOutBranch?.kind === "branch") { const createsSelectedContext = scenario.expected.writes.some( (write) => @@ -2791,6 +2861,7 @@ export const validateManagedStackContractFixtures = ( output.json?.runtime !== persistedRuntime.runtime || output.json.configured_runtime !== configuredRuntime.runtime || output.json.drift !== true || + scenario.expected.details?.mixed_runtime !== false || !isManagedStackContractRecord(projectedServices) || projectedServices.runtime !== persistedRuntime.runtime ) { @@ -2897,11 +2968,17 @@ export const validateManagedStackContractFixtures = ( const availability = scenario.given.find( (fact) => fact.kind === "runtime-availability" && fact.runtime === unavailableRuntime, ); + const fallbackRuntime = unavailableRuntime === "docker" ? "native" : "docker"; + const fallbackAvailability = scenario.given.find( + (fact) => fact.kind === "runtime-availability" && fact.runtime === fallbackRuntime, + ); if ( explicitRequest?.kind !== "runtime-request" || availability?.kind !== "runtime-availability" || availability.available || availability.reason === undefined || + fallbackAvailability?.kind !== "runtime-availability" || + !fallbackAvailability.available || output.json?.requested_runtime !== unavailableRuntime || output.json?.reason !== availability.reason || output.json?.fallback_attempted !== false || @@ -3005,6 +3082,19 @@ export const validateManagedStackContractFixtures = ( `${scenario.id}: bootstrap rollback requires a compatible stopped legacy source`, ); } + if ( + scenario.expected.details?.active_target_exists !== false || + scenario.expected.details.registry_record_published !== false || + output.api?.activeTargetExists !== false || + output.api?.registryRecordPublished !== false || + scenario.expected.writes.some( + (write) => + (write.target === "managed-state" && write.operation !== "delete") || + (write.target === "registry" && write.operation === "publish"), + ) + ) { + errors.push(`${scenario.id}: bootstrap rollback must leave no published partial target`); + } } const destructivelyDeletesManagedState = @@ -3061,19 +3151,22 @@ export const validateManagedStackContractFixtures = ( errors.push(`${scenario.id}: destructive stop requires --no-backup`); } - if (scenario.expected.details?.idempotent === true || output.json?.already_deleted === true) { + const tombstonedTarget = scenario.given.find( + (fact) => + fact.kind === "managed-record" && + fact.status === "tombstoned" && + fact.stackId === explicitActionStackId, + ); + const claimsIdempotentDeletion = + scenario.expected.details?.idempotent === true || output.json?.already_deleted === true; + if (tombstonedTarget?.kind === "managed-record" || claimsIdempotentDeletion) { if ( - typeof explicitActionStackId !== "string" || - !scenario.given.some( - (fact) => - fact.kind === "managed-record" && - fact.stackId === explicitActionStackId && - fact.status === "tombstoned", - ) || + tombstonedTarget?.kind !== "managed-record" || scenario.expected.outcome !== "no-op" || scenario.expected.details?.tombstoned !== true || scenario.expected.details?.idempotent !== true || output.json?.tombstoned !== true || + output.json?.already_deleted !== true || scenario.expected.writes.length > 0 || scenario.expected.runtimeEffects.length > 0 ) { @@ -3148,6 +3241,18 @@ export const validateManagedStackContractFixtures = ( ); } } + const stableLocalCredentials = scenario.given.find( + (fact) => fact.kind === "credential-state" && fact.source === "local-default", + ); + if ( + stableLocalCredentials?.kind === "credential-state" && + (scenario.expected.details?.credential_values_id !== stableLocalCredentials.valuesId || + scenario.expected.details.generated_per_start !== false || + output.json?.credentials_source !== "local-default" || + output.json?.credentials_stable !== true) + ) { + errors.push(`${scenario.id}: omitted credentials must reuse stable local defaults`); + } if ( changedCredentials?.kind === "credential-state" && changedCredentials.previousValuesId === changedCredentials.valuesId @@ -3228,9 +3333,17 @@ export const validateManagedStackContractFixtures = ( if ( scenario.when.interface === "cli" && scenario.when.argv[0] === "stack" && - scenario.when.argv[1] === "prune" && - scenario.expected.details?.mutable_data_deleted === false + scenario.when.argv[1] === "prune" ) { + if ( + scenario.expected.details?.mutable_data_deleted !== false || + output.human?.fields.dataDeleted !== "false" || + output.json?.mutable_data_deleted !== false || + scenario.expected.writes.some((write) => write.target === "managed-state") || + scenario.expected.runtimeEffects.some((effect) => effect.operation === "delete") + ) { + errors.push(`${scenario.id}: prune must preserve mutable stack data`); + } for (const write of scenario.expected.writes) { if (write.target !== "registry" || write.operation !== "delete") { continue; @@ -3710,6 +3823,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ ...freshManagedStartFacts("stack-feat-a-default"), { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, { kind: "branch", name: "main", contextId: "context-main", checkedOut: false }, + { kind: "branch", name: "feat-a", contextId: "context-feat-a", checkedOut: true }, { kind: "git-state", workspacePath: "checkout-a", @@ -6860,6 +6974,7 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ writes: [], runtimeEffects: [], details: { + managed_command_ran: false, stack_data_preserved: true, stack_orphaned: true, orphaned_stack_id: "stack-feat-default", From de4e22ca31f5ab05e01e7803fc21e2b45479b846 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 11 Aug 2026 01:21:26 +0200 Subject: [PATCH 28/41] test(stack): complete observable contract bindings --- .../0015-managed-stack-contract-fixtures.md | 6 +- ...managed-stack-contract.integration.test.ts | 327 ++++++++++++++++++ packages/stack/src/managed-stack-contract.ts | 311 ++++++++++++++++- 3 files changed, 628 insertions(+), 16 deletions(-) diff --git a/docs/adr/0015-managed-stack-contract-fixtures.md b/docs/adr/0015-managed-stack-contract-fixtures.md index 68a078a3c1..6ddaa2fdc8 100644 --- a/docs/adr/0015-managed-stack-contract-fixtures.md +++ b/docs/adr/0015-managed-stack-contract-fixtures.md @@ -99,13 +99,15 @@ target-existence facts cannot contradict stack facts; running-source and credential-drift reports must begin from running sources, idempotent deletion must begin from a tombstone, orphan deletion must target orphaned state, and failed-copy cleanup may delete only a target proven absent before the attempt; direct-stack root facts must agree with -caller-supplied root inputs and temporary-state behavior; +caller-supplied root inputs and temporary-state behavior, and disposing a direct stack whose roots +were omitted must delete its declared temporary state; every state write and runtime effect must identify its target; contextual CLI stack results must bind their output to a selected target; Git identity writes must use the correct common or worktree scope, context writes must name the active branch as owner, and adapters cannot recreate an identity already declared by a checkout; new Git-derived contexts, manual ref replacement, branch deletion and recreation, detached-commit reuse, and selected linked worktrees must declare the relevant Git -state or transition; selected contexts must agree with the active Git branch or an explicit +state or transition; comparisons between branch contexts at one commit must declare both branch +refs at that commit; selected contexts must agree with the active Git branch or an explicit checkout-scoped claim; ordinary folders must write their full untracked identity marker to the action workspace on creation and resolve it on reuse, while Git workspaces cannot trust that local marker; copied-branch evidence must agree with whether the original branch still exists, read-only diff --git a/packages/stack/src/managed-stack-contract.integration.test.ts b/packages/stack/src/managed-stack-contract.integration.test.ts index 3f4f3534b1..acf61fbc13 100644 --- a/packages/stack/src/managed-stack-contract.integration.test.ts +++ b/packages/stack/src/managed-stack-contract.integration.test.ts @@ -2981,6 +2981,332 @@ describe("managed stack acceptance contract", () => { expect(validateManagedStackContractFixtures([standaloneGitReportsManagedDeletion])).toContain( `${standaloneGitScenario.id}: standalone Git action must remain outside managed lifecycle`, ); + + const recreatedBranchScenario = findScenario("identity.branch-delete-recreate-creates-context"); + const recreatedBranchOrphansUnrelatedContext = { + ...recreatedBranchScenario, + expected: { + ...recreatedBranchScenario.expected, + details: { ...recreatedBranchScenario.expected.details, orphaned_context_id: "context-x" }, + output: { + ...recreatedBranchScenario.expected.output, + json: { + ...recreatedBranchScenario.expected.output.json, + orphaned_context_id: "context-x", + }, + }, + }, + } satisfies ManagedStackContractScenario; + expect( + validateManagedStackContractFixtures([recreatedBranchOrphansUnrelatedContext]), + ).toContain( + `${recreatedBranchScenario.id}: branch recreation must orphan the displaced context`, + ); + + const incompatibleLegacyScenario = findScenario("bootstrap.incompatible-legacy-starts-fresh"); + const incompatibleLegacyReportsMutation = { + ...incompatibleLegacyScenario, + expected: { + ...incompatibleLegacyScenario.expected, + details: { ...incompatibleLegacyScenario.expected.details, legacy_state_mutated: true }, + output: { + ...incompatibleLegacyScenario.expected.output, + json: { + ...incompatibleLegacyScenario.expected.output.json, + legacy_state_mutated: true, + }, + }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([incompatibleLegacyReportsMutation])).toContain( + `${incompatibleLegacyScenario.id}: fresh bootstrap must not copy or mutate legacy state`, + ); + + const restartPersistedCredentialsScenario = findScenario( + "credentials.unchanged-values-survive-restart", + ); + const persistedCredentialsRotate = { + ...restartPersistedCredentialsScenario, + expected: { + ...restartPersistedCredentialsScenario.expected, + details: { + ...restartPersistedCredentialsScenario.expected.details, + credentials_rotated: true, + }, + output: { + ...restartPersistedCredentialsScenario.expected.output, + json: { + ...restartPersistedCredentialsScenario.expected.output.json, + credentials_unchanged: false, + }, + }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([persistedCredentialsRotate])).toContain( + `${restartPersistedCredentialsScenario.id}: persisted credentials must survive restart unchanged`, + ); + + const freshAutomaticRuntimeScenario = findScenario("runtime.auto-prefers-docker"); + const freshAutomaticRuntimeIsNotPersisted = { + ...freshAutomaticRuntimeScenario, + expected: { + ...freshAutomaticRuntimeScenario.expected, + details: { ...freshAutomaticRuntimeScenario.expected.details, persisted: false }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([freshAutomaticRuntimeIsNotPersisted])).toContain( + `${freshAutomaticRuntimeScenario.id}: fresh automatic runtime selection must be persisted`, + ); + + const resolvedNamesScenario = findScenario( + "identity.valid-stack-names-resolve-deterministically", + ); + if (resolvedNamesScenario.when.interface !== "managed-api") { + throw new Error( + "identity.valid-stack-names-resolve-deterministically managed API fixture is required", + ); + } + const resolvedNamesIncludeInvalidName = { + ...resolvedNamesScenario, + given: resolvedNamesScenario.given.map((fact) => + fact.kind === "stack-names" ? { ...fact, names: ["default", "Review_42"] } : fact, + ), + when: { + ...resolvedNamesScenario.when, + input: { ...resolvedNamesScenario.when.input, stackNames: ["default", "Review_42"] }, + }, + expected: { + ...resolvedNamesScenario.expected, + details: { + default: "stack-feat-default", + Review_42: "stack-feat-review-42", + }, + output: { + ...resolvedNamesScenario.expected.output, + api: { + default: { contextId: "context-feat", stackId: "stack-feat-default" }, + Review_42: { contextId: "context-feat", stackId: "stack-feat-review-42" }, + }, + }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([resolvedNamesIncludeInvalidName])).toContain( + `${resolvedNamesScenario.id}: requested stack names must match their fact and projected results`, + ); + + const runtimeInputScenario = findScenario("runtime.explicit-api-overrides-auto"); + const authInputScenario = findScenario("credentials.configured-values-are-authoritative"); + const portInputScenario = findScenario("ports.explicit-free-port-is-used"); + if ( + runtimeInputScenario.when.interface !== "managed-api" || + authInputScenario.when.interface !== "managed-api" || + portInputScenario.when.interface !== "managed-api" + ) { + throw new Error("startStack managed API fixtures are required"); + } + const invalidStartInputs = [ + { + ...runtimeInputScenario, + when: { + ...runtimeInputScenario.when, + input: { ...runtimeInputScenario.when.input, runtime: 42 }, + }, + } satisfies ManagedStackContractScenario, + { + ...authInputScenario, + when: { + ...authInputScenario.when, + input: { ...authInputScenario.when.input, auth: 42 }, + }, + } satisfies ManagedStackContractScenario, + { + ...portInputScenario, + when: { + ...portInputScenario.when, + input: { ...portInputScenario.when.input, portIntents: "invalid" }, + }, + } satisfies ManagedStackContractScenario, + ]; + for (const invalidStartInput of invalidStartInputs) { + expect(validateManagedStackContractFixtures([invalidStartInput])).toContain( + `${invalidStartInput.id}: managed action must use a declared public method`, + ); + } + + const nativeAutomaticScenario = findScenario("runtime.auto-selects-fully-qualified-native"); + const nativeAutomaticReportsPartialGraph = { + ...nativeAutomaticScenario, + expected: { + ...nativeAutomaticScenario.expected, + details: { + ...nativeAutomaticScenario.expected.details, + qualified_service_count: 1, + mixed_runtime: true, + }, + output: { + ...nativeAutomaticScenario.expected.output, + api: { ...nativeAutomaticScenario.expected.output.api, qualifiedServiceCount: 1 }, + }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([nativeAutomaticReportsPartialGraph])).toContain( + `${nativeAutomaticScenario.id}: automatic native selection must bind the full qualified graph`, + ); + + const symlinkAliasScenario = findScenario("identity.symlink-alias-reuses-checkout"); + const symlinkAliasReportsUnrelatedPath = { + ...symlinkAliasScenario, + expected: { + ...symlinkAliasScenario.expected, + output: { + ...symlinkAliasScenario.expected.output, + json: { ...symlinkAliasScenario.expected.output.json, canonical_path: "/other/project" }, + }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([symlinkAliasReportsUnrelatedPath])).toContain( + `${symlinkAliasScenario.id}: symlink alias must report its canonical checkout path`, + ); + + const firstDeletionScenario = findScenario("reclamation.delete-orphan-by-stack-id"); + const firstDeletionHidesTombstone = { + ...firstDeletionScenario, + expected: { + ...firstDeletionScenario.expected, + details: { ...firstDeletionScenario.expected.details, tombstoned: false }, + output: { + ...firstDeletionScenario.expected.output, + human: { + ...firstDeletionScenario.expected.output.human!, + fields: { + ...firstDeletionScenario.expected.output.human!.fields, + tombstoned: "false", + }, + }, + json: { ...firstDeletionScenario.expected.output.json, tombstoned: false }, + }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([firstDeletionHidesTombstone])).toContain( + `${firstDeletionScenario.id}: registry tombstone must be reported by every projection`, + ); + + const injectedServiceScenario = findScenario( + "api-boundary.managed-api-accepts-injected-repository", + ); + const injectedServiceRequiresCli = { + ...injectedServiceScenario, + expected: { + ...injectedServiceScenario.expected, + details: { ...injectedServiceScenario.expected.details, cli_required: true }, + output: { + ...injectedServiceScenario.expected.output, + api: { ...injectedServiceScenario.expected.output.api, cliRequired: true }, + }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([injectedServiceRequiresCli])).toContain( + `${injectedServiceScenario.id}: injected repository and state root must match the observed managed service`, + ); + + const sameCommitScenario = findScenario( + "identity.same-commit-different-branches-are-independent", + ); + const sameCommitScenarioHasDifferentCheckedOutCommit = { + ...sameCommitScenario, + given: sameCommitScenario.given.map((fact) => + fact.kind === "git-state" ? { ...fact, commit: "unrelated-commit" } : fact, + ), + } satisfies ManagedStackContractScenario; + expect( + validateManagedStackContractFixtures([sameCommitScenarioHasDifferentCheckedOutCommit]), + ).toContain( + `${sameCommitScenario.id}: branch comparison must prove both refs share the checked-out commit`, + ); + + const ordinaryFolderScenario = findScenario( + "identity.non-git-folder-first-start-persists-identity", + ); + const ordinaryFolderTracksMarker = { + ...ordinaryFolderScenario, + expected: { + ...ordinaryFolderScenario.expected, + details: { ...ordinaryFolderScenario.expected.details, identity_marker_tracked: true }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([ordinaryFolderTracksMarker])).toContain( + `${ordinaryFolderScenario.id}: ordinary-folder identity marker must remain untracked`, + ); + + const runningLegacyNoMutationScenario = findScenario( + "bootstrap.running-legacy-source-fails-without-mutation", + ); + const runningLegacyReportsPartialTarget = { + ...runningLegacyNoMutationScenario, + expected: { + ...runningLegacyNoMutationScenario.expected, + details: { + ...runningLegacyNoMutationScenario.expected.details, + managed_target_published: true, + partial_state: true, + }, + output: { + ...runningLegacyNoMutationScenario.expected.output, + json: { + ...runningLegacyNoMutationScenario.expected.output.json, + managed_target_published: true, + }, + }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([runningLegacyReportsPartialTarget])).toContain( + `${runningLegacyNoMutationScenario.id}: running legacy error must leave no partial managed target`, + ); + + const copiedBranchWarningScenario = findScenario( + "identity.branch-copy-read-only-does-not-write", + ); + const copiedBranchWarningReportsUnrelatedOwners = { + ...copiedBranchWarningScenario, + expected: { + ...copiedBranchWarningScenario.expected, + output: { + ...copiedBranchWarningScenario.expected.output, + json: { + ...copiedBranchWarningScenario.expected.output.json, + branch: "unrelated-branch", + owner: "unrelated-owner", + }, + }, + }, + } satisfies ManagedStackContractScenario; + expect( + validateManagedStackContractFixtures([copiedBranchWarningReportsUnrelatedOwners]), + ).toContain( + `${copiedBranchWarningScenario.id}: copied branch warning must bind observed branch ownership`, + ); + + const directDisposalScenario = findScenario( + "api-boundary.direct-dispose-removes-temporary-roots", + ); + const directDisposalLeaksTemporaryRoots = { + ...directDisposalScenario, + expected: { + ...directDisposalScenario.expected, + writes: [], + details: { ...directDisposalScenario.expected.details, temporary_roots_removed: false }, + output: { + ...directDisposalScenario.expected.output, + api: { + ...directDisposalScenario.expected.output.api, + temporaryRootsRemoved: false, + }, + }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([directDisposalLeaksTemporaryRoots])).toContain( + `${directDisposalScenario.id}: direct stack disposal must remove omitted temporary roots`, + ); }); it("covers the approved identity journeys through public commands and APIs", () => { @@ -3267,6 +3593,7 @@ describe("managed stack acceptance contract", () => { [ "api-boundary.cli-projects-shared-managed-results", "api-boundary.direct-create-stack-is-ephemeral", + "api-boundary.direct-dispose-removes-temporary-roots", "api-boundary.managed-api-accepts-injected-repository", "api-boundary.managed-api-accepts-isolated-state-root", "api-boundary.managed-surface-is-node-and-bun-portable", diff --git a/packages/stack/src/managed-stack-contract.ts b/packages/stack/src/managed-stack-contract.ts index 445ffb59a9..fe70861f88 100644 --- a/packages/stack/src/managed-stack-contract.ts +++ b/packages/stack/src/managed-stack-contract.ts @@ -95,6 +95,11 @@ export type ManagedStackContractFact = readonly contextId: string; readonly checkedOut: boolean; } + | { + readonly kind: "branch-ref"; + readonly name: string; + readonly commit: string; + } | { readonly kind: "stack"; readonly name: string; @@ -188,6 +193,13 @@ export type ManagedStackContractFact = readonly kind: "direct-stack-options"; readonly roots: "explicit" | "omitted"; } + | { + readonly kind: "direct-stack-state"; + readonly handle: string; + readonly stateId: string; + readonly roots: "temporary"; + readonly lifecycle: "created"; + } | { readonly kind: "managed-api-options"; readonly stateRoot: "default" | "isolated"; @@ -227,7 +239,7 @@ type ManagedStackContractWrite = } | { readonly target: "ephemeral-state"; - readonly operation: "create"; + readonly operation: "create" | "delete"; readonly id: string; } | { @@ -497,15 +509,27 @@ export const validateManagedStackContractFixtures = ( typeof input.cwd === "string" && typeof input.stackName === "string" && typeof input.contenders === "number") || - (method === "startStack" && typeof input.stackId === "string"); + (method === "startStack" && + typeof input.stackId === "string" && + (input.auth === undefined || typeof input.auth === "string") && + (input.injectCopyFailure === undefined || typeof input.injectCopyFailure === "boolean") && + (input.portIntents === undefined || isManagedStackContractRecord(input.portIntents)) && + (input.runtime === undefined || + input.runtime === "auto" || + input.runtime === "docker" || + input.runtime === "native")); const inputUsesOnlyDeclaredKeys = Object.keys(input).every((key) => allowedInputKeys[method]?.has(key), ); if (!managedMethods.has(method) || !inputMatchesMethod || !inputUsesOnlyDeclaredKeys) { errors.push(`${scenario.id}: managed action must use a declared public method`); } - } else if (scenario.when.interface === "stack-api" && scenario.when.method !== "createStack") { - errors.push(`${scenario.id}: direct stack action must use createStack`); + } else if ( + scenario.when.interface === "stack-api" && + scenario.when.method !== "createStack" && + scenario.when.method !== "dispose" + ) { + errors.push(`${scenario.id}: direct stack action must use createStack or dispose`); } if (scenario.when.interface === "cli") { @@ -669,6 +693,33 @@ export const validateManagedStackContractFixtures = ( errors.push(`${scenario.id}: direct createStack must remain isolated from managed state`); } } + if (scenario.when.interface === "stack-api" && scenario.when.method === "dispose") { + const handle = scenario.when.input.handle; + const directState = scenario.given.find( + (fact) => fact.kind === "direct-stack-state" && fact.handle === handle, + ); + if ( + typeof handle !== "string" || + directState?.kind !== "direct-stack-state" || + directState.roots !== "temporary" || + directState.lifecycle !== "created" || + scenario.expected.outcome !== "delete" || + !scenario.expected.writes.some( + (write) => + write.target === "ephemeral-state" && + write.operation === "delete" && + write.id === directState.stateId, + ) || + scenario.expected.writes.some((write) => write.target !== "ephemeral-state") || + scenario.expected.runtimeEffects.length > 0 || + scenario.expected.details?.temporary_roots_removed !== true || + output.api?.handle !== handle || + output.api.disposed !== true || + output.api.temporaryRootsRemoved !== true + ) { + errors.push(`${scenario.id}: direct stack disposal must remove omitted temporary roots`); + } + } for (const write of scenario.expected.writes) { if (write.id.trim().length === 0) { errors.push(`${scenario.id}: ${write.target} write requires a target ID`); @@ -900,6 +951,26 @@ export const validateManagedStackContractFixtures = ( nativeQualified ? "native" : undefined; + if ( + persistedRuntime === undefined && + resolvedRuntime !== undefined && + scenario.expected.outcome !== "error" && + scenario.expected.details?.persisted !== true + ) { + errors.push(`${scenario.id}: fresh automatic runtime selection must be persisted`); + } + if ( + resolvedRuntime === "native" && + nativeQualification?.kind === "native-qualification" && + (scenario.expected.details?.qualified_service_count !== + nativeQualification.qualifiedServices.length || + scenario.expected.details.mixed_runtime !== false || + output.api?.qualifiedServiceCount !== nativeQualification.qualifiedServices.length) + ) { + errors.push( + `${scenario.id}: automatic native selection must bind the full qualified graph`, + ); + } if ( persistedRuntime?.kind === "persisted-runtime" && persistedAvailability?.kind === "runtime-availability" && @@ -1039,7 +1110,9 @@ export const validateManagedStackContractFixtures = ( write.operation === "create" && write.id === repositoryId, ) || - output.api?.repository !== repositoryId + output.api?.repository !== repositoryId || + scenario.expected.details?.cli_required !== false || + output.api.cliRequired !== false ) { errors.push( `${scenario.id}: injected repository and state root must match the observed managed service`, @@ -1058,6 +1131,9 @@ export const validateManagedStackContractFixtures = ( if ( !Array.isArray(requestedNames) || !requestedNames.every((name) => typeof name === "string") || + !requestedNames.every( + (name) => typeof name === "string" && managedStackNamePattern.test(name), + ) || declaredNames?.kind !== "stack-names" || !managedStackContractStringSetEquals(requestedNames, declaredNames.names) || !managedStackContractStringSetEquals(requestedNames, detailKeys) || @@ -1394,6 +1470,9 @@ export const validateManagedStackContractFixtures = ( declaredIds.add(fact.previousValuesId); } break; + case "direct-stack-state": + declaredIds.add(fact.stateId); + break; case "identity-claim": if (fact.status !== "absent") { declaredIds.add(fact.id); @@ -1513,6 +1592,27 @@ export const validateManagedStackContractFixtures = ( `${scenario.id}: copied branch transition must match live original and checked-out branch facts`, ); } + if (scenario.expected.warning?.code === "copied_branch_context_conflict") { + const originalClaim = scenario.given.find( + (fact) => + fact.kind === "identity-claim" && + fact.scope === "context" && + fact.status === "exact" && + fact.owner === branchCopy.from, + ); + if ( + typeof branchCopy.from !== "string" || + typeof branchCopy.to !== "string" || + originalClaim?.kind !== "identity-claim" || + scenario.expected.warning.message !== + `${branchCopy.to} copied ${originalClaim.id} from ${branchCopy.from}` || + output.json?.branch !== branchCopy.to || + output.json.owner !== branchCopy.from || + output.json.context_id !== originalClaim.id + ) { + errors.push(`${scenario.id}: copied branch warning must bind observed branch ownership`); + } + } } if ( scenario.when.interface === "managed-api" && @@ -1532,6 +1632,28 @@ export const validateManagedStackContractFixtures = ( const branchRename = scenario.given.find( (fact) => fact.kind === "identity-transition" && fact.operation === "branch-rename", ); + const branchRecreation = scenario.given.find( + (fact) => fact.kind === "identity-transition" && fact.operation === "branch-delete-recreate", + ); + if (branchRecreation?.kind === "identity-transition") { + const displacedContext = scenario.given.find( + (fact) => + fact.kind === "identity-claim" && + fact.scope === "context" && + fact.status === "absent" && + fact.owner === branchRecreation.from, + ); + if ( + typeof branchRecreation.from !== "string" || + typeof branchRecreation.to !== "string" || + branchRecreation.from !== branchRecreation.to || + displacedContext?.kind !== "identity-claim" || + scenario.expected.details?.orphaned_context_id !== displacedContext.id || + output.json?.orphaned_context_id !== displacedContext.id + ) { + errors.push(`${scenario.id}: branch recreation must orphan the displaced context`); + } + } if (branchRename?.kind === "identity-transition") { const renamedContextWrite = scenario.expected.writes.find( (write) => @@ -1567,6 +1689,63 @@ export const validateManagedStackContractFixtures = ( ) { errors.push(`${scenario.id}: ref replacement target must match the action workspace commit`); } + const branchRefs = scenario.given.filter((fact) => fact.kind === "branch-ref"); + if (branchRefs.length > 0) { + const comparedBranches = scenario.given.filter( + (fact) => + fact.kind === "branch" && + !fact.checkedOut && + branchRefs.some((ref) => ref.name === fact.name), + ); + const comparedBranch = comparedBranches.length === 1 ? comparedBranches[0] : undefined; + const checkedOutRef = + checkedOutBranch?.kind === "branch" + ? branchRefs.find((fact) => fact.name === checkedOutBranch.name) + : undefined; + const comparedRef = + comparedBranch?.kind === "branch" + ? branchRefs.find((fact) => fact.name === comparedBranch.name) + : undefined; + if ( + branchRefs.length !== 2 || + comparedBranches.length !== 1 || + actionGitState?.kind !== "git-state" || + checkedOutBranch?.kind !== "branch" || + comparedBranch?.kind !== "branch" || + checkedOutRef?.kind !== "branch-ref" || + comparedRef?.kind !== "branch-ref" || + checkedOutRef.commit !== actionGitState.commit || + comparedRef.commit !== actionGitState.commit || + output.api?.otherContextId !== comparedBranch.contextId + ) { + errors.push( + `${scenario.id}: branch comparison must prove both refs share the checked-out commit`, + ); + } + } + const symlinkAlias = scenario.given.find( + (fact) => fact.kind === "identity-transition" && fact.operation === "symlink-alias", + ); + if (symlinkAlias?.kind === "identity-transition") { + const aliasWorkspace = scenario.given.find( + (fact) => fact.kind === "workspace" && fact.path === symlinkAlias.to, + ); + const canonicalClaim = scenario.given.find( + (fact) => + fact.kind === "identity-claim" && + fact.scope === "checkout" && + fact.status === "exact" && + fact.path === symlinkAlias.from, + ); + if ( + aliasWorkspace?.kind !== "workspace" || + aliasWorkspace.canonicalPath !== symlinkAlias.from || + canonicalClaim?.kind !== "identity-claim" || + output.json?.canonical_path !== symlinkAlias.from + ) { + errors.push(`${scenario.id}: symlink alias must report its canonical checkout path`); + } + } if (scenario.expected.error?.code === "ambiguous_context_owner") { const projectedContextId = output.json?.context_id; const projectedBranches = output.json?.branches; @@ -1959,6 +2138,9 @@ export const validateManagedStackContractFixtures = ( ) { errors.push(`${scenario.id}: Git workspace identity must use Git-local metadata`); } + if (scenario.expected.details?.identity_marker_tracked !== false) { + errors.push(`${scenario.id}: ordinary-folder identity marker must remain untracked`); + } } if (scenario.expected.outcome !== "create") { @@ -2044,6 +2226,21 @@ export const validateManagedStackContractFixtures = ( `${scenario.id}: managed creation must declare legacy state absent or incompatible`, ); } + if ( + legacyState?.kind === "legacy-state" && + legacyState.lifecycle === "stopped" && + (legacyState.database === "incompatible" || + legacyState.storage === "incompatible" || + legacyState.credentials === "incompatible") && + (scenario.expected.details?.legacy_state_mutated !== false || + output.json?.legacy_state_mutated !== false || + scenario.expected.writes.some( + (write) => write.target === "managed-state" && write.operation === "copy", + ) || + scenario.expected.runtimeEffects.some((effect) => effect.operation === "copy")) + ) { + errors.push(`${scenario.id}: fresh bootstrap must not copy or mutate legacy state`); + } } const copiedStackIds = new Set( @@ -2991,19 +3188,30 @@ export const validateManagedStackContractFixtures = ( } } - if (scenario.expected.error?.code === "legacy_source_running") { - const legacySource = scenario.given.find((fact) => fact.kind === "legacy-state"); - const absentTarget = scenario.given.find( - (fact) => fact.kind === "managed-target" && fact.exists === false, - ); + const legacySource = scenario.given.find((fact) => fact.kind === "legacy-state"); + const absentManagedTarget = scenario.given.find( + (fact) => fact.kind === "managed-target" && fact.exists === false, + ); + if ( + scenario.expected.error?.code === "legacy_source_running" || + (isManagedStartAction(scenario.when) && + legacySource?.kind === "legacy-state" && + legacySource.lifecycle === "running" && + absentManagedTarget?.kind === "managed-target") + ) { if ( legacySource?.kind !== "legacy-state" || legacySource.lifecycle !== "running" || - absentTarget?.kind !== "managed-target" || + absentManagedTarget?.kind !== "managed-target" || scenario.given.some( (fact) => - fact.kind === "managed-target" && fact.stackId === absentTarget.stackId && fact.exists, + fact.kind === "managed-target" && + absentManagedTarget?.kind === "managed-target" && + fact.stackId === absentManagedTarget.stackId && + fact.exists, ) || + scenario.expected.outcome !== "error" || + scenario.expected.error?.code !== "legacy_source_running" || scenario.expected.writes.length > 0 || scenario.expected.runtimeEffects.length > 0 ) { @@ -3011,6 +3219,15 @@ export const validateManagedStackContractFixtures = ( `${scenario.id}: running legacy error requires a running source and absent target`, ); } + if ( + scenario.expected.details?.legacy_source_stopped !== false || + scenario.expected.details.managed_target_published !== false || + scenario.expected.details.partial_state !== false || + output.json?.legacy_source_stopped !== false || + output.json.managed_target_published !== false + ) { + errors.push(`${scenario.id}: running legacy error must leave no partial managed target`); + } const configuredPort = scenario.given.find( (fact) => fact.kind === "config-port" && fact.intent === "exact", @@ -3253,6 +3470,17 @@ export const validateManagedStackContractFixtures = ( ) { errors.push(`${scenario.id}: omitted credentials must reuse stable local defaults`); } + const persistedCredentials = scenario.given.find( + (fact) => fact.kind === "credential-state" && fact.source === "persisted", + ); + if ( + persistedCredentials?.kind === "credential-state" && + (scenario.expected.details?.credential_values_id !== persistedCredentials.valuesId || + scenario.expected.details.credentials_rotated !== false || + output.json?.credentials_unchanged !== true) + ) { + errors.push(`${scenario.id}: persisted credentials must survive restart unchanged`); + } if ( changedCredentials?.kind === "credential-state" && changedCredentials.previousValuesId === changedCredentials.valuesId @@ -3525,6 +3753,15 @@ export const validateManagedStackContractFixtures = ( ) { errors.push(`${scenario.id}: registry tombstone requires managed-state deletion`); } + if ( + write.target === "registry" && + write.operation === "tombstone" && + (scenario.expected.details?.tombstoned !== true || + (output.json !== undefined && output.json.tombstoned !== true) || + (output.human !== undefined && output.human.fields.tombstoned !== "true")) + ) { + errors.push(`${scenario.id}: registry tombstone must be reported by every projection`); + } const requiredRuntimeOperation = write.target === "runtime-state" && write.operation === "start" @@ -3991,6 +4228,8 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, { kind: "branch", name: "main", contextId: "context-main", checkedOut: false }, { kind: "branch", name: "feat-a", contextId: "context-feat", checkedOut: true }, + { kind: "branch-ref", name: "main", commit: "shared-commit" }, + { kind: "branch-ref", name: "feat-a", commit: "shared-commit" }, { kind: "stack", name: "default", @@ -5790,7 +6029,12 @@ const additionalPortContractFixtures = defineManagedStackContractFixtures([ }, writes: [], runtimeEffects: [], - details: { allocation_attempted: false, legacy_source_stopped: false }, + details: { + allocation_attempted: false, + legacy_source_stopped: false, + managed_target_published: false, + partial_state: false, + }, output: { json: { outcome: "error", @@ -5798,6 +6042,8 @@ const additionalPortContractFixtures = defineManagedStackContractFixtures([ port: 54321, config_key: "api.port", allocation_attempted: false, + legacy_source_stopped: false, + managed_target_published: false, recovery: ["Stop the legacy stack, then retry supabase start --experimental"], }, }, @@ -5968,7 +6214,12 @@ const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ { target: "runtime-state", operation: "start", id: "stack-main-default" }, ], runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], - details: { resolved_runtime: "native", qualified_service_count: 13, mixed_runtime: false }, + details: { + resolved_runtime: "native", + qualified_service_count: 13, + mixed_runtime: false, + persisted: true, + }, output: { api: { stackId: "stack-main-default", runtime: "native", qualifiedServiceCount: 13 }, }, @@ -7864,4 +8115,36 @@ export const managedStackContractFixtures = defineManagedStackContractFixtures([ }, }, }, + { + id: "api-boundary.direct-dispose-removes-temporary-roots", + title: "Disposing a direct stack removes every omitted temporary root", + area: "api-boundary", + given: [ + { + kind: "direct-stack-state", + handle: "stack-handle", + stateId: "ephemeral-stack", + roots: "temporary", + lifecycle: "created", + }, + ], + when: { + interface: "stack-api", + method: "dispose", + input: { handle: "stack-handle" }, + }, + expected: { + outcome: "delete", + writes: [{ target: "ephemeral-state", operation: "delete", id: "ephemeral-stack" }], + runtimeEffects: [], + details: { temporary_roots_removed: true }, + output: { + api: { + handle: "stack-handle", + disposed: true, + temporaryRootsRemoved: true, + }, + }, + }, + }, ]); From 783bbb1e3675a5cf0e251df145091f61ee6334e6 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 11 Aug 2026 01:48:39 +0200 Subject: [PATCH 29/41] test(stack): bind remaining lifecycle projections --- .../0015-managed-stack-contract-fixtures.md | 12 +- ...managed-stack-contract.integration.test.ts | 182 ++++++++- packages/stack/src/managed-stack-contract.ts | 386 ++++++++++++++++-- 3 files changed, 528 insertions(+), 52 deletions(-) diff --git a/docs/adr/0015-managed-stack-contract-fixtures.md b/docs/adr/0015-managed-stack-contract-fixtures.md index 6ddaa2fdc8..13d4aa5c4b 100644 --- a/docs/adr/0015-managed-stack-contract-fixtures.md +++ b/docs/adr/0015-managed-stack-contract-fixtures.md @@ -33,8 +33,10 @@ and links to implementation work; it is not a second source of executable truth. `@supabase/stack` has two distinct public responsibilities: 1. Direct `createStack(config)` creates one caller-controlled stack. Omitted stack and runtime roots - are disposable temporary directories and are removed on disposal. It does not inspect Git, - create identity markers, or mutate a global managed registry. + are resolved independently as disposable temporary directories and are removed on disposal. + Supplying project, cache, or one state-root path does not make another omitted state root + persistent. Direct usage does not inspect Git, create identity markers, or mutate a global + managed registry. 2. The explicit managed surface owns system-aware discovery, identity, stack selection, ports, runtime persistence, bootstrap, and reclamation. It accepts an isolated state root or injected repository so applications and tests can use it without the CLI. @@ -98,9 +100,9 @@ reuse must begin from an existing target, runtime stop effects must begin from a target-existence facts cannot contradict stack facts; running-source and credential-drift reports must begin from running sources, idempotent deletion must begin from a tombstone, orphan deletion must target orphaned state, and failed-copy cleanup may -delete only a target proven absent before the attempt; direct-stack root facts must agree with -caller-supplied root inputs and temporary-state behavior, and disposing a direct stack whose roots -were omitted must delete its declared temporary state; +delete only a target proven absent before the attempt; direct-stack root facts must bind stack and +runtime roots independently to caller-supplied inputs and temporary-state behavior, and disposing a +direct stack must delete every declared temporary root; every state write and runtime effect must identify its target; contextual CLI stack results must bind their output to a selected target; Git identity writes must use the correct common or worktree scope, context writes must name the active branch as owner, and adapters cannot recreate an identity diff --git a/packages/stack/src/managed-stack-contract.integration.test.ts b/packages/stack/src/managed-stack-contract.integration.test.ts index acf61fbc13..74ca42e8e1 100644 --- a/packages/stack/src/managed-stack-contract.integration.test.ts +++ b/packages/stack/src/managed-stack-contract.integration.test.ts @@ -1937,7 +1937,7 @@ describe("managed stack acceptance contract", () => { const explicitRootsReportedTemporary = { ...directStackScenario, given: directStackScenario.given.map((fact) => - fact.kind === "direct-stack-options" ? { ...fact, roots: "explicit" } : fact, + fact.kind === "direct-stack-options" ? { ...fact, stackRoot: "explicit" } : fact, ), } satisfies ManagedStackContractScenario; expect(validateManagedStackContractFixtures([explicitRootsReportedTemporary])).toContain( @@ -3307,6 +3307,162 @@ describe("managed stack acceptance contract", () => { expect(validateManagedStackContractFixtures([directDisposalLeaksTemporaryRoots])).toContain( `${directDisposalScenario.id}: direct stack disposal must remove omitted temporary roots`, ); + + const partialDirectRootsScenario = findScenario( + "api-boundary.direct-create-stack-keeps-omitted-runtime-root-temporary", + ); + const partialDirectRootsLeakRuntimeRoot = { + ...partialDirectRootsScenario, + expected: { + ...partialDirectRootsScenario.expected, + writes: [], + details: { ...partialDirectRootsScenario.expected.details, temporary_roots: [] }, + output: { + ...partialDirectRootsScenario.expected.output, + api: { ...partialDirectRootsScenario.expected.output.api, temporaryRoots: [] }, + }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([partialDirectRootsLeakRuntimeRoot])).toContain( + `${partialDirectRootsScenario.id}: direct stack root inputs must agree with temporary-state behavior`, + ); + + const copiedBranchCreateScenario = findScenario( + "identity.branch-copy-known-owner-creates-context-on-mutation", + ); + const copiedBranchCreateReportsUnrelatedAncestry = { + ...copiedBranchCreateScenario, + expected: { + ...copiedBranchCreateScenario.expected, + details: { + ...copiedBranchCreateScenario.expected.details, + original_context_id: "context-unrelated", + }, + output: { + ...copiedBranchCreateScenario.expected.output, + json: { + ...copiedBranchCreateScenario.expected.output.json, + original_context_id: "context-unrelated", + }, + }, + }, + } satisfies ManagedStackContractScenario; + expect( + validateManagedStackContractFixtures([copiedBranchCreateReportsUnrelatedAncestry]), + ).toContain( + `${copiedBranchCreateScenario.id}: copied branch creation must bind its original context`, + ); + + const retryableBootstrapScenario = findScenario("bootstrap.failed-copy-rolls-back"); + const failedBootstrapIsNotRetryable = { + ...retryableBootstrapScenario, + expected: { + ...retryableBootstrapScenario.expected, + output: { + ...retryableBootstrapScenario.expected.output, + api: { ...retryableBootstrapScenario.expected.output.api, retryable: false }, + }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([failedBootstrapIsNotRetryable])).toContain( + `${retryableBootstrapScenario.id}: bootstrap rollback must leave no published partial target`, + ); + + const bareWorktreeScenario = findScenario( + "identity.bare-repository-linked-worktrees-share-project", + ); + const bareWorktreeRequiresPrimary = { + ...bareWorktreeScenario, + expected: { + ...bareWorktreeScenario.expected, + output: { + ...bareWorktreeScenario.expected.output, + api: { ...bareWorktreeScenario.expected.output.api, primaryWorktreeRequired: true }, + }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([bareWorktreeRequiresPrimary])).toContain( + `${bareWorktreeScenario.id}: bare-repository worktree must not require a primary worktree`, + ); + + const missingOriginalScenario = findScenario("identity.original-gone-turns-copy-into-rename"); + const missingOriginalHidesRename = { + ...missingOriginalScenario, + expected: { + ...missingOriginalScenario.expected, + output: { + ...missingOriginalScenario.expected.output, + json: { ...missingOriginalScenario.expected.output.json, rename_detected: false }, + }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([missingOriginalHidesRename])).toContain( + `${missingOriginalScenario.id}: missing original branch must be reported as a rename`, + ); + + const divergedTimelineScenario = findScenario( + "bootstrap.managed-and-legacy-diverge-after-copy", + ); + const divergedTimelineReportsSynchronization = { + ...divergedTimelineScenario, + expected: { + ...divergedTimelineScenario.expected, + details: { ...divergedTimelineScenario.expected.details, timelines_diverged: false }, + output: { + ...divergedTimelineScenario.expected.output, + json: { ...divergedTimelineScenario.expected.output.json, timelines_diverged: false }, + }, + }, + } satisfies ManagedStackContractScenario; + expect( + validateManagedStackContractFixtures([divergedTimelineReportsSynchronization]), + ).toContain( + `${divergedTimelineScenario.id}: managed restart must report legacy timeline divergence`, + ); + + const cliProjectionScenario = findScenario("api-boundary.cli-projects-shared-managed-results"); + const cliReimplementsManagedDecisions = { + ...cliProjectionScenario, + expected: { + ...cliProjectionScenario.expected, + details: { + ...cliProjectionScenario.expected.details, + managed_result_projected: false, + identity_decisions_in_cli: 1, + }, + output: { + ...cliProjectionScenario.expected.output, + human: { + ...cliProjectionScenario.expected.output.human!, + fields: { stackId: "stack-main-default", runtime: "native" }, + }, + json: { ...cliProjectionScenario.expected.output.json, runtime: "native" }, + }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([cliReimplementsManagedDecisions])).toContain( + `${cliProjectionScenario.id}: projected managed status requires an active running record and persisted runtime`, + ); + + const folderConversionScenario = findScenario( + "identity.folder-to-git-exact-claim-preserves-identity", + ); + const folderConversionLosesTransition = { + ...folderConversionScenario, + given: folderConversionScenario.given.filter( + (fact) => fact.kind !== "identity-transition" || fact.operation !== "folder-to-git", + ), + expected: { + ...folderConversionScenario.expected, + output: { + ...folderConversionScenario.expected.output, + json: { ...folderConversionScenario.expected.output.json, converted_to_git: false }, + }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([folderConversionLosesTransition])).toContain( + `${folderConversionScenario.id}: folder-to-Git result must bind the workspace transition`, + ); }); it("covers the approved identity journeys through public commands and APIs", () => { @@ -3593,6 +3749,8 @@ describe("managed stack acceptance contract", () => { [ "api-boundary.cli-projects-shared-managed-results", "api-boundary.direct-create-stack-is-ephemeral", + "api-boundary.direct-create-stack-keeps-omitted-runtime-root-temporary", + "api-boundary.direct-create-stack-keeps-omitted-stack-root-temporary", "api-boundary.direct-dispose-removes-temporary-roots", "api-boundary.managed-api-accepts-injected-repository", "api-boundary.managed-api-accepts-isolated-state-root", @@ -3953,7 +4111,8 @@ describe("managed stack acceptance contract", () => { given: [ { kind: "direct-stack-options", - roots: "omitted", + stackRoot: "omitted", + runtimeRoot: "omitted", }, ], when: { @@ -3963,18 +4122,31 @@ describe("managed stack acceptance contract", () => { }, expected: { outcome: "create", - writes: [{ target: "ephemeral-state", operation: "create", id: "ephemeral-stack" }], + writes: [ + { + target: "temporary-root", + operation: "create", + id: "ephemeral-stack-root", + root: "stack", + }, + { + target: "temporary-root", + operation: "create", + id: "ephemeral-runtime-root", + root: "runtime", + }, + ], runtimeEffects: [], details: { git_inspected: false, identity_marker_created: false, global_registry_mutated: false, - state_root: "temporary", + temporary_roots: ["stack", "runtime"], }, output: { api: { handle: "stack-handle", - state_root: "temporary", + temporaryRoots: ["stack", "runtime"], }, }, }, diff --git a/packages/stack/src/managed-stack-contract.ts b/packages/stack/src/managed-stack-contract.ts index fe70861f88..bd9b5ea903 100644 --- a/packages/stack/src/managed-stack-contract.ts +++ b/packages/stack/src/managed-stack-contract.ts @@ -29,6 +29,11 @@ export type ManagedStackContractFact = readonly copiedFrom?: string; readonly clonedFrom?: string; } + | { + readonly kind: "workspace-history"; + readonly path: string; + readonly previousMode: "ordinary-folder"; + } | { readonly kind: "git-state"; readonly workspacePath: string; @@ -191,13 +196,16 @@ export type ManagedStackContractFact = } | { readonly kind: "direct-stack-options"; - readonly roots: "explicit" | "omitted"; + readonly stackRoot: "explicit" | "omitted"; + readonly runtimeRoot: "explicit" | "omitted"; } | { readonly kind: "direct-stack-state"; readonly handle: string; - readonly stateId: string; - readonly roots: "temporary"; + readonly temporaryRoots: ReadonlyArray<{ + readonly root: "stack" | "runtime"; + readonly stateId: string; + }>; readonly lifecycle: "created"; } | { @@ -239,8 +247,14 @@ type ManagedStackContractWrite = } | { readonly target: "ephemeral-state"; + readonly operation: "create"; + readonly id: string; + } + | { + readonly target: "temporary-root"; readonly operation: "create" | "delete"; readonly id: string; + readonly root: "stack" | "runtime"; } | { readonly target: "managed-state"; @@ -258,6 +272,11 @@ type ManagedStackContractWrite = readonly id: string; }; +type ManagedStackContractTemporaryRootWrite = Extract< + ManagedStackContractWrite, + { readonly target: "temporary-root" } +>; + export interface ManagedStackContractEffects { readonly writes: ReadonlyArray; readonly runtimeEffects: ReadonlyArray<{ @@ -661,23 +680,52 @@ export const validateManagedStackContractFixtures = ( if (scenario.when.interface === "stack-api" && scenario.when.method === "createStack") { const directOptions = scenario.given.filter((fact) => fact.kind === "direct-stack-options"); - const explicitRootKeys = ["cacheRoot", "projectDir", "runtimeRoot", "stackRoot"]; const directInput = scenario.when.input; - const hasExplicitRoot = explicitRootKeys.some((key) => typeof directInput[key] === "string"); - const rootMode = hasExplicitRoot ? "explicit" : "omitted"; - const hasTemporaryDetails = scenario.expected.details?.state_root === "temporary"; - const hasTemporaryProjection = output.api?.state_root === "temporary"; - const hasEphemeralWrite = scenario.expected.writes.some( - (write) => write.target === "ephemeral-state" && write.operation === "create", + const expectedStackRoot = typeof directInput.stackRoot === "string" ? "explicit" : "omitted"; + const expectedRuntimeRoot = + typeof directInput.runtimeRoot === "string" ? "explicit" : "omitted"; + const omittedRoots: Array<"stack" | "runtime"> = []; + if (expectedStackRoot === "omitted") { + omittedRoots.push("stack"); + } + if (expectedRuntimeRoot === "omitted") { + omittedRoots.push("runtime"); + } + const temporaryRootWrites = scenario.expected.writes.filter( + (write): write is ManagedStackContractTemporaryRootWrite => + write.target === "temporary-root" && write.operation === "create", ); - const usesTemporaryState = hasTemporaryDetails && hasTemporaryProjection && hasEphemeralWrite; - const exposesTemporaryState = - hasTemporaryDetails || hasTemporaryProjection || hasEphemeralWrite; + const detailRoots = scenario.expected.details?.temporary_roots; + const projectedRoots = output.api?.temporaryRoots; + const validDetailRoots = + Array.isArray(detailRoots) && + detailRoots.every( + (root): root is "stack" | "runtime" => root === "stack" || root === "runtime", + ) + ? detailRoots + : undefined; + const validProjectedRoots = + Array.isArray(projectedRoots) && + projectedRoots.every( + (root): root is "stack" | "runtime" => root === "stack" || root === "runtime", + ) + ? projectedRoots + : undefined; if ( directOptions.length !== 1 || - directOptions[0]?.roots !== rootMode || - (rootMode === "omitted" && !usesTemporaryState) || - (rootMode === "explicit" && exposesTemporaryState) + directOptions[0]?.stackRoot !== expectedStackRoot || + directOptions[0]?.runtimeRoot !== expectedRuntimeRoot || + temporaryRootWrites.length !== omittedRoots.length || + !managedStackContractStringSetEquals( + temporaryRootWrites.map((write) => write.root), + omittedRoots, + ) || + validDetailRoots === undefined || + validDetailRoots.length !== omittedRoots.length || + !managedStackContractStringSetEquals(validDetailRoots, omittedRoots) || + validProjectedRoots === undefined || + validProjectedRoots.length !== omittedRoots.length || + !managedStackContractStringSetEquals(validProjectedRoots, omittedRoots) ) { errors.push( `${scenario.id}: direct stack root inputs must agree with temporary-state behavior`, @@ -687,7 +735,7 @@ export const validateManagedStackContractFixtures = ( scenario.expected.details?.git_inspected !== false || scenario.expected.details.identity_marker_created !== false || scenario.expected.details.global_registry_mutated !== false || - scenario.expected.writes.some((write) => write.target !== "ephemeral-state") || + scenario.expected.writes.some((write) => write.target !== "temporary-root") || scenario.expected.runtimeEffects.length > 0 ) { errors.push(`${scenario.id}: direct createStack must remain isolated from managed state`); @@ -698,24 +746,66 @@ export const validateManagedStackContractFixtures = ( const directState = scenario.given.find( (fact) => fact.kind === "direct-stack-state" && fact.handle === handle, ); + const deletedTemporaryRoots = scenario.expected.writes.filter( + (write): write is ManagedStackContractTemporaryRootWrite => + write.target === "temporary-root" && write.operation === "delete", + ); + const removedDetailRoots = scenario.expected.details?.removed_temporary_roots; + const removedProjectedRoots = output.api?.removedTemporaryRoots; + const validRemovedDetailRoots = + Array.isArray(removedDetailRoots) && + removedDetailRoots.every( + (root): root is "stack" | "runtime" => root === "stack" || root === "runtime", + ) + ? removedDetailRoots + : undefined; + const validRemovedProjectedRoots = + Array.isArray(removedProjectedRoots) && + removedProjectedRoots.every( + (root): root is "stack" | "runtime" => root === "stack" || root === "runtime", + ) + ? removedProjectedRoots + : undefined; + const declaredTemporaryRoots = + directState?.kind === "direct-stack-state" ? directState.temporaryRoots : []; if ( typeof handle !== "string" || directState?.kind !== "direct-stack-state" || - directState.roots !== "temporary" || directState.lifecycle !== "created" || + new Set(declaredTemporaryRoots.map(({ root }) => root)).size !== + declaredTemporaryRoots.length || scenario.expected.outcome !== "delete" || - !scenario.expected.writes.some( + deletedTemporaryRoots.length !== declaredTemporaryRoots.length || + declaredTemporaryRoots.some( + (root) => + !deletedTemporaryRoots.some( + (write) => write.root === root.root && write.id === root.stateId, + ), + ) || + deletedTemporaryRoots.some( (write) => - write.target === "ephemeral-state" && - write.operation === "delete" && - write.id === directState.stateId, + !declaredTemporaryRoots.some( + (root) => root.root === write.root && root.stateId === write.id, + ), ) || - scenario.expected.writes.some((write) => write.target !== "ephemeral-state") || + scenario.expected.writes.some((write) => write.target !== "temporary-root") || scenario.expected.runtimeEffects.length > 0 || scenario.expected.details?.temporary_roots_removed !== true || + validRemovedDetailRoots === undefined || + validRemovedDetailRoots.length !== declaredTemporaryRoots.length || + !managedStackContractStringSetEquals( + validRemovedDetailRoots, + declaredTemporaryRoots.map(({ root }) => root), + ) || output.api?.handle !== handle || output.api.disposed !== true || - output.api.temporaryRootsRemoved !== true + output.api.temporaryRootsRemoved !== true || + validRemovedProjectedRoots === undefined || + validRemovedProjectedRoots.length !== declaredTemporaryRoots.length || + !managedStackContractStringSetEquals( + validRemovedProjectedRoots, + declaredTemporaryRoots.map(({ root }) => root), + ) ) { errors.push(`${scenario.id}: direct stack disposal must remove omitted temporary roots`); } @@ -1471,7 +1561,9 @@ export const validateManagedStackContractFixtures = ( } break; case "direct-stack-state": - declaredIds.add(fact.stateId); + for (const root of fact.temporaryRoots) { + declaredIds.add(root.stateId); + } break; case "identity-claim": if (fact.status !== "absent") { @@ -1560,6 +1652,28 @@ export const validateManagedStackContractFixtures = ( errors.push(`${scenario.id}: CLI action cwd must match a declared workspace`); } } + const workspaceHistory = scenario.given.find( + (fact) => fact.kind === "workspace-history" && fact.path === actionCwd, + ); + if (workspaceHistory?.kind === "workspace-history") { + const currentWorkspace = scenario.given.find( + (fact) => fact.kind === "workspace" && fact.path === actionCwd, + ); + const folderToGitTransition = scenario.given.find( + (fact) => fact.kind === "identity-transition" && fact.operation === "folder-to-git", + ); + if ( + workspaceHistory.previousMode !== "ordinary-folder" || + currentWorkspace?.kind !== "workspace" || + currentWorkspace.mode !== "git" || + folderToGitTransition?.kind !== "identity-transition" || + folderToGitTransition.from !== "ordinary-folder" || + folderToGitTransition.to !== "git" || + (scenario.expected.outcome !== "error" && output.json?.converted_to_git !== true) + ) { + errors.push(`${scenario.id}: folder-to-Git result must bind the workspace transition`); + } + } if ( isStatusOperation && (scenario.expected.details?.registered === false || output.json?.registered === false) && @@ -1581,6 +1695,13 @@ export const validateManagedStackContractFixtures = ( const copiedBranch = scenario.given.find( (fact) => fact.kind === "branch" && fact.name === branchCopy.to, ); + const originalClaim = scenario.given.find( + (fact) => + fact.kind === "identity-claim" && + fact.scope === "context" && + fact.owner === branchCopy.from && + fact.status === (branchCopy.originalExists ? "exact" : "absent"), + ); if ( typeof branchCopy.from !== "string" || typeof branchCopy.to !== "string" || @@ -1593,13 +1714,6 @@ export const validateManagedStackContractFixtures = ( ); } if (scenario.expected.warning?.code === "copied_branch_context_conflict") { - const originalClaim = scenario.given.find( - (fact) => - fact.kind === "identity-claim" && - fact.scope === "context" && - fact.status === "exact" && - fact.owner === branchCopy.from, - ); if ( typeof branchCopy.from !== "string" || typeof branchCopy.to !== "string" || @@ -1613,6 +1727,23 @@ export const validateManagedStackContractFixtures = ( errors.push(`${scenario.id}: copied branch warning must bind observed branch ownership`); } } + if ( + branchCopy.originalExists === true && + scenario.expected.outcome === "create" && + (originalClaim?.kind !== "identity-claim" || + scenario.expected.details?.original_context_id !== originalClaim.id || + scenario.expected.details.original_owner !== branchCopy.from || + output.json?.original_context_id !== originalClaim.id) + ) { + errors.push(`${scenario.id}: copied branch creation must bind its original context`); + } + if ( + branchCopy.originalExists === false && + scenario.expected.outcome === "reuse" && + (originalClaim?.kind !== "identity-claim" || output.json?.rename_detected !== true) + ) { + errors.push(`${scenario.id}: missing original branch must be reported as a rename`); + } } if ( scenario.when.interface === "managed-api" && @@ -1626,6 +1757,20 @@ export const validateManagedStackContractFixtures = ( const actionGitState = scenario.given.find( (fact) => fact.kind === "git-state" && fact.workspacePath === actionCwd, ); + const actionWorkspace = scenario.given.find( + (fact) => fact.kind === "workspace" && fact.path === actionCwd, + ); + if (actionWorkspace?.kind === "workspace" && actionWorkspace.mode === "bare-worktree") { + if ( + actionGitState?.kind !== "git-state" || + actionGitState.commonDirectory === actionGitState.gitDirectory || + scenario.expected.details?.project_identity_location !== actionGitState.commonDirectory || + scenario.expected.details.checkout_identity_location !== actionGitState.gitDirectory || + output.api?.primaryWorktreeRequired !== false + ) { + errors.push(`${scenario.id}: bare-repository worktree must not require a primary worktree`); + } + } const checkedOutBranch = scenario.given.find( (fact) => fact.kind === "branch" && fact.checkedOut, ); @@ -2191,6 +2336,18 @@ export const validateManagedStackContractFixtures = ( ) { errors.push(`${scenario.id}: existing managed target must not report legacy bootstrap`); } + const legacyState = scenario.given.find((fact) => fact.kind === "legacy-state"); + if ( + existingStartedTarget?.kind === "managed-target" && + legacyState?.kind === "legacy-state" && + (legacyState.database === "incompatible" || + legacyState.storage === "incompatible" || + legacyState.credentials === "incompatible") && + (scenario.expected.details?.timelines_diverged !== true || + output.json?.timelines_diverged !== true) + ) { + errors.push(`${scenario.id}: managed restart must report legacy timeline divergence`); + } } const createdStackIds = scenario.expected.writes.flatMap((write) => @@ -3068,7 +3225,11 @@ export const validateManagedStackContractFixtures = ( } } - if (scenario.expected.details?.managed_result_projected === true) { + const cliProjectsManagedResult = + scenario.when.interface === "cli" && + scenario.when.argv[0] === "status" && + scenario.given.some((fact) => fact.kind === "managed-api-options"); + if (cliProjectsManagedResult) { const managedRecord = scenario.given.find( (fact) => fact.kind === "managed-record" && fact.stackId === selection?.stackId, ); @@ -3085,6 +3246,7 @@ export const validateManagedStackContractFixtures = ( selectedStack?.kind !== "stack" || selectedStack.lifecycle !== "running" || persistedRuntime?.kind !== "persisted-runtime" || + scenario.expected.details?.managed_result_projected !== true || scenario.expected.details.identity_decisions_in_cli !== 0 || output.human?.fields.stackId !== selection.stackId || output.human.fields.runtime !== persistedRuntime.runtime || @@ -3304,6 +3466,7 @@ export const validateManagedStackContractFixtures = ( scenario.expected.details.registry_record_published !== false || output.api?.activeTargetExists !== false || output.api?.registryRecordPublished !== false || + output.api?.retryable !== true || scenario.expected.writes.some( (write) => (write.target === "managed-state" && write.operation !== "delete") || @@ -5314,6 +5477,11 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ title: "Folder-to-Git conversion preserves one exact live path claim", area: "identity", given: [ + { + kind: "workspace-history", + path: "/work/project-a", + previousMode: "ordinary-folder", + }, { kind: "identity-transition", operation: "folder-to-git", @@ -5384,6 +5552,11 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ area: "identity", given: [ ...freshManagedStartFacts("stack-git-default"), + { + kind: "workspace-history", + path: "/work/project-a", + previousMode: "ordinary-folder", + }, { kind: "identity-transition", operation: "folder-to-git", @@ -5429,7 +5602,12 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ runtimeEffects: [{ operation: "start", stackId: "stack-git-default" }], details: { project_identity_storage: "git-local", git_index_mutated: false }, output: { - json: { outcome: "create", project_id: "project-git", checkout_id: "checkout-git" }, + json: { + outcome: "create", + project_id: "project-git", + checkout_id: "checkout-git", + converted_to_git: true, + }, }, }, }, @@ -5438,6 +5616,11 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ title: "Folder-to-Git conversion fails on ambiguous live identity claims", area: "identity", given: [ + { + kind: "workspace-history", + path: "/work/project-a", + previousMode: "ordinary-folder", + }, { kind: "identity-transition", operation: "folder-to-git", @@ -8089,7 +8272,8 @@ export const managedStackContractFixtures = defineManagedStackContractFixtures([ given: [ { kind: "direct-stack-options", - roots: "omitted", + stackRoot: "omitted", + runtimeRoot: "omitted", }, ], when: { @@ -8099,18 +8283,117 @@ export const managedStackContractFixtures = defineManagedStackContractFixtures([ }, expected: { outcome: "create", - writes: [{ target: "ephemeral-state", operation: "create", id: "ephemeral-stack" }], + writes: [ + { + target: "temporary-root", + operation: "create", + id: "ephemeral-stack-root", + root: "stack", + }, + { + target: "temporary-root", + operation: "create", + id: "ephemeral-runtime-root", + root: "runtime", + }, + ], runtimeEffects: [], details: { git_inspected: false, identity_marker_created: false, global_registry_mutated: false, - state_root: "temporary", + temporary_roots: ["stack", "runtime"], }, output: { api: { handle: "stack-handle", - state_root: "temporary", + temporaryRoots: ["stack", "runtime"], + }, + }, + }, + }, + { + id: "api-boundary.direct-create-stack-keeps-omitted-runtime-root-temporary", + title: "Direct createStack keeps an omitted runtime root temporary", + area: "api-boundary", + given: [ + { + kind: "direct-stack-options", + stackRoot: "explicit", + runtimeRoot: "omitted", + }, + ], + when: { + interface: "stack-api", + method: "createStack", + input: { + projectDir: "/work/project-a", + cacheRoot: "/work/cache", + stackRoot: "/work/stack", + }, + }, + expected: { + outcome: "create", + writes: [ + { + target: "temporary-root", + operation: "create", + id: "ephemeral-runtime-root", + root: "runtime", + }, + ], + runtimeEffects: [], + details: { + git_inspected: false, + identity_marker_created: false, + global_registry_mutated: false, + temporary_roots: ["runtime"], + }, + output: { + api: { + handle: "partial-stack-handle", + temporaryRoots: ["runtime"], + }, + }, + }, + }, + { + id: "api-boundary.direct-create-stack-keeps-omitted-stack-root-temporary", + title: "Direct createStack keeps an omitted stack root temporary", + area: "api-boundary", + given: [ + { + kind: "direct-stack-options", + stackRoot: "omitted", + runtimeRoot: "explicit", + }, + ], + when: { + interface: "stack-api", + method: "createStack", + input: { runtimeRoot: "/work/runtime" }, + }, + expected: { + outcome: "create", + writes: [ + { + target: "temporary-root", + operation: "create", + id: "ephemeral-stack-root", + root: "stack", + }, + ], + runtimeEffects: [], + details: { + git_inspected: false, + identity_marker_created: false, + global_registry_mutated: false, + temporary_roots: ["stack"], + }, + output: { + api: { + handle: "partial-stack-handle", + temporaryRoots: ["stack"], }, }, }, @@ -8123,8 +8406,10 @@ export const managedStackContractFixtures = defineManagedStackContractFixtures([ { kind: "direct-stack-state", handle: "stack-handle", - stateId: "ephemeral-stack", - roots: "temporary", + temporaryRoots: [ + { root: "stack", stateId: "ephemeral-stack-root" }, + { root: "runtime", stateId: "ephemeral-runtime-root" }, + ], lifecycle: "created", }, ], @@ -8135,14 +8420,31 @@ export const managedStackContractFixtures = defineManagedStackContractFixtures([ }, expected: { outcome: "delete", - writes: [{ target: "ephemeral-state", operation: "delete", id: "ephemeral-stack" }], + writes: [ + { + target: "temporary-root", + operation: "delete", + id: "ephemeral-stack-root", + root: "stack", + }, + { + target: "temporary-root", + operation: "delete", + id: "ephemeral-runtime-root", + root: "runtime", + }, + ], runtimeEffects: [], - details: { temporary_roots_removed: true }, + details: { + temporary_roots_removed: true, + removed_temporary_roots: ["stack", "runtime"], + }, output: { api: { handle: "stack-handle", disposed: true, temporaryRootsRemoved: true, + removedTemporaryRoots: ["stack", "runtime"], }, }, }, From eed459fc7af223bb87e55ab4b513a475e5300e55 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 11 Aug 2026 02:26:47 +0200 Subject: [PATCH 30/41] test(stack): bind remaining state projections --- .../0015-managed-stack-contract-fixtures.md | 9 +- ...managed-stack-contract.integration.test.ts | 193 ++++++++++++++ packages/stack/src/managed-stack-contract.ts | 241 ++++++++++++++---- 3 files changed, 394 insertions(+), 49 deletions(-) diff --git a/docs/adr/0015-managed-stack-contract-fixtures.md b/docs/adr/0015-managed-stack-contract-fixtures.md index 13d4aa5c4b..577dbf7920 100644 --- a/docs/adr/0015-managed-stack-contract-fixtures.md +++ b/docs/adr/0015-managed-stack-contract-fixtures.md @@ -108,8 +108,10 @@ bind their output to a selected target; Git identity writes must use the correct scope, context writes must name the active branch as owner, and adapters cannot recreate an identity already declared by a checkout; new Git-derived contexts, manual ref replacement, branch deletion and recreation, detached-commit reuse, and selected linked worktrees must declare the relevant Git -state or transition; comparisons between branch contexts at one commit must declare both branch -refs at that commit; selected contexts must agree with the active Git branch or an explicit +state or transition; branch commit, rebase, and reset preservation must bind an explicit history +fact to the checked-out branch and matching transition; comparisons between branch contexts at one +commit must declare both branch refs at that commit; selected contexts must agree with the active +Git branch or an explicit checkout-scoped claim; ordinary folders must write their full untracked identity marker to the action workspace on creation and resolve it on reuse, while Git workspaces cannot trust that local marker; copied-branch evidence must agree with whether the original branch still exists, read-only @@ -136,7 +138,8 @@ runtime, and every service projection; native preflight results must agree with the action platform and complete qualified and failed service partitions; credential create, update, and copy operations must prove that global state contains references -instead of plaintext, credential changes must bind distinct old and new references, and local, +instead of plaintext, configured credential changes must bind configured state with distinct old and +new references in both directions, and local, persisted, and copied-legacy credentials must retain their declared reference and source; data-preserving prune must begin with mutable data and delete metadata only for an orphaned record with matching orphaned stack state; tracked identity markers diff --git a/packages/stack/src/managed-stack-contract.integration.test.ts b/packages/stack/src/managed-stack-contract.integration.test.ts index 74ca42e8e1..fedbb47c07 100644 --- a/packages/stack/src/managed-stack-contract.integration.test.ts +++ b/packages/stack/src/managed-stack-contract.integration.test.ts @@ -3463,6 +3463,199 @@ describe("managed stack acceptance contract", () => { expect(validateManagedStackContractFixtures([folderConversionLosesTransition])).toContain( `${folderConversionScenario.id}: folder-to-Git result must bind the workspace transition`, ); + + const replacedRefOrphanScenario = findScenario( + "identity.manual-ref-replacement-orphans-context", + ); + const replacedRefOrphansUnrelatedContext = { + ...replacedRefOrphanScenario, + expected: { + ...replacedRefOrphanScenario.expected, + details: { + ...replacedRefOrphanScenario.expected.details, + orphaned_context_id: "context-unrelated", + }, + output: { + ...replacedRefOrphanScenario.expected.output, + json: { + ...replacedRefOrphanScenario.expected.output.json, + orphaned_context_id: "context-unrelated", + }, + }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([replacedRefOrphansUnrelatedContext])).toContain( + `${replacedRefOrphanScenario.id}: ref replacement must orphan the displaced branch context`, + ); + + const managedReuseLegacyScenario = findScenario( + "bootstrap.managed-and-legacy-diverge-after-copy", + ); + const managedReuseMutatesLegacy = { + ...managedReuseLegacyScenario, + expected: { + ...managedReuseLegacyScenario.expected, + details: { + ...managedReuseLegacyScenario.expected.details, + legacy_state_mutated: true, + }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([managedReuseMutatesLegacy])).toContain( + `${managedReuseLegacyScenario.id}: managed target reuse must not mutate legacy state`, + ); + + const readOnlyUnregisteredScenario = findScenario( + "identity.read-only-unregistered-checkout-does-not-write", + ); + const readOnlyDiscoveryReportsRegistration = { + ...readOnlyUnregisteredScenario, + expected: { + ...readOnlyUnregisteredScenario.expected, + details: { + ...readOnlyUnregisteredScenario.expected.details, + registered: true, + identity_marker_created: true, + }, + output: { + ...readOnlyUnregisteredScenario.expected.output, + json: { ...readOnlyUnregisteredScenario.expected.output.json, registered: true }, + }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([readOnlyDiscoveryReportsRegistration])).toContain( + `${readOnlyUnregisteredScenario.id}: read-only unregistered status must not create identity state`, + ); + + const stickyReturnScenario = findScenario("ports.sticky-ports-reuse-on-return"); + const stickyReturnHidesPersistence = { + ...stickyReturnScenario, + expected: { + ...stickyReturnScenario.expected, + output: { + ...stickyReturnScenario.expected.output, + json: { ...stickyReturnScenario.expected.output.json, sticky: false }, + }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([stickyReturnHidesPersistence])).toContain( + `${stickyReturnScenario.id}: sticky port reuse must bind automatic config, assignment, and projections`, + ); + + const unavailablePersistedRuntimeScenario = findScenario( + "runtime.missing-persisted-prerequisite-fails", + ); + const unavailablePersistedRuntimeReportsSwitch = { + ...unavailablePersistedRuntimeScenario, + expected: { + ...unavailablePersistedRuntimeScenario.expected, + details: { + ...unavailablePersistedRuntimeScenario.expected.details, + switched_to_docker: true, + }, + }, + } satisfies ManagedStackContractScenario; + expect( + validateManagedStackContractFixtures([unavailablePersistedRuntimeReportsSwitch]), + ).toContain( + `${unavailablePersistedRuntimeScenario.id}: unavailable persisted runtime must not report a switch`, + ); + + const unnamedFreshCloneScenario = findScenario( + "identity.fresh-clone-creates-project-and-checkout", + ); + if (unnamedFreshCloneScenario.expected.selection === undefined) { + throw new Error("fresh clone selection is required"); + } + const unnamedFreshCloneSelectsReview = { + ...unnamedFreshCloneScenario, + expected: { + ...unnamedFreshCloneScenario.expected, + selection: { ...unnamedFreshCloneScenario.expected.selection, stackName: "review" }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([unnamedFreshCloneSelectsReview])).toContain( + `${unnamedFreshCloneScenario.id}: unnamed CLI start must select the default stack`, + ); + + const freshCloneMutatesIndex = { + ...unnamedFreshCloneScenario, + expected: { + ...unnamedFreshCloneScenario.expected, + details: { ...unnamedFreshCloneScenario.expected.details, git_index_mutated: true }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([freshCloneMutatesIndex])).toContain( + `${unnamedFreshCloneScenario.id}: fresh clone identity creation must not mutate the Git index`, + ); + + const exactPortProjectionScenario = findScenario("ports.explicit-free-port-is-used"); + const exactPortProjectsAutomaticIntent = { + ...exactPortProjectionScenario, + expected: { + ...exactPortProjectionScenario.expected, + output: { + ...exactPortProjectionScenario.expected.output, + api: { ...exactPortProjectionScenario.expected.output.api, intent: "automatic" }, + }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([exactPortProjectsAutomaticIntent])).toContain( + `${exactPortProjectionScenario.id}: exact port request api.port must project exact intent`, + ); + + const metadataPruneScenario = findScenario("reclamation.prune-removes-metadata-only"); + const metadataPruneReportsNoRemoval = { + ...metadataPruneScenario, + expected: { + ...metadataPruneScenario.expected, + details: { ...metadataPruneScenario.expected.details, metadata_removed: false }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([metadataPruneReportsNoRemoval])).toContain( + `${metadataPruneScenario.id}: registry deletion must report removed metadata`, + ); + + const checkoutIndependentDeletionScenario = findScenario( + "reclamation.delete-orphan-by-stack-id", + ); + const globalDeletionRequiresCheckout = { + ...checkoutIndependentDeletionScenario, + expected: { + ...checkoutIndependentDeletionScenario.expected, + details: { + ...checkoutIndependentDeletionScenario.expected.details, + checkout_required: true, + }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([globalDeletionRequiresCheckout])).toContain( + `${checkoutIndependentDeletionScenario.id}: global orphan deletion requires an orphaned target`, + ); + + const branchHistoryEvidenceScenario = findScenario("identity.branch-commit-preserves-context"); + const branchHistoryLosesEvidence = { + ...branchHistoryEvidenceScenario, + given: branchHistoryEvidenceScenario.given.filter( + (fact) => fact.kind !== "branch" && fact.kind !== "identity-transition", + ), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([branchHistoryLosesEvidence])).toContain( + `${branchHistoryEvidenceScenario.id}: branch history preservation requires matching branch evidence`, + ); + + const configuredCredentialUpdateScenario = findScenario( + "credentials.explicit-change-applies-after-stop", + ); + const configuredCredentialUpdateLosesState = { + ...configuredCredentialUpdateScenario, + given: configuredCredentialUpdateScenario.given.filter( + (fact) => fact.kind !== "credential-state", + ), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([configuredCredentialUpdateLosesState])).toContain( + `${configuredCredentialUpdateScenario.id}: credential change requires configured old and new values`, + ); }); it("covers the approved identity journeys through public commands and APIs", () => { diff --git a/packages/stack/src/managed-stack-contract.ts b/packages/stack/src/managed-stack-contract.ts index bd9b5ea903..91740e1ef4 100644 --- a/packages/stack/src/managed-stack-contract.ts +++ b/packages/stack/src/managed-stack-contract.ts @@ -105,6 +105,13 @@ export type ManagedStackContractFact = readonly name: string; readonly commit: string; } + | { + readonly kind: "branch-history"; + readonly branch: string; + readonly operation: "commit" | "rebase" | "reset"; + readonly fromCommit: string; + readonly toCommit: string; + } | { readonly kind: "stack"; readonly name: string; @@ -217,6 +224,11 @@ export type ManagedStackContractFact = readonly runtime: "bun" | "node"; }; +type ManagedStackContractPortAssignmentFact = Extract< + ManagedStackContractFact, + { readonly kind: "port-assignment" } +>; + export interface ManagedStackContractOutput { readonly human?: { readonly summary: string; @@ -883,6 +895,15 @@ export const validateManagedStackContractFixtures = ( `${scenario.id}: explicit stack name ${explicitActionStackName} disagrees with selected stack ${scenario.expected.selection.stackName}`, ); } + if ( + scenario.when.interface === "cli" && + scenario.when.argv[0] === "start" && + cliStackNameIndex < 0 && + scenario.expected.selection !== undefined && + scenario.expected.selection.stackName !== "default" + ) { + errors.push(`${scenario.id}: unnamed CLI start must select the default stack`); + } if (scenario.expected.error?.code === "invalid_stack_name") { const declaredNames = scenario.given.find((fact) => fact.kind === "stack-names"); if ( @@ -1674,13 +1695,24 @@ export const validateManagedStackContractFixtures = ( errors.push(`${scenario.id}: folder-to-Git result must bind the workspace transition`); } } + const absentCheckoutClaim = scenario.given.find( + (fact) => + fact.kind === "identity-claim" && fact.scope === "checkout" && fact.status === "absent", + ); + if ( + isStatusOperation && + absentCheckoutClaim?.kind === "identity-claim" && + scenario.expected.writes.length === 0 && + (scenario.expected.details?.registered !== false || + scenario.expected.details.identity_marker_created !== false || + output.json?.registered !== false) + ) { + errors.push(`${scenario.id}: read-only unregistered status must not create identity state`); + } if ( isStatusOperation && (scenario.expected.details?.registered === false || output.json?.registered === false) && - !scenario.given.some( - (fact) => - fact.kind === "identity-claim" && fact.scope === "checkout" && fact.status === "absent", - ) + absentCheckoutClaim?.kind !== "identity-claim" ) { errors.push(`${scenario.id}: unregistered status requires an absent checkout claim`); } @@ -1834,6 +1866,23 @@ export const validateManagedStackContractFixtures = ( ) { errors.push(`${scenario.id}: ref replacement target must match the action workspace commit`); } + if (refReplacement?.kind === "identity-transition") { + const displacedContext = scenario.given.find( + (fact) => + fact.kind === "identity-claim" && + fact.scope === "context" && + fact.status === "absent" && + actionGitState?.kind === "git-state" && + fact.owner === actionGitState.branch, + ); + if ( + displacedContext?.kind !== "identity-claim" || + scenario.expected.details?.orphaned_context_id !== displacedContext.id || + output.json?.orphaned_context_id !== displacedContext.id + ) { + errors.push(`${scenario.id}: ref replacement must orphan the displaced branch context`); + } + } const branchRefs = scenario.given.filter((fact) => fact.kind === "branch-ref"); if (branchRefs.length > 0) { const comparedBranches = scenario.given.filter( @@ -1868,6 +1917,34 @@ export const validateManagedStackContractFixtures = ( ); } } + const branchHistory = scenario.given.find((fact) => fact.kind === "branch-history"); + if (branchHistory?.kind === "branch-history") { + const expectedTransitionOperation = + branchHistory.operation === "commit" + ? "branch-commit" + : branchHistory.operation === "rebase" + ? "branch-rebase" + : "branch-reset"; + const historyTransition = scenario.given.find( + (fact) => + fact.kind === "identity-transition" && fact.operation === expectedTransitionOperation, + ); + const historyBranch = scenario.given.find( + (fact) => fact.kind === "branch" && fact.name === branchHistory.branch && fact.checkedOut, + ); + if ( + historyTransition?.kind !== "identity-transition" || + historyTransition.from !== branchHistory.fromCommit || + historyTransition.to !== branchHistory.toCommit || + historyBranch?.kind !== "branch" || + scenario.expected.selection?.contextId !== historyBranch.contextId || + scenario.expected.outcome !== "reuse" + ) { + errors.push( + `${scenario.id}: branch history preservation requires matching branch evidence`, + ); + } + } const symlinkAlias = scenario.given.find( (fact) => fact.kind === "identity-transition" && fact.operation === "symlink-alias", ); @@ -2258,6 +2335,18 @@ export const validateManagedStackContractFixtures = ( const writesIdentityMarker = scenario.expected.writes.some( (write) => write.target === "identity-marker", ); + const clonedWorkspace = scenario.given.find( + (fact) => + fact.kind === "workspace" && fact.path === actionCwd && fact.clonedFrom !== undefined, + ); + if ( + clonedWorkspace?.kind === "workspace" && + scenario.expected.outcome === "create" && + scenario.expected.writes.some((write) => write.target === "git-config") && + (scenario.expected.details?.git_index_mutated !== false || writesIdentityMarker) + ) { + errors.push(`${scenario.id}: fresh clone identity creation must not mutate the Git index`); + } const trackedIdentityMarker = scenario.given.find( (fact) => fact.kind === "git-state" && fact.trackedIdentityMarker === true, ); @@ -2337,6 +2426,15 @@ export const validateManagedStackContractFixtures = ( errors.push(`${scenario.id}: existing managed target must not report legacy bootstrap`); } const legacyState = scenario.given.find((fact) => fact.kind === "legacy-state"); + if ( + existingStartedTarget?.kind === "managed-target" && + legacyState?.kind === "legacy-state" && + (scenario.expected.details?.legacy_state_mutated !== false || + (output.json?.legacy_state_mutated !== undefined && + output.json.legacy_state_mutated !== false)) + ) { + errors.push(`${scenario.id}: managed target reuse must not mutate legacy state`); + } if ( existingStartedTarget?.kind === "managed-target" && legacyState?.kind === "legacy-state" && @@ -2461,6 +2559,9 @@ export const validateManagedStackContractFixtures = ( } if (scenario.expected.error?.code === "persisted_runtime_unavailable") { + if (scenario.expected.details?.switched_to_docker !== false) { + errors.push(`${scenario.id}: unavailable persisted runtime must not report a switch`); + } for (const fact of scenario.given) { if (fact.kind !== "persisted-runtime") { continue; @@ -2996,41 +3097,45 @@ export const validateManagedStackContractFixtures = ( } } - if (scenario.expected.output.json?.sticky === true) { - const projectedPorts = scenario.expected.output.json.ports; + const stickyAssignments = + scenario.expected.outcome === "reuse" && selection !== undefined + ? scenario.given.filter( + (fact): fact is ManagedStackContractPortAssignmentFact => + fact.kind === "port-assignment" && + fact.stackId === selection.stackId && + fact.intent === "automatic" && + scenario.given.some( + (config) => + config.kind === "config-port" && + config.key === fact.key && + config.intent === "automatic" && + config.source === "omitted", + ), + ) + : []; + if (output.json?.sticky === true || stickyAssignments.length > 0) { + const projectedPorts = scenario.expected.output.json?.ports; if (selection === undefined) { errors.push(`${scenario.id}: sticky port reuse requires a selected target`); - } else if (!isManagedStackContractRecord(projectedPorts)) { - errors.push(`${scenario.id}: sticky port reuse must project its assigned ports`); - } else { - for (const [service, port] of Object.entries(projectedPorts)) { - const key = `${service}.port`; - if ( - typeof port !== "number" || - !scenario.given.some( - (fact) => - fact.kind === "config-port" && - fact.key === key && - fact.intent === "automatic" && - fact.source === "omitted", - ) || - !scenario.given.some( - (fact) => - fact.kind === "port-assignment" && - fact.stackId === selection.stackId && - fact.key === key && - fact.port === port && - fact.intent === "automatic", - ) || + } else if ( + stickyAssignments.length === 0 || + output.json?.sticky !== true || + !isManagedStackContractRecord(projectedPorts) || + stickyAssignments.some((assignment) => { + const service = assignment.key.endsWith(".port") + ? assignment.key.slice(0, -".port".length) + : assignment.key; + return ( + projectedPorts?.[service] !== assignment.port || (service === "api" && output.human !== undefined && - output.human.fields.apiUrl !== `http://127.0.0.1:${port}`) - ) { - errors.push( - `${scenario.id}: sticky port reuse must bind automatic config, assignment, and projections`, - ); - } - } + output.human.fields.apiUrl !== `http://127.0.0.1:${assignment.port}`) + ); + }) + ) { + errors.push( + `${scenario.id}: sticky port reuse must bind automatic config, assignment, and projections`, + ); } } @@ -3187,6 +3292,15 @@ export const validateManagedStackContractFixtures = ( `${scenario.id}: projected exact port ${key} must match request ${requestedPort}`, ); } + const projectedExactIntent = + requestedEntries.length === 1 + ? output.api?.intent + : isManagedStackContractRecord(projectedIntents) + ? projectedIntents[service] + : undefined; + if (projectedExactIntent !== "exact") { + errors.push(`${scenario.id}: exact port request ${key} must project exact intent`); + } } } @@ -3557,21 +3671,20 @@ export const validateManagedStackContractFixtures = ( const globallyTargetedStack = scenario.given.find( (fact) => fact.kind === "stack" && fact.stackId === explicitActionStackId, ); - if ( + const isCheckoutIndependentStackDeletion = scenario.when.interface === "cli" && scenario.when.argv[0] === "stop" && scenario.when.argv.includes("--stack-id") && scenario.expected.outcome === "delete" && - globallyTargetedStack?.kind === "stack" && - (globallyTargetedStack.orphaned !== undefined || - output.json?.orphaned !== undefined || - output.human?.fields.orphaned !== undefined) - ) { + !scenario.given.some((fact) => fact.kind === "checkout" && fact.path === actionCwd); + if (isCheckoutIndependentStackDeletion) { if ( typeof explicitActionStackId !== "string" || + globallyTargetedStack?.kind !== "stack" || globallyTargetedStack.orphaned !== true || output.json?.orphaned !== true || - output.human?.fields.orphaned !== "true" + output.human?.fields.orphaned !== "true" || + scenario.expected.details?.checkout_required !== false ) { errors.push(`${scenario.id}: global orphan deletion requires an orphaned target`); } @@ -3650,11 +3763,33 @@ export const validateManagedStackContractFixtures = ( ) { errors.push(`${scenario.id}: credential change requires different old and new values`); } - if ( - changedCredentials?.kind === "credential-state" && + const configuredCredentialChange = + changedCredentials?.kind === "credential-state" && changedCredentials.source === "configured" + ? changedCredentials + : undefined; + const selectedStoppedStack = scenario.given.find( + (fact) => + fact.kind === "stack" && + fact.stackId === selection?.stackId && + fact.lifecycle === "stopped", + ); + const projectsCredentialUpdate = scenario.expected.outcome === "update" && - (output.json?.previous_credentials_values_id !== changedCredentials.previousValuesId || - output.json?.credentials_values_id !== changedCredentials.valuesId || + (output.json?.previous_credentials_values_id !== undefined || + output.json?.credentials_values_id !== undefined); + const appliesConfiguredCredentialChange = + configuredCredentialChange !== undefined && selectedStoppedStack?.kind === "stack"; + if (projectsCredentialUpdate && configuredCredentialChange === undefined) { + errors.push(`${scenario.id}: credential change requires configured old and new values`); + } + if ( + (projectsCredentialUpdate || appliesConfiguredCredentialChange) && + configuredCredentialChange !== undefined && + (selectedStoppedStack?.kind !== "stack" || + scenario.expected.outcome !== "update" || + output.json?.previous_credentials_values_id !== + configuredCredentialChange.previousValuesId || + output.json?.credentials_values_id !== configuredCredentialChange.valuesId || !scenario.expected.writes.some( (write) => write.target === "managed-state" && write.operation === "update", )) @@ -3925,6 +4060,13 @@ export const validateManagedStackContractFixtures = ( ) { errors.push(`${scenario.id}: registry tombstone must be reported by every projection`); } + if ( + write.target === "registry" && + write.operation === "delete" && + scenario.expected.details?.metadata_removed !== true + ) { + errors.push(`${scenario.id}: registry deletion must report removed metadata`); + } const requiredRuntimeOperation = write.target === "runtime-state" && write.operation === "start" @@ -4047,6 +4189,13 @@ const branchHistoryFixture = ( given: [ { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, { kind: "branch", name: "feat-a", contextId: "context-feat", checkedOut: true }, + { + kind: "branch-history", + branch: "feat-a", + operation: label, + fromCommit: "commit-a", + toCommit: "commit-b", + }, { kind: "identity-transition", operation, from: "commit-a", to: "commit-b" }, { kind: "stack", From 3398c7f4155f5a89f6b5e9e0eac450a5fb54743b Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 11 Aug 2026 02:52:50 +0200 Subject: [PATCH 31/41] test(stack): close remaining evidence triggers --- .../0015-managed-stack-contract-fixtures.md | 14 ++- ...managed-stack-contract.integration.test.ts | 111 ++++++++++++++++++ packages/stack/src/managed-stack-contract.ts | 88 ++++++++++++-- 3 files changed, 201 insertions(+), 12 deletions(-) diff --git a/docs/adr/0015-managed-stack-contract-fixtures.md b/docs/adr/0015-managed-stack-contract-fixtures.md index 577dbf7920..c9b64be372 100644 --- a/docs/adr/0015-managed-stack-contract-fixtures.md +++ b/docs/adr/0015-managed-stack-contract-fixtures.md @@ -96,6 +96,7 @@ declare a stopped lifecycle, every managed creation must declare an absent targe must declare legacy state that is explicitly absent or incompatible, and every bootstrap copy must declare an absent target plus fully compatible stopped legacy state; managed state creation and registry publication must imply each other, as must managed-state deletion and registry tombstoning; +projections of fully absent legacy state must remain non-mutating; reuse must begin from an existing target, runtime stop effects must begin from a running stack, and target-existence facts cannot contradict stack facts; running-source and credential-drift reports must begin from running sources, idempotent deletion @@ -123,14 +124,17 @@ identify their actual target; managed port ownership requires an owner stack ID every projection; exact-port conflicts must bind the same configured, occupied, and projected port; sticky reuse and collision must bind automatic config intent, assignment key, assignment port, and the selected target, while an exact-port change must bind the previous assignment and newly -configured value, including the transition from a removed exact key to sticky automatic state; a +configured value and affirmative drift projections, including the transition from a removed exact +key to sticky automatic state; fresh omitted automatic allocations must report host-wide sticky +state, and a sibling automatic port allocation fixture must use unique service ports through the public managed start action without reusing sibling-owned ports; concurrent creation must bind its action target, contender count, result cardinality, and single-publication outcome to the declared race; persisted-runtime preflight failures must identify a stopped stack; a successful bootstrap retry must follow an explicit failed attempt that was rolled back; failed-copy rollback requires explicit failure injection against an absent target and a compatible stopped legacy source; automatic runtime selection must reuse -persisted state owned by the selected stack or follow Docker-then-qualified-native availability, +persisted state owned by the selected stack or evaluate both fresh candidates before following +Docker-then-qualified-native availability, and total automatic failure must project both declared unavailability reasons; successful explicit or configured runtime requests must match availability and every runtime/source projection, while runtime-drift reports must bind a running stack, its persisted runtime, the distinct configured @@ -139,11 +143,13 @@ native preflight results must agree with the action platform and complete qualif service partitions; credential create, update, and copy operations must prove that global state contains references instead of plaintext, configured credential changes must bind configured state with distinct old and -new references in both directions, and local, +new references in both directions, stable-default projections require matching local-default state, +and local, persisted, and copied-legacy credentials must retain their declared reference and source; data-preserving prune must begin with mutable data and delete metadata only for an orphaned record with matching orphaned stack state; tracked identity markers -must remain untouched; caller-provided state roots must agree with isolated managed options and the +must remain untouched; optional state-root inputs must be strings, and caller-provided state roots +must agree with isolated managed options and the observed no-default-state boundary, and a CLI-projected managed status must begin from an active record, running stack, and matching persisted runtime; native qualification facts must partition the service matrix, use a declared platform, and match the platform passed to preflight; status diff --git a/packages/stack/src/managed-stack-contract.integration.test.ts b/packages/stack/src/managed-stack-contract.integration.test.ts index fedbb47c07..a346b61084 100644 --- a/packages/stack/src/managed-stack-contract.integration.test.ts +++ b/packages/stack/src/managed-stack-contract.integration.test.ts @@ -3656,6 +3656,117 @@ describe("managed stack acceptance contract", () => { expect(validateManagedStackContractFixtures([configuredCredentialUpdateLosesState])).toContain( `${configuredCredentialUpdateScenario.id}: credential change requires configured old and new values`, ); + + const branchHistoryLosesIntent = { + ...branchHistoryEvidenceScenario, + given: branchHistoryEvidenceScenario.given.filter((fact) => fact.kind !== "branch-history"), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([branchHistoryLosesIntent])).toContain( + `${branchHistoryEvidenceScenario.id}: branch history preservation requires matching branch evidence`, + ); + + const freshAutomaticPortsScenario = findScenario( + "ports.new-target-allocates-and-persists-omitted-ports", + ); + const freshAutomaticPortsHidePersistence = { + ...freshAutomaticPortsScenario, + expected: { + ...freshAutomaticPortsScenario.expected, + details: { + ...freshAutomaticPortsScenario.expected.details, + host_wide: false, + sticky: false, + }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([freshAutomaticPortsHidePersistence])).toContain( + `${freshAutomaticPortsScenario.id}: fresh automatic ports must be host-wide sticky assignments`, + ); + + const absentLegacyScenario = findScenario("bootstrap.absent-legacy-starts-fresh"); + const absentLegacyReportsMutation = { + ...absentLegacyScenario, + expected: { + ...absentLegacyScenario.expected, + details: { ...absentLegacyScenario.expected.details, legacy_state_mutated: true }, + output: { + ...absentLegacyScenario.expected.output, + json: { + ...absentLegacyScenario.expected.output.json, + legacy_state_mutated: true, + }, + }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([absentLegacyReportsMutation])).toContain( + `${absentLegacyScenario.id}: absent legacy bootstrap must not report mutation`, + ); + + const stableDefaultCredentialsScenario = findScenario( + "credentials.omitted-values-use-stable-defaults", + ); + const stableDefaultCredentialsLoseEvidence = { + ...stableDefaultCredentialsScenario, + given: stableDefaultCredentialsScenario.given.filter( + (fact) => fact.kind !== "credential-state", + ), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([stableDefaultCredentialsLoseEvidence])).toContain( + `${stableDefaultCredentialsScenario.id}: omitted credentials must reuse stable local defaults`, + ); + + const dockerPrecedenceScenario = findScenario("runtime.auto-prefers-docker"); + const dockerPrecedenceLosesNativeCandidate = { + ...dockerPrecedenceScenario, + given: dockerPrecedenceScenario.given.filter( + (fact) => fact.kind !== "runtime-availability" || fact.runtime !== "native", + ), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([dockerPrecedenceLosesNativeCandidate])).toContain( + `${dockerPrecedenceScenario.id}: fresh automatic runtime selection requires Docker and native availability evidence`, + ); + + const resolveStackScenario = findScenario( + "identity.same-checkout-branch-and-name-reuses-stack", + ); + if (resolveStackScenario.when.interface !== "managed-api") { + throw new Error("same-checkout reuse must use the managed API"); + } + const resolveStackUsesMalformedStateRoot = { + ...resolveStackScenario, + when: { + ...resolveStackScenario.when, + input: { ...resolveStackScenario.when.input, stateRoot: 42 }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([resolveStackUsesMalformedStateRoot])).toContain( + `${resolveStackScenario.id}: managed action must use a declared public method`, + ); + + const runningPortDriftScenario = findScenario( + "ports.config-change-on-running-stack-reports-drift", + ); + const runningPortDriftHuman = runningPortDriftScenario.expected.output.human; + if (runningPortDriftHuman === undefined) { + throw new Error("running port drift human output is required"); + } + const runningPortDriftReportsFalse = { + ...runningPortDriftScenario, + expected: { + ...runningPortDriftScenario.expected, + output: { + ...runningPortDriftScenario.expected.output, + human: { + ...runningPortDriftHuman, + fields: { ...runningPortDriftHuman.fields, drift: "false" }, + }, + json: { ...runningPortDriftScenario.expected.output.json, drift: false }, + }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([runningPortDriftReportsFalse])).toContain( + `${runningPortDriftScenario.id}: exact port change must bind previous assignment and requested value`, + ); }); it("covers the approved identity journeys through public commands and APIs", () => { diff --git a/packages/stack/src/managed-stack-contract.ts b/packages/stack/src/managed-stack-contract.ts index 91740e1ef4..0dba6c2099 100644 --- a/packages/stack/src/managed-stack-contract.ts +++ b/packages/stack/src/managed-stack-contract.ts @@ -447,6 +447,14 @@ const managedStackContractStringSetEquals = ( const managedStackNamePattern = /^(?=.{1,63}$)[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/; +const requiredBranchHistoryByScenarioId: Readonly< + Partial> +> = { + "identity.branch-commit-preserves-context": "commit", + "identity.branch-rebase-preserves-context": "rebase", + "identity.branch-reset-preserves-context": "reset", +}; + const isManagedStartAction = (action: ManagedStackContractAction): boolean => (action.interface === "cli" && action.argv[0] === "start") || (action.interface === "managed-api" && @@ -526,7 +534,8 @@ export const validateManagedStackContractFixtures = ( isManagedStackContractRecord(input.effectiveConfig))) || (method === "resolveStack" && typeof input.cwd === "string" && - typeof input.stackName === "string") || + typeof input.stackName === "string" && + (input.stateRoot === undefined || typeof input.stateRoot === "string")) || (method === "resolveStackNames" && typeof input.cwd === "string" && Array.isArray(input.stackNames)) || @@ -1047,6 +1056,15 @@ export const validateManagedStackContractFixtures = ( `${scenario.id}: persisted automatic runtime requires matching availability evidence`, ); } + if ( + persistedRuntime === undefined && + (dockerAvailability?.kind !== "runtime-availability" || + nativeAvailability?.kind !== "runtime-availability") + ) { + errors.push( + `${scenario.id}: fresh automatic runtime selection requires Docker and native availability evidence`, + ); + } const resolvedRuntime = persistedRuntime?.kind === "persisted-runtime" && persistedAvailability?.kind === "runtime-availability" @@ -1917,12 +1935,14 @@ export const validateManagedStackContractFixtures = ( ); } } + const requiredBranchHistory = requiredBranchHistoryByScenarioId[scenario.id]; const branchHistory = scenario.given.find((fact) => fact.kind === "branch-history"); - if (branchHistory?.kind === "branch-history") { + const branchHistoryOperation = requiredBranchHistory ?? branchHistory?.operation; + if (branchHistoryOperation !== undefined) { const expectedTransitionOperation = - branchHistory.operation === "commit" + branchHistoryOperation === "commit" ? "branch-commit" - : branchHistory.operation === "rebase" + : branchHistoryOperation === "rebase" ? "branch-rebase" : "branch-reset"; const historyTransition = scenario.given.find( @@ -1930,9 +1950,16 @@ export const validateManagedStackContractFixtures = ( fact.kind === "identity-transition" && fact.operation === expectedTransitionOperation, ); const historyBranch = scenario.given.find( - (fact) => fact.kind === "branch" && fact.name === branchHistory.branch && fact.checkedOut, + (fact) => + fact.kind === "branch" && + branchHistory?.kind === "branch-history" && + fact.name === branchHistory.branch && + fact.checkedOut, ); if ( + branchHistory?.kind !== "branch-history" || + (requiredBranchHistory !== undefined && + branchHistory.operation !== requiredBranchHistory) || historyTransition?.kind !== "identity-transition" || historyTransition.from !== branchHistory.fromCommit || historyTransition.to !== branchHistory.toCommit || @@ -2481,6 +2508,21 @@ export const validateManagedStackContractFixtures = ( `${scenario.id}: managed creation must declare legacy state absent or incompatible`, ); } + const legacyIsFullyAbsent = + legacyState?.kind === "legacy-state" && + legacyState.lifecycle === "absent" && + legacyState.database === "absent" && + legacyState.storage === "absent" && + legacyState.credentials === "absent"; + if ( + legacyIsFullyAbsent && + ((scenario.expected.details?.legacy_state_mutated !== undefined && + scenario.expected.details.legacy_state_mutated !== false) || + (output.json?.legacy_state_mutated !== undefined && + output.json.legacy_state_mutated !== false)) + ) { + errors.push(`${scenario.id}: absent legacy bootstrap must not report mutation`); + } if ( legacyState?.kind === "legacy-state" && legacyState.lifecycle === "stopped" && @@ -3061,9 +3103,11 @@ export const validateManagedStackContractFixtures = ( output.json?.config_key === changedExactPort.key && output.json?.running_port === changedExactPort.previousValue && output.json?.requested_port === changedExactPort.value && + output.json?.drift === true && output.human?.fields.configKey === changedExactPort.key && output.human?.fields.runningPort === String(changedExactPort.previousValue) && - output.human?.fields.configuredPort === String(changedExactPort.value)); + output.human?.fields.configuredPort === String(changedExactPort.value) && + output.human?.fields.drift === "true"); exactPortChangeMatches = previousAssignment?.kind === "port-assignment" && previousAssignment.intent === "exact" && @@ -3232,6 +3276,29 @@ export const validateManagedStackContractFixtures = ( const projectedIntents = scenario.expected.output.api?.intents; const requestedEntries = Object.entries(scenario.when.input.portIntents); const configPorts = scenario.given.filter((fact) => fact.kind === "config-port"); + const actionStackId = scenario.when.input.stackId; + const createsManagedTarget = + typeof actionStackId === "string" && + scenario.expected.writes.some( + (write) => + write.target === "managed-state" && + write.operation === "create" && + write.id === actionStackId, + ); + const requestsOmittedAutomaticPort = requestedEntries.some( + ([key, intent]) => + intent === "automatic" && + configPorts.some( + (fact) => fact.key === key && fact.intent === "automatic" && fact.source === "omitted", + ), + ); + if ( + createsManagedTarget && + requestsOmittedAutomaticPort && + (scenario.expected.details?.host_wide !== true || scenario.expected.details.sticky !== true) + ) { + errors.push(`${scenario.id}: fresh automatic ports must be host-wide sticky assignments`); + } if (isManagedStackContractRecord(projectedPorts)) { const allocatedPorts = new Set(); for (const port of Object.values(projectedPorts)) { @@ -3737,9 +3804,14 @@ export const validateManagedStackContractFixtures = ( const stableLocalCredentials = scenario.given.find( (fact) => fact.kind === "credential-state" && fact.source === "local-default", ); + const projectsStableLocalCredentials = + scenario.expected.details?.generated_per_start !== undefined || + output.json?.credentials_source === "local-default" || + output.json?.credentials_stable !== undefined; if ( - stableLocalCredentials?.kind === "credential-state" && - (scenario.expected.details?.credential_values_id !== stableLocalCredentials.valuesId || + (stableLocalCredentials?.kind === "credential-state" || projectsStableLocalCredentials) && + (stableLocalCredentials?.kind !== "credential-state" || + scenario.expected.details?.credential_values_id !== stableLocalCredentials.valuesId || scenario.expected.details.generated_per_start !== false || output.json?.credentials_source !== "local-default" || output.json?.credentials_stable !== true) From d219a4bff45b4f878f8613c54dc4cb2feaa0ce45 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 11 Aug 2026 07:15:14 +0200 Subject: [PATCH 32/41] test(stack): tighten managed contract invariants --- .../0015-managed-stack-contract-fixtures.md | 10 +- ...managed-stack-contract.integration.test.ts | 124 ++++++++-- packages/stack/src/managed-stack-contract.ts | 224 +++++++++++------- 3 files changed, 244 insertions(+), 114 deletions(-) diff --git a/docs/adr/0015-managed-stack-contract-fixtures.md b/docs/adr/0015-managed-stack-contract-fixtures.md index c9b64be372..c607a970bd 100644 --- a/docs/adr/0015-managed-stack-contract-fixtures.md +++ b/docs/adr/0015-managed-stack-contract-fixtures.md @@ -29,6 +29,13 @@ of the M1 managed-stack behavior. Each scenario records: Opaque symbolic IDs make the same scenario reusable across an in-memory repository, a persistent adapter, the managed package, and future CLI integration tests. Linear records the decision history and links to implementation work; it is not a second source of executable truth. +Each scenario starts from its own isolated `given` state, so a symbolic ID or port has no shared +state across scenarios unless a fixture explicitly references another scenario. Conformance drivers +must reset their repository between scenarios. + +Structured error and warning codes follow ADR 0001's `SCREAMING_SNAKE_CASE` convention. A `report` +is always read-only, and an `error` has no state mutation or runtime effect except the explicit +failed-bootstrap rollback, whose only permitted effects remove partial managed state. `@supabase/stack` has two distinct public responsibilities: @@ -140,7 +147,8 @@ or configured runtime requests must match availability and every runtime/source runtime-drift reports must bind a running stack, its persisted runtime, the distinct configured runtime, and every service projection; native preflight results must agree with the action platform and complete qualified and failed -service partitions; +service partitions, and the qualification matrix must use the package service catalog's current +service names and default versions rather than duplicating them; credential create, update, and copy operations must prove that global state contains references instead of plaintext, configured credential changes must bind configured state with distinct old and new references in both directions, stable-default projections require matching local-default state, diff --git a/packages/stack/src/managed-stack-contract.integration.test.ts b/packages/stack/src/managed-stack-contract.integration.test.ts index a346b61084..7aadd27d58 100644 --- a/packages/stack/src/managed-stack-contract.integration.test.ts +++ b/packages/stack/src/managed-stack-contract.integration.test.ts @@ -18,6 +18,7 @@ import { type ManagedStackContractScenario, validateManagedStackContractFixtures, } from "./testing.ts"; +import { DEFAULT_VERSIONS, SERVICE_NAMES } from "./versions.ts"; describe("managed stack acceptance contract", () => { it("keeps every shared scenario readable and executable through a public interface", () => { @@ -1144,7 +1145,7 @@ describe("managed stack acceptance contract", () => { throw new Error("invalid stack name JSON fixture is required"); } const { code: omittedCode, ...jsonWithoutCode } = invalidNameJson; - expect(omittedCode).toBe("invalid_stack_name"); + expect(omittedCode).toBe("INVALID_STACK_NAME"); const jsonProjectionWithoutCode = { ...invalidNameScenario, expected: { @@ -1461,6 +1462,78 @@ describe("managed stack acceptance contract", () => { ); }); + it("enforces diagnostic codes and non-mutating report and error outcomes", () => { + const readOnlyScenario: ManagedStackContractScenario | undefined = + managedStackContractFixtures.find( + ({ id }) => id === "identity.branch-copy-read-only-does-not-write", + ); + const ambiguousScenario: ManagedStackContractScenario | undefined = + managedStackContractFixtures.find( + ({ id }) => id === "identity.branch-copy-ambiguous-read-only", + ); + if (readOnlyScenario === undefined || ambiguousScenario?.expected.error === undefined) { + throw new Error("branch-copy read-only fixtures are required"); + } + + const mutatingReport = { + ...readOnlyScenario, + expected: { + ...readOnlyScenario.expected, + writes: [{ target: "registry", operation: "update", id: "context-main" }], + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([mutatingReport])).toContain( + `${readOnlyScenario.id}: report outcome must not mutate state`, + ); + + const mutatingError = { + ...ambiguousScenario, + expected: { + ...ambiguousScenario.expected, + writes: [{ target: "managed-state", operation: "update", id: "context-main" }], + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([mutatingError])).toContain( + `${ambiguousScenario.id}: error outcome must not mutate state outside rollback cleanup`, + ); + + const lowerCaseCode = { + ...ambiguousScenario, + expected: { + ...ambiguousScenario.expected, + error: { ...ambiguousScenario.expected.error, code: "ambiguous_context_owner" }, + output: { + ...ambiguousScenario.expected.output, + json: { + ...ambiguousScenario.expected.output.json, + code: "ambiguous_context_owner", + }, + }, + }, + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([lowerCaseCode])).toContain( + `${ambiguousScenario.id}: diagnostic code ambiguous_context_owner must use SCREAMING_SNAKE_CASE`, + ); + + const ambiguityWithoutClaim = { + ...ambiguousScenario, + given: ambiguousScenario.given.filter((fact) => fact.kind !== "identity-claim"), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([ambiguityWithoutClaim])).toContain( + `${ambiguousScenario.id}: ambiguous context must bind at least two claiming branches to its projections`, + ); + + const ambiguityWithoutTransition = { + ...ambiguousScenario, + given: ambiguousScenario.given.filter( + (fact) => fact.kind !== "identity-transition" || fact.operation !== "branch-copy", + ), + } satisfies ManagedStackContractScenario; + expect(validateManagedStackContractFixtures([ambiguityWithoutTransition])).toContain( + `${ambiguousScenario.id}: ambiguous context must bind at least two claiming branches to its projections`, + ); + }); + it("binds public action inputs and state facts to observable results", () => { const findScenario = (id: string): ManagedStackContractScenario => { const scenario = managedStackContractFixtures.find((candidate) => candidate.id === id); @@ -2656,11 +2729,11 @@ describe("managed stack acceptance contract", () => { ...qualificationScenario, expected: { ...qualificationScenario.expected, - error: { ...qualificationScenario.expected.error, code: "unrelated_error" }, + error: { ...qualificationScenario.expected.error, code: "UNRELATED_ERROR" }, }, } satisfies ManagedStackContractScenario; expect(validateManagedStackContractFixtures([qualificationUsesUnrelatedError])).toContain( - `${qualificationScenario.id}: supported unqualified native platform must use native_platform_not_qualified`, + `${qualificationScenario.id}: supported unqualified native platform must use NATIVE_PLATFORM_NOT_QUALIFIED`, ); const persistedRuntimeWithoutProvenance = { @@ -3930,21 +4003,7 @@ describe("managed stack acceptance contract", () => { expect(managedNativeServiceMatrix).toEqual({ targetPlatforms: ["darwin-arm64", "linux-amd64", "linux-arm64"], unsupportedPlatforms: ["darwin-x64", "windows-amd64", "windows-arm64"], - services: [ - ["postgres", "17.6.1.160"], - ["postgrest", "v14.16"], - ["auth", "v2.195.0"], - ["edge-runtime", "v1.74.3"], - ["realtime", "v2.124.1"], - ["storage", "v1.68.9"], - ["pgmeta", "v0.96.8"], - ["studio", "2026.08.03-sha-022b374"], - ["analytics", "v1.50.1"], - ["pooler", "v2.9.10"], - ["mailpit", "v1.30.2"], - ["vector", "v0.53.0"], - ["imgproxy", "v3.27.2"], - ], + services: SERVICE_NAMES.map((service) => [service, DEFAULT_VERSIONS[service]]), }); expect( @@ -4180,10 +4239,27 @@ describe("managed stack acceptance contract", () => { argv: ["status", "--experimental", "--output", "json"], cwd: "checkout-a", }); + expect(scenario?.given).toEqual( + expect.arrayContaining([ + { + kind: "identity-transition", + operation: "branch-copy", + from: "main", + to: "feat-copy", + originalExists: true, + }, + { + kind: "identity-claim", + scope: "context", + id: "context-main", + status: "ambiguous", + }, + ]), + ); expect(scenario?.expected).toEqual({ outcome: "error", error: { - code: "ambiguous_context_owner", + code: "AMBIGUOUS_CONTEXT_OWNER", message: "Branches feat-copy and main both claim context-main", recovery: [ "supabase stack inspect --context-id context-main", @@ -4206,7 +4282,7 @@ describe("managed stack acceptance contract", () => { }, json: { outcome: "error", - code: "ambiguous_context_owner", + code: "AMBIGUOUS_CONTEXT_OWNER", context_id: "context-main", branches: ["feat-copy", "main"], recovery: [ @@ -4245,7 +4321,7 @@ describe("managed stack acceptance contract", () => { expected: { outcome: "error", error: { - code: "exact_port_occupied", + code: "EXACT_PORT_OCCUPIED", recovery: [ "Stop the process using port 54321", "Change api.port in supabase/config.toml", @@ -4257,7 +4333,7 @@ describe("managed stack acceptance contract", () => { output: { json: { outcome: "error", - code: "exact_port_occupied", + code: "EXACT_PORT_OCCUPIED", port: 54321, config_key: "api.port", }, @@ -4292,7 +4368,7 @@ describe("managed stack acceptance contract", () => { expected: { outcome: "error", error: { - code: "runtime_conflicts_with_persisted_stack", + code: "RUNTIME_CONFLICTS_WITH_PERSISTED_STACK", recovery: [ "Start a new named stack with --stack ", "Delete and recreate stack-main-default", @@ -4303,7 +4379,7 @@ describe("managed stack acceptance contract", () => { output: { json: { outcome: "error", - code: "runtime_conflicts_with_persisted_stack", + code: "RUNTIME_CONFLICTS_WITH_PERSISTED_STACK", persisted_runtime: "docker", requested_runtime: "native", }, diff --git a/packages/stack/src/managed-stack-contract.ts b/packages/stack/src/managed-stack-contract.ts index 0dba6c2099..c704f2bfdf 100644 --- a/packages/stack/src/managed-stack-contract.ts +++ b/packages/stack/src/managed-stack-contract.ts @@ -1,3 +1,4 @@ +import { DEFAULT_VERSIONS, SERVICE_NAMES } from "./ServiceCatalog.ts"; import type { ServiceName } from "./ServiceName.ts"; export type ManagedStackContractArea = @@ -360,21 +361,10 @@ export interface ManagedNativeServiceMatrix { export const managedNativeServiceMatrix: ManagedNativeServiceMatrix = { targetPlatforms: ["darwin-arm64", "linux-amd64", "linux-arm64"], unsupportedPlatforms: ["darwin-x64", "windows-amd64", "windows-arm64"], - services: [ - ["postgres", "17.6.1.160"], - ["postgrest", "v14.16"], - ["auth", "v2.195.0"], - ["edge-runtime", "v1.74.3"], - ["realtime", "v2.124.1"], - ["storage", "v1.68.9"], - ["pgmeta", "v0.96.8"], - ["studio", "2026.08.03-sha-022b374"], - ["analytics", "v1.50.1"], - ["pooler", "v2.9.10"], - ["mailpit", "v1.30.2"], - ["vector", "v0.53.0"], - ["imgproxy", "v3.27.2"], - ], + services: SERVICE_NAMES.map((service): readonly [ServiceName, string] => [ + service, + DEFAULT_VERSIONS[service], + ]), }; const defineManagedStackContractFixtures = < @@ -668,7 +658,7 @@ export const validateManagedStackContractFixtures = ( if (output.human === undefined && output.json === undefined && output.api === undefined) { errors.push(`${scenario.id}: at least one observable output is required`); } - if (scenario.expected.error?.code === "mutually_exclusive_stack_selectors") { + if (scenario.expected.error?.code === "MUTUALLY_EXCLUSIVE_STACK_SELECTORS") { const stopArgv = scenario.when.interface === "cli" && scenario.when.argv[0] === "stop" ? scenario.when.argv @@ -913,7 +903,7 @@ export const validateManagedStackContractFixtures = ( ) { errors.push(`${scenario.id}: unnamed CLI start must select the default stack`); } - if (scenario.expected.error?.code === "invalid_stack_name") { + if (scenario.expected.error?.code === "INVALID_STACK_NAME") { const declaredNames = scenario.given.find((fact) => fact.kind === "stack-names"); if ( explicitActionStackName === undefined || @@ -1130,7 +1120,7 @@ export const validateManagedStackContractFixtures = ( ) { if ( scenario.expected.outcome !== "error" || - scenario.expected.error?.code !== "persisted_runtime_unavailable" || + scenario.expected.error?.code !== "PERSISTED_RUNTIME_UNAVAILABLE" || typeof persistedAvailability.reason !== "string" || output.json?.runtime !== persistedRuntime.runtime || output.json.reason !== persistedAvailability.reason || @@ -1152,7 +1142,7 @@ export const validateManagedStackContractFixtures = ( output.json.native_reason === nativeAvailability.reason; if ( scenario.expected.outcome !== "error" || - scenario.expected.error?.code !== "no_runtime_available" || + scenario.expected.error?.code !== "NO_RUNTIME_AVAILABLE" || scenario.expected.runtimeEffects.some((effect) => effect.operation === "start") || !unavailableReasonsMatch ) { @@ -1565,6 +1555,30 @@ export const validateManagedStackContractFixtures = ( errors.push(`${scenario.id}: non-error outcome cannot include error metadata`); } + for (const diagnostic of [scenario.expected.error, scenario.expected.warning]) { + if (diagnostic !== undefined && !/^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*$/.test(diagnostic.code)) { + errors.push( + `${scenario.id}: diagnostic code ${diagnostic.code} must use SCREAMING_SNAKE_CASE`, + ); + } + } + + const hasMutation = + scenario.expected.writes.length > 0 || scenario.expected.runtimeEffects.length > 0; + if (scenario.expected.outcome === "report" && hasMutation) { + errors.push(`${scenario.id}: report outcome must not mutate state`); + } + if (scenario.expected.outcome === "error" && hasMutation) { + const isBootstrapRollback = scenario.expected.error?.code === "LEGACY_BOOTSTRAP_FAILED"; + const containsOnlyRollbackCleanup = + scenario.expected.writes.every( + (write) => write.target === "managed-state" && write.operation === "delete", + ) && scenario.expected.runtimeEffects.every((effect) => effect.operation === "delete"); + if (!isBootstrapRollback || !containsOnlyRollbackCleanup) { + errors.push(`${scenario.id}: error outcome must not mutate state outside rollback cleanup`); + } + } + if (scenario.expected.warning !== undefined) { if (scenario.expected.outcome === "error") { errors.push(`${scenario.id}: error outcome cannot also include warning metadata`); @@ -1763,7 +1777,7 @@ export const validateManagedStackContractFixtures = ( `${scenario.id}: copied branch transition must match live original and checked-out branch facts`, ); } - if (scenario.expected.warning?.code === "copied_branch_context_conflict") { + if (scenario.expected.warning?.code === "COPIED_BRANCH_CONTEXT_CONFLICT") { if ( typeof branchCopy.from !== "string" || typeof branchCopy.to !== "string" || @@ -1995,7 +2009,7 @@ export const validateManagedStackContractFixtures = ( errors.push(`${scenario.id}: symlink alias must report its canonical checkout path`); } } - if (scenario.expected.error?.code === "ambiguous_context_owner") { + if (scenario.expected.error?.code === "AMBIGUOUS_CONTEXT_OWNER") { const projectedContextId = output.json?.context_id; const projectedBranches = output.json?.branches; const projectedBranchNames = @@ -2013,9 +2027,28 @@ export const validateManagedStackContractFixtures = ( ?.split(",") .map((branch) => branch.trim()) .filter((branch) => branch.length > 0); + const ambiguousContextClaim = scenario.given.find( + (fact) => + fact.kind === "identity-claim" && + fact.scope === "context" && + fact.id === projectedContextId && + fact.status === "ambiguous", + ); + const copiedBranchTransition = scenario.given.find( + (fact) => + fact.kind === "identity-transition" && + fact.operation === "branch-copy" && + typeof fact.from === "string" && + claimingBranchNames.includes(fact.from) && + typeof fact.to === "string" && + claimingBranchNames.includes(fact.to) && + fact.originalExists === true, + ); if ( typeof projectedContextId !== "string" || new Set(claimingBranchNames).size < 2 || + ambiguousContextClaim?.kind !== "identity-claim" || + copiedBranchTransition?.kind !== "identity-transition" || projectedBranchNames === undefined || !managedStackContractStringSetEquals(claimingBranchNames, projectedBranchNames) || humanBranchNames === undefined || @@ -2105,7 +2138,7 @@ export const validateManagedStackContractFixtures = ( errors.push(`${scenario.id}: detached reuse must declare the commit transition`); } - if (scenario.expected.error?.code === "duplicate_checkout_claim") { + if (scenario.expected.error?.code === "DUPLICATE_CHECKOUT_CLAIM") { const copiedWorkspace = scenario.given.find( (fact) => fact.kind === "workspace" && fact.path === actionCwd, ); @@ -2137,7 +2170,7 @@ export const validateManagedStackContractFixtures = ( } } - if (scenario.expected.error?.code === "checkout_path_inaccessible") { + if (scenario.expected.error?.code === "CHECKOUT_PATH_INACCESSIBLE") { const movedWorkspace = scenario.given.find( (fact) => fact.kind === "workspace" && fact.path === actionCwd, ); @@ -2203,7 +2236,7 @@ export const validateManagedStackContractFixtures = ( } } - if (scenario.expected.error?.code === "ambiguous_folder_to_git_identity") { + if (scenario.expected.error?.code === "AMBIGUOUS_FOLDER_TO_GIT_IDENTITY") { const folderToGitTransition = scenario.given.find( (fact) => fact.kind === "identity-transition" && fact.operation === "folder-to-git", ); @@ -2284,7 +2317,7 @@ export const validateManagedStackContractFixtures = ( const platformSupported = managedNativeServiceMatrix.targetPlatforms.includes( fact.platform, ); - if (!platformSupported && scenario.expected.error?.code !== "native_platform_unsupported") { + if (!platformSupported && scenario.expected.error?.code !== "NATIVE_PLATFORM_UNSUPPORTED") { errors.push( `${scenario.id}: unsupported native platform must use the dedicated preflight error`, ); @@ -2293,10 +2326,10 @@ export const validateManagedStackContractFixtures = ( if ( platformSupported && !platformQualified && - scenario.expected.error?.code !== "native_platform_not_qualified" + scenario.expected.error?.code !== "NATIVE_PLATFORM_NOT_QUALIFIED" ) { errors.push( - `${scenario.id}: supported unqualified native platform must use native_platform_not_qualified`, + `${scenario.id}: supported unqualified native platform must use NATIVE_PLATFORM_NOT_QUALIFIED`, ); } if ( @@ -2341,7 +2374,7 @@ export const validateManagedStackContractFixtures = ( } } - if (scenario.expected.error?.code === "native_platform_unsupported") { + if (scenario.expected.error?.code === "NATIVE_PLATFORM_UNSUPPORTED") { const qualification = scenario.given.find((fact) => fact.kind === "native-qualification"); const projectedPlatforms = output.json?.supported_platforms; if ( @@ -2600,7 +2633,7 @@ export const validateManagedStackContractFixtures = ( } } - if (scenario.expected.error?.code === "persisted_runtime_unavailable") { + if (scenario.expected.error?.code === "PERSISTED_RUNTIME_UNAVAILABLE") { if (scenario.expected.details?.switched_to_docker !== false) { errors.push(`${scenario.id}: unavailable persisted runtime must not report a switch`); } @@ -2968,7 +3001,7 @@ export const validateManagedStackContractFixtures = ( } } - if (scenario.expected.error?.code === "exact_port_occupied") { + if (scenario.expected.error?.code === "EXACT_PORT_OCCUPIED") { const configuredPort = scenario.given.find( (fact) => fact.kind === "config-port" && fact.intent === "exact", ); @@ -2998,7 +3031,7 @@ export const validateManagedStackContractFixtures = ( const managedPortOwners = scenario.given.flatMap((fact) => fact.kind === "occupied-port" && fact.owner === "managed-stack" ? [fact] : [], ); - if (scenario.expected.error?.code === "exact_port_occupied" && managedPortOwners.length > 0) { + if (scenario.expected.error?.code === "EXACT_PORT_OCCUPIED" && managedPortOwners.length > 0) { if (selection === undefined) { errors.push(`${scenario.id}: managed sibling port conflict requires a selected target`); } @@ -3021,7 +3054,7 @@ export const validateManagedStackContractFixtures = ( } } - if (scenario.expected.error?.code === "sticky_port_occupied") { + if (scenario.expected.error?.code === "STICKY_PORT_OCCUPIED") { if (selection === undefined) { errors.push(`${scenario.id}: sticky port conflict requires a selected target`); } else { @@ -3072,7 +3105,7 @@ export const validateManagedStackContractFixtures = ( (scenario.expected.outcome === "update" && typeof output.json?.previous_port === "number" && typeof output.json?.port === "number") || - scenario.expected.warning?.code === "running_stack_config_drift"; + scenario.expected.warning?.code === "RUNNING_STACK_CONFIG_DRIFT"; if (expectsExactPortChange) { let exactPortChangeMatches = false; if ( @@ -3093,7 +3126,7 @@ export const validateManagedStackContractFixtures = ( output.json?.port === changedExactPort.value && output.human?.fields.apiUrl === `http://127.0.0.1:${changedExactPort.value}`); const driftProjectionMatches = - scenario.expected.warning?.code !== "running_stack_config_drift" || + scenario.expected.warning?.code !== "RUNNING_STACK_CONFIG_DRIFT" || (scenario.given.some( (fact) => fact.kind === "stack" && @@ -3371,7 +3404,7 @@ export const validateManagedStackContractFixtures = ( } } - if (scenario.expected.warning?.code === "running_stack_runtime_drift") { + if (scenario.expected.warning?.code === "RUNNING_STACK_RUNTIME_DRIFT") { const persistedRuntime = scenario.given.find( (fact) => fact.kind === "persisted-runtime" && fact.stackId === selection?.stackId, ); @@ -3439,7 +3472,7 @@ export const validateManagedStackContractFixtures = ( } } - if (scenario.expected.error?.code === "runtime_conflicts_with_persisted_stack") { + if (scenario.expected.error?.code === "RUNTIME_CONFLICTS_WITH_PERSISTED_STACK") { if (selection === undefined) { errors.push(`${scenario.id}: persisted runtime conflict requires a selected target`); } else { @@ -3472,7 +3505,7 @@ export const validateManagedStackContractFixtures = ( } } - if (scenario.expected.error?.code === "runtime_selection_conflict") { + if (scenario.expected.error?.code === "RUNTIME_SELECTION_CONFLICT") { const explicitRequest = runtimeRequests.find( (fact) => fact.source === "cli" || fact.source === "managed-api", ); @@ -3494,10 +3527,10 @@ export const validateManagedStackContractFixtures = ( } if ( - scenario.expected.error?.code === "docker_unavailable" || - scenario.expected.error?.code === "native_unavailable" + scenario.expected.error?.code === "DOCKER_UNAVAILABLE" || + scenario.expected.error?.code === "NATIVE_UNAVAILABLE" ) { - const unavailableRuntime = scenario.expected.error.code.startsWith("docker") + const unavailableRuntime = scenario.expected.error.code.startsWith("DOCKER") ? "docker" : "native"; const explicitRequest = runtimeRequests.find( @@ -3536,7 +3569,7 @@ export const validateManagedStackContractFixtures = ( (fact) => fact.kind === "managed-target" && fact.exists === false, ); if ( - scenario.expected.error?.code === "legacy_source_running" || + scenario.expected.error?.code === "LEGACY_SOURCE_RUNNING" || (isManagedStartAction(scenario.when) && legacySource?.kind === "legacy-state" && legacySource.lifecycle === "running" && @@ -3554,7 +3587,7 @@ export const validateManagedStackContractFixtures = ( fact.exists, ) || scenario.expected.outcome !== "error" || - scenario.expected.error?.code !== "legacy_source_running" || + scenario.expected.error?.code !== "LEGACY_SOURCE_RUNNING" || scenario.expected.writes.length > 0 || scenario.expected.runtimeEffects.length > 0 ) { @@ -3595,7 +3628,7 @@ export const validateManagedStackContractFixtures = ( } } - if (scenario.expected.error?.code === "legacy_bootstrap_failed") { + if (scenario.expected.error?.code === "LEGACY_BOOTSTRAP_FAILED") { const rollbackStackId = scenario.when.interface === "managed-api" && scenario.when.method === "startStack" ? scenario.when.input.stackId @@ -3869,7 +3902,7 @@ export const validateManagedStackContractFixtures = ( errors.push(`${scenario.id}: credential update must bind old and new persisted references`); } - if (scenario.expected.warning?.code === "running_stack_credentials_drift") { + if (scenario.expected.warning?.code === "RUNNING_STACK_CREDENTIALS_DRIFT") { if ( selection === undefined || changedCredentials?.kind !== "credential-state" || @@ -4313,7 +4346,7 @@ const invalidStackNameFixture = ( expected: { outcome: "error", error: { - code: "invalid_stack_name", + code: "INVALID_STACK_NAME", message: `${stackName} is not a lowercase DNS-label name`, recovery: ["Use default or a lowercase DNS-label name such as feature-a"], }, @@ -4327,7 +4360,7 @@ const invalidStackNameFixture = ( }, json: { outcome: "error", - code: "invalid_stack_name", + code: "INVALID_STACK_NAME", stack_name: stackName, recovery: ["Use default or a lowercase DNS-label name such as feature-a"], }, @@ -5168,7 +5201,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ expected: { outcome: "error", error: { - code: "duplicate_checkout_claim", + code: "DUPLICATE_CHECKOUT_CLAIM", message: "Two live paths claim checkout-a", recovery: [ "Use the original checkout at /work/project-a", @@ -5180,7 +5213,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ output: { json: { outcome: "error", - code: "duplicate_checkout_claim", + code: "DUPLICATE_CHECKOUT_CLAIM", checkout_id: "checkout-a", paths: ["/copy/project-a", "/work/project-a"], recovery: [ @@ -5332,7 +5365,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ expected: { outcome: "error", error: { - code: "checkout_path_inaccessible", + code: "CHECKOUT_PATH_INACCESSIBLE", message: "Cannot verify whether /mnt/project-a still owns checkout-a", recovery: [ "Restore access to /mnt/project-a and retry", @@ -5352,7 +5385,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ }, json: { outcome: "error", - code: "checkout_path_inaccessible", + code: "CHECKOUT_PATH_INACCESSIBLE", checkout_id: "checkout-a", recovery: [ "Restore access to /mnt/project-a and retry", @@ -5549,7 +5582,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ expected: { outcome: "report", warning: { - code: "copied_branch_context_conflict", + code: "COPIED_BRANCH_CONTEXT_CONFLICT", message: "feat-copy copied context-main from main", recovery: [ "Run supabase start --experimental to create an independent context for feat-copy", @@ -5560,7 +5593,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ output: { json: { outcome: "report", - code: "copied_branch_context_conflict", + code: "COPIED_BRANCH_CONTEXT_CONFLICT", branch: "feat-copy", owner: "main", context_id: "context-main", @@ -5861,7 +5894,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ expected: { outcome: "error", error: { - code: "ambiguous_folder_to_git_identity", + code: "AMBIGUOUS_FOLDER_TO_GIT_IDENTITY", message: "Multiple live claims can preserve the folder identity", recovery: [ "Inspect the claims and explicitly adopt one identity or create a fresh Git identity", @@ -5873,7 +5906,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ output: { json: { outcome: "error", - code: "ambiguous_folder_to_git_identity", + code: "AMBIGUOUS_FOLDER_TO_GIT_IDENTITY", recovery: [ "Inspect the claims and explicitly adopt one identity or create a fresh Git identity", ], @@ -6214,7 +6247,7 @@ const additionalPortContractFixtures = defineManagedStackContractFixtures([ stackName: "default", }, error: { - code: "sticky_port_occupied", + code: "STICKY_PORT_OCCUPIED", message: "stack-feat-default owns sticky api.port 55421, but it is in use", recovery: [ "Stop the process using port 55421", @@ -6226,7 +6259,7 @@ const additionalPortContractFixtures = defineManagedStackContractFixtures([ output: { json: { outcome: "error", - code: "sticky_port_occupied", + code: "STICKY_PORT_OCCUPIED", stack_id: "stack-feat-default", port: 55421, config_key: "api.port", @@ -6326,7 +6359,7 @@ const additionalPortContractFixtures = defineManagedStackContractFixtures([ outcome: "report", selection: mainDefaultSelection, warning: { - code: "running_stack_config_drift", + code: "RUNNING_STACK_CONFIG_DRIFT", message: "api.port is running on 54321 but config requires 55321", recovery: ["Run supabase stop --experimental, then supabase start --experimental"], }, @@ -6346,7 +6379,7 @@ const additionalPortContractFixtures = defineManagedStackContractFixtures([ }, json: { outcome: "report", - code: "running_stack_config_drift", + code: "RUNNING_STACK_CONFIG_DRIFT", stack_id: "stack-main-default", config_key: "api.port", running_port: 54321, @@ -6427,7 +6460,7 @@ const additionalPortContractFixtures = defineManagedStackContractFixtures([ expected: { outcome: "error", error: { - code: "legacy_source_running", + code: "LEGACY_SOURCE_RUNNING", message: "The matching legacy stack is still running on api.port 54321", recovery: ["Stop the legacy stack, then retry supabase start --experimental"], }, @@ -6442,7 +6475,7 @@ const additionalPortContractFixtures = defineManagedStackContractFixtures([ output: { json: { outcome: "error", - code: "legacy_source_running", + code: "LEGACY_SOURCE_RUNNING", port: 54321, config_key: "api.port", allocation_attempted: false, @@ -6539,7 +6572,7 @@ const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ expected: { outcome: "error", error: { - code: "runtime_selection_conflict", + code: "RUNTIME_SELECTION_CONFLICT", message: "CLI requests docker while config.toml requests native", recovery: ["Remove one runtime override", "Make the CLI and config runtime values agree"], }, @@ -6548,7 +6581,7 @@ const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ output: { json: { outcome: "error", - code: "runtime_selection_conflict", + code: "RUNTIME_SELECTION_CONFLICT", cli_runtime: "docker", config_runtime: "native", recovery: ["Remove one runtime override", "Make the CLI and config runtime values agree"], @@ -6652,7 +6685,7 @@ const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ expected: { outcome: "error", error: { - code: "no_runtime_available", + code: "NO_RUNTIME_AVAILABLE", message: "Neither Docker nor native can run this stack", recovery: [ "Start or install Docker", @@ -6672,7 +6705,7 @@ const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ }, json: { outcome: "error", - code: "no_runtime_available", + code: "NO_RUNTIME_AVAILABLE", docker_reason: "daemon unavailable", native_reason: "platform graph not qualified", recovery: [ @@ -6705,7 +6738,7 @@ const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ expected: { outcome: "error", error: { - code: "docker_unavailable", + code: "DOCKER_UNAVAILABLE", message: "Docker was explicitly requested but its daemon is unavailable", recovery: ["Start Docker", "Remove --runtime docker to use automatic selection"], }, @@ -6715,7 +6748,7 @@ const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ output: { json: { outcome: "error", - code: "docker_unavailable", + code: "DOCKER_UNAVAILABLE", requested_runtime: "docker", reason: "daemon unavailable", fallback_attempted: false, @@ -6787,7 +6820,7 @@ const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ outcome: "error", selection: mainDefaultSelection, error: { - code: "persisted_runtime_unavailable", + code: "PERSISTED_RUNTIME_UNAVAILABLE", message: "stack-main-default uses native, but a required artifact is missing", recovery: [ "Restore the native prerequisite", @@ -6801,7 +6834,7 @@ const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ output: { json: { outcome: "error", - code: "persisted_runtime_unavailable", + code: "PERSISTED_RUNTIME_UNAVAILABLE", stack_id: "stack-main-default", runtime: "native", reason: "artifact missing", @@ -6839,7 +6872,7 @@ const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ outcome: "report", selection: mainDefaultSelection, warning: { - code: "running_stack_runtime_drift", + code: "RUNNING_STACK_RUNTIME_DRIFT", message: "stack-main-default runs with docker but config requests native", recovery: [ "Keep using Docker by restoring runtime = docker", @@ -6862,7 +6895,7 @@ const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ }, json: { outcome: "report", - code: "running_stack_runtime_drift", + code: "RUNNING_STACK_RUNTIME_DRIFT", stack_id: "stack-main-default", runtime: "docker", configured_runtime: "native", @@ -6922,7 +6955,7 @@ const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ expected: { outcome: "error", error: { - code: "native_platform_not_qualified", + code: "NATIVE_PLATFORM_NOT_QUALIFIED", message: "linux-amd64 is missing qualification for imgproxy", recovery: ["Use Docker", "Complete imgproxy qualification for linux-amd64"], }, @@ -6966,7 +6999,7 @@ const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ expected: { outcome: "error", error: { - code: "native_platform_unsupported", + code: "NATIVE_PLATFORM_UNSUPPORTED", message: "Native mode is not qualified on darwin-x64", recovery: ["Use Docker", "Use darwin-arm64, linux-amd64, or linux-arm64"], }, @@ -6975,7 +7008,7 @@ const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ output: { json: { outcome: "error", - code: "native_platform_unsupported", + code: "NATIVE_PLATFORM_UNSUPPORTED", platform: "darwin-x64", supported_platforms: ["darwin-arm64", "linux-amd64", "linux-arm64"], recovery: ["Use Docker", "Use darwin-arm64, linux-amd64, or linux-arm64"], @@ -7003,7 +7036,7 @@ const selectorConflictFixture = ( expected: { outcome: "error", error: { - code: "mutually_exclusive_stack_selectors", + code: "MUTUALLY_EXCLUSIVE_STACK_SELECTORS", message: "Choose exactly one of contextual, --stack, --stack-id, or --all selection", recovery: ["Remove all but one stack selector"], }, @@ -7017,7 +7050,7 @@ const selectorConflictFixture = ( }, json: { outcome: "error", - code: "mutually_exclusive_stack_selectors", + code: "MUTUALLY_EXCLUSIVE_STACK_SELECTORS", selectors: selectorSummary.split(", "), recovery: ["Remove all but one stack selector"], }, @@ -7158,7 +7191,7 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ expected: { outcome: "error", error: { - code: "legacy_source_running", + code: "LEGACY_SOURCE_RUNNING", message: "The legacy stack must be stopped before it can be copied", recovery: ["Stop the legacy stack", "Retry supabase start --experimental"], }, @@ -7172,7 +7205,7 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ output: { json: { outcome: "error", - code: "legacy_source_running", + code: "LEGACY_SOURCE_RUNNING", legacy_source_stopped: false, managed_target_published: false, recovery: ["Stop the legacy stack", "Retry supabase start --experimental"], @@ -7202,7 +7235,7 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ expected: { outcome: "error", error: { - code: "legacy_bootstrap_failed", + code: "LEGACY_BOOTSTRAP_FAILED", message: "Copying compatible legacy state failed before publication", recovery: ["Retry the same start command after correcting the copy failure"], }, @@ -7216,7 +7249,7 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ output: { api: { outcome: "error", - code: "legacy_bootstrap_failed", + code: "LEGACY_BOOTSTRAP_FAILED", activeTargetExists: false, registryRecordPublished: false, retryable: true, @@ -7479,7 +7512,7 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ outcome: "report", selection: mainDefaultSelection, warning: { - code: "running_stack_credentials_drift", + code: "RUNNING_STACK_CREDENTIALS_DRIFT", message: "Configured auth values differ from the running stack", recovery: ["Run supabase stop --experimental, then supabase start --experimental"], }, @@ -7493,7 +7526,7 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ }, json: { outcome: "report", - code: "running_stack_credentials_drift", + code: "RUNNING_STACK_CREDENTIALS_DRIFT", stack_id: "stack-main-default", drift: true, recovery: ["Run supabase stop --experimental, then supabase start --experimental"], @@ -8100,6 +8133,19 @@ export const managedStackContractFixtures = defineManagedStackContractFixtures([ contextId: "context-main", checkedOut: true, }, + { + kind: "identity-transition", + operation: "branch-copy", + from: "main", + to: "feat-copy", + originalExists: true, + }, + { + kind: "identity-claim", + scope: "context", + id: "context-main", + status: "ambiguous", + }, ], when: { interface: "cli", @@ -8109,7 +8155,7 @@ export const managedStackContractFixtures = defineManagedStackContractFixtures([ expected: { outcome: "error", error: { - code: "ambiguous_context_owner", + code: "AMBIGUOUS_CONTEXT_OWNER", message: "Branches feat-copy and main both claim context-main", recovery: [ "supabase stack inspect --context-id context-main", @@ -8132,7 +8178,7 @@ export const managedStackContractFixtures = defineManagedStackContractFixtures([ }, json: { outcome: "error", - code: "ambiguous_context_owner", + code: "AMBIGUOUS_CONTEXT_OWNER", context_id: "context-main", branches: ["feat-copy", "main"], recovery: [ @@ -8168,7 +8214,7 @@ export const managedStackContractFixtures = defineManagedStackContractFixtures([ expected: { outcome: "error", error: { - code: "exact_port_occupied", + code: "EXACT_PORT_OCCUPIED", message: "api.port requires 54321, but that port is already in use", recovery: [ "Stop the process using port 54321", @@ -8194,7 +8240,7 @@ export const managedStackContractFixtures = defineManagedStackContractFixtures([ }, json: { outcome: "error", - code: "exact_port_occupied", + code: "EXACT_PORT_OCCUPIED", port: 54321, config_key: "api.port", owner: "external-process", @@ -8249,7 +8295,7 @@ export const managedStackContractFixtures = defineManagedStackContractFixtures([ stackName: "default", }, error: { - code: "exact_port_occupied", + code: "EXACT_PORT_OCCUPIED", message: "api.port requires 54321, but stack-main-default already owns that port", recovery: [ "Stop managed stack stack-main-default", @@ -8276,7 +8322,7 @@ export const managedStackContractFixtures = defineManagedStackContractFixtures([ }, json: { outcome: "error", - code: "exact_port_occupied", + code: "EXACT_PORT_OCCUPIED", port: 54321, config_key: "api.port", owner: "managed-stack", @@ -8330,7 +8376,7 @@ export const managedStackContractFixtures = defineManagedStackContractFixtures([ stackName: "default", }, error: { - code: "runtime_conflicts_with_persisted_stack", + code: "RUNTIME_CONFLICTS_WITH_PERSISTED_STACK", message: "stack-main-default uses docker, but start requested native", recovery: [ "Start a new named stack with --stack ", @@ -8354,7 +8400,7 @@ export const managedStackContractFixtures = defineManagedStackContractFixtures([ }, json: { outcome: "error", - code: "runtime_conflicts_with_persisted_stack", + code: "RUNTIME_CONFLICTS_WITH_PERSISTED_STACK", stack_id: "stack-main-default", persisted_runtime: "docker", requested_runtime: "native", From 5b20d308913ebd3a9835430583e00b7e08574bf8 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 11 Aug 2026 07:39:17 +0200 Subject: [PATCH 33/41] test(stack): simplify managed contract scaffolding --- .../0015-managed-stack-contract-fixtures.md | 114 +- .../src/managed-stack-contract-validation.ts | 308 ++ ...managed-stack-contract.integration.test.ts | 3877 +--------------- packages/stack/src/managed-stack-contract.ts | 3954 +---------------- packages/stack/src/testing.ts | 2 +- 5 files changed, 443 insertions(+), 7812 deletions(-) create mode 100644 packages/stack/src/managed-stack-contract-validation.ts diff --git a/docs/adr/0015-managed-stack-contract-fixtures.md b/docs/adr/0015-managed-stack-contract-fixtures.md index c607a970bd..97e88b8ad3 100644 --- a/docs/adr/0015-managed-stack-contract-fixtures.md +++ b/docs/adr/0015-managed-stack-contract-fixtures.md @@ -91,94 +91,25 @@ persistent adapter exist. The implementation issues it unblocks must attach real fixtures. CLI integration coverage begins when a real command boundary exists; a fixture-presence test is not evidence that an unimplemented command already satisfies the behavior. -The fixture validator therefore checks more than catalog shape: selected, written, and effected -identities must be declared rather than relying on absent claims, every API action must use a -declared method with its required public inputs, a selection must belong to the -checkout at the action path, and a selected stack's context and name must match its declared stack -fact; explicit CLI and API stack IDs must match every selected, mutated, effected, and projected -stack target, and requested stack-name sets, exact and automatic ports, runtime overrides, -credential references, injected repositories, and isolated state roots must agree with their facts -and projections; starts of existing stacks must -declare a stopped lifecycle, every managed creation must declare an absent target, every fresh start -must declare legacy state that is explicitly absent or incompatible, and every bootstrap copy must -declare an absent target plus fully compatible stopped legacy state; managed state creation and -registry publication must imply each other, as must managed-state deletion and registry tombstoning; -projections of fully absent legacy state must remain non-mutating; -reuse must begin from an existing target, runtime stop effects must begin from a running stack, and -target-existence facts cannot contradict stack facts; -running-source and credential-drift reports must begin from running sources, idempotent deletion -must begin from a tombstone, orphan deletion must target orphaned state, and failed-copy cleanup may -delete only a target proven absent before the attempt; direct-stack root facts must bind stack and -runtime roots independently to caller-supplied inputs and temporary-state behavior, and disposing a -direct stack must delete every declared temporary root; -every state write and runtime effect must identify its target; contextual CLI stack results must -bind their output to a selected target; Git identity writes must use the correct common or worktree -scope, context writes must name the active branch as owner, and adapters cannot recreate an identity -already declared by a checkout; new Git-derived contexts, manual ref replacement, branch deletion -and recreation, detached-commit reuse, and selected linked worktrees must declare the relevant Git -state or transition; branch commit, rebase, and reset preservation must bind an explicit history -fact to the checked-out branch and matching transition; comparisons between branch contexts at one -commit must declare both branch refs at that commit; selected contexts must agree with the active -Git branch or an explicit -checkout-scoped claim; ordinary folders must write their full untracked identity marker to the -action workspace on creation and resolve it on reuse, while Git workspaces cannot trust that local -marker; copied-branch evidence must agree with whether the original branch still exists, read-only -unregistered results require an absent checkout claim, and named-stack API entries must agree with -their deterministic context and stack-ID results; branch deletion must bind the deleted ref to its -checkout, Git state, context, and orphaned stack; managed and sticky port conflicts and -persisted-runtime conflicts must -identify their actual target; managed port ownership requires an owner stack ID that agrees with -every projection; exact-port conflicts must bind the same configured, occupied, and projected port; -sticky reuse and collision must bind automatic config intent, assignment key, assignment port, and -the selected target, while an exact-port change must bind the previous assignment and newly -configured value and affirmative drift projections, including the transition from a removed exact -key to sticky automatic state; fresh omitted automatic allocations must report host-wide sticky -state, and a -sibling automatic port allocation fixture must use unique service ports through the public managed -start action without reusing sibling-owned ports; concurrent creation must bind its action target, -contender count, result cardinality, and single-publication outcome to the declared race; persisted-runtime preflight -failures must identify a stopped stack; a successful bootstrap retry must follow an explicit failed -attempt that was rolled back; failed-copy rollback requires explicit failure injection against an -absent target and a compatible stopped legacy source; automatic runtime selection must reuse -persisted state owned by the selected stack or evaluate both fresh candidates before following -Docker-then-qualified-native availability, -and total automatic failure must project both declared unavailability reasons; successful explicit -or configured runtime requests must match availability and every runtime/source projection, while -runtime-drift reports must bind a running stack, its persisted runtime, the distinct configured -runtime, and every service projection; -native preflight results must agree with the action platform and complete qualified and failed -service partitions, and the qualification matrix must use the package service catalog's current -service names and default versions rather than duplicating them; -credential create, update, and copy operations must prove that global state contains references -instead of plaintext, configured credential changes must bind configured state with distinct old and -new references in both directions, stable-default projections require matching local-default state, -and local, -persisted, and copied-legacy credentials must retain their declared reference and source; -data-preserving prune must begin with mutable data and delete metadata only for an orphaned record -with matching orphaned stack state; tracked identity markers -must remain untouched; optional state-root inputs must be strings, and caller-provided state roots -must agree with isolated managed options and the -observed no-default-state boundary, and a CLI-projected managed status must begin from an active -record, running stack, and matching persisted runtime; native qualification facts must partition the -service matrix, use a declared platform, and match the platform passed to preflight; status -operations must remain read-only reports; repository adapter matrices must be non-empty, unique, and match their declared repository -facts while holding runtime and state-root options constant, and portable runtime matrices must -satisfy the same rules against runtime facts while holding repository and state-root options -constant, while -repository adapter and portable runtime projections must reference a declared scenario, match its -identity, agree on their complete decision, and publish equality flags derived from that comparison; -every invalid stack name and every pair of -mutually exclusive stop selectors must be exercised through a public action; structured JSON -projections must always name their outcome and include the matching structured error or warning -code; destructive stop deletion requires `--no-backup`; destructive runtime effects must map to -mutable-state deletion and runtime-state deletion must stop the running target; other runtime effects -must agree with permitted state writes; duplicate checkout and inaccessible-path failures must bind -their exact claims and paths; explicit runtime failures must bind an unavailable requested runtime, -and unsupported-native failures must use the declared unsupported-platform set; and stable -identity plus exact human and JSON recovery fields cannot contradict the managed result. We -deliberately do not introduce a parallel test-only identity resolver; it would duplicate product -policy before the real managed surface exists and could pass while the production implementation -drifts. +The fixture validator is deliberately fixture lint, not a second implementation of the managed +stack policy. It checks a small set of generic rule families: + +- catalog shape and unique scenario identity; +- referential integrity for selected, written, and effected identities; +- state-write and runtime-effect pairing; +- structured diagnostic and read-only outcome shape; and +- consistency between the managed result and its human, JSON, and API projections. + +The lint implementation lives separately in `managed-stack-contract-validation.ts` so the contract +module remains centered on types and normative scenario data. + +The native qualification matrix derives service names and versions from the package service catalog +so it cannot drift from the shipped manifest. Identity resolution, lifecycle preconditions, port and +runtime selection, bootstrap policy, credential policy, and reclamation semantics belong to the real +managed resolver and engine delivered by the implementation issues below. Further requests to +"validate" those semantics should be covered by running these scenarios against that implementation, +not by expanding this lint into a parallel rule engine. A new lint rule is appropriate only when it +protects a generic fixture-format invariant across behavior areas. ## Implementation Handoff @@ -236,10 +167,3 @@ managed surface explicitly. 4. **Put the whole matrix in E2E tests**: rejected because the suite would be slow and failure diagnosis poor. E2E remains the fallback for boundaries that integration tests cannot exercise faithfully. - -## See Also - -- [CLI-2102](https://linear.app/supabase/issue/CLI-2102/contract-encode-approved-behavior-as-cross-layer-acceptance-fixtures) -- [CLI-2103](https://linear.app/supabase/issue/CLI-2103/contract-freeze-project-checkout-worktree-branch-and-named-stack) -- [CLI-2104](https://linear.app/supabase/issue/CLI-2104/contract-freeze-legacy-migration-declared-port-stop-and-rollback) -- [CLI-2105](https://linear.app/supabase/issue/CLI-2105/contract-freeze-runtime-selection-naming-precedence-and-persistence) diff --git a/packages/stack/src/managed-stack-contract-validation.ts b/packages/stack/src/managed-stack-contract-validation.ts new file mode 100644 index 0000000000..1d1f3c3608 --- /dev/null +++ b/packages/stack/src/managed-stack-contract-validation.ts @@ -0,0 +1,308 @@ +import type { + ManagedStackContractJson, + ManagedStackContractScenario, +} from "./managed-stack-contract.ts"; + +export const validateManagedStackContractFixtures = ( + fixtures: ReadonlyArray, +): ReadonlyArray => { + const errors: Array = []; + const scenarioIds = new Set(); + + for (const scenario of fixtures) { + if (scenarioIds.has(scenario.id)) { + errors.push(`${scenario.id}: duplicate scenario ID`); + } + scenarioIds.add(scenario.id); + + if (!scenario.id.startsWith(`${scenario.area}.`)) { + errors.push(`${scenario.id}: ID must start with ${scenario.area}.`); + } + if (scenario.title.trim().length === 0) { + errors.push(`${scenario.id}: title is required`); + } + if (scenario.given.length === 0) { + errors.push(`${scenario.id}: at least one given fact is required`); + } + + if (scenario.when.interface === "cli" || scenario.when.interface === "git") { + if (scenario.when.argv.length === 0) { + errors.push(`${scenario.id}: argv must contain a public command`); + } + if (scenario.when.cwd.trim().length === 0) { + errors.push(`${scenario.id}: cwd is required for command scenarios`); + } + } else if (scenario.when.method.trim().length === 0) { + errors.push(`${scenario.id}: public API method is required`); + } + + const { output } = scenario.expected; + if (output.human === undefined && output.json === undefined && output.api === undefined) { + errors.push(`${scenario.id}: at least one observable output is required`); + } + + if (scenario.expected.outcome === "error") { + if (scenario.expected.error === undefined) { + errors.push(`${scenario.id}: error outcome requires structured error metadata`); + } else if (scenario.expected.error.recovery.length === 0) { + errors.push(`${scenario.id}: error outcome requires recovery guidance`); + } + } else if (scenario.expected.error !== undefined) { + errors.push(`${scenario.id}: non-error outcome cannot include error metadata`); + } + + if (scenario.expected.warning !== undefined) { + if (scenario.expected.outcome === "error") { + errors.push(`${scenario.id}: error outcome cannot also include warning metadata`); + } + if (scenario.expected.warning.recovery.length === 0) { + errors.push(`${scenario.id}: warning metadata requires recovery guidance`); + } + } + + for (const diagnostic of [scenario.expected.error, scenario.expected.warning]) { + if (diagnostic !== undefined && !/^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*$/.test(diagnostic.code)) { + errors.push( + `${scenario.id}: diagnostic code ${diagnostic.code} must use SCREAMING_SNAKE_CASE`, + ); + } + } + + const hasMutation = + scenario.expected.writes.length > 0 || scenario.expected.runtimeEffects.length > 0; + if (scenario.expected.outcome === "report" && hasMutation) { + errors.push(`${scenario.id}: report outcome must not mutate state`); + } + if (scenario.expected.outcome === "error" && hasMutation) { + const isBootstrapRollback = scenario.expected.error?.code === "LEGACY_BOOTSTRAP_FAILED"; + const containsOnlyRollbackCleanup = + scenario.expected.writes.every( + (write) => write.target === "managed-state" && write.operation === "delete", + ) && scenario.expected.runtimeEffects.every((effect) => effect.operation === "delete"); + if (!isBootstrapRollback || !containsOnlyRollbackCleanup) { + errors.push(`${scenario.id}: error outcome must not mutate state outside rollback cleanup`); + } + } + + const declaredIds = new Set(); + for (const fact of scenario.given) { + switch (fact.kind) { + case "branch": + declaredIds.add(fact.contextId); + break; + case "checkout": + declaredIds.add(fact.projectId); + declaredIds.add(fact.checkoutId); + break; + case "credential-state": + declaredIds.add(fact.valuesId); + if (fact.previousValuesId !== undefined) { + declaredIds.add(fact.previousValuesId); + } + break; + case "direct-stack-state": + declaredIds.add(fact.handle); + for (const root of fact.temporaryRoots) { + declaredIds.add(root.stateId); + } + break; + case "identity-claim": + declaredIds.add(fact.id); + break; + case "identity-marker": + declaredIds.add(fact.markerId); + declaredIds.add(fact.projectId); + declaredIds.add(fact.checkoutId); + declaredIds.add(fact.contextId); + break; + case "managed-record": + case "managed-target": + case "operation-result": + case "persisted-runtime": + declaredIds.add(fact.stackId); + break; + case "occupied-port": + if (fact.ownerId !== undefined) { + declaredIds.add(fact.ownerId); + } + break; + case "port-assignment": + declaredIds.add(fact.stackId); + break; + case "stack": + declaredIds.add(fact.contextId); + declaredIds.add(fact.stackId); + break; + default: + break; + } + } + + for (const write of scenario.expected.writes) { + if ( + write.operation === "copy" || + write.operation === "create" || + write.operation === "publish" + ) { + declaredIds.add(write.id); + } + + if (write.target === "identity-marker") { + declaredIds.add(write.projectId); + declaredIds.add(write.checkoutId); + declaredIds.add(write.contextId); + } + } + for (const write of scenario.expected.writes) { + if ( + write.operation !== "copy" && + write.operation !== "create" && + write.operation !== "publish" && + !declaredIds.has(write.id) + ) { + errors.push( + `${scenario.id}: ${write.target} ${write.operation} references undeclared ID ${write.id}`, + ); + } + } + + if (scenario.expected.selection !== undefined) { + for (const id of [ + scenario.expected.selection.projectId, + scenario.expected.selection.checkoutId, + scenario.expected.selection.contextId, + scenario.expected.selection.stackId, + ]) { + if (!declaredIds.has(id)) { + errors.push(`${scenario.id}: selection references undeclared ID ${id}`); + } + } + } + + for (const effect of scenario.expected.runtimeEffects) { + if (!declaredIds.has(effect.stackId)) { + errors.push(`${scenario.id}: runtime effect references undeclared ID ${effect.stackId}`); + } + + const hasWrite = scenario.expected.writes.some((write) => { + if (write.id !== effect.stackId) { + return false; + } + switch (effect.operation) { + case "copy": + return write.target === "managed-state" && write.operation === "copy"; + case "delete": + return write.target === "managed-state" && write.operation === "delete"; + case "start": + return write.target === "runtime-state" && write.operation === "start"; + case "stop": + return ( + write.target === "runtime-state" && + (write.operation === "delete" || write.operation === "update") + ); + } + }); + if (!hasWrite) { + errors.push( + `${scenario.id}: ${effect.operation} runtime effect requires a matching state write`, + ); + } + } + + for (const write of scenario.expected.writes) { + const requiredRuntimeOperation = + write.target === "runtime-state" && write.operation === "start" + ? "start" + : write.target === "runtime-state" && + (write.operation === "delete" || write.operation === "update") + ? "stop" + : write.target === "managed-state" && write.operation === "copy" + ? "copy" + : write.target === "managed-state" && write.operation === "delete" + ? "delete" + : undefined; + if ( + requiredRuntimeOperation !== undefined && + !scenario.expected.runtimeEffects.some( + (effect) => effect.operation === requiredRuntimeOperation && effect.stackId === write.id, + ) + ) { + errors.push( + `${scenario.id}: ${write.target} ${write.operation} requires a matching runtime effect`, + ); + } + } + + const checkProjection = ( + projection: Readonly> | undefined, + key: string, + expected: ManagedStackContractJson, + ): void => { + if (projection?.[key] !== undefined && projection[key] !== expected) { + errors.push(`${scenario.id}: projected ${key} disagrees with the managed result`); + } + }; + + if (output.json !== undefined && output.json.outcome === undefined) { + errors.push(`${scenario.id}: JSON projection requires an outcome`); + } + const diagnosticCode = scenario.expected.error?.code ?? scenario.expected.warning?.code; + if ( + output.json !== undefined && + diagnosticCode !== undefined && + output.json.code === undefined + ) { + errors.push(`${scenario.id}: JSON projection requires a code`); + } + for (const projection of [output.json, output.api]) { + checkProjection(projection, "outcome", scenario.expected.outcome); + if (diagnosticCode !== undefined) { + checkProjection(projection, "code", diagnosticCode); + } + } + + const selection = scenario.expected.selection; + if (selection !== undefined) { + checkProjection(output.json, "project_id", selection.projectId); + checkProjection(output.json, "checkout_id", selection.checkoutId); + checkProjection(output.json, "context_id", selection.contextId); + checkProjection(output.json, "stack_id", selection.stackId); + checkProjection(output.json, "stack_name", selection.stackName); + checkProjection(output.api, "projectId", selection.projectId); + checkProjection(output.api, "checkoutId", selection.checkoutId); + checkProjection(output.api, "contextId", selection.contextId); + checkProjection(output.api, "stackId", selection.stackId); + checkProjection(output.api, "stackName", selection.stackName); + checkProjection(output.human?.fields, "projectId", selection.projectId); + checkProjection(output.human?.fields, "checkoutId", selection.checkoutId); + checkProjection(output.human?.fields, "contextId", selection.contextId); + checkProjection(output.human?.fields, "stackId", selection.stackId); + checkProjection(output.human?.fields, "stack", selection.stackName); + checkProjection(output.human?.fields, "stackName", selection.stackName); + } + + const expectedRecovery = + scenario.expected.error?.recovery ?? scenario.expected.warning?.recovery; + if ( + output.human !== undefined && + expectedRecovery !== undefined && + (output.human.recovery === undefined || + output.human.recovery.length !== expectedRecovery.length || + output.human.recovery.some((step, index) => step !== expectedRecovery[index])) + ) { + errors.push(`${scenario.id}: human recovery disagrees with the managed result`); + } + const jsonRecovery = output.json?.recovery; + if ( + output.json !== undefined && + expectedRecovery !== undefined && + (!Array.isArray(jsonRecovery) || + jsonRecovery.length !== expectedRecovery.length || + jsonRecovery.some((step, index) => step !== expectedRecovery[index])) + ) { + errors.push(`${scenario.id}: JSON recovery disagrees with the managed result`); + } + } + + return errors; +}; diff --git a/packages/stack/src/managed-stack-contract.integration.test.ts b/packages/stack/src/managed-stack-contract.integration.test.ts index 7aadd27d58..c553483320 100644 --- a/packages/stack/src/managed-stack-contract.integration.test.ts +++ b/packages/stack/src/managed-stack-contract.integration.test.ts @@ -14,7 +14,6 @@ import { createStack } from "./node.ts"; import { managedNativeServiceMatrix, managedStackContractFixtures, - type ManagedStackContractFact, type ManagedStackContractScenario, validateManagedStackContractFixtures, } from "./testing.ts"; @@ -25,3821 +24,108 @@ describe("managed stack acceptance contract", () => { expect(validateManagedStackContractFixtures(managedStackContractFixtures)).toEqual([]); }); - it("rejects contract edits that make IDs, effects, and projections disagree", () => { - const scenario: ManagedStackContractScenario | undefined = managedStackContractFixtures.find( - ({ id }) => id === "identity.return-to-branch-reuses-stack", - ); - if (scenario === undefined) { - throw new Error("identity.return-to-branch-reuses-stack fixture is required"); - } - if ( - scenario.expected.selection === undefined || - scenario.expected.output.human === undefined || - scenario.expected.output.json === undefined - ) { - throw new Error("identity.return-to-branch-reuses-stack must select and project a stack"); - } - - const missingStartWrite = { - ...scenario, - expected: { ...scenario.expected, writes: [] }, - }; - expect(validateManagedStackContractFixtures([missingStartWrite])).toContain( - `${scenario.id}: start runtime effect requires a matching state write`, - ); - - const undeclaredSelection = { - ...scenario, - expected: { - ...scenario.expected, - selection: { ...scenario.expected.selection, stackId: "stack-undeclared" }, - }, - }; - expect(validateManagedStackContractFixtures([undeclaredSelection])).toContain( - `${scenario.id}: selection references undeclared ID stack-undeclared`, - ); - - const independentBranchesScenario: ManagedStackContractScenario | undefined = - managedStackContractFixtures.find( - ({ id }) => id === "identity.same-commit-different-branches-are-independent", - ); - if ( - independentBranchesScenario === undefined || - independentBranchesScenario.expected.selection === undefined - ) { - throw new Error( - "identity.same-commit-different-branches-are-independent selection is required", - ); - } - const selectionWithWrongContext = { - ...independentBranchesScenario, - expected: { - ...independentBranchesScenario.expected, - selection: { - ...independentBranchesScenario.expected.selection, - contextId: "context-main", - }, - output: { - ...independentBranchesScenario.expected.output, - api: { - ...independentBranchesScenario.expected.output.api, - contextId: "context-main", - }, - }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([selectionWithWrongContext])).toContain( - `${independentBranchesScenario.id}: selected stack stack-feat-default belongs to context context-feat, not context-main`, - ); - - const selectionWithWrongName = { - ...independentBranchesScenario, - expected: { - ...independentBranchesScenario.expected, - selection: { - ...independentBranchesScenario.expected.selection, - stackName: "review", - }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([selectionWithWrongName])).toContain( - `${independentBranchesScenario.id}: selected stack stack-feat-default is named default, not review`, - ); - - const linkedWorktreeScenario: ManagedStackContractScenario | undefined = - managedStackContractFixtures.find( - ({ id }) => id === "identity.linked-worktrees-share-project-not-checkout", - ); - if ( - linkedWorktreeScenario?.expected.selection === undefined || - linkedWorktreeScenario.expected.output.api === undefined - ) { - throw new Error("identity.linked-worktrees-share-project-not-checkout fixture is required"); - } - const siblingCheckoutSelection = { - ...linkedWorktreeScenario, - expected: { - ...linkedWorktreeScenario.expected, - selection: { ...linkedWorktreeScenario.expected.selection, checkoutId: "checkout-a" }, - output: { - ...linkedWorktreeScenario.expected.output, - api: { ...linkedWorktreeScenario.expected.output.api, checkoutId: "checkout-a" }, - }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([siblingCheckoutSelection])).toContain( - `${linkedWorktreeScenario.id}: selection must use checkout checkout-b for worktree-b`, - ); - - const namedStackScenario: ManagedStackContractScenario | undefined = - managedStackContractFixtures.find( - ({ id }) => id === "identity.named-stacks-are-context-scoped", + it("lints structural, referential, effect, and projection mistakes", () => { + const findScenario = (id: string): ManagedStackContractScenario => { + const scenario: ManagedStackContractScenario | undefined = managedStackContractFixtures.find( + (candidate) => candidate.id === id, ); - if (namedStackScenario === undefined || namedStackScenario.when.interface !== "cli") { - throw new Error("identity.named-stacks-are-context-scoped fixture is required"); - } - const actionSelectingDefaultStack = { - ...namedStackScenario, - when: { - ...namedStackScenario.when, - argv: namedStackScenario.when.argv.map((arg) => (arg === "review" ? "default" : arg)), - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([actionSelectingDefaultStack])).toContain( - `${namedStackScenario.id}: explicit stack name default disagrees with selected stack review`, - ); - - const undeclaredWrite = { - ...scenario, - expected: { - ...scenario.expected, - writes: [{ target: "runtime-state", operation: "start", id: "stack-undeclared" }], - runtimeEffects: [{ operation: "start", stackId: "stack-undeclared" }], - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([undeclaredWrite])).toContain( - `${scenario.id}: runtime-state start references undeclared ID stack-undeclared`, - ); - - const divergentProjection = { - ...scenario, - expected: { - ...scenario.expected, - output: { - ...scenario.expected.output, - json: { ...scenario.expected.output.json, outcome: "create" }, - }, - }, - }; - expect(validateManagedStackContractFixtures([divergentProjection])).toContain( - `${scenario.id}: projected outcome disagrees with the managed result`, - ); - - const divergentHumanProjection = { - ...scenario, - expected: { - ...scenario.expected, - output: { - ...scenario.expected.output, - human: { - ...scenario.expected.output.human, - fields: { ...scenario.expected.output.human.fields, stackId: "stack-other" }, - }, - }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([divergentHumanProjection])).toContain( - `${scenario.id}: projected stackId disagrees with the managed result`, - ); - - const existingTarget: ManagedStackContractFact = { - kind: "managed-target", - stackId: "stack-main-default", - exists: true, + if (scenario === undefined) { + throw new Error(`${id} fixture is required`); + } + return scenario; }; - const ambiguousExistingStart = { - ...scenario, - given: [...scenario.given.filter(({ kind }) => kind !== "stack"), existingTarget], - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([ambiguousExistingStart])).toContain( - `${scenario.id}: starting existing stack stack-main-default requires an explicit stopped lifecycle`, - ); - - const trackedMarkerScenario = managedStackContractFixtures.find( - ({ id }) => id === "identity.fresh-clone-ignores-tracked-marker", - ); - if (trackedMarkerScenario === undefined) { - throw new Error("identity.fresh-clone-ignores-tracked-marker fixture is required"); - } - const identityMarkerWrite = { - target: "identity-marker", - operation: "create", - id: "marker-project-a", - storage: "project-local-untracked", - workspacePath: "checkout-a", - projectId: "project-a", - checkoutId: "checkout-a", - contextId: "context-main", - } satisfies ManagedStackContractScenario["expected"]["writes"][number]; - const trackedMarkerMutation = { - ...trackedMarkerScenario, - expected: { - ...trackedMarkerScenario.expected, - writes: [...trackedMarkerScenario.expected.writes, identityMarkerWrite], - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([trackedMarkerMutation])).toContain( - `${trackedMarkerScenario.id}: a tracked identity marker must remain untouched`, - ); - - const gitWorkspaceScenario: ManagedStackContractScenario | undefined = - managedStackContractFixtures.find( - ({ id }) => id === "identity.fresh-clone-creates-project-and-checkout", - ); - if (gitWorkspaceScenario === undefined) { - throw new Error("identity.fresh-clone-creates-project-and-checkout fixture is required"); - } - const gitWorkspaceMarkerMutation = { - ...gitWorkspaceScenario, - expected: { - ...gitWorkspaceScenario.expected, - writes: [...gitWorkspaceScenario.expected.writes, identityMarkerWrite], - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([gitWorkspaceMarkerMutation])).toContain( - `${gitWorkspaceScenario.id}: Git workspace identity must use Git-local metadata`, - ); + const reuse = findScenario("identity.return-to-branch-reuses-stack"); + const portConflict = findScenario("ports.explicit-port-conflict-fails"); + const readOnly = findScenario("identity.branch-copy-read-only-does-not-write"); if ( - gitWorkspaceScenario.expected.selection === undefined || - gitWorkspaceScenario.expected.output.json === undefined + reuse.expected.selection === undefined || + reuse.expected.output.json === undefined || + portConflict.expected.error === undefined || + portConflict.expected.output.json === undefined ) { - throw new Error("identity.fresh-clone-creates-project-and-checkout selection is required"); - } - const selectionUsingAbsentProject = { - ...gitWorkspaceScenario, - expected: { - ...gitWorkspaceScenario.expected, - selection: { ...gitWorkspaceScenario.expected.selection, projectId: "project-a" }, - output: { - ...gitWorkspaceScenario.expected.output, - json: { ...gitWorkspaceScenario.expected.output.json, project_id: "project-a" }, - }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([selectionUsingAbsentProject])).toContain( - `${gitWorkspaceScenario.id}: selection references undeclared ID project-a`, - ); - - const absentLegacyScenario = managedStackContractFixtures.find( - ({ id }) => id === "bootstrap.absent-legacy-starts-fresh", - ); - if (absentLegacyScenario === undefined) { - throw new Error("bootstrap.absent-legacy-starts-fresh fixture is required"); - } - const unpublishedManagedState = { - ...absentLegacyScenario, - expected: { - ...absentLegacyScenario.expected, - writes: absentLegacyScenario.expected.writes.filter(({ target }) => target !== "registry"), - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([unpublishedManagedState])).toContain( - `${absentLegacyScenario.id}: managed-state create requires registry publication`, - ); - - const publishedWithoutState = { - ...absentLegacyScenario, - expected: { - ...absentLegacyScenario.expected, - writes: absentLegacyScenario.expected.writes.filter( - ({ target }) => target !== "managed-state", - ), - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([publishedWithoutState])).toContain( - `${absentLegacyScenario.id}: registry publication requires managed-state creation or copy`, - ); - - const targetlessStateWrites = { - ...absentLegacyScenario, - expected: { - ...absentLegacyScenario.expected, - writes: absentLegacyScenario.expected.writes.map((write) => - write.target === "managed-state" || write.target === "registry" - ? { ...write, id: "" } - : write, - ), - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([targetlessStateWrites])).toContain( - `${absentLegacyScenario.id}: managed-state write requires a target ID`, - ); - - const targetlessRuntimeEffect = { - ...absentLegacyScenario, - expected: { - ...absentLegacyScenario.expected, - runtimeEffects: absentLegacyScenario.expected.runtimeEffects.map((effect) => ({ - ...effect, - stackId: "", - })), - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([targetlessRuntimeEffect])).toContain( - `${absentLegacyScenario.id}: start runtime effect requires a stack ID`, - ); - - const stoppedStackScenario = managedStackContractFixtures.find( - ({ id }) => id === "reclamation.default-stop-preserves-data", - ); - if (stoppedStackScenario === undefined) { - throw new Error("reclamation.default-stop-preserves-data fixture is required"); - } - const missingStopEffect = { - ...stoppedStackScenario, - expected: { ...stoppedStackScenario.expected, runtimeEffects: [] }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([missingStopEffect])).toContain( - `${stoppedStackScenario.id}: runtime-state update requires a matching runtime effect`, - ); - - const folderToGitScenario = managedStackContractFixtures.find( - ({ id }) => id === "identity.folder-to-git-exact-claim-preserves-identity", - ); - if (folderToGitScenario === undefined) { - throw new Error("identity.folder-to-git-exact-claim-preserves-identity fixture is required"); - } - const incompleteGitIdentity = { - ...folderToGitScenario, - expected: { - ...folderToGitScenario.expected, - writes: folderToGitScenario.expected.writes.filter(({ id }) => id !== "project-a"), - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([incompleteGitIdentity])).toContain( - `${folderToGitScenario.id}: folder-to-Git identity project-a must be persisted in Git-local metadata`, - ); - - const incorrectlyScopedGitIdentity = { - ...folderToGitScenario, - expected: { - ...folderToGitScenario.expected, - writes: folderToGitScenario.expected.writes.map((write) => - write.target === "git-config" && write.id === "project-a" - ? { ...write, scope: "worktree" } - : write, - ), - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([incorrectlyScopedGitIdentity])).toContain( - `${folderToGitScenario.id}: Git identity project-a must use common config scope`, - ); - - const qualificationScenario = managedStackContractFixtures.find( - ({ id }) => id === "native-qualification.all-services-qualify-platform", - ); - if (qualificationScenario === undefined) { - throw new Error("native-qualification.all-services-qualify-platform fixture is required"); - } - const incompleteQualification = { - ...qualificationScenario, - given: qualificationScenario.given.map((fact) => - fact.kind === "native-qualification" - ? { ...fact, qualifiedServices: fact.qualifiedServices.slice(1) } - : fact, - ), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([incompleteQualification])).toContain( - `${qualificationScenario.id}: native qualification omits service postgres`, - ); - - const qualificationForDifferentPlatform = { - ...qualificationScenario, - given: qualificationScenario.given.map((fact) => - fact.kind === "native-qualification" ? { ...fact, platform: "linux-amd64" } : fact, - ), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([qualificationForDifferentPlatform])).toContain( - `${qualificationScenario.id}: native qualification platform must match the preflight action`, - ); - - const qualificationForUnknownPlatform = { - ...qualificationScenario, - given: qualificationScenario.given.map((fact) => - fact.kind === "native-qualification" ? { ...fact, platform: "solaris-sparc" } : fact, - ), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([qualificationForUnknownPlatform])).toContain( - `${qualificationScenario.id}: native qualification uses unknown platform solaris-sparc`, - ); - - const failedQualificationScenario: ManagedStackContractScenario | undefined = - managedStackContractFixtures.find( - ({ id }) => id === "native-qualification.one-service-failure-disables-platform", - ); - if (failedQualificationScenario === undefined) { - throw new Error( - "native-qualification.one-service-failure-disables-platform fixture is required", - ); - } - const qualificationPartitionsContradictResult = { - ...failedQualificationScenario, - given: failedQualificationScenario.given.map((fact) => - fact.kind === "native-qualification" - ? { - ...fact, - qualifiedServices: managedNativeServiceMatrix.services.map(([service]) => service), - failedServices: [], - } - : fact, - ), - } satisfies ManagedStackContractScenario; - expect( - validateManagedStackContractFixtures([qualificationPartitionsContradictResult]), - ).toContain( - `${failedQualificationScenario.id}: native preflight decision must match its qualification partitions`, - ); - - const statusScenario: ManagedStackContractScenario | undefined = - managedStackContractFixtures.find( - ({ id }) => id === "identity.symlink-alias-reuses-checkout", - ); - if (statusScenario === undefined || statusScenario.expected.output.json === undefined) { - throw new Error("identity.symlink-alias-reuses-checkout JSON fixture is required"); - } - const mutatingStatus = { - ...statusScenario, - expected: { - ...statusScenario.expected, - outcome: "reuse", - output: { - ...statusScenario.expected.output, - json: { ...statusScenario.expected.output.json, outcome: "reuse" }, - }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([mutatingStatus])).toContain( - `${statusScenario.id}: successful status operations must report`, - ); - - const stateWritingStatus = { - ...statusScenario, - expected: { - ...statusScenario.expected, - writes: [{ target: "registry", operation: "update", id: "checkout-a" }], - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([stateWritingStatus])).toContain( - `${statusScenario.id}: status operations must not mutate state`, - ); - - const apiStatusScenario: ManagedStackContractScenario | undefined = - managedStackContractFixtures.find( - ({ id }) => id === "identity.same-checkout-branch-and-name-reuses-stack", - ); - if (apiStatusScenario === undefined || apiStatusScenario.expected.output.api === undefined) { - throw new Error( - "identity.same-checkout-branch-and-name-reuses-stack API fixture is required", - ); - } - const apiStatusReturningReuse = { - ...apiStatusScenario, - expected: { - ...apiStatusScenario.expected, - outcome: "reuse", - output: { - ...apiStatusScenario.expected.output, - api: { ...apiStatusScenario.expected.output.api, outcome: "reuse" }, - }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([apiStatusReturningReuse])).toContain( - `${apiStatusScenario.id}: successful status operations must report`, - ); - - const bareWorktreeScenario = managedStackContractFixtures.find( - ({ id }) => id === "identity.bare-repository-linked-worktrees-share-project", - ); - if (bareWorktreeScenario === undefined) { - throw new Error( - "identity.bare-repository-linked-worktrees-share-project fixture is required", - ); + throw new Error("lint examples require selected and structured fixture outputs"); } - const siblingGitStateOnly = { - ...bareWorktreeScenario, - given: bareWorktreeScenario.given.map((fact) => - fact.kind === "git-state" ? { ...fact, workspacePath: "worktree-a" } : fact, - ), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([siblingGitStateOnly])).toContain( - `${bareWorktreeScenario.id}: resolving worktree worktree-b requires its Git state`, - ); - const recreatedCheckoutIdentity = { - ...bareWorktreeScenario, - expected: { - ...bareWorktreeScenario.expected, - writes: [ - ...bareWorktreeScenario.expected.writes, + const cases: ReadonlyArray<{ + readonly fixtures: ReadonlyArray; + readonly expectedError: string; + }> = [ + { + fixtures: [ { - target: "git-config", - operation: "create", - id: "checkout-b", - scope: "worktree", + ...reuse, + expected: { ...reuse.expected, writes: [] }, }, ], + expectedError: `${reuse.id}: start runtime effect requires a matching state write`, }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([recreatedCheckoutIdentity])).toContain( - `${bareWorktreeScenario.id}: Git identity checkout-b is already declared`, - ); - - const unavailableRuntimeScenario = managedStackContractFixtures.find( - ({ id }) => id === "runtime.missing-persisted-prerequisite-fails", - ); - if (unavailableRuntimeScenario === undefined) { - throw new Error("runtime.missing-persisted-prerequisite-fails fixture is required"); - } - const ambiguousRuntimeFailure = { - ...unavailableRuntimeScenario, - given: unavailableRuntimeScenario.given.filter(({ kind }) => kind !== "stack"), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([ambiguousRuntimeFailure])).toContain( - `${unavailableRuntimeScenario.id}: persisted runtime failure for stack-main-default requires an explicit stopped lifecycle`, - ); - - const portabilityScenario: ManagedStackContractScenario | undefined = - managedStackContractFixtures.find( - ({ id }) => id === "api-boundary.managed-surface-is-node-and-bun-portable", - ); - if (portabilityScenario === undefined || portabilityScenario.when.interface !== "managed-api") { - throw new Error("api-boundary.managed-surface-is-node-and-bun-portable fixture is required"); - } - const emptyRuntimeMatrix = { - ...portabilityScenario, - when: { - ...portabilityScenario.when, - input: { ...portabilityScenario.when.input, runtimes: [] }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([apiStatusScenario, emptyRuntimeMatrix])).toContain( - `${portabilityScenario.id}: portable contract must declare its runtimes`, - ); - - const duplicateRuntimeMatrix = { - ...portabilityScenario, - when: { - ...portabilityScenario.when, - input: { ...portabilityScenario.when.input, runtimes: ["node", "node"] }, - }, - } satisfies ManagedStackContractScenario; - expect( - validateManagedStackContractFixtures([apiStatusScenario, duplicateRuntimeMatrix]), - ).toContain(`${portabilityScenario.id}: portable contract runtimes must be unique`); - - const runtimeMatrixMissingFact = { - ...portabilityScenario, - when: { - ...portabilityScenario.when, - input: { ...portabilityScenario.when.input, runtimes: ["node"] }, + { + fixtures: [ + { + ...reuse, + expected: { + ...reuse.expected, + selection: { ...reuse.expected.selection, stackId: "stack-undeclared" }, + }, + }, + ], + expectedError: `${reuse.id}: selection references undeclared ID stack-undeclared`, }, - } satisfies ManagedStackContractScenario; - expect( - validateManagedStackContractFixtures([apiStatusScenario, runtimeMatrixMissingFact]), - ).toContain(`${portabilityScenario.id}: portable runtimes must match declared runtime facts`); - - const divergentPortableResult = { - ...portabilityScenario, - expected: { - ...portabilityScenario.expected, - output: { - ...portabilityScenario.expected.output, - api: { - node: { outcome: "reuse", stackId: "stack-main-default" }, - bun: { outcome: "report", stackId: "stack-main-default" }, - equal: true, + { + fixtures: [ + { + ...reuse, + expected: { + ...reuse.expected, + output: { + ...reuse.expected.output, + json: { ...reuse.expected.output.json, outcome: "create" }, + }, + }, }, - }, + ], + expectedError: `${reuse.id}: projected outcome disagrees with the managed result`, }, - } satisfies ManagedStackContractScenario; - expect( - validateManagedStackContractFixtures([apiStatusScenario, divergentPortableResult]), - ).toContain( - `${portabilityScenario.id}: portable node outcome must match ${apiStatusScenario.id}`, - ); - - const divergentPortableIdentity = { - ...portabilityScenario, - expected: { - ...portabilityScenario.expected, - output: { - ...portabilityScenario.expected.output, - api: { - node: { outcome: "report", stackId: "stack-main-default" }, - bun: { outcome: "report", stackId: "stack-other" }, - equal: true, + { + fixtures: [ + { + ...portConflict, + expected: { + ...portConflict.expected, + error: { ...portConflict.expected.error, code: "exact_port_occupied" }, + output: { + ...portConflict.expected.output, + json: { ...portConflict.expected.output.json, code: "exact_port_occupied" }, + }, + }, }, - }, + ], + expectedError: `${portConflict.id}: diagnostic code exact_port_occupied must use SCREAMING_SNAKE_CASE`, }, - } satisfies ManagedStackContractScenario; - const divergentPortableIdentityErrors = validateManagedStackContractFixtures([ - apiStatusScenario, - divergentPortableIdentity, - ]); - expect(divergentPortableIdentityErrors).toContain( - `${portabilityScenario.id}: portable bun stackId must match ${apiStatusScenario.id}`, - ); - expect(divergentPortableIdentityErrors).toContain( - `${portabilityScenario.id}: portable runtime decisions must be identical`, - ); - - const siblingPortScenario: ManagedStackContractScenario | undefined = - managedStackContractFixtures.find( - ({ id }) => id === "ports.explicit-port-conflict-with-sibling-fails", - ); - if (siblingPortScenario?.expected.selection === undefined) { - throw new Error("ports.explicit-port-conflict-with-sibling-fails selection is required"); - } - const targetOwnsConflictingPort = { - ...siblingPortScenario, - expected: { - ...siblingPortScenario.expected, - selection: { - ...siblingPortScenario.expected.selection, - stackId: "stack-main-default", - }, + { + fixtures: [ + { + ...readOnly, + expected: { + ...readOnly.expected, + writes: [{ target: "registry", operation: "update", id: "context-main" }], + }, + }, + ], + expectedError: `${readOnly.id}: report outcome must not mutate state`, }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([targetOwnsConflictingPort])).toContain( - `${siblingPortScenario.id}: managed sibling port owner must differ from the selected target`, - ); - - const siblingJsonOutput = siblingPortScenario.expected.output.json; - if (siblingJsonOutput === undefined) { - throw new Error("ports.explicit-port-conflict-with-sibling-fails JSON fixture is required"); - } - const projectedWrongPortOwner = { - ...siblingPortScenario, - expected: { - ...siblingPortScenario.expected, - output: { - ...siblingPortScenario.expected.output, - json: { ...siblingJsonOutput, owner_stack_id: "stack-other" }, - }, + { + fixtures: [reuse, reuse], + expectedError: `${reuse.id}: duplicate scenario ID`, }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([projectedWrongPortOwner])).toContain( - `${siblingPortScenario.id}: projected managed port owner must match stack-main-default`, - ); + ]; - const siblingAllocationScenario: ManagedStackContractScenario | undefined = - managedStackContractFixtures.find( - ({ id }) => id === "ports.sibling-targets-allocate-independent-ports", + for (const testCase of cases) { + expect(validateManagedStackContractFixtures(testCase.fixtures)).toContain( + testCase.expectedError, ); - if ( - siblingAllocationScenario === undefined || - siblingAllocationScenario.expected.output.api === undefined - ) { - throw new Error("ports.sibling-targets-allocate-independent-ports API fixture is required"); } - const siblingAllocationCollision = { - ...siblingAllocationScenario, - expected: { - ...siblingAllocationScenario.expected, - output: { - ...siblingAllocationScenario.expected.output, - api: { - ...siblingAllocationScenario.expected.output.api, - ports: { api: 55421, db: 55424 }, - }, - }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([siblingAllocationCollision])).toContain( - `${siblingAllocationScenario.id}: allocated port 55421 conflicts with a sibling target`, - ); - - const duplicateSiblingAllocation = { - ...siblingAllocationScenario, - expected: { - ...siblingAllocationScenario.expected, - output: { - ...siblingAllocationScenario.expected.output, - api: { - ...siblingAllocationScenario.expected.output.api, - ports: { api: 55424, db: 55424 }, - }, - }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([duplicateSiblingAllocation])).toContain( - `${siblingAllocationScenario.id}: allocated port 55424 is assigned more than once`, - ); - - const exactPortScenario: ManagedStackContractScenario | undefined = - managedStackContractFixtures.find(({ id }) => id === "ports.explicit-free-port-is-used"); - if (exactPortScenario === undefined || exactPortScenario.when.interface !== "managed-api") { - throw new Error("ports.explicit-free-port-is-used fixture is required"); - } - const actionRequestingDifferentExactPort = { - ...exactPortScenario, - when: { - ...exactPortScenario.when, - input: { - ...exactPortScenario.when.input, - portIntents: { "api.port": { intent: "exact", port: 54322 } }, - }, - }, - } satisfies ManagedStackContractScenario; - const exactPortErrors = validateManagedStackContractFixtures([ - actionRequestingDifferentExactPort, - ]); - expect(exactPortErrors).toContain( - `${exactPortScenario.id}: exact port request api.port must match its config fact`, - ); - - const stickyPortScenario: ManagedStackContractScenario | undefined = - managedStackContractFixtures.find( - ({ id }) => id === "ports.later-sticky-port-collision-fails", - ); - if (stickyPortScenario === undefined) { - throw new Error("ports.later-sticky-port-collision-fails fixture is required"); - } - const stickyPortWithoutStoppedTarget = { - ...stickyPortScenario, - given: stickyPortScenario.given.filter(({ kind }) => kind !== "stack"), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([stickyPortWithoutStoppedTarget])).toContain( - `${stickyPortScenario.id}: sticky port conflict requires a stopped selected stack`, - ); - - const portUpdateScenario = managedStackContractFixtures.find( - ({ id }) => id === "ports.config-change-on-stopped-stack-applies", - ); - if (portUpdateScenario === undefined) { - throw new Error("ports.config-change-on-stopped-stack-applies fixture is required"); - } - const unboundPortUpdate = { - ...portUpdateScenario, - expected: { ...portUpdateScenario.expected, selection: undefined }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([unboundPortUpdate])).toContain( - `${portUpdateScenario.id}: contextual CLI stack result requires a selected target`, - ); - - const runtimeConflictScenario: ManagedStackContractScenario | undefined = - managedStackContractFixtures.find( - ({ id }) => id === "runtime.persisted-runtime-conflict-fails", - ); - if (runtimeConflictScenario === undefined) { - throw new Error("runtime.persisted-runtime-conflict-fails fixture is required"); - } - const unrelatedPersistedRuntime = { - ...runtimeConflictScenario, - given: runtimeConflictScenario.given.map((fact) => - fact.kind === "persisted-runtime" ? { ...fact, stackId: "stack-other" } : fact, - ), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([unrelatedPersistedRuntime])).toContain( - `${runtimeConflictScenario.id}: persisted runtime must belong to the selected target`, - ); - - const freshCloneScenario = managedStackContractFixtures.find( - ({ id }) => id === "identity.fresh-clone-creates-project-and-checkout", - ); - if (freshCloneScenario === undefined) { - throw new Error("identity.fresh-clone-creates-project-and-checkout fixture is required"); - } - const cloneWithoutGitState = { - ...freshCloneScenario, - given: freshCloneScenario.given.filter(({ kind }) => kind !== "git-state"), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([cloneWithoutGitState])).toContain( - `${freshCloneScenario.id}: creating a Git context requires Git state for the workspace`, - ); - - const refReplacementScenario = managedStackContractFixtures.find( - ({ id }) => id === "identity.manual-ref-replacement-orphans-context", - ); - if (refReplacementScenario === undefined) { - throw new Error("identity.manual-ref-replacement-orphans-context fixture is required"); - } - const refReplacementWithoutGitState = { - ...refReplacementScenario, - given: refReplacementScenario.given.filter((fact) => fact.kind !== "git-state"), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([refReplacementWithoutGitState])).toContain( - `${refReplacementScenario.id}: creating a Git context requires Git state for the workspace`, - ); - - const detachedCommitScenario = managedStackContractFixtures.find( - ({ id }) => id === "identity.detached-commits-reuse-checkout-context", - ); - if (detachedCommitScenario === undefined) { - throw new Error("identity.detached-commits-reuse-checkout-context fixture is required"); - } - const detachedReuseWithoutTransition = { - ...detachedCommitScenario, - given: detachedCommitScenario.given.filter( - (fact) => fact.kind !== "identity-transition" || fact.operation !== "detached-commit", - ), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([detachedReuseWithoutTransition])).toContain( - `${detachedCommitScenario.id}: detached reuse must declare the commit transition`, - ); - - const retryScenario = managedStackContractFixtures.find( - ({ id }) => id === "bootstrap.retry-after-failed-copy-succeeds", - ); - if (retryScenario === undefined) { - throw new Error("bootstrap.retry-after-failed-copy-succeeds fixture is required"); - } - const retryWithoutRollback = { - ...retryScenario, - given: retryScenario.given.filter(({ kind }) => kind !== "operation-result"), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([retryWithoutRollback])).toContain( - `${retryScenario.id}: bootstrap retry requires a rolled-back prior attempt`, - ); - - for (const scenarioId of [ - "credentials.configured-values-are-authoritative", - "credentials.explicit-change-applies-after-stop", - "credentials.omitted-values-use-stable-defaults", - "credentials.compatible-legacy-auth-is-retained", - ]) { - const credentialPersistenceScenario: ManagedStackContractScenario | undefined = - managedStackContractFixtures.find(({ id }) => id === scenarioId); - if (credentialPersistenceScenario === undefined) { - throw new Error(`${scenarioId} fixture is required`); - } - const globallyPersistedPlaintext = { - ...credentialPersistenceScenario, - expected: { - ...credentialPersistenceScenario.expected, - details: { - ...credentialPersistenceScenario.expected.details, - plaintext_secrets_in_global_state: true, - }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([globallyPersistedPlaintext])).toContain( - `${credentialPersistenceScenario.id}: credential persistence must not expose plaintext globally`, - ); - } - - const pruneScenario = managedStackContractFixtures.find( - ({ id }) => id === "reclamation.prune-removes-metadata-only", - ); - if (pruneScenario === undefined) { - throw new Error("reclamation.prune-removes-metadata-only fixture is required"); - } - const pruneWithoutMutableData = { - ...pruneScenario, - given: pruneScenario.given.filter(({ kind }) => kind !== "stack"), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([pruneWithoutMutableData])).toContain( - `${pruneScenario.id}: data-preserving prune must declare mutable stack data`, - ); - - const branchDeletionScenario = managedStackContractFixtures.find( - ({ id }) => id === "reclamation.branch-delete-does-not-delete-data", - ); - if (branchDeletionScenario === undefined) { - throw new Error("reclamation.branch-delete-does-not-delete-data fixture is required"); - } - const deletionWithUnboundBranch = { - ...branchDeletionScenario, - given: branchDeletionScenario.given.map((fact) => - fact.kind === "branch" && fact.name === "feat-a" - ? { ...fact, contextId: "context-other" } - : fact, - ), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([deletionWithUnboundBranch])).toContain( - `${branchDeletionScenario.id}: branch deletion must bind its branch to an affected managed stack`, - ); - - const deletionWithoutCheckoutGitState = { - ...branchDeletionScenario, - given: branchDeletionScenario.given.filter( - (fact) => fact.kind !== "checkout" && fact.kind !== "git-state", - ), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([deletionWithoutCheckoutGitState])).toContain( - `${branchDeletionScenario.id}: branch deletion must declare checkout Git state`, - ); - - const deletionWithoutPreservationResult = { - ...branchDeletionScenario, - expected: { ...branchDeletionScenario.expected, details: undefined }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([deletionWithoutPreservationResult])).toContain( - `${branchDeletionScenario.id}: branch deletion must preserve and orphan managed stack data`, - ); - - const deleteScenario: ManagedStackContractScenario | undefined = - managedStackContractFixtures.find(({ id }) => id === "reclamation.delete-orphan-by-stack-id"); - if (deleteScenario === undefined || deleteScenario.when.interface !== "cli") { - throw new Error("reclamation.delete-orphan-by-stack-id fixture is required"); - } - const deleteWithMismatchedActionTarget = { - ...deleteScenario, - when: { - ...deleteScenario.when, - argv: deleteScenario.when.argv.map((arg) => (arg === "stack-orphan" ? "stack-other" : arg)), - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([deleteWithMismatchedActionTarget])).toContain( - `${deleteScenario.id}: explicit action target stack-other disagrees with expected stack stack-orphan`, - ); - - const failedCopyScenario: ManagedStackContractScenario | undefined = - managedStackContractFixtures.find(({ id }) => id === "bootstrap.failed-copy-rolls-back"); - if (failedCopyScenario === undefined || failedCopyScenario.when.interface !== "managed-api") { - throw new Error("bootstrap.failed-copy-rolls-back managed API fixture is required"); - } - const failedCopyWithMismatchedActionTarget = { - ...failedCopyScenario, - when: { - ...failedCopyScenario.when, - input: { ...failedCopyScenario.when.input, stackId: "stack-other" }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([failedCopyWithMismatchedActionTarget])).toContain( - `${failedCopyScenario.id}: explicit action target stack-other disagrees with expected stack stack-main-default`, - ); - - const runtimeMetadataOnlyDelete = { - ...deleteScenario, - expected: { - ...deleteScenario.expected, - writes: deleteScenario.expected.writes.filter((write) => write.target !== "managed-state"), - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([runtimeMetadataOnlyDelete])).toContain( - `${deleteScenario.id}: delete runtime effect requires a matching state write`, - ); - - const deleteWithoutStop = { - ...deleteScenario, - expected: { - ...deleteScenario.expected, - runtimeEffects: deleteScenario.expected.runtimeEffects.filter( - ({ operation }) => operation !== "stop", - ), - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([deleteWithoutStop])).toContain( - `${deleteScenario.id}: runtime-state delete requires a matching runtime effect`, - ); - - const deleteWithoutTombstone = { - ...deleteScenario, - expected: { - ...deleteScenario.expected, - writes: deleteScenario.expected.writes.filter( - (write) => write.target !== "registry" || write.operation !== "tombstone", - ), - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([deleteWithoutTombstone])).toContain( - `${deleteScenario.id}: managed-state deletion requires a registry tombstone`, - ); - - const stickyReuseScenario = managedStackContractFixtures.find( - ({ id }) => id === "ports.sticky-ports-reuse-on-return", - ); - if (stickyReuseScenario === undefined) { - throw new Error("ports.sticky-ports-reuse-on-return fixture is required"); - } - const unboundStickyReuse = { - ...stickyReuseScenario, - expected: { ...stickyReuseScenario.expected, selection: undefined }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([unboundStickyReuse])).toContain( - `${stickyReuseScenario.id}: sticky port reuse requires a selected target`, - ); - - const repositoryContractScenario: ManagedStackContractScenario | undefined = - managedStackContractFixtures.find( - ({ id }) => id === "api-boundary.repository-contract-is-storage-agnostic", - ); - if ( - repositoryContractScenario === undefined || - repositoryContractScenario.when.interface !== "managed-api" - ) { - throw new Error("api-boundary.repository-contract-is-storage-agnostic fixture is required"); - } - const repositoryApiOutput = repositoryContractScenario.expected.output.api; - if (repositoryApiOutput === undefined) { - throw new Error("repository contract API output is required"); - } - const emptyRepositoryMatrix = { - ...repositoryContractScenario, - when: { - ...repositoryContractScenario.when, - input: { ...repositoryContractScenario.when.input, adapters: [] }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([scenario, emptyRepositoryMatrix])).toContain( - `${repositoryContractScenario.id}: repository contract must declare its adapters`, - ); - - const duplicateRepositoryMatrix = { - ...repositoryContractScenario, - when: { - ...repositoryContractScenario.when, - input: { - ...repositoryContractScenario.when.input, - adapters: ["in-memory", "in-memory"], - }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([scenario, duplicateRepositoryMatrix])).toContain( - `${repositoryContractScenario.id}: repository contract adapters must be unique`, - ); - - const repositoryMatrixMissingFact = { - ...repositoryContractScenario, - when: { - ...repositoryContractScenario.when, - input: { ...repositoryContractScenario.when.input, adapters: ["in-memory"] }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([scenario, repositoryMatrixMissingFact])).toContain( - `${repositoryContractScenario.id}: repository adapters must match declared repository facts`, - ); - - const unknownRepositoryReference = { - ...repositoryContractScenario, - when: { - ...repositoryContractScenario.when, - input: { ...repositoryContractScenario.when.input, scenarioId: "identity.unknown" }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([unknownRepositoryReference])).toContain( - `${repositoryContractScenario.id}: repository contract must reference a declared scenario`, - ); - - const staleRepositoryOutcome = { - ...repositoryContractScenario, - expected: { - ...repositoryContractScenario.expected, - output: { - ...repositoryContractScenario.expected.output, - api: { - ...repositoryApiOutput, - "in-memory": { outcome: "create", stackId: "stack-main-default" }, - }, - }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([scenario, staleRepositoryOutcome])).toContain( - `${repositoryContractScenario.id}: repository in-memory outcome must match ${scenario.id}`, - ); - - const divergentRepositoryIdentity = { - ...repositoryContractScenario, - expected: { - ...repositoryContractScenario.expected, - output: { - ...repositoryContractScenario.expected.output, - api: { - ...repositoryApiOutput, - "persistent-adapter": { outcome: "reuse", stackId: "stack-other" }, - }, - }, - }, - } satisfies ManagedStackContractScenario; - const divergentRepositoryErrors = validateManagedStackContractFixtures([ - scenario, - divergentRepositoryIdentity, - ]); - expect(divergentRepositoryErrors).toContain( - `${repositoryContractScenario.id}: repository persistent-adapter stackId must match ${scenario.id}`, - ); - expect(divergentRepositoryErrors).toContain( - `${repositoryContractScenario.id}: repository adapter decisions must be identical`, - ); - - const invalidNameScenario: ManagedStackContractScenario | undefined = - managedStackContractFixtures.find( - ({ id }) => id === "identity.invalid-stack-name-uppercase-underscore-fails", - ); - const invalidNameHumanOutput = invalidNameScenario?.expected.output.human; - if (invalidNameScenario === undefined || invalidNameHumanOutput === undefined) { - throw new Error("invalid stack name fixture with human recovery is required"); - } - const divergentHumanRecovery = { - ...invalidNameScenario, - expected: { - ...invalidNameScenario.expected, - output: { - ...invalidNameScenario.expected.output, - human: { ...invalidNameHumanOutput, recovery: ["Try again"] }, - }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([divergentHumanRecovery])).toContain( - `${invalidNameScenario.id}: human recovery disagrees with the managed result`, - ); - - const divergentJsonRecovery = { - ...invalidNameScenario, - expected: { - ...invalidNameScenario.expected, - output: { - ...invalidNameScenario.expected.output, - json: { ...invalidNameScenario.expected.output.json, recovery: ["Try again"] }, - }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([divergentJsonRecovery])).toContain( - `${invalidNameScenario.id}: JSON recovery disagrees with the managed result`, - ); - - const invalidNameJson = invalidNameScenario.expected.output.json; - if (invalidNameJson === undefined) { - throw new Error("invalid stack name JSON fixture is required"); - } - const { code: omittedCode, ...jsonWithoutCode } = invalidNameJson; - expect(omittedCode).toBe("INVALID_STACK_NAME"); - const jsonProjectionWithoutCode = { - ...invalidNameScenario, - expected: { - ...invalidNameScenario.expected, - output: { ...invalidNameScenario.expected.output, json: jsonWithoutCode }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([jsonProjectionWithoutCode])).toContain( - `${invalidNameScenario.id}: JSON projection requires a code`, - ); - - const credentialDefaultsScenario: ManagedStackContractScenario | undefined = - managedStackContractFixtures.find( - ({ id }) => id === "credentials.omitted-values-use-stable-defaults", - ); - const credentialDefaultsJson = credentialDefaultsScenario?.expected.output.json; - if (credentialDefaultsScenario === undefined || credentialDefaultsJson === undefined) { - throw new Error("credentials.omitted-values-use-stable-defaults JSON fixture is required"); - } - const { outcome: omittedOutcome, ...jsonWithoutOutcome } = credentialDefaultsJson; - expect(omittedOutcome).toBe("create"); - const credentialProjectionWithoutOutcome = { - ...credentialDefaultsScenario, - expected: { - ...credentialDefaultsScenario.expected, - output: { ...credentialDefaultsScenario.expected.output, json: jsonWithoutOutcome }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([credentialProjectionWithoutOutcome])).toContain( - `${credentialDefaultsScenario.id}: JSON projection requires an outcome`, - ); - - const newBranchScenario = managedStackContractFixtures.find( - ({ id }) => id === "identity.new-branch-first-start-creates-stack", - ); - if (newBranchScenario === undefined) { - throw new Error("identity.new-branch-first-start-creates-stack fixture is required"); - } - const contextOwnedByDifferentBranch = { - ...newBranchScenario, - expected: { - ...newBranchScenario.expected, - writes: newBranchScenario.expected.writes.map((write) => - write.target === "git-config" && write.id === "context-feat-a" - ? { ...write, owner: "main" } - : write, - ), - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([contextOwnedByDifferentBranch])).toContain( - `${newBranchScenario.id}: Git context context-feat-a must belong to branch feat-a`, - ); - - const recreatedBranchScenario = managedStackContractFixtures.find( - ({ id }) => id === "identity.branch-delete-recreate-creates-context", - ); - if (recreatedBranchScenario === undefined) { - throw new Error("identity.branch-delete-recreate-creates-context fixture is required"); - } - const recreatedBranchWithoutGitState = { - ...recreatedBranchScenario, - given: recreatedBranchScenario.given.filter((fact) => fact.kind !== "git-state"), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([recreatedBranchWithoutGitState])).toContain( - `${recreatedBranchScenario.id}: creating a Git context requires Git state for the workspace`, - ); - - const firstOrdinaryFolderStart = managedStackContractFixtures.find( - ({ id }) => id === "identity.non-git-folder-first-start-persists-identity", - ); - if (firstOrdinaryFolderStart === undefined) { - throw new Error("identity.non-git-folder-first-start-persists-identity fixture is required"); - } - const ordinaryFolderWithoutMarkerWrite = { - ...firstOrdinaryFolderStart, - expected: { - ...firstOrdinaryFolderStart.expected, - writes: firstOrdinaryFolderStart.expected.writes.filter( - (write) => write.target !== "identity-marker", - ), - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([ordinaryFolderWithoutMarkerWrite])).toContain( - `${firstOrdinaryFolderStart.id}: ordinary-folder creation must persist its identity marker`, - ); - - const laterOrdinaryFolderStart = managedStackContractFixtures.find( - ({ id }) => id === "identity.non-git-folder-recovers-persisted-identity", - ); - if (laterOrdinaryFolderStart === undefined) { - throw new Error("identity.non-git-folder-recovers-persisted-identity fixture is required"); - } - const ordinaryFolderWithoutMarkerFact = { - ...laterOrdinaryFolderStart, - given: laterOrdinaryFolderStart.given.filter((fact) => fact.kind !== "identity-marker"), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([ordinaryFolderWithoutMarkerFact])).toContain( - `${laterOrdinaryFolderStart.id}: ordinary-folder reuse must resolve its identity marker`, - ); - - const runtimeCreationScenario = managedStackContractFixtures.find( - ({ id }) => id === "runtime.explicit-api-overrides-auto", - ); - if (runtimeCreationScenario === undefined) { - throw new Error("runtime.explicit-api-overrides-auto fixture is required"); - } - const runtimeCreationWithoutAbsentTarget = { - ...runtimeCreationScenario, - given: runtimeCreationScenario.given.filter((fact) => fact.kind !== "managed-target"), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([runtimeCreationWithoutAbsentTarget])).toContain( - `${runtimeCreationScenario.id}: managed creation must declare absent target stack-main-default`, - ); - - const runtimeCreationWithoutLegacyState = { - ...runtimeCreationScenario, - given: runtimeCreationScenario.given.filter((fact) => fact.kind !== "legacy-state"), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([runtimeCreationWithoutLegacyState])).toContain( - `${runtimeCreationScenario.id}: managed creation must declare legacy state absent or incompatible`, - ); - if (runtimeCreationScenario.when.interface !== "managed-api") { - throw new Error("runtime.explicit-api-overrides-auto managed API fixture is required"); - } - const runtimeActionDisagreesWithRequest = { - ...runtimeCreationScenario, - when: { - ...runtimeCreationScenario.when, - input: { ...runtimeCreationScenario.when.input, runtime: "docker" }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([runtimeActionDisagreesWithRequest])).toContain( - `${runtimeCreationScenario.id}: explicit runtime docker must match its managed-api request fact`, - ); - - const credentialCreationScenario = managedStackContractFixtures.find( - ({ id }) => id === "credentials.configured-values-are-authoritative", - ); - if (credentialCreationScenario === undefined) { - throw new Error("credentials.configured-values-are-authoritative fixture is required"); - } - const credentialCreationWithoutAbsentTarget = { - ...credentialCreationScenario, - given: credentialCreationScenario.given.filter((fact) => fact.kind !== "managed-target"), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([credentialCreationWithoutAbsentTarget])).toContain( - `${credentialCreationScenario.id}: managed creation must declare absent target stack-main-default`, - ); - - const credentialCreationWithoutLegacyState = { - ...credentialCreationScenario, - given: credentialCreationScenario.given.filter((fact) => fact.kind !== "legacy-state"), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([credentialCreationWithoutLegacyState])).toContain( - `${credentialCreationScenario.id}: managed creation must declare legacy state absent or incompatible`, - ); - if (credentialCreationScenario.when.interface !== "managed-api") { - throw new Error( - "credentials.configured-values-are-authoritative managed API fixture is required", - ); - } - const credentialActionUsingDifferentReference = { - ...credentialCreationScenario, - when: { - ...credentialCreationScenario.when, - input: { ...credentialCreationScenario.when.input, auth: "configured-auth-v2" }, - }, - } satisfies ManagedStackContractScenario; - expect( - validateManagedStackContractFixtures([credentialActionUsingDifferentReference]), - ).toContain( - `${credentialCreationScenario.id}: configured credential input configured-auth-v2 must match persisted references`, - ); - - const portCreationScenario = managedStackContractFixtures.find( - ({ id }) => id === "ports.new-target-allocates-and-persists-omitted-ports", - ); - if (portCreationScenario === undefined) { - throw new Error("ports.new-target-allocates-and-persists-omitted-ports fixture is required"); - } - const portCreationWithoutLegacyState = { - ...portCreationScenario, - given: portCreationScenario.given.filter((fact) => fact.kind !== "legacy-state"), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([portCreationWithoutLegacyState])).toContain( - `${portCreationScenario.id}: managed creation must declare legacy state absent or incompatible`, - ); - const portCreationWithCopyableLegacyState = { - ...portCreationScenario, - given: portCreationScenario.given.map((fact) => - fact.kind === "legacy-state" - ? { - ...fact, - lifecycle: "stopped", - database: "compatible", - storage: "compatible", - credentials: "compatible", - } - : fact, - ), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([portCreationWithCopyableLegacyState])).toContain( - `${portCreationScenario.id}: managed creation must declare legacy state absent or incompatible`, - ); - - const concurrencyScenario: ManagedStackContractScenario | undefined = - managedStackContractFixtures.find( - ({ id }) => id === "identity.concurrent-create-publishes-once", - ); - if (concurrencyScenario === undefined || concurrencyScenario.when.interface !== "managed-api") { - throw new Error("identity.concurrent-create-publishes-once fixture is required"); - } - const singleContenderAction = { - ...concurrencyScenario, - when: { - ...concurrencyScenario.when, - input: { ...concurrencyScenario.when.input, contenders: 1 }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([singleContenderAction])).toContain( - `${concurrencyScenario.id}: concurrent action contenders must match the declared race of 2`, - ); - - const concurrencyForDifferentTarget = { - ...concurrencyScenario, - when: { - ...concurrencyScenario.when, - input: { ...concurrencyScenario.when.input, stackName: "review" }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([concurrencyForDifferentTarget])).toContain( - `${concurrencyScenario.id}: concurrent action target must match context-feat/default`, - ); - - const incompleteConcurrentResults = { - ...concurrencyScenario, - expected: { - ...concurrencyScenario.expected, - details: { ...concurrencyScenario.expected.details, contender_results: ["create"] }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([incompleteConcurrentResults])).toContain( - `${concurrencyScenario.id}: concurrent details results must cover 2 contenders`, - ); - - const duplicateConcurrentCreation = { - ...concurrencyScenario, - expected: { - ...concurrencyScenario.expected, - details: { - ...concurrencyScenario.expected.details, - contender_results: ["create", "create"], - }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([duplicateConcurrentCreation])).toContain( - `${concurrencyScenario.id}: concurrent race must create once and reuse thereafter`, - ); - - const isolatedStateRootScenario: ManagedStackContractScenario | undefined = - managedStackContractFixtures.find( - ({ id }) => id === "api-boundary.managed-api-accepts-isolated-state-root", - ); - if ( - isolatedStateRootScenario === undefined || - isolatedStateRootScenario.when.interface !== "managed-api" - ) { - throw new Error("api-boundary.managed-api-accepts-isolated-state-root fixture is required"); - } - const actionUsingDifferentStateRoot = { - ...isolatedStateRootScenario, - when: { - ...isolatedStateRootScenario.when, - input: { ...isolatedStateRootScenario.when.input, stateRoot: "/tmp/other-root" }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([actionUsingDifferentStateRoot])).toContain( - `${isolatedStateRootScenario.id}: isolated state root input must match its options and observed boundary`, - ); - - const isolatedResolutionWithoutAbsentTarget = { - ...isolatedStateRootScenario, - given: isolatedStateRootScenario.given.filter((fact) => fact.kind !== "managed-target"), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([isolatedResolutionWithoutAbsentTarget])).toContain( - `${isolatedStateRootScenario.id}: managed creation must declare absent target stack-main-default`, - ); - - const isolatedResolutionWithoutIdentityClaims = { - ...isolatedStateRootScenario, - given: isolatedStateRootScenario.given.filter((fact) => fact.kind !== "identity-claim"), - } satisfies ManagedStackContractScenario; - expect( - validateManagedStackContractFixtures([isolatedResolutionWithoutIdentityClaims]), - ).toContain( - `${isolatedStateRootScenario.id}: creating Git identity project-a requires an absent project claim`, - ); - - const bootstrapCopyScenario: ManagedStackContractScenario | undefined = - managedStackContractFixtures.find( - ({ id }) => id === "bootstrap.first-start-copies-compatible-legacy-state", - ); - if (bootstrapCopyScenario === undefined) { - throw new Error("bootstrap.first-start-copies-compatible-legacy-state fixture is required"); - } - const copyFromRunningLegacyState = { - ...bootstrapCopyScenario, - given: bootstrapCopyScenario.given.map((fact) => - fact.kind === "legacy-state" ? { ...fact, lifecycle: "running" } : fact, - ), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([copyFromRunningLegacyState])).toContain( - `${bootstrapCopyScenario.id}: bootstrap copy requires absent target stack-main-default and compatible stopped legacy state`, - ); - }); - - it("enforces diagnostic codes and non-mutating report and error outcomes", () => { - const readOnlyScenario: ManagedStackContractScenario | undefined = - managedStackContractFixtures.find( - ({ id }) => id === "identity.branch-copy-read-only-does-not-write", - ); - const ambiguousScenario: ManagedStackContractScenario | undefined = - managedStackContractFixtures.find( - ({ id }) => id === "identity.branch-copy-ambiguous-read-only", - ); - if (readOnlyScenario === undefined || ambiguousScenario?.expected.error === undefined) { - throw new Error("branch-copy read-only fixtures are required"); - } - - const mutatingReport = { - ...readOnlyScenario, - expected: { - ...readOnlyScenario.expected, - writes: [{ target: "registry", operation: "update", id: "context-main" }], - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([mutatingReport])).toContain( - `${readOnlyScenario.id}: report outcome must not mutate state`, - ); - - const mutatingError = { - ...ambiguousScenario, - expected: { - ...ambiguousScenario.expected, - writes: [{ target: "managed-state", operation: "update", id: "context-main" }], - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([mutatingError])).toContain( - `${ambiguousScenario.id}: error outcome must not mutate state outside rollback cleanup`, - ); - - const lowerCaseCode = { - ...ambiguousScenario, - expected: { - ...ambiguousScenario.expected, - error: { ...ambiguousScenario.expected.error, code: "ambiguous_context_owner" }, - output: { - ...ambiguousScenario.expected.output, - json: { - ...ambiguousScenario.expected.output.json, - code: "ambiguous_context_owner", - }, - }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([lowerCaseCode])).toContain( - `${ambiguousScenario.id}: diagnostic code ambiguous_context_owner must use SCREAMING_SNAKE_CASE`, - ); - - const ambiguityWithoutClaim = { - ...ambiguousScenario, - given: ambiguousScenario.given.filter((fact) => fact.kind !== "identity-claim"), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([ambiguityWithoutClaim])).toContain( - `${ambiguousScenario.id}: ambiguous context must bind at least two claiming branches to its projections`, - ); - - const ambiguityWithoutTransition = { - ...ambiguousScenario, - given: ambiguousScenario.given.filter( - (fact) => fact.kind !== "identity-transition" || fact.operation !== "branch-copy", - ), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([ambiguityWithoutTransition])).toContain( - `${ambiguousScenario.id}: ambiguous context must bind at least two claiming branches to its projections`, - ); - }); - - it("binds public action inputs and state facts to observable results", () => { - const findScenario = (id: string): ManagedStackContractScenario => { - const scenario = managedStackContractFixtures.find((candidate) => candidate.id === id); - if (scenario === undefined) { - throw new Error(`${id} fixture is required`); - } - return scenario; - }; - - const stackNamesScenario = findScenario("identity.valid-stack-names-resolve-deterministically"); - if (stackNamesScenario.when.interface !== "managed-api") { - throw new Error("stack-name resolution must use the managed API"); - } - const missingRequestedStackName = { - ...stackNamesScenario, - when: { - ...stackNamesScenario.when, - input: { ...stackNamesScenario.when.input, stackNames: ["default"] }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([missingRequestedStackName])).toContain( - `${stackNamesScenario.id}: requested stack names must match their fact and projected results`, - ); - - const automaticPortsScenario = findScenario( - "ports.new-target-allocates-and-persists-omitted-ports", - ); - if (automaticPortsScenario.when.interface !== "managed-api") { - throw new Error("automatic port allocation must use the managed API"); - } - const missingAutomaticPortRequest = { - ...automaticPortsScenario, - when: { - ...automaticPortsScenario.when, - input: { - ...automaticPortsScenario.when.input, - portIntents: { "api.port": "automatic" }, - }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([missingAutomaticPortRequest])).toContain( - `${automaticPortsScenario.id}: requested port keys must match their config facts`, - ); - const projectedAutomaticIntentDisagrees = { - ...automaticPortsScenario, - expected: { - ...automaticPortsScenario.expected, - output: { - ...automaticPortsScenario.expected.output, - api: { - ...automaticPortsScenario.expected.output.api, - intents: { api: "automatic", db: "exact" }, - }, - }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([projectedAutomaticIntentDisagrees])).toContain( - `${automaticPortsScenario.id}: automatic port request db.port must match its fact and projected allocation`, - ); - - const autoRuntimeScenario = findScenario("runtime.auto-prefers-docker"); - const unavailablePreferredRuntime = { - ...autoRuntimeScenario, - given: autoRuntimeScenario.given.map((fact) => - fact.kind === "runtime-availability" && fact.runtime === "docker" - ? { ...fact, available: false } - : fact, - ), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([unavailablePreferredRuntime])).toContain( - `${autoRuntimeScenario.id}: automatic runtime failure must bind both unavailability reasons`, - ); - - const injectedRepositoryScenario = findScenario( - "api-boundary.managed-api-accepts-injected-repository", - ); - if (injectedRepositoryScenario.when.interface !== "managed-api") { - throw new Error("injected repository boundary must use the managed API"); - } - const differentInjectedRepository = { - ...injectedRepositoryScenario, - when: { - ...injectedRepositoryScenario.when, - input: { ...injectedRepositoryScenario.when.input, repository: "other-repository" }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([differentInjectedRepository])).toContain( - `${injectedRepositoryScenario.id}: injected repository and state root must match the observed managed service`, - ); - - const nativePreflightScenario = findScenario( - "native-qualification.all-services-qualify-platform", - ); - if (nativePreflightScenario.when.interface !== "managed-api") { - throw new Error("native preflight must use the managed API"); - } - const preflightWithoutPlatform = { - ...nativePreflightScenario, - when: { ...nativePreflightScenario.when, input: {} }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([preflightWithoutPlatform])).toContain( - `${nativePreflightScenario.id}: native qualification platform must match the preflight action`, - ); - - const persistedRuntimeConflictScenario = findScenario( - "runtime.persisted-runtime-conflict-fails", - ); - const matchingPersistedRuntime = { - ...persistedRuntimeConflictScenario, - given: persistedRuntimeConflictScenario.given.map((fact) => - fact.kind === "persisted-runtime" ? { ...fact, runtime: "native" } : fact, - ), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([matchingPersistedRuntime])).toContain( - `${persistedRuntimeConflictScenario.id}: persisted runtime conflict must bind persisted and requested values`, - ); - - const destructiveStopScenario = findScenario("reclamation.delete-orphan-by-stack-id"); - if (destructiveStopScenario.when.interface !== "cli") { - throw new Error("destructive orphan deletion must use the CLI"); - } - const destructiveStopWithoutFlag = { - ...destructiveStopScenario, - when: { - ...destructiveStopScenario.when, - argv: destructiveStopScenario.when.argv.filter((arg) => arg !== "--no-backup"), - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([destructiveStopWithoutFlag])).toContain( - `${destructiveStopScenario.id}: destructive stop requires --no-backup`, - ); - - const failedCopyScenario = findScenario("bootstrap.failed-copy-rolls-back"); - if (failedCopyScenario.when.interface !== "managed-api") { - throw new Error("failed bootstrap copy must use the managed API"); - } - const rollbackWithoutInjectedFailure = { - ...failedCopyScenario, - when: { - ...failedCopyScenario.when, - input: { ...failedCopyScenario.when.input, injectCopyFailure: false }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([rollbackWithoutInjectedFailure])).toContain( - `${failedCopyScenario.id}: bootstrap rollback requires enabled copy-failure injection`, - ); - - const branchSelectionScenario = findScenario( - "identity.same-commit-different-branches-are-independent", - ); - if (branchSelectionScenario.when.interface !== "managed-api") { - throw new Error("branch selection must use the managed API"); - } - const mismatchedActiveGitBranch = { - ...branchSelectionScenario, - given: branchSelectionScenario.given.map((fact) => - fact.kind === "git-state" ? { ...fact, branch: "main" } : fact, - ), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([mismatchedActiveGitBranch])).toContain( - `${branchSelectionScenario.id}: selected context must match the active Git branch and checked-out branch fact`, - ); - - const ordinaryFolderScenario = findScenario( - "identity.non-git-folder-first-start-persists-identity", - ); - const markerWrittenToDifferentWorkspace = { - ...ordinaryFolderScenario, - expected: { - ...ordinaryFolderScenario.expected, - writes: ordinaryFolderScenario.expected.writes.map((write) => - write.target === "identity-marker" - ? { ...write, workspacePath: "/work/other-project" } - : write, - ), - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([markerWrittenToDifferentWorkspace])).toContain( - `${ordinaryFolderScenario.id}: ordinary-folder creation must persist its identity marker`, - ); - - const repositoryEqualityScenario = findScenario( - "api-boundary.repository-contract-is-storage-agnostic", - ); - const falseRepositoryEqualityFlag = { - ...repositoryEqualityScenario, - expected: { - ...repositoryEqualityScenario.expected, - output: { - ...repositoryEqualityScenario.expected.output, - api: { ...repositoryEqualityScenario.expected.output.api, equal: false }, - }, - }, - } satisfies ManagedStackContractScenario; - const referencedRepositoryScenario = findScenario("identity.return-to-branch-reuses-stack"); - expect( - validateManagedStackContractFixtures([ - falseRepositoryEqualityFlag, - referencedRepositoryScenario, - ]), - ).toContain( - `${repositoryEqualityScenario.id}: repository equality flags must match compared decisions`, - ); - - const portableEqualityScenario = findScenario( - "api-boundary.managed-surface-is-node-and-bun-portable", - ); - const falsePortableEqualityFlag = { - ...portableEqualityScenario, - expected: { - ...portableEqualityScenario.expected, - output: { - ...portableEqualityScenario.expected.output, - api: { ...portableEqualityScenario.expected.output.api, equal: false }, - }, - }, - } satisfies ManagedStackContractScenario; - const referencedPortableScenario = findScenario( - "identity.same-checkout-branch-and-name-reuses-stack", - ); - expect( - validateManagedStackContractFixtures([falsePortableEqualityFlag, referencedPortableScenario]), - ).toContain( - `${portableEqualityScenario.id}: portable equality flags must match compared results`, - ); - - const actionFromUndeclaredWorkspace = { - ...branchSelectionScenario, - when: { - ...branchSelectionScenario.when, - input: { ...branchSelectionScenario.when.input, cwd: "undeclared-workspace" }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([actionFromUndeclaredWorkspace])).toContain( - `${branchSelectionScenario.id}: managed action cwd must match a declared workspace`, - ); - const actionWithUnknownOperation = { - ...branchSelectionScenario, - when: { - ...branchSelectionScenario.when, - input: { ...branchSelectionScenario.when.input, operation: "unknown" }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([actionWithUnknownOperation])).toContain( - `${branchSelectionScenario.id}: resolveStack operation must be start or status`, - ); - - const portResolutionScenario = findScenario( - "ports.exact-default-value-differs-from-omitted-default", - ); - if (portResolutionScenario.when.interface !== "managed-api") { - throw new Error("port-intent resolution must use the managed API"); - } - const changedExplicitConfigPort = { - ...portResolutionScenario, - when: { - ...portResolutionScenario.when, - input: { - ...portResolutionScenario.when.input, - config: { "api.port": 54322 }, - }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([changedExplicitConfigPort])).toContain( - `${portResolutionScenario.id}: resolved port api.port must match its input and fact`, - ); - }); - - it("binds failure and lifecycle decisions to their declared preconditions", () => { - const findScenario = (id: string): ManagedStackContractScenario => { - const scenario = managedStackContractFixtures.find((candidate) => candidate.id === id); - if (scenario === undefined) { - throw new Error(`${id} fixture is required`); - } - return scenario; - }; - - const managedStartScenario = findScenario("ports.explicit-free-port-is-used"); - if (managedStartScenario.when.interface !== "managed-api") { - throw new Error("explicit managed port start must use the managed API"); - } - const actionUsingWrongManagedMethod = { - ...managedStartScenario, - when: { ...managedStartScenario.when, method: "stopStack" }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([actionUsingWrongManagedMethod])).toContain( - `${managedStartScenario.id}: managed action must use a declared public method`, - ); - - const runtimeConflictScenario = findScenario("runtime.explicit-and-config-conflict-fails"); - const matchingRuntimeRequests = { - ...runtimeConflictScenario, - given: runtimeConflictScenario.given.map((fact) => - fact.kind === "runtime-request" && fact.source === "config" - ? { ...fact, runtime: "docker" } - : fact, - ), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([matchingRuntimeRequests])).toContain( - `${runtimeConflictScenario.id}: runtime conflict must bind different explicit and configured runtimes`, - ); - - const exactPortConflictScenario = findScenario("ports.explicit-port-conflict-fails"); - const differentConfiguredConflictPort = { - ...exactPortConflictScenario, - given: exactPortConflictScenario.given.map((fact) => - fact.kind === "config-port" ? { ...fact, value: 54322 } : fact, - ), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([differentConfiguredConflictPort])).toContain( - `${exactPortConflictScenario.id}: exact port conflict must bind config, occupancy, and projections`, - ); - - const changedCredentialsScenario = findScenario( - "credentials.explicit-change-applies-after-stop", - ); - const unchangedCredentialUpdate = { - ...changedCredentialsScenario, - given: changedCredentialsScenario.given.map((fact) => - fact.kind === "credential-state" - ? { ...fact, valuesId: fact.previousValuesId ?? fact.valuesId } - : fact, - ), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([unchangedCredentialUpdate])).toContain( - `${changedCredentialsScenario.id}: credential change requires different old and new values`, - ); - - const decodedDefaultsScenario = findScenario( - "ports.exact-default-value-differs-from-omitted-default", - ); - if (decodedDefaultsScenario.when.interface !== "managed-api") { - throw new Error("decoded defaults must use the managed API"); - } - const missingDecodedDefault = { - ...decodedDefaultsScenario, - when: { - ...decodedDefaultsScenario.when, - input: { - ...decodedDefaultsScenario.when.input, - decodedDefaults: { "api.port": 54321 }, - }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([missingDecodedDefault])).toContain( - `${decodedDefaultsScenario.id}: decoded default keys must cover resolved port facts`, - ); - - const existingTargetScenario = findScenario("bootstrap.existing-managed-target-ignores-legacy"); - const absentExistingTarget = { - ...existingTargetScenario, - given: existingTargetScenario.given.map((fact) => - fact.kind === "managed-target" ? { ...fact, exists: false } : fact, - ), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([absentExistingTarget])).toContain( - `${existingTargetScenario.id}: absent managed target stack-main-default contradicts an existing stack`, - ); - - const duplicateClaimScenario = findScenario("identity.copied-checkout-reports-duplicate-claim"); - const exactDuplicateClaim = { - ...duplicateClaimScenario, - given: duplicateClaimScenario.given.map((fact) => - fact.kind === "identity-claim" ? { ...fact, status: "exact" } : fact, - ), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([exactDuplicateClaim])).toContain( - `${duplicateClaimScenario.id}: duplicate checkout error must bind both conflicting live paths`, - ); - - const inaccessiblePathScenario = findScenario("identity.inaccessible-previous-path-fails"); - const missingPreviousPath = { - ...inaccessiblePathScenario, - given: inaccessiblePathScenario.given.map((fact) => - fact.kind === "workspace" ? { ...fact, previousPathAccess: "missing" } : fact, - ), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([missingPreviousPath])).toContain( - `${inaccessiblePathScenario.id}: inaccessible checkout error must bind path access and ambiguous claim`, - ); - - const stopScenario = findScenario("reclamation.default-stop-preserves-data"); - const stopAlreadyStoppedStack = { - ...stopScenario, - given: stopScenario.given.map((fact) => - fact.kind === "stack" ? { ...fact, lifecycle: "stopped" } : fact, - ), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([stopAlreadyStoppedStack])).toContain( - `${stopScenario.id}: stopping stack stack-main-default requires a running lifecycle`, - ); - - const pruneScenario = findScenario("reclamation.prune-removes-metadata-only"); - const pruneActiveRecord = { - ...pruneScenario, - given: pruneScenario.given.map((fact) => - fact.kind === "managed-record" ? { ...fact, status: "active" } : fact, - ), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([pruneActiveRecord])).toContain( - `${pruneScenario.id}: prune may delete only orphaned registry metadata`, - ); - const pruneNonOrphanedStack = { - ...pruneScenario, - given: pruneScenario.given.map((fact) => - fact.kind === "stack" ? { ...fact, orphaned: false } : fact, - ), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([pruneNonOrphanedStack])).toContain( - `${pruneScenario.id}: prune may delete only orphaned registry metadata`, - ); - - const strictRuntimeScenario = findScenario("runtime.explicit-runtime-is-strict"); - const requestedRuntimeIsAvailable = { - ...strictRuntimeScenario, - given: strictRuntimeScenario.given.map((fact) => - fact.kind === "runtime-availability" && fact.runtime === "docker" - ? { ...fact, available: true } - : fact, - ), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([requestedRuntimeIsAvailable])).toContain( - `${strictRuntimeScenario.id}: explicit runtime error must bind an unavailable requested runtime`, - ); - const differentUnavailableReason = { - ...strictRuntimeScenario, - given: strictRuntimeScenario.given.map((fact) => - fact.kind === "runtime-availability" && fact.runtime === "docker" - ? { ...fact, reason: "socket unavailable" } - : fact, - ), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([differentUnavailableReason])).toContain( - `${strictRuntimeScenario.id}: explicit runtime error must bind an unavailable requested runtime`, - ); - - const unsupportedPlatformScenario = findScenario( - "native-qualification.unsupported-platform-fails-preflight", - ); - const supportedPlatformReportedUnsupported = { - ...unsupportedPlatformScenario, - given: unsupportedPlatformScenario.given.map((fact) => - fact.kind === "native-qualification" ? { ...fact, platform: "darwin-arm64" } : fact, - ), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([supportedPlatformReportedUnsupported])).toContain( - `${unsupportedPlatformScenario.id}: unsupported native error must bind an unsupported platform`, - ); - }); - - it("rejects lifecycle, ownership, and comparison results with contradictory evidence", () => { - const findScenario = (id: string): ManagedStackContractScenario => { - const scenario = managedStackContractFixtures.find((candidate) => candidate.id === id); - if (scenario === undefined) { - throw new Error(`${id} fixture is required`); - } - return scenario; - }; - - const runningLegacyScenario = findScenario( - "bootstrap.running-legacy-source-fails-without-mutation", - ); - const stoppedLegacyReportedRunning = { - ...runningLegacyScenario, - given: runningLegacyScenario.given.map((fact) => - fact.kind === "legacy-state" ? { ...fact, lifecycle: "stopped" } : fact, - ), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([stoppedLegacyReportedRunning])).toContain( - `${runningLegacyScenario.id}: running legacy error requires a running source and absent target`, - ); - - const directStackScenario = findScenario("api-boundary.direct-create-stack-is-ephemeral"); - const explicitRootsReportedTemporary = { - ...directStackScenario, - given: directStackScenario.given.map((fact) => - fact.kind === "direct-stack-options" ? { ...fact, stackRoot: "explicit" } : fact, - ), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([explicitRootsReportedTemporary])).toContain( - `${directStackScenario.id}: direct stack root inputs must agree with temporary-state behavior`, - ); - - const failedCopyScenario = findScenario("bootstrap.failed-copy-rolls-back"); - const rollbackAgainstExistingTarget = { - ...failedCopyScenario, - given: failedCopyScenario.given.map((fact) => - fact.kind === "managed-target" ? { ...fact, exists: true } : fact, - ), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([rollbackAgainstExistingTarget])).toContain( - `${failedCopyScenario.id}: bootstrap rollback requires failure injection against an absent target`, - ); - - const stickyCollisionScenario = findScenario("ports.later-sticky-port-collision-fails"); - const unrelatedStickyAssignment = { - ...stickyCollisionScenario, - given: stickyCollisionScenario.given.map((fact) => - fact.kind === "port-assignment" ? { ...fact, port: 55422 } : fact, - ), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([unrelatedStickyAssignment])).toContain( - `${stickyCollisionScenario.id}: sticky port conflict must bind assignment, occupancy, and projections`, - ); - - const exactPortChangeScenario = findScenario("ports.config-change-on-stopped-stack-applies"); - const ignoredExactPortChange = { - ...exactPortChangeScenario, - given: exactPortChangeScenario.given.map((fact) => - fact.kind === "config-port" ? { ...fact, value: 55322 } : fact, - ), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([ignoredExactPortChange])).toContain( - `${exactPortChangeScenario.id}: exact port change must bind previous assignment and requested value`, - ); - const automaticIntentReportedAsExactChange = { - ...exactPortChangeScenario, - given: exactPortChangeScenario.given.map((fact) => - fact.kind === "config-port" ? { ...fact, intent: "automatic" } : fact, - ), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([automaticIntentReportedAsExactChange])).toContain( - `${exactPortChangeScenario.id}: exact port change must bind previous assignment and requested value`, - ); - - const explicitRuntimeScenario = findScenario("runtime.explicit-api-overrides-auto"); - const unavailableExplicitRuntimeStarts = { - ...explicitRuntimeScenario, - given: explicitRuntimeScenario.given.map((fact) => - fact.kind === "runtime-availability" && fact.runtime === "native" - ? { ...fact, available: false } - : fact, - ), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([unavailableExplicitRuntimeStarts])).toContain( - `${explicitRuntimeScenario.id}: successful explicit runtime requires matching availability`, - ); - - const orphanDeletionScenario = findScenario("reclamation.delete-orphan-by-stack-id"); - const activeStackReportedOrphaned = { - ...orphanDeletionScenario, - given: orphanDeletionScenario.given.map((fact) => - fact.kind === "stack" ? { ...fact, orphaned: false } : fact, - ), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([activeStackReportedOrphaned])).toContain( - `${orphanDeletionScenario.id}: global orphan deletion requires an orphaned target`, - ); - - const legacyCredentialsScenario = findScenario( - "credentials.compatible-legacy-auth-is-retained", - ); - const differentLegacyCredentialReference = { - ...legacyCredentialsScenario, - given: legacyCredentialsScenario.given.map((fact) => - fact.kind === "credential-state" ? { ...fact, valuesId: "legacy-auth-v2" } : fact, - ), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([differentLegacyCredentialReference])).toContain( - `${legacyCredentialsScenario.id}: copied legacy credentials must bind their persisted reference`, - ); - - const credentialDriftScenario = findScenario("credentials.running-change-reports-drift"); - const stoppedStackReportedRunningCredentialDrift = { - ...credentialDriftScenario, - given: credentialDriftScenario.given.map((fact) => - fact.kind === "stack" ? { ...fact, lifecycle: "stopped" } : fact, - ), - } satisfies ManagedStackContractScenario; - expect( - validateManagedStackContractFixtures([stoppedStackReportedRunningCredentialDrift]), - ).toContain( - `${credentialDriftScenario.id}: credential drift report requires a running selected stack`, - ); - - const idempotentDeletionScenario = findScenario("reclamation.delete-repeat-is-idempotent"); - const activeRecordReportedDeleted = { - ...idempotentDeletionScenario, - given: idempotentDeletionScenario.given.map((fact) => - fact.kind === "managed-record" ? { ...fact, status: "active" } : fact, - ), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([activeRecordReportedDeleted])).toContain( - `${idempotentDeletionScenario.id}: idempotent deletion requires a tombstoned target`, - ); - - const folderReuseScenario = findScenario( - "identity.folder-to-git-exact-claim-preserves-identity", - ); - const ambiguousProjectClaimReused = { - ...folderReuseScenario, - given: folderReuseScenario.given.map((fact) => - fact.kind === "identity-claim" && fact.scope === "project" - ? { ...fact, status: "ambiguous" } - : fact, - ), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([ambiguousProjectClaimReused])).toContain( - `${folderReuseScenario.id}: folder-to-Git reuse requires exact project and checkout claims`, - ); - - const repositoryMatrixScenario = findScenario( - "api-boundary.repository-contract-is-storage-agnostic", - ); - const repositoryMatrixChangesRuntime = { - ...repositoryMatrixScenario, - given: repositoryMatrixScenario.given.map((fact) => - fact.kind === "managed-api-options" && fact.repository === "in-memory" - ? { ...fact, runtime: "bun" } - : fact, - ), - } satisfies ManagedStackContractScenario; - expect( - validateManagedStackContractFixtures([ - repositoryMatrixChangesRuntime, - findScenario("identity.return-to-branch-reuses-stack"), - ]), - ).toContain( - `${repositoryMatrixScenario.id}: repository comparison must hold runtime and state root constant`, - ); - - const portableMatrixScenario = findScenario( - "api-boundary.managed-surface-is-node-and-bun-portable", - ); - const portableMatrixChangesRepository = { - ...portableMatrixScenario, - given: portableMatrixScenario.given.map((fact) => - fact.kind === "managed-api-options" && fact.runtime === "bun" - ? { ...fact, repository: "persistent-adapter" } - : fact, - ), - } satisfies ManagedStackContractScenario; - expect( - validateManagedStackContractFixtures([ - portableMatrixChangesRepository, - findScenario("identity.same-checkout-branch-and-name-reuses-stack"), - ]), - ).toContain( - `${portableMatrixScenario.id}: portable comparison must hold repository and state root constant`, - ); - }); - - it("rejects observable results whose identity, runtime, port, and credential evidence disagrees", () => { - const findScenario = (id: string): ManagedStackContractScenario => { - const scenario = managedStackContractFixtures.find((candidate) => candidate.id === id); - if (scenario === undefined) { - throw new Error(`${id} fixture is required`); - } - return scenario; - }; - - const copiedBranchScenario = findScenario( - "identity.branch-copy-known-owner-creates-context-on-mutation", - ); - const copiedBranchWithoutOriginal = { - ...copiedBranchScenario, - given: copiedBranchScenario.given.map((fact) => - fact.kind === "identity-transition" && fact.operation === "branch-copy" - ? { ...fact, originalExists: false } - : fact, - ), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([copiedBranchWithoutOriginal])).toContain( - `${copiedBranchScenario.id}: copied branch transition must match live original and checked-out branch facts`, - ); - - const configRuntimeScenario = findScenario("runtime.config-overrides-default-auto"); - const ignoredConfigRuntime = { - ...configRuntimeScenario, - given: configRuntimeScenario.given.map((fact) => - fact.kind === "runtime-request" && fact.source === "config" - ? { ...fact, runtime: "docker" } - : fact, - ), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([ignoredConfigRuntime])).toContain( - `${configRuntimeScenario.id}: effective runtime request must match availability and successful projections`, - ); - - const runtimeDriftScenario = findScenario("runtime.status-reports-one-stack-wide-runtime"); - const inventedRuntimeDrift = { - ...runtimeDriftScenario, - given: runtimeDriftScenario.given.map((fact) => - fact.kind === "persisted-runtime" ? { ...fact, runtime: "native" } : fact, - ), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([inventedRuntimeDrift])).toContain( - `${runtimeDriftScenario.id}: runtime drift must bind running stack, persisted runtime, config, and projections`, - ); - - const failedCopyScenario = findScenario("bootstrap.failed-copy-rolls-back"); - const runningLegacyCopyRolledBack = { - ...failedCopyScenario, - given: failedCopyScenario.given.map((fact) => - fact.kind === "legacy-state" ? { ...fact, lifecycle: "running" } : fact, - ), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([runningLegacyCopyRolledBack])).toContain( - `${failedCopyScenario.id}: bootstrap rollback requires a compatible stopped legacy source`, - ); - - const runningLegacyScenario = findScenario( - "bootstrap.running-legacy-source-fails-without-mutation", - ); - const existingManagedTargetReadsLegacy = { - ...runningLegacyScenario, - given: runningLegacyScenario.given.map((fact) => - fact.kind === "managed-target" ? { ...fact, exists: true } : fact, - ), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([existingManagedTargetReadsLegacy])).toContain( - `${runningLegacyScenario.id}: running legacy error requires a running source and absent target`, - ); - - const runningLegacyPortScenario = findScenario( - "ports.running-legacy-source-fails-before-allocation", - ); - const unrelatedLegacyPortConflict = { - ...runningLegacyPortScenario, - given: runningLegacyPortScenario.given.map((fact) => - fact.kind === "config-port" ? { ...fact, value: 54322 } : fact, - ), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([unrelatedLegacyPortConflict])).toContain( - `${runningLegacyPortScenario.id}: running legacy port failure must bind config, occupancy, and projections`, - ); - - const defaultCredentialsScenario = findScenario( - "credentials.omitted-values-use-stable-defaults", - ); - const unrelatedDefaultCredentials = { - ...defaultCredentialsScenario, - given: defaultCredentialsScenario.given.map((fact) => - fact.kind === "credential-state" ? { ...fact, valuesId: "other-local-defaults" } : fact, - ), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([unrelatedDefaultCredentials])).toContain( - `${defaultCredentialsScenario.id}: persisted credential reference and source must match declared values`, - ); - - const persistedCredentialsScenario = findScenario( - "credentials.unchanged-values-survive-restart", - ); - const unrelatedPersistedCredentials = { - ...persistedCredentialsScenario, - given: persistedCredentialsScenario.given.map((fact) => - fact.kind === "credential-state" ? { ...fact, valuesId: "other-persisted-values" } : fact, - ), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([unrelatedPersistedCredentials])).toContain( - `${persistedCredentialsScenario.id}: persisted credential reference and source must match declared values`, - ); - - const stickyPortScenario = findScenario("ports.sticky-ports-reuse-on-return"); - const exactAssignmentReportedSticky = { - ...stickyPortScenario, - given: stickyPortScenario.given.map((fact) => - fact.kind === "port-assignment" ? { ...fact, intent: "exact" } : fact, - ), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([exactAssignmentReportedSticky])).toContain( - `${stickyPortScenario.id}: sticky port reuse must bind automatic config, assignment, and projections`, - ); - - const removedExactPortScenario = findScenario( - "ports.removing-exact-key-keeps-current-port-sticky", - ); - const unrelatedExactPortReportedSticky = { - ...removedExactPortScenario, - given: removedExactPortScenario.given.map((fact) => - fact.kind === "config-port" ? { ...fact, previousValue: 54322 } : fact, - ), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([unrelatedExactPortReportedSticky])).toContain( - `${removedExactPortScenario.id}: exact-to-automatic port transition must preserve the previous assignment`, - ); - - const unregisteredScenario = findScenario( - "identity.read-only-unregistered-checkout-does-not-write", - ); - const registeredCheckoutReportedAbsent = { - ...unregisteredScenario, - given: unregisteredScenario.given.map((fact) => - fact.kind === "identity-claim" ? { ...fact, status: "exact" } : fact, - ), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([registeredCheckoutReportedAbsent])).toContain( - `${unregisteredScenario.id}: unregistered status requires an absent checkout claim`, - ); - - const markerRecoveryScenario = findScenario( - "identity.non-git-folder-recovers-persisted-identity", - ); - const gitWorkspaceTrustsLocalMarker = { - ...markerRecoveryScenario, - given: markerRecoveryScenario.given.map((fact) => - fact.kind === "workspace" ? { ...fact, mode: "git" } : fact, - ), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([gitWorkspaceTrustsLocalMarker])).toContain( - `${markerRecoveryScenario.id}: local identity marker recovery requires an ordinary folder`, - ); - - const persistedRuntimeScenario = findScenario("runtime.persisted-runtime-reused-for-auto"); - const siblingRuntimeReused = { - ...persistedRuntimeScenario, - given: persistedRuntimeScenario.given.map((fact) => - fact.kind === "persisted-runtime" ? { ...fact, stackId: "stack-other-default" } : fact, - ), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([siblingRuntimeReused])).toContain( - `${persistedRuntimeScenario.id}: automatic runtime must resolve from persisted state or declared availability`, - ); - - const isolatedRootScenario = findScenario( - "api-boundary.managed-api-accepts-isolated-state-root", - ); - const defaultOptionsUseIsolatedRoot = { - ...isolatedRootScenario, - given: isolatedRootScenario.given.map((fact) => - fact.kind === "managed-api-options" ? { ...fact, stateRoot: "default" } : fact, - ), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([defaultOptionsUseIsolatedRoot])).toContain( - `${isolatedRootScenario.id}: isolated state root input must match its options and observed boundary`, - ); - - const automaticRuntimeFailureScenario = findScenario( - "runtime.auto-fails-when-neither-runtime-is-available", - ); - const unrelatedAutomaticFailureReason = { - ...automaticRuntimeFailureScenario, - given: automaticRuntimeFailureScenario.given.map((fact) => - fact.kind === "runtime-availability" && fact.runtime === "docker" - ? { ...fact, reason: "unrelated failure" } - : fact, - ), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([unrelatedAutomaticFailureReason])).toContain( - `${automaticRuntimeFailureScenario.id}: automatic runtime failure must bind both unavailability reasons`, - ); - - const stackNamesScenario = findScenario("identity.valid-stack-names-resolve-deterministically"); - const wrongNamedStackResult = { - ...stackNamesScenario, - expected: { - ...stackNamesScenario.expected, - output: { - ...stackNamesScenario.expected.output, - api: { - ...stackNamesScenario.expected.output.api, - default: { contextId: "context-feat", stackId: "stack-unrelated" }, - }, - }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([wrongNamedStackResult])).toContain( - `${stackNamesScenario.id}: resolved stack name default must bind its context and stack ID`, - ); - - const projectedManagedResultScenario = findScenario( - "api-boundary.cli-projects-shared-managed-results", - ); - const tombstonedRecordReportedRunning = { - ...projectedManagedResultScenario, - given: projectedManagedResultScenario.given.map((fact) => - fact.kind === "managed-record" ? { ...fact, status: "tombstoned" } : fact, - ), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([tombstonedRecordReportedRunning])).toContain( - `${projectedManagedResultScenario.id}: projected managed status requires an active running record and persisted runtime`, - ); - const stoppedStackReportedRunning = { - ...projectedManagedResultScenario, - given: projectedManagedResultScenario.given.map((fact) => - fact.kind === "stack" ? { ...fact, lifecycle: "stopped" } : fact, - ), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([stoppedStackReportedRunning])).toContain( - `${projectedManagedResultScenario.id}: projected managed status requires an active running record and persisted runtime`, - ); - - const persistedRuntimeAvailabilityScenario = findScenario( - "runtime.persisted-runtime-reused-for-auto", - ); - const persistedRuntimeWithoutAvailability = { - ...persistedRuntimeAvailabilityScenario, - given: persistedRuntimeAvailabilityScenario.given.map((fact) => - fact.kind === "runtime-availability" && fact.runtime === "native" - ? { ...fact, runtime: "docker" } - : fact, - ), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([persistedRuntimeWithoutAvailability])).toContain( - `${persistedRuntimeAvailabilityScenario.id}: persisted automatic runtime requires matching availability evidence`, - ); - - const qualificationScenario = findScenario( - "native-qualification.one-service-failure-disables-platform", - ); - if (qualificationScenario.when.interface !== "managed-api") { - throw new Error("native qualification fixture must use the managed API"); - } - const unsupportedPlatformReportedAsUnqualified = { - ...qualificationScenario, - given: qualificationScenario.given.map((fact) => - fact.kind === "native-qualification" ? { ...fact, platform: "darwin-x64" } : fact, - ), - when: { - ...qualificationScenario.when, - input: { ...qualificationScenario.when.input, platform: "darwin-x64" }, - }, - expected: { - ...qualificationScenario.expected, - output: { - ...qualificationScenario.expected.output, - api: { ...qualificationScenario.expected.output.api, platform: "darwin-x64" }, - }, - }, - } satisfies ManagedStackContractScenario; - expect( - validateManagedStackContractFixtures([unsupportedPlatformReportedAsUnqualified]), - ).toContain( - `${qualificationScenario.id}: unsupported native platform must use the dedicated preflight error`, - ); - - const renameScenario = findScenario("identity.branch-rename-preserves-context"); - const renameToUnrelatedBranch = { - ...renameScenario, - given: renameScenario.given.map((fact) => - fact.kind === "identity-transition" && fact.operation === "branch-rename" - ? { ...fact, to: "unrelated" } - : fact, - ), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([renameToUnrelatedBranch])).toContain( - `${renameScenario.id}: branch rename must match the checked-out branch and updated context owner`, - ); - - const repositoryScenario = findScenario("api-boundary.repository-contract-is-storage-agnostic"); - const referencedRepositoryScenario = findScenario("identity.return-to-branch-reuses-stack"); - const adaptersAgreeOnWrongContext = { - ...repositoryScenario, - expected: { - ...repositoryScenario.expected, - output: { - ...repositoryScenario.expected.output, - api: { - ...repositoryScenario.expected.output.api, - "in-memory": { - outcome: "reuse", - projectId: "project-a", - checkoutId: "checkout-a", - contextId: "context-unrelated", - stackId: "stack-main-default", - stackName: "default", - }, - "persistent-adapter": { - outcome: "reuse", - projectId: "project-a", - checkoutId: "checkout-a", - contextId: "context-unrelated", - stackId: "stack-main-default", - stackName: "default", - }, - }, - }, - }, - } satisfies ManagedStackContractScenario; - expect( - validateManagedStackContractFixtures([ - referencedRepositoryScenario, - adaptersAgreeOnWrongContext, - ]), - ).toContain( - `${repositoryScenario.id}: repository in-memory decision must completely match ${referencedRepositoryScenario.id}`, - ); - - const ambiguousContextScenario = findScenario("identity.branch-copy-ambiguous-read-only"); - const independentBranchesReportedAmbiguous = { - ...ambiguousContextScenario, - given: ambiguousContextScenario.given.map((fact) => - fact.kind === "branch" && fact.name === "main" - ? { ...fact, contextId: "context-independent" } - : fact, - ), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([independentBranchesReportedAmbiguous])).toContain( - `${ambiguousContextScenario.id}: ambiguous context must bind at least two claiming branches to its projections`, - ); - - const invalidStackNameScenario = findScenario( - "identity.invalid-stack-name-uppercase-underscore-fails", - ); - if (invalidStackNameScenario.when.interface !== "cli") { - throw new Error("invalid stack name fixture must use the CLI"); - } - const validStackNameRejected = { - ...invalidStackNameScenario, - given: invalidStackNameScenario.given.map((fact) => - fact.kind === "stack-names" ? { ...fact, names: ["review"] } : fact, - ), - when: { - ...invalidStackNameScenario.when, - argv: invalidStackNameScenario.when.argv.map((argument) => - argument === "Feature_A" ? "review" : argument, - ), - }, - expected: { - ...invalidStackNameScenario.expected, - output: { - ...invalidStackNameScenario.expected.output, - human: { - ...invalidStackNameScenario.expected.output.human!, - fields: { stack: "review" }, - }, - json: { ...invalidStackNameScenario.expected.output.json, stack_name: "review" }, - }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([validStackNameRejected])).toContain( - `${invalidStackNameScenario.id}: invalid stack name error must bind a requested name outside the supported grammar`, - ); - - const automaticPortScenario = findScenario( - "ports.new-target-allocates-and-persists-omitted-ports", - ); - const duplicateAutomaticPort = { - ...automaticPortScenario, - expected: { - ...automaticPortScenario.expected, - output: { - ...automaticPortScenario.expected.output, - api: { - ...automaticPortScenario.expected.output.api, - ports: { api: 55421, db: 55421 }, - }, - }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([duplicateAutomaticPort])).toContain( - `${automaticPortScenario.id}: allocated port 55421 is assigned more than once`, - ); - - const refReplacementScenario = findScenario("identity.manual-ref-replacement-orphans-context"); - const refReplacementTargetsUnobservedCommit = { - ...refReplacementScenario, - given: refReplacementScenario.given.map((fact) => - fact.kind === "identity-transition" && fact.operation === "ref-replacement" - ? { ...fact, to: "commit-unrelated" } - : fact, - ), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([refReplacementTargetsUnobservedCommit])).toContain( - `${refReplacementScenario.id}: ref replacement target must match the action workspace commit`, - ); - - const selectorConflictScenario = findScenario( - "reclamation.selectors-stack-and-stack-id-conflict", - ); - if (selectorConflictScenario.when.interface !== "cli") { - throw new Error("selector conflict fixture must use the CLI"); - } - const singleSelectorReportedAsConflict = { - ...selectorConflictScenario, - when: { - ...selectorConflictScenario.when, - argv: ["stop", "--experimental", "--stack", "review"], - }, - expected: { - ...selectorConflictScenario.expected, - output: { - ...selectorConflictScenario.expected.output, - human: { - ...selectorConflictScenario.expected.output.human!, - fields: { selectors: "--stack" }, - }, - json: { ...selectorConflictScenario.expected.output.json, selectors: ["--stack"] }, - }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([singleSelectorReportedAsConflict])).toContain( - `${selectorConflictScenario.id}: selector conflict must bind at least two requested selection modes`, - ); - - const pruneScenario = findScenario("reclamation.prune-removes-metadata-only"); - const pruneReportsDifferentRecord = { - ...pruneScenario, - expected: { - ...pruneScenario.expected, - output: { - ...pruneScenario.expected.output, - json: { ...pruneScenario.expected.output.json, pruned_records: ["stack-other"] }, - }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([pruneReportsDifferentRecord])).toContain( - `${pruneScenario.id}: prune projections must match deleted registry records`, - ); - - const portableDecisionScenario = findScenario( - "api-boundary.managed-surface-is-node-and-bun-portable", - ); - const referencedPortableDecisionScenario = findScenario( - "identity.same-checkout-branch-and-name-reuses-stack", - ); - const portableRuntimesAgreeOnWrongContext = { - ...portableDecisionScenario, - expected: { - ...portableDecisionScenario.expected, - output: { - ...portableDecisionScenario.expected.output, - api: { - ...portableDecisionScenario.expected.output.api, - node: { - outcome: "report", - projectId: "project-a", - checkoutId: "checkout-a", - contextId: "context-unrelated", - stackId: "stack-main-default", - stackName: "default", - }, - bun: { - outcome: "report", - projectId: "project-a", - checkoutId: "checkout-a", - contextId: "context-unrelated", - stackId: "stack-main-default", - stackName: "default", - }, - }, - }, - }, - } satisfies ManagedStackContractScenario; - expect( - validateManagedStackContractFixtures([ - referencedPortableDecisionScenario, - portableRuntimesAgreeOnWrongContext, - ]), - ).toContain( - `${portableDecisionScenario.id}: portable node decision must completely match ${referencedPortableDecisionScenario.id}`, - ); - - const persistedRuntimeFailureScenario = findScenario( - "runtime.missing-persisted-prerequisite-fails", - ); - const persistedRuntimeReportsUnrelatedReason = { - ...persistedRuntimeFailureScenario, - given: persistedRuntimeFailureScenario.given.map((fact) => - fact.kind === "runtime-availability" && fact.runtime === "native" - ? { ...fact, reason: "unrelated failure" } - : fact, - ), - } satisfies ManagedStackContractScenario; - expect( - validateManagedStackContractFixtures([persistedRuntimeReportsUnrelatedReason]), - ).toContain( - `${persistedRuntimeFailureScenario.id}: unavailable persisted runtime must fail without switching`, - ); - - const reducedNativeGraph = { - ...qualificationScenario, - expected: { - ...qualificationScenario.expected, - output: { - ...qualificationScenario.expected.output, - api: { - ...qualificationScenario.expected.output.api, - availableServices: ["postgres"], - }, - }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([reducedNativeGraph])).toContain( - `${qualificationScenario.id}: failed native qualification must expose no reduced service graph`, - ); - - const automaticNativeScenario = findScenario("runtime.auto-selects-fully-qualified-native"); - const automaticNativeWithoutDockerEvidence = { - ...automaticNativeScenario, - given: automaticNativeScenario.given.filter( - (fact) => fact.kind !== "runtime-availability" || fact.runtime !== "docker", - ), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([automaticNativeWithoutDockerEvidence])).toContain( - `${automaticNativeScenario.id}: automatic native fallback requires explicit Docker unavailability`, - ); - - if (qualificationScenario.expected.error === undefined) { - throw new Error("failed native qualification fixture must project an error"); - } - const qualificationUsesUnrelatedError = { - ...qualificationScenario, - expected: { - ...qualificationScenario.expected, - error: { ...qualificationScenario.expected.error, code: "UNRELATED_ERROR" }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([qualificationUsesUnrelatedError])).toContain( - `${qualificationScenario.id}: supported unqualified native platform must use NATIVE_PLATFORM_NOT_QUALIFIED`, - ); - - const persistedRuntimeWithoutProvenance = { - ...persistedRuntimeAvailabilityScenario, - expected: { - ...persistedRuntimeAvailabilityScenario.expected, - output: { - ...persistedRuntimeAvailabilityScenario.expected.output, - json: { ...persistedRuntimeAvailabilityScenario.expected.output.json, persisted: false }, - }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([persistedRuntimeWithoutProvenance])).toContain( - `${persistedRuntimeAvailabilityScenario.id}: automatic persisted runtime reuse must report persisted provenance`, - ); - - const siblingPortScenario = findScenario("ports.sibling-targets-allocate-independent-ports"); - const omittedSiblingAssignment = { - ...siblingPortScenario, - given: siblingPortScenario.given.filter( - (fact) => fact.kind !== "port-assignment" || fact.stackId !== "stack-main-default", - ), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([omittedSiblingAssignment])).toContain( - `${siblingPortScenario.id}: sibling allocation must bind all avoided stack IDs`, - ); - - const nonDestructiveStopScenario = findScenario("reclamation.default-stop-preserves-data"); - const nonDestructiveStopReportsDataLoss = { - ...nonDestructiveStopScenario, - expected: { - ...nonDestructiveStopScenario.expected, - details: { - ...nonDestructiveStopScenario.expected.details, - data_preserved: false, - registry_record_preserved: false, - }, - output: { - ...nonDestructiveStopScenario.expected.output, - human: { - ...nonDestructiveStopScenario.expected.output.human!, - fields: { dataPreserved: "false" }, - }, - json: { - ...nonDestructiveStopScenario.expected.output.json, - data_preserved: false, - }, - }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([nonDestructiveStopReportsDataLoss])).toContain( - `${nonDestructiveStopScenario.id}: non-destructive stop must report preserved data and registry`, - ); - - const existingManagedTargetScenario = findScenario( - "bootstrap.existing-managed-target-ignores-legacy", - ); - const existingTargetReportsBootstrap = { - ...existingManagedTargetScenario, - expected: { - ...existingManagedTargetScenario.expected, - details: { ...existingManagedTargetScenario.expected.details, legacy_state_read: true }, - output: { - ...existingManagedTargetScenario.expected.output, - api: { - ...existingManagedTargetScenario.expected.output.api, - bootstrap: "copied", - legacyStateRead: true, - }, - }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([existingTargetReportsBootstrap])).toContain( - `${existingManagedTargetScenario.id}: existing managed target must not report legacy bootstrap`, - ); - - const checkoutRebindScenario = findScenario("identity.missing-previous-path-rebinds-checkout"); - const checkoutRebindWithoutRegistryUpdate = { - ...checkoutRebindScenario, - expected: { - ...checkoutRebindScenario.expected, - writes: checkoutRebindScenario.expected.writes.filter( - (write) => write.target !== "registry", - ), - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([checkoutRebindWithoutRegistryUpdate])).toContain( - `${checkoutRebindScenario.id}: automatic checkout rebind must persist the checkout registry update`, - ); - - const exactPortChangeScenario = findScenario("ports.config-change-on-stopped-stack-applies"); - const exactPortChangeWithoutPersistence = { - ...exactPortChangeScenario, - expected: { - ...exactPortChangeScenario.expected, - writes: exactPortChangeScenario.expected.writes.filter( - (write) => write.target !== "managed-state", - ), - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([exactPortChangeWithoutPersistence])).toContain( - `${exactPortChangeScenario.id}: exact port change must persist assignment before runtime start`, - ); - - const engineScopedStopScenario = findScenario("reclamation.stop-is-engine-scoped"); - const engineScopedStopWithoutRunningLegacy = { - ...engineScopedStopScenario, - given: engineScopedStopScenario.given.map((fact) => - fact.kind === "legacy-state" ? { ...fact, lifecycle: "stopped" } : fact, - ), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([engineScopedStopWithoutRunningLegacy])).toContain( - `${engineScopedStopScenario.id}: engine-scoped stop requires a simultaneously running legacy stack`, - ); - - const bootstrapCopyScenario = findScenario( - "bootstrap.first-start-copies-compatible-legacy-state", - ); - const bootstrapCopyReportsLegacyMutation = { - ...bootstrapCopyScenario, - expected: { - ...bootstrapCopyScenario.expected, - details: { ...bootstrapCopyScenario.expected.details, legacy_state_mutated: true }, - output: { - ...bootstrapCopyScenario.expected.output, - json: { - ...bootstrapCopyScenario.expected.output.json, - legacy_state_mutated: true, - }, - }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([bootstrapCopyReportsLegacyMutation])).toContain( - `${bootstrapCopyScenario.id}: bootstrap copy must not mutate legacy state`, - ); - - const pruneReportsMutableDataDeletion = { - ...pruneScenario, - expected: { - ...pruneScenario.expected, - details: { ...pruneScenario.expected.details, mutable_data_deleted: true }, - output: { - ...pruneScenario.expected.output, - human: { - ...pruneScenario.expected.output.human!, - fields: { dataDeleted: "true" }, - }, - json: { ...pruneScenario.expected.output.json, mutable_data_deleted: true }, - }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([pruneReportsMutableDataDeletion])).toContain( - `${pruneScenario.id}: prune must preserve mutable stack data`, - ); - - const directCreateScenario = findScenario("api-boundary.direct-create-stack-is-ephemeral"); - const directCreateReportsManagedSideEffects = { - ...directCreateScenario, - expected: { - ...directCreateScenario.expected, - details: { - ...directCreateScenario.expected.details, - git_inspected: true, - identity_marker_created: true, - global_registry_mutated: true, - }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([directCreateReportsManagedSideEffects])).toContain( - `${directCreateScenario.id}: direct createStack must remain isolated from managed state`, - ); - - const contextualSelectionScenario = findScenario( - "identity.same-checkout-branch-and-name-reuses-stack", - ); - const contextualSelectionWithoutCheckedOutBranch = { - ...contextualSelectionScenario, - given: contextualSelectionScenario.given.map((fact) => - fact.kind === "branch" ? { ...fact, checkedOut: false } : fact, - ), - } satisfies ManagedStackContractScenario; - expect( - validateManagedStackContractFixtures([contextualSelectionWithoutCheckedOutBranch]), - ).toContain( - `${contextualSelectionScenario.id}: contextual Git selection requires exactly one checked-out branch`, - ); - - const failedBootstrapScenario = findScenario("bootstrap.failed-copy-rolls-back"); - const failedBootstrapReportsPublishedPartialTarget = { - ...failedBootstrapScenario, - expected: { - ...failedBootstrapScenario.expected, - details: { - ...failedBootstrapScenario.expected.details, - active_target_exists: true, - registry_record_published: true, - }, - output: { - ...failedBootstrapScenario.expected.output, - api: { - ...failedBootstrapScenario.expected.output.api, - activeTargetExists: true, - registryRecordPublished: true, - }, - }, - }, - } satisfies ManagedStackContractScenario; - expect( - validateManagedStackContractFixtures([failedBootstrapReportsPublishedPartialTarget]), - ).toContain( - `${failedBootstrapScenario.id}: bootstrap rollback must leave no published partial target`, - ); - - const stableCredentialsScenario = findScenario( - "credentials.omitted-values-use-stable-defaults", - ); - const omittedCredentialsRotatePerStart = { - ...stableCredentialsScenario, - expected: { - ...stableCredentialsScenario.expected, - details: { ...stableCredentialsScenario.expected.details, generated_per_start: true }, - output: { - ...stableCredentialsScenario.expected.output, - json: { ...stableCredentialsScenario.expected.output.json, credentials_stable: false }, - }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([omittedCredentialsRotatePerStart])).toContain( - `${stableCredentialsScenario.id}: omitted credentials must reuse stable local defaults`, - ); - - const trackedMarkerScenario = findScenario("identity.fresh-clone-ignores-tracked-marker"); - const trackedMarkerReportedMutable = { - ...trackedMarkerScenario, - expected: { - ...trackedMarkerScenario.expected, - details: { - ...trackedMarkerScenario.expected.details, - tracked_marker_ignored: false, - tracked_marker_mutated: true, - }, - output: { - ...trackedMarkerScenario.expected.output, - json: { - ...trackedMarkerScenario.expected.output.json, - tracked_marker_ignored: false, - }, - }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([trackedMarkerReportedMutable])).toContain( - `${trackedMarkerScenario.id}: tracked identity marker must remain ignored and unmodified`, - ); - - const runtimeStatusScenario = findScenario("runtime.status-reports-one-stack-wide-runtime"); - const runtimeStatusReportsMixedGraph = { - ...runtimeStatusScenario, - expected: { - ...runtimeStatusScenario.expected, - details: { ...runtimeStatusScenario.expected.details, mixed_runtime: true }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([runtimeStatusReportsMixedGraph])).toContain( - `${runtimeStatusScenario.id}: runtime drift must bind running stack, persisted runtime, config, and projections`, - ); - - const folderToGitAmbiguityScenario = findScenario( - "identity.folder-to-git-ambiguous-claim-fails", - ); - const exactFolderClaimReportedAmbiguous = { - ...folderToGitAmbiguityScenario, - given: folderToGitAmbiguityScenario.given.map((fact) => - fact.kind === "identity-claim" ? { ...fact, status: "exact" } : fact, - ), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([exactFolderClaimReportedAmbiguous])).toContain( - `${folderToGitAmbiguityScenario.id}: folder-to-Git ambiguity error requires an ambiguous live project claim`, - ); - - const strictRuntimeScenario = findScenario("runtime.explicit-runtime-is-strict"); - const strictRuntimeWithoutAvailableAlternative = { - ...strictRuntimeScenario, - given: strictRuntimeScenario.given.filter( - (fact) => fact.kind !== "runtime-availability" || fact.runtime !== "native", - ), - } satisfies ManagedStackContractScenario; - expect( - validateManagedStackContractFixtures([strictRuntimeWithoutAvailableAlternative]), - ).toContain( - `${strictRuntimeScenario.id}: explicit runtime error must bind an unavailable requested runtime`, - ); - - const repeatedDeletionScenario = findScenario("reclamation.delete-repeat-is-idempotent"); - const repeatedDeletionHidesIdempotency = { - ...repeatedDeletionScenario, - expected: { - ...repeatedDeletionScenario.expected, - details: { ...repeatedDeletionScenario.expected.details, idempotent: false }, - output: { - ...repeatedDeletionScenario.expected.output, - json: { ...repeatedDeletionScenario.expected.output.json, already_deleted: false }, - }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([repeatedDeletionHidesIdempotency])).toContain( - `${repeatedDeletionScenario.id}: idempotent deletion requires a tombstoned target`, - ); - - const standaloneGitScenario = findScenario("identity.branch-create-and-switch-is-no-op"); - const standaloneGitReportsManagedDeletion = { - ...standaloneGitScenario, - expected: { - ...standaloneGitScenario.expected, - outcome: "delete", - details: { ...standaloneGitScenario.expected.details, managed_command_ran: true }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([standaloneGitReportsManagedDeletion])).toContain( - `${standaloneGitScenario.id}: standalone Git action must remain outside managed lifecycle`, - ); - - const recreatedBranchScenario = findScenario("identity.branch-delete-recreate-creates-context"); - const recreatedBranchOrphansUnrelatedContext = { - ...recreatedBranchScenario, - expected: { - ...recreatedBranchScenario.expected, - details: { ...recreatedBranchScenario.expected.details, orphaned_context_id: "context-x" }, - output: { - ...recreatedBranchScenario.expected.output, - json: { - ...recreatedBranchScenario.expected.output.json, - orphaned_context_id: "context-x", - }, - }, - }, - } satisfies ManagedStackContractScenario; - expect( - validateManagedStackContractFixtures([recreatedBranchOrphansUnrelatedContext]), - ).toContain( - `${recreatedBranchScenario.id}: branch recreation must orphan the displaced context`, - ); - - const incompatibleLegacyScenario = findScenario("bootstrap.incompatible-legacy-starts-fresh"); - const incompatibleLegacyReportsMutation = { - ...incompatibleLegacyScenario, - expected: { - ...incompatibleLegacyScenario.expected, - details: { ...incompatibleLegacyScenario.expected.details, legacy_state_mutated: true }, - output: { - ...incompatibleLegacyScenario.expected.output, - json: { - ...incompatibleLegacyScenario.expected.output.json, - legacy_state_mutated: true, - }, - }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([incompatibleLegacyReportsMutation])).toContain( - `${incompatibleLegacyScenario.id}: fresh bootstrap must not copy or mutate legacy state`, - ); - - const restartPersistedCredentialsScenario = findScenario( - "credentials.unchanged-values-survive-restart", - ); - const persistedCredentialsRotate = { - ...restartPersistedCredentialsScenario, - expected: { - ...restartPersistedCredentialsScenario.expected, - details: { - ...restartPersistedCredentialsScenario.expected.details, - credentials_rotated: true, - }, - output: { - ...restartPersistedCredentialsScenario.expected.output, - json: { - ...restartPersistedCredentialsScenario.expected.output.json, - credentials_unchanged: false, - }, - }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([persistedCredentialsRotate])).toContain( - `${restartPersistedCredentialsScenario.id}: persisted credentials must survive restart unchanged`, - ); - - const freshAutomaticRuntimeScenario = findScenario("runtime.auto-prefers-docker"); - const freshAutomaticRuntimeIsNotPersisted = { - ...freshAutomaticRuntimeScenario, - expected: { - ...freshAutomaticRuntimeScenario.expected, - details: { ...freshAutomaticRuntimeScenario.expected.details, persisted: false }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([freshAutomaticRuntimeIsNotPersisted])).toContain( - `${freshAutomaticRuntimeScenario.id}: fresh automatic runtime selection must be persisted`, - ); - - const resolvedNamesScenario = findScenario( - "identity.valid-stack-names-resolve-deterministically", - ); - if (resolvedNamesScenario.when.interface !== "managed-api") { - throw new Error( - "identity.valid-stack-names-resolve-deterministically managed API fixture is required", - ); - } - const resolvedNamesIncludeInvalidName = { - ...resolvedNamesScenario, - given: resolvedNamesScenario.given.map((fact) => - fact.kind === "stack-names" ? { ...fact, names: ["default", "Review_42"] } : fact, - ), - when: { - ...resolvedNamesScenario.when, - input: { ...resolvedNamesScenario.when.input, stackNames: ["default", "Review_42"] }, - }, - expected: { - ...resolvedNamesScenario.expected, - details: { - default: "stack-feat-default", - Review_42: "stack-feat-review-42", - }, - output: { - ...resolvedNamesScenario.expected.output, - api: { - default: { contextId: "context-feat", stackId: "stack-feat-default" }, - Review_42: { contextId: "context-feat", stackId: "stack-feat-review-42" }, - }, - }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([resolvedNamesIncludeInvalidName])).toContain( - `${resolvedNamesScenario.id}: requested stack names must match their fact and projected results`, - ); - - const runtimeInputScenario = findScenario("runtime.explicit-api-overrides-auto"); - const authInputScenario = findScenario("credentials.configured-values-are-authoritative"); - const portInputScenario = findScenario("ports.explicit-free-port-is-used"); - if ( - runtimeInputScenario.when.interface !== "managed-api" || - authInputScenario.when.interface !== "managed-api" || - portInputScenario.when.interface !== "managed-api" - ) { - throw new Error("startStack managed API fixtures are required"); - } - const invalidStartInputs = [ - { - ...runtimeInputScenario, - when: { - ...runtimeInputScenario.when, - input: { ...runtimeInputScenario.when.input, runtime: 42 }, - }, - } satisfies ManagedStackContractScenario, - { - ...authInputScenario, - when: { - ...authInputScenario.when, - input: { ...authInputScenario.when.input, auth: 42 }, - }, - } satisfies ManagedStackContractScenario, - { - ...portInputScenario, - when: { - ...portInputScenario.when, - input: { ...portInputScenario.when.input, portIntents: "invalid" }, - }, - } satisfies ManagedStackContractScenario, - ]; - for (const invalidStartInput of invalidStartInputs) { - expect(validateManagedStackContractFixtures([invalidStartInput])).toContain( - `${invalidStartInput.id}: managed action must use a declared public method`, - ); - } - - const nativeAutomaticScenario = findScenario("runtime.auto-selects-fully-qualified-native"); - const nativeAutomaticReportsPartialGraph = { - ...nativeAutomaticScenario, - expected: { - ...nativeAutomaticScenario.expected, - details: { - ...nativeAutomaticScenario.expected.details, - qualified_service_count: 1, - mixed_runtime: true, - }, - output: { - ...nativeAutomaticScenario.expected.output, - api: { ...nativeAutomaticScenario.expected.output.api, qualifiedServiceCount: 1 }, - }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([nativeAutomaticReportsPartialGraph])).toContain( - `${nativeAutomaticScenario.id}: automatic native selection must bind the full qualified graph`, - ); - - const symlinkAliasScenario = findScenario("identity.symlink-alias-reuses-checkout"); - const symlinkAliasReportsUnrelatedPath = { - ...symlinkAliasScenario, - expected: { - ...symlinkAliasScenario.expected, - output: { - ...symlinkAliasScenario.expected.output, - json: { ...symlinkAliasScenario.expected.output.json, canonical_path: "/other/project" }, - }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([symlinkAliasReportsUnrelatedPath])).toContain( - `${symlinkAliasScenario.id}: symlink alias must report its canonical checkout path`, - ); - - const firstDeletionScenario = findScenario("reclamation.delete-orphan-by-stack-id"); - const firstDeletionHidesTombstone = { - ...firstDeletionScenario, - expected: { - ...firstDeletionScenario.expected, - details: { ...firstDeletionScenario.expected.details, tombstoned: false }, - output: { - ...firstDeletionScenario.expected.output, - human: { - ...firstDeletionScenario.expected.output.human!, - fields: { - ...firstDeletionScenario.expected.output.human!.fields, - tombstoned: "false", - }, - }, - json: { ...firstDeletionScenario.expected.output.json, tombstoned: false }, - }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([firstDeletionHidesTombstone])).toContain( - `${firstDeletionScenario.id}: registry tombstone must be reported by every projection`, - ); - - const injectedServiceScenario = findScenario( - "api-boundary.managed-api-accepts-injected-repository", - ); - const injectedServiceRequiresCli = { - ...injectedServiceScenario, - expected: { - ...injectedServiceScenario.expected, - details: { ...injectedServiceScenario.expected.details, cli_required: true }, - output: { - ...injectedServiceScenario.expected.output, - api: { ...injectedServiceScenario.expected.output.api, cliRequired: true }, - }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([injectedServiceRequiresCli])).toContain( - `${injectedServiceScenario.id}: injected repository and state root must match the observed managed service`, - ); - - const sameCommitScenario = findScenario( - "identity.same-commit-different-branches-are-independent", - ); - const sameCommitScenarioHasDifferentCheckedOutCommit = { - ...sameCommitScenario, - given: sameCommitScenario.given.map((fact) => - fact.kind === "git-state" ? { ...fact, commit: "unrelated-commit" } : fact, - ), - } satisfies ManagedStackContractScenario; - expect( - validateManagedStackContractFixtures([sameCommitScenarioHasDifferentCheckedOutCommit]), - ).toContain( - `${sameCommitScenario.id}: branch comparison must prove both refs share the checked-out commit`, - ); - - const ordinaryFolderScenario = findScenario( - "identity.non-git-folder-first-start-persists-identity", - ); - const ordinaryFolderTracksMarker = { - ...ordinaryFolderScenario, - expected: { - ...ordinaryFolderScenario.expected, - details: { ...ordinaryFolderScenario.expected.details, identity_marker_tracked: true }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([ordinaryFolderTracksMarker])).toContain( - `${ordinaryFolderScenario.id}: ordinary-folder identity marker must remain untracked`, - ); - - const runningLegacyNoMutationScenario = findScenario( - "bootstrap.running-legacy-source-fails-without-mutation", - ); - const runningLegacyReportsPartialTarget = { - ...runningLegacyNoMutationScenario, - expected: { - ...runningLegacyNoMutationScenario.expected, - details: { - ...runningLegacyNoMutationScenario.expected.details, - managed_target_published: true, - partial_state: true, - }, - output: { - ...runningLegacyNoMutationScenario.expected.output, - json: { - ...runningLegacyNoMutationScenario.expected.output.json, - managed_target_published: true, - }, - }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([runningLegacyReportsPartialTarget])).toContain( - `${runningLegacyNoMutationScenario.id}: running legacy error must leave no partial managed target`, - ); - - const copiedBranchWarningScenario = findScenario( - "identity.branch-copy-read-only-does-not-write", - ); - const copiedBranchWarningReportsUnrelatedOwners = { - ...copiedBranchWarningScenario, - expected: { - ...copiedBranchWarningScenario.expected, - output: { - ...copiedBranchWarningScenario.expected.output, - json: { - ...copiedBranchWarningScenario.expected.output.json, - branch: "unrelated-branch", - owner: "unrelated-owner", - }, - }, - }, - } satisfies ManagedStackContractScenario; - expect( - validateManagedStackContractFixtures([copiedBranchWarningReportsUnrelatedOwners]), - ).toContain( - `${copiedBranchWarningScenario.id}: copied branch warning must bind observed branch ownership`, - ); - - const directDisposalScenario = findScenario( - "api-boundary.direct-dispose-removes-temporary-roots", - ); - const directDisposalLeaksTemporaryRoots = { - ...directDisposalScenario, - expected: { - ...directDisposalScenario.expected, - writes: [], - details: { ...directDisposalScenario.expected.details, temporary_roots_removed: false }, - output: { - ...directDisposalScenario.expected.output, - api: { - ...directDisposalScenario.expected.output.api, - temporaryRootsRemoved: false, - }, - }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([directDisposalLeaksTemporaryRoots])).toContain( - `${directDisposalScenario.id}: direct stack disposal must remove omitted temporary roots`, - ); - - const partialDirectRootsScenario = findScenario( - "api-boundary.direct-create-stack-keeps-omitted-runtime-root-temporary", - ); - const partialDirectRootsLeakRuntimeRoot = { - ...partialDirectRootsScenario, - expected: { - ...partialDirectRootsScenario.expected, - writes: [], - details: { ...partialDirectRootsScenario.expected.details, temporary_roots: [] }, - output: { - ...partialDirectRootsScenario.expected.output, - api: { ...partialDirectRootsScenario.expected.output.api, temporaryRoots: [] }, - }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([partialDirectRootsLeakRuntimeRoot])).toContain( - `${partialDirectRootsScenario.id}: direct stack root inputs must agree with temporary-state behavior`, - ); - - const copiedBranchCreateScenario = findScenario( - "identity.branch-copy-known-owner-creates-context-on-mutation", - ); - const copiedBranchCreateReportsUnrelatedAncestry = { - ...copiedBranchCreateScenario, - expected: { - ...copiedBranchCreateScenario.expected, - details: { - ...copiedBranchCreateScenario.expected.details, - original_context_id: "context-unrelated", - }, - output: { - ...copiedBranchCreateScenario.expected.output, - json: { - ...copiedBranchCreateScenario.expected.output.json, - original_context_id: "context-unrelated", - }, - }, - }, - } satisfies ManagedStackContractScenario; - expect( - validateManagedStackContractFixtures([copiedBranchCreateReportsUnrelatedAncestry]), - ).toContain( - `${copiedBranchCreateScenario.id}: copied branch creation must bind its original context`, - ); - - const retryableBootstrapScenario = findScenario("bootstrap.failed-copy-rolls-back"); - const failedBootstrapIsNotRetryable = { - ...retryableBootstrapScenario, - expected: { - ...retryableBootstrapScenario.expected, - output: { - ...retryableBootstrapScenario.expected.output, - api: { ...retryableBootstrapScenario.expected.output.api, retryable: false }, - }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([failedBootstrapIsNotRetryable])).toContain( - `${retryableBootstrapScenario.id}: bootstrap rollback must leave no published partial target`, - ); - - const bareWorktreeScenario = findScenario( - "identity.bare-repository-linked-worktrees-share-project", - ); - const bareWorktreeRequiresPrimary = { - ...bareWorktreeScenario, - expected: { - ...bareWorktreeScenario.expected, - output: { - ...bareWorktreeScenario.expected.output, - api: { ...bareWorktreeScenario.expected.output.api, primaryWorktreeRequired: true }, - }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([bareWorktreeRequiresPrimary])).toContain( - `${bareWorktreeScenario.id}: bare-repository worktree must not require a primary worktree`, - ); - - const missingOriginalScenario = findScenario("identity.original-gone-turns-copy-into-rename"); - const missingOriginalHidesRename = { - ...missingOriginalScenario, - expected: { - ...missingOriginalScenario.expected, - output: { - ...missingOriginalScenario.expected.output, - json: { ...missingOriginalScenario.expected.output.json, rename_detected: false }, - }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([missingOriginalHidesRename])).toContain( - `${missingOriginalScenario.id}: missing original branch must be reported as a rename`, - ); - - const divergedTimelineScenario = findScenario( - "bootstrap.managed-and-legacy-diverge-after-copy", - ); - const divergedTimelineReportsSynchronization = { - ...divergedTimelineScenario, - expected: { - ...divergedTimelineScenario.expected, - details: { ...divergedTimelineScenario.expected.details, timelines_diverged: false }, - output: { - ...divergedTimelineScenario.expected.output, - json: { ...divergedTimelineScenario.expected.output.json, timelines_diverged: false }, - }, - }, - } satisfies ManagedStackContractScenario; - expect( - validateManagedStackContractFixtures([divergedTimelineReportsSynchronization]), - ).toContain( - `${divergedTimelineScenario.id}: managed restart must report legacy timeline divergence`, - ); - - const cliProjectionScenario = findScenario("api-boundary.cli-projects-shared-managed-results"); - const cliReimplementsManagedDecisions = { - ...cliProjectionScenario, - expected: { - ...cliProjectionScenario.expected, - details: { - ...cliProjectionScenario.expected.details, - managed_result_projected: false, - identity_decisions_in_cli: 1, - }, - output: { - ...cliProjectionScenario.expected.output, - human: { - ...cliProjectionScenario.expected.output.human!, - fields: { stackId: "stack-main-default", runtime: "native" }, - }, - json: { ...cliProjectionScenario.expected.output.json, runtime: "native" }, - }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([cliReimplementsManagedDecisions])).toContain( - `${cliProjectionScenario.id}: projected managed status requires an active running record and persisted runtime`, - ); - - const folderConversionScenario = findScenario( - "identity.folder-to-git-exact-claim-preserves-identity", - ); - const folderConversionLosesTransition = { - ...folderConversionScenario, - given: folderConversionScenario.given.filter( - (fact) => fact.kind !== "identity-transition" || fact.operation !== "folder-to-git", - ), - expected: { - ...folderConversionScenario.expected, - output: { - ...folderConversionScenario.expected.output, - json: { ...folderConversionScenario.expected.output.json, converted_to_git: false }, - }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([folderConversionLosesTransition])).toContain( - `${folderConversionScenario.id}: folder-to-Git result must bind the workspace transition`, - ); - - const replacedRefOrphanScenario = findScenario( - "identity.manual-ref-replacement-orphans-context", - ); - const replacedRefOrphansUnrelatedContext = { - ...replacedRefOrphanScenario, - expected: { - ...replacedRefOrphanScenario.expected, - details: { - ...replacedRefOrphanScenario.expected.details, - orphaned_context_id: "context-unrelated", - }, - output: { - ...replacedRefOrphanScenario.expected.output, - json: { - ...replacedRefOrphanScenario.expected.output.json, - orphaned_context_id: "context-unrelated", - }, - }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([replacedRefOrphansUnrelatedContext])).toContain( - `${replacedRefOrphanScenario.id}: ref replacement must orphan the displaced branch context`, - ); - - const managedReuseLegacyScenario = findScenario( - "bootstrap.managed-and-legacy-diverge-after-copy", - ); - const managedReuseMutatesLegacy = { - ...managedReuseLegacyScenario, - expected: { - ...managedReuseLegacyScenario.expected, - details: { - ...managedReuseLegacyScenario.expected.details, - legacy_state_mutated: true, - }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([managedReuseMutatesLegacy])).toContain( - `${managedReuseLegacyScenario.id}: managed target reuse must not mutate legacy state`, - ); - - const readOnlyUnregisteredScenario = findScenario( - "identity.read-only-unregistered-checkout-does-not-write", - ); - const readOnlyDiscoveryReportsRegistration = { - ...readOnlyUnregisteredScenario, - expected: { - ...readOnlyUnregisteredScenario.expected, - details: { - ...readOnlyUnregisteredScenario.expected.details, - registered: true, - identity_marker_created: true, - }, - output: { - ...readOnlyUnregisteredScenario.expected.output, - json: { ...readOnlyUnregisteredScenario.expected.output.json, registered: true }, - }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([readOnlyDiscoveryReportsRegistration])).toContain( - `${readOnlyUnregisteredScenario.id}: read-only unregistered status must not create identity state`, - ); - - const stickyReturnScenario = findScenario("ports.sticky-ports-reuse-on-return"); - const stickyReturnHidesPersistence = { - ...stickyReturnScenario, - expected: { - ...stickyReturnScenario.expected, - output: { - ...stickyReturnScenario.expected.output, - json: { ...stickyReturnScenario.expected.output.json, sticky: false }, - }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([stickyReturnHidesPersistence])).toContain( - `${stickyReturnScenario.id}: sticky port reuse must bind automatic config, assignment, and projections`, - ); - - const unavailablePersistedRuntimeScenario = findScenario( - "runtime.missing-persisted-prerequisite-fails", - ); - const unavailablePersistedRuntimeReportsSwitch = { - ...unavailablePersistedRuntimeScenario, - expected: { - ...unavailablePersistedRuntimeScenario.expected, - details: { - ...unavailablePersistedRuntimeScenario.expected.details, - switched_to_docker: true, - }, - }, - } satisfies ManagedStackContractScenario; - expect( - validateManagedStackContractFixtures([unavailablePersistedRuntimeReportsSwitch]), - ).toContain( - `${unavailablePersistedRuntimeScenario.id}: unavailable persisted runtime must not report a switch`, - ); - - const unnamedFreshCloneScenario = findScenario( - "identity.fresh-clone-creates-project-and-checkout", - ); - if (unnamedFreshCloneScenario.expected.selection === undefined) { - throw new Error("fresh clone selection is required"); - } - const unnamedFreshCloneSelectsReview = { - ...unnamedFreshCloneScenario, - expected: { - ...unnamedFreshCloneScenario.expected, - selection: { ...unnamedFreshCloneScenario.expected.selection, stackName: "review" }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([unnamedFreshCloneSelectsReview])).toContain( - `${unnamedFreshCloneScenario.id}: unnamed CLI start must select the default stack`, - ); - - const freshCloneMutatesIndex = { - ...unnamedFreshCloneScenario, - expected: { - ...unnamedFreshCloneScenario.expected, - details: { ...unnamedFreshCloneScenario.expected.details, git_index_mutated: true }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([freshCloneMutatesIndex])).toContain( - `${unnamedFreshCloneScenario.id}: fresh clone identity creation must not mutate the Git index`, - ); - - const exactPortProjectionScenario = findScenario("ports.explicit-free-port-is-used"); - const exactPortProjectsAutomaticIntent = { - ...exactPortProjectionScenario, - expected: { - ...exactPortProjectionScenario.expected, - output: { - ...exactPortProjectionScenario.expected.output, - api: { ...exactPortProjectionScenario.expected.output.api, intent: "automatic" }, - }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([exactPortProjectsAutomaticIntent])).toContain( - `${exactPortProjectionScenario.id}: exact port request api.port must project exact intent`, - ); - - const metadataPruneScenario = findScenario("reclamation.prune-removes-metadata-only"); - const metadataPruneReportsNoRemoval = { - ...metadataPruneScenario, - expected: { - ...metadataPruneScenario.expected, - details: { ...metadataPruneScenario.expected.details, metadata_removed: false }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([metadataPruneReportsNoRemoval])).toContain( - `${metadataPruneScenario.id}: registry deletion must report removed metadata`, - ); - - const checkoutIndependentDeletionScenario = findScenario( - "reclamation.delete-orphan-by-stack-id", - ); - const globalDeletionRequiresCheckout = { - ...checkoutIndependentDeletionScenario, - expected: { - ...checkoutIndependentDeletionScenario.expected, - details: { - ...checkoutIndependentDeletionScenario.expected.details, - checkout_required: true, - }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([globalDeletionRequiresCheckout])).toContain( - `${checkoutIndependentDeletionScenario.id}: global orphan deletion requires an orphaned target`, - ); - - const branchHistoryEvidenceScenario = findScenario("identity.branch-commit-preserves-context"); - const branchHistoryLosesEvidence = { - ...branchHistoryEvidenceScenario, - given: branchHistoryEvidenceScenario.given.filter( - (fact) => fact.kind !== "branch" && fact.kind !== "identity-transition", - ), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([branchHistoryLosesEvidence])).toContain( - `${branchHistoryEvidenceScenario.id}: branch history preservation requires matching branch evidence`, - ); - - const configuredCredentialUpdateScenario = findScenario( - "credentials.explicit-change-applies-after-stop", - ); - const configuredCredentialUpdateLosesState = { - ...configuredCredentialUpdateScenario, - given: configuredCredentialUpdateScenario.given.filter( - (fact) => fact.kind !== "credential-state", - ), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([configuredCredentialUpdateLosesState])).toContain( - `${configuredCredentialUpdateScenario.id}: credential change requires configured old and new values`, - ); - - const branchHistoryLosesIntent = { - ...branchHistoryEvidenceScenario, - given: branchHistoryEvidenceScenario.given.filter((fact) => fact.kind !== "branch-history"), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([branchHistoryLosesIntent])).toContain( - `${branchHistoryEvidenceScenario.id}: branch history preservation requires matching branch evidence`, - ); - - const freshAutomaticPortsScenario = findScenario( - "ports.new-target-allocates-and-persists-omitted-ports", - ); - const freshAutomaticPortsHidePersistence = { - ...freshAutomaticPortsScenario, - expected: { - ...freshAutomaticPortsScenario.expected, - details: { - ...freshAutomaticPortsScenario.expected.details, - host_wide: false, - sticky: false, - }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([freshAutomaticPortsHidePersistence])).toContain( - `${freshAutomaticPortsScenario.id}: fresh automatic ports must be host-wide sticky assignments`, - ); - - const absentLegacyScenario = findScenario("bootstrap.absent-legacy-starts-fresh"); - const absentLegacyReportsMutation = { - ...absentLegacyScenario, - expected: { - ...absentLegacyScenario.expected, - details: { ...absentLegacyScenario.expected.details, legacy_state_mutated: true }, - output: { - ...absentLegacyScenario.expected.output, - json: { - ...absentLegacyScenario.expected.output.json, - legacy_state_mutated: true, - }, - }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([absentLegacyReportsMutation])).toContain( - `${absentLegacyScenario.id}: absent legacy bootstrap must not report mutation`, - ); - - const stableDefaultCredentialsScenario = findScenario( - "credentials.omitted-values-use-stable-defaults", - ); - const stableDefaultCredentialsLoseEvidence = { - ...stableDefaultCredentialsScenario, - given: stableDefaultCredentialsScenario.given.filter( - (fact) => fact.kind !== "credential-state", - ), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([stableDefaultCredentialsLoseEvidence])).toContain( - `${stableDefaultCredentialsScenario.id}: omitted credentials must reuse stable local defaults`, - ); - - const dockerPrecedenceScenario = findScenario("runtime.auto-prefers-docker"); - const dockerPrecedenceLosesNativeCandidate = { - ...dockerPrecedenceScenario, - given: dockerPrecedenceScenario.given.filter( - (fact) => fact.kind !== "runtime-availability" || fact.runtime !== "native", - ), - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([dockerPrecedenceLosesNativeCandidate])).toContain( - `${dockerPrecedenceScenario.id}: fresh automatic runtime selection requires Docker and native availability evidence`, - ); - - const resolveStackScenario = findScenario( - "identity.same-checkout-branch-and-name-reuses-stack", - ); - if (resolveStackScenario.when.interface !== "managed-api") { - throw new Error("same-checkout reuse must use the managed API"); - } - const resolveStackUsesMalformedStateRoot = { - ...resolveStackScenario, - when: { - ...resolveStackScenario.when, - input: { ...resolveStackScenario.when.input, stateRoot: 42 }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([resolveStackUsesMalformedStateRoot])).toContain( - `${resolveStackScenario.id}: managed action must use a declared public method`, - ); - - const runningPortDriftScenario = findScenario( - "ports.config-change-on-running-stack-reports-drift", - ); - const runningPortDriftHuman = runningPortDriftScenario.expected.output.human; - if (runningPortDriftHuman === undefined) { - throw new Error("running port drift human output is required"); - } - const runningPortDriftReportsFalse = { - ...runningPortDriftScenario, - expected: { - ...runningPortDriftScenario.expected, - output: { - ...runningPortDriftScenario.expected.output, - human: { - ...runningPortDriftHuman, - fields: { ...runningPortDriftHuman.fields, drift: "false" }, - }, - json: { ...runningPortDriftScenario.expected.output.json, drift: false }, - }, - }, - } satisfies ManagedStackContractScenario; - expect(validateManagedStackContractFixtures([runningPortDriftReportsFalse])).toContain( - `${runningPortDriftScenario.id}: exact port change must bind previous assignment and requested value`, - ); }); it("covers the approved identity journeys through public commands and APIs", () => { @@ -4063,6 +349,7 @@ describe("managed stack acceptance contract", () => { "credentials.configured-values-are-authoritative", "credentials.explicit-change-applies-after-stop", "credentials.omitted-values-use-stable-defaults", + "credentials.plaintext-secrets-stay-out-of-global-state", "credentials.running-change-reports-drift", "credentials.unchanged-values-survive-restart", ].sort(), diff --git a/packages/stack/src/managed-stack-contract.ts b/packages/stack/src/managed-stack-contract.ts index c704f2bfdf..f9166cc06f 100644 --- a/packages/stack/src/managed-stack-contract.ts +++ b/packages/stack/src/managed-stack-contract.ts @@ -192,6 +192,7 @@ export type ManagedStackContractFact = readonly source: "configured" | "legacy" | "local-default" | "persisted"; readonly valuesId: string; readonly previousValuesId?: string; + readonly plaintextPresentInGlobalState?: boolean; } | { readonly kind: "identity-marker"; @@ -225,11 +226,6 @@ export type ManagedStackContractFact = readonly runtime: "bun" | "node"; }; -type ManagedStackContractPortAssignmentFact = Extract< - ManagedStackContractFact, - { readonly kind: "port-assignment" } ->; - export interface ManagedStackContractOutput { readonly human?: { readonly summary: string; @@ -285,11 +281,6 @@ type ManagedStackContractWrite = readonly id: string; }; -type ManagedStackContractTemporaryRootWrite = Extract< - ManagedStackContractWrite, - { readonly target: "temporary-root" } ->; - export interface ManagedStackContractEffects { readonly writes: ReadonlyArray; readonly runtimeEffects: ReadonlyArray<{ @@ -373,3917 +364,6 @@ const defineManagedStackContractFixtures = < fixtures: Fixtures, ): Fixtures => fixtures; -const isManagedStackContractRecord = ( - value: ManagedStackContractJson | undefined, -): value is Readonly> => - typeof value === "object" && value !== null && !Array.isArray(value); - -const managedStackContractJsonEquals = ( - left: ManagedStackContractJson, - right: ManagedStackContractJson, -): boolean => { - if (Object.is(left, right)) { - return true; - } - if (Array.isArray(left) && Array.isArray(right)) { - return ( - left.length === right.length && - left.every((value, index) => managedStackContractJsonEquals(value, right[index] ?? null)) - ); - } - if (isManagedStackContractRecord(left) && isManagedStackContractRecord(right)) { - const leftKeys = Object.keys(left).sort(); - const rightKeys = Object.keys(right).sort(); - return ( - leftKeys.length === rightKeys.length && - leftKeys.every( - (key, index) => - key === rightKeys[index] && - right[key] !== undefined && - managedStackContractJsonEquals(left[key] ?? null, right[key]), - ) - ); - } - return false; -}; - -const managedStackContractDecision = ( - scenario: ManagedStackContractScenario, -): Readonly> => - scenario.expected.selection === undefined - ? { outcome: scenario.expected.outcome } - : { - outcome: scenario.expected.outcome, - projectId: scenario.expected.selection.projectId, - checkoutId: scenario.expected.selection.checkoutId, - contextId: scenario.expected.selection.contextId, - stackId: scenario.expected.selection.stackId, - stackName: scenario.expected.selection.stackName, - }; - -const managedStackContractStringSetEquals = ( - left: ReadonlyArray, - right: ReadonlyArray, -): boolean => { - const leftSet = new Set(left); - const rightSet = new Set(right); - return ( - leftSet.size === left.length && - rightSet.size === right.length && - leftSet.size === rightSet.size && - [...leftSet].every((value) => rightSet.has(value)) - ); -}; - -const managedStackNamePattern = /^(?=.{1,63}$)[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/; - -const requiredBranchHistoryByScenarioId: Readonly< - Partial> -> = { - "identity.branch-commit-preserves-context": "commit", - "identity.branch-rebase-preserves-context": "rebase", - "identity.branch-reset-preserves-context": "reset", -}; - -const isManagedStartAction = (action: ManagedStackContractAction): boolean => - (action.interface === "cli" && action.argv[0] === "start") || - (action.interface === "managed-api" && - (action.method === "startStack" || - action.method === "startConcurrently" || - action.input.operation === "start")); - -export const validateManagedStackContractFixtures = ( - fixtures: ReadonlyArray, -): ReadonlyArray => { - const errors: Array = []; - const ids = new Set(); - const nativeServices = managedNativeServiceMatrix.services.map(([service]) => service); - const nativeServiceSet = new Set(nativeServices); - const fixturesById = new Map(fixtures.map((scenario) => [scenario.id, scenario])); - - for (const scenario of fixtures) { - if (ids.has(scenario.id)) { - errors.push(`${scenario.id}: duplicate scenario ID`); - } - ids.add(scenario.id); - - if (!scenario.id.startsWith(`${scenario.area}.`)) { - errors.push(`${scenario.id}: ID must start with ${scenario.area}.`); - } - if (scenario.title.trim().length === 0) { - errors.push(`${scenario.id}: title is required`); - } - if (scenario.given.length === 0) { - errors.push(`${scenario.id}: at least one given fact is required`); - } - - if (scenario.when.interface === "cli" || scenario.when.interface === "git") { - if (scenario.when.argv.length === 0) { - errors.push(`${scenario.id}: argv must contain a public command`); - } - if (scenario.when.cwd.trim().length === 0) { - errors.push(`${scenario.id}: cwd is required for command scenarios`); - } - } else { - if (scenario.when.method.trim().length === 0) { - errors.push(`${scenario.id}: public API method is required`); - } - } - - if (scenario.when.interface === "managed-api") { - const managedMethods = new Set([ - "createManagedStackService", - "preflightNative", - "resolvePortIntents", - "resolveStack", - "resolveStackNames", - "runPortableContract", - "runRepositoryContract", - "startConcurrently", - "startStack", - ]); - const { input, method } = scenario.when; - const allowedInputKeys: Readonly>> = { - createManagedStackService: new Set(["repository", "stateRoot"]), - preflightNative: new Set(["platform"]), - resolvePortIntents: new Set(["config", "decodedDefaults", "effectiveConfig"]), - resolveStack: new Set(["cwd", "operation", "stackName", "stateRoot"]), - resolveStackNames: new Set(["cwd", "stackNames"]), - runPortableContract: new Set(["runtimes", "scenarioId"]), - runRepositoryContract: new Set(["adapters", "scenarioId"]), - startConcurrently: new Set(["contenders", "cwd", "stackName"]), - startStack: new Set(["auth", "injectCopyFailure", "portIntents", "runtime", "stackId"]), - }; - const inputMatchesMethod = - (method === "createManagedStackService" && - typeof input.repository === "string" && - typeof input.stateRoot === "string") || - (method === "preflightNative" && typeof input.platform === "string") || - (method === "resolvePortIntents" && - (isManagedStackContractRecord(input.config) || - isManagedStackContractRecord(input.effectiveConfig))) || - (method === "resolveStack" && - typeof input.cwd === "string" && - typeof input.stackName === "string" && - (input.stateRoot === undefined || typeof input.stateRoot === "string")) || - (method === "resolveStackNames" && - typeof input.cwd === "string" && - Array.isArray(input.stackNames)) || - (method === "runPortableContract" && - Array.isArray(input.runtimes) && - typeof input.scenarioId === "string") || - (method === "runRepositoryContract" && - Array.isArray(input.adapters) && - typeof input.scenarioId === "string") || - (method === "startConcurrently" && - typeof input.cwd === "string" && - typeof input.stackName === "string" && - typeof input.contenders === "number") || - (method === "startStack" && - typeof input.stackId === "string" && - (input.auth === undefined || typeof input.auth === "string") && - (input.injectCopyFailure === undefined || typeof input.injectCopyFailure === "boolean") && - (input.portIntents === undefined || isManagedStackContractRecord(input.portIntents)) && - (input.runtime === undefined || - input.runtime === "auto" || - input.runtime === "docker" || - input.runtime === "native")); - const inputUsesOnlyDeclaredKeys = Object.keys(input).every((key) => - allowedInputKeys[method]?.has(key), - ); - if (!managedMethods.has(method) || !inputMatchesMethod || !inputUsesOnlyDeclaredKeys) { - errors.push(`${scenario.id}: managed action must use a declared public method`); - } - } else if ( - scenario.when.interface === "stack-api" && - scenario.when.method !== "createStack" && - scenario.when.method !== "dispose" - ) { - errors.push(`${scenario.id}: direct stack action must use createStack or dispose`); - } - - if (scenario.when.interface === "cli") { - const [command, subcommand] = scenario.when.argv; - const valueFlagsByCommand: Readonly>> = { - start: new Set(["--runtime", "--stack"]), - status: new Set(["--output"]), - stop: new Set(["--stack", "--stack-id"]), - }; - const switchFlagsByCommand: Readonly>> = { - start: new Set(["--experimental"]), - status: new Set(["--experimental"]), - stop: new Set(["--all", "--experimental", "--no-backup"]), - }; - let validShape = command === "start" || command === "status" || command === "stop"; - if (command === "stack") { - validShape = subcommand === "prune"; - } - let index = command === "stack" ? 2 : 1; - while (validShape && index < scenario.when.argv.length) { - const argument = scenario.when.argv[index]; - if (argument === undefined) { - validShape = false; - break; - } - if (command === "stack" && argument === "--experimental") { - index += 1; - continue; - } - if (command !== undefined && switchFlagsByCommand[command]?.has(argument)) { - index += 1; - continue; - } - if (command !== undefined && valueFlagsByCommand[command]?.has(argument)) { - const value = scenario.when.argv[index + 1]; - validShape = value !== undefined; - if ( - (argument === "--output" && value !== "json") || - (argument === "--runtime" && value !== "docker" && value !== "native") - ) { - validShape = false; - } - index += 2; - continue; - } - validShape = false; - } - if (!scenario.when.argv.includes("--experimental") || !validShape) { - errors.push(`${scenario.id}: CLI action must use a declared experimental command shape`); - } - } - - if (scenario.when.interface === "git") { - const [command, option, branchName] = scenario.when.argv; - const validShape = - typeof branchName === "string" && - ((command === "switch" && option === "-c") || - (command === "branch" && (option === "-D" || option === "-d"))); - if (!validShape) { - errors.push(`${scenario.id}: Git action must use a declared branch command shape`); - } else if ( - command === "switch" && - scenario.expected.output.human?.summary !== `Switched to a new branch '${branchName}'` - ) { - errors.push(`${scenario.id}: Git switch output must match its requested branch`); - } - if ( - scenario.expected.writes.length === 0 && - scenario.expected.runtimeEffects.length === 0 && - (scenario.expected.outcome !== "no-op" || - scenario.expected.details?.managed_command_ran !== false) - ) { - errors.push(`${scenario.id}: standalone Git action must remain outside managed lifecycle`); - } - } - - const isStatusOperation = - (scenario.when.interface === "cli" && scenario.when.argv[0] === "status") || - ((scenario.when.interface === "managed-api" || scenario.when.interface === "stack-api") && - scenario.when.input.operation === "status"); - if ( - isStatusOperation && - scenario.expected.outcome !== "error" && - scenario.expected.outcome !== "report" - ) { - errors.push(`${scenario.id}: successful status operations must report`); - } - if ( - isStatusOperation && - (scenario.expected.writes.length > 0 || scenario.expected.runtimeEffects.length > 0) - ) { - errors.push(`${scenario.id}: status operations must not mutate state`); - } - - const { output } = scenario.expected; - if (output.human === undefined && output.json === undefined && output.api === undefined) { - errors.push(`${scenario.id}: at least one observable output is required`); - } - if (scenario.expected.error?.code === "MUTUALLY_EXCLUSIVE_STACK_SELECTORS") { - const stopArgv = - scenario.when.interface === "cli" && scenario.when.argv[0] === "stop" - ? scenario.when.argv - : []; - const selectorModes = ["--stack", "--stack-id", "--all"].filter((selector) => - stopArgv.includes(selector), - ); - const jsonSelectors = output.json?.selectors; - const projectedJsonSelectors = - Array.isArray(jsonSelectors) && - jsonSelectors.every((selector): selector is string => typeof selector === "string") - ? jsonSelectors - : undefined; - const projectedHumanSelectors = output.human?.fields.selectors - ?.split(",") - .map((selector) => selector.trim()) - .filter((selector) => selector.length > 0); - if ( - selectorModes.length < 2 || - projectedJsonSelectors === undefined || - !managedStackContractStringSetEquals(selectorModes, projectedJsonSelectors) || - projectedHumanSelectors === undefined || - !managedStackContractStringSetEquals(selectorModes, projectedHumanSelectors) - ) { - errors.push( - `${scenario.id}: selector conflict must bind at least two requested selection modes`, - ); - } - } - - if (scenario.when.interface === "stack-api" && scenario.when.method === "createStack") { - const directOptions = scenario.given.filter((fact) => fact.kind === "direct-stack-options"); - const directInput = scenario.when.input; - const expectedStackRoot = typeof directInput.stackRoot === "string" ? "explicit" : "omitted"; - const expectedRuntimeRoot = - typeof directInput.runtimeRoot === "string" ? "explicit" : "omitted"; - const omittedRoots: Array<"stack" | "runtime"> = []; - if (expectedStackRoot === "omitted") { - omittedRoots.push("stack"); - } - if (expectedRuntimeRoot === "omitted") { - omittedRoots.push("runtime"); - } - const temporaryRootWrites = scenario.expected.writes.filter( - (write): write is ManagedStackContractTemporaryRootWrite => - write.target === "temporary-root" && write.operation === "create", - ); - const detailRoots = scenario.expected.details?.temporary_roots; - const projectedRoots = output.api?.temporaryRoots; - const validDetailRoots = - Array.isArray(detailRoots) && - detailRoots.every( - (root): root is "stack" | "runtime" => root === "stack" || root === "runtime", - ) - ? detailRoots - : undefined; - const validProjectedRoots = - Array.isArray(projectedRoots) && - projectedRoots.every( - (root): root is "stack" | "runtime" => root === "stack" || root === "runtime", - ) - ? projectedRoots - : undefined; - if ( - directOptions.length !== 1 || - directOptions[0]?.stackRoot !== expectedStackRoot || - directOptions[0]?.runtimeRoot !== expectedRuntimeRoot || - temporaryRootWrites.length !== omittedRoots.length || - !managedStackContractStringSetEquals( - temporaryRootWrites.map((write) => write.root), - omittedRoots, - ) || - validDetailRoots === undefined || - validDetailRoots.length !== omittedRoots.length || - !managedStackContractStringSetEquals(validDetailRoots, omittedRoots) || - validProjectedRoots === undefined || - validProjectedRoots.length !== omittedRoots.length || - !managedStackContractStringSetEquals(validProjectedRoots, omittedRoots) - ) { - errors.push( - `${scenario.id}: direct stack root inputs must agree with temporary-state behavior`, - ); - } - if ( - scenario.expected.details?.git_inspected !== false || - scenario.expected.details.identity_marker_created !== false || - scenario.expected.details.global_registry_mutated !== false || - scenario.expected.writes.some((write) => write.target !== "temporary-root") || - scenario.expected.runtimeEffects.length > 0 - ) { - errors.push(`${scenario.id}: direct createStack must remain isolated from managed state`); - } - } - if (scenario.when.interface === "stack-api" && scenario.when.method === "dispose") { - const handle = scenario.when.input.handle; - const directState = scenario.given.find( - (fact) => fact.kind === "direct-stack-state" && fact.handle === handle, - ); - const deletedTemporaryRoots = scenario.expected.writes.filter( - (write): write is ManagedStackContractTemporaryRootWrite => - write.target === "temporary-root" && write.operation === "delete", - ); - const removedDetailRoots = scenario.expected.details?.removed_temporary_roots; - const removedProjectedRoots = output.api?.removedTemporaryRoots; - const validRemovedDetailRoots = - Array.isArray(removedDetailRoots) && - removedDetailRoots.every( - (root): root is "stack" | "runtime" => root === "stack" || root === "runtime", - ) - ? removedDetailRoots - : undefined; - const validRemovedProjectedRoots = - Array.isArray(removedProjectedRoots) && - removedProjectedRoots.every( - (root): root is "stack" | "runtime" => root === "stack" || root === "runtime", - ) - ? removedProjectedRoots - : undefined; - const declaredTemporaryRoots = - directState?.kind === "direct-stack-state" ? directState.temporaryRoots : []; - if ( - typeof handle !== "string" || - directState?.kind !== "direct-stack-state" || - directState.lifecycle !== "created" || - new Set(declaredTemporaryRoots.map(({ root }) => root)).size !== - declaredTemporaryRoots.length || - scenario.expected.outcome !== "delete" || - deletedTemporaryRoots.length !== declaredTemporaryRoots.length || - declaredTemporaryRoots.some( - (root) => - !deletedTemporaryRoots.some( - (write) => write.root === root.root && write.id === root.stateId, - ), - ) || - deletedTemporaryRoots.some( - (write) => - !declaredTemporaryRoots.some( - (root) => root.root === write.root && root.stateId === write.id, - ), - ) || - scenario.expected.writes.some((write) => write.target !== "temporary-root") || - scenario.expected.runtimeEffects.length > 0 || - scenario.expected.details?.temporary_roots_removed !== true || - validRemovedDetailRoots === undefined || - validRemovedDetailRoots.length !== declaredTemporaryRoots.length || - !managedStackContractStringSetEquals( - validRemovedDetailRoots, - declaredTemporaryRoots.map(({ root }) => root), - ) || - output.api?.handle !== handle || - output.api.disposed !== true || - output.api.temporaryRootsRemoved !== true || - validRemovedProjectedRoots === undefined || - validRemovedProjectedRoots.length !== declaredTemporaryRoots.length || - !managedStackContractStringSetEquals( - validRemovedProjectedRoots, - declaredTemporaryRoots.map(({ root }) => root), - ) - ) { - errors.push(`${scenario.id}: direct stack disposal must remove omitted temporary roots`); - } - } - for (const write of scenario.expected.writes) { - if (write.id.trim().length === 0) { - errors.push(`${scenario.id}: ${write.target} write requires a target ID`); - } - } - for (const effect of scenario.expected.runtimeEffects) { - if (effect.stackId.trim().length === 0) { - errors.push(`${scenario.id}: ${effect.operation} runtime effect requires a stack ID`); - } - } - - const cliStackIdIndex = - scenario.when.interface === "cli" ? scenario.when.argv.indexOf("--stack-id") : -1; - const explicitActionStackId = - scenario.when.interface === "cli" && cliStackIdIndex >= 0 - ? scenario.when.argv[cliStackIdIndex + 1] - : (scenario.when.interface === "managed-api" || scenario.when.interface === "stack-api") && - typeof scenario.when.input.stackId === "string" - ? scenario.when.input.stackId - : undefined; - if (explicitActionStackId !== undefined) { - const expectedStackIds = new Set(); - if (scenario.expected.selection !== undefined) { - expectedStackIds.add(scenario.expected.selection.stackId); - } - for (const write of scenario.expected.writes) { - if ( - write.target === "managed-state" || - write.target === "registry" || - write.target === "runtime-state" - ) { - expectedStackIds.add(write.id); - } - } - for (const effect of scenario.expected.runtimeEffects) { - expectedStackIds.add(effect.stackId); - } - for (const projectedStackId of [ - output.api?.stackId, - output.json?.stack_id, - output.human?.fields.stackId, - ]) { - if (typeof projectedStackId === "string") { - expectedStackIds.add(projectedStackId); - } - } - for (const expectedStackId of expectedStackIds) { - if (expectedStackId !== explicitActionStackId) { - errors.push( - `${scenario.id}: explicit action target ${explicitActionStackId} disagrees with expected stack ${expectedStackId}`, - ); - } - } - } - - const cliStackNameIndex = - scenario.when.interface === "cli" ? scenario.when.argv.indexOf("--stack") : -1; - const explicitActionStackName = - scenario.when.interface === "cli" && cliStackNameIndex >= 0 - ? scenario.when.argv[cliStackNameIndex + 1] - : (scenario.when.interface === "managed-api" || scenario.when.interface === "stack-api") && - typeof scenario.when.input.stackName === "string" - ? scenario.when.input.stackName - : undefined; - if ( - explicitActionStackName !== undefined && - scenario.expected.selection !== undefined && - explicitActionStackName !== scenario.expected.selection.stackName - ) { - errors.push( - `${scenario.id}: explicit stack name ${explicitActionStackName} disagrees with selected stack ${scenario.expected.selection.stackName}`, - ); - } - if ( - scenario.when.interface === "cli" && - scenario.when.argv[0] === "start" && - cliStackNameIndex < 0 && - scenario.expected.selection !== undefined && - scenario.expected.selection.stackName !== "default" - ) { - errors.push(`${scenario.id}: unnamed CLI start must select the default stack`); - } - if (scenario.expected.error?.code === "INVALID_STACK_NAME") { - const declaredNames = scenario.given.find((fact) => fact.kind === "stack-names"); - if ( - explicitActionStackName === undefined || - managedStackNamePattern.test(explicitActionStackName) || - declaredNames?.kind !== "stack-names" || - declaredNames.names.length !== 1 || - declaredNames.names[0] !== explicitActionStackName || - output.human?.fields.stack !== explicitActionStackName || - output.json?.stack_name !== explicitActionStackName - ) { - errors.push( - `${scenario.id}: invalid stack name error must bind a requested name outside the supported grammar`, - ); - } - } - - const cliRuntimeIndex = - scenario.when.interface === "cli" ? scenario.when.argv.indexOf("--runtime") : -1; - const explicitRuntime = - scenario.when.interface === "cli" && cliRuntimeIndex >= 0 - ? scenario.when.argv[cliRuntimeIndex + 1] - : scenario.when.interface === "managed-api" && - typeof scenario.when.input.runtime === "string" - ? scenario.when.input.runtime - : undefined; - if (explicitRuntime !== undefined) { - const actionRequestSource = scenario.when.interface === "cli" ? "cli" : "managed-api"; - const runtimeRequest = scenario.given.find( - (fact) => - fact.kind === "runtime-request" && - fact.runtime === explicitRuntime && - (explicitRuntime === "auto" || fact.source === actionRequestSource), - ); - if (runtimeRequest?.kind !== "runtime-request") { - errors.push( - `${scenario.id}: explicit runtime ${explicitRuntime} must match its ${actionRequestSource} request fact`, - ); - } - if (explicitRuntime !== "auto") { - for (const projectedRuntime of [ - scenario.expected.details?.resolved_runtime, - output.api?.runtime, - output.json?.runtime, - output.human?.fields.runtime, - ]) { - if (projectedRuntime !== undefined && projectedRuntime !== explicitRuntime) { - errors.push( - `${scenario.id}: resolved runtime must match explicit request ${explicitRuntime}`, - ); - } - } - if (isManagedStartAction(scenario.when) && scenario.expected.outcome !== "error") { - const availability = scenario.given.find( - (fact) => fact.kind === "runtime-availability" && fact.runtime === explicitRuntime, - ); - if (availability?.kind !== "runtime-availability" || availability.available !== true) { - errors.push( - `${scenario.id}: successful explicit runtime requires matching availability`, - ); - } - } - } - } - - const runtimeRequests = scenario.given.filter((fact) => fact.kind === "runtime-request"); - const effectiveRuntimeRequest = - runtimeRequests.find((fact) => fact.source === "cli" || fact.source === "managed-api") ?? - runtimeRequests.find((fact) => fact.source === "config") ?? - runtimeRequests.find((fact) => fact.source === "default"); - if ( - isManagedStartAction(scenario.when) && - scenario.expected.outcome !== "error" && - effectiveRuntimeRequest?.kind === "runtime-request" && - effectiveRuntimeRequest.runtime !== "auto" - ) { - const requestedRuntime = effectiveRuntimeRequest.runtime; - const availability = scenario.given.find( - (fact) => fact.kind === "runtime-availability" && fact.runtime === requestedRuntime, - ); - const projectedRuntimes = [ - scenario.expected.details?.resolved_runtime, - output.api?.runtime, - output.json?.runtime, - output.human?.fields.runtime, - ].filter((runtime) => runtime !== undefined); - const projectedSources = [ - scenario.expected.details?.source, - output.api?.runtimeSource, - output.json?.runtime_source, - ].filter((source) => source !== undefined); - if ( - availability?.kind !== "runtime-availability" || - availability.available !== true || - projectedRuntimes.length === 0 || - projectedRuntimes.some((runtime) => runtime !== requestedRuntime) || - projectedSources.some((source) => source !== effectiveRuntimeRequest.source) - ) { - errors.push( - `${scenario.id}: effective runtime request must match availability and successful projections`, - ); - } - } - if ( - isManagedStartAction(scenario.when) && - effectiveRuntimeRequest?.kind === "runtime-request" && - effectiveRuntimeRequest.runtime === "auto" - ) { - const runtimeTargetStackId = scenario.expected.selection?.stackId ?? explicitActionStackId; - const persistedRuntime = scenario.given.find( - (fact) => - fact.kind === "persisted-runtime" && - runtimeTargetStackId !== undefined && - fact.stackId === runtimeTargetStackId, - ); - const dockerAvailability = scenario.given.find( - (fact) => fact.kind === "runtime-availability" && fact.runtime === "docker", - ); - const nativeAvailability = scenario.given.find( - (fact) => fact.kind === "runtime-availability" && fact.runtime === "native", - ); - const nativeQualification = scenario.given.find( - (fact) => fact.kind === "native-qualification", - ); - const nativeQualified = - nativeQualification?.kind === "native-qualification" && - nativeQualification.failedServices.length === 0 && - nativeQualification.qualifiedServices.length === nativeServices.length; - const persistedAvailability = - persistedRuntime?.kind === "persisted-runtime" - ? scenario.given.find( - (fact) => - fact.kind === "runtime-availability" && fact.runtime === persistedRuntime.runtime, - ) - : undefined; - if ( - persistedRuntime?.kind === "persisted-runtime" && - persistedAvailability?.kind !== "runtime-availability" - ) { - errors.push( - `${scenario.id}: persisted automatic runtime requires matching availability evidence`, - ); - } - if ( - persistedRuntime === undefined && - (dockerAvailability?.kind !== "runtime-availability" || - nativeAvailability?.kind !== "runtime-availability") - ) { - errors.push( - `${scenario.id}: fresh automatic runtime selection requires Docker and native availability evidence`, - ); - } - const resolvedRuntime = - persistedRuntime?.kind === "persisted-runtime" && - persistedAvailability?.kind === "runtime-availability" - ? persistedAvailability.available - ? persistedRuntime.runtime - : undefined - : persistedRuntime?.kind === "persisted-runtime" - ? undefined - : dockerAvailability?.kind === "runtime-availability" && dockerAvailability.available - ? "docker" - : nativeAvailability?.kind === "runtime-availability" && - nativeAvailability.available && - nativeQualified - ? "native" - : undefined; - if ( - persistedRuntime === undefined && - resolvedRuntime !== undefined && - scenario.expected.outcome !== "error" && - scenario.expected.details?.persisted !== true - ) { - errors.push(`${scenario.id}: fresh automatic runtime selection must be persisted`); - } - if ( - resolvedRuntime === "native" && - nativeQualification?.kind === "native-qualification" && - (scenario.expected.details?.qualified_service_count !== - nativeQualification.qualifiedServices.length || - scenario.expected.details.mixed_runtime !== false || - output.api?.qualifiedServiceCount !== nativeQualification.qualifiedServices.length) - ) { - errors.push( - `${scenario.id}: automatic native selection must bind the full qualified graph`, - ); - } - if ( - persistedRuntime?.kind === "persisted-runtime" && - persistedAvailability?.kind === "runtime-availability" && - persistedAvailability.available && - (scenario.expected.details?.runtime !== persistedRuntime.runtime || - scenario.expected.details.auto_re_evaluated !== false || - output.json?.persisted !== true) - ) { - errors.push( - `${scenario.id}: automatic persisted runtime reuse must report persisted provenance`, - ); - } - if ( - persistedRuntime === undefined && - resolvedRuntime === "native" && - (dockerAvailability?.kind !== "runtime-availability" || - dockerAvailability.available || - typeof dockerAvailability.reason !== "string") - ) { - errors.push( - `${scenario.id}: automatic native fallback requires explicit Docker unavailability`, - ); - } - if ( - persistedRuntime?.kind === "persisted-runtime" && - persistedAvailability?.kind === "runtime-availability" && - !persistedAvailability.available - ) { - if ( - scenario.expected.outcome !== "error" || - scenario.expected.error?.code !== "PERSISTED_RUNTIME_UNAVAILABLE" || - typeof persistedAvailability.reason !== "string" || - output.json?.runtime !== persistedRuntime.runtime || - output.json.reason !== persistedAvailability.reason || - scenario.expected.runtimeEffects.some((effect) => effect.operation === "start") - ) { - errors.push(`${scenario.id}: unavailable persisted runtime must fail without switching`); - } - } else if (resolvedRuntime === undefined) { - const unavailableReasonsMatch = - dockerAvailability?.kind === "runtime-availability" && - dockerAvailability.available === false && - typeof dockerAvailability.reason === "string" && - nativeAvailability?.kind === "runtime-availability" && - nativeAvailability.available === false && - typeof nativeAvailability.reason === "string" && - output.human?.fields.docker === dockerAvailability.reason && - output.human.fields.native === nativeAvailability.reason && - output.json?.docker_reason === dockerAvailability.reason && - output.json.native_reason === nativeAvailability.reason; - if ( - scenario.expected.outcome !== "error" || - scenario.expected.error?.code !== "NO_RUNTIME_AVAILABLE" || - scenario.expected.runtimeEffects.some((effect) => effect.operation === "start") || - !unavailableReasonsMatch - ) { - errors.push( - `${scenario.id}: automatic runtime failure must bind both unavailability reasons`, - ); - } - } else { - const projectedRuntimes = [ - scenario.expected.details?.resolved_runtime, - output.api?.runtime, - output.json?.runtime, - output.human?.fields.runtime, - ].filter((runtime) => runtime !== undefined); - if ( - scenario.expected.outcome === "error" || - projectedRuntimes.length === 0 || - projectedRuntimes.some((runtime) => runtime !== resolvedRuntime) - ) { - errors.push( - `${scenario.id}: automatic runtime must resolve from persisted state or declared availability`, - ); - } - } - } - - if (scenario.when.interface === "managed-api" && typeof scenario.when.input.auth === "string") { - const authReference = scenario.when.input.auth; - const configuredCredentials = scenario.given.find( - (fact) => fact.kind === "credential-state" && fact.source === "configured", - ); - if ( - configuredCredentials?.kind !== "credential-state" || - configuredCredentials.valuesId !== authReference || - scenario.expected.details?.credential_values_id !== authReference || - scenario.expected.details.global_credentials_reference !== authReference || - output.api?.credentialsValuesId !== authReference - ) { - errors.push( - `${scenario.id}: configured credential input ${authReference} must match persisted references`, - ); - } - } - - if (scenario.when.interface === "managed-api" && scenario.when.method === "resolveStack") { - const stateRoot = scenario.when.input.stateRoot; - const managedOptions = scenario.given.filter((fact) => fact.kind === "managed-api-options"); - const isolatedOptions = managedOptions.find((fact) => fact.stateRoot === "isolated"); - if (typeof stateRoot === "string" || isolatedOptions !== undefined) { - if ( - typeof stateRoot !== "string" || - managedOptions.length !== 1 || - isolatedOptions?.kind !== "managed-api-options" || - isolatedOptions.stateRootPath !== stateRoot || - scenario.expected.details?.state_root !== stateRoot || - scenario.expected.details.default_system_state_mutated !== false - ) { - errors.push( - `${scenario.id}: isolated state root input must match its options and observed boundary`, - ); - } - } - } - - if ( - scenario.when.interface === "managed-api" && - scenario.when.method === "createManagedStackService" - ) { - const repositoryId = scenario.when.input.repository; - const stateRootPath = scenario.when.input.stateRoot; - const injectedOptions = scenario.given.find( - (fact) => fact.kind === "managed-api-options" && fact.repository === "injected", - ); - if ( - typeof repositoryId !== "string" || - typeof stateRootPath !== "string" || - injectedOptions?.kind !== "managed-api-options" || - injectedOptions.repositoryId !== repositoryId || - injectedOptions.stateRoot !== "isolated" || - injectedOptions.stateRootPath !== stateRootPath || - !scenario.expected.writes.some( - (write) => - write.target === "ephemeral-state" && - write.operation === "create" && - write.id === repositoryId, - ) || - output.api?.repository !== repositoryId || - scenario.expected.details?.cli_required !== false || - output.api.cliRequired !== false - ) { - errors.push( - `${scenario.id}: injected repository and state root must match the observed managed service`, - ); - } - } - - if (scenario.when.interface === "managed-api" && scenario.when.method === "resolveStackNames") { - const requestedNames = scenario.when.input.stackNames; - const declaredNames = scenario.given.find((fact) => fact.kind === "stack-names"); - const activeContext = scenario.given.find( - (fact) => fact.kind === "branch" && fact.checkedOut, - ); - const detailKeys = Object.keys(scenario.expected.details ?? {}); - const apiKeys = Object.keys(output.api ?? {}); - if ( - !Array.isArray(requestedNames) || - !requestedNames.every((name) => typeof name === "string") || - !requestedNames.every( - (name) => typeof name === "string" && managedStackNamePattern.test(name), - ) || - declaredNames?.kind !== "stack-names" || - !managedStackContractStringSetEquals(requestedNames, declaredNames.names) || - !managedStackContractStringSetEquals(requestedNames, detailKeys) || - !managedStackContractStringSetEquals(requestedNames, apiKeys) - ) { - errors.push( - `${scenario.id}: requested stack names must match their fact and projected results`, - ); - } - if (Array.isArray(requestedNames)) { - for (const name of requestedNames) { - if (typeof name !== "string") { - continue; - } - const detailStackId = scenario.expected.details?.[name]; - const apiResult = output.api?.[name]; - if ( - typeof detailStackId !== "string" || - !isManagedStackContractRecord(apiResult) || - activeContext?.kind !== "branch" || - apiResult.contextId !== activeContext.contextId || - apiResult.stackId !== detailStackId - ) { - errors.push( - `${scenario.id}: resolved stack name ${name} must bind its context and stack ID`, - ); - } - } - } - } - - if ( - scenario.when.interface === "managed-api" && - scenario.when.method === "resolvePortIntents" - ) { - const configFacts = scenario.given.filter((fact) => fact.kind === "config-port"); - const projectedKeys = Object.keys(output.api ?? {}); - if ( - !managedStackContractStringSetEquals( - configFacts.map(({ key }) => key), - projectedKeys, - ) - ) { - errors.push(`${scenario.id}: resolved port keys must match their config facts`); - } - if (isManagedStackContractRecord(scenario.when.input.config)) { - const decodedDefaults = scenario.when.input.decodedDefaults; - if ( - !isManagedStackContractRecord(decodedDefaults) || - !managedStackContractStringSetEquals( - configFacts.map(({ key }) => key), - Object.keys(decodedDefaults), - ) - ) { - errors.push(`${scenario.id}: decoded default keys must cover resolved port facts`); - } - } - for (const fact of configFacts) { - const projection = output.api?.[fact.key]; - const effectiveConfig = isManagedStackContractRecord(scenario.when.input.effectiveConfig) - ? scenario.when.input.effectiveConfig - : undefined; - const localConfig = isManagedStackContractRecord(scenario.when.input.config) - ? scenario.when.input.config - : undefined; - const actionValue = effectiveConfig?.[fact.key] ?? localConfig?.[fact.key]; - if ( - !isManagedStackContractRecord(projection) || - projection.intent !== fact.intent || - projection.source !== fact.source || - (fact.intent === "exact" && - (actionValue !== fact.value || projection.port !== fact.value)) || - (fact.intent === "automatic" && localConfig?.[fact.key] !== undefined) - ) { - errors.push(`${scenario.id}: resolved port ${fact.key} must match its input and fact`); - } - } - } - - if ( - scenario.when.interface === "cli" && - (scenario.when.argv[0] === "start" || - scenario.when.argv[0] === "status" || - scenario.when.argv[0] === "stop") && - !scenario.when.argv.includes("--stack-id") && - typeof output.json?.stack_id === "string" && - scenario.expected.selection === undefined - ) { - errors.push(`${scenario.id}: contextual CLI stack result requires a selected target`); - } - - if ( - scenario.when.interface === "managed-api" && - scenario.when.method === "runPortableContract" - ) { - const referencedId = scenario.when.input.scenarioId; - const referencedScenario = - typeof referencedId === "string" ? fixturesById.get(referencedId) : undefined; - if (referencedScenario === undefined) { - errors.push(`${scenario.id}: portable contract must reference a declared scenario`); - } else { - const referencedDecision = managedStackContractDecision(referencedScenario); - const runtimes = scenario.when.input.runtimes; - if ( - !Array.isArray(runtimes) || - runtimes.length === 0 || - !runtimes.every((runtime) => typeof runtime === "string" && runtime.length > 0) - ) { - errors.push(`${scenario.id}: portable contract must declare its runtimes`); - } else { - if (new Set(runtimes).size !== runtimes.length) { - errors.push(`${scenario.id}: portable contract runtimes must be unique`); - } - const runtimeFacts = scenario.given.flatMap((fact) => - fact.kind === "managed-api-options" ? [fact.runtime] : [], - ); - const declaredRuntimeSet = new Set(runtimes); - const runtimeFactSet = new Set(runtimeFacts); - if ( - declaredRuntimeSet.size !== runtimeFactSet.size || - [...declaredRuntimeSet].some((runtime) => !runtimeFactSet.has(runtime)) - ) { - errors.push(`${scenario.id}: portable runtimes must match declared runtime facts`); - } - const runtimeOptions = scenario.given.filter( - (fact) => fact.kind === "managed-api-options", - ); - const firstRuntimeOptions = runtimeOptions[0]; - if ( - firstRuntimeOptions === undefined || - runtimeOptions.length !== runtimes.length || - runtimes.some( - (runtime) => - runtimeOptions.filter((options) => options.runtime === runtime).length !== 1, - ) || - runtimeOptions.some( - (options) => - options.repository !== firstRuntimeOptions.repository || - options.repositoryId !== firstRuntimeOptions.repositoryId || - options.stateRoot !== firstRuntimeOptions.stateRoot || - options.stateRootPath !== firstRuntimeOptions.stateRootPath, - ) - ) { - errors.push( - `${scenario.id}: portable comparison must hold repository and state root constant`, - ); - } - - let firstRuntimeResult: Readonly> | undefined; - let runtimeResultsEqual = true; - for (const runtime of runtimes) { - const runtimeResult = output.api?.[runtime]; - if ( - !isManagedStackContractRecord(runtimeResult) || - runtimeResult.outcome !== referencedScenario.expected.outcome - ) { - runtimeResultsEqual = false; - errors.push( - `${scenario.id}: portable ${runtime} outcome must match ${referencedScenario.id}`, - ); - continue; - } - if ( - referencedScenario.expected.selection !== undefined && - runtimeResult.stackId !== referencedScenario.expected.selection.stackId - ) { - errors.push( - `${scenario.id}: portable ${runtime} stackId must match ${referencedScenario.id}`, - ); - } - if (!managedStackContractJsonEquals(runtimeResult, referencedDecision)) { - errors.push( - `${scenario.id}: portable ${runtime} decision must completely match ${referencedScenario.id}`, - ); - } - if (firstRuntimeResult === undefined) { - firstRuntimeResult = runtimeResult; - } else if (!managedStackContractJsonEquals(firstRuntimeResult, runtimeResult)) { - runtimeResultsEqual = false; - errors.push(`${scenario.id}: portable runtime decisions must be identical`); - } - } - if ( - scenario.expected.details?.results_equal !== runtimeResultsEqual || - output.api?.equal !== runtimeResultsEqual - ) { - errors.push(`${scenario.id}: portable equality flags must match compared results`); - } - } - } - } - - if ( - scenario.when.interface === "managed-api" && - scenario.when.method === "runRepositoryContract" - ) { - const referencedId = scenario.when.input.scenarioId; - const referencedScenario = - typeof referencedId === "string" ? fixturesById.get(referencedId) : undefined; - if (referencedScenario === undefined) { - errors.push(`${scenario.id}: repository contract must reference a declared scenario`); - } else { - const referencedDecision = managedStackContractDecision(referencedScenario); - const adapters = scenario.when.input.adapters; - if ( - !Array.isArray(adapters) || - adapters.length === 0 || - !adapters.every((adapter) => typeof adapter === "string" && adapter.length > 0) - ) { - errors.push(`${scenario.id}: repository contract must declare its adapters`); - } else { - if (new Set(adapters).size !== adapters.length) { - errors.push(`${scenario.id}: repository contract adapters must be unique`); - } - const repositoryFacts = scenario.given.flatMap((fact) => - fact.kind === "managed-api-options" ? [fact.repository] : [], - ); - const declaredRepositorySet = new Set(adapters); - const repositoryFactSet = new Set(repositoryFacts); - if ( - declaredRepositorySet.size !== repositoryFactSet.size || - [...declaredRepositorySet].some((adapter) => !repositoryFactSet.has(adapter)) - ) { - errors.push(`${scenario.id}: repository adapters must match declared repository facts`); - } - const repositoryOptions = scenario.given.filter( - (fact) => fact.kind === "managed-api-options", - ); - const firstRepositoryOptions = repositoryOptions[0]; - if ( - firstRepositoryOptions === undefined || - repositoryOptions.length !== adapters.length || - adapters.some( - (adapter) => - repositoryOptions.filter((options) => options.repository === adapter).length !== 1, - ) || - repositoryOptions.some( - (options) => - options.runtime !== firstRepositoryOptions.runtime || - options.stateRoot !== firstRepositoryOptions.stateRoot || - options.stateRootPath !== firstRepositoryOptions.stateRootPath, - ) - ) { - errors.push( - `${scenario.id}: repository comparison must hold runtime and state root constant`, - ); - } - - let firstAdapterResult: Readonly> | undefined; - let adapterResultsEqual = true; - for (const adapter of adapters) { - const adapterResult = output.api?.[adapter]; - if ( - !isManagedStackContractRecord(adapterResult) || - adapterResult.outcome !== referencedScenario.expected.outcome - ) { - adapterResultsEqual = false; - errors.push( - `${scenario.id}: repository ${adapter} outcome must match ${referencedScenario.id}`, - ); - continue; - } - if ( - referencedScenario.expected.selection !== undefined && - adapterResult.stackId !== referencedScenario.expected.selection.stackId - ) { - errors.push( - `${scenario.id}: repository ${adapter} stackId must match ${referencedScenario.id}`, - ); - } - if (!managedStackContractJsonEquals(adapterResult, referencedDecision)) { - errors.push( - `${scenario.id}: repository ${adapter} decision must completely match ${referencedScenario.id}`, - ); - } - if (firstAdapterResult === undefined) { - firstAdapterResult = adapterResult; - } else if (!managedStackContractJsonEquals(firstAdapterResult, adapterResult)) { - adapterResultsEqual = false; - errors.push(`${scenario.id}: repository adapter decisions must be identical`); - } - } - if ( - scenario.expected.details?.decisions_equal !== adapterResultsEqual || - output.api?.equal !== adapterResultsEqual - ) { - errors.push(`${scenario.id}: repository equality flags must match compared decisions`); - } - } - } - } - - if (scenario.expected.outcome === "error") { - if (scenario.expected.error === undefined) { - errors.push(`${scenario.id}: error outcome requires structured error metadata`); - } else if (scenario.expected.error.recovery.length === 0) { - errors.push(`${scenario.id}: error outcome requires recovery guidance`); - } - } else if (scenario.expected.error !== undefined) { - errors.push(`${scenario.id}: non-error outcome cannot include error metadata`); - } - - for (const diagnostic of [scenario.expected.error, scenario.expected.warning]) { - if (diagnostic !== undefined && !/^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*$/.test(diagnostic.code)) { - errors.push( - `${scenario.id}: diagnostic code ${diagnostic.code} must use SCREAMING_SNAKE_CASE`, - ); - } - } - - const hasMutation = - scenario.expected.writes.length > 0 || scenario.expected.runtimeEffects.length > 0; - if (scenario.expected.outcome === "report" && hasMutation) { - errors.push(`${scenario.id}: report outcome must not mutate state`); - } - if (scenario.expected.outcome === "error" && hasMutation) { - const isBootstrapRollback = scenario.expected.error?.code === "LEGACY_BOOTSTRAP_FAILED"; - const containsOnlyRollbackCleanup = - scenario.expected.writes.every( - (write) => write.target === "managed-state" && write.operation === "delete", - ) && scenario.expected.runtimeEffects.every((effect) => effect.operation === "delete"); - if (!isBootstrapRollback || !containsOnlyRollbackCleanup) { - errors.push(`${scenario.id}: error outcome must not mutate state outside rollback cleanup`); - } - } - - if (scenario.expected.warning !== undefined) { - if (scenario.expected.outcome === "error") { - errors.push(`${scenario.id}: error outcome cannot also include warning metadata`); - } - if (scenario.expected.warning.recovery.length === 0) { - errors.push(`${scenario.id}: warning metadata requires recovery guidance`); - } - } - - const declaredIds = new Set(); - const projectIds = new Set(); - const checkoutIds = new Set(); - const contextIds = new Set(); - const existingCheckoutIdentityIds = new Set(); - for (const fact of scenario.given) { - switch (fact.kind) { - case "branch": - declaredIds.add(fact.contextId); - contextIds.add(fact.contextId); - break; - case "checkout": - declaredIds.add(fact.projectId); - declaredIds.add(fact.checkoutId); - projectIds.add(fact.projectId); - checkoutIds.add(fact.checkoutId); - existingCheckoutIdentityIds.add(fact.projectId); - existingCheckoutIdentityIds.add(fact.checkoutId); - break; - case "credential-state": - declaredIds.add(fact.valuesId); - if (fact.previousValuesId !== undefined) { - declaredIds.add(fact.previousValuesId); - } - break; - case "direct-stack-state": - for (const root of fact.temporaryRoots) { - declaredIds.add(root.stateId); - } - break; - case "identity-claim": - if (fact.status !== "absent") { - declaredIds.add(fact.id); - } - switch (fact.scope) { - case "checkout": - checkoutIds.add(fact.id); - break; - case "context": - contextIds.add(fact.id); - break; - case "project": - projectIds.add(fact.id); - break; - } - break; - case "identity-marker": - declaredIds.add(fact.markerId); - declaredIds.add(fact.projectId); - declaredIds.add(fact.checkoutId); - declaredIds.add(fact.contextId); - projectIds.add(fact.projectId); - checkoutIds.add(fact.checkoutId); - contextIds.add(fact.contextId); - existingCheckoutIdentityIds.add(fact.projectId); - existingCheckoutIdentityIds.add(fact.checkoutId); - break; - case "managed-record": - case "managed-target": - case "persisted-runtime": - declaredIds.add(fact.stackId); - break; - case "operation-result": - declaredIds.add(fact.stackId); - break; - case "occupied-port": - if (fact.ownerId !== undefined) { - declaredIds.add(fact.ownerId); - } - break; - case "port-assignment": - declaredIds.add(fact.stackId); - break; - case "stack": - declaredIds.add(fact.contextId); - declaredIds.add(fact.stackId); - contextIds.add(fact.contextId); - break; - default: - break; - } - } - - const actionCwd = - scenario.when.interface === "cli" || scenario.when.interface === "git" - ? scenario.when.cwd - : typeof scenario.when.input.cwd === "string" - ? scenario.when.input.cwd - : undefined; - if ( - scenario.when.interface === "managed-api" && - scenario.when.input.cwd !== undefined && - (actionCwd === undefined || - !scenario.given.some( - (fact) => - (fact.kind === "checkout" && fact.path === actionCwd) || - (fact.kind === "workspace" && - (fact.path === actionCwd || fact.canonicalPath === actionCwd)) || - (fact.kind === "git-state" && fact.workspacePath === actionCwd), - )) - ) { - errors.push(`${scenario.id}: managed action cwd must match a declared workspace`); - } - if (scenario.when.interface === "cli") { - const declaredActionPaths = scenario.given.flatMap((fact) => { - if (fact.kind === "checkout" || fact.kind === "git-state") { - return [fact.kind === "checkout" ? fact.path : fact.workspacePath]; - } - if (fact.kind === "workspace") { - return fact.canonicalPath === undefined ? [fact.path] : [fact.path, fact.canonicalPath]; - } - return []; - }); - if (declaredActionPaths.length > 0 && !declaredActionPaths.includes(scenario.when.cwd)) { - errors.push(`${scenario.id}: CLI action cwd must match a declared workspace`); - } - } - const workspaceHistory = scenario.given.find( - (fact) => fact.kind === "workspace-history" && fact.path === actionCwd, - ); - if (workspaceHistory?.kind === "workspace-history") { - const currentWorkspace = scenario.given.find( - (fact) => fact.kind === "workspace" && fact.path === actionCwd, - ); - const folderToGitTransition = scenario.given.find( - (fact) => fact.kind === "identity-transition" && fact.operation === "folder-to-git", - ); - if ( - workspaceHistory.previousMode !== "ordinary-folder" || - currentWorkspace?.kind !== "workspace" || - currentWorkspace.mode !== "git" || - folderToGitTransition?.kind !== "identity-transition" || - folderToGitTransition.from !== "ordinary-folder" || - folderToGitTransition.to !== "git" || - (scenario.expected.outcome !== "error" && output.json?.converted_to_git !== true) - ) { - errors.push(`${scenario.id}: folder-to-Git result must bind the workspace transition`); - } - } - const absentCheckoutClaim = scenario.given.find( - (fact) => - fact.kind === "identity-claim" && fact.scope === "checkout" && fact.status === "absent", - ); - if ( - isStatusOperation && - absentCheckoutClaim?.kind === "identity-claim" && - scenario.expected.writes.length === 0 && - (scenario.expected.details?.registered !== false || - scenario.expected.details.identity_marker_created !== false || - output.json?.registered !== false) - ) { - errors.push(`${scenario.id}: read-only unregistered status must not create identity state`); - } - if ( - isStatusOperation && - (scenario.expected.details?.registered === false || output.json?.registered === false) && - absentCheckoutClaim?.kind !== "identity-claim" - ) { - errors.push(`${scenario.id}: unregistered status requires an absent checkout claim`); - } - - const branchCopy = scenario.given.find( - (fact) => fact.kind === "identity-transition" && fact.operation === "branch-copy", - ); - if (branchCopy?.kind === "identity-transition") { - const originalBranchExists = scenario.given.some( - (fact) => fact.kind === "branch" && fact.name === branchCopy.from, - ); - const copiedBranch = scenario.given.find( - (fact) => fact.kind === "branch" && fact.name === branchCopy.to, - ); - const originalClaim = scenario.given.find( - (fact) => - fact.kind === "identity-claim" && - fact.scope === "context" && - fact.owner === branchCopy.from && - fact.status === (branchCopy.originalExists ? "exact" : "absent"), - ); - if ( - typeof branchCopy.from !== "string" || - typeof branchCopy.to !== "string" || - branchCopy.originalExists !== originalBranchExists || - copiedBranch?.kind !== "branch" || - copiedBranch.checkedOut !== true - ) { - errors.push( - `${scenario.id}: copied branch transition must match live original and checked-out branch facts`, - ); - } - if (scenario.expected.warning?.code === "COPIED_BRANCH_CONTEXT_CONFLICT") { - if ( - typeof branchCopy.from !== "string" || - typeof branchCopy.to !== "string" || - originalClaim?.kind !== "identity-claim" || - scenario.expected.warning.message !== - `${branchCopy.to} copied ${originalClaim.id} from ${branchCopy.from}` || - output.json?.branch !== branchCopy.to || - output.json.owner !== branchCopy.from || - output.json.context_id !== originalClaim.id - ) { - errors.push(`${scenario.id}: copied branch warning must bind observed branch ownership`); - } - } - if ( - branchCopy.originalExists === true && - scenario.expected.outcome === "create" && - (originalClaim?.kind !== "identity-claim" || - scenario.expected.details?.original_context_id !== originalClaim.id || - scenario.expected.details.original_owner !== branchCopy.from || - output.json?.original_context_id !== originalClaim.id) - ) { - errors.push(`${scenario.id}: copied branch creation must bind its original context`); - } - if ( - branchCopy.originalExists === false && - scenario.expected.outcome === "reuse" && - (originalClaim?.kind !== "identity-claim" || output.json?.rename_detected !== true) - ) { - errors.push(`${scenario.id}: missing original branch must be reported as a rename`); - } - } - if ( - scenario.when.interface === "managed-api" && - scenario.when.method === "resolveStack" && - scenario.when.input.operation !== undefined && - scenario.when.input.operation !== "start" && - scenario.when.input.operation !== "status" - ) { - errors.push(`${scenario.id}: resolveStack operation must be start or status`); - } - const actionGitState = scenario.given.find( - (fact) => fact.kind === "git-state" && fact.workspacePath === actionCwd, - ); - const actionWorkspace = scenario.given.find( - (fact) => fact.kind === "workspace" && fact.path === actionCwd, - ); - if (actionWorkspace?.kind === "workspace" && actionWorkspace.mode === "bare-worktree") { - if ( - actionGitState?.kind !== "git-state" || - actionGitState.commonDirectory === actionGitState.gitDirectory || - scenario.expected.details?.project_identity_location !== actionGitState.commonDirectory || - scenario.expected.details.checkout_identity_location !== actionGitState.gitDirectory || - output.api?.primaryWorktreeRequired !== false - ) { - errors.push(`${scenario.id}: bare-repository worktree must not require a primary worktree`); - } - } - const checkedOutBranch = scenario.given.find( - (fact) => fact.kind === "branch" && fact.checkedOut, - ); - const branchRename = scenario.given.find( - (fact) => fact.kind === "identity-transition" && fact.operation === "branch-rename", - ); - const branchRecreation = scenario.given.find( - (fact) => fact.kind === "identity-transition" && fact.operation === "branch-delete-recreate", - ); - if (branchRecreation?.kind === "identity-transition") { - const displacedContext = scenario.given.find( - (fact) => - fact.kind === "identity-claim" && - fact.scope === "context" && - fact.status === "absent" && - fact.owner === branchRecreation.from, - ); - if ( - typeof branchRecreation.from !== "string" || - typeof branchRecreation.to !== "string" || - branchRecreation.from !== branchRecreation.to || - displacedContext?.kind !== "identity-claim" || - scenario.expected.details?.orphaned_context_id !== displacedContext.id || - output.json?.orphaned_context_id !== displacedContext.id - ) { - errors.push(`${scenario.id}: branch recreation must orphan the displaced context`); - } - } - if (branchRename?.kind === "identity-transition") { - const renamedContextWrite = scenario.expected.writes.find( - (write) => - write.target === "git-config" && - write.operation === "update" && - checkedOutBranch?.kind === "branch" && - write.id === checkedOutBranch.contextId, - ); - if ( - typeof branchRename.from !== "string" || - typeof branchRename.to !== "string" || - branchRename.from === branchRename.to || - checkedOutBranch?.kind !== "branch" || - checkedOutBranch.name !== branchRename.to || - renamedContextWrite?.target !== "git-config" || - renamedContextWrite.owner !== branchRename.to - ) { - errors.push( - `${scenario.id}: branch rename must match the checked-out branch and updated context owner`, - ); - } - } - const refReplacement = scenario.given.find( - (fact) => fact.kind === "identity-transition" && fact.operation === "ref-replacement", - ); - if ( - refReplacement?.kind === "identity-transition" && - (typeof refReplacement.from !== "string" || - typeof refReplacement.to !== "string" || - refReplacement.from === refReplacement.to || - actionGitState?.kind !== "git-state" || - refReplacement.to !== actionGitState.commit) - ) { - errors.push(`${scenario.id}: ref replacement target must match the action workspace commit`); - } - if (refReplacement?.kind === "identity-transition") { - const displacedContext = scenario.given.find( - (fact) => - fact.kind === "identity-claim" && - fact.scope === "context" && - fact.status === "absent" && - actionGitState?.kind === "git-state" && - fact.owner === actionGitState.branch, - ); - if ( - displacedContext?.kind !== "identity-claim" || - scenario.expected.details?.orphaned_context_id !== displacedContext.id || - output.json?.orphaned_context_id !== displacedContext.id - ) { - errors.push(`${scenario.id}: ref replacement must orphan the displaced branch context`); - } - } - const branchRefs = scenario.given.filter((fact) => fact.kind === "branch-ref"); - if (branchRefs.length > 0) { - const comparedBranches = scenario.given.filter( - (fact) => - fact.kind === "branch" && - !fact.checkedOut && - branchRefs.some((ref) => ref.name === fact.name), - ); - const comparedBranch = comparedBranches.length === 1 ? comparedBranches[0] : undefined; - const checkedOutRef = - checkedOutBranch?.kind === "branch" - ? branchRefs.find((fact) => fact.name === checkedOutBranch.name) - : undefined; - const comparedRef = - comparedBranch?.kind === "branch" - ? branchRefs.find((fact) => fact.name === comparedBranch.name) - : undefined; - if ( - branchRefs.length !== 2 || - comparedBranches.length !== 1 || - actionGitState?.kind !== "git-state" || - checkedOutBranch?.kind !== "branch" || - comparedBranch?.kind !== "branch" || - checkedOutRef?.kind !== "branch-ref" || - comparedRef?.kind !== "branch-ref" || - checkedOutRef.commit !== actionGitState.commit || - comparedRef.commit !== actionGitState.commit || - output.api?.otherContextId !== comparedBranch.contextId - ) { - errors.push( - `${scenario.id}: branch comparison must prove both refs share the checked-out commit`, - ); - } - } - const requiredBranchHistory = requiredBranchHistoryByScenarioId[scenario.id]; - const branchHistory = scenario.given.find((fact) => fact.kind === "branch-history"); - const branchHistoryOperation = requiredBranchHistory ?? branchHistory?.operation; - if (branchHistoryOperation !== undefined) { - const expectedTransitionOperation = - branchHistoryOperation === "commit" - ? "branch-commit" - : branchHistoryOperation === "rebase" - ? "branch-rebase" - : "branch-reset"; - const historyTransition = scenario.given.find( - (fact) => - fact.kind === "identity-transition" && fact.operation === expectedTransitionOperation, - ); - const historyBranch = scenario.given.find( - (fact) => - fact.kind === "branch" && - branchHistory?.kind === "branch-history" && - fact.name === branchHistory.branch && - fact.checkedOut, - ); - if ( - branchHistory?.kind !== "branch-history" || - (requiredBranchHistory !== undefined && - branchHistory.operation !== requiredBranchHistory) || - historyTransition?.kind !== "identity-transition" || - historyTransition.from !== branchHistory.fromCommit || - historyTransition.to !== branchHistory.toCommit || - historyBranch?.kind !== "branch" || - scenario.expected.selection?.contextId !== historyBranch.contextId || - scenario.expected.outcome !== "reuse" - ) { - errors.push( - `${scenario.id}: branch history preservation requires matching branch evidence`, - ); - } - } - const symlinkAlias = scenario.given.find( - (fact) => fact.kind === "identity-transition" && fact.operation === "symlink-alias", - ); - if (symlinkAlias?.kind === "identity-transition") { - const aliasWorkspace = scenario.given.find( - (fact) => fact.kind === "workspace" && fact.path === symlinkAlias.to, - ); - const canonicalClaim = scenario.given.find( - (fact) => - fact.kind === "identity-claim" && - fact.scope === "checkout" && - fact.status === "exact" && - fact.path === symlinkAlias.from, - ); - if ( - aliasWorkspace?.kind !== "workspace" || - aliasWorkspace.canonicalPath !== symlinkAlias.from || - canonicalClaim?.kind !== "identity-claim" || - output.json?.canonical_path !== symlinkAlias.from - ) { - errors.push(`${scenario.id}: symlink alias must report its canonical checkout path`); - } - } - if (scenario.expected.error?.code === "AMBIGUOUS_CONTEXT_OWNER") { - const projectedContextId = output.json?.context_id; - const projectedBranches = output.json?.branches; - const projectedBranchNames = - Array.isArray(projectedBranches) && - projectedBranches.every((branch): branch is string => typeof branch === "string") - ? projectedBranches - : undefined; - const claimingBranchNames = - typeof projectedContextId === "string" - ? scenario.given.flatMap((fact) => - fact.kind === "branch" && fact.contextId === projectedContextId ? [fact.name] : [], - ) - : []; - const humanBranchNames = output.human?.fields.branches - ?.split(",") - .map((branch) => branch.trim()) - .filter((branch) => branch.length > 0); - const ambiguousContextClaim = scenario.given.find( - (fact) => - fact.kind === "identity-claim" && - fact.scope === "context" && - fact.id === projectedContextId && - fact.status === "ambiguous", - ); - const copiedBranchTransition = scenario.given.find( - (fact) => - fact.kind === "identity-transition" && - fact.operation === "branch-copy" && - typeof fact.from === "string" && - claimingBranchNames.includes(fact.from) && - typeof fact.to === "string" && - claimingBranchNames.includes(fact.to) && - fact.originalExists === true, - ); - if ( - typeof projectedContextId !== "string" || - new Set(claimingBranchNames).size < 2 || - ambiguousContextClaim?.kind !== "identity-claim" || - copiedBranchTransition?.kind !== "identity-transition" || - projectedBranchNames === undefined || - !managedStackContractStringSetEquals(claimingBranchNames, projectedBranchNames) || - humanBranchNames === undefined || - !managedStackContractStringSetEquals(claimingBranchNames, humanBranchNames) - ) { - errors.push( - `${scenario.id}: ambiguous context must bind at least two claiming branches to its projections`, - ); - } - } - if ( - scenario.when.interface === "git" && - scenario.when.argv[0] === "branch" && - (scenario.when.argv[1] === "-D" || scenario.when.argv[1] === "-d") - ) { - if ( - scenario.expected.details?.stack_orphaned !== true || - scenario.expected.details.stack_data_preserved !== true - ) { - errors.push(`${scenario.id}: branch deletion must preserve and orphan managed stack data`); - } - const deletedBranchName = scenario.when.argv[2]; - const deletedBranch = scenario.given.find( - (fact) => fact.kind === "branch" && fact.name === deletedBranchName, - ); - const affectedStack = - deletedBranch?.kind === "branch" - ? scenario.given.find( - (fact) => fact.kind === "stack" && fact.contextId === deletedBranch.contextId, - ) - : undefined; - if ( - deletedBranchName === undefined || - deletedBranch?.kind !== "branch" || - affectedStack?.kind !== "stack" - ) { - errors.push( - `${scenario.id}: branch deletion must bind its branch to an affected managed stack`, - ); - } else { - if (deletedBranch.checkedOut) { - errors.push(`${scenario.id}: deleted branch ${deletedBranchName} cannot be checked out`); - } - if (scenario.expected.details?.orphaned_stack_id !== affectedStack.stackId) { - errors.push( - `${scenario.id}: orphaned stack must be ${affectedStack.stackId} for branch ${deletedBranchName}`, - ); - } - } - if ( - actionCwd === undefined || - !scenario.given.some((fact) => fact.kind === "checkout" && fact.path === actionCwd) || - !scenario.given.some( - (fact) => fact.kind === "git-state" && fact.workspacePath === actionCwd, - ) - ) { - errors.push(`${scenario.id}: branch deletion must declare checkout Git state`); - } - } - if ( - actionCwd !== undefined && - scenario.given.some( - (fact) => - fact.kind === "workspace" && - fact.path === actionCwd && - (fact.mode === "bare-worktree" || fact.mode === "linked-worktree"), - ) && - !scenario.given.some((fact) => fact.kind === "git-state" && fact.workspacePath === actionCwd) - ) { - errors.push(`${scenario.id}: resolving worktree ${actionCwd} requires its Git state`); - } - - const detachedGitState = scenario.given.find( - (fact) => - fact.kind === "git-state" && fact.workspacePath === actionCwd && fact.head === "detached", - ); - if ( - detachedGitState?.kind === "git-state" && - scenario.expected.outcome === "reuse" && - !scenario.given.some( - (fact) => - fact.kind === "identity-transition" && - fact.operation === "detached-commit" && - fact.to === detachedGitState.commit, - ) - ) { - errors.push(`${scenario.id}: detached reuse must declare the commit transition`); - } - - if (scenario.expected.error?.code === "DUPLICATE_CHECKOUT_CLAIM") { - const copiedWorkspace = scenario.given.find( - (fact) => fact.kind === "workspace" && fact.path === actionCwd, - ); - const duplicateClaim = scenario.given.find( - (fact) => fact.kind === "identity-claim" && fact.scope === "checkout", - ); - const copyTransition = scenario.given.find( - (fact) => fact.kind === "identity-transition" && fact.operation === "checkout-copy", - ); - const projectedPaths = output.json?.paths; - if ( - copiedWorkspace?.kind !== "workspace" || - copiedWorkspace.copiedFrom === undefined || - duplicateClaim?.kind !== "identity-claim" || - duplicateClaim.status !== "duplicate" || - duplicateClaim.path !== copiedWorkspace.copiedFrom || - copyTransition?.kind !== "identity-transition" || - copyTransition.from !== copiedWorkspace.copiedFrom || - copyTransition.to !== copiedWorkspace.path || - output.json?.checkout_id !== duplicateClaim.id || - !Array.isArray(projectedPaths) || - projectedPaths.length !== 2 || - !projectedPaths.includes(copiedWorkspace.path) || - !projectedPaths.includes(copiedWorkspace.copiedFrom) - ) { - errors.push( - `${scenario.id}: duplicate checkout error must bind both conflicting live paths`, - ); - } - } - - if (scenario.expected.error?.code === "CHECKOUT_PATH_INACCESSIBLE") { - const movedWorkspace = scenario.given.find( - (fact) => fact.kind === "workspace" && fact.path === actionCwd, - ); - const ambiguousClaim = scenario.given.find( - (fact) => fact.kind === "identity-claim" && fact.scope === "checkout", - ); - if ( - movedWorkspace?.kind !== "workspace" || - movedWorkspace.previousPathAccess !== "inaccessible" || - movedWorkspace.previousPath === undefined || - ambiguousClaim?.kind !== "identity-claim" || - ambiguousClaim.status !== "ambiguous" || - ambiguousClaim.path !== movedWorkspace.previousPath || - output.human?.fields.previousPath !== movedWorkspace.previousPath || - output.human?.fields.currentPath !== movedWorkspace.path || - output.json?.checkout_id !== ambiguousClaim.id - ) { - errors.push( - `${scenario.id}: inaccessible checkout error must bind path access and ambiguous claim`, - ); - } - } - - const movedWorkspace = scenario.given.find( - (fact) => fact.kind === "workspace" && fact.path === actionCwd, - ); - const exactCheckoutClaim = scenario.given.find( - (fact) => fact.kind === "identity-claim" && fact.scope === "checkout", - ); - if ( - movedWorkspace?.kind === "workspace" && - movedWorkspace.previousPathAccess === "missing" && - exactCheckoutClaim?.kind === "identity-claim" && - exactCheckoutClaim.status === "exact" && - scenario.expected.outcome === "reuse" - ) { - const apiRebindMatches = - output.api === undefined || - (output.api.rebound === true && output.api.checkoutId === exactCheckoutClaim.id); - const jsonRebindMatches = - output.json === undefined || - (output.json.rebound_from === movedWorkspace.previousPath && - output.json.checkout_id === exactCheckoutClaim.id); - if ( - movedWorkspace.previousPath === undefined || - exactCheckoutClaim.path !== movedWorkspace.previousPath || - !apiRebindMatches || - !jsonRebindMatches - ) { - errors.push(`${scenario.id}: automatic checkout rebind requires a missing previous path`); - } - if ( - !scenario.expected.writes.some( - (write) => - write.target === "registry" && - write.operation === "update" && - write.id === exactCheckoutClaim.id, - ) - ) { - errors.push( - `${scenario.id}: automatic checkout rebind must persist the checkout registry update`, - ); - } - } - - if (scenario.expected.error?.code === "AMBIGUOUS_FOLDER_TO_GIT_IDENTITY") { - const folderToGitTransition = scenario.given.find( - (fact) => fact.kind === "identity-transition" && fact.operation === "folder-to-git", - ); - const actionWorkspace = scenario.given.find( - (fact) => fact.kind === "workspace" && fact.path === actionCwd, - ); - const ambiguousProjectClaim = scenario.given.find( - (fact) => - fact.kind === "identity-claim" && - fact.scope === "project" && - fact.status === "ambiguous" && - fact.path === actionCwd, - ); - if ( - folderToGitTransition?.kind !== "identity-transition" || - actionWorkspace?.kind !== "workspace" || - actionWorkspace.mode !== "git" || - ambiguousProjectClaim?.kind !== "identity-claim" - ) { - errors.push( - `${scenario.id}: folder-to-Git ambiguity error requires an ambiguous live project claim`, - ); - } - } - - for (const fact of scenario.given) { - if (fact.kind !== "native-qualification") { - continue; - } - - if ( - !managedNativeServiceMatrix.targetPlatforms.includes(fact.platform) && - !managedNativeServiceMatrix.unsupportedPlatforms.includes(fact.platform) - ) { - errors.push(`${scenario.id}: native qualification uses unknown platform ${fact.platform}`); - } - if ( - scenario.when.interface === "managed-api" && - scenario.when.method === "preflightNative" && - (typeof scenario.when.input.platform !== "string" || - scenario.when.input.platform !== fact.platform || - scenario.expected.output.api?.platform !== fact.platform) - ) { - errors.push( - `${scenario.id}: native qualification platform must match the preflight action`, - ); - } - - const qualified = new Set(); - const failed = new Set(); - for (const service of fact.qualifiedServices) { - if (!nativeServiceSet.has(service)) { - errors.push(`${scenario.id}: native qualification contains unknown service ${service}`); - } - if (qualified.has(service)) { - errors.push(`${scenario.id}: native qualification duplicates service ${service}`); - } - qualified.add(service); - } - for (const service of fact.failedServices) { - if (!nativeServiceSet.has(service)) { - errors.push(`${scenario.id}: native qualification contains unknown service ${service}`); - } - if (failed.has(service)) { - errors.push(`${scenario.id}: native qualification duplicates service ${service}`); - } - if (qualified.has(service)) { - errors.push(`${scenario.id}: native qualification places ${service} in both partitions`); - } - failed.add(service); - } - for (const service of nativeServices) { - if (!qualified.has(service) && !failed.has(service)) { - errors.push(`${scenario.id}: native qualification omits service ${service}`); - } - } - if (scenario.when.interface === "managed-api" && scenario.when.method === "preflightNative") { - const platformSupported = managedNativeServiceMatrix.targetPlatforms.includes( - fact.platform, - ); - if (!platformSupported && scenario.expected.error?.code !== "NATIVE_PLATFORM_UNSUPPORTED") { - errors.push( - `${scenario.id}: unsupported native platform must use the dedicated preflight error`, - ); - } - const platformQualified = failed.size === 0 && qualified.size === nativeServices.length; - if ( - platformSupported && - !platformQualified && - scenario.expected.error?.code !== "NATIVE_PLATFORM_NOT_QUALIFIED" - ) { - errors.push( - `${scenario.id}: supported unqualified native platform must use NATIVE_PLATFORM_NOT_QUALIFIED`, - ); - } - if ( - scenario.expected.outcome !== (platformQualified ? "report" : "error") || - scenario.expected.details?.qualified !== platformQualified || - scenario.expected.details.qualified_service_count !== qualified.size || - scenario.expected.details.failed_service_count !== failed.size || - scenario.expected.output.api?.qualified !== platformQualified - ) { - errors.push( - `${scenario.id}: native preflight decision must match its qualification partitions`, - ); - } - const projectedServices = scenario.expected.output.api?.services; - if ( - platformQualified && - (!Array.isArray(projectedServices) || - !managedStackContractJsonEquals(projectedServices, fact.qualifiedServices)) - ) { - errors.push(`${scenario.id}: native preflight services must match qualified services`); - } - const projectedFailures = scenario.expected.output.api?.failedServices; - if ( - !platformQualified && - (!Array.isArray(projectedFailures) || - !managedStackContractJsonEquals(projectedFailures, fact.failedServices)) - ) { - errors.push(`${scenario.id}: native preflight failures must match failed services`); - } - const projectedAvailableServices = scenario.expected.output.api?.availableServices; - if ( - !platformQualified && - (!Array.isArray(projectedAvailableServices) || - projectedAvailableServices.length !== 0 || - scenario.expected.details?.reduced_graph !== false || - scenario.expected.details.docker_fallback_per_service !== false) - ) { - errors.push( - `${scenario.id}: failed native qualification must expose no reduced service graph`, - ); - } - } - } - - if (scenario.expected.error?.code === "NATIVE_PLATFORM_UNSUPPORTED") { - const qualification = scenario.given.find((fact) => fact.kind === "native-qualification"); - const projectedPlatforms = output.json?.supported_platforms; - if ( - qualification?.kind !== "native-qualification" || - !managedNativeServiceMatrix.unsupportedPlatforms.includes(qualification.platform) || - output.json?.platform !== qualification.platform || - !Array.isArray(projectedPlatforms) || - projectedPlatforms.length !== managedNativeServiceMatrix.targetPlatforms.length || - !managedNativeServiceMatrix.targetPlatforms.every((platform) => - projectedPlatforms.includes(platform), - ) || - explicitRuntime !== "native" - ) { - errors.push(`${scenario.id}: unsupported native error must bind an unsupported platform`); - } - } - - const writesIdentityMarker = scenario.expected.writes.some( - (write) => write.target === "identity-marker", - ); - const clonedWorkspace = scenario.given.find( - (fact) => - fact.kind === "workspace" && fact.path === actionCwd && fact.clonedFrom !== undefined, - ); - if ( - clonedWorkspace?.kind === "workspace" && - scenario.expected.outcome === "create" && - scenario.expected.writes.some((write) => write.target === "git-config") && - (scenario.expected.details?.git_index_mutated !== false || writesIdentityMarker) - ) { - errors.push(`${scenario.id}: fresh clone identity creation must not mutate the Git index`); - } - const trackedIdentityMarker = scenario.given.find( - (fact) => fact.kind === "git-state" && fact.trackedIdentityMarker === true, - ); - if ( - trackedIdentityMarker?.kind === "git-state" && - (scenario.expected.details?.tracked_marker_ignored !== true || - scenario.expected.details.tracked_marker_mutated !== false || - scenario.expected.details.git_index_mutated !== false || - output.json?.tracked_marker_ignored !== true || - writesIdentityMarker) - ) { - errors.push(`${scenario.id}: tracked identity marker must remain ignored and unmodified`); - } - if (writesIdentityMarker) { - if ( - scenario.given.some( - (fact) => fact.kind === "git-state" && fact.trackedIdentityMarker === true, - ) - ) { - errors.push(`${scenario.id}: a tracked identity marker must remain untouched`); - } else if ( - scenario.given.some((fact) => fact.kind === "workspace" && fact.mode !== "ordinary-folder") - ) { - errors.push(`${scenario.id}: Git workspace identity must use Git-local metadata`); - } - if (scenario.expected.details?.identity_marker_tracked !== false) { - errors.push(`${scenario.id}: ordinary-folder identity marker must remain untracked`); - } - } - - if (scenario.expected.outcome !== "create") { - for (const effect of scenario.expected.runtimeEffects) { - if (effect.operation !== "start") { - continue; - } - const explicitlyStopped = scenario.given.some( - (fact) => - fact.kind === "stack" && - fact.stackId === effect.stackId && - fact.lifecycle === "stopped", - ); - if (!explicitlyStopped) { - errors.push( - `${scenario.id}: starting existing stack ${effect.stackId} requires an explicit stopped lifecycle`, - ); - } - } - } - - for (const target of scenario.given) { - if ( - target.kind === "managed-target" && - !target.exists && - scenario.given.some((fact) => fact.kind === "stack" && fact.stackId === target.stackId) - ) { - errors.push( - `${scenario.id}: absent managed target ${target.stackId} contradicts an existing stack`, - ); - } - } - if (isManagedStartAction(scenario.when) && scenario.expected.outcome === "reuse") { - const existingStartedTarget = scenario.given.find( - (fact) => - fact.kind === "managed-target" && - fact.exists && - scenario.expected.runtimeEffects.some( - (effect) => effect.operation === "start" && effect.stackId === fact.stackId, - ), - ); - if ( - existingStartedTarget?.kind === "managed-target" && - (scenario.expected.details?.legacy_state_read !== false || - (output.api !== undefined && - (output.api.bootstrap !== "not-attempted" || output.api.legacyStateRead !== false)) || - (output.json !== undefined && output.json.bootstrap !== "not-attempted")) - ) { - errors.push(`${scenario.id}: existing managed target must not report legacy bootstrap`); - } - const legacyState = scenario.given.find((fact) => fact.kind === "legacy-state"); - if ( - existingStartedTarget?.kind === "managed-target" && - legacyState?.kind === "legacy-state" && - (scenario.expected.details?.legacy_state_mutated !== false || - (output.json?.legacy_state_mutated !== undefined && - output.json.legacy_state_mutated !== false)) - ) { - errors.push(`${scenario.id}: managed target reuse must not mutate legacy state`); - } - if ( - existingStartedTarget?.kind === "managed-target" && - legacyState?.kind === "legacy-state" && - (legacyState.database === "incompatible" || - legacyState.storage === "incompatible" || - legacyState.credentials === "incompatible") && - (scenario.expected.details?.timelines_diverged !== true || - output.json?.timelines_diverged !== true) - ) { - errors.push(`${scenario.id}: managed restart must report legacy timeline divergence`); - } - } - - const createdStackIds = scenario.expected.writes.flatMap((write) => - write.target === "managed-state" && write.operation === "create" ? [write.id] : [], - ); - for (const stackId of createdStackIds) { - if ( - !scenario.given.some( - (fact) => fact.kind === "managed-target" && fact.stackId === stackId && !fact.exists, - ) - ) { - errors.push(`${scenario.id}: managed creation must declare absent target ${stackId}`); - } - } - if ( - isManagedStartAction(scenario.when) && - scenario.expected.outcome === "create" && - createdStackIds.length > 0 - ) { - const legacyState = scenario.given.find((fact) => fact.kind === "legacy-state"); - const legacyAllowsFreshCreation = - legacyState?.kind === "legacy-state" && - ((legacyState.lifecycle === "absent" && - legacyState.database === "absent" && - legacyState.storage === "absent" && - legacyState.credentials === "absent") || - (legacyState.lifecycle === "stopped" && - (legacyState.database === "incompatible" || - legacyState.storage === "incompatible" || - legacyState.credentials === "incompatible"))); - if (!legacyAllowsFreshCreation) { - errors.push( - `${scenario.id}: managed creation must declare legacy state absent or incompatible`, - ); - } - const legacyIsFullyAbsent = - legacyState?.kind === "legacy-state" && - legacyState.lifecycle === "absent" && - legacyState.database === "absent" && - legacyState.storage === "absent" && - legacyState.credentials === "absent"; - if ( - legacyIsFullyAbsent && - ((scenario.expected.details?.legacy_state_mutated !== undefined && - scenario.expected.details.legacy_state_mutated !== false) || - (output.json?.legacy_state_mutated !== undefined && - output.json.legacy_state_mutated !== false)) - ) { - errors.push(`${scenario.id}: absent legacy bootstrap must not report mutation`); - } - if ( - legacyState?.kind === "legacy-state" && - legacyState.lifecycle === "stopped" && - (legacyState.database === "incompatible" || - legacyState.storage === "incompatible" || - legacyState.credentials === "incompatible") && - (scenario.expected.details?.legacy_state_mutated !== false || - output.json?.legacy_state_mutated !== false || - scenario.expected.writes.some( - (write) => write.target === "managed-state" && write.operation === "copy", - ) || - scenario.expected.runtimeEffects.some((effect) => effect.operation === "copy")) - ) { - errors.push(`${scenario.id}: fresh bootstrap must not copy or mutate legacy state`); - } - } - - const copiedStackIds = new Set( - scenario.expected.writes.flatMap((write) => - write.target === "managed-state" && write.operation === "copy" ? [write.id] : [], - ), - ); - for (const effect of scenario.expected.runtimeEffects) { - if (effect.operation === "copy") { - copiedStackIds.add(effect.stackId); - } - } - if (copiedStackIds.size > 0) { - const legacyState = scenario.given.find((fact) => fact.kind === "legacy-state"); - const legacyIsCopyable = - legacyState?.kind === "legacy-state" && - legacyState.lifecycle === "stopped" && - legacyState.database === "compatible" && - legacyState.storage === "compatible" && - legacyState.credentials === "compatible"; - for (const stackId of copiedStackIds) { - if ( - !scenario.given.some( - (fact) => fact.kind === "managed-target" && fact.stackId === stackId && !fact.exists, - ) || - !legacyIsCopyable - ) { - errors.push( - `${scenario.id}: bootstrap copy requires absent target ${stackId} and compatible stopped legacy state`, - ); - } - } - if ( - scenario.expected.details?.legacy_state_mutated !== false || - (output.json !== undefined && output.json.legacy_state_mutated !== false) - ) { - errors.push(`${scenario.id}: bootstrap copy must not mutate legacy state`); - } - } - - for (const effect of scenario.expected.runtimeEffects) { - if (effect.operation !== "start") { - continue; - } - const createsTarget = scenario.expected.writes.some( - (write) => - write.target === "managed-state" && - (write.operation === "create" || write.operation === "copy") && - write.id === effect.stackId, - ); - const targetExists = scenario.given.some( - (fact) => - (fact.kind === "managed-target" && fact.stackId === effect.stackId && fact.exists) || - (fact.kind === "stack" && fact.stackId === effect.stackId), - ); - if (!createsTarget && !targetExists) { - errors.push( - `${scenario.id}: starting existing stack ${effect.stackId} requires an existing managed target`, - ); - } - } - - if (scenario.expected.error?.code === "PERSISTED_RUNTIME_UNAVAILABLE") { - if (scenario.expected.details?.switched_to_docker !== false) { - errors.push(`${scenario.id}: unavailable persisted runtime must not report a switch`); - } - for (const fact of scenario.given) { - if (fact.kind !== "persisted-runtime") { - continue; - } - const explicitlyStopped = scenario.given.some( - (candidate) => - candidate.kind === "stack" && - candidate.stackId === fact.stackId && - candidate.lifecycle === "stopped", - ); - if (!explicitlyStopped) { - errors.push( - `${scenario.id}: persisted runtime failure for ${fact.stackId} requires an explicit stopped lifecycle`, - ); - } - } - } - - for (const write of scenario.expected.writes) { - if ( - write.operation === "copy" || - write.operation === "create" || - write.operation === "publish" - ) { - declaredIds.add(write.id); - } - if (write.target === "identity-marker") { - declaredIds.add(write.projectId); - declaredIds.add(write.checkoutId); - declaredIds.add(write.contextId); - projectIds.add(write.projectId); - checkoutIds.add(write.checkoutId); - contextIds.add(write.contextId); - } - } - for (const write of scenario.expected.writes) { - if ( - write.operation !== "copy" && - write.operation !== "create" && - write.operation !== "publish" && - !declaredIds.has(write.id) - ) { - errors.push( - `${scenario.id}: ${write.target} ${write.operation} references undeclared ID ${write.id}`, - ); - } - } - - const selection = scenario.expected.selection; - if (selection !== undefined) { - projectIds.add(selection.projectId); - checkoutIds.add(selection.checkoutId); - contextIds.add(selection.contextId); - for (const id of [ - selection.projectId, - selection.checkoutId, - selection.contextId, - selection.stackId, - ]) { - if (!declaredIds.has(id)) { - errors.push(`${scenario.id}: selection references undeclared ID ${id}`); - } - } - - const selectedStack = scenario.given.find( - (fact) => fact.kind === "stack" && fact.stackId === selection.stackId, - ); - if (selectedStack?.kind === "stack") { - if (selectedStack.contextId !== selection.contextId) { - errors.push( - `${scenario.id}: selected stack ${selection.stackId} belongs to context ${selectedStack.contextId}, not ${selection.contextId}`, - ); - } - if (selectedStack.name !== selection.stackName) { - errors.push( - `${scenario.id}: selected stack ${selection.stackId} is named ${selectedStack.name}, not ${selection.stackName}`, - ); - } - } - - const actionCheckout = scenario.given.find( - (fact) => fact.kind === "checkout" && fact.path === actionCwd, - ); - if ( - actionCheckout?.kind === "checkout" && - (selection.projectId !== actionCheckout.projectId || - selection.checkoutId !== actionCheckout.checkoutId) - ) { - errors.push( - `${scenario.id}: selection must use checkout ${actionCheckout.checkoutId} for ${actionCwd}`, - ); - } - - const declaredBranches = scenario.given.filter((fact) => fact.kind === "branch"); - const checkedOutBranches = declaredBranches.filter((fact) => fact.checkedOut); - if (declaredBranches.length > 0 && checkedOutBranches.length !== 1) { - errors.push( - `${scenario.id}: contextual Git selection requires exactly one checked-out branch`, - ); - } - - if (checkedOutBranch?.kind === "branch") { - const createsSelectedContext = scenario.expected.writes.some( - (write) => - write.target === "git-config" && - write.operation === "create" && - write.id === selection.contextId, - ); - const hasCheckoutScopedContextClaim = scenario.given.some( - (fact) => - fact.kind === "identity-claim" && - fact.scope === "context" && - fact.id === selection.contextId && - fact.status === "exact" && - (fact.owner === checkedOutBranch.name || - fact.owner === `${selection.checkoutId}/${checkedOutBranch.name}`), - ); - if ( - (actionGitState?.kind === "git-state" && - actionGitState.head === "branch" && - actionGitState.branch !== checkedOutBranch.name) || - (selection.contextId !== checkedOutBranch.contextId && - !createsSelectedContext && - !hasCheckoutScopedContextClaim) - ) { - errors.push( - `${scenario.id}: selected context must match the active Git branch and checked-out branch fact`, - ); - } - } - - if (scenario.when.interface === "managed-api" && scenario.when.method === "resolveStack") { - for (const write of scenario.expected.writes) { - if (write.target !== "git-config" || write.operation !== "create") { - continue; - } - const scope = - write.id === selection.projectId - ? "project" - : write.id === selection.checkoutId - ? "checkout" - : write.id === selection.contextId - ? "context" - : undefined; - if ( - scope !== undefined && - !scenario.given.some( - (fact) => - fact.kind === "identity-claim" && - fact.scope === scope && - fact.id === write.id && - fact.status === "absent", - ) - ) { - errors.push( - `${scenario.id}: creating Git identity ${write.id} requires an absent ${scope} claim`, - ); - } - } - } - - if ( - scenario.given.some( - (fact) => fact.kind === "identity-transition" && fact.operation === "folder-to-git", - ) - ) { - for (const id of [selection.projectId, selection.checkoutId, selection.contextId]) { - if ( - !scenario.expected.writes.some( - (write) => write.target === "git-config" && write.id === id, - ) - ) { - errors.push( - `${scenario.id}: folder-to-Git identity ${id} must be persisted in Git-local metadata`, - ); - } - } - if ( - scenario.expected.outcome === "reuse" && - (!scenario.given.some( - (fact) => - fact.kind === "identity-claim" && - fact.scope === "project" && - fact.id === selection.projectId && - fact.path === actionCwd && - fact.status === "exact", - ) || - !scenario.given.some( - (fact) => - fact.kind === "identity-claim" && - fact.scope === "checkout" && - fact.id === selection.checkoutId && - fact.path === actionCwd && - fact.status === "exact", - )) - ) { - errors.push( - `${scenario.id}: folder-to-Git reuse requires exact project and checkout claims`, - ); - } - } - - const createsSelectedContext = scenario.expected.writes.some( - (write) => - write.target === "git-config" && - write.operation === "create" && - write.id === selection.contextId, - ); - const derivesContextFromGit = scenario.given.some( - (fact) => - fact.kind === "identity-transition" && - (fact.operation === "clone" || - fact.operation === "branch-delete-recreate" || - fact.operation === "folder-to-git" || - fact.operation === "ref-replacement"), - ); - const contextAlreadyDeclaredByBranch = scenario.given.some( - (fact) => - fact.kind === "branch" && fact.contextId === selection.contextId && fact.checkedOut, - ); - if ( - createsSelectedContext && - derivesContextFromGit && - !contextAlreadyDeclaredByBranch && - (actionCwd === undefined || - !scenario.given.some( - (fact) => fact.kind === "git-state" && fact.workspacePath === actionCwd, - )) - ) { - errors.push(`${scenario.id}: creating a Git context requires Git state for the workspace`); - } - - const recoveredIdentityMarker = scenario.given.find( - (fact) => - fact.kind === "identity-marker" && - fact.workspacePath === actionCwd && - fact.projectId === selection.projectId && - fact.checkoutId === selection.checkoutId && - fact.contextId === selection.contextId, - ); - if ( - recoveredIdentityMarker?.kind === "identity-marker" && - !scenario.given.some( - (fact) => - fact.kind === "workspace" && - fact.mode === "ordinary-folder" && - (fact.path === actionCwd || fact.canonicalPath === actionCwd), - ) - ) { - errors.push(`${scenario.id}: local identity marker recovery requires an ordinary folder`); - } - - const ordinaryWorkspace = scenario.given.find( - (fact) => - fact.kind === "workspace" && - fact.mode === "ordinary-folder" && - (fact.path === actionCwd || fact.canonicalPath === actionCwd), - ); - if (ordinaryWorkspace?.kind === "workspace") { - if (scenario.expected.outcome === "create") { - if ( - !scenario.expected.writes.some( - (write) => - write.target === "identity-marker" && - write.workspacePath === actionCwd && - write.projectId === selection.projectId && - write.checkoutId === selection.checkoutId && - write.contextId === selection.contextId, - ) - ) { - errors.push( - `${scenario.id}: ordinary-folder creation must persist its identity marker`, - ); - } - } else if ( - !scenario.given.some( - (fact) => - fact.kind === "identity-marker" && - fact.workspacePath === actionCwd && - fact.projectId === selection.projectId && - fact.checkoutId === selection.checkoutId && - fact.contextId === selection.contextId, - ) - ) { - errors.push(`${scenario.id}: ordinary-folder reuse must resolve its identity marker`); - } - } - } - - if (scenario.when.interface === "managed-api" && scenario.when.method === "startConcurrently") { - const concurrencyFacts = scenario.given.filter( - (fact) => fact.kind === "concurrent-operation" && fact.operation === "create-stack", - ); - const concurrencyFact = concurrencyFacts.length === 1 ? concurrencyFacts[0] : undefined; - if (concurrencyFact?.kind !== "concurrent-operation") { - errors.push(`${scenario.id}: concurrent start requires exactly one create-stack fact`); - } else { - const actionContenders = scenario.when.input.contenders; - if ( - typeof actionContenders !== "number" || - !Number.isInteger(actionContenders) || - actionContenders < 2 || - actionContenders !== concurrencyFact.contenders - ) { - errors.push( - `${scenario.id}: concurrent action contenders must match the declared race of ${concurrencyFact.contenders}`, - ); - } - - const actionStackName = scenario.when.input.stackName; - const actionTarget = - selection !== undefined && typeof actionStackName === "string" - ? `${selection.contextId}/${actionStackName}` - : undefined; - if (actionTarget !== concurrencyFact.target) { - errors.push( - `${scenario.id}: concurrent action target must match ${concurrencyFact.target}`, - ); - } - - const contenderResults = [ - { - projection: "details", - results: scenario.expected.details?.contender_results, - }, - { - projection: "API", - results: scenario.expected.output.api?.contenderResults, - }, - ]; - for (const { projection, results } of contenderResults) { - if (!Array.isArray(results) || results.length !== concurrencyFact.contenders) { - errors.push( - `${scenario.id}: concurrent ${projection} results must cover ${concurrencyFact.contenders} contenders`, - ); - } - } - const detailResults = scenario.expected.details?.contender_results; - const apiResults = scenario.expected.output.api?.contenderResults; - if ( - Array.isArray(detailResults) && - Array.isArray(apiResults) && - !managedStackContractJsonEquals(detailResults, apiResults) - ) { - errors.push(`${scenario.id}: concurrent result projections must agree`); - } - if ( - Array.isArray(detailResults) && - (detailResults.filter((result) => result === "create").length !== 1 || - detailResults.some((result) => result !== "create" && result !== "reuse")) - ) { - errors.push(`${scenario.id}: concurrent race must create once and reuse thereafter`); - } - if ( - scenario.expected.details?.published_stack_count !== 1 || - scenario.expected.output.api?.publishedStackCount !== 1 || - scenario.expected.details?.alias_count !== 0 || - scenario.expected.output.api?.aliasCount !== 0 - ) { - errors.push(`${scenario.id}: concurrent race must publish one stack without aliases`); - } - } - } - - if (scenario.expected.error?.code === "EXACT_PORT_OCCUPIED") { - const configuredPort = scenario.given.find( - (fact) => fact.kind === "config-port" && fact.intent === "exact", - ); - const occupiedPort = - configuredPort?.kind === "config-port" - ? scenario.given.find( - (fact) => fact.kind === "occupied-port" && fact.port === configuredPort.value, - ) - : undefined; - if ( - configuredPort?.kind !== "config-port" || - typeof configuredPort.value !== "number" || - occupiedPort?.kind !== "occupied-port" || - output.human?.fields.port !== String(configuredPort.value) || - output.human?.fields.configKey !== configuredPort.key || - output.human?.fields.owner !== occupiedPort.owner || - output.json?.port !== configuredPort.value || - output.json?.config_key !== configuredPort.key || - output.json?.owner !== occupiedPort.owner - ) { - errors.push( - `${scenario.id}: exact port conflict must bind config, occupancy, and projections`, - ); - } - } - - const managedPortOwners = scenario.given.flatMap((fact) => - fact.kind === "occupied-port" && fact.owner === "managed-stack" ? [fact] : [], - ); - if (scenario.expected.error?.code === "EXACT_PORT_OCCUPIED" && managedPortOwners.length > 0) { - if (selection === undefined) { - errors.push(`${scenario.id}: managed sibling port conflict requires a selected target`); - } - for (const owner of managedPortOwners) { - if (owner.ownerId.trim().length === 0) { - errors.push(`${scenario.id}: managed sibling port owner requires a stack ID`); - } - if (owner.ownerId === selection?.stackId) { - errors.push( - `${scenario.id}: managed sibling port owner must differ from the selected target`, - ); - } - if ( - (output.json !== undefined && output.json.owner_stack_id !== owner.ownerId) || - (output.api !== undefined && output.api.ownerStackId !== owner.ownerId) || - (output.human !== undefined && output.human.fields.ownerStackId !== owner.ownerId) - ) { - errors.push(`${scenario.id}: projected managed port owner must match ${owner.ownerId}`); - } - } - } - - if (scenario.expected.error?.code === "STICKY_PORT_OCCUPIED") { - if (selection === undefined) { - errors.push(`${scenario.id}: sticky port conflict requires a selected target`); - } else { - const assignment = scenario.given.find( - (fact) => - fact.kind === "port-assignment" && - fact.stackId === selection.stackId && - fact.intent === "automatic", - ); - const occupiedPort = - assignment?.kind === "port-assignment" - ? scenario.given.find( - (fact) => fact.kind === "occupied-port" && fact.port === assignment.port, - ) - : undefined; - if ( - assignment?.kind !== "port-assignment" || - occupiedPort?.kind !== "occupied-port" || - output.json?.port !== assignment.port || - output.json?.config_key !== assignment.key || - output.json?.relocated !== false - ) { - errors.push( - `${scenario.id}: sticky port conflict must bind assignment, occupancy, and projections`, - ); - } - if ( - !scenario.given.some( - (fact) => - fact.kind === "stack" && - fact.stackId === selection.stackId && - fact.lifecycle === "stopped", - ) - ) { - errors.push(`${scenario.id}: sticky port conflict requires a stopped selected stack`); - } - } - } - - const changedExactPort = scenario.given.find( - (fact) => - fact.kind === "config-port" && - fact.intent === "exact" && - typeof fact.value === "number" && - typeof fact.previousValue === "number", - ); - const expectsExactPortChange = - (scenario.expected.outcome === "update" && - typeof output.json?.previous_port === "number" && - typeof output.json?.port === "number") || - scenario.expected.warning?.code === "RUNNING_STACK_CONFIG_DRIFT"; - if (expectsExactPortChange) { - let exactPortChangeMatches = false; - if ( - changedExactPort?.kind === "config-port" && - typeof changedExactPort.value === "number" && - typeof changedExactPort.previousValue === "number" - ) { - const previousAssignment = scenario.given.find( - (fact) => - fact.kind === "port-assignment" && - fact.stackId === selection?.stackId && - fact.key === changedExactPort.key && - fact.port === changedExactPort.previousValue, - ); - const updateProjectionMatches = - scenario.expected.outcome !== "update" || - (output.json?.previous_port === changedExactPort.previousValue && - output.json?.port === changedExactPort.value && - output.human?.fields.apiUrl === `http://127.0.0.1:${changedExactPort.value}`); - const driftProjectionMatches = - scenario.expected.warning?.code !== "RUNNING_STACK_CONFIG_DRIFT" || - (scenario.given.some( - (fact) => - fact.kind === "stack" && - fact.stackId === selection?.stackId && - fact.lifecycle === "running", - ) && - output.json?.config_key === changedExactPort.key && - output.json?.running_port === changedExactPort.previousValue && - output.json?.requested_port === changedExactPort.value && - output.json?.drift === true && - output.human?.fields.configKey === changedExactPort.key && - output.human?.fields.runningPort === String(changedExactPort.previousValue) && - output.human?.fields.configuredPort === String(changedExactPort.value) && - output.human?.fields.drift === "true"); - exactPortChangeMatches = - previousAssignment?.kind === "port-assignment" && - previousAssignment.intent === "exact" && - changedExactPort.value !== changedExactPort.previousValue && - updateProjectionMatches && - driftProjectionMatches; - } - if (!exactPortChangeMatches) { - errors.push( - `${scenario.id}: exact port change must bind previous assignment and requested value`, - ); - } - if (scenario.expected.outcome === "update" && selection !== undefined) { - const persistenceIndex = scenario.expected.writes.findIndex( - (write) => - write.target === "managed-state" && - write.operation === "update" && - write.id === selection.stackId, - ); - const runtimeStartIndex = scenario.expected.writes.findIndex( - (write) => - write.target === "runtime-state" && - write.operation === "start" && - write.id === selection.stackId, - ); - if (persistenceIndex < 0 || runtimeStartIndex <= persistenceIndex) { - errors.push( - `${scenario.id}: exact port change must persist assignment before runtime start`, - ); - } - } - } - - const stickyAssignments = - scenario.expected.outcome === "reuse" && selection !== undefined - ? scenario.given.filter( - (fact): fact is ManagedStackContractPortAssignmentFact => - fact.kind === "port-assignment" && - fact.stackId === selection.stackId && - fact.intent === "automatic" && - scenario.given.some( - (config) => - config.kind === "config-port" && - config.key === fact.key && - config.intent === "automatic" && - config.source === "omitted", - ), - ) - : []; - if (output.json?.sticky === true || stickyAssignments.length > 0) { - const projectedPorts = scenario.expected.output.json?.ports; - if (selection === undefined) { - errors.push(`${scenario.id}: sticky port reuse requires a selected target`); - } else if ( - stickyAssignments.length === 0 || - output.json?.sticky !== true || - !isManagedStackContractRecord(projectedPorts) || - stickyAssignments.some((assignment) => { - const service = assignment.key.endsWith(".port") - ? assignment.key.slice(0, -".port".length) - : assignment.key; - return ( - projectedPorts?.[service] !== assignment.port || - (service === "api" && - output.human !== undefined && - output.human.fields.apiUrl !== `http://127.0.0.1:${assignment.port}`) - ); - }) - ) { - errors.push( - `${scenario.id}: sticky port reuse must bind automatic config, assignment, and projections`, - ); - } - } - - if (output.api?.sticky === true && scenario.expected.outcome === "update") { - const targetStackId = selection?.stackId ?? explicitActionStackId; - const projectedPorts = output.api.ports; - const projectedIntents = output.api.intents; - if ( - typeof targetStackId !== "string" || - !isManagedStackContractRecord(projectedPorts) || - !isManagedStackContractRecord(projectedIntents) - ) { - errors.push(`${scenario.id}: exact-to-automatic port transition requires projections`); - } else { - for (const [service, port] of Object.entries(projectedPorts)) { - const key = `${service}.port`; - const configPort = scenario.given.find( - (fact) => fact.kind === "config-port" && fact.key === key, - ); - const previousAssignment = scenario.given.find( - (fact) => - fact.kind === "port-assignment" && fact.stackId === targetStackId && fact.key === key, - ); - if ( - typeof port !== "number" || - configPort?.kind !== "config-port" || - configPort.intent !== "automatic" || - configPort.source !== "omitted" || - configPort.previousValue !== port || - previousAssignment?.kind !== "port-assignment" || - previousAssignment.intent !== "exact" || - previousAssignment.port !== port || - projectedIntents[service] !== "automatic" - ) { - errors.push( - `${scenario.id}: exact-to-automatic port transition must preserve the previous assignment`, - ); - } - } - } - } - - if ( - scenario.area === "ports" && - scenario.when.interface === "managed-api" && - scenario.when.method === "startStack" && - scenario.expected.outcome === "create" - ) { - const targetStackId = scenario.when.input.stackId; - const siblingAssignments = scenario.given.flatMap((fact) => - fact.kind === "port-assignment" && - typeof targetStackId === "string" && - fact.stackId !== targetStackId - ? [fact] - : [], - ); - const siblingStackIds = [...new Set(siblingAssignments.map(({ stackId }) => stackId))]; - const projectedAvoidedStackIds = scenario.expected.details?.avoided_sibling_stack_ids; - const avoidedStackIds = - Array.isArray(projectedAvoidedStackIds) && - projectedAvoidedStackIds.every((stackId): stackId is string => typeof stackId === "string") - ? projectedAvoidedStackIds - : undefined; - if ( - (siblingStackIds.length > 0 || projectedAvoidedStackIds !== undefined) && - (avoidedStackIds === undefined || - !managedStackContractStringSetEquals(siblingStackIds, avoidedStackIds)) - ) { - errors.push(`${scenario.id}: sibling allocation must bind all avoided stack IDs`); - } - const projectedPorts = scenario.expected.output.api?.ports; - if (!isManagedStackContractRecord(projectedPorts)) { - if (siblingAssignments.length > 0) { - errors.push(`${scenario.id}: sibling allocation must project its allocated ports`); - } - } else { - const occupiedSiblingPorts = new Set(siblingAssignments.map(({ port }) => port)); - for (const port of Object.values(projectedPorts)) { - if (typeof port === "number") { - if (occupiedSiblingPorts.has(port)) { - errors.push(`${scenario.id}: allocated port ${port} conflicts with a sibling target`); - } - } - } - } - } - - if ( - scenario.when.interface === "managed-api" && - scenario.when.method === "startStack" && - isManagedStackContractRecord(scenario.when.input.portIntents) - ) { - const projectedPorts = scenario.expected.output.api?.ports; - const projectedIntents = scenario.expected.output.api?.intents; - const requestedEntries = Object.entries(scenario.when.input.portIntents); - const configPorts = scenario.given.filter((fact) => fact.kind === "config-port"); - const actionStackId = scenario.when.input.stackId; - const createsManagedTarget = - typeof actionStackId === "string" && - scenario.expected.writes.some( - (write) => - write.target === "managed-state" && - write.operation === "create" && - write.id === actionStackId, - ); - const requestsOmittedAutomaticPort = requestedEntries.some( - ([key, intent]) => - intent === "automatic" && - configPorts.some( - (fact) => fact.key === key && fact.intent === "automatic" && fact.source === "omitted", - ), - ); - if ( - createsManagedTarget && - requestsOmittedAutomaticPort && - (scenario.expected.details?.host_wide !== true || scenario.expected.details.sticky !== true) - ) { - errors.push(`${scenario.id}: fresh automatic ports must be host-wide sticky assignments`); - } - if (isManagedStackContractRecord(projectedPorts)) { - const allocatedPorts = new Set(); - for (const port of Object.values(projectedPorts)) { - if (typeof port !== "number") { - continue; - } - if (allocatedPorts.has(port)) { - errors.push(`${scenario.id}: allocated port ${port} is assigned more than once`); - } - allocatedPorts.add(port); - } - } - if ( - !managedStackContractStringSetEquals( - requestedEntries.map(([key]) => key), - configPorts.map(({ key }) => key), - ) - ) { - errors.push(`${scenario.id}: requested port keys must match their config facts`); - } - for (const [key, intent] of requestedEntries) { - const configPort = scenario.given.find( - (fact) => fact.kind === "config-port" && fact.key === key, - ); - const service = key.endsWith(".port") ? key.slice(0, -".port".length) : key; - if (intent === "automatic") { - if ( - configPort?.kind !== "config-port" || - configPort.intent !== "automatic" || - !isManagedStackContractRecord(projectedPorts) || - typeof projectedPorts[service] !== "number" || - !isManagedStackContractRecord(projectedIntents) || - projectedIntents[service] !== "automatic" - ) { - errors.push( - `${scenario.id}: automatic port request ${key} must match its fact and projected allocation`, - ); - } - continue; - } - if (!isManagedStackContractRecord(intent) || intent.intent !== "exact") { - errors.push(`${scenario.id}: port request ${key} has an invalid intent`); - continue; - } - const requestedPort = intent.port; - if ( - typeof requestedPort !== "number" || - configPort?.kind !== "config-port" || - configPort.intent !== "exact" || - configPort.value !== requestedPort - ) { - errors.push(`${scenario.id}: exact port request ${key} must match its config fact`); - } else if ( - !isManagedStackContractRecord(projectedPorts) || - projectedPorts[service] !== requestedPort - ) { - errors.push( - `${scenario.id}: projected exact port ${key} must match request ${requestedPort}`, - ); - } - const projectedExactIntent = - requestedEntries.length === 1 - ? output.api?.intent - : isManagedStackContractRecord(projectedIntents) - ? projectedIntents[service] - : undefined; - if (projectedExactIntent !== "exact") { - errors.push(`${scenario.id}: exact port request ${key} must project exact intent`); - } - } - } - - if (scenario.expected.warning?.code === "RUNNING_STACK_RUNTIME_DRIFT") { - const persistedRuntime = scenario.given.find( - (fact) => fact.kind === "persisted-runtime" && fact.stackId === selection?.stackId, - ); - const configuredRuntime = runtimeRequests.find( - (fact) => fact.source === "config" && fact.runtime !== "auto", - ); - const projectedServices = output.json?.services; - if ( - selection === undefined || - !scenario.given.some( - (fact) => - fact.kind === "stack" && - fact.stackId === selection.stackId && - fact.lifecycle === "running", - ) || - persistedRuntime?.kind !== "persisted-runtime" || - configuredRuntime?.kind !== "runtime-request" || - persistedRuntime.runtime === configuredRuntime.runtime || - output.human?.fields.runtime !== persistedRuntime.runtime || - output.human.fields.configuredRuntime !== configuredRuntime.runtime || - output.human.fields.drift !== "true" || - output.json?.runtime !== persistedRuntime.runtime || - output.json.configured_runtime !== configuredRuntime.runtime || - output.json.drift !== true || - scenario.expected.details?.mixed_runtime !== false || - !isManagedStackContractRecord(projectedServices) || - projectedServices.runtime !== persistedRuntime.runtime - ) { - errors.push( - `${scenario.id}: runtime drift must bind running stack, persisted runtime, config, and projections`, - ); - } - } - - const cliProjectsManagedResult = - scenario.when.interface === "cli" && - scenario.when.argv[0] === "status" && - scenario.given.some((fact) => fact.kind === "managed-api-options"); - if (cliProjectsManagedResult) { - const managedRecord = scenario.given.find( - (fact) => fact.kind === "managed-record" && fact.stackId === selection?.stackId, - ); - const selectedStack = scenario.given.find( - (fact) => fact.kind === "stack" && fact.stackId === selection?.stackId, - ); - const persistedRuntime = scenario.given.find( - (fact) => fact.kind === "persisted-runtime" && fact.stackId === selection?.stackId, - ); - if ( - selection === undefined || - managedRecord?.kind !== "managed-record" || - managedRecord.status !== "active" || - selectedStack?.kind !== "stack" || - selectedStack.lifecycle !== "running" || - persistedRuntime?.kind !== "persisted-runtime" || - scenario.expected.details?.managed_result_projected !== true || - scenario.expected.details.identity_decisions_in_cli !== 0 || - output.human?.fields.stackId !== selection.stackId || - output.human.fields.runtime !== persistedRuntime.runtime || - output.json?.runtime !== persistedRuntime.runtime - ) { - errors.push( - `${scenario.id}: projected managed status requires an active running record and persisted runtime`, - ); - } - } - - if (scenario.expected.error?.code === "RUNTIME_CONFLICTS_WITH_PERSISTED_STACK") { - if (selection === undefined) { - errors.push(`${scenario.id}: persisted runtime conflict requires a selected target`); - } else { - const persistedRuntime = scenario.given.find( - (fact) => fact.kind === "persisted-runtime" && fact.stackId === selection.stackId, - ); - const requestedRuntime = - explicitRuntime === "docker" || explicitRuntime === "native" - ? explicitRuntime - : runtimeRequests.find( - (fact) => - (fact.source === "cli" || fact.source === "managed-api") && - fact.runtime !== "auto", - )?.runtime; - if (persistedRuntime?.kind !== "persisted-runtime") { - errors.push(`${scenario.id}: persisted runtime must belong to the selected target`); - } else if ( - requestedRuntime === undefined || - persistedRuntime.runtime === requestedRuntime || - output.human?.fields.persistedRuntime !== persistedRuntime.runtime || - output.human?.fields.requestedRuntime !== requestedRuntime || - output.json?.persisted_runtime !== persistedRuntime.runtime || - output.json?.requested_runtime !== requestedRuntime || - output.json?.code !== scenario.expected.error.code - ) { - errors.push( - `${scenario.id}: persisted runtime conflict must bind persisted and requested values`, - ); - } - } - } - - if (scenario.expected.error?.code === "RUNTIME_SELECTION_CONFLICT") { - const explicitRequest = runtimeRequests.find( - (fact) => fact.source === "cli" || fact.source === "managed-api", - ); - const configRequest = runtimeRequests.find((fact) => fact.source === "config"); - if ( - explicitRequest?.kind !== "runtime-request" || - configRequest?.kind !== "runtime-request" || - explicitRequest.runtime === "auto" || - configRequest.runtime === "auto" || - explicitRequest.runtime === configRequest.runtime || - output.json?.cli_runtime !== explicitRequest.runtime || - output.json?.config_runtime !== configRequest.runtime || - output.json?.code !== scenario.expected.error.code - ) { - errors.push( - `${scenario.id}: runtime conflict must bind different explicit and configured runtimes`, - ); - } - } - - if ( - scenario.expected.error?.code === "DOCKER_UNAVAILABLE" || - scenario.expected.error?.code === "NATIVE_UNAVAILABLE" - ) { - const unavailableRuntime = scenario.expected.error.code.startsWith("DOCKER") - ? "docker" - : "native"; - const explicitRequest = runtimeRequests.find( - (fact) => - (fact.source === "cli" || fact.source === "managed-api") && - fact.runtime === unavailableRuntime, - ); - const availability = scenario.given.find( - (fact) => fact.kind === "runtime-availability" && fact.runtime === unavailableRuntime, - ); - const fallbackRuntime = unavailableRuntime === "docker" ? "native" : "docker"; - const fallbackAvailability = scenario.given.find( - (fact) => fact.kind === "runtime-availability" && fact.runtime === fallbackRuntime, - ); - if ( - explicitRequest?.kind !== "runtime-request" || - availability?.kind !== "runtime-availability" || - availability.available || - availability.reason === undefined || - fallbackAvailability?.kind !== "runtime-availability" || - !fallbackAvailability.available || - output.json?.requested_runtime !== unavailableRuntime || - output.json?.reason !== availability.reason || - output.json?.fallback_attempted !== false || - scenario.expected.details?.fallback_attempted !== false || - scenario.expected.runtimeEffects.some((effect) => effect.operation === "start") - ) { - errors.push( - `${scenario.id}: explicit runtime error must bind an unavailable requested runtime`, - ); - } - } - - const legacySource = scenario.given.find((fact) => fact.kind === "legacy-state"); - const absentManagedTarget = scenario.given.find( - (fact) => fact.kind === "managed-target" && fact.exists === false, - ); - if ( - scenario.expected.error?.code === "LEGACY_SOURCE_RUNNING" || - (isManagedStartAction(scenario.when) && - legacySource?.kind === "legacy-state" && - legacySource.lifecycle === "running" && - absentManagedTarget?.kind === "managed-target") - ) { - if ( - legacySource?.kind !== "legacy-state" || - legacySource.lifecycle !== "running" || - absentManagedTarget?.kind !== "managed-target" || - scenario.given.some( - (fact) => - fact.kind === "managed-target" && - absentManagedTarget?.kind === "managed-target" && - fact.stackId === absentManagedTarget.stackId && - fact.exists, - ) || - scenario.expected.outcome !== "error" || - scenario.expected.error?.code !== "LEGACY_SOURCE_RUNNING" || - scenario.expected.writes.length > 0 || - scenario.expected.runtimeEffects.length > 0 - ) { - errors.push( - `${scenario.id}: running legacy error requires a running source and absent target`, - ); - } - if ( - scenario.expected.details?.legacy_source_stopped !== false || - scenario.expected.details.managed_target_published !== false || - scenario.expected.details.partial_state !== false || - output.json?.legacy_source_stopped !== false || - output.json.managed_target_published !== false - ) { - errors.push(`${scenario.id}: running legacy error must leave no partial managed target`); - } - - const configuredPort = scenario.given.find( - (fact) => fact.kind === "config-port" && fact.intent === "exact", - ); - const occupiedLegacyPort = scenario.given.find( - (fact) => fact.kind === "occupied-port" && fact.owner === "legacy-stack", - ); - if ( - (configuredPort !== undefined || occupiedLegacyPort !== undefined) && - (configuredPort?.kind !== "config-port" || - typeof configuredPort.value !== "number" || - occupiedLegacyPort?.kind !== "occupied-port" || - occupiedLegacyPort.port !== configuredPort.value || - output.json?.port !== configuredPort.value || - output.json.config_key !== configuredPort.key || - scenario.expected.details?.allocation_attempted !== false || - output.json.allocation_attempted !== false) - ) { - errors.push( - `${scenario.id}: running legacy port failure must bind config, occupancy, and projections`, - ); - } - } - - if (scenario.expected.error?.code === "LEGACY_BOOTSTRAP_FAILED") { - const rollbackStackId = - scenario.when.interface === "managed-api" && scenario.when.method === "startStack" - ? scenario.when.input.stackId - : undefined; - if ( - scenario.when.interface !== "managed-api" || - scenario.when.method !== "startStack" || - scenario.when.input.injectCopyFailure !== true || - !scenario.expected.writes.some( - (write) => - write.target === "managed-state" && - write.operation === "delete" && - write.id === rollbackStackId, - ) || - !scenario.expected.runtimeEffects.some( - (effect) => effect.operation === "delete" && effect.stackId === rollbackStackId, - ) - ) { - errors.push(`${scenario.id}: bootstrap rollback requires enabled copy-failure injection`); - } - if ( - typeof rollbackStackId !== "string" || - !scenario.given.some( - (fact) => - fact.kind === "managed-target" && - fact.stackId === rollbackStackId && - fact.exists === false, - ) || - scenario.given.some((fact) => fact.kind === "stack" && fact.stackId === rollbackStackId) - ) { - errors.push( - `${scenario.id}: bootstrap rollback requires failure injection against an absent target`, - ); - } - const legacySource = scenario.given.find((fact) => fact.kind === "legacy-state"); - if ( - legacySource?.kind !== "legacy-state" || - legacySource.lifecycle !== "stopped" || - legacySource.database !== "compatible" || - legacySource.storage !== "compatible" || - legacySource.credentials !== "compatible" - ) { - errors.push( - `${scenario.id}: bootstrap rollback requires a compatible stopped legacy source`, - ); - } - if ( - scenario.expected.details?.active_target_exists !== false || - scenario.expected.details.registry_record_published !== false || - output.api?.activeTargetExists !== false || - output.api?.registryRecordPublished !== false || - output.api?.retryable !== true || - scenario.expected.writes.some( - (write) => - (write.target === "managed-state" && write.operation !== "delete") || - (write.target === "registry" && write.operation === "publish"), - ) - ) { - errors.push(`${scenario.id}: bootstrap rollback must leave no published partial target`); - } - } - - const destructivelyDeletesManagedState = - scenario.expected.writes.some( - (write) => write.target === "managed-state" && write.operation === "delete", - ) || scenario.expected.runtimeEffects.some((effect) => effect.operation === "delete"); - const nonDestructiveStop = - scenario.when.interface === "cli" && - scenario.when.argv[0] === "stop" && - scenario.expected.outcome === "update" && - scenario.expected.runtimeEffects.some((effect) => effect.operation === "stop") && - !destructivelyDeletesManagedState && - !scenario.expected.writes.some( - (write) => - write.target === "registry" && - (write.operation === "delete" || write.operation === "tombstone"), - ); - if ( - nonDestructiveStop && - (scenario.expected.details?.data_preserved !== true || - scenario.expected.details.registry_record_preserved !== true || - output.human?.fields.dataPreserved !== "true" || - output.json?.data_preserved !== true) - ) { - errors.push(`${scenario.id}: non-destructive stop must report preserved data and registry`); - } - if ( - scenario.expected.details?.legacy_stack_stopped !== undefined || - output.json?.legacy_stack_stopped !== undefined - ) { - const runningLegacyState = scenario.given.find( - (fact) => fact.kind === "legacy-state" && fact.lifecycle === "running", - ); - if ( - !nonDestructiveStop || - runningLegacyState?.kind !== "legacy-state" || - scenario.expected.details?.managed_stack_stopped !== true || - scenario.expected.details.legacy_stack_stopped !== false || - scenario.expected.details.legacy_state_mutated !== false || - output.json?.managed_stack_stopped !== true || - output.json.legacy_stack_stopped !== false - ) { - errors.push( - `${scenario.id}: engine-scoped stop requires a simultaneously running legacy stack`, - ); - } - } - if ( - destructivelyDeletesManagedState && - scenario.when.interface === "cli" && - scenario.when.argv[0] === "stop" && - !scenario.when.argv.includes("--no-backup") - ) { - errors.push(`${scenario.id}: destructive stop requires --no-backup`); - } - - const tombstonedTarget = scenario.given.find( - (fact) => - fact.kind === "managed-record" && - fact.status === "tombstoned" && - fact.stackId === explicitActionStackId, - ); - const claimsIdempotentDeletion = - scenario.expected.details?.idempotent === true || output.json?.already_deleted === true; - if (tombstonedTarget?.kind === "managed-record" || claimsIdempotentDeletion) { - if ( - tombstonedTarget?.kind !== "managed-record" || - scenario.expected.outcome !== "no-op" || - scenario.expected.details?.tombstoned !== true || - scenario.expected.details?.idempotent !== true || - output.json?.tombstoned !== true || - output.json?.already_deleted !== true || - scenario.expected.writes.length > 0 || - scenario.expected.runtimeEffects.length > 0 - ) { - errors.push(`${scenario.id}: idempotent deletion requires a tombstoned target`); - } - } - - const globallyTargetedStack = scenario.given.find( - (fact) => fact.kind === "stack" && fact.stackId === explicitActionStackId, - ); - const isCheckoutIndependentStackDeletion = - scenario.when.interface === "cli" && - scenario.when.argv[0] === "stop" && - scenario.when.argv.includes("--stack-id") && - scenario.expected.outcome === "delete" && - !scenario.given.some((fact) => fact.kind === "checkout" && fact.path === actionCwd); - if (isCheckoutIndependentStackDeletion) { - if ( - typeof explicitActionStackId !== "string" || - globallyTargetedStack?.kind !== "stack" || - globallyTargetedStack.orphaned !== true || - output.json?.orphaned !== true || - output.human?.fields.orphaned !== "true" || - scenario.expected.details?.checkout_required !== false - ) { - errors.push(`${scenario.id}: global orphan deletion requires an orphaned target`); - } - } - - if ( - scenario.given.some((fact) => fact.kind === "credential-state") && - scenario.expected.writes.some( - (write) => - write.target === "managed-state" && - (write.operation === "copy" || - write.operation === "create" || - write.operation === "update"), - ) && - scenario.expected.details?.plaintext_secrets_in_global_state !== false - ) { - errors.push(`${scenario.id}: credential persistence must not expose plaintext globally`); - } - - const changedCredentials = scenario.given.find( - (fact) => fact.kind === "credential-state" && fact.previousValuesId !== undefined, - ); - const projectedCredentialReference = scenario.expected.details?.credential_values_id; - if ( - typeof projectedCredentialReference === "string" && - scenario.given.some((fact) => fact.kind === "credential-state") - ) { - const credentialState = scenario.given.find( - (fact) => - fact.kind === "credential-state" && fact.valuesId === projectedCredentialReference, - ); - const projectedSources = [ - scenario.expected.details?.source, - output.api?.credentialsSource, - output.json?.credentials_source, - ].filter((source) => source !== undefined); - if ( - credentialState?.kind !== "credential-state" || - (output.api?.credentialsValuesId !== undefined && - output.api.credentialsValuesId !== credentialState.valuesId) || - (output.json?.credentials_values_id !== undefined && - output.json.credentials_values_id !== credentialState.valuesId) || - projectedSources.some((source) => source !== credentialState.source) - ) { - errors.push( - `${scenario.id}: persisted credential reference and source must match declared values`, - ); - } - } - const stableLocalCredentials = scenario.given.find( - (fact) => fact.kind === "credential-state" && fact.source === "local-default", - ); - const projectsStableLocalCredentials = - scenario.expected.details?.generated_per_start !== undefined || - output.json?.credentials_source === "local-default" || - output.json?.credentials_stable !== undefined; - if ( - (stableLocalCredentials?.kind === "credential-state" || projectsStableLocalCredentials) && - (stableLocalCredentials?.kind !== "credential-state" || - scenario.expected.details?.credential_values_id !== stableLocalCredentials.valuesId || - scenario.expected.details.generated_per_start !== false || - output.json?.credentials_source !== "local-default" || - output.json?.credentials_stable !== true) - ) { - errors.push(`${scenario.id}: omitted credentials must reuse stable local defaults`); - } - const persistedCredentials = scenario.given.find( - (fact) => fact.kind === "credential-state" && fact.source === "persisted", - ); - if ( - persistedCredentials?.kind === "credential-state" && - (scenario.expected.details?.credential_values_id !== persistedCredentials.valuesId || - scenario.expected.details.credentials_rotated !== false || - output.json?.credentials_unchanged !== true) - ) { - errors.push(`${scenario.id}: persisted credentials must survive restart unchanged`); - } - if ( - changedCredentials?.kind === "credential-state" && - changedCredentials.previousValuesId === changedCredentials.valuesId - ) { - errors.push(`${scenario.id}: credential change requires different old and new values`); - } - const configuredCredentialChange = - changedCredentials?.kind === "credential-state" && changedCredentials.source === "configured" - ? changedCredentials - : undefined; - const selectedStoppedStack = scenario.given.find( - (fact) => - fact.kind === "stack" && - fact.stackId === selection?.stackId && - fact.lifecycle === "stopped", - ); - const projectsCredentialUpdate = - scenario.expected.outcome === "update" && - (output.json?.previous_credentials_values_id !== undefined || - output.json?.credentials_values_id !== undefined); - const appliesConfiguredCredentialChange = - configuredCredentialChange !== undefined && selectedStoppedStack?.kind === "stack"; - if (projectsCredentialUpdate && configuredCredentialChange === undefined) { - errors.push(`${scenario.id}: credential change requires configured old and new values`); - } - if ( - (projectsCredentialUpdate || appliesConfiguredCredentialChange) && - configuredCredentialChange !== undefined && - (selectedStoppedStack?.kind !== "stack" || - scenario.expected.outcome !== "update" || - output.json?.previous_credentials_values_id !== - configuredCredentialChange.previousValuesId || - output.json?.credentials_values_id !== configuredCredentialChange.valuesId || - !scenario.expected.writes.some( - (write) => write.target === "managed-state" && write.operation === "update", - )) - ) { - errors.push(`${scenario.id}: credential update must bind old and new persisted references`); - } - - if (scenario.expected.warning?.code === "RUNNING_STACK_CREDENTIALS_DRIFT") { - if ( - selection === undefined || - changedCredentials?.kind !== "credential-state" || - !scenario.given.some( - (fact) => - fact.kind === "stack" && - fact.stackId === selection.stackId && - fact.lifecycle === "running", - ) || - scenario.expected.outcome !== "report" || - scenario.expected.writes.length > 0 || - scenario.expected.runtimeEffects.length > 0 || - output.json?.stack_id !== selection.stackId || - output.json?.drift !== true || - output.human?.fields.stackId !== selection.stackId || - output.human?.fields.drift !== "true" - ) { - errors.push(`${scenario.id}: credential drift report requires a running selected stack`); - } - } - - const legacyCredentials = scenario.given.find( - (fact) => fact.kind === "credential-state" && fact.source === "legacy", - ); - const copiesManagedCredentials = scenario.expected.writes.some( - (write) => write.target === "managed-state" && write.operation === "copy", - ); - if ( - copiesManagedCredentials && - (legacyCredentials?.kind === "credential-state" || - typeof scenario.expected.details?.credential_values_id === "string" || - typeof output.api?.credentialsValuesId === "string") && - (legacyCredentials?.kind !== "credential-state" || - scenario.expected.details?.credential_values_id !== legacyCredentials.valuesId || - output.api?.credentialsValuesId !== legacyCredentials.valuesId) - ) { - errors.push(`${scenario.id}: copied legacy credentials must bind their persisted reference`); - } - - if (scenario.expected.details?.retry_after_rollback === true) { - const retryStackId = - scenario.when.interface === "managed-api" && scenario.when.method === "startStack" - ? scenario.when.input.stackId - : undefined; - if ( - typeof retryStackId !== "string" || - !scenario.given.some( - (fact) => - fact.kind === "operation-result" && - fact.operation === "legacy-bootstrap" && - fact.stackId === retryStackId && - fact.outcome === "rolled-back", - ) - ) { - errors.push(`${scenario.id}: bootstrap retry requires a rolled-back prior attempt`); - } - } - - if ( - scenario.when.interface === "cli" && - scenario.when.argv[0] === "stack" && - scenario.when.argv[1] === "prune" - ) { - if ( - scenario.expected.details?.mutable_data_deleted !== false || - output.human?.fields.dataDeleted !== "false" || - output.json?.mutable_data_deleted !== false || - scenario.expected.writes.some((write) => write.target === "managed-state") || - scenario.expected.runtimeEffects.some((effect) => effect.operation === "delete") - ) { - errors.push(`${scenario.id}: prune must preserve mutable stack data`); - } - for (const write of scenario.expected.writes) { - if (write.target !== "registry" || write.operation !== "delete") { - continue; - } - const mutableDataExists = scenario.given.some( - (fact) => - (fact.kind === "stack" && fact.stackId === write.id) || - (fact.kind === "managed-target" && fact.stackId === write.id && fact.exists), - ); - if (!mutableDataExists) { - errors.push(`${scenario.id}: data-preserving prune must declare mutable stack data`); - } - const orphanedRecord = scenario.given.some( - (fact) => - fact.kind === "managed-record" && - fact.stackId === write.id && - fact.status === "orphaned", - ); - const orphanedStack = scenario.given.some( - (fact) => fact.kind === "stack" && fact.stackId === write.id && fact.orphaned === true, - ); - if (!orphanedRecord || !orphanedStack) { - errors.push(`${scenario.id}: prune may delete only orphaned registry metadata`); - } - } - const deletedRecordIds = scenario.expected.writes.flatMap((write) => - write.target === "registry" && write.operation === "delete" ? [write.id] : [], - ); - const projectedRecords = output.json?.pruned_records; - const projectedRecordIds = - Array.isArray(projectedRecords) && - projectedRecords.every((record): record is string => typeof record === "string") - ? projectedRecords - : undefined; - const deletedRecordCount = deletedRecordIds.length; - const expectedSummary = `Pruned ${deletedRecordCount} orphaned metadata record${deletedRecordCount === 1 ? "" : "s"}`; - if ( - deletedRecordCount === 0 || - projectedRecordIds === undefined || - !managedStackContractStringSetEquals(deletedRecordIds, projectedRecordIds) || - output.json?.pruned_count !== deletedRecordCount || - output.human?.summary !== expectedSummary - ) { - errors.push(`${scenario.id}: prune projections must match deleted registry records`); - } - } - - const activeBranchName = - actionGitState?.kind === "git-state" && actionGitState.head === "branch" - ? actionGitState.branch - : checkedOutBranch?.kind === "branch" - ? checkedOutBranch.name - : undefined; - - for (const write of scenario.expected.writes) { - if (write.target !== "git-config") { - continue; - } - if (write.operation === "create" && existingCheckoutIdentityIds.has(write.id)) { - errors.push(`${scenario.id}: Git identity ${write.id} is already declared`); - } - const expectedScope = projectIds.has(write.id) - ? "common" - : checkoutIds.has(write.id) || contextIds.has(write.id) - ? "worktree" - : undefined; - if (expectedScope !== undefined && write.scope !== expectedScope) { - errors.push( - `${scenario.id}: Git identity ${write.id} must use ${expectedScope} config scope`, - ); - } - if (contextIds.has(write.id)) { - if (write.owner === undefined) { - errors.push(`${scenario.id}: Git context ${write.id} must declare its branch owner`); - } else if (activeBranchName !== undefined && write.owner !== activeBranchName) { - errors.push( - `${scenario.id}: Git context ${write.id} must belong to branch ${activeBranchName}`, - ); - } - } - } - - for (const effect of scenario.expected.runtimeEffects) { - if (!declaredIds.has(effect.stackId)) { - errors.push(`${scenario.id}: runtime effect references undeclared ID ${effect.stackId}`); - } - - if ( - effect.operation === "stop" && - !scenario.given.some( - (fact) => - fact.kind === "stack" && - fact.stackId === effect.stackId && - fact.lifecycle === "running", - ) - ) { - errors.push( - `${scenario.id}: stopping stack ${effect.stackId} requires a running lifecycle`, - ); - } - - const hasWrite = scenario.expected.writes.some((write) => { - if (write.id !== effect.stackId) { - return false; - } - switch (effect.operation) { - case "copy": - return write.target === "managed-state" && write.operation === "copy"; - case "delete": - return write.target === "managed-state" && write.operation === "delete"; - case "start": - return write.target === "runtime-state" && write.operation === "start"; - case "stop": - return ( - write.target === "runtime-state" && - (write.operation === "delete" || write.operation === "update") - ); - } - }); - if (!hasWrite) { - errors.push( - `${scenario.id}: ${effect.operation} runtime effect requires a matching state write`, - ); - } - } - - for (const write of scenario.expected.writes) { - if ( - write.target === "managed-state" && - (write.operation === "copy" || write.operation === "create") && - !scenario.expected.writes.some( - (candidate) => - candidate.target === "registry" && - candidate.operation === "publish" && - candidate.id === write.id, - ) - ) { - errors.push( - `${scenario.id}: managed-state ${write.operation} requires registry publication`, - ); - } - - if ( - write.target === "registry" && - write.operation === "publish" && - !scenario.expected.writes.some( - (candidate) => - candidate.target === "managed-state" && - (candidate.operation === "create" || candidate.operation === "copy") && - candidate.id === write.id, - ) - ) { - errors.push(`${scenario.id}: registry publication requires managed-state creation or copy`); - } - - if ( - scenario.expected.outcome === "delete" && - write.target === "managed-state" && - write.operation === "delete" && - !scenario.expected.writes.some( - (candidate) => - candidate.target === "registry" && - candidate.operation === "tombstone" && - candidate.id === write.id, - ) - ) { - errors.push(`${scenario.id}: managed-state deletion requires a registry tombstone`); - } - - if ( - write.target === "registry" && - write.operation === "tombstone" && - !scenario.expected.writes.some( - (candidate) => - candidate.target === "managed-state" && - candidate.operation === "delete" && - candidate.id === write.id, - ) - ) { - errors.push(`${scenario.id}: registry tombstone requires managed-state deletion`); - } - if ( - write.target === "registry" && - write.operation === "tombstone" && - (scenario.expected.details?.tombstoned !== true || - (output.json !== undefined && output.json.tombstoned !== true) || - (output.human !== undefined && output.human.fields.tombstoned !== "true")) - ) { - errors.push(`${scenario.id}: registry tombstone must be reported by every projection`); - } - if ( - write.target === "registry" && - write.operation === "delete" && - scenario.expected.details?.metadata_removed !== true - ) { - errors.push(`${scenario.id}: registry deletion must report removed metadata`); - } - - const requiredRuntimeOperation = - write.target === "runtime-state" && write.operation === "start" - ? "start" - : write.target === "runtime-state" && - (write.operation === "delete" || write.operation === "update") - ? "stop" - : write.target === "managed-state" && write.operation === "copy" - ? "copy" - : write.target === "managed-state" && write.operation === "delete" - ? "delete" - : undefined; - if ( - requiredRuntimeOperation !== undefined && - !scenario.expected.runtimeEffects.some( - (effect) => effect.operation === requiredRuntimeOperation && effect.stackId === write.id, - ) - ) { - errors.push( - `${scenario.id}: ${write.target} ${write.operation} requires a matching runtime effect`, - ); - } - } - - const checkProjection = ( - projection: Readonly> | undefined, - key: string, - expected: ManagedStackContractJson, - ): void => { - if (projection?.[key] !== undefined && projection[key] !== expected) { - errors.push(`${scenario.id}: projected ${key} disagrees with the managed result`); - } - }; - - if ( - scenario.expected.output.json !== undefined && - scenario.expected.output.json.outcome === undefined - ) { - errors.push(`${scenario.id}: JSON projection requires an outcome`); - } - const structuredCode = scenario.expected.error?.code ?? scenario.expected.warning?.code; - if ( - scenario.expected.output.json !== undefined && - structuredCode !== undefined && - scenario.expected.output.json.code === undefined - ) { - errors.push(`${scenario.id}: JSON projection requires a code`); - } - for (const projection of [scenario.expected.output.json, scenario.expected.output.api]) { - checkProjection(projection, "outcome", scenario.expected.outcome); - if (scenario.expected.error !== undefined) { - checkProjection(projection, "code", scenario.expected.error.code); - } - if (scenario.expected.warning !== undefined) { - checkProjection(projection, "code", scenario.expected.warning.code); - } - } - if (selection !== undefined) { - checkProjection(scenario.expected.output.json, "project_id", selection.projectId); - checkProjection(scenario.expected.output.json, "checkout_id", selection.checkoutId); - checkProjection(scenario.expected.output.json, "context_id", selection.contextId); - checkProjection(scenario.expected.output.json, "stack_id", selection.stackId); - checkProjection(scenario.expected.output.json, "stack_name", selection.stackName); - checkProjection(scenario.expected.output.api, "projectId", selection.projectId); - checkProjection(scenario.expected.output.api, "checkoutId", selection.checkoutId); - checkProjection(scenario.expected.output.api, "contextId", selection.contextId); - checkProjection(scenario.expected.output.api, "stackId", selection.stackId); - checkProjection(scenario.expected.output.api, "stackName", selection.stackName); - checkProjection(scenario.expected.output.human?.fields, "projectId", selection.projectId); - checkProjection(scenario.expected.output.human?.fields, "checkoutId", selection.checkoutId); - checkProjection(scenario.expected.output.human?.fields, "contextId", selection.contextId); - checkProjection(scenario.expected.output.human?.fields, "stackId", selection.stackId); - checkProjection(scenario.expected.output.human?.fields, "stack", selection.stackName); - checkProjection(scenario.expected.output.human?.fields, "stackName", selection.stackName); - - const selectedBranch = scenario.given.find( - (fact) => - fact.kind === "branch" && fact.checkedOut && fact.contextId === selection.contextId, - ); - if (selectedBranch?.kind === "branch") { - checkProjection(scenario.expected.output.human?.fields, "branch", selectedBranch.name); - } - } - - const expectedRecovery = - scenario.expected.error?.recovery ?? scenario.expected.warning?.recovery; - const humanOutput = scenario.expected.output.human; - if ( - humanOutput !== undefined && - expectedRecovery !== undefined && - (humanOutput.recovery === undefined || - humanOutput.recovery.length !== expectedRecovery.length || - humanOutput.recovery.some((step, index) => step !== expectedRecovery[index])) - ) { - errors.push(`${scenario.id}: human recovery disagrees with the managed result`); - } - const jsonOutput = scenario.expected.output.json; - const jsonRecovery = jsonOutput?.recovery; - if ( - jsonOutput !== undefined && - expectedRecovery !== undefined && - (!Array.isArray(jsonRecovery) || - jsonRecovery.length !== expectedRecovery.length || - jsonRecovery.some((step, index) => step !== expectedRecovery[index])) - ) { - errors.push(`${scenario.id}: JSON recovery disagrees with the managed result`); - } - } - - return errors; -}; - const branchHistoryFixture = ( label: "commit" | "rebase" | "reset", operation: "branch-commit" | "branch-rebase" | "branch-reset", @@ -7579,6 +3659,38 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ }, }, }, + { + id: "credentials.plaintext-secrets-stay-out-of-global-state", + title: "Resolved plaintext secrets are absent from the global managed registry", + area: "credentials", + given: [ + { + kind: "credential-state", + source: "persisted", + valuesId: "configured-auth-v1", + plaintextPresentInGlobalState: false, + }, + { kind: "managed-record", stackId: "stack-main-default", status: "active" }, + ], + when: { + interface: "managed-api", + method: "inspectGlobalRecord", + input: { stackId: "stack-main-default" }, + }, + expected: { + outcome: "report", + writes: [], + runtimeEffects: [], + details: { plaintext_secrets_present: false }, + output: { + api: { + stackId: "stack-main-default", + credentialsReference: "configured-auth-v1", + plaintextSecrets: [], + }, + }, + }, + }, { id: "reclamation.default-stop-preserves-data", title: "Default experimental stop preserves managed data", diff --git a/packages/stack/src/testing.ts b/packages/stack/src/testing.ts index b820d09e61..689fa9e05e 100644 --- a/packages/stack/src/testing.ts +++ b/packages/stack/src/testing.ts @@ -14,6 +14,6 @@ export type { export { managedNativeServiceMatrix, managedStackContractFixtures, - validateManagedStackContractFixtures, } from "./managed-stack-contract.ts"; +export { validateManagedStackContractFixtures } from "./managed-stack-contract-validation.ts"; export { UnixHttpClient } from "./UnixHttpClient.ts"; From 2129033ca1d8460c27aa8bdf092e5196f0a2223b Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 11 Aug 2026 07:59:09 +0200 Subject: [PATCH 34/41] test(stack): tighten fixture data hygiene --- .../src/managed-stack-contract-validation.ts | 8 +++++ ...managed-stack-contract.integration.test.ts | 30 +++++++++++++++++++ packages/stack/src/managed-stack-contract.ts | 18 +++++++---- 3 files changed, 51 insertions(+), 5 deletions(-) diff --git a/packages/stack/src/managed-stack-contract-validation.ts b/packages/stack/src/managed-stack-contract-validation.ts index 1d1f3c3608..a81afb3a2c 100644 --- a/packages/stack/src/managed-stack-contract-validation.ts +++ b/packages/stack/src/managed-stack-contract-validation.ts @@ -139,6 +139,10 @@ export const validateManagedStackContractFixtures = ( } for (const write of scenario.expected.writes) { + if (write.id.trim().length === 0) { + errors.push(`${scenario.id}: write ID is required`); + continue; + } if ( write.operation === "copy" || write.operation === "create" || @@ -180,6 +184,10 @@ export const validateManagedStackContractFixtures = ( } for (const effect of scenario.expected.runtimeEffects) { + if (effect.stackId.trim().length === 0) { + errors.push(`${scenario.id}: runtime effect stack ID is required`); + continue; + } if (!declaredIds.has(effect.stackId)) { errors.push(`${scenario.id}: runtime effect references undeclared ID ${effect.stackId}`); } diff --git a/packages/stack/src/managed-stack-contract.integration.test.ts b/packages/stack/src/managed-stack-contract.integration.test.ts index c553483320..ea95ff76b7 100644 --- a/packages/stack/src/managed-stack-contract.integration.test.ts +++ b/packages/stack/src/managed-stack-contract.integration.test.ts @@ -119,6 +119,36 @@ describe("managed stack acceptance contract", () => { fixtures: [reuse, reuse], expectedError: `${reuse.id}: duplicate scenario ID`, }, + { + fixtures: [ + { + ...reuse, + expected: { + ...reuse.expected, + writes: [ + ...reuse.expected.writes, + { target: "registry", operation: "publish", id: "" }, + ], + }, + }, + ], + expectedError: `${reuse.id}: write ID is required`, + }, + { + fixtures: [ + { + ...reuse, + expected: { + ...reuse.expected, + runtimeEffects: [ + ...reuse.expected.runtimeEffects, + { operation: "start", stackId: "" }, + ], + }, + }, + ], + expectedError: `${reuse.id}: runtime effect stack ID is required`, + }, ]; for (const testCase of cases) { diff --git a/packages/stack/src/managed-stack-contract.ts b/packages/stack/src/managed-stack-contract.ts index f9166cc06f..198f497f79 100644 --- a/packages/stack/src/managed-stack-contract.ts +++ b/packages/stack/src/managed-stack-contract.ts @@ -2733,12 +2733,16 @@ const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], details: { resolved_runtime: "native", - qualified_service_count: 13, + qualified_service_count: nativeServiceNames.length, mixed_runtime: false, persisted: true, }, output: { - api: { stackId: "stack-main-default", runtime: "native", qualifiedServiceCount: 13 }, + api: { + stackId: "stack-main-default", + runtime: "native", + qualifiedServiceCount: nativeServiceNames.length, + }, }, }, }, @@ -2992,7 +2996,7 @@ const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ }, { id: "native-qualification.all-services-qualify-platform", - title: "A platform is native-supported only when all 13 services qualify", + title: `A platform is native-supported only when all ${nativeServiceNames.length} services qualify`, area: "native-qualification", given: [ { @@ -3011,7 +3015,11 @@ const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ outcome: "report", writes: [], runtimeEffects: [], - details: { qualified: true, qualified_service_count: 13, failed_service_count: 0 }, + details: { + qualified: true, + qualified_service_count: nativeServiceNames.length, + failed_service_count: 0, + }, output: { api: { platform: "darwin-arm64", qualified: true, services: nativeServiceNames } }, }, }, @@ -3043,7 +3051,7 @@ const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ runtimeEffects: [], details: { qualified: false, - qualified_service_count: 12, + qualified_service_count: nativeServiceNames.length - 1, failed_service_count: 1, reduced_graph: false, docker_fallback_per_service: false, From f47cf7fc948e85cc2cc9df2f55f2c74c13330583 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 11 Aug 2026 08:32:18 +0200 Subject: [PATCH 35/41] test(stack): complete fixture boundary coverage --- .../src/managed-stack-contract-validation.ts | 80 ++++++--- ...managed-stack-contract.integration.test.ts | 152 +++++++++++++++++- packages/stack/src/managed-stack-contract.ts | 9 +- 3 files changed, 210 insertions(+), 31 deletions(-) diff --git a/packages/stack/src/managed-stack-contract-validation.ts b/packages/stack/src/managed-stack-contract-validation.ts index a81afb3a2c..f86adce0d6 100644 --- a/packages/stack/src/managed-stack-contract-validation.ts +++ b/packages/stack/src/managed-stack-contract-validation.ts @@ -1,3 +1,4 @@ +import { isDeepStrictEqual } from "node:util"; import type { ManagedStackContractJson, ManagedStackContractScenario, @@ -61,17 +62,29 @@ export const validateManagedStackContractFixtures = ( } for (const diagnostic of [scenario.expected.error, scenario.expected.warning]) { - if (diagnostic !== undefined && !/^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*$/.test(diagnostic.code)) { + if (diagnostic === undefined) { + continue; + } + if (!/^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*$/.test(diagnostic.code)) { errors.push( `${scenario.id}: diagnostic code ${diagnostic.code} must use SCREAMING_SNAKE_CASE`, ); } + if (diagnostic.message.trim().length === 0) { + errors.push(`${scenario.id}: diagnostic message is required`); + } + if (diagnostic.recovery.some((step) => step.trim().length === 0)) { + errors.push(`${scenario.id}: diagnostic recovery steps must not be blank`); + } } const hasMutation = scenario.expected.writes.length > 0 || scenario.expected.runtimeEffects.length > 0; - if (scenario.expected.outcome === "report" && hasMutation) { - errors.push(`${scenario.id}: report outcome must not mutate state`); + if ( + (scenario.expected.outcome === "report" || scenario.expected.outcome === "no-op") && + hasMutation + ) { + errors.push(`${scenario.id}: ${scenario.expected.outcome} outcome must not mutate state`); } if (scenario.expected.outcome === "error" && hasMutation) { const isBootstrapRollback = scenario.expected.error?.code === "LEGACY_BOOTSTRAP_FAILED"; @@ -85,53 +98,60 @@ export const validateManagedStackContractFixtures = ( } const declaredIds = new Set(); + const declareId = (id: string): void => { + if (id.trim().length === 0) { + errors.push(`${scenario.id}: declared ID is required`); + } else { + declaredIds.add(id); + } + }; for (const fact of scenario.given) { switch (fact.kind) { case "branch": - declaredIds.add(fact.contextId); + declareId(fact.contextId); break; case "checkout": - declaredIds.add(fact.projectId); - declaredIds.add(fact.checkoutId); + declareId(fact.projectId); + declareId(fact.checkoutId); break; case "credential-state": - declaredIds.add(fact.valuesId); + declareId(fact.valuesId); if (fact.previousValuesId !== undefined) { - declaredIds.add(fact.previousValuesId); + declareId(fact.previousValuesId); } break; case "direct-stack-state": - declaredIds.add(fact.handle); + declareId(fact.handle); for (const root of fact.temporaryRoots) { - declaredIds.add(root.stateId); + declareId(root.stateId); } break; case "identity-claim": - declaredIds.add(fact.id); + declareId(fact.id); break; case "identity-marker": - declaredIds.add(fact.markerId); - declaredIds.add(fact.projectId); - declaredIds.add(fact.checkoutId); - declaredIds.add(fact.contextId); + declareId(fact.markerId); + declareId(fact.projectId); + declareId(fact.checkoutId); + declareId(fact.contextId); break; case "managed-record": case "managed-target": case "operation-result": case "persisted-runtime": - declaredIds.add(fact.stackId); + declareId(fact.stackId); break; case "occupied-port": if (fact.ownerId !== undefined) { - declaredIds.add(fact.ownerId); + declareId(fact.ownerId); } break; case "port-assignment": - declaredIds.add(fact.stackId); + declareId(fact.stackId); break; case "stack": - declaredIds.add(fact.contextId); - declaredIds.add(fact.stackId); + declareId(fact.contextId); + declareId(fact.stackId); break; default: break; @@ -148,13 +168,13 @@ export const validateManagedStackContractFixtures = ( write.operation === "create" || write.operation === "publish" ) { - declaredIds.add(write.id); + declareId(write.id); } if (write.target === "identity-marker") { - declaredIds.add(write.projectId); - declaredIds.add(write.checkoutId); - declaredIds.add(write.contextId); + declareId(write.projectId); + declareId(write.checkoutId); + declareId(write.contextId); } } for (const write of scenario.expected.writes) { @@ -246,7 +266,7 @@ export const validateManagedStackContractFixtures = ( key: string, expected: ManagedStackContractJson, ): void => { - if (projection?.[key] !== undefined && projection[key] !== expected) { + if (projection?.[key] !== undefined && !isDeepStrictEqual(projection[key], expected)) { errors.push(`${scenario.id}: projected ${key} disagrees with the managed result`); } }; @@ -268,6 +288,16 @@ export const validateManagedStackContractFixtures = ( checkProjection(projection, "code", diagnosticCode); } } + const jsonShape = (value: ManagedStackContractJson): string => + value === null ? "null" : Array.isArray(value) ? "array" : typeof value; + for (const [key, value] of Object.entries(scenario.expected.details ?? {})) { + for (const projection of [output.json, output.api]) { + const projectedValue = projection?.[key]; + if (projectedValue !== undefined && jsonShape(projectedValue) === jsonShape(value)) { + checkProjection(projection, key, value); + } + } + } const selection = scenario.expected.selection; if (selection !== undefined) { diff --git a/packages/stack/src/managed-stack-contract.integration.test.ts b/packages/stack/src/managed-stack-contract.integration.test.ts index ea95ff76b7..417ad5907d 100644 --- a/packages/stack/src/managed-stack-contract.integration.test.ts +++ b/packages/stack/src/managed-stack-contract.integration.test.ts @@ -17,6 +17,7 @@ import { type ManagedStackContractScenario, validateManagedStackContractFixtures, } from "./testing.ts"; +import { shortTempPrefixRoot } from "./paths.ts"; import { DEFAULT_VERSIONS, SERVICE_NAMES } from "./versions.ts"; describe("managed stack acceptance contract", () => { @@ -38,18 +39,22 @@ describe("managed stack acceptance contract", () => { const reuse = findScenario("identity.return-to-branch-reuses-stack"); const portConflict = findScenario("ports.explicit-port-conflict-fails"); const readOnly = findScenario("identity.branch-copy-read-only-does-not-write"); + const noOp = findScenario("reclamation.delete-repeat-is-idempotent"); + const freshBootstrap = findScenario("bootstrap.absent-legacy-starts-fresh"); if ( reuse.expected.selection === undefined || reuse.expected.output.json === undefined || portConflict.expected.error === undefined || - portConflict.expected.output.json === undefined + portConflict.expected.output.json === undefined || + freshBootstrap.expected.details === undefined || + freshBootstrap.expected.output.json === undefined ) { throw new Error("lint examples require selected and structured fixture outputs"); } const cases: ReadonlyArray<{ readonly fixtures: ReadonlyArray; - readonly expectedError: string; + readonly expectedError: string | ReadonlyArray; }> = [ { fixtures: [ @@ -149,12 +154,84 @@ describe("managed stack acceptance contract", () => { ], expectedError: `${reuse.id}: runtime effect stack ID is required`, }, + { + fixtures: [ + { + ...reuse, + given: reuse.given.map((fact) => + fact.kind === "checkout" ? { ...fact, projectId: "" } : fact, + ), + expected: { + ...reuse.expected, + selection: { ...reuse.expected.selection, projectId: "" }, + output: { + ...reuse.expected.output, + json: { ...reuse.expected.output.json, project_id: "" }, + }, + }, + }, + ], + expectedError: `${reuse.id}: declared ID is required`, + }, + { + fixtures: [ + { + ...freshBootstrap, + expected: { + ...freshBootstrap.expected, + output: { + ...freshBootstrap.expected.output, + json: { + ...freshBootstrap.expected.output.json, + legacy_state_mutated: true, + }, + }, + }, + }, + ], + expectedError: `${freshBootstrap.id}: projected legacy_state_mutated disagrees with the managed result`, + }, + { + fixtures: [ + { + ...portConflict, + expected: { + ...portConflict.expected, + error: { + ...portConflict.expected.error, + message: " ", + recovery: [" "], + }, + }, + }, + ], + expectedError: [ + `${portConflict.id}: diagnostic message is required`, + `${portConflict.id}: diagnostic recovery steps must not be blank`, + ], + }, + { + fixtures: [ + { + ...noOp, + expected: { + ...noOp.expected, + writes: [{ target: "registry", operation: "update", id: "stack-orphan" }], + }, + }, + ], + expectedError: `${noOp.id}: no-op outcome must not mutate state`, + }, ]; for (const testCase of cases) { - expect(validateManagedStackContractFixtures(testCase.fixtures)).toContain( - testCase.expectedError, - ); + const expectedErrors = + typeof testCase.expectedError === "string" + ? [testCase.expectedError] + : testCase.expectedError; + for (const expectedError of expectedErrors) { + expect(validateManagedStackContractFixtures(testCase.fixtures)).toContain(expectedError); + } } }); @@ -186,6 +263,8 @@ describe("managed stack acceptance contract", () => { "identity.inaccessible-previous-path-fails", "identity.invalid-stack-name-leading-hyphen-fails", "identity.invalid-stack-name-repeated-dot-fails", + "identity.invalid-stack-name-too-long-fails", + "identity.invalid-stack-name-trailing-hyphen-fails", "identity.invalid-stack-name-uppercase-underscore-fails", "identity.linked-worktrees-share-project-not-checkout", "identity.manual-ref-replacement-orphans-context", @@ -287,6 +366,14 @@ describe("managed stack acceptance contract", () => { action: ["start", "--experimental", "--stack", "review..two"], names: ["review..two"], }, + { + action: ["start", "--experimental", "--stack", "review-"], + names: ["review-"], + }, + { + action: ["start", "--experimental", "--stack", "a".repeat(64)], + names: ["a".repeat(64)], + }, ]); }); @@ -478,6 +565,61 @@ describe("managed stack acceptance contract", () => { } }); + it("keeps explicitly supplied state roots while disposing each omitted root independently", async () => { + const explicitRootKinds: ReadonlyArray<"runtime" | "stack"> = ["stack", "runtime"]; + + for (const explicitRootKind of explicitRootKinds) { + const testRoot = mkdtempSync(join(tmpdir(), "supabase-partial-root-contract-")); + const projectDir = join(testRoot, "project"); + const cacheRoot = join(testRoot, "cache"); + const explicitRoot = join(testRoot, `${explicitRootKind}-root`); + const sentinel = join(explicitRoot, "caller-owned"); + const generatedPrefix = explicitRootKind === "stack" ? "sb-run-" : "sb-stack-"; + const temporaryRoots = (): ReadonlySet => + new Set( + readdirSync(shortTempPrefixRoot()) + .filter((name) => name.startsWith(generatedPrefix)) + .map((name) => join(shortTempPrefixRoot(), name)), + ); + + mkdirSync(projectDir, { recursive: true }); + mkdirSync(cacheRoot, { recursive: true }); + mkdirSync(explicitRoot, { recursive: true }); + writeFileSync(sentinel, "caller-owned\n"); + + try { + const before = temporaryRoots(); + const explicitConfig = + explicitRootKind === "stack" + ? { stackRoot: explicitRoot } + : { runtimeRoot: explicitRoot }; + const stack = await createStack({ + cacheRoot, + projectDir, + startupMode: "lazy", + ...explicitConfig, + }); + let stableCandidates: ReadonlyArray = []; + + try { + const candidates = [...temporaryRoots()].filter((root) => !before.has(root)); + await new Promise((resolve) => setTimeout(resolve, 50)); + stableCandidates = candidates.filter((root) => existsSync(root)); + expect(stableCandidates.length).toBeGreaterThanOrEqual(1); + } finally { + await stack.dispose(); + } + + const removedRoots = stableCandidates.filter((root) => !existsSync(root)); + expect(removedRoots).toHaveLength(1); + expect(readFileSync(sentinel, "utf8")).toBe("caller-owned\n"); + expect(existsSync(explicitRoot)).toBe(true); + } finally { + rmSync(testRoot, { recursive: true, force: true }); + } + } + }); + it("reuses the existing stack when a developer returns to a branch", () => { const scenario = managedStackContractFixtures.find( ({ id }) => id === "identity.return-to-branch-reuses-stack", diff --git a/packages/stack/src/managed-stack-contract.ts b/packages/stack/src/managed-stack-contract.ts index 198f497f79..5ef7a3e052 100644 --- a/packages/stack/src/managed-stack-contract.ts +++ b/packages/stack/src/managed-stack-contract.ts @@ -411,7 +411,12 @@ const branchHistoryFixture = ( }); const invalidStackNameFixture = ( - label: "leading-hyphen" | "repeated-dot" | "uppercase-underscore", + label: + | "leading-hyphen" + | "repeated-dot" + | "too-long" + | "trailing-hyphen" + | "uppercase-underscore", stackName: string, ): ManagedStackContractScenario => ({ id: `identity.invalid-stack-name-${label}-fails`, @@ -1524,6 +1529,8 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ invalidStackNameFixture("uppercase-underscore", "Feature_A"), invalidStackNameFixture("leading-hyphen", "-review"), invalidStackNameFixture("repeated-dot", "review..two"), + invalidStackNameFixture("trailing-hyphen", "review-"), + invalidStackNameFixture("too-long", "a".repeat(64)), { id: "identity.valid-stack-names-resolve-deterministically", title: "Default and lowercase DNS-label stack names resolve deterministically", From 3fc14f6f1f3dc04f6cdeee2f96773ba6be203b20 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 11 Aug 2026 08:47:34 +0200 Subject: [PATCH 36/41] test(stack): tighten contract fixture lint --- .../src/managed-stack-contract-validation.ts | 54 +++++++++++---- ...managed-stack-contract.integration.test.ts | 69 ++++++++++++++++++- packages/stack/src/managed-stack-contract.ts | 11 +-- 3 files changed, 116 insertions(+), 18 deletions(-) diff --git a/packages/stack/src/managed-stack-contract-validation.ts b/packages/stack/src/managed-stack-contract-validation.ts index f86adce0d6..c167c88bb7 100644 --- a/packages/stack/src/managed-stack-contract-validation.ts +++ b/packages/stack/src/managed-stack-contract-validation.ts @@ -1,5 +1,6 @@ import { isDeepStrictEqual } from "node:util"; import type { + ManagedStackContractFact, ManagedStackContractJson, ManagedStackContractScenario, } from "./managed-stack-contract.ts"; @@ -33,6 +34,14 @@ export const validateManagedStackContractFixtures = ( if (scenario.when.cwd.trim().length === 0) { errors.push(`${scenario.id}: cwd is required for command scenarios`); } + const givenPaths = scenario.given.flatMap((fact) => + fact.kind === "workspace" || fact.kind === "checkout" ? [fact.path] : [], + ); + if (givenPaths.length > 0 && !givenPaths.includes(scenario.when.cwd)) { + errors.push( + `${scenario.id}: cwd ${scenario.when.cwd} does not match a given workspace or checkout path`, + ); + } } else if (scenario.when.method.trim().length === 0) { errors.push(`${scenario.id}: public API method is required`); } @@ -41,6 +50,19 @@ export const validateManagedStackContractFixtures = ( if (output.human === undefined && output.json === undefined && output.api === undefined) { errors.push(`${scenario.id}: at least one observable output is required`); } + if (output.human !== undefined && output.human.summary.trim().length === 0) { + errors.push(`${scenario.id}: human summary is required`); + } + for (const key of Object.keys(output.json ?? {})) { + if (!/^[a-z0-9]+(?:_[a-z0-9]+)*$/.test(key)) { + errors.push(`${scenario.id}: JSON projection key ${key} must use snake_case`); + } + } + for (const key of Object.keys(output.api ?? {})) { + if (key.includes("_")) { + errors.push(`${scenario.id}: API projection key ${key} must not use snake_case`); + } + } if (scenario.expected.outcome === "error") { if (scenario.expected.error === undefined) { @@ -98,6 +120,7 @@ export const validateManagedStackContractFixtures = ( } const declaredIds = new Set(); + const stackFactsById = new Map(); const declareId = (id: string): void => { if (id.trim().length === 0) { errors.push(`${scenario.id}: declared ID is required`); @@ -152,6 +175,12 @@ export const validateManagedStackContractFixtures = ( case "stack": declareId(fact.contextId); declareId(fact.stackId); + const previousStackFact = stackFactsById.get(fact.stackId); + if (previousStackFact !== undefined && !isDeepStrictEqual(previousStackFact, fact)) { + errors.push(`${scenario.id}: conflicting stack facts for ID ${fact.stackId}`); + } else if (previousStackFact === undefined) { + stackFactsById.set(fact.stackId, fact); + } break; default: break; @@ -190,17 +219,24 @@ export const validateManagedStackContractFixtures = ( } } - if (scenario.expected.selection !== undefined) { + const selection = scenario.expected.selection; + if (selection !== undefined) { for (const id of [ - scenario.expected.selection.projectId, - scenario.expected.selection.checkoutId, - scenario.expected.selection.contextId, - scenario.expected.selection.stackId, + selection.projectId, + selection.checkoutId, + selection.contextId, + selection.stackId, ]) { if (!declaredIds.has(id)) { errors.push(`${scenario.id}: selection references undeclared ID ${id}`); } } + const selectedStackFact = stackFactsById.get(selection.stackId); + if (selectedStackFact?.kind === "stack" && selectedStackFact.name !== selection.stackName) { + errors.push( + `${scenario.id}: selected stack name ${selection.stackName} disagrees with stack ${selection.stackId}`, + ); + } } for (const effect of scenario.expected.runtimeEffects) { @@ -288,18 +324,12 @@ export const validateManagedStackContractFixtures = ( checkProjection(projection, "code", diagnosticCode); } } - const jsonShape = (value: ManagedStackContractJson): string => - value === null ? "null" : Array.isArray(value) ? "array" : typeof value; for (const [key, value] of Object.entries(scenario.expected.details ?? {})) { for (const projection of [output.json, output.api]) { - const projectedValue = projection?.[key]; - if (projectedValue !== undefined && jsonShape(projectedValue) === jsonShape(value)) { - checkProjection(projection, key, value); - } + checkProjection(projection, key, value); } } - const selection = scenario.expected.selection; if (selection !== undefined) { checkProjection(output.json, "project_id", selection.projectId); checkProjection(output.json, "checkout_id", selection.checkoutId); diff --git a/packages/stack/src/managed-stack-contract.integration.test.ts b/packages/stack/src/managed-stack-contract.integration.test.ts index 417ad5907d..337cd3a721 100644 --- a/packages/stack/src/managed-stack-contract.integration.test.ts +++ b/packages/stack/src/managed-stack-contract.integration.test.ts @@ -41,9 +41,13 @@ describe("managed stack acceptance contract", () => { const readOnly = findScenario("identity.branch-copy-read-only-does-not-write"); const noOp = findScenario("reclamation.delete-repeat-is-idempotent"); const freshBootstrap = findScenario("bootstrap.absent-legacy-starts-fresh"); + const reusedStack = reuse.given.find((fact) => fact.kind === "stack"); if ( reuse.expected.selection === undefined || + reuse.when.interface !== "cli" || reuse.expected.output.json === undefined || + reuse.expected.output.human === undefined || + reusedStack === undefined || portConflict.expected.error === undefined || portConflict.expected.output.json === undefined || freshBootstrap.expected.details === undefined || @@ -183,7 +187,7 @@ describe("managed stack acceptance contract", () => { ...freshBootstrap.expected.output, json: { ...freshBootstrap.expected.output.json, - legacy_state_mutated: true, + legacy_state_mutated: { value: true }, }, }, }, @@ -222,6 +226,69 @@ describe("managed stack acceptance contract", () => { ], expectedError: `${noOp.id}: no-op outcome must not mutate state`, }, + { + fixtures: [ + { + ...reuse, + expected: { + ...reuse.expected, + selection: { ...reuse.expected.selection, stackName: "review" }, + output: { + ...reuse.expected.output, + json: { ...reuse.expected.output.json, stack_name: "review" }, + }, + }, + }, + ], + expectedError: `${reuse.id}: selected stack name review disagrees with stack stack-main-default`, + }, + { + fixtures: [ + { + ...reuse, + given: [...reuse.given, { ...reusedStack, lifecycle: "running" }], + }, + ], + expectedError: `${reuse.id}: conflicting stack facts for ID stack-main-default`, + }, + { + fixtures: [ + { + ...reuse, + expected: { + ...reuse.expected, + output: { + ...reuse.expected.output, + human: { ...reuse.expected.output.human, summary: " " }, + }, + }, + }, + ], + expectedError: `${reuse.id}: human summary is required`, + }, + { + fixtures: [ + { + ...reuse, + expected: { + ...reuse.expected, + output: { + ...reuse.expected.output, + json: { ...reuse.expected.output.json, stackName: "default" }, + api: { stack_id: "stack-main-default" }, + }, + }, + }, + ], + expectedError: [ + `${reuse.id}: JSON projection key stackName must use snake_case`, + `${reuse.id}: API projection key stack_id must not use snake_case`, + ], + }, + { + fixtures: [{ ...reuse, when: { ...reuse.when, cwd: "another-checkout" } }], + expectedError: `${reuse.id}: cwd another-checkout does not match a given workspace or checkout path`, + }, ]; for (const testCase of cases) { diff --git a/packages/stack/src/managed-stack-contract.ts b/packages/stack/src/managed-stack-contract.ts index 5ef7a3e052..29095d8682 100644 --- a/packages/stack/src/managed-stack-contract.ts +++ b/packages/stack/src/managed-stack-contract.ts @@ -898,8 +898,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ { kind: "workspace", mode: "ordinary-folder", - path: "project-a", - canonicalPath: "/work/project-a", + path: "/work/project-a", }, ], when: { interface: "cli", argv: ["start", "--experimental"], cwd: "/work/project-a" }, @@ -948,8 +947,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ { kind: "workspace", mode: "ordinary-folder", - path: "project-a", - canonicalPath: "/work/project-a", + path: "/work/project-a", }, { kind: "identity-marker", @@ -1549,7 +1547,10 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ outcome: "report", writes: [], runtimeEffects: [], - details: { default: "stack-feat-default", "review-42": "stack-feat-review-42" }, + details: { + default_stack_id: "stack-feat-default", + review_42_stack_id: "stack-feat-review-42", + }, output: { api: { default: { contextId: "context-feat", stackId: "stack-feat-default" }, From e4195192822c58275984ea004539a84474abba76 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 11 Aug 2026 08:51:31 +0200 Subject: [PATCH 37/41] test(stack): align contract fixtures with runtime --- ...managed-stack-contract.integration.test.ts | 53 ++++++++++++------- packages/stack/src/managed-stack-contract.ts | 9 +++- 2 files changed, 42 insertions(+), 20 deletions(-) diff --git a/packages/stack/src/managed-stack-contract.integration.test.ts b/packages/stack/src/managed-stack-contract.integration.test.ts index 337cd3a721..7c2d2e78ab 100644 --- a/packages/stack/src/managed-stack-contract.integration.test.ts +++ b/packages/stack/src/managed-stack-contract.integration.test.ts @@ -9,7 +9,7 @@ import { } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { createStack } from "./node.ts"; import { managedNativeServiceMatrix, @@ -17,9 +17,22 @@ import { type ManagedStackContractScenario, validateManagedStackContractFixtures, } from "./testing.ts"; -import { shortTempPrefixRoot } from "./paths.ts"; import { DEFAULT_VERSIONS, SERVICE_NAMES } from "./versions.ts"; +const { createdTempRoots } = vi.hoisted(() => ({ createdTempRoots: new Array() })); + +vi.mock("node:fs", async (importOriginal) => { + const fs = await importOriginal(); + return { + ...fs, + mkdtempSync(prefix: string) { + const root = fs.mkdtempSync(prefix); + createdTempRoots.push(root); + return root; + }, + }; +}); + describe("managed stack acceptance contract", () => { it("keeps every shared scenario readable and executable through a public interface", () => { expect(validateManagedStackContractFixtures(managedStackContractFixtures)).toEqual([]); @@ -328,8 +341,10 @@ describe("managed stack acceptance contract", () => { "identity.fresh-clone-creates-project-and-checkout", "identity.fresh-clone-ignores-tracked-marker", "identity.inaccessible-previous-path-fails", + "identity.invalid-stack-name-double-dot-fails", "identity.invalid-stack-name-leading-hyphen-fails", "identity.invalid-stack-name-repeated-dot-fails", + "identity.invalid-stack-name-single-dot-fails", "identity.invalid-stack-name-too-long-fails", "identity.invalid-stack-name-trailing-hyphen-fails", "identity.invalid-stack-name-uppercase-underscore-fails", @@ -433,6 +448,14 @@ describe("managed stack acceptance contract", () => { action: ["start", "--experimental", "--stack", "review..two"], names: ["review..two"], }, + { + action: ["start", "--experimental", "--stack", "."], + names: ["."], + }, + { + action: ["start", "--experimental", "--stack", ".."], + names: [".."], + }, { action: ["start", "--experimental", "--stack", "review-"], names: ["review-"], @@ -641,13 +664,6 @@ describe("managed stack acceptance contract", () => { const cacheRoot = join(testRoot, "cache"); const explicitRoot = join(testRoot, `${explicitRootKind}-root`); const sentinel = join(explicitRoot, "caller-owned"); - const generatedPrefix = explicitRootKind === "stack" ? "sb-run-" : "sb-stack-"; - const temporaryRoots = (): ReadonlySet => - new Set( - readdirSync(shortTempPrefixRoot()) - .filter((name) => name.startsWith(generatedPrefix)) - .map((name) => join(shortTempPrefixRoot(), name)), - ); mkdirSync(projectDir, { recursive: true }); mkdirSync(cacheRoot, { recursive: true }); @@ -655,7 +671,7 @@ describe("managed stack acceptance contract", () => { writeFileSync(sentinel, "caller-owned\n"); try { - const before = temporaryRoots(); + const createdRootIndex = createdTempRoots.length; const explicitConfig = explicitRootKind === "stack" ? { stackRoot: explicitRoot } @@ -666,19 +682,20 @@ describe("managed stack acceptance contract", () => { startupMode: "lazy", ...explicitConfig, }); - let stableCandidates: ReadonlyArray = []; + const generatedRoots = createdTempRoots.slice(createdRootIndex); + const generatedRoot = generatedRoots[0]; + if (generatedRoot === undefined) { + throw new Error("createStack must generate the omitted state root"); + } try { - const candidates = [...temporaryRoots()].filter((root) => !before.has(root)); - await new Promise((resolve) => setTimeout(resolve, 50)); - stableCandidates = candidates.filter((root) => existsSync(root)); - expect(stableCandidates.length).toBeGreaterThanOrEqual(1); + expect(generatedRoots).toHaveLength(1); + expect(existsSync(generatedRoot)).toBe(true); } finally { await stack.dispose(); } - const removedRoots = stableCandidates.filter((root) => !existsSync(root)); - expect(removedRoots).toHaveLength(1); + expect(existsSync(generatedRoot)).toBe(false); expect(readFileSync(sentinel, "utf8")).toBe("caller-owned\n"); expect(existsSync(explicitRoot)).toBe(true); } finally { @@ -1024,7 +1041,7 @@ describe("managed stack acceptance contract", () => { when: { interface: "stack-api", method: "createStack", - input: {}, + input: { startupMode: "lazy" }, }, expected: { outcome: "create", diff --git a/packages/stack/src/managed-stack-contract.ts b/packages/stack/src/managed-stack-contract.ts index 29095d8682..9b6d8be389 100644 --- a/packages/stack/src/managed-stack-contract.ts +++ b/packages/stack/src/managed-stack-contract.ts @@ -412,8 +412,10 @@ const branchHistoryFixture = ( const invalidStackNameFixture = ( label: + | "double-dot" | "leading-hyphen" | "repeated-dot" + | "single-dot" | "too-long" | "trailing-hyphen" | "uppercase-underscore", @@ -1527,6 +1529,8 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ invalidStackNameFixture("uppercase-underscore", "Feature_A"), invalidStackNameFixture("leading-hyphen", "-review"), invalidStackNameFixture("repeated-dot", "review..two"), + invalidStackNameFixture("single-dot", "."), + invalidStackNameFixture("double-dot", ".."), invalidStackNameFixture("trailing-hyphen", "review-"), invalidStackNameFixture("too-long", "a".repeat(64)), { @@ -4674,7 +4678,7 @@ export const managedStackContractFixtures = defineManagedStackContractFixtures([ when: { interface: "stack-api", method: "createStack", - input: {}, + input: { startupMode: "lazy" }, }, expected: { outcome: "create", @@ -4725,6 +4729,7 @@ export const managedStackContractFixtures = defineManagedStackContractFixtures([ projectDir: "/work/project-a", cacheRoot: "/work/cache", stackRoot: "/work/stack", + startupMode: "lazy", }, }, expected: { @@ -4766,7 +4771,7 @@ export const managedStackContractFixtures = defineManagedStackContractFixtures([ when: { interface: "stack-api", method: "createStack", - input: { runtimeRoot: "/work/runtime" }, + input: { runtimeRoot: "/work/runtime", startupMode: "lazy" }, }, expected: { outcome: "create", From 532476fe172bdd0a98e9fb9e3e4e9d89733724e7 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 11 Aug 2026 09:23:41 +0200 Subject: [PATCH 38/41] test(stack): close contract soundness gaps --- .../src/managed-stack-contract-validation.ts | 105 +++++++++++++++--- ...managed-stack-contract.integration.test.ts | 75 ++++++++++++- 2 files changed, 165 insertions(+), 15 deletions(-) diff --git a/packages/stack/src/managed-stack-contract-validation.ts b/packages/stack/src/managed-stack-contract-validation.ts index c167c88bb7..6643c50096 100644 --- a/packages/stack/src/managed-stack-contract-validation.ts +++ b/packages/stack/src/managed-stack-contract-validation.ts @@ -5,10 +5,55 @@ import type { ManagedStackContractScenario, } from "./managed-stack-contract.ts"; +const factPrimaryId = (fact: ManagedStackContractFact): string | undefined => { + switch (fact.kind) { + case "checkout": + return fact.checkoutId; + case "credential-state": + return fact.valuesId; + case "direct-stack-state": + return fact.handle; + case "identity-claim": + return `${fact.scope}:${fact.id}`; + case "identity-marker": + return fact.markerId; + case "managed-record": + case "managed-target": + case "operation-result": + case "persisted-runtime": + case "stack": + return fact.stackId; + case "port-assignment": + return `${fact.stackId}:${fact.key}`; + default: + return undefined; + } +}; + +const containsNonFiniteNumber = (value: ManagedStackContractJson | undefined): boolean => { + if (value === undefined) { + return false; + } + if (typeof value === "number") { + return !Number.isFinite(value); + } + if (Array.isArray(value)) { + return value.some(containsNonFiniteNumber); + } + if (value !== null && typeof value === "object") { + return Object.values(value).some(containsNonFiniteNumber); + } + return false; +}; + +const snakeToCamel = (key: string): string => + key.replace(/_([a-z0-9])/g, (_match, character: string) => character.toUpperCase()); + export const validateManagedStackContractFixtures = ( fixtures: ReadonlyArray, ): ReadonlyArray => { const errors: Array = []; + const knownScenarioIds = new Set(fixtures.map((scenario) => scenario.id)); const scenarioIds = new Set(); for (const scenario of fixtures) { @@ -28,8 +73,8 @@ export const validateManagedStackContractFixtures = ( } if (scenario.when.interface === "cli" || scenario.when.interface === "git") { - if (scenario.when.argv.length === 0) { - errors.push(`${scenario.id}: argv must contain a public command`); + if (scenario.when.argv.length === 0 || scenario.when.argv[0]?.trim().length === 0) { + errors.push(`${scenario.id}: argv must start with a public command`); } if (scenario.when.cwd.trim().length === 0) { errors.push(`${scenario.id}: cwd is required for command scenarios`); @@ -42,8 +87,20 @@ export const validateManagedStackContractFixtures = ( `${scenario.id}: cwd ${scenario.when.cwd} does not match a given workspace or checkout path`, ); } - } else if (scenario.when.method.trim().length === 0) { - errors.push(`${scenario.id}: public API method is required`); + } else { + if (scenario.when.method.trim().length === 0) { + errors.push(`${scenario.id}: public API method is required`); + } + const referencedScenarioId = scenario.when.input.scenarioId; + if ( + referencedScenarioId !== undefined && + (typeof referencedScenarioId !== "string" || !knownScenarioIds.has(referencedScenarioId)) + ) { + errors.push(`${scenario.id}: references unknown scenario ID ${referencedScenarioId}`); + } + if (containsNonFiniteNumber(scenario.when.input)) { + errors.push(`${scenario.id}: public API input contains a non-finite number`); + } } const { output } = scenario.expected; @@ -63,6 +120,18 @@ export const validateManagedStackContractFixtures = ( errors.push(`${scenario.id}: API projection key ${key} must not use snake_case`); } } + const jsonValues: ReadonlyArray< + readonly [label: string, value: ManagedStackContractJson | undefined] + > = [ + ["managed detail data", scenario.expected.details], + ["JSON projection", output.json], + ["API projection", output.api], + ]; + for (const [label, value] of jsonValues) { + if (containsNonFiniteNumber(value)) { + errors.push(`${scenario.id}: ${label} contains a non-finite number`); + } + } if (scenario.expected.outcome === "error") { if (scenario.expected.error === undefined) { @@ -120,7 +189,7 @@ export const validateManagedStackContractFixtures = ( } const declaredIds = new Set(); - const stackFactsById = new Map(); + const factsByPrimaryId = new Map(); const declareId = (id: string): void => { if (id.trim().length === 0) { errors.push(`${scenario.id}: declared ID is required`); @@ -129,6 +198,17 @@ export const validateManagedStackContractFixtures = ( } }; for (const fact of scenario.given) { + const primaryId = factPrimaryId(fact); + if (primaryId !== undefined) { + const factKey = `${fact.kind}:${primaryId}`; + const previousFact = factsByPrimaryId.get(factKey); + if (previousFact !== undefined && !isDeepStrictEqual(previousFact, fact)) { + errors.push(`${scenario.id}: conflicting ${fact.kind} facts for ID ${primaryId}`); + } else if (previousFact === undefined) { + factsByPrimaryId.set(factKey, fact); + } + } + switch (fact.kind) { case "branch": declareId(fact.contextId); @@ -175,12 +255,6 @@ export const validateManagedStackContractFixtures = ( case "stack": declareId(fact.contextId); declareId(fact.stackId); - const previousStackFact = stackFactsById.get(fact.stackId); - if (previousStackFact !== undefined && !isDeepStrictEqual(previousStackFact, fact)) { - errors.push(`${scenario.id}: conflicting stack facts for ID ${fact.stackId}`); - } else if (previousStackFact === undefined) { - stackFactsById.set(fact.stackId, fact); - } break; default: break; @@ -231,7 +305,7 @@ export const validateManagedStackContractFixtures = ( errors.push(`${scenario.id}: selection references undeclared ID ${id}`); } } - const selectedStackFact = stackFactsById.get(selection.stackId); + const selectedStackFact = factsByPrimaryId.get(`stack:${selection.stackId}`); if (selectedStackFact?.kind === "stack" && selectedStackFact.name !== selection.stackName) { errors.push( `${scenario.id}: selected stack name ${selection.stackName} disagrees with stack ${selection.stackId}`, @@ -325,8 +399,11 @@ export const validateManagedStackContractFixtures = ( } } for (const [key, value] of Object.entries(scenario.expected.details ?? {})) { - for (const projection of [output.json, output.api]) { - checkProjection(projection, key, value); + checkProjection(output.json, key, value); + checkProjection(output.api, key, value); + const apiKey = snakeToCamel(key); + if (apiKey !== key) { + checkProjection(output.api, apiKey, value); } } diff --git a/packages/stack/src/managed-stack-contract.integration.test.ts b/packages/stack/src/managed-stack-contract.integration.test.ts index 7c2d2e78ab..db2878bdc5 100644 --- a/packages/stack/src/managed-stack-contract.integration.test.ts +++ b/packages/stack/src/managed-stack-contract.integration.test.ts @@ -54,6 +54,10 @@ describe("managed stack acceptance contract", () => { const readOnly = findScenario("identity.branch-copy-read-only-does-not-write"); const noOp = findScenario("reclamation.delete-repeat-is-idempotent"); const freshBootstrap = findScenario("bootstrap.absent-legacy-starts-fresh"); + const directDispose = findScenario("api-boundary.direct-dispose-removes-temporary-roots"); + const repositoryContract = findScenario("api-boundary.repository-contract-is-storage-agnostic"); + const persistedRuntime = findScenario("runtime.persisted-runtime-reused-for-auto"); + const repositoryAction = repositoryContract.when; const reusedStack = reuse.given.find((fact) => fact.kind === "stack"); if ( reuse.expected.selection === undefined || @@ -64,7 +68,9 @@ describe("managed stack acceptance contract", () => { portConflict.expected.error === undefined || portConflict.expected.output.json === undefined || freshBootstrap.expected.details === undefined || - freshBootstrap.expected.output.json === undefined + freshBootstrap.expected.output.json === undefined || + directDispose.expected.output.api === undefined || + repositoryAction.interface !== "managed-api" ) { throw new Error("lint examples require selected and structured fixture outputs"); } @@ -302,6 +308,73 @@ describe("managed stack acceptance contract", () => { fixtures: [{ ...reuse, when: { ...reuse.when, cwd: "another-checkout" } }], expectedError: `${reuse.id}: cwd another-checkout does not match a given workspace or checkout path`, }, + { + fixtures: [ + { + ...directDispose, + expected: { + ...directDispose.expected, + output: { + ...directDispose.expected.output, + api: { + ...directDispose.expected.output.api, + temporaryRootsRemoved: false, + }, + }, + }, + }, + ], + expectedError: `${directDispose.id}: projected temporaryRootsRemoved disagrees with the managed result`, + }, + { + fixtures: managedStackContractFixtures.map((scenario) => + scenario.id === repositoryContract.id + ? { + ...repositoryContract, + when: { + ...repositoryAction, + input: { + ...repositoryAction.input, + scenarioId: "identity.missing-contract-scenario", + }, + }, + } + : scenario, + ), + expectedError: `${repositoryContract.id}: references unknown scenario ID identity.missing-contract-scenario`, + }, + { + fixtures: [{ ...reuse, when: { ...reuse.when, argv: [" "] } }], + expectedError: `${reuse.id}: argv must start with a public command`, + }, + { + fixtures: [ + { + ...persistedRuntime, + given: [ + ...persistedRuntime.given, + { + kind: "persisted-runtime", + stackId: "stack-main-default", + runtime: "docker", + }, + ], + }, + ], + expectedError: `${persistedRuntime.id}: conflicting persisted-runtime facts for ID stack-main-default`, + }, + { + fixtures: [ + { + ...freshBootstrap, + expected: { + ...freshBootstrap.expected, + details: { ...freshBootstrap.expected.details, invalid_number: Number.NaN }, + }, + }, + ], + expectedError: `${freshBootstrap.id}: managed detail data contains a non-finite number`, + }, ]; for (const testCase of cases) { From 6adc2253922e24db7368079678bbff7213e58670 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 11 Aug 2026 09:58:31 +0200 Subject: [PATCH 39/41] test(stack): restore managed contract fidelity --- .../0015-managed-stack-contract-fixtures.md | 16 +- packages/stack/src/entrypoints.unit.test.ts | 2 + .../src/managed-stack-contract-validation.ts | 21 ++ ...managed-stack-contract.integration.test.ts | 138 +++++++++ packages/stack/src/managed-stack-contract.ts | 274 ++++++++++++++---- packages/stack/src/testing.ts | 2 + 6 files changed, 392 insertions(+), 61 deletions(-) diff --git a/docs/adr/0015-managed-stack-contract-fixtures.md b/docs/adr/0015-managed-stack-contract-fixtures.md index 97e88b8ad3..3df3580bf9 100644 --- a/docs/adr/0015-managed-stack-contract-fixtures.md +++ b/docs/adr/0015-managed-stack-contract-fixtures.md @@ -52,13 +52,12 @@ The CLI is a consumer and presentation layer. It translates arguments into manag projects managed results into human and JSON output. It must not implement a second identity, selection, port, runtime, or lifecycle decision path. -Git workspaces store project, checkout, and context identities in Git-local metadata, using common -or worktree scope as appropriate. Contract effects record that scope explicitly: project identity -uses common Git config, while checkout and context identities use worktree-local config. Context -writes also declare their owning branch so storage adapters cannot persist an unbound context. A -tracked working-tree identity marker is inert: discovery never trusts or rewrites it. Ordinary -non-Git folders persist a project-local, untracked identity marker on first start and recover that -same project, checkout, and context identity on later starts. +Git workspaces store project and branch-context identities in common local Git configuration, which +is shared by linked worktrees. Checkout identity is stored separately under each checkout's Git +directory. Context writes declare their owning branch so storage adapters cannot persist an unbound +context. A tracked working-tree identity marker is inert: discovery never trusts or rewrites it. +Ordinary non-Git folders persist a project-local, untracked identity marker on first start and +recover that same project, checkout, and context identity on later starts. Read-only status remains a successful `report` when it can identify a running stack but finds unapplied port, credential, or runtime configuration. The report includes a structured warning and @@ -118,6 +117,9 @@ own the expected behavior: - CLI-2106, CLI-2107, and CLI-2108 attach the repository and identity resolver to the identity fixtures, including ordinary folders, worktrees, branches, and orphan handling. +- CLI-2106 and CLI-2108 must store checkout identity beneath the checkout-specific Git directory + without implicitly enabling `extensions.worktreeConfig`; branch contexts remain in common local + Git configuration. - CLI-2109 attaches automatic legacy bootstrap and rollback-safe publication. - CLI-2110 attaches exact and automatic port intent, allocation, stickiness, drift, and collisions. - CLI-2124 attaches runtime selection, persistence, and strict conflict handling. diff --git a/packages/stack/src/entrypoints.unit.test.ts b/packages/stack/src/entrypoints.unit.test.ts index 67e366ebf5..30fa2fdd75 100644 --- a/packages/stack/src/entrypoints.unit.test.ts +++ b/packages/stack/src/entrypoints.unit.test.ts @@ -74,6 +74,8 @@ describe("@supabase/stack entrypoints", () => { expect(Object.keys(testing).sort()).toEqual([ "DaemonServer", "UnixHttpClient", + "managedNativePlatformByNodeTarget", + "managedNativePlatformFromNode", "managedNativeServiceMatrix", "managedStackContractFixtures", "validateManagedStackContractFixtures", diff --git a/packages/stack/src/managed-stack-contract-validation.ts b/packages/stack/src/managed-stack-contract-validation.ts index 6643c50096..aa2703d625 100644 --- a/packages/stack/src/managed-stack-contract-validation.ts +++ b/packages/stack/src/managed-stack-contract-validation.ts @@ -104,6 +104,19 @@ export const validateManagedStackContractFixtures = ( } const { output } = scenario.expected; + if (scenario.when.interface === "cli") { + const cliArgv = scenario.when.argv; + const jsonRequested = cliArgv.some( + (argument, index) => + argument === "--output=json" || + ((argument === "--output" || argument === "-o") && cliArgv[index + 1] === "json"), + ); + if (jsonRequested && output.json === undefined) { + errors.push(`${scenario.id}: JSON CLI invocation requires a JSON projection`); + } else if (!jsonRequested && output.human === undefined) { + errors.push(`${scenario.id}: default CLI invocation requires a human projection`); + } + } if (output.human === undefined && output.json === undefined && output.api === undefined) { errors.push(`${scenario.id}: at least one observable output is required`); } @@ -311,6 +324,14 @@ export const validateManagedStackContractFixtures = ( `${scenario.id}: selected stack name ${selection.stackName} disagrees with stack ${selection.stackId}`, ); } + if ( + selectedStackFact?.kind === "stack" && + selectedStackFact.contextId !== selection.contextId + ) { + errors.push( + `${scenario.id}: selected context ${selection.contextId} disagrees with stack ${selection.stackId}`, + ); + } } for (const effect of scenario.expected.runtimeEffects) { diff --git a/packages/stack/src/managed-stack-contract.integration.test.ts b/packages/stack/src/managed-stack-contract.integration.test.ts index db2878bdc5..e6b782ce38 100644 --- a/packages/stack/src/managed-stack-contract.integration.test.ts +++ b/packages/stack/src/managed-stack-contract.integration.test.ts @@ -12,6 +12,8 @@ import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; import { createStack } from "./node.ts"; import { + managedNativePlatformByNodeTarget, + managedNativePlatformFromNode, managedNativeServiceMatrix, managedStackContractFixtures, type ManagedStackContractScenario, @@ -57,6 +59,8 @@ describe("managed stack acceptance contract", () => { const directDispose = findScenario("api-boundary.direct-dispose-removes-temporary-roots"); const repositoryContract = findScenario("api-boundary.repository-contract-is-storage-agnostic"); const persistedRuntime = findScenario("runtime.persisted-runtime-reused-for-auto"); + const defaultOutputCli = findScenario("identity.non-git-folder-first-start-persists-identity"); + const jsonOutputCli = findScenario("identity.read-only-unregistered-checkout-does-not-write"); const repositoryAction = repositoryContract.when; const reusedStack = reuse.given.find((fact) => fact.kind === "stack"); if ( @@ -375,6 +379,54 @@ describe("managed stack acceptance contract", () => { ], expectedError: `${freshBootstrap.id}: managed detail data contains a non-finite number`, }, + { + fixtures: [ + { + ...defaultOutputCli, + expected: { + ...defaultOutputCli.expected, + output: { ...defaultOutputCli.expected.output, human: undefined }, + }, + }, + ], + expectedError: `${defaultOutputCli.id}: default CLI invocation requires a human projection`, + }, + { + fixtures: [ + { + ...jsonOutputCli, + expected: { + ...jsonOutputCli.expected, + output: { ...jsonOutputCli.expected.output, json: undefined }, + }, + }, + ], + expectedError: `${jsonOutputCli.id}: JSON CLI invocation requires a JSON projection`, + }, + { + fixtures: [ + { + ...reuse, + given: [ + ...reuse.given, + { kind: "branch", name: "feat-a", contextId: "context-feat", checkedOut: false }, + ], + expected: { + ...reuse.expected, + selection: { ...reuse.expected.selection, contextId: "context-feat" }, + output: { + ...reuse.expected.output, + human: { + ...reuse.expected.output.human, + fields: { ...reuse.expected.output.human.fields, contextId: "context-feat" }, + }, + json: { ...reuse.expected.output.json, context_id: "context-feat" }, + }, + }, + }, + ], + expectedError: `${reuse.id}: selected context context-feat disagrees with stack stack-main-default`, + }, ]; for (const testCase of cases) { @@ -442,6 +494,79 @@ describe("managed stack acceptance contract", () => { ); }); + it("shares branch contexts across worktrees while checkout identity keeps stacks isolated", () => { + const findIdentityScenario = (id: string): ManagedStackContractScenario => { + const scenario = managedStackContractFixtures.find((candidate) => candidate.id === id); + if (scenario === undefined) { + throw new Error(`${id} fixture is required`); + } + return scenario; + }; + const linkedWorktrees = findIdentityScenario( + "identity.linked-worktrees-share-project-not-checkout", + ); + const forcedBranch = findIdentityScenario("identity.same-branch-in-two-worktrees-is-isolated"); + const bareWorktrees = findIdentityScenario( + "identity.bare-repository-linked-worktrees-share-project", + ); + + expect(linkedWorktrees).toMatchObject({ + expected: { + selection: { checkoutId: "checkout-b", contextId: "context-main" }, + writes: expect.arrayContaining([ + { + target: "git-config", + operation: "create", + id: "context-main", + scope: "common", + owner: "main", + }, + ]), + }, + }); + expect(forcedBranch).toMatchObject({ + given: expect.arrayContaining([ + expect.objectContaining({ kind: "branch", name: "main", contextId: "context-main" }), + ]), + expected: { + selection: { + checkoutId: "checkout-b", + contextId: "context-main", + stackId: "stack-b-main-default", + }, + }, + }); + expect(forcedBranch.expected.writes.filter((write) => write.target === "git-config")).toEqual( + [], + ); + expect(bareWorktrees).toMatchObject({ + expected: { + selection: { checkoutId: "checkout-b", contextId: "context-main" }, + writes: expect.arrayContaining([ + { + target: "git-config", + operation: "create", + id: "context-main", + scope: "common", + owner: "main", + }, + ]), + }, + }); + }); + + it("does not rewrite branch context after Git has preserved a rename", () => { + for (const id of [ + "identity.branch-rename-preserves-context", + "identity.original-gone-turns-copy-into-rename", + ]) { + const scenario = managedStackContractFixtures.find((candidate) => candidate.id === id); + expect(scenario?.expected.writes.filter((write) => write.target === "git-config")).toEqual( + [], + ); + } + }); + it("persists and recovers ordinary-folder identity across starts", () => { const firstStart = managedStackContractFixtures.find( ({ id }) => id === "identity.non-git-folder-first-start-persists-identity", @@ -566,6 +691,19 @@ describe("managed stack acceptance contract", () => { }); it("freezes runtime selection and the atomic native service graph", () => { + expect(managedNativePlatformByNodeTarget).toEqual({ + "darwin-arm64": "darwin-arm64", + "darwin-x64": "darwin-x64", + "linux-arm64": "linux-arm64", + "linux-x64": "linux-amd64", + "win32-arm64": "windows-arm64", + "win32-x64": "windows-amd64", + }); + expect(managedNativePlatformFromNode("linux", "x64")).toBe("linux-amd64"); + expect(managedNativePlatformFromNode("linux", "arm64")).toBe("linux-arm64"); + expect(managedNativePlatformFromNode("win32", "x64")).toBe("windows-amd64"); + expect(managedNativePlatformByNodeTarget).toHaveProperty(`${process.platform}-${process.arch}`); + expect(managedNativeServiceMatrix).toEqual({ targetPlatforms: ["darwin-arm64", "linux-amd64", "linux-arm64"], unsupportedPlatforms: ["darwin-x64", "windows-amd64", "windows-arm64"], diff --git a/packages/stack/src/managed-stack-contract.ts b/packages/stack/src/managed-stack-contract.ts index 9b6d8be389..6a7cbbcac6 100644 --- a/packages/stack/src/managed-stack-contract.ts +++ b/packages/stack/src/managed-stack-contract.ts @@ -241,9 +241,14 @@ type ManagedStackContractWrite = readonly target: "git-config"; readonly operation: "create" | "update"; readonly id: string; - readonly scope: "common" | "worktree"; + readonly scope: "common"; readonly owner?: string; } + | { + readonly target: "git-checkout-id"; + readonly operation: "create" | "update"; + readonly id: string; + } | { readonly target: "identity-marker"; readonly operation: "create" | "update"; @@ -349,6 +354,20 @@ export interface ManagedNativeServiceMatrix { readonly services: ReadonlyArray; } +export const managedNativePlatformByNodeTarget: Readonly> = { + "darwin-arm64": "darwin-arm64", + "darwin-x64": "darwin-x64", + "linux-arm64": "linux-arm64", + "linux-x64": "linux-amd64", + "win32-arm64": "windows-arm64", + "win32-x64": "windows-amd64", +}; + +export const managedNativePlatformFromNode = ( + os: string, + architecture: string, +): string | undefined => managedNativePlatformByNodeTarget[`${os}-${architecture}`]; + export const managedNativeServiceMatrix: ManagedNativeServiceMatrix = { targetPlatforms: ["darwin-arm64", "linux-amd64", "linux-arm64"], unsupportedPlatforms: ["darwin-x64", "windows-amd64", "windows-arm64"], @@ -590,7 +609,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ target: "git-config", operation: "create", id: "context-feat-a", - scope: "worktree", + scope: "common", owner: "feat-a", }, { target: "registry", operation: "publish", id: "stack-feat-a-default" }, @@ -641,16 +660,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ stackId: "stack-feat-default", stackName: "default", }, - writes: [ - { - target: "git-config", - operation: "update", - id: "context-feat", - scope: "worktree", - owner: "feat-a", - }, - { target: "runtime-state", operation: "start", id: "stack-feat-default" }, - ], + writes: [{ target: "runtime-state", operation: "start", id: "stack-feat-default" }], runtimeEffects: [{ operation: "start", stackId: "stack-feat-default" }], output: { api: { outcome: "reuse", contextId: "context-feat", stackId: "stack-feat-default" }, @@ -702,7 +712,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ target: "git-config", operation: "create", id: "context-new", - scope: "worktree", + scope: "common", owner: "feat-a", }, { target: "registry", operation: "publish", id: "stack-new-default" }, @@ -712,6 +722,10 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ runtimeEffects: [{ operation: "start", stackId: "stack-new-default" }], details: { orphaned_context_id: "context-old" }, output: { + human: { + summary: "Created default stack with a new branch context", + fields: { contextId: "context-new", stackId: "stack-new-default", stack: "default" }, + }, json: { outcome: "create", context_id: "context-new", @@ -822,7 +836,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ target: "git-config", operation: "create", id: "context-new", - scope: "worktree", + scope: "common", owner: "feat-a", }, { target: "registry", operation: "publish", id: "stack-new-default" }, @@ -832,6 +846,10 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ runtimeEffects: [{ operation: "start", stackId: "stack-new-default" }], details: { orphaned_context_id: "context-old", adoption_required: true }, output: { + human: { + summary: "Created default stack after manual ref replacement", + fields: { contextId: "context-new", stackId: "stack-new-default", stack: "default" }, + }, json: { outcome: "create", context_id: "context-new", @@ -931,6 +949,16 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ runtimeEffects: [{ operation: "start", stackId: "stack-workspace-default" }], details: { identity_marker_tracked: false }, output: { + human: { + summary: "Created workspace/default", + fields: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-workspace", + stackId: "stack-workspace-default", + stack: "default", + }, + }, json: { outcome: "create", project_id: "project-a", @@ -981,6 +1009,14 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ writes: [{ target: "runtime-state", operation: "start", id: "stack-workspace-default" }], runtimeEffects: [{ operation: "start", stackId: "stack-workspace-default" }], output: { + human: { + summary: "Started workspace/default", + fields: { + contextId: "context-workspace", + stackId: "stack-workspace-default", + stack: "default", + }, + }, json: { outcome: "reuse", context_id: "context-workspace", @@ -1008,7 +1044,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ }, { kind: "checkout", path: "worktree-a", projectId: "project-a", checkoutId: "checkout-a" }, { kind: "checkout", path: "worktree-b", projectId: "project-a", checkoutId: "checkout-b" }, - { kind: "identity-claim", scope: "context", id: "context-b-main", status: "absent" }, + { kind: "identity-claim", scope: "context", id: "context-main", status: "absent" }, ], when: { interface: "managed-api", @@ -1020,7 +1056,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ selection: { projectId: "project-a", checkoutId: "checkout-b", - contextId: "context-b-main", + contextId: "context-main", stackId: "stack-b-main-default", stackName: "default", }, @@ -1028,8 +1064,8 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ { target: "git-config", operation: "create", - id: "context-b-main", - scope: "worktree", + id: "context-main", + scope: "common", owner: "main", }, { target: "registry", operation: "publish", id: "stack-b-main-default" }, @@ -1038,7 +1074,12 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ ], runtimeEffects: [{ operation: "start", stackId: "stack-b-main-default" }], output: { - api: { projectId: "project-a", checkoutId: "checkout-b", stackId: "stack-b-main-default" }, + api: { + projectId: "project-a", + checkoutId: "checkout-b", + contextId: "context-main", + stackId: "stack-b-main-default", + }, }, }, }, @@ -1048,16 +1089,20 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ area: "identity", given: [ ...freshManagedStartFacts("stack-b-main-default"), - { kind: "checkout", path: "worktree-a", projectId: "project-a", checkoutId: "checkout-a" }, - { kind: "checkout", path: "worktree-b", projectId: "project-a", checkoutId: "checkout-b" }, - { kind: "branch", name: "main", contextId: "context-a-main", checkedOut: true }, + { kind: "workspace", mode: "linked-worktree", path: "worktree-a" }, + { kind: "workspace", mode: "linked-worktree", path: "worktree-b" }, { - kind: "identity-claim", - scope: "context", - id: "context-b-main", - owner: "checkout-b/main", - status: "exact", + kind: "git-state", + workspacePath: "worktree-b", + commonDirectory: "repo/.git", + gitDirectory: "repo/.git/worktrees/worktree-b", + head: "branch", + branch: "main", + commit: "commit-a", }, + { kind: "checkout", path: "worktree-a", projectId: "project-a", checkoutId: "checkout-a" }, + { kind: "checkout", path: "worktree-b", projectId: "project-a", checkoutId: "checkout-b" }, + { kind: "branch", name: "main", contextId: "context-main", checkedOut: true }, ], when: { interface: "managed-api", @@ -1069,7 +1114,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ selection: { projectId: "project-a", checkoutId: "checkout-b", - contextId: "context-b-main", + contextId: "context-main", stackId: "stack-b-main-default", stackName: "default", }, @@ -1082,7 +1127,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ output: { api: { checkoutId: "checkout-b", - contextId: "context-b-main", + contextId: "context-main", stackId: "stack-b-main-default", }, }, @@ -1194,6 +1239,10 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ ], runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], output: { + human: { + summary: "Started main/default after moving the checkout", + fields: { checkoutId: "checkout-a", stackId: "stack-main-default", stack: "default" }, + }, json: { outcome: "reuse", checkout_id: "checkout-a", rebound_from: "/old/project-a" }, }, }, @@ -1345,12 +1394,12 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ }, writes: [ { target: "git-config", operation: "create", id: "project-clone", scope: "common" }, - { target: "git-config", operation: "create", id: "checkout-clone", scope: "worktree" }, + { target: "git-checkout-id", operation: "create", id: "checkout-clone" }, { target: "git-config", operation: "create", id: "context-clone-main", - scope: "worktree", + scope: "common", owner: "main", }, { target: "registry", operation: "publish", id: "stack-clone-main-default" }, @@ -1360,6 +1409,16 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ runtimeEffects: [{ operation: "start", stackId: "stack-clone-main-default" }], details: { project_identity_storage: "git-local", git_index_mutated: false }, output: { + human: { + summary: "Created main/default for the fresh clone", + fields: { + projectId: "project-clone", + checkoutId: "checkout-clone", + contextId: "context-clone-main", + stackId: "stack-clone-main-default", + stack: "default", + }, + }, json: { outcome: "create", project_id: "project-clone", @@ -1624,7 +1683,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ target: "git-config", operation: "create", id: "context-copy", - scope: "worktree", + scope: "common", owner: "feat-copy", }, { target: "registry", operation: "publish", id: "stack-copy-default" }, @@ -1634,6 +1693,10 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ runtimeEffects: [{ operation: "start", stackId: "stack-copy-default" }], details: { original_context_id: "context-main", original_owner: "main" }, output: { + human: { + summary: "Created feat-copy/default with a new branch context", + fields: { contextId: "context-copy", stackId: "stack-copy-default", stack: "default" }, + }, json: { outcome: "create", branch: "feat-copy", @@ -1735,18 +1798,13 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ stackId: "stack-main-default", stackName: "default", }, - writes: [ - { - target: "git-config", - operation: "update", - id: "context-main", - scope: "worktree", - owner: "renamed", - }, - { target: "runtime-state", operation: "start", id: "stack-main-default" }, - ], + writes: [{ target: "runtime-state", operation: "start", id: "stack-main-default" }], runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], output: { + human: { + summary: "Started renamed/default with its preserved context", + fields: { contextId: "context-main", stackId: "stack-main-default", stack: "default" }, + }, json: { outcome: "reuse", branch: "renamed", @@ -1787,12 +1845,12 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ }, writes: [ { target: "git-config", operation: "create", id: "project-clone", scope: "common" }, - { target: "git-config", operation: "create", id: "checkout-clone", scope: "worktree" }, + { target: "git-checkout-id", operation: "create", id: "checkout-clone" }, { target: "git-config", operation: "create", id: "context-clone-main", - scope: "worktree", + scope: "common", owner: "main", }, { target: "registry", operation: "publish", id: "stack-clone-main-default" }, @@ -1807,6 +1865,16 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ git_index_mutated: false, }, output: { + human: { + summary: "Created main/default without inheriting the tracked marker", + fields: { + projectId: "project-clone", + checkoutId: "checkout-clone", + contextId: "context-clone-main", + stackId: "stack-clone-main-default", + stack: "default", + }, + }, json: { outcome: "create", project_id: "project-clone", @@ -1870,12 +1938,12 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ }, writes: [ { target: "git-config", operation: "create", id: "project-a", scope: "common" }, - { target: "git-config", operation: "create", id: "checkout-a", scope: "worktree" }, + { target: "git-checkout-id", operation: "create", id: "checkout-a" }, { target: "git-config", operation: "create", id: "context-main", - scope: "worktree", + scope: "common", owner: "main", }, { target: "runtime-state", operation: "start", id: "stack-main-default" }, @@ -1883,6 +1951,16 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], details: { git_index_mutated: false }, output: { + human: { + summary: "Started main/default after converting the folder to Git", + fields: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-main", + stackId: "stack-main-default", + stack: "default", + }, + }, json: { outcome: "reuse", project_id: "project-a", @@ -1933,12 +2011,12 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ }, writes: [ { target: "git-config", operation: "create", id: "project-git", scope: "common" }, - { target: "git-config", operation: "create", id: "checkout-git", scope: "worktree" }, + { target: "git-checkout-id", operation: "create", id: "checkout-git" }, { target: "git-config", operation: "create", id: "context-git-main", - scope: "worktree", + scope: "common", owner: "main", }, { target: "registry", operation: "publish", id: "stack-git-default" }, @@ -1948,6 +2026,16 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ runtimeEffects: [{ operation: "start", stackId: "stack-git-default" }], details: { project_identity_storage: "git-local", git_index_mutated: false }, output: { + human: { + summary: "Created main/default with fresh Git identity", + fields: { + projectId: "project-git", + checkoutId: "checkout-git", + contextId: "context-git-main", + stackId: "stack-git-default", + stack: "default", + }, + }, json: { outcome: "create", project_id: "project-git", @@ -1996,6 +2084,13 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ runtimeEffects: [], details: { git_index_mutated: false }, output: { + human: { + summary: "Cannot choose a folder identity for the Git repository", + fields: { code: "AMBIGUOUS_FOLDER_TO_GIT_IDENTITY" }, + recovery: [ + "Inspect the claims and explicitly adopt one identity or create a fresh Git identity", + ], + }, json: { outcome: "error", code: "AMBIGUOUS_FOLDER_TO_GIT_IDENTITY", @@ -2025,7 +2120,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ }, { kind: "checkout", path: "worktree-a", projectId: "project-bare", checkoutId: "checkout-a" }, { kind: "checkout", path: "worktree-b", projectId: "project-bare", checkoutId: "checkout-b" }, - { kind: "identity-claim", scope: "context", id: "context-b-main", status: "absent" }, + { kind: "identity-claim", scope: "context", id: "context-main", status: "absent" }, ], when: { interface: "managed-api", @@ -2037,7 +2132,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ selection: { projectId: "project-bare", checkoutId: "checkout-b", - contextId: "context-b-main", + contextId: "context-main", stackId: "stack-b-main-default", stackName: "default", }, @@ -2045,8 +2140,8 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ { target: "git-config", operation: "create", - id: "context-b-main", - scope: "worktree", + id: "context-main", + scope: "common", owner: "main", }, { target: "registry", operation: "publish", id: "stack-b-main-default" }, @@ -2062,6 +2157,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ api: { projectId: "project-bare", checkoutId: "checkout-b", + contextId: "context-main", primaryWorktreeRequired: false, }, }, @@ -2349,6 +2445,14 @@ const additionalPortContractFixtures = defineManagedStackContractFixtures([ writes: [], runtimeEffects: [], output: { + human: { + summary: "Cannot start default because its sticky port is occupied", + fields: { code: "STICKY_PORT_OCCUPIED", stackId: "stack-feat-default", port: "55421" }, + recovery: [ + "Stop the process using port 55421", + "Delete and recreate the stack to allocate new automatic ports", + ], + }, json: { outcome: "error", code: "STICKY_PORT_OCCUPIED", @@ -2565,6 +2669,11 @@ const additionalPortContractFixtures = defineManagedStackContractFixtures([ partial_state: false, }, output: { + human: { + summary: "Cannot start while the matching legacy stack is running", + fields: { code: "LEGACY_SOURCE_RUNNING", port: "54321" }, + recovery: ["Stop the legacy stack, then retry supabase start --experimental"], + }, json: { outcome: "error", code: "LEGACY_SOURCE_RUNNING", @@ -2671,6 +2780,15 @@ const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ writes: [], runtimeEffects: [], output: { + human: { + summary: "Conflicting runtime selections", + fields: { + code: "RUNTIME_SELECTION_CONFLICT", + cliRuntime: "docker", + configRuntime: "native", + }, + recovery: ["Remove one runtime override", "Make the CLI and config runtime values agree"], + }, json: { outcome: "error", code: "RUNTIME_SELECTION_CONFLICT", @@ -2842,6 +2960,11 @@ const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ runtimeEffects: [], details: { fallback_attempted: false }, output: { + human: { + summary: "Docker is unavailable", + fields: { code: "DOCKER_UNAVAILABLE", requestedRuntime: "docker" }, + recovery: ["Start Docker", "Remove --runtime docker to use automatic selection"], + }, json: { outcome: "error", code: "DOCKER_UNAVAILABLE", @@ -2879,6 +3002,10 @@ const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], details: { runtime: "native", auto_re_evaluated: false }, output: { + human: { + summary: "Started main/default with its persisted runtime", + fields: { stackId: "stack-main-default", stack: "default", runtime: "native" }, + }, json: { outcome: "reuse", stack_id: "stack-main-default", @@ -2928,6 +3055,15 @@ const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ runtimeEffects: [], details: { switched_to_docker: false }, output: { + human: { + summary: "The persisted native runtime is unavailable", + fields: { code: "PERSISTED_RUNTIME_UNAVAILABLE", stackId: "stack-main-default" }, + recovery: [ + "Restore the native prerequisite", + "Create a new Docker named stack", + "Delete and recreate this stack", + ], + }, json: { outcome: "error", code: "PERSISTED_RUNTIME_UNAVAILABLE", @@ -3106,6 +3242,11 @@ const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ writes: [], runtimeEffects: [], output: { + human: { + summary: "Native mode is unsupported on darwin-x64", + fields: { code: "NATIVE_PLATFORM_UNSUPPORTED", platform: "darwin-x64" }, + recovery: ["Use Docker", "Use darwin-arm64, linux-amd64, or linux-arm64"], + }, json: { outcome: "error", code: "NATIVE_PLATFORM_UNSUPPORTED", @@ -3303,6 +3444,11 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ partial_state: false, }, output: { + human: { + summary: "The legacy stack must be stopped before bootstrap", + fields: { code: "LEGACY_SOURCE_RUNNING" }, + recovery: ["Stop the legacy stack", "Retry supabase start --experimental"], + }, json: { outcome: "error", code: "LEGACY_SOURCE_RUNNING", @@ -3437,6 +3583,10 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], details: { legacy_state_read: false, legacy_state_mutated: false, timelines_diverged: true }, output: { + human: { + summary: "Started main/default from independent managed state", + fields: { stackId: "stack-main-default", stack: "default" }, + }, json: { outcome: "reuse", stack_id: "stack-main-default", @@ -3507,6 +3657,10 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ plaintext_secrets_in_global_state: false, }, output: { + human: { + summary: "Created main/default with stable local credentials", + fields: { stackId: "stack-main-default", stack: "default" }, + }, json: { outcome: "create", stack_id: "stack-main-default", @@ -3539,6 +3693,10 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], details: { credential_values_id: "stable-local-defaults-v1", credentials_rotated: false }, output: { + human: { + summary: "Started main/default with unchanged credentials", + fields: { stackId: "stack-main-default", stack: "default" }, + }, json: { outcome: "reuse", stack_id: "stack-main-default", credentials_unchanged: true }, }, }, @@ -3574,6 +3732,10 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], details: { plaintext_secrets_in_global_state: false }, output: { + human: { + summary: "Updated credentials and started main/default", + fields: { stackId: "stack-main-default", stack: "default" }, + }, json: { outcome: "update", stack_id: "stack-main-default", @@ -3754,6 +3916,10 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ runtimeEffects: [], details: { tombstoned: true, idempotent: true }, output: { + human: { + summary: "Stack stack-orphan was already deleted", + fields: { stackId: "stack-orphan" }, + }, json: { outcome: "no-op", stack_id: "stack-orphan", @@ -3987,12 +4153,12 @@ const additionalApiBoundaryContractFixtures = defineManagedStackContractFixtures }, writes: [ { target: "git-config", operation: "create", id: "project-a", scope: "common" }, - { target: "git-config", operation: "create", id: "checkout-a", scope: "worktree" }, + { target: "git-checkout-id", operation: "create", id: "checkout-a" }, { target: "git-config", operation: "create", id: "context-main", - scope: "worktree", + scope: "common", owner: "main", }, { target: "registry", operation: "publish", id: "stack-main-default" }, diff --git a/packages/stack/src/testing.ts b/packages/stack/src/testing.ts index 689fa9e05e..f2316af105 100644 --- a/packages/stack/src/testing.ts +++ b/packages/stack/src/testing.ts @@ -12,6 +12,8 @@ export type { ManagedNativeServiceMatrix, } from "./managed-stack-contract.ts"; export { + managedNativePlatformByNodeTarget, + managedNativePlatformFromNode, managedNativeServiceMatrix, managedStackContractFixtures, } from "./managed-stack-contract.ts"; From f87061caffbfbcefe26b50a720471086423a2cac Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 11 Aug 2026 10:23:21 +0200 Subject: [PATCH 40/41] test(stack): tighten contract boundary fixtures --- .../0015-managed-stack-contract-fixtures.md | 4 + .../src/managed-stack-contract-validation.ts | 18 +++- ...managed-stack-contract.integration.test.ts | 92 ++++++++++++------ packages/stack/src/managed-stack-contract.ts | 97 ++++++++++++------- 4 files changed, 146 insertions(+), 65 deletions(-) diff --git a/docs/adr/0015-managed-stack-contract-fixtures.md b/docs/adr/0015-managed-stack-contract-fixtures.md index 3df3580bf9..4141a3ccd9 100644 --- a/docs/adr/0015-managed-stack-contract-fixtures.md +++ b/docs/adr/0015-managed-stack-contract-fixtures.md @@ -110,6 +110,10 @@ managed resolver and engine delivered by the implementation issues below. Furthe not by expanding this lint into a parallel rule engine. A new lint rule is appropriate only when it protects a generic fixture-format invariant across behavior areas. +Native-qualification facts describe the complete M5 launch-scope target, not the package's current +Docker-backed implementation. CLI-2121 through CLI-2141 attach the real native service graph to that +target contract. + ## Implementation Handoff The downstream implementation issues own the executable drivers, while this ADR and fixture data diff --git a/packages/stack/src/managed-stack-contract-validation.ts b/packages/stack/src/managed-stack-contract-validation.ts index aa2703d625..997359e5d9 100644 --- a/packages/stack/src/managed-stack-contract-validation.ts +++ b/packages/stack/src/managed-stack-contract-validation.ts @@ -117,7 +117,14 @@ export const validateManagedStackContractFixtures = ( errors.push(`${scenario.id}: default CLI invocation requires a human projection`); } } - if (output.human === undefined && output.json === undefined && output.api === undefined) { + const returnsVoid = + scenario.when.interface === "stack-api" && scenario.when.method === "dispose"; + if ( + !returnsVoid && + output.human === undefined && + output.json === undefined && + output.api === undefined + ) { errors.push(`${scenario.id}: at least one observable output is required`); } if (output.human !== undefined && output.human.summary.trim().length === 0) { @@ -266,6 +273,7 @@ export const validateManagedStackContractFixtures = ( declareId(fact.stackId); break; case "stack": + declareId(fact.checkoutId); declareId(fact.contextId); declareId(fact.stackId); break; @@ -332,6 +340,14 @@ export const validateManagedStackContractFixtures = ( `${scenario.id}: selected context ${selection.contextId} disagrees with stack ${selection.stackId}`, ); } + if ( + selectedStackFact?.kind === "stack" && + selectedStackFact.checkoutId !== selection.checkoutId + ) { + errors.push( + `${scenario.id}: selected checkout ${selection.checkoutId} disagrees with stack ${selection.stackId}`, + ); + } } for (const effect of scenario.expected.runtimeEffects) { diff --git a/packages/stack/src/managed-stack-contract.integration.test.ts b/packages/stack/src/managed-stack-contract.integration.test.ts index e6b782ce38..c83f3fab96 100644 --- a/packages/stack/src/managed-stack-contract.integration.test.ts +++ b/packages/stack/src/managed-stack-contract.integration.test.ts @@ -35,6 +35,11 @@ vi.mock("node:fs", async (importOriginal) => { }; }); +const projectDirectStackHandle = (stack: { readonly url: string; readonly dbUrl: string }) => ({ + url: stack.url.replace(/:\d+$/, ":"), + dbUrl: stack.dbUrl.replace(/:\d+\//, ":/"), +}); + describe("managed stack acceptance contract", () => { it("keeps every shared scenario readable and executable through a public interface", () => { expect(validateManagedStackContractFixtures(managedStackContractFixtures)).toEqual([]); @@ -56,7 +61,6 @@ describe("managed stack acceptance contract", () => { const readOnly = findScenario("identity.branch-copy-read-only-does-not-write"); const noOp = findScenario("reclamation.delete-repeat-is-idempotent"); const freshBootstrap = findScenario("bootstrap.absent-legacy-starts-fresh"); - const directDispose = findScenario("api-boundary.direct-dispose-removes-temporary-roots"); const repositoryContract = findScenario("api-boundary.repository-contract-is-storage-agnostic"); const persistedRuntime = findScenario("runtime.persisted-runtime-reused-for-auto"); const defaultOutputCli = findScenario("identity.non-git-folder-first-start-persists-identity"); @@ -73,7 +77,6 @@ describe("managed stack acceptance contract", () => { portConflict.expected.output.json === undefined || freshBootstrap.expected.details === undefined || freshBootstrap.expected.output.json === undefined || - directDispose.expected.output.api === undefined || repositoryAction.interface !== "managed-api" ) { throw new Error("lint examples require selected and structured fixture outputs"); @@ -315,20 +318,23 @@ describe("managed stack acceptance contract", () => { { fixtures: [ { - ...directDispose, - expected: { - ...directDispose.expected, - output: { - ...directDispose.expected.output, - api: { - ...directDispose.expected.output.api, - temporaryRootsRemoved: false, - }, + ...reuse, + given: [ + ...reuse.given, + { + kind: "checkout", + path: "checkout-b", + projectId: "project-a", + checkoutId: "checkout-b", }, + ], + expected: { + ...reuse.expected, + selection: { ...reuse.expected.selection, checkoutId: "checkout-b" }, }, }, ], - expectedError: `${directDispose.id}: projected temporaryRootsRemoved disagrees with the managed result`, + expectedError: `${reuse.id}: selected checkout checkout-b disagrees with stack stack-main-default`, }, { fixtures: managedStackContractFixtures.map((scenario) => @@ -527,6 +533,14 @@ describe("managed stack acceptance contract", () => { expect(forcedBranch).toMatchObject({ given: expect.arrayContaining([ expect.objectContaining({ kind: "branch", name: "main", contextId: "context-main" }), + { + kind: "stack", + name: "default", + stackId: "stack-a-main-default", + checkoutId: "checkout-a", + contextId: "context-main", + lifecycle: "running", + }, ]), expected: { selection: { @@ -540,19 +554,23 @@ describe("managed stack acceptance contract", () => { [], ); expect(bareWorktrees).toMatchObject({ + given: expect.arrayContaining([ + { + kind: "stack", + name: "default", + stackId: "stack-a-main-default", + checkoutId: "checkout-a", + contextId: "context-main", + lifecycle: "running", + }, + ]), expected: { selection: { checkoutId: "checkout-b", contextId: "context-main" }, - writes: expect.arrayContaining([ - { - target: "git-config", - operation: "create", - id: "context-main", - scope: "common", - owner: "main", - }, - ]), }, }); + expect(bareWorktrees.expected.writes.filter((write) => write.target === "git-config")).toEqual( + [], + ); }); it("does not rewrite branch context after Git has preserved a rename", () => { @@ -829,6 +847,12 @@ describe("managed stack acceptance contract", () => { }); it("keeps public createStack usage isolated when state roots are omitted", async () => { + const scenario: ManagedStackContractScenario | undefined = managedStackContractFixtures.find( + ({ id }) => id === "api-boundary.direct-create-stack-is-ephemeral", + ); + if (scenario?.expected.output.api === undefined) { + throw new Error("direct createStack fixture requires its public API projection"); + } const testRoot = mkdtempSync(join(tmpdir(), "supabase-direct-contract-")); const projectDir = join(testRoot, "project"); const cacheRoot = join(testRoot, "cache"); @@ -846,12 +870,9 @@ describe("managed stack acceptance contract", () => { try { const stack = await createStack({ cacheRoot, projectDir, startupMode: "lazy" }); try { - expect(stack).toMatchObject({ - url: expect.stringMatching(/^http:\/\/127\.0\.0\.1:/), - dbUrl: expect.stringMatching(/^postgresql:\/\//), - }); + expect(projectDirectStackHandle(stack)).toEqual(scenario.expected.output.api); } finally { - await stack.dispose(); + expect(await stack.dispose()).toBeUndefined(); } expect(readFileSync(gitConfig, "utf8")).toBe("[core]\n\trepositoryformatversion = 0\n"); @@ -870,6 +891,16 @@ describe("managed stack acceptance contract", () => { const explicitRootKinds: ReadonlyArray<"runtime" | "stack"> = ["stack", "runtime"]; for (const explicitRootKind of explicitRootKinds) { + const scenarioId = + explicitRootKind === "stack" + ? "api-boundary.direct-create-stack-keeps-omitted-runtime-root-temporary" + : "api-boundary.direct-create-stack-keeps-omitted-stack-root-temporary"; + const scenario: ManagedStackContractScenario | undefined = managedStackContractFixtures.find( + ({ id }) => id === scenarioId, + ); + if (scenario?.expected.output.api === undefined) { + throw new Error(`${scenarioId} fixture requires its public API projection`); + } const testRoot = mkdtempSync(join(tmpdir(), "supabase-partial-root-contract-")); const projectDir = join(testRoot, "project"); const cacheRoot = join(testRoot, "cache"); @@ -900,10 +931,11 @@ describe("managed stack acceptance contract", () => { } try { + expect(projectDirectStackHandle(stack)).toEqual(scenario.expected.output.api); expect(generatedRoots).toHaveLength(1); expect(existsSync(generatedRoot)).toBe(true); } finally { - await stack.dispose(); + expect(await stack.dispose()).toBeUndefined(); } expect(existsSync(generatedRoot)).toBe(false); @@ -942,6 +974,7 @@ describe("managed stack acceptance contract", () => { name: "default", stackId: "stack-main-default", contextId: "context-main", + checkoutId: "checkout-a", lifecycle: "stopped", }, ], @@ -1205,6 +1238,7 @@ describe("managed stack acceptance contract", () => { { kind: "stack", stackId: "stack-orphan", + checkoutId: "checkout-orphan", lifecycle: "running", orphaned: true, }, @@ -1279,8 +1313,8 @@ describe("managed stack acceptance contract", () => { }, output: { api: { - handle: "stack-handle", - temporaryRoots: ["stack", "runtime"], + url: "http://127.0.0.1:", + dbUrl: "postgresql://postgres:postgres@127.0.0.1:/postgres", }, }, }, diff --git a/packages/stack/src/managed-stack-contract.ts b/packages/stack/src/managed-stack-contract.ts index 6a7cbbcac6..d41429c98b 100644 --- a/packages/stack/src/managed-stack-contract.ts +++ b/packages/stack/src/managed-stack-contract.ts @@ -117,6 +117,7 @@ export type ManagedStackContractFact = readonly kind: "stack"; readonly name: string; readonly stackId: string; + readonly checkoutId: string; readonly contextId: string; readonly lifecycle: "running" | "stopped"; readonly orphaned?: boolean; @@ -377,6 +378,11 @@ export const managedNativeServiceMatrix: ManagedNativeServiceMatrix = { ]), }; +const directStackApiProjection = { + url: "http://127.0.0.1:", + dbUrl: "postgresql://postgres:postgres@127.0.0.1:/postgres", +}; + const defineManagedStackContractFixtures = < const Fixtures extends ReadonlyArray, >( @@ -405,6 +411,7 @@ const branchHistoryFixture = ( kind: "stack", name: "default", stackId: "stack-feat-default", + checkoutId: "checkout-a", contextId: "context-feat", lifecycle: "stopped", }, @@ -512,6 +519,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ kind: "stack", name: "default", stackId: "stack-main-default", + checkoutId: "checkout-a", contextId: "context-main", lifecycle: "running", }, @@ -642,6 +650,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ kind: "stack", name: "default", stackId: "stack-feat-default", + checkoutId: "checkout-a", contextId: "context-feat", lifecycle: "stopped", }, @@ -752,6 +761,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ kind: "stack", name: "default", stackId: "stack-feat-default", + checkoutId: "checkout-a", contextId: "context-feat", lifecycle: "running", }, @@ -884,6 +894,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ kind: "stack", name: "default", stackId: "stack-detached-default", + checkoutId: "checkout-a", contextId: "context-detached", lifecycle: "stopped", }, @@ -992,6 +1003,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ kind: "stack", name: "default", stackId: "stack-workspace-default", + checkoutId: "checkout-a", contextId: "context-workspace", lifecycle: "stopped", }, @@ -1103,6 +1115,14 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ { kind: "checkout", path: "worktree-a", projectId: "project-a", checkoutId: "checkout-a" }, { kind: "checkout", path: "worktree-b", projectId: "project-a", checkoutId: "checkout-b" }, { kind: "branch", name: "main", contextId: "context-main", checkedOut: true }, + { + kind: "stack", + name: "default", + stackId: "stack-a-main-default", + checkoutId: "checkout-a", + contextId: "context-main", + lifecycle: "running", + }, ], when: { interface: "managed-api", @@ -1145,6 +1165,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ kind: "stack", name: "default", stackId: "stack-feat-default", + checkoutId: "checkout-a", contextId: "context-feat", lifecycle: "running", }, @@ -1215,6 +1236,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ kind: "stack", name: "default", stackId: "stack-main-default", + checkoutId: "checkout-a", contextId: "context-main", lifecycle: "stopped", }, @@ -1282,6 +1304,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ kind: "stack", name: "default", stackId: "stack-main-default", + checkoutId: "checkout-a", contextId: "context-main", lifecycle: "running", }, @@ -1459,6 +1482,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ kind: "stack", name: "default", stackId: "stack-main-default", + checkoutId: "checkout-a", contextId: "context-main", lifecycle: "stopped", }, @@ -1784,6 +1808,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ kind: "stack", name: "default", stackId: "stack-main-default", + checkoutId: "checkout-a", contextId: "context-main", lifecycle: "stopped", }, @@ -1922,6 +1947,7 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ kind: "stack", name: "default", stackId: "stack-main-default", + checkoutId: "checkout-a", contextId: "context-main", lifecycle: "stopped", }, @@ -2120,7 +2146,15 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ }, { kind: "checkout", path: "worktree-a", projectId: "project-bare", checkoutId: "checkout-a" }, { kind: "checkout", path: "worktree-b", projectId: "project-bare", checkoutId: "checkout-b" }, - { kind: "identity-claim", scope: "context", id: "context-main", status: "absent" }, + { kind: "branch", name: "main", contextId: "context-main", checkedOut: true }, + { + kind: "stack", + name: "default", + stackId: "stack-a-main-default", + checkoutId: "checkout-a", + contextId: "context-main", + lifecycle: "running", + }, ], when: { interface: "managed-api", @@ -2137,13 +2171,6 @@ const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ stackName: "default", }, writes: [ - { - target: "git-config", - operation: "create", - id: "context-main", - scope: "common", - owner: "main", - }, { target: "registry", operation: "publish", id: "stack-b-main-default" }, { target: "managed-state", operation: "create", id: "stack-b-main-default" }, { target: "runtime-state", operation: "start", id: "stack-b-main-default" }, @@ -2236,6 +2263,7 @@ const additionalPortContractFixtures = defineManagedStackContractFixtures([ kind: "stack", name: "default", stackId: "stack-main-default", + checkoutId: "checkout-a", contextId: "context-main", lifecycle: "stopped", }, @@ -2366,6 +2394,7 @@ const additionalPortContractFixtures = defineManagedStackContractFixtures([ kind: "stack", name: "default", stackId: "stack-feat-default", + checkoutId: "checkout-a", contextId: "context-feat", lifecycle: "stopped", }, @@ -2412,6 +2441,7 @@ const additionalPortContractFixtures = defineManagedStackContractFixtures([ kind: "stack", name: "default", stackId: "stack-feat-default", + checkoutId: "checkout-a", contextId: "context-feat", lifecycle: "stopped", }, @@ -2478,6 +2508,7 @@ const additionalPortContractFixtures = defineManagedStackContractFixtures([ kind: "stack", name: "default", stackId: "stack-main-default", + checkoutId: "checkout-a", contextId: "context-main", lifecycle: "stopped", }, @@ -2527,6 +2558,7 @@ const additionalPortContractFixtures = defineManagedStackContractFixtures([ kind: "stack", name: "default", stackId: "stack-main-default", + checkoutId: "checkout-a", contextId: "context-main", lifecycle: "running", }, @@ -2595,6 +2627,7 @@ const additionalPortContractFixtures = defineManagedStackContractFixtures([ kind: "stack", name: "default", stackId: "stack-main-default", + checkoutId: "checkout-a", contextId: "context-main", lifecycle: "stopped", }, @@ -2987,6 +3020,7 @@ const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ kind: "stack", name: "default", stackId: "stack-main-default", + checkoutId: "checkout-a", contextId: "context-main", lifecycle: "stopped", }, @@ -3026,6 +3060,7 @@ const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ kind: "stack", name: "default", stackId: "stack-main-default", + checkoutId: "checkout-a", contextId: "context-main", lifecycle: "stopped", }, @@ -3089,6 +3124,7 @@ const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ kind: "stack", name: "default", stackId: "stack-main-default", + checkoutId: "checkout-a", contextId: "context-main", lifecycle: "running", }, @@ -3317,6 +3353,7 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ kind: "stack", name: "default", stackId: "stack-main-default", + checkoutId: "checkout-a", contextId: "context-main", lifecycle: "stopped", }, @@ -3571,6 +3608,7 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ kind: "stack", name: "default", stackId: "stack-main-default", + checkoutId: "checkout-a", contextId: "context-main", lifecycle: "stopped", }, @@ -3680,6 +3718,7 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ kind: "stack", name: "default", stackId: "stack-main-default", + checkoutId: "checkout-a", contextId: "context-main", lifecycle: "stopped", }, @@ -3711,6 +3750,7 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ kind: "stack", name: "default", stackId: "stack-main-default", + checkoutId: "checkout-a", contextId: "context-main", lifecycle: "stopped", }, @@ -3755,6 +3795,7 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ kind: "stack", name: "default", stackId: "stack-main-default", + checkoutId: "checkout-a", contextId: "context-main", lifecycle: "running", }, @@ -3883,6 +3924,7 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ kind: "stack", name: "default", stackId: "stack-main-default", + checkoutId: "checkout-a", contextId: "context-main", lifecycle: "running", }, @@ -3950,6 +3992,7 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ kind: "stack", name: "default", stackId: "stack-feat-default", + checkoutId: "checkout-a", contextId: "context-feat", lifecycle: "stopped", }, @@ -3978,6 +4021,7 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ kind: "stack", name: "default", stackId: "stack-orphan", + checkoutId: "checkout-orphan", contextId: "context-orphan", lifecycle: "stopped", orphaned: true, @@ -4028,6 +4072,7 @@ const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ kind: "stack", name: "default", stackId: "stack-main-default", + checkoutId: "checkout-a", contextId: "context-main", lifecycle: "running", }, @@ -4247,6 +4292,7 @@ const additionalApiBoundaryContractFixtures = defineManagedStackContractFixtures kind: "stack", name: "default", stackId: "stack-main-default", + checkoutId: "checkout-a", contextId: "context-main", lifecycle: "running", }, @@ -4368,6 +4414,7 @@ export const managedStackContractFixtures = defineManagedStackContractFixtures([ kind: "stack", name: "default", stackId: "stack-main-default", + checkoutId: "checkout-a", contextId: "context-main", lifecycle: "stopped", }, @@ -4645,6 +4692,7 @@ export const managedStackContractFixtures = defineManagedStackContractFixtures([ kind: "stack", name: "default", stackId: "stack-main-default", + checkoutId: "checkout-a", contextId: "context-main", lifecycle: "stopped", }, @@ -4787,6 +4835,7 @@ export const managedStackContractFixtures = defineManagedStackContractFixtures([ kind: "stack", name: "default", stackId: "stack-orphan", + checkoutId: "checkout-orphan", contextId: "context-orphan", lifecycle: "running", orphaned: true, @@ -4869,12 +4918,7 @@ export const managedStackContractFixtures = defineManagedStackContractFixtures([ global_registry_mutated: false, temporary_roots: ["stack", "runtime"], }, - output: { - api: { - handle: "stack-handle", - temporaryRoots: ["stack", "runtime"], - }, - }, + output: { api: directStackApiProjection }, }, }, { @@ -4915,12 +4959,7 @@ export const managedStackContractFixtures = defineManagedStackContractFixtures([ global_registry_mutated: false, temporary_roots: ["runtime"], }, - output: { - api: { - handle: "partial-stack-handle", - temporaryRoots: ["runtime"], - }, - }, + output: { api: directStackApiProjection }, }, }, { @@ -4956,12 +4995,7 @@ export const managedStackContractFixtures = defineManagedStackContractFixtures([ global_registry_mutated: false, temporary_roots: ["stack"], }, - output: { - api: { - handle: "partial-stack-handle", - temporaryRoots: ["stack"], - }, - }, + output: { api: directStackApiProjection }, }, }, { @@ -4982,7 +5016,7 @@ export const managedStackContractFixtures = defineManagedStackContractFixtures([ when: { interface: "stack-api", method: "dispose", - input: { handle: "stack-handle" }, + input: {}, }, expected: { outcome: "delete", @@ -5005,14 +5039,7 @@ export const managedStackContractFixtures = defineManagedStackContractFixtures([ temporary_roots_removed: true, removed_temporary_roots: ["stack", "runtime"], }, - output: { - api: { - handle: "stack-handle", - disposed: true, - temporaryRootsRemoved: true, - removedTemporaryRoots: ["stack", "runtime"], - }, - }, + output: {}, }, }, ]); From 53ec048109226041c73898bb86c809f1679b1903 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 11 Aug 2026 10:36:46 +0200 Subject: [PATCH 41/41] test(stack): verify direct stack cleanup --- ...managed-stack-contract.integration.test.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/packages/stack/src/managed-stack-contract.integration.test.ts b/packages/stack/src/managed-stack-contract.integration.test.ts index c83f3fab96..2fbbec2924 100644 --- a/packages/stack/src/managed-stack-contract.integration.test.ts +++ b/packages/stack/src/managed-stack-contract.integration.test.ts @@ -40,6 +40,22 @@ const projectDirectStackHandle = (stack: { readonly url: string; readonly dbUrl: dbUrl: stack.dbUrl.replace(/:\d+\//, ":/"), }); +const snapshotDirectoryTree = (root: string): ReadonlyArray => { + const paths: Array = []; + const visit = (directory: string, relativeDirectory: string): void => { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const relativePath = join(relativeDirectory, entry.name); + paths.push(entry.isDirectory() ? `${relativePath}/` : relativePath); + if (entry.isDirectory()) { + visit(join(directory, entry.name), relativePath); + } + } + }; + + visit(root, ""); + return paths.sort(); +}; + describe("managed stack acceptance contract", () => { it("keeps every shared scenario readable and executable through a public interface", () => { expect(validateManagedStackContractFixtures(managedStackContractFixtures)).toEqual([]); @@ -866,16 +882,23 @@ describe("managed stack acceptance contract", () => { writeFileSync(gitConfig, "[core]\n\trepositoryformatversion = 0\n"); writeFileSync(identityMarker, '{"sentinel":true}\n'); writeFileSync(registrySentinel, '{"sentinel":true}\n'); + const gitTreeBefore = snapshotDirectoryTree(join(projectDir, ".git")); try { + const createdRootIndex = createdTempRoots.length; const stack = await createStack({ cacheRoot, projectDir, startupMode: "lazy" }); + const generatedRoots = createdTempRoots.slice(createdRootIndex); try { expect(projectDirectStackHandle(stack)).toEqual(scenario.expected.output.api); + expect(generatedRoots).toHaveLength(2); + expect(generatedRoots.every(existsSync)).toBe(true); } finally { expect(await stack.dispose()).toBeUndefined(); } + expect(generatedRoots.every((root) => !existsSync(root))).toBe(true); expect(readFileSync(gitConfig, "utf8")).toBe("[core]\n\trepositoryformatversion = 0\n"); + expect(snapshotDirectoryTree(join(projectDir, ".git"))).toEqual(gitTreeBefore); expect(readFileSync(identityMarker, "utf8")).toBe('{"sentinel":true}\n'); expect(readFileSync(registrySentinel, "utf8")).toBe('{"sentinel":true}\n'); expect(existsSync(join(cacheRoot, "projects"))).toBe(false);