Skip to content

Commit 41647d8

Browse files
committed
Preserve Composer overlay source revisions
1 parent c30df7f commit 41647d8

5 files changed

Lines changed: 179 additions & 15 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,7 @@
201201
"test:preview-options": "tsx tests/preview-options.test.ts",
202202
"test:evidence-bundle-digest-and-recipe-artifact": "tsx tests/evidence-bundle-digest-and-recipe-artifact.test.ts",
203203
"test:runtime-overlay-descriptors": "tsx tests/runtime-overlay-descriptors.test.ts",
204+
"test:composer-package-overlay-revision": "tsx scripts/composer-backed-source-hydration-smoke.ts",
204205
"test:runtime-preset-registry": "tsx tests/runtime-preset-registry.test.ts",
205206
"test:generic-ability-runtime-run": "tsx tests/generic-ability-runtime-run.test.ts",
206207
"test:provider-runtime-contracts": "tsx tests/provider-runtime-contracts.test.ts",

packages/cli/src/commands/agent-task-run.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -793,7 +793,7 @@ function compactPreparedPaths(context: Record<string, unknown>, recipeInputs: Re
793793
component_contracts: arrayRecords(context.preparedComponentContracts).map((contract) => compactRecord(contract, ["slug", "requestedPath", "preparedPath", "pluginFile", "loadAs", "activate", "status"])),
794794
workspaces: arrayRecords(context.preparedWorkspaces).map((workspace) => compactRecord(workspace, ["target", "mode", "metadata"])),
795795
staged_files: arrayRecords(context.preparedStagedFiles).map((file) => compactRecord(file, ["sourceRef", "target", "type", "provenance", "metadata"])),
796-
dependency_overlays: arrayRecords(context.preparedDependencyOverlays).map((overlay) => compactRecord(overlay, ["package", "target", "type", "mode", "metadata"])),
796+
dependency_overlays: arrayRecords(context.preparedDependencyOverlays).map((overlay) => compactRecord(overlay, ["package", "reference", "target", "type", "mode", "metadata"])),
797797
runtime_overlays: arrayRecords(context.preparedRuntimeOverlays).map((overlay) => compactRecord(overlay, ["target", "type", "mode", "metadata"])),
798798
requested_component_contracts: arrayRecords(recipeInputs.component_contracts ?? taskInputs.component_contracts).map((contract) => compactRecord(contract, ["slug", "path", "loadAs", "activate"])),
799799
})

