Skip to content

Commit 0b0d07f

Browse files
authored
Preserve Composer overlay source revisions (#1849)
* Preserve Composer overlay source revisions * Align Composer overlay provenance datasets
1 parent 03633fd commit 0b0d07f

6 files changed

Lines changed: 307 additions & 15 deletions

File tree

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,8 @@
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",
205+
"test:composer-installed-versions-loader-order": "tsx scripts/composer-installed-versions-loader-order-smoke.ts",
204206
"test:runtime-preset-registry": "tsx tests/runtime-preset-registry.test.ts",
205207
"test:generic-ability-runtime-run": "tsx tests/generic-ability-runtime-run.test.ts",
206208
"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: 163 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,14 +452,20 @@ 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)
453456
const stagingRoot = await mkdtemp(join(tmpdir(), "wp-codebox-dependency-overlay-"))
454457
const preparedSource = await prepareComposerBackedSource(source, stagingRoot, `dependency overlay ${overlay.package}`)
458+
if (reference) {
459+
await preserveComposerDependencyReference(consumer, overlay.package, reference, stagedConsumers)
460+
await preserveComposerPackageReference(preparedSource, overlay.package, reference)
461+
}
455462
const target = `${consumer.target}/vendor/${composerPackageVendorPath(overlay.package)}`
456463
const digest = await directoryContentDigest(preparedSource)
457464

458465
return {
459466
source: preparedSource,
460467
sourceRef: overlay.source,
468+
...(reference ? { reference } : {}),
461469
target,
462470
package: overlay.package,
463471
consumer: overlay.consumer,
@@ -470,6 +478,7 @@ async function prepareRecipeDependencyOverlay(overlay: WorkspaceRecipeDependency
470478
overlayKind: overlay.kind,
471479
package: overlay.package,
472480
source: overlay.source,
481+
...(reference ? { reference } : {}),
473482
consumer: overlay.consumer,
474483
target,
475484
digest: { sha256: digest },
@@ -478,6 +487,158 @@ async function prepareRecipeDependencyOverlay(overlay: WorkspaceRecipeDependency
478487
}
479488
}
480489

490+
/**
491+
* Capture the clean checkout commit before Composer staging so runtime
492+
* provenance remains tied to the source revision rather than its staged path.
493+
*/
494+
async function resolvedGitSourceReference(source: string): Promise<string | undefined> {
495+
try {
496+
const status = await executeManagedHostCommand({
497+
command: "git",
498+
args: ["status", "--porcelain", "--untracked-files=all"],
499+
cwd: source,
500+
allowedCwdRoots: [source],
501+
timeoutMs: 10_000,
502+
maxOutputBytes: 64 * 1024,
503+
label: "inspect Composer overlay Git source",
504+
})
505+
if (status.stdout.trim()) {
506+
return undefined
507+
}
508+
509+
const revision = await executeManagedHostCommand({
510+
command: "git",
511+
args: ["rev-parse", "--verify", "HEAD"],
512+
cwd: source,
513+
allowedCwdRoots: [source],
514+
timeoutMs: 10_000,
515+
maxOutputBytes: 64 * 1024,
516+
label: "resolve Composer overlay Git revision",
517+
})
518+
const reference = revision.stdout.trim()
519+
return /^[a-f0-9]{40,64}$/i.test(reference) ? reference : undefined
520+
} catch {
521+
return undefined
522+
}
523+
}
524+
525+
async function preserveComposerDependencyReference(consumer: PreparedExtraPlugin, packageName: string, reference: string, stagedConsumers: Set<PreparedExtraPlugin>): Promise<void> {
526+
const installedJson = join(consumer.source, "vendor", "composer", "installed.json")
527+
if (!await composerInstalledJsonHasPackage(installedJson, packageName)) {
528+
return
529+
}
530+
531+
if (!stagedConsumers.has(consumer)) {
532+
const stagingRoot = await mkdtemp(join(tmpdir(), `wp-codebox-dependency-consumer-${consumer.slug}-`))
533+
const stagedSource = join(stagingRoot, basename(consumer.source))
534+
await cp(consumer.source, stagedSource, { recursive: true })
535+
consumer.source = stagedSource
536+
consumer.cleanupPaths.push(stagingRoot)
537+
stagedConsumers.add(consumer)
538+
}
539+
540+
await writeComposerDependencyReference(join(consumer.source, "vendor", "composer", "installed.json"), packageName, reference)
541+
await writeComposerInstalledPhpReference(join(consumer.source, "vendor", "composer", "installed.php"), packageName, reference)
542+
}
543+
544+
async function preserveComposerPackageReference(source: string, packageName: string, reference: string): Promise<void> {
545+
await writeComposerDependencyReference(join(source, "vendor", "composer", "installed.json"), packageName, reference)
546+
await writeComposerInstalledPhpReference(join(source, "vendor", "composer", "installed.php"), packageName, reference)
547+
}
548+
549+
async function composerInstalledJsonHasPackage(path: string, packageName: string): Promise<boolean> {
550+
try {
551+
return composerInstalledPackageRecords(JSON.parse(await readFile(path, "utf8"))).some((pkg) => pkg.name === packageName)
552+
} catch {
553+
return false
554+
}
555+
}
556+
557+
async function writeComposerDependencyReference(path: string, packageName: string, reference: string): Promise<void> {
558+
const installed = JSON.parse(await readFile(path, "utf8")) as unknown
559+
const pkg = composerInstalledPackageRecords(installed).find((candidate) => candidate.name === packageName)
560+
if (!pkg) {
561+
return
562+
}
563+
pkg.source = { ...(isRecord(pkg.source) ? pkg.source : {}), reference }
564+
await writeFile(path, `${JSON.stringify(installed, null, 2)}\n`)
565+
}
566+
567+
async function writeComposerInstalledPhpReference(path: string, packageName: string, reference: string): Promise<void> {
568+
if (!await pathIsFile(path)) {
569+
return
570+
}
571+
const contents = await readFile(path, "utf8")
572+
const packageStart = contents.search(new RegExp(`['\"]${escapeRegExp(packageName)}['\"]\\s*=>\\s*array\\s*\\(`))
573+
if (packageStart < 0) {
574+
return
575+
}
576+
const before = contents.slice(0, packageStart)
577+
const entry = contents.slice(packageStart)
578+
const packageEntryEnd = composerInstalledPhpPackageEntryEnd(entry, packageName)
579+
if (packageEntryEnd === undefined) {
580+
return
581+
}
582+
const packageEntry = entry.slice(0, packageEntryEnd)
583+
const remainingEntries = entry.slice(packageEntryEnd)
584+
const updatedEntry = packageEntry.replace(/(['"]reference['"]\s*=>\s*)(?:NULL|null|'[^']*'|"[^"]*")/, `$1'${reference}'`)
585+
if (updatedEntry !== packageEntry) {
586+
await writeFile(path, before + updatedEntry + remainingEntries)
587+
return
588+
}
589+
590+
// Composer may omit an unavailable reference entirely. Add it to this
591+
// package record so InstalledVersions exposes the clean overlay revision.
592+
const entryWithReference = packageEntry.replace(
593+
new RegExp(`((?:['"]${escapeRegExp(packageName)}['"]\\s*=>\\s*array\\s*\\(\\s*))`),
594+
`$1'reference' => '${reference}',\n`,
595+
)
596+
if (entryWithReference !== packageEntry) {
597+
await writeFile(path, before + entryWithReference + remainingEntries)
598+
}
599+
}
600+
601+
function composerInstalledPhpPackageEntryEnd(entry: string, packageName: string): number | undefined {
602+
const header = new RegExp(`['"]${escapeRegExp(packageName)}['"]\\s*=>\\s*array\\s*\\(`).exec(entry)
603+
if (!header || header.index === undefined) {
604+
return undefined
605+
}
606+
607+
const openingParenthesis = header.index + header[0].lastIndexOf("(")
608+
let depth = 0
609+
let quote = ""
610+
for (let index = openingParenthesis; index < entry.length; index++) {
611+
const character = entry[index]
612+
if (quote) {
613+
if (character === "\\") {
614+
index++
615+
} else if (character === quote) {
616+
quote = ""
617+
}
618+
continue
619+
}
620+
if (character === "'" || character === '"') {
621+
quote = character
622+
} else if (character === "(") {
623+
depth++
624+
} else if (character === ")" && --depth === 0) {
625+
return index + 1
626+
}
627+
}
628+
return undefined
629+
}
630+
631+
function composerInstalledPackageRecords(installed: unknown): Array<Record<string, unknown> & { name?: string }> {
632+
if (Array.isArray(installed)) {
633+
return installed.filter(isRecord)
634+
}
635+
return isRecord(installed) && Array.isArray(installed.packages) ? installed.packages.filter(isRecord) : []
636+
}
637+
638+
function isRecord(value: unknown): value is Record<string, unknown> {
639+
return !!value && typeof value === "object" && !Array.isArray(value)
640+
}
641+
481642
export async function prepareRecipeStagedFiles(recipe: WorkspaceRecipe, recipeDirectory: string): Promise<PreparedStagedFile[]> {
482643
const stagedFiles: PreparedStagedFile[] = []
483644
for (const [index, stagedFile] of (recipe.inputs?.stagedFiles ?? []).entries()) {

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

Lines changed: 74 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,35 @@ 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+
),
81+
'acme/non-git-package' => array(
82+
'pretty_version' => '1.0.0+no-version-set',
83+
'reference' => NULL,
84+
),
85+
),
86+
);
87+
`)
5488

5589
await writeFile(scoperPath, `<?php
5690
$workingDir = '';
@@ -116,38 +150,66 @@ const recipe: WorkspaceRecipe = {
116150
}
117151

118152
const overlays = await prepareRecipeRuntimeOverlays(recipe, root)
153+
const consumers: PreparedExtraPlugin[] = [{
154+
source: consumerSource,
155+
slug: "consumer-plugin",
156+
target: "/wordpress/wp-content/plugins/consumer-plugin",
157+
pluginFile: "consumer-plugin.php",
158+
activate: true,
159+
loadAs: "plugin",
160+
cleanupPaths: [],
161+
provenance: { kind: "local", original: consumerSource },
162+
}]
119163
const dependencyOverlays = await prepareRecipeDependencyOverlays({
120164
inputs: {
121165
dependency_overlays: [{
122166
kind: "composer-package",
123167
package: "acme/package",
124168
source: dependencySource,
125169
consumer: "consumer-plugin",
170+
}, {
171+
kind: "composer-package",
172+
package: "acme/non-git-package",
173+
source: nonGitDependencySource,
174+
consumer: "consumer-plugin",
126175
}],
127176
},
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[])
177+
}, root, consumers)
138178
try {
139179
assert.equal(overlays.length, 1)
140-
assert.equal(dependencyOverlays.length, 1)
180+
assert.equal(dependencyOverlays.length, 2)
141181
assert.equal(await exists(join(overlaySource, "vendor")), false, "overlay source checkout must not be mutated")
142182
assert.equal(await exists(join(dependencySource, "vendor")), false, "dependency overlay source checkout must not be mutated")
143183
assert.equal(await exists(join(overlays[0].source, "autoload.php")), true)
144184
assert.equal(await exists(join(overlays[0].source, "src", "Client.php")), true)
145185
assert.equal(await exists(join(overlays[0].source, "third-party", "Psr", "Log", "LoggerInterface.php")), true)
146186
assert.equal(await exists(join(dependencyOverlays[0].source, "vendor", "composer", "installed.json")), true)
147187
assert.equal(dependencyOverlays[0].target, "/wordpress/wp-content/plugins/consumer-plugin/vendor/acme/package")
188+
assert.equal(dependencyOverlays[0].reference, dependencyReference.trim(), "clean Git source revision survives Composer staging")
189+
assert.equal(dependencyOverlays[0].metadata.reference, dependencyReference.trim(), "mounted dependency metadata preserves the source revision")
190+
assert.equal(recipeRunDependencyOverlay(dependencyOverlays[0]).reference, dependencyReference.trim(), "runtime dependency provenance exposes the source revision")
191+
assert.equal(dependencyOverlays[1].reference, undefined, "non-Git source has no fabricated revision")
192+
assert.equal(dependencyOverlays[1].metadata.reference, undefined, "non-Git source metadata omits the revision")
193+
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")
194+
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 } }> }
195+
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")
196+
assert.deepEqual(runtimeInstalled.packages[1], { name: "acme/non-git-package", version: "1.0.0+no-version-set" }, "runtime Composer provenance omits unresolved source references")
197+
const { stdout: runtimeInstalledPhp } = await execFile("php", ["-r", "echo json_encode(require $argv[1]);", join(consumers[0].source, "vendor", "composer", "installed.php")])
198+
const runtimePhpVersions = JSON.parse(runtimeInstalledPhp) as { versions: Record<string, { reference?: string | null }> }
199+
assert.equal(runtimePhpVersions.versions["acme/package"]?.reference, dependencyReference.trim(), "Composer runtime metadata includes the immutable source reference")
200+
assert.equal(runtimePhpVersions.versions["acme/non-git-package"]?.reference, null, "Composer runtime metadata leaves unresolved references unchanged")
201+
const runtimePackageRow = runtimePhpVersions.versions["acme/package"]
202+
assert.deepEqual(runtimePackageRow, {
203+
pretty_version: "1.0.0+no-version-set",
204+
reference: dependencyReference.trim(),
205+
}, "the final PHP-visible package row exposes the clean Git reference")
206+
assert.deepEqual(runtimePhpVersions.versions["acme/non-git-package"], {
207+
pretty_version: "1.0.0+no-version-set",
208+
reference: null,
209+
}, "the final PHP-visible package row leaves an unavailable reference unchanged")
148210
assert.match(await readFile(join(overlays[0].source, "src", "Client.php"), "utf8"), /WordPress\\AiClientDependencies\\Psr\\Log\\LoggerInterface/)
149211
} finally {
150-
await Promise.all([...overlays, ...dependencyOverlays].flatMap((overlay) => overlay.cleanupPaths).map((path) => rm(path, { recursive: true, force: true })))
212+
await Promise.all([...overlays, ...dependencyOverlays, ...consumers].flatMap((overlay) => overlay.cleanupPaths).map((path) => rm(path, { recursive: true, force: true })))
151213
process.env.PATH = originalPath
152214
if (originalScoper === undefined) {
153215
delete process.env.WP_CODEBOX_PHP_SCOPER_PHAR

0 commit comments

Comments
 (0)