diff --git a/CONTEXT.md b/CONTEXT.md index 864f8ca..aa1646c 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -8,6 +8,10 @@ This context describes how one plugin moves from authoring into Claude Code and An agent environment that discovers, installs, and executes plugins. Claude Code and Codex are the supported harnesses. _Avoid_: Host, runtime environment +**Harness Identity**: +The canonical lowercase discriminator for a supported Harness together with its harness-owned manifest directory, hook declaration path, plugin-root environment variable, and human-readable display name. +_Avoid_: Qualification Client, host identity + **Plugin Repository**: The workspace containing a plugin's source, tests, documentation, and release history. _Avoid_: Plugin, when referring to the whole repository diff --git a/docs/adr/0009-canonical-harness-identity.md b/docs/adr/0009-canonical-harness-identity.md new file mode 100644 index 0000000..328f6ce --- /dev/null +++ b/docs/adr/0009-canonical-harness-identity.md @@ -0,0 +1,39 @@ +# Establish canonical harness identity + +## Status + +Accepted — 2026-08-14. + +## Context + +Claude Code and Codex need shared vocabulary for payload identity while retaining +harness-specific discovery, trust, installation, and restoration behavior. The +same harness facts were previously re-derived across scripts, and qualification +client labels risked being mistaken for new harness or driver branches. + +[ADR-0001](0001-one-payload-native-harness-adapters.md) keeps native harness +adapters distinct around one payload. [ADR-0003](0003-reviewed-versioned-releases.md) +keeps Claude and Codex replacement state and lifecycle behavior distinct. The +identity vocabulary and driver seams must preserve both decisions. + +## Decision + +Use `claude` and `codex` as the canonical lowercase harness IDs. Keep their +harness-owned values in `scripts/harness-identity.ts`: + +- `claude` displays as `Claude`, reads its native manifest from + `.claude-plugin`, declares hooks at `./hooks/claude/hooks.json`, and receives + the installed plugin root through `CLAUDE_PLUGIN_ROOT`. +- `codex` displays as `Codex`, reads its native manifest from `.codex-plugin`, + declares hooks at `./hooks/codex/hooks.json`, and receives the installed + plugin root through `PLUGIN_ROOT`. + +Freeze `claude-cli` and `codex-cli` as the qualification-client IDs for CLI +journeys and receipts. Keep `codex-desktop` as vocabulary mapped to the `codex` +harness only; it does not create a desktop-specific code path. + +Give the Claude install driver a Claude-specific dependency-injection interface +parallel to `CodexDriverDependencies`. Preserve the same lifecycle shape: +preflight, capture, mutate, verify, then restore on failure. Carry harness-typed +state payloads through each driver. Never collapse Claude and Codex state into a +unioned common struct. diff --git a/docs/agents/doc-targets.yml b/docs/agents/doc-targets.yml index b6827f0..daabfbd 100644 --- a/docs/agents/doc-targets.yml +++ b/docs/agents/doc-targets.yml @@ -46,6 +46,8 @@ targets: - plugin/hooks/native-capability-hook - plugin/hooks/claude/hooks.json - plugin/hooks/codex/hooks.json + docs/adr/0009-canonical-harness-identity.md: + - scripts/harness-identity.ts # Payload docs. capability-tour/SKILL.md enumerates the skills in prose; # scripts/native-capability-surface.test.ts now guards that line, but the rest @@ -78,6 +80,7 @@ targets: CONTEXT.md: - .claude-plugin/marketplace.json - plugin/.codex-plugin/plugin.json + - scripts/harness-identity.ts # Deliberately uncovered: docs/releasing.md is a link index, docs/adr/0001 and # 0006 are stable rationale, and hello-world / skill-a / skill-b SKILL.md claim diff --git a/scripts/build.ts b/scripts/build.ts index 2f2cbde..14c1ef9 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -13,6 +13,7 @@ import { builtinModules } from "node:module" import { tmpdir } from "node:os" import { dirname, join, relative, resolve } from "node:path" +import { HARNESS_IDENTITIES } from "./harness-identity" import { loadPluginConfig } from "./plugin-config" import { compareCodeUnits, pluginPayloadInventory } from "./plugin-files" import { checkRuntimeCustodyFiles, loadSkillCatalog, shellQuote } from "./runtime-custody-config" @@ -1756,17 +1757,15 @@ export function validateBunOnlyPayload(root: string): void { throw new Error(`Bun payload closure: unexpected payload file ${path}`) } } - for (const manifestPath of [".claude-plugin/plugin.json", ".codex-plugin/plugin.json"] as const) { + for (const identity of Object.values(HARNESS_IDENTITIES)) { + const manifestPath = `${identity.manifestDirectory}/plugin.json` const manifest = JSON.parse(readFileSync(join(root, "plugin", manifestPath), "utf8")) as { hooks?: unknown description?: unknown interface?: { capabilities?: unknown } [key: string]: unknown } - const expectedHooks = - manifestPath === ".claude-plugin/plugin.json" - ? "./hooks/claude/hooks.json" - : "./hooks/codex/hooks.json" + const expectedHooks = identity.hooksDeclarationPath if (manifest.hooks !== expectedHooks) { throw new Error(`Bun payload closure: invalid hook declaration in ${manifestPath}`) } diff --git a/scripts/codex-production-update.ts b/scripts/codex-production-update.ts index 11c3e7f..33b14af 100644 --- a/scripts/codex-production-update.ts +++ b/scripts/codex-production-update.ts @@ -19,6 +19,7 @@ import { assertExactHarnessRecovery, type HarnessRecoverySnapshot, } from "./harness-install-recovery" +import type { HarnessId } from "./harness-identity" import { loadPluginConfig } from "./plugin-config" import { payloadInventorySha256, pluginPayloadInventory } from "./plugin-files" @@ -188,7 +189,7 @@ export interface CodexProductionUpdateResult { /** Whether the invocation previewed or applied the transaction. */ mode: "preview" | "apply" /** Only supported production-update harness. */ - harness: "codex" + harness: HarnessId /** Whether this invocation changed native state. */ changed: boolean /** Whether selected and prior Releases differ. */ @@ -1085,7 +1086,7 @@ function restorePriorRelease( const restored = inspectCurrentState(repositoryRoot, environment, "recovery") verifyReleaseState(restored, restoration, current.source, current.enabled, addResult.installedPath) try { - assertExactHarnessRecovery(recoverySnapshot(current), recoverySnapshot(restored), "Codex") + assertExactHarnessRecovery(recoverySnapshot(current), recoverySnapshot(restored), "codex") } catch { throw new CodexProductionUpdateError( "recovery", diff --git a/scripts/dev.ts b/scripts/dev.ts index bdd69cd..a27ab20 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -7,6 +7,11 @@ import { } from "node:fs" import { join, resolve } from "node:path" +import { + HARNESS_IDENTITIES, + QUALIFICATION_CLIENT_HARNESSES, + type HarnessId, +} from "./harness-identity" import { copyPluginPayload } from "./plugin-files" import { loadPluginConfig } from "./plugin-config" @@ -56,10 +61,8 @@ Examples: bun run dev -- claude --dry-run --json ` -type Harness = "claude" | "codex" - interface Options { - harness: Harness + harness: HarnessId check: boolean launch: boolean dryRun: boolean @@ -78,8 +81,8 @@ function parseOptions(arguments_: string[]): Options | null { return null } - const harness = arguments_[0] - if (harness !== "claude" && harness !== "codex") fail(`unknown command: ${harness}`) + const harness = arguments_[0] as HarnessId + if (!Object.hasOwn(HARNESS_IDENTITIES, harness)) fail(`unknown command: ${harness}`) const flags = new Set(arguments_.slice(1)) for (const flag of flags) { @@ -235,22 +238,23 @@ async function main(): Promise { if (!options) return if (options.dryRun) { + const isClaude = options.harness === QUALIFICATION_CLIENT_HARNESSES["claude-cli"] const plan = { harness: options.harness, build: "bun run build", - source: options.harness === "claude" ? pluginRoot : stagedPluginRoot, + source: isClaude ? pluginRoot : stagedPluginRoot, install: - options.harness === "claude" + isClaude ? `claude --settings ${JSON.stringify(claudeSessionSettings)} --plugin-dir ${JSON.stringify(pluginRoot)}` : `codex plugin add ${pluginName}@${developmentMarketplaceName}`, reload: - options.harness === "claude" + isClaude ? "Run /reload-plugins after the watcher rebuilds" : "Start a fresh Codex task after reinstall", } if (options.json) console.log(JSON.stringify(plan)) else console.log(Object.values(plan).join("\n")) - } else if (options.harness === "claude") { + } else if (options.harness === QUALIFICATION_CLIENT_HARNESSES["claude-cli"]) { await runClaude(options) } else { runCodex(options) diff --git a/scripts/harness-identity.test.ts b/scripts/harness-identity.test.ts new file mode 100644 index 0000000..390217d --- /dev/null +++ b/scripts/harness-identity.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from "bun:test" + +import { + HARNESS_IDENTITIES, + QUALIFICATION_CLIENT_HARNESSES, + type HarnessId, + type QualificationClient, +} from "./harness-identity" + +describe("harness identities", () => { + test("preserve canonical paths, environment variables, and display names", () => { + const expected = { + claude: { + hooksDeclarationPath: "./hooks/claude/hooks.json", + manifestDirectory: ".claude-plugin", + pluginRootEnvVar: "CLAUDE_PLUGIN_ROOT", + displayName: "Claude", + }, + codex: { + hooksDeclarationPath: "./hooks/codex/hooks.json", + manifestDirectory: ".codex-plugin", + pluginRootEnvVar: "PLUGIN_ROOT", + displayName: "Codex", + }, + } as const satisfies Record + + expect(HARNESS_IDENTITIES).toEqual(expected) + expect(Object.keys(HARNESS_IDENTITIES).sort()).toEqual(["claude", "codex"]) + expect(HARNESS_IDENTITIES.codex.pluginRootEnvVar).not.toBe("CODEX_PLUGIN_ROOT") + }) +}) + +describe("qualification clients", () => { + test("map every client to its canonical harness", () => { + const expected = { + "claude-cli": "claude", + "codex-cli": "codex", + "codex-desktop": "codex", + } as const satisfies Record + + expect(QUALIFICATION_CLIENT_HARNESSES).toEqual(expected) + expect(Object.keys(QUALIFICATION_CLIENT_HARNESSES).sort()).toEqual([ + "claude-cli", + "codex-cli", + "codex-desktop", + ]) + }) +}) diff --git a/scripts/harness-identity.ts b/scripts/harness-identity.ts new file mode 100644 index 0000000..0582a63 --- /dev/null +++ b/scripts/harness-identity.ts @@ -0,0 +1,77 @@ +/** + * Harness-owned paths and presentation vocabulary. + * + * @example + * ```typescript + * const identity: HarnessIdentity = HARNESS_IDENTITIES.claude + * ``` + */ +export interface HarnessIdentity { + /** Plugin-relative hook declaration referenced by the native manifest. */ + hooksDeclarationPath: string + /** Plugin-relative directory containing the native manifest. */ + manifestDirectory: string + /** Host-provided environment variable containing the installed plugin root. */ + pluginRootEnvVar: string + /** Human-readable harness name used in diagnostics. */ + displayName: string +} + +/** + * Canonical harness identities and their native integration values. + * + * @example + * ```typescript + * const hooksPath = HARNESS_IDENTITIES.claude.hooksDeclarationPath + * ``` + */ +export const HARNESS_IDENTITIES = { + claude: { + hooksDeclarationPath: "./hooks/claude/hooks.json", + manifestDirectory: ".claude-plugin", + pluginRootEnvVar: "CLAUDE_PLUGIN_ROOT", + displayName: "Claude", + }, + codex: { + hooksDeclarationPath: "./hooks/codex/hooks.json", + manifestDirectory: ".codex-plugin", + pluginRootEnvVar: "PLUGIN_ROOT", + displayName: "Codex", + }, +} as const satisfies Record + +/** + * Canonical lowercase harness discriminator. + * + * @example + * ```typescript + * const harness: HarnessId = "codex" + * ``` + */ +export type HarnessId = keyof typeof HARNESS_IDENTITIES + +/** + * Qualification clients mapped to the harness whose payload they exercise. + * + * `codex-desktop` is vocabulary only; this registry does not create a desktop code path. + * + * @example + * ```typescript + * const harness = QUALIFICATION_CLIENT_HARNESSES["codex-desktop"] + * ``` + */ +export const QUALIFICATION_CLIENT_HARNESSES = { + "claude-cli": "claude", + "codex-cli": "codex", + "codex-desktop": "codex", +} as const satisfies Record + +/** + * Client vocabulary used by native qualification receipts and journeys. + * + * @example + * ```typescript + * const client: QualificationClient = "claude-cli" + * ``` + */ +export type QualificationClient = keyof typeof QUALIFICATION_CLIENT_HARNESSES diff --git a/scripts/harness-install-claude.ts b/scripts/harness-install-claude.ts new file mode 100644 index 0000000..bab9c80 --- /dev/null +++ b/scripts/harness-install-claude.ts @@ -0,0 +1,251 @@ +import { mkdirSync, readFileSync, writeFileSync } from "node:fs" +import { dirname, join } from "node:path" + +import { provePostMutationRecovery } from "./harness-install-recovery" +import { QUALIFICATION_CLIENT_HARNESSES } from "./harness-identity" +import { CLAUDE_DISABLED_BY_DEFAULT_COMPATIBILITY } from "./plugin-config" +import type { + ClaudeInstall, + ClaudeProof, + ClaudeScope, + ClaudeScopeProof, + FixtureRelease, + TaggedCheckout, +} from "./prove-harness-install" + +/** + * Claude-shaped native operations kept injectable across local and hosted install proofs. + * + * The surface follows Claude's scoped marketplace lifecycle instead of mirroring Codex JSON APIs. + */ +export interface ClaudeDriverDependencies { + addMarketplace: ( + executable: string, + marketplaceRoot: string, + scope: ClaudeScope, + environment: Record, + cwd: string, + ) => void + command: ( + commandArguments: string[], + options: { cwd: string; env: Record }, + ) => void + comparePayload: (checkout: TaggedCheckout, installedPath: string) => string[] + environment: (home: string) => Record + findInstall: ( + executable: string, + environment: Record, + cwd: string, + pluginId: string, + scope: ClaudeScope, + ) => ClaudeInstall + replaceInstall: ( + executable: string, + pluginId: string, + marketplaceName: string, + marketplaceRoot: string, + scope: ClaudeScope, + environment: Record, + cwd: string, + ) => ClaudeInstall +} + +/** + * Prove Claude's scoped install, replacement, rollback, and recovery lifecycle. + * + * @param fixture - Immutable base and target release checkouts + * @param pluginName - Shared plugin and marketplace identity + * @param claudeExecutable - Native Claude CLI executable + * @param temporaryRoot - Isolated proof root + * @param dependencies - Claude-specific native operations + * @returns Native Claude proof with one result per supported scope + * @throws {Error} When native state, bytes, or recovery differ from the captured release + */ +export function proveClaudeNative( + fixture: FixtureRelease, + pluginName: string, + claudeExecutable: string, + temporaryRoot: string, + dependencies: ClaudeDriverDependencies, +): ClaudeProof { + const marketplaceName = pluginName + const pluginId = `${pluginName}@${marketplaceName}` + const scopeResults: ClaudeScopeProof[] = [] + let primary: ClaudeInstall | undefined + let primaryInventory: string[] = [] + for (const scope of ["user", "project", "local"] as const) { + const home = join(temporaryRoot, "claude", scope, "home") + const project = join(temporaryRoot, "claude", scope, "project") + mkdirSync(home, { recursive: true }) + mkdirSync(project, { recursive: true }) + const environment = dependencies.environment(home) + dependencies.addMarketplace( + claudeExecutable, + fixture.base.checkoutRoot, + scope, + environment, + project, + ) + dependencies.command([claudeExecutable, "plugin", "install", pluginId, "--scope", scope], { + cwd: project, + env: environment, + }) + const initial = dependencies.findInstall( + claudeExecutable, + environment, + project, + pluginId, + scope, + ) + if (initial.enabled) throw new Error("Claude installed a default-disabled plugin as enabled") + const initialInventory = dependencies.comparePayload(fixture.base, initial.activeCachePath) + const dataRoot = join(home, "plugins", "data", pluginId) + const markerPath = join(dataRoot, "u6-marker.txt") + mkdirSync(dataRoot, { recursive: true }) + writeFileSync(markerPath, `${scope} marker\n`) + const priorRecovery = { + source: fixture.base.checkoutRoot, + ref: fixture.base.requestedRef, + version: initial.version, + payloadInventory: initialInventory, + enabled: initial.enabled, + scope, + persistentData: readFileSync(markerPath, "utf8"), + } + + const upgraded = dependencies.replaceInstall( + claudeExecutable, + pluginId, + marketplaceName, + fixture.target.checkoutRoot, + scope, + environment, + project, + ) + if (upgraded.version !== fixture.target.manifestVersion) { + throw new Error(`Claude ${scope} upgrade reported the wrong version`) + } + const rolledBack = dependencies.replaceInstall( + claudeExecutable, + pluginId, + marketplaceName, + fixture.base.checkoutRoot, + scope, + environment, + project, + ) + if (rolledBack.version !== fixture.base.manifestVersion) { + throw new Error(`Claude ${scope} rollback reported the wrong version`) + } + if (readFileSync(markerPath, "utf8") !== `${scope} marker\n`) { + throw new Error(`Claude ${scope} persistent data did not survive replacement`) + } + const restoredAfterFailure = provePostMutationRecovery(priorRecovery, { + harness: QUALIFICATION_CLIENT_HARNESSES["claude-cli"], + mutate: () => { + dependencies.command( + [claudeExecutable, "plugin", "uninstall", pluginId, "--keep-data", "--scope", scope], + { cwd: project, env: environment }, + ) + dependencies.command( + [claudeExecutable, "plugin", "marketplace", "remove", marketplaceName, "--scope", scope], + { cwd: project, env: environment }, + ) + }, + restore: () => { + dependencies.addMarketplace( + claudeExecutable, + fixture.base.checkoutRoot, + scope, + environment, + project, + ) + dependencies.command( + [claudeExecutable, "plugin", "install", pluginId, "--scope", scope], + { + cwd: project, + env: environment, + }, + ) + const restored = dependencies.findInstall( + claudeExecutable, + environment, + project, + pluginId, + scope, + ) + return { + value: restored, + snapshot: { + source: fixture.base.checkoutRoot, + ref: fixture.base.requestedRef, + version: restored.version, + payloadInventory: dependencies.comparePayload( + fixture.base, + restored.activeCachePath, + ), + enabled: restored.enabled, + scope: restored.scope, + persistentData: readFileSync(markerPath, "utf8"), + }, + } + }, + }) + const failureRestored = true + dependencies.command([claudeExecutable, "plugin", "enable", pluginId, "--scope", scope], { + cwd: project, + env: environment, + }) + const activeAfterFailure = dependencies.findInstall( + claudeExecutable, + environment, + project, + pluginId, + scope, + ) + const orphanedCachePath = join(dirname(activeAfterFailure.activeCachePath), "0.0.0-orphaned") + mkdirSync(orphanedCachePath, { recursive: true }) + writeFileSync(join(orphanedCachePath, "orphan-marker.txt"), "not active\n") + const hostSelected = dependencies.findInstall( + claudeExecutable, + environment, + project, + pluginId, + scope, + ) + if (hostSelected.activeCachePath === orphanedCachePath) { + throw new Error("Claude proof selected an orphaned cache directory") + } + const inventory = dependencies.comparePayload(fixture.base, hostSelected.activeCachePath) + scopeResults.push({ + scope, + initialVersion: initial.version, + initialEnabled: initial.enabled, + upgradedVersion: upgraded.version, + rolledBackVersion: rolledBack.version, + enabledAfterReview: hostSelected.enabled, + dataMarkerPreserved: true, + failureRestored, + orphanedCacheIgnored: true, + activeCachePath: hostSelected.activeCachePath, + }) + if (scope === "user") { + primary = hostSelected + primaryInventory = inventory + } + } + if (!primary) throw new Error("Claude user-scope proof did not run") + return { + mode: "native-local-marketplace", + version: primary.version, + scope: primary.scope, + enabled: primary.enabled, + activeCachePath: primary.activeCachePath, + inventory: primaryInventory, + requestedRef: fixture.base.requestedRef, + resolvedSha: fixture.base.resolvedSha, + defaultEnabled: false, + compatibility: CLAUDE_DISABLED_BY_DEFAULT_COMPATIBILITY, + scopes: scopeResults, + } +} diff --git a/scripts/harness-install-codex.ts b/scripts/harness-install-codex.ts index 2ac5245..f895b87 100644 --- a/scripts/harness-install-codex.ts +++ b/scripts/harness-install-codex.ts @@ -119,7 +119,7 @@ export function proveCodexNative( restored = provePostMutationRecovery( priorRecovery, { - harness: "Codex", + harness: "codex", mutate: () => { dependencies.remove(codexExecutable, pluginId, marketplaceName, environment, project) }, diff --git a/scripts/harness-install-recovery.test.ts b/scripts/harness-install-recovery.test.ts index 7e5218c..71b239f 100644 --- a/scripts/harness-install-recovery.test.ts +++ b/scripts/harness-install-recovery.test.ts @@ -25,7 +25,7 @@ const prior: HarnessRecoverySnapshot = { test("post-mutation failure enters the recovery handler and returns its restored value", () => { const phases: string[] = [] const result = provePostMutationRecovery(prior, { - harness: "Claude", + harness: "claude", mutate: () => phases.push("removed"), restore: () => { phases.push("restored") @@ -41,7 +41,7 @@ test("unexpected mutation failure does not masquerade as the injected recovery p let restored = false expect(() => provePostMutationRecovery(prior, { - harness: "Claude", + harness: "claude", mutate: () => { throw new Error("real removal failure") }, @@ -71,7 +71,7 @@ test.each([ ] as const)("exact recovery rejects mismatched %s", (field, value) => { expect(() => provePostMutationRecovery(prior, { - harness: "Claude", + harness: "claude", mutate: () => {}, restore: () => ({ value: "restored", snapshot: { ...prior, [field]: value } }), }), @@ -79,5 +79,5 @@ test.each([ }) test("exact recovery accepts an identical captured snapshot", () => { - expect(() => assertExactHarnessRecovery(prior, structuredClone(prior), "Claude")).not.toThrow() + expect(() => assertExactHarnessRecovery(prior, structuredClone(prior), "claude")).not.toThrow() }) diff --git a/scripts/harness-install-recovery.ts b/scripts/harness-install-recovery.ts index 31bafa3..1c9c997 100644 --- a/scripts/harness-install-recovery.ts +++ b/scripts/harness-install-recovery.ts @@ -1,3 +1,5 @@ +import { HARNESS_IDENTITIES, type HarnessId } from "./harness-identity" + /** * Captured harness state that recovery must reproduce without approximation. * @@ -50,15 +52,15 @@ export interface HarnessRecoverySnapshot { * @example * ```typescript * const adapter: HarnessRecoveryAdapter = { - * harness: "Codex", + * harness: "codex", * mutate: () => removePlugin(), * restore: () => ({ value: "restored", snapshot: restoredSnapshot }), * } * ``` */ export interface HarnessRecoveryAdapter { - /** Harness name used in actionable failures. */ - harness: "Claude" | "Codex" + /** Canonical harness ID used in actionable failures. */ + harness: HarnessId /** Complete destructive phase after which the fault is injected. */ mutate: () => void /** Real restoration path whose result is compared with prior state. */ @@ -66,13 +68,17 @@ export interface HarnessRecoveryAdapter { } class InjectedPostMutationFailure extends Error { - constructor(harness: "Claude" | "Codex") { - super(`${harness} injected post-mutation failure`) + constructor(harness: HarnessId) { + super(`${harnessDisplayName(harness)} injected post-mutation failure`) this.name = "InjectedPostMutationFailure" } } -function injectPostMutationFailure(harness: "Claude" | "Codex"): never { +function harnessDisplayName(harness: HarnessId): string { + return HARNESS_IDENTITIES[harness].displayName +} + +function injectPostMutationFailure(harness: HarnessId): never { throw new InjectedPostMutationFailure(harness) } @@ -86,14 +92,15 @@ function injectPostMutationFailure(harness: "Claude" | "Codex"): never { * * @example * ```typescript - * assertExactHarnessRecovery(priorSnapshot, restoredSnapshot, "Claude") + * assertExactHarnessRecovery(priorSnapshot, restoredSnapshot, "claude") * ``` */ export function assertExactHarnessRecovery( prior: HarnessRecoverySnapshot, restored: HarnessRecoverySnapshot, - harness: "Claude" | "Codex", + harness: HarnessId, ): void { + const displayName = harnessDisplayName(harness) for (const field of [ "source", "ref", @@ -109,11 +116,11 @@ export function assertExactHarnessRecovery( "payloadHash", ] as const) { if (restored[field] !== prior[field]) { - throw new Error(`${harness} recovery did not restore prior ${field}`) + throw new Error(`${displayName} recovery did not restore prior ${field}`) } } if (JSON.stringify(restored.payloadInventory) !== JSON.stringify(prior.payloadInventory)) { - throw new Error(`${harness} recovery did not restore prior payloadInventory`) + throw new Error(`${displayName} recovery did not restore prior payloadInventory`) } } diff --git a/scripts/native-capability-hook.test.ts b/scripts/native-capability-hook.test.ts index 90b29cb..e6a8fb6 100644 --- a/scripts/native-capability-hook.test.ts +++ b/scripts/native-capability-hook.test.ts @@ -17,11 +17,58 @@ import { fileURLToPath } from "node:url" import { afterEach, expect, test } from "bun:test" +import { HARNESS_IDENTITIES } from "./harness-identity" + const root = fileURLToPath(new URL("..", import.meta.url)).replace(/\/$/, "") const temporaryRoots: string[] = [] const warning = "This plugin could not run its lifecycle mechanics proof; continuing without blocking.\n" +function lifecycleCaseArms(hook: string): string[] { + const lifecycleCase = hook.match( + /^case "\$event:\$client" in\n(?[\s\S]*?)^esac$/m, + ) + if (!lifecycleCase?.groups?.body) { + throw new Error("native capability hook lifecycle case not found") + } + + return Array.from( + lifecycleCase.groups.body.matchAll(/^\s*((?:SessionStart|Stop):[^\n)]+)\)/gm), + (match) => match[1], + ) + .flatMap((pattern) => pattern.split("|")) + .map((arm) => arm.trim()) + .sort() +} + +function expectCanonicalLifecycleCaseArms(hook: string): void { + const harnessIds = Object.keys(HARNESS_IDENTITIES) + const expected = ["SessionStart", "Stop"] + .flatMap((event) => harnessIds.map((harnessId) => `${event}:${harnessId}`)) + .sort() + + expect(lifecycleCaseArms(hook)).toEqual(expected) +} + +test("lifecycle case arms cover exactly the canonical harness IDs", () => { + const hook = readFileSync(join(root, "plugin", "hooks", "native-capability-hook"), "utf8") + + expectCanonicalLifecycleCaseArms(hook) +}) + +test("lifecycle case-arm parity detects added, removed, and renamed harness IDs", () => { + const hook = readFileSync(join(root, "plugin", "hooks", "native-capability-hook"), "utf8") + const mutations = [ + hook.replace("SessionStart:claude|", ""), + hook.replace("Stop:codex)", "Stop:codex|Stop:future)"), + hook.replace("SessionStart:codex", "SessionStart:renamed"), + ] + + for (const mutation of mutations) { + expect(() => expectCanonicalLifecycleCaseArms(mutation)).toThrow() + } +}) + afterEach(() => { for (const temporaryRoot of temporaryRoots.splice(0)) { rmSync(temporaryRoot, { recursive: true, force: true }) diff --git a/scripts/plugin-config.ts b/scripts/plugin-config.ts index 3098920..9ed3c08 100644 --- a/scripts/plugin-config.ts +++ b/scripts/plugin-config.ts @@ -1,6 +1,8 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs" import { dirname, join } from "node:path" +import { type HarnessId, HARNESS_IDENTITIES } from "./harness-identity" + /** Canonical plugin identity and presentation metadata. */ export interface PluginConfig { /** True only in the reusable template before a recipient initializes it. */ @@ -388,8 +390,9 @@ function codexMarketplace(config: PluginConfig): GeneratedFile { } function claudeManifest(config: PluginConfig): GeneratedFile { + const identity = HARNESS_IDENTITIES.claude return { - path: "plugin/.claude-plugin/plugin.json", + path: `plugin/${identity.manifestDirectory}/plugin.json`, contents: serialize({ name: config.name, displayName: config.displayName, @@ -401,7 +404,7 @@ function claudeManifest(config: PluginConfig): GeneratedFile { license: config.license, keywords: config.keywords, skills: "./skills/", - hooks: "./hooks/claude/hooks.json", + hooks: identity.hooksDeclarationPath, }), } } @@ -422,8 +425,9 @@ function codexInterface(config: PluginConfig): Record { } function codexManifest(config: PluginConfig): GeneratedFile { + const identity = HARNESS_IDENTITIES.codex return { - path: "plugin/.codex-plugin/plugin.json", + path: `plugin/${identity.manifestDirectory}/plugin.json`, contents: serialize({ name: config.name, version: config.version, @@ -433,15 +437,15 @@ function codexManifest(config: PluginConfig): GeneratedFile { license: config.license, keywords: config.keywords, skills: "./skills/", - hooks: "./hooks/codex/hooks.json", + hooks: identity.hooksDeclarationPath, interface: codexInterface(config), }), } } /** Build one client's hook declaration object; proofs reuse this as the comparison contract. */ -export function hookDeclarationBody(client: "claude" | "codex"): Record { - const pluginRoot = client === "claude" ? "CLAUDE_PLUGIN_ROOT" : "PLUGIN_ROOT" +export function hookDeclarationBody(client: HarnessId): Record { + const pluginRoot = HARNESS_IDENTITIES[client].pluginRootEnvVar const command = (event: "SessionStart" | "Stop") => `"\${${pluginRoot}}/hooks/native-capability-hook" ${event} ${client}` return { @@ -452,9 +456,10 @@ export function hookDeclarationBody(client: "claude" | "codex"): Record { +function dryRun(harness: HarnessId): Record { const result = Bun.spawnSync({ cmd: ["bun", "run", "scripts/dev.ts", harness, "--dry-run", "--json"], cwd: root, @@ -41,8 +42,8 @@ if (claudeManifest.version !== codexManifest.version) { throw new Error("native manifest versions do not match") } if ( - claudeManifest.hooks !== "./hooks/claude/hooks.json" || - codexManifest.hooks !== "./hooks/codex/hooks.json" + claudeManifest.hooks !== HARNESS_IDENTITIES.claude.hooksDeclarationPath || + codexManifest.hooks !== HARNESS_IDENTITIES.codex.hooksDeclarationPath ) { throw new Error("native manifests do not reference the exact client hook declarations") } diff --git a/scripts/prove-harness-install.ts b/scripts/prove-harness-install.ts index 25a7d9f..c014c6c 100644 --- a/scripts/prove-harness-install.ts +++ b/scripts/prove-harness-install.ts @@ -14,14 +14,21 @@ import { import { tmpdir } from "node:os" import { basename, dirname, join, relative, resolve, sep } from "node:path" +import { + type ClaudeDriverDependencies, + proveClaudeNative as runClaudeNative, +} from "./harness-install-claude" import { assertCodexReportedVersion, proveCodexFixtureCopy as runCodexFixtureCopy, proveCodexNative as runCodexNative, } from "./harness-install-codex" import { - provePostMutationRecovery, -} from "./harness-install-recovery" + HARNESS_IDENTITIES, + QUALIFICATION_CLIENT_HARNESSES, + type HarnessId, + type QualificationClient, +} from "./harness-identity" import { copyPluginPayload, payloadInventorySha256, pluginPayloadInventory } from "./plugin-files" import { CLAUDE_DISABLED_BY_DEFAULT_COMPATIBILITY, @@ -52,7 +59,8 @@ Safety: ` type HarnessMode = "native-local-marketplace" | "native-hosted-marketplace" | "fixture-copy" -type ClaudeScope = "user" | "project" | "local" +/** Claude marketplace scopes exercised independently by the native proof. */ +export type ClaudeScope = "user" | "project" | "local" export interface TaggedCheckout { requestedRef: string @@ -96,7 +104,8 @@ export interface ReplacementAdmissionInput { removable: boolean } -interface ClaudeInstall { +/** Native Claude state selected by plugin identity and scope. */ +export interface ClaudeInstall { version: string scope: ClaudeScope enabled: boolean @@ -111,7 +120,8 @@ interface ClaudeInstalledJson { installPath: string } -interface ClaudeScopeProof { +/** Replacement and recovery evidence retained for one Claude scope. */ +export interface ClaudeScopeProof { scope: ClaudeScope initialVersion: string initialEnabled: boolean @@ -124,7 +134,8 @@ interface ClaudeScopeProof { activeCachePath: string } -interface ClaudeProof { +/** Claude install proof persisted in the cross-harness report. */ +export interface ClaudeProof { mode: HarnessMode version: string scope: ClaudeScope @@ -246,7 +257,7 @@ export interface InstalledCapabilityEvidence { /** Hash-only conclusions that may be promoted from a private fresh-client receipt. */ export interface NativeQualificationEvidence { schema: "native-capability-qualification-v1" - client: "claude" | "codex" + client: HarnessId platform: "macos" | "linux" receiptSha256: string sourceCandidateSha: string @@ -280,7 +291,7 @@ export interface NativeQualificationEvidence { } export interface NativeQualificationBinding { - client: "claude" | "codex" + client: HarnessId sourceCommit: string archiveSha256: string packagedPayloadHash: string @@ -351,7 +362,9 @@ export function promoteNativeQualificationEvidence( "reentrySha256", "hooksFallbackSha256", ] - if (expected.client === "codex") evidenceKeys.push("exactDefinitionTrustSha256") + if (expected.client === QUALIFICATION_CLIENT_HARNESSES["codex-cli"]) { + evidenceKeys.push("exactDefinitionTrustSha256") + } const evidence = requireQualificationKeys(summary.evidence, evidenceKeys) if ( @@ -398,8 +411,9 @@ export function promoteNativeQualificationEvidence( } } if ( - (expected.client === "claude" && conclusions.exactDefinitionTrust !== "not-applicable") || - (expected.client === "codex" && + (expected.client === QUALIFICATION_CLIENT_HARNESSES["claude-cli"] && + conclusions.exactDefinitionTrust !== "not-applicable") || + (expected.client === QUALIFICATION_CLIENT_HARNESSES["codex-cli"] && conclusions.exactDefinitionTrust !== "proved" && conclusions.exactDefinitionTrust !== "failed") ) { @@ -419,7 +433,7 @@ function nativeQualificationPassed(receipt: NativeQualificationEvidence): boolea receipt.conclusions.driftContinuation === "proved" && receipt.conclusions.reentry === "silent" && receipt.conclusions.hooksFallback === "proved" && - (receipt.client === "claude" + (receipt.client === QUALIFICATION_CLIENT_HARNESSES["claude-cli"] ? receipt.conclusions.exactDefinitionTrust === "not-applicable" : receipt.conclusions.exactDefinitionTrust === "proved") ) @@ -465,7 +479,7 @@ export interface HarnessInstallProofOptions { interface NativeRuntimeJourney { kind: "installed-payload-mechanics" - client: "claude-cli" | "codex-cli" + client: Exclude target: string repository: string sourceCommit: string @@ -480,10 +494,7 @@ interface NativeRuntimeJourney { journey: string[] } -interface NativeHarnessExecutables { - claude?: string - codex?: string -} +type NativeHarnessExecutables = Record /** Replace a temporary evidence path with an explicit cleaned marker, including macOS /var aliases. */ export function redactTemporaryEvidencePath(value: unknown, temporaryRoot: string): unknown { @@ -844,178 +855,14 @@ function replaceClaudeInstall( return findClaudeInstall(claudeExecutable, environment, cwd, pluginId, scope) } -function proveClaudeNative( - fixture: FixtureRelease, - pluginName: string, - claudeExecutable: string, - temporaryRoot: string, -): ClaudeProof { - const marketplaceName = pluginName - const pluginId = `${pluginName}@${marketplaceName}` - const scopeResults: ClaudeScopeProof[] = [] - let primary: ClaudeInstall | undefined - let primaryInventory: string[] = [] - for (const scope of ["user", "project", "local"] as const) { - const home = join(temporaryRoot, "claude", scope, "home") - const project = join(temporaryRoot, "claude", scope, "project") - mkdirSync(home, { recursive: true }) - mkdirSync(project, { recursive: true }) - const environment = claudeEnvironment(home) - addClaudeMarketplace(claudeExecutable, fixture.base.checkoutRoot, scope, environment, project) - command([claudeExecutable, "plugin", "install", pluginId, "--scope", scope], { - cwd: project, - env: environment, - }) - const initial = findClaudeInstall(claudeExecutable, environment, project, pluginId, scope) - if (initial.enabled) throw new Error("Claude installed a default-disabled plugin as enabled") - const initialInventory = comparePayload(fixture.base, initial.activeCachePath) - const dataRoot = join(home, "plugins", "data", pluginId) - const markerPath = join(dataRoot, "u6-marker.txt") - mkdirSync(dataRoot, { recursive: true }) - writeFileSync(markerPath, `${scope} marker\n`) - const priorRecovery = { - source: fixture.base.checkoutRoot, - ref: fixture.base.requestedRef, - version: initial.version, - payloadInventory: initialInventory, - enabled: initial.enabled, - scope, - persistentData: readFileSync(markerPath, "utf8"), - } - - const upgraded = replaceClaudeInstall( - claudeExecutable, - pluginId, - marketplaceName, - fixture.target.checkoutRoot, - scope, - environment, - project, - ) - if (upgraded.version !== fixture.target.manifestVersion) { - throw new Error(`Claude ${scope} upgrade reported the wrong version`) - } - const rolledBack = replaceClaudeInstall( - claudeExecutable, - pluginId, - marketplaceName, - fixture.base.checkoutRoot, - scope, - environment, - project, - ) - if (rolledBack.version !== fixture.base.manifestVersion) { - throw new Error(`Claude ${scope} rollback reported the wrong version`) - } - if (readFileSync(markerPath, "utf8") !== `${scope} marker\n`) { - throw new Error(`Claude ${scope} persistent data did not survive replacement`) - } - const restoredAfterFailure = provePostMutationRecovery( - priorRecovery, - { - harness: "Claude", - mutate: () => { - command( - [claudeExecutable, "plugin", "uninstall", pluginId, "--keep-data", "--scope", scope], - { cwd: project, env: environment }, - ) - command( - [claudeExecutable, "plugin", "marketplace", "remove", marketplaceName, "--scope", scope], - { cwd: project, env: environment }, - ) - }, - restore: () => { - addClaudeMarketplace( - claudeExecutable, - fixture.base.checkoutRoot, - scope, - environment, - project, - ) - command([claudeExecutable, "plugin", "install", pluginId, "--scope", scope], { - cwd: project, - env: environment, - }) - const restored = findClaudeInstall( - claudeExecutable, - environment, - project, - pluginId, - scope, - ) - return { - value: restored, - snapshot: { - source: fixture.base.checkoutRoot, - ref: fixture.base.requestedRef, - version: restored.version, - payloadInventory: comparePayload(fixture.base, restored.activeCachePath), - enabled: restored.enabled, - scope: restored.scope, - persistentData: readFileSync(markerPath, "utf8"), - }, - } - }, - }, - ) - const failureRestored = true - command([claudeExecutable, "plugin", "enable", pluginId, "--scope", scope], { - cwd: project, - env: environment, - }) - const activeAfterFailure = findClaudeInstall( - claudeExecutable, - environment, - project, - pluginId, - scope, - ) - const orphanedCachePath = join(dirname(activeAfterFailure.activeCachePath), "0.0.0-orphaned") - mkdirSync(orphanedCachePath, { recursive: true }) - writeFileSync(join(orphanedCachePath, "orphan-marker.txt"), "not active\n") - const hostSelected = findClaudeInstall( - claudeExecutable, - environment, - project, - pluginId, - scope, - ) - if (hostSelected.activeCachePath === orphanedCachePath) { - throw new Error("Claude proof selected an orphaned cache directory") - } - const inventory = comparePayload(fixture.base, hostSelected.activeCachePath) - scopeResults.push({ - scope, - initialVersion: initial.version, - initialEnabled: initial.enabled, - upgradedVersion: upgraded.version, - rolledBackVersion: rolledBack.version, - enabledAfterReview: hostSelected.enabled, - dataMarkerPreserved: true, - failureRestored, - orphanedCacheIgnored: true, - activeCachePath: hostSelected.activeCachePath, - }) - if (scope === "user") { - primary = hostSelected - primaryInventory = inventory - } - } - if (!primary) throw new Error("Claude user-scope proof did not run") - return { - mode: "native-local-marketplace" as HarnessMode, - version: primary.version, - scope: primary.scope, - enabled: primary.enabled, - activeCachePath: primary.activeCachePath, - inventory: primaryInventory, - requestedRef: fixture.base.requestedRef, - resolvedSha: fixture.base.resolvedSha, - defaultEnabled: false, - compatibility: CLAUDE_DISABLED_BY_DEFAULT_COMPATIBILITY, - scopes: scopeResults, - } -} +const claudeDriverDependencies = { + addMarketplace: addClaudeMarketplace, + command, + comparePayload, + environment: claudeEnvironment, + findInstall: findClaudeInstall, + replaceInstall: replaceClaudeInstall, +} satisfies ClaudeDriverDependencies function proveClaudeFixtureCopy( fixture: FixtureRelease, @@ -1172,8 +1019,8 @@ export function proveHostedHarnessInstall( const claudeProject = join(temporaryRoot, "claude", "project") mkdirSync(claudeHome, { recursive: true }) mkdirSync(claudeProject, { recursive: true }) - const claudeEnv = claudeEnvironment(claudeHome) - addClaudeMarketplace( + const claudeEnv = claudeDriverDependencies.environment(claudeHome) + claudeDriverDependencies.addMarketplace( claudeExecutable, sources.claude, "user", @@ -1181,11 +1028,14 @@ export function proveHostedHarnessInstall( claudeProject, ) const pluginId = `${claudeManifest.name}@${claudeManifest.name}` - command([claudeExecutable, "plugin", "install", pluginId, "--scope", "user"], { - cwd: claudeProject, - env: claudeEnv, - }) - const claudeInstall = findClaudeInstall( + claudeDriverDependencies.command( + [claudeExecutable, "plugin", "install", pluginId, "--scope", "user"], + { + cwd: claudeProject, + env: claudeEnv, + }, + ) + const claudeInstall = claudeDriverDependencies.findInstall( claudeExecutable, claudeEnv, claudeProject, @@ -1195,7 +1045,10 @@ export function proveHostedHarnessInstall( if (claudeInstall.version !== claudeManifest.version) { throw new Error("Claude hosted install reported the wrong manifest version") } - const claudeInventory = comparePayload(expected, claudeInstall.activeCachePath) + const claudeInventory = claudeDriverDependencies.comparePayload( + expected, + claudeInstall.activeCachePath, + ) const claudeInstalledPayloadHash = payloadInventorySha256( claudeInstall.activeCachePath, claudeInventory, @@ -1355,7 +1208,7 @@ export function runtimeClosureEvidence( */ export function proveInstalledCapabilityEvidence( pluginRoot: string, - client: "claude" | "codex", + client: HarnessId, candidateCommit: string, candidatePayloadHash: string, qualification?: NativeQualificationPromotion, @@ -1364,10 +1217,11 @@ export function proveInstalledCapabilityEvidence( if (!/^[a-f0-9]{40}$/.test(candidateCommit) || !/^[a-f0-9]{64}$/.test(candidatePayloadHash)) { throw new Error("installed capability evidence requires a candidate commit and payload hash") } + const identity = HARNESS_IDENTITIES[client] const manifest = JSON.parse( - readFileSync(join(pluginRoot, `.${client}-plugin`, "plugin.json"), "utf8"), + readFileSync(join(pluginRoot, identity.manifestDirectory, "plugin.json"), "utf8"), ) as { version?: unknown; hooks?: unknown } - const declarationPath = `./hooks/${client}/hooks.json` + const declarationPath = identity.hooksDeclarationPath if (manifest.hooks !== declarationPath) { throw new Error(`${client} installed declaration path is invalid`) } @@ -1745,7 +1599,13 @@ function runHarnessInstallProof( ] const { claude: claudeExecutable, codex: codexExecutable } = executables const claude = claudeExecutable - ? proveClaudeNative(fixture, pluginConfig.name, claudeExecutable, temporaryRoot) + ? runClaudeNative( + fixture, + pluginConfig.name, + claudeExecutable, + temporaryRoot, + claudeDriverDependencies, + ) : proveClaudeFixtureCopy(fixture, pluginConfig.name, temporaryRoot) if (!claudeExecutable) { skips.push({ @@ -1789,13 +1649,13 @@ function runHarnessInstallProof( clients: { claude: proveInstalledCapabilityEvidence( claude.activeCachePath, - "claude", + QUALIFICATION_CLIENT_HARNESSES["claude-cli"], sourceCommit, candidatePayloadHash, ), codex: proveInstalledCapabilityEvidence( codex.installedPath, - "codex", + QUALIFICATION_CLIENT_HARNESSES["codex-cli"], sourceCommit, candidatePayloadHash, ), @@ -1855,10 +1715,10 @@ export function proveHarnessInstall( sourceRoot: string, options: HarnessInstallProofOptions = {}, ): HarnessInstallProof { - const executables: NativeHarnessExecutables = { - claude: Bun.which("claude") ?? undefined, - codex: Bun.which("codex") ?? undefined, - } + const harnessIds = Object.keys(HARNESS_IDENTITIES) as HarnessId[] + const executables = Object.fromEntries( + harnessIds.map((harness) => [harness, Bun.which(harness) ?? undefined]), + ) as NativeHarnessExecutables if (options.requireNative && (!executables.claude || !executables.codex)) { const missing = [!executables.claude && "claude", !executables.codex && "codex"].filter(Boolean) throw new Error(`native harness CLIs are required; missing: ${missing.join(", ")}`) diff --git a/scripts/release-validate.ts b/scripts/release-validate.ts index c0f61eb..44b161c 100644 --- a/scripts/release-validate.ts +++ b/scripts/release-validate.ts @@ -2,6 +2,7 @@ import { existsSync, readFileSync } from "node:fs" import { join, resolve } from "node:path" import { validateBunOnlyPayload } from "./build" +import { HARNESS_IDENTITIES } from "./harness-identity" import { checkNativeCapabilityFixture } from "./native-capability-fixture" import { checkGeneratedFiles, loadPluginConfig } from "./plugin-config" import { RELEASE_PROJECTION_PATH_SET } from "./release-projection" @@ -587,8 +588,8 @@ function validateRepository(repositoryRoot: string) { } } if ( - claudeManifest.hooks !== "./hooks/claude/hooks.json" || - codexManifest.hooks !== "./hooks/codex/hooks.json" + claudeManifest.hooks !== HARNESS_IDENTITIES.claude.hooksDeclarationPath || + codexManifest.hooks !== HARNESS_IDENTITIES.codex.hooksDeclarationPath ) { throw new Error("native manifests must reference the exact client-specific hook declarations") } diff --git a/scripts/update.ts b/scripts/update.ts index dbd9529..77061be 100644 --- a/scripts/update.ts +++ b/scripts/update.ts @@ -5,6 +5,7 @@ import { CodexProductionUpdateError, runCodexProductionUpdate, } from "./codex-production-update" +import { QUALIFICATION_CLIENT_HARNESSES } from "./harness-identity" /** * Rendered command contract for the production Plugin Installation update workflow. @@ -196,7 +197,9 @@ export function main(arguments_: string[]): number { const runId = randomUUID() try { const invocation = parseInvocation(arguments_) - if (invocation.harness !== "codex") throw new UsageError("--harness must be codex") + if (invocation.harness !== QUALIFICATION_CLIENT_HARNESSES["codex-cli"]) { + throw new UsageError("--harness must be codex") + } if ( invocation.target !== "latest" && !/^v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/.test(invocation.target) diff --git a/scripts/workflow-pin-parity.test.ts b/scripts/workflow-pin-parity.test.ts new file mode 100644 index 0000000..239a057 --- /dev/null +++ b/scripts/workflow-pin-parity.test.ts @@ -0,0 +1,55 @@ +import { readFileSync } from "node:fs" +import { fileURLToPath } from "node:url" + +import { expect, test } from "bun:test" + +const root = fileURLToPath(new URL("..", import.meta.url)).replace(/\/$/, "") +const workflowPaths = [ + ".github/workflows/plugin-ci.yml", + ".github/workflows/hosted-canary.yml", + ".github/workflows/release.yml", +] as const + +function workflowSources(): string[] { + return workflowPaths.map((path) => readFileSync(`${root}/${path}`, "utf8")) +} + +function extractPinLine(workflow: string): string { + const matches = workflow + .split("\n") + .filter((line) => line.trimStart().startsWith("run: bun add --global ")) + if (matches.length !== 1) { + throw new Error(`expected one native CLI pin line, found ${matches.length}`) + } + return matches[0] +} + +function expectWorkflowPinParity(workflows: string[]): void { + const pinLines = workflows.map(extractPinLine) + expect(pinLines).toEqual(Array.from({ length: pinLines.length }, () => pinLines[0])) +} + +function bumpPinnedVersions(workflow: string): string { + const pinLine = extractPinLine(workflow) + const bumpedPinLine = pinLine.replace( + /@(\d+)\.(\d+)\.(\d+)(?=")/g, + (_, major, minor, patch) => `@${major}.${minor}.${Number(patch) + 1}`, + ) + if (bumpedPinLine === pinLine) { + throw new Error("native CLI pin line contains no semantic versions") + } + return workflow.replace(pinLine, bumpedPinLine) +} + +test("native CLI workflow pin lines are byte-identical", () => { + expectWorkflowPinParity(workflowSources()) +}) + +test("workflow pin parity rejects one differing line and accepts a coordinated bump", () => { + const workflows = workflowSources() + const oneDiffering = [...workflows] + oneDiffering[0] = bumpPinnedVersions(oneDiffering[0]) + + expect(() => expectWorkflowPinParity(oneDiffering)).toThrow() + expectWorkflowPinParity(workflows.map(bumpPinnedVersions)) +})