packages/cli/src/commands/recipe-runtime-setup.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,7 @@ export function recipeRunDependencyOverlay(overlay: PreparedDependencyOverlay):
249249
return {
250250
source: overlay.source,
251251
sourceRef: overlay.sourceRef,
252+
...(overlay.reference ? { reference: overlay.reference } : {}),
252253
target: overlay.target,
253254
package: overlay.package,
254255
consumer: overlay.consumer,

packages/cli/src/recipe-sources.ts

Lines changed: 110 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ export interface PreparedRuntimeOverlay {
108108
export interface PreparedDependencyOverlay {
109109
source: string
110110
sourceRef: string
111+
reference?: string
111112
target: string
112113
package: string
113114
consumer: string
@@ -428,14 +429,15 @@ function composerInstalledPackageClassmapPaths(pkg: ComposerInstalledPackage): s
428429

429430
export async function prepareRecipeDependencyOverlays(recipe: WorkspaceRecipe, recipeDirectory: string, extraPlugins: PreparedExtraPlugin[]): Promise<PreparedDependencyOverlay[]> {
430431
const overlays: PreparedDependencyOverlay[] = []
432+
const stagedConsumers = new Set<PreparedExtraPlugin>()
431433
for (const [index, overlay] of (recipe.inputs?.dependency_overlays ?? []).entries()) {
432-
overlays.push(await prepareRecipeDependencyOverlay(overlay, recipeDirectory, extraPlugins, index))
434+
overlays.push(await prepareRecipeDependencyOverlay(overlay, recipeDirectory, extraPlugins, stagedConsumers, index))
433435
}
434436

435437
return overlays
436438
}
437439

438-
async function prepareRecipeDependencyOverlay(overlay: WorkspaceRecipeDependencyOverlay, recipeDirectory: string, extraPlugins: PreparedExtraPlugin[], index: number): Promise<PreparedDependencyOverlay> {
440+
async function prepareRecipeDependencyOverlay(overlay: WorkspaceRecipeDependencyOverlay, recipeDirectory: string, extraPlugins: PreparedExtraPlugin[], stagedConsumers: Set<PreparedExtraPlugin>, index: number): Promise<PreparedDependencyOverlay> {
439441
if (overlay.kind !== "composer-package") {
440442
throw new Error(`Unsupported dependency overlay kind: ${overlay.kind}`)
441443
}
@@ -450,6 +452,10 @@ async function prepareRecipeDependencyOverlay(overlay: WorkspaceRecipeDependency
450452

451453
const source = resolve(recipeDirectory, overlay.source)
452454
await validateExistingDirectoryForOverlay(source, overlay.source)
455+
const reference = await resolvedGitSourceReference(source)
456+
if (reference) {
457+
await preserveComposerDependencyReference(consumer, overlay.package, reference, stagedConsumers)
458+
}
453459
const stagingRoot = await mkdtemp(join(tmpdir(), "wp-codebox-dependency-overlay-"))
454460
const preparedSource = await prepareComposerBackedSource(source, stagingRoot, `dependency overlay ${overlay.package}`)
455461
const target = `${consumer.target}/vendor/${composerPackageVendorPath(overlay.package)}`
@@ -458,6 +464,7 @@ async function prepareRecipeDependencyOverlay(overlay: WorkspaceRecipeDependency
458464
return {
459465
source: preparedSource,
460466
sourceRef: overlay.source,
467+
...(reference ? { reference } : {}),
461468
target,
462469
package: overlay.package,
463470
consumer: overlay.consumer,
@@ -470,6 +477,7 @@ async function prepareRecipeDependencyOverlay(overlay: WorkspaceRecipeDependency
470477
overlayKind: overlay.kind,
471478
package: overlay.package,
472479
source: overlay.source,
480+
...(reference ? { reference } : {}),
473481
consumer: overlay.consumer,
474482
target,
475483
digest: { sha256: digest },
@@ -478,6 +486,106 @@ async function prepareRecipeDependencyOverlay(overlay: WorkspaceRecipeDependency
478486
}
479487
}
480488

489+
/**
490+
* Capture the clean checkout commit before Composer staging so runtime
491+
* provenance remains tied to the source revision rather than its staged path.
492+
*/
493+
async function resolvedGitSourceReference(source: string): Promise<string | undefined> {
494+
try {
495+
const status = await executeManagedHostCommand({
496+
command: "git",
497+
args: ["status", "--porcelain", "--untracked-files=all"],
498+
cwd: source,
499+
allowedCwdRoots: [source],
500+
timeoutMs: 10_000,
501+
maxOutputBytes: 64 * 1024,
502+
label: "inspect Composer overlay Git source",
503+
})
504+
if (status.stdout.trim()) {
505+
return undefined
506+
}
507+
508+
const revision = await executeManagedHostCommand({
509+
command: "git",
510+
args: ["rev-parse", "--verify", "HEAD"],
511+
cwd: source,
512+
allowedCwdRoots: [source],
513+
timeoutMs: 10_000,
514+
maxOutputBytes: 64 * 1024,
515+
label: "resolve Composer overlay Git revision",
516+
})
517+
const reference = revision.stdout.trim()
518+
return /^[a-f0-9]{40,64}$/i.test(reference) ? reference : undefined
519+
} catch {
520+
return undefined
521+
}
522+
}
523+
524+
async function preserveComposerDependencyReference(consumer: PreparedExtraPlugin, packageName: string, reference: string, stagedConsumers: Set<PreparedExtraPlugin>): Promise<void> {
525+
const installedJson = join(consumer.source, "vendor", "composer", "installed.json")
526+
if (!await composerInstalledJsonHasPackage(installedJson, packageName)) {
527+
return
528+
}
529+
530+
if (!stagedConsumers.has(consumer)) {
531+
const stagingRoot = await mkdtemp(join(tmpdir(), `wp-codebox-dependency-consumer-${consumer.slug}-`))
532+
const stagedSource = join(stagingRoot, basename(consumer.source))
533+
await cp(consumer.source, stagedSource, { recursive: true })
534+
consumer.source = stagedSource
535+
consumer.cleanupPaths.push(stagingRoot)
536+
stagedConsumers.add(consumer)
537+
}
538+
539+
await writeComposerDependencyReference(join(consumer.source, "vendor", "composer", "installed.json"), packageName, reference)
540+
await writeComposerInstalledPhpReference(join(consumer.source, "vendor", "composer", "installed.php"), packageName, reference)
541+
}
542+
543+
async function composerInstalledJsonHasPackage(path: string, packageName: string): Promise<boolean> {
544+
try {
545+
return composerInstalledPackageRecords(JSON.parse(await readFile(path, "utf8"))).some((pkg) => pkg.name === packageName)
546+
} catch {
547+
return false
548+
}
549+
}
550+
551+
async function writeComposerDependencyReference(path: string, packageName: string, reference: string): Promise<void> {
552+
const installed = JSON.parse(await readFile(path, "utf8")) as unknown
553+
const pkg = composerInstalledPackageRecords(installed).find((candidate) => candidate.name === packageName)
554+
if (!pkg) {
555+
return
556+
}
557+
pkg.source = { ...(isRecord(pkg.source) ? pkg.source : {}), reference }
558+
await writeFile(path, `${JSON.stringify(installed, null, 2)}\n`)
559+
}
560+
561+
async function writeComposerInstalledPhpReference(path: string, packageName: string, reference: string): Promise<void> {
562+
if (!await pathIsFile(path)) {
563+
return
564+
}
565+
const contents = await readFile(path, "utf8")
566+
const packageStart = contents.search(new RegExp(`['\"]${escapeRegExp(packageName)}['\"]\\s*=>\\s*array\\s*\\(`))
567+
if (packageStart < 0) {
568+
return
569+
}
570+
const before = contents.slice(0, packageStart)
571+
const entry = contents.slice(packageStart)
572+
const updatedEntry = entry.replace(/(['"]reference['"]\s*=>\s*)(?:NULL|null|'[^']*'|"[^"]*")/, `$1'${reference}'`)
573+
if (updatedEntry !== entry) {
574+
await writeFile(path, before + updatedEntry)
575+
}
576+
}
577+
578+
function composerInstalledPackageRecords(installed: unknown): Array<Record<string, unknown> & { name?: string }> {
579+
if (Array.isArray(installed)) {
580+
return installed.filter(isRecord)
581+
}
582+
return isRecord(installed) && Array.isArray(installed.packages) ? installed.packages.filter(isRecord) : []
583+
}
584+
585+
function isRecord(value: unknown): value is Record<string, unknown> {
586+
return !!value && typeof value === "object" && !Array.isArray(value)
587+
}
588+
481589
export async function prepareRecipeStagedFiles(recipe: WorkspaceRecipe, recipeDirectory: string): Promise<PreparedStagedFile[]> {
482590
const stagedFiles: PreparedStagedFile[] = []
483591
for (const [index, stagedFile] of (recipe.inputs?.stagedFiles ?? []).entries()) {

scripts/composer-backed-source-hydration-smoke.ts

Lines changed: 66 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,23 @@
11
import assert from "node:assert/strict"
2+
import { execFile as execFileCallback } from "node:child_process"
23
import { chmod, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"
34
import { tmpdir } from "node:os"
45
import { join } from "node:path"
6+
import { promisify } from "node:util"
7+
import { recipeRunDependencyOverlay } from "../packages/cli/src/commands/recipe-runtime-setup.js"
58
import { prepareRecipeDependencyOverlays, prepareRecipeRuntimeOverlays } from "../packages/cli/src/recipe-sources.js"
69
import type { PreparedExtraPlugin } from "../packages/cli/src/recipe-sources.js"
710
import type { WorkspaceRecipe } from "../packages/runtime-core/src/runtime-contracts.js"
811

912
const root = await mkdtemp(join(tmpdir(), "wp-codebox-runtime-overlay-hydration-"))
1013
const overlaySource = join(root, "php-ai-client")
1114
const dependencySource = join(root, "generic-composer-package")
15+
const nonGitDependencySource = join(root, "non-git-composer-package")
1216
const binDir = join(root, "bin")
1317
const scoperPath = join(root, "php-scoper.phar")
1418
const originalPath = process.env.PATH
1519
const originalScoper = process.env.WP_CODEBOX_PHP_SCOPER_PHAR
20+
const execFile = promisify(execFileCallback)
1621

1722
async function exists(path: string): Promise<boolean> {
1823
try {
@@ -51,6 +56,36 @@ await writeFile(join(dependencySource, "composer.json"), JSON.stringify({
5156
autoload: { "psr-4": { "Acme\\Package\\": "src/" } },
5257
require: { "psr/log": "^3.0" },
5358
}, null, 2))
59+
await execFile("git", ["init", "--quiet"], { cwd: dependencySource })
60+
await execFile("git", ["add", "."], { cwd: dependencySource })
61+
await execFile("git", ["-c", "user.name=WP Codebox", "-c", "user.email=wp-codebox@example.test", "commit", "--quiet", "-m", "fixture"], { cwd: dependencySource })
62+
const { stdout: dependencyReference } = await execFile("git", ["rev-parse", "HEAD"], { cwd: dependencySource })
63+
64+
await mkdir(join(nonGitDependencySource, "src"), { recursive: true })
65+
await writeFile(join(nonGitDependencySource, "src", "Package.php"), "<?php\n")
66+
await writeFile(join(nonGitDependencySource, "composer.json"), JSON.stringify({ name: "acme/non-git-package" }, null, 2))
67+
68+
const consumerSource = join(root, "consumer-plugin")
69+
await mkdir(join(consumerSource, "vendor", "composer"), { recursive: true })
70+
await writeFile(join(consumerSource, "vendor", "composer", "installed.json"), JSON.stringify({ packages: [
71+
{ name: "acme/package", version: "1.0.0+no-version-set" },
72+
{ name: "acme/non-git-package", version: "1.0.0+no-version-set" },
73+
] }, null, 2))
74+
await writeFile(join(consumerSource, "vendor", "composer", "installed.php"), `<?php
75+
76+
return array(
77+
'versions' => array(
78+
'acme/package' => array(
79+
'pretty_version' => '1.0.0+no-version-set',
80+
'reference' => NULL,
81+
),
82+
'acme/non-git-package' => array(
83+
'pretty_version' => '1.0.0+no-version-set',
84+
'reference' => NULL,
85+
),
86+
),
87+
);
88+
`)
5489

5590
await writeFile(scoperPath, `<?php
5691
$workingDir = '';
@@ -116,38 +151,57 @@ const recipe: WorkspaceRecipe = {
116151
}
117152

118153
const overlays = await prepareRecipeRuntimeOverlays(recipe, root)
154+
const consumers: PreparedExtraPlugin[] = [{
155+
source: consumerSource,
156+
slug: "consumer-plugin",
157+
target: "/wordpress/wp-content/plugins/consumer-plugin",
158+
pluginFile: "consumer-plugin.php",
159+
activate: true,
160+
loadAs: "plugin",
161+
cleanupPaths: [],
162+
provenance: { kind: "local", original: consumerSource },
163+
}]
119164
const dependencyOverlays = await prepareRecipeDependencyOverlays({
120165
inputs: {
121166
dependency_overlays: [{
122167
kind: "composer-package",
123168
package: "acme/package",
124169
source: dependencySource,
125170
consumer: "consumer-plugin",
171+
}, {
172+
kind: "composer-package",
173+
package: "acme/non-git-package",
174+
source: nonGitDependencySource,
175+
consumer: "consumer-plugin",
126176
}],
127177
},
128-
}, root, [{
129-
source: join(root, "consumer-plugin"),
130-
slug: "consumer-plugin",
131-
target: "/wordpress/wp-content/plugins/consumer-plugin",
132-
pluginFile: "consumer-plugin.php",
133-
activate: true,
134-
loadAs: "plugin",
135-
cleanupPaths: [],
136-
provenance: { kind: "local", original: join(root, "consumer-plugin") },
137-
}] satisfies PreparedExtraPlugin[])
178+
}, root, consumers)
138179
try {
139180
assert.equal(overlays.length, 1)
140-
assert.equal(dependencyOverlays.length, 1)
181+
assert.equal(dependencyOverlays.length, 2)
141182
assert.equal(await exists(join(overlaySource, "vendor")), false, "overlay source checkout must not be mutated")
142183
assert.equal(await exists(join(dependencySource, "vendor")), false, "dependency overlay source checkout must not be mutated")
143184
assert.equal(await exists(join(overlays[0].source, "autoload.php")), true)
144185
assert.equal(await exists(join(overlays[0].source, "src", "Client.php")), true)
145186
assert.equal(await exists(join(overlays[0].source, "third-party", "Psr", "Log", "LoggerInterface.php")), true)
146187
assert.equal(await exists(join(dependencyOverlays[0].source, "vendor", "composer", "installed.json")), true)
147188
assert.equal(dependencyOverlays[0].target, "/wordpress/wp-content/plugins/consumer-plugin/vendor/acme/package")
189+
assert.equal(dependencyOverlays[0].reference, dependencyReference.trim(), "clean Git source revision survives Composer staging")
190+
assert.equal(dependencyOverlays[0].metadata.reference, dependencyReference.trim(), "mounted dependency metadata preserves the source revision")
191+
assert.equal(recipeRunDependencyOverlay(dependencyOverlays[0]).reference, dependencyReference.trim(), "runtime dependency provenance exposes the source revision")
192+
assert.equal(dependencyOverlays[1].reference, undefined, "non-Git source has no fabricated revision")
193+
assert.equal(dependencyOverlays[1].metadata.reference, undefined, "non-Git source metadata omits the revision")
194+
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")
195+
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 } }> }
196+
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")
197+
assert.deepEqual(runtimeInstalled.packages[1], { name: "acme/non-git-package", version: "1.0.0+no-version-set" }, "runtime Composer provenance omits unresolved source references")
198+
const { stdout: runtimeInstalledPhp } = await execFile("php", ["-r", "echo json_encode(require $argv[1]);", join(consumers[0].source, "vendor", "composer", "installed.php")])
199+
const runtimePhpVersions = JSON.parse(runtimeInstalledPhp) as { versions: Record<string, { reference?: string | null }> }
200+
assert.equal(runtimePhpVersions.versions["acme/package"]?.reference, dependencyReference.trim(), "Composer runtime metadata includes the immutable source reference")
201+
assert.equal(runtimePhpVersions.versions["acme/non-git-package"]?.reference, null, "Composer runtime metadata leaves unresolved references unchanged")
148202
assert.match(await readFile(join(overlays[0].source, "src", "Client.php"), "utf8"), /WordPress\\AiClientDependencies\\Psr\\Log\\LoggerInterface/)
149203
} finally {
150-
await Promise.all([...overlays, ...dependencyOverlays].flatMap((overlay) => overlay.cleanupPaths).map((path) => rm(path, { recursive: true, force: true })))
204+
await Promise.all([...overlays, ...dependencyOverlays, ...consumers].flatMap((overlay) => overlay.cleanupPaths).map((path) => rm(path, { recursive: true, force: true })))
151205
process.env.PATH = originalPath
152206
if (originalScoper === undefined) {
153207
delete process.env.WP_CODEBOX_PHP_SCOPER_PHAR

0 commit comments

Comments
 (0)