From e28be8aec8123d57b371bad160dfec46ab9e85de Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 25 Jul 2026 09:53:39 -0400 Subject: [PATCH] Preserve portable dependency overlay source references --- packages/cli/src/recipe-dry-run.ts | 1 + packages/cli/src/recipe-sources.ts | 2 +- packages/cli/src/recipe-validation.ts | 6 ++ packages/runtime-core/src/recipe-schema.ts | 5 ++ .../runtime-core/src/runtime-contracts.ts | 1 + .../composer-backed-source-hydration-smoke.ts | 60 +++++++++++++++++-- 6 files changed, 68 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/recipe-dry-run.ts b/packages/cli/src/recipe-dry-run.ts index c00171a8d..f0729a86a 100644 --- a/packages/cli/src/recipe-dry-run.ts +++ b/packages/cli/src/recipe-dry-run.ts @@ -376,6 +376,7 @@ export async function planWorkspaceRecipe(recipe: WorkspaceRecipe, recipeDirecto overlayKind: overlay.kind, package: overlay.package, source: overlay.source, + ...(overlay.reference ? { reference: overlay.reference } : {}), consumer: overlay.consumer, target, ...(overlay.metadata ? { userMetadata: overlay.metadata } : {}), diff --git a/packages/cli/src/recipe-sources.ts b/packages/cli/src/recipe-sources.ts index 7957dddfb..492937abe 100644 --- a/packages/cli/src/recipe-sources.ts +++ b/packages/cli/src/recipe-sources.ts @@ -456,7 +456,7 @@ async function prepareRecipeDependencyOverlay(overlay: WorkspaceRecipeDependency const source = resolve(recipeDirectory, overlay.source) await validateExistingDirectoryForOverlay(source, overlay.source) - const reference = await resolvedGitSourceReference(source) + const reference = overlay.reference ?? await resolvedGitSourceReference(source) const stagingRoot = await mkdtemp(join(tmpdir(), "wp-codebox-dependency-overlay-")) const hydratedSource = await prepareComposerBackedSource(source, stagingRoot, `dependency overlay ${overlay.package}`) // The overlay is mounted at the consumer's vendor path for the package and is diff --git a/packages/cli/src/recipe-validation.ts b/packages/cli/src/recipe-validation.ts index 620a7506e..089dcd5f7 100644 --- a/packages/cli/src/recipe-validation.ts +++ b/packages/cli/src/recipe-validation.ts @@ -497,6 +497,9 @@ function validateRecipeDependencyOverlays(overlays: WorkspaceRecipeDependencyOve if (!overlay.source || typeof overlay.source !== "string") { throw new Error(`Recipe dependency overlays must include source: ${recipePath}`) } + if (overlay.reference !== undefined && (typeof overlay.reference !== "string" || !/^[a-f0-9]{40,64}$/i.test(overlay.reference))) { + throw new Error(`Recipe dependency overlay reference must be a 40-64 character hexadecimal immutable reference when provided: ${recipePath}`) + } if (!overlay.consumer || typeof overlay.consumer !== "string") { throw new Error(`Recipe dependency overlays must include consumer: ${recipePath}`) } @@ -681,6 +684,9 @@ export async function validateWorkspaceRecipeSemantics(recipe: WorkspaceRecipe, addIssue("invalid-composer-package", `${path}.package`, `Dependency overlay package must be a safe Composer package name: ${overlay.package}`) continue } + if (overlay.reference !== undefined && (typeof overlay.reference !== "string" || !/^[a-f0-9]{40,64}$/i.test(overlay.reference))) { + addIssue("invalid-dependency-overlay-reference", `${path}.reference`, "Dependency overlay reference must be a 40-64 character hexadecimal immutable reference when provided.") + } const consumerPlugin = recipeExtraPlugins(recipe).find((plugin) => recipeExtraPluginSlug(plugin) === overlay.consumer) const loadAs = consumerPlugin?.loadAs ?? "plugin" diff --git a/packages/runtime-core/src/recipe-schema.ts b/packages/runtime-core/src/recipe-schema.ts index b36ea1ab4..2a025be8e 100644 --- a/packages/runtime-core/src/recipe-schema.ts +++ b/packages/runtime-core/src/recipe-schema.ts @@ -817,6 +817,11 @@ export function createWorkspaceRecipeJsonSchema(options: WorkspaceRecipeJsonSche kind: { const: "composer-package" }, package: { type: "string", pattern: "^[a-z0-9_.-]+/[a-z0-9_.-]+$" }, source: { type: "string" }, + reference: { + type: "string", + pattern: "^[a-fA-F0-9]{40,64}$", + description: "Optional immutable source reference. When omitted, a clean local Git checkout may supply the reference.", + }, consumer: { type: "string", pattern: "^[A-Za-z0-9][A-Za-z0-9_-]*$" }, metadata: { $ref: "#/$defs/metadata" }, }, diff --git a/packages/runtime-core/src/runtime-contracts.ts b/packages/runtime-core/src/runtime-contracts.ts index d5f6f8b64..cf5a66091 100644 --- a/packages/runtime-core/src/runtime-contracts.ts +++ b/packages/runtime-core/src/runtime-contracts.ts @@ -516,6 +516,7 @@ export interface WorkspaceRecipeDependencyOverlay { kind: "composer-package" package: string source: string + reference?: string consumer: string metadata?: Record } diff --git a/scripts/composer-backed-source-hydration-smoke.ts b/scripts/composer-backed-source-hydration-smoke.ts index 518c0823f..af0d1f39c 100644 --- a/scripts/composer-backed-source-hydration-smoke.ts +++ b/scripts/composer-backed-source-hydration-smoke.ts @@ -5,14 +5,17 @@ import { tmpdir } from "node:os" import { join } from "node:path" import { promisify } from "node:util" import { recipeRunDependencyOverlay } from "../packages/cli/src/commands/recipe-runtime-setup.js" +import { validateWorkspaceRecipeSemantics } from "../packages/cli/src/recipe-validation.js" import { prepareRecipeDependencyOverlays, prepareRecipeRuntimeOverlays } from "../packages/cli/src/recipe-sources.js" import type { PreparedExtraPlugin } from "../packages/cli/src/recipe-sources.js" +import { assertWorkspaceRecipeJsonSchema } from "../packages/runtime-core/src/recipe-schema.js" import type { WorkspaceRecipe } from "../packages/runtime-core/src/runtime-contracts.js" const root = await mkdtemp(join(tmpdir(), "wp-codebox-runtime-overlay-hydration-")) const overlaySource = join(root, "php-ai-client") const dependencySource = join(root, "generic-composer-package") const nonGitDependencySource = join(root, "non-git-composer-package") +const nonGitDependencyReference = "0123456789abcdef0123456789abcdef01234567" const binDir = join(root, "bin") const scoperPath = join(root, "php-scoper.phar") const originalPath = process.env.PATH @@ -149,6 +152,49 @@ const recipe: WorkspaceRecipe = { }, } +assertWorkspaceRecipeJsonSchema({ + schema: "wp-codebox/workspace-recipe/v1", + workflow: { steps: [{ command: "wordpress.run-php", args: [" assertWorkspaceRecipeJsonSchema({ + schema: "wp-codebox/workspace-recipe/v1", + workflow: { steps: [{ command: "wordpress.run-php", args: [" issue.path === "$.inputs.dependency_overlays[0].reference"), [{ + code: "invalid-dependency-overlay-reference", + path: "$.inputs.dependency_overlays[0].reference", + message: "Dependency overlay reference must be a 40-64 character hexadecimal immutable reference when provided.", +}]) + const overlays = await prepareRecipeRuntimeOverlays(recipe, root) const consumers: PreparedExtraPlugin[] = [{ source: consumerSource, @@ -171,6 +217,7 @@ const dependencyOverlays = await prepareRecipeDependencyOverlays({ kind: "composer-package", package: "acme/non-git-package", source: nonGitDependencySource, + reference: nonGitDependencyReference, consumer: "consumer-plugin", }], }, @@ -188,16 +235,17 @@ try { assert.equal(dependencyOverlays[0].reference, dependencyReference.trim(), "clean Git source revision survives Composer staging") assert.equal(dependencyOverlays[0].metadata.reference, dependencyReference.trim(), "mounted dependency metadata preserves the source revision") assert.equal(recipeRunDependencyOverlay(dependencyOverlays[0]).reference, dependencyReference.trim(), "runtime dependency provenance exposes the source revision") - assert.equal(dependencyOverlays[1].reference, undefined, "non-Git source has no fabricated revision") - assert.equal(dependencyOverlays[1].metadata.reference, undefined, "non-Git source metadata omits the revision") + assert.equal(dependencyOverlays[1].reference, nonGitDependencyReference, "declared reference supports non-Git sources") + assert.equal(dependencyOverlays[1].metadata.reference, nonGitDependencyReference, "mounted dependency metadata preserves the declared reference") + assert.equal(recipeRunDependencyOverlay(dependencyOverlays[1]).reference, nonGitDependencyReference, "runtime dependency provenance exposes the declared reference") assert.equal((JSON.parse(await readFile(join(consumerSource, "vendor", "composer", "installed.json"), "utf8")) as { packages: Array<{ source?: unknown }> }).packages[0].source, undefined, "original consumer Composer provenance remains unchanged") const runtimeInstalled = JSON.parse(await readFile(join(consumers[0].source, "vendor", "composer", "installed.json"), "utf8")) as { packages: Array<{ name: string, version: string, source?: { reference?: string } }> } assert.deepEqual(runtimeInstalled.packages[0], { name: "acme/package", version: "1.0.0+no-version-set", source: { reference: dependencyReference.trim() } }, "runtime Composer dependency provenance includes the immutable source reference") - assert.deepEqual(runtimeInstalled.packages[1], { name: "acme/non-git-package", version: "1.0.0+no-version-set" }, "runtime Composer provenance omits unresolved source references") + assert.deepEqual(runtimeInstalled.packages[1], { name: "acme/non-git-package", version: "1.0.0+no-version-set", source: { reference: nonGitDependencyReference } }, "runtime Composer provenance uses the declared source reference") const { stdout: runtimeInstalledPhp } = await execFile("php", ["-r", "echo json_encode(require $argv[1]);", join(consumers[0].source, "vendor", "composer", "installed.php")]) const runtimePhpVersions = JSON.parse(runtimeInstalledPhp) as { versions: Record } assert.equal(runtimePhpVersions.versions["acme/package"]?.reference, dependencyReference.trim(), "Composer runtime metadata includes the immutable source reference") - assert.equal(runtimePhpVersions.versions["acme/non-git-package"]?.reference, null, "Composer runtime metadata leaves unresolved references unchanged") + assert.equal(runtimePhpVersions.versions["acme/non-git-package"]?.reference, nonGitDependencyReference, "Composer runtime metadata includes the declared source reference") const runtimePackageRow = runtimePhpVersions.versions["acme/package"] assert.deepEqual(runtimePackageRow, { pretty_version: "1.0.0+no-version-set", @@ -205,8 +253,8 @@ try { }, "the final PHP-visible package row exposes the clean Git reference") assert.deepEqual(runtimePhpVersions.versions["acme/non-git-package"], { pretty_version: "1.0.0+no-version-set", - reference: null, - }, "the final PHP-visible package row leaves an unavailable reference unchanged") + reference: nonGitDependencyReference, + }, "the final PHP-visible package row exposes the declared reference") assert.match(await readFile(join(overlays[0].source, "src", "Client.php"), "utf8"), /WordPress\\AiClientDependencies\\Psr\\Log\\LoggerInterface/) } finally { await Promise.all([...overlays, ...dependencyOverlays, ...consumers].flatMap((overlay) => overlay.cleanupPaths).map((path) => rm(path, { recursive: true, force: true })))