Skip to content

Commit 2210570

Browse files
committed
Align Composer overlay provenance datasets
1 parent 4356937 commit 2210570

4 files changed

Lines changed: 134 additions & 6 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,7 @@
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",
204204
"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",
205206
"test:runtime-preset-registry": "tsx tests/runtime-preset-registry.test.ts",
206207
"test:generic-ability-runtime-run": "tsx tests/generic-ability-runtime-run.test.ts",
207208
"test:provider-runtime-contracts": "tsx tests/provider-runtime-contracts.test.ts",

packages/cli/src/recipe-sources.ts

Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -453,11 +453,12 @@ async function prepareRecipeDependencyOverlay(overlay: WorkspaceRecipeDependency
453453
const source = resolve(recipeDirectory, overlay.source)
454454
await validateExistingDirectoryForOverlay(source, overlay.source)
455455
const reference = await resolvedGitSourceReference(source)
456+
const stagingRoot = await mkdtemp(join(tmpdir(), "wp-codebox-dependency-overlay-"))
457+
const preparedSource = await prepareComposerBackedSource(source, stagingRoot, `dependency overlay ${overlay.package}`)
456458
if (reference) {
457459
await preserveComposerDependencyReference(consumer, overlay.package, reference, stagedConsumers)
460+
await preserveComposerPackageReference(preparedSource, overlay.package, reference)
458461
}
459-
const stagingRoot = await mkdtemp(join(tmpdir(), "wp-codebox-dependency-overlay-"))
460-
const preparedSource = await prepareComposerBackedSource(source, stagingRoot, `dependency overlay ${overlay.package}`)
461462
const target = `${consumer.target}/vendor/${composerPackageVendorPath(overlay.package)}`
462463
const digest = await directoryContentDigest(preparedSource)
463464

@@ -540,6 +541,11 @@ async function preserveComposerDependencyReference(consumer: PreparedExtraPlugin
540541
await writeComposerInstalledPhpReference(join(consumer.source, "vendor", "composer", "installed.php"), packageName, reference)
541542
}
542543

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+
543549
async function composerInstalledJsonHasPackage(path: string, packageName: string): Promise<boolean> {
544550
try {
545551
return composerInstalledPackageRecords(JSON.parse(await readFile(path, "utf8"))).some((pkg) => pkg.name === packageName)
@@ -569,10 +575,57 @@ async function writeComposerInstalledPhpReference(path: string, packageName: str
569575
}
570576
const before = contents.slice(0, packageStart)
571577
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)
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+
}
575627
}
628+
return undefined
576629
}
577630

578631
function composerInstalledPackageRecords(installed: unknown): Array<Record<string, unknown> & { name?: string }> {

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,6 @@ return array(
7777
'versions' => array(
7878
'acme/package' => array(
7979
'pretty_version' => '1.0.0+no-version-set',
80-
'reference' => NULL,
8180
),
8281
'acme/non-git-package' => array(
8382
'pretty_version' => '1.0.0+no-version-set',
@@ -199,6 +198,15 @@ try {
199198
const runtimePhpVersions = JSON.parse(runtimeInstalledPhp) as { versions: Record<string, { reference?: string | null }> }
200199
assert.equal(runtimePhpVersions.versions["acme/package"]?.reference, dependencyReference.trim(), "Composer runtime metadata includes the immutable source reference")
201200
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")
202210
assert.match(await readFile(join(overlays[0].source, "src", "Client.php"), "utf8"), /WordPress\\AiClientDependencies\\Psr\\Log\\LoggerInterface/)
203211
} finally {
204212
await Promise.all([...overlays, ...dependencyOverlays, ...consumers].flatMap((overlay) => overlay.cleanupPaths).map((path) => rm(path, { recursive: true, force: true })))
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import assert from "node:assert/strict"
2+
import { execFile as execFileCallback } from "node:child_process"
3+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"
4+
import { tmpdir } from "node:os"
5+
import { join } from "node:path"
6+
import { promisify } from "node:util"
7+
import { installPluginComposerAutoloadersCode, type PreparedExtraPlugin } from "../packages/cli/src/recipe-sources.js"
8+
9+
const execFile = promisify(execFileCallback)
10+
const root = await mkdtemp(join(tmpdir(), "wp-codebox-composer-installed-versions-"))
11+
const pluginDir = join(root, "plugins", "consumer")
12+
const muPluginDir = join(root, "mu-plugins")
13+
const packageName = "acme/package"
14+
15+
function installedVersionsSource(reference: string | null): string {
16+
const encodedReference = reference === null ? "null" : JSON.stringify(reference)
17+
return `<?php
18+
namespace Composer;
19+
class InstalledVersions {
20+
private static $data = array('versions' => array('${packageName}' => array('reference' => ${encodedReference})));
21+
public static function getReference($package) { return self::$data['versions'][$package]['reference'] ?? null; }
22+
public static function reload($data) { self::$data = $data; }
23+
}
24+
`
25+
}
26+
27+
function installedDataSource(reference: string | null): string {
28+
return `<?php return array('versions' => array('${packageName}' => array('reference' => ${reference === null ? "null" : JSON.stringify(reference)})));\n`
29+
}
30+
31+
async function run(reference: string | null): Promise<string> {
32+
await rm(root, { recursive: true, force: true })
33+
await mkdir(join(pluginDir, "vendor", "composer"), { recursive: true })
34+
await mkdir(join(pluginDir, "vendor", "acme", "package", "vendor", "composer"), { recursive: true })
35+
await mkdir(muPluginDir, { recursive: true })
36+
await writeFile(join(pluginDir, "vendor", "composer", "InstalledVersions.php"), installedVersionsSource(reference))
37+
await writeFile(join(pluginDir, "vendor", "composer", "installed.php"), installedDataSource(reference))
38+
await writeFile(join(pluginDir, "vendor", "autoload.php"), "<?php\n")
39+
await writeFile(join(pluginDir, "vendor", "acme", "package", "vendor", "composer", "InstalledVersions.php"), installedVersionsSource(reference))
40+
await writeFile(join(pluginDir, "vendor", "acme", "package", "vendor", "composer", "installed.php"), installedDataSource(reference))
41+
await writeFile(join(pluginDir, "vendor", "acme", "package", "vendor", "autoload.php"), "<?php if (!class_exists('Composer\\\\InstalledVersions', false)) { require_once __DIR__ . '/composer/InstalledVersions.php'; } Composer\\InstalledVersions::reload(require __DIR__ . '/composer/installed.php');\n")
42+
await writeFile(join(pluginDir, "vendor", "autoload_packages.php"), "<?php require_once __DIR__ . '/acme/package/vendor/autoload.php'; require_once __DIR__ . '/autoload.php';\n")
43+
44+
const plugin: PreparedExtraPlugin = {
45+
source: pluginDir,
46+
slug: "consumer",
47+
target: "/wordpress/wp-content/plugins/consumer",
48+
pluginFile: "consumer/consumer.php",
49+
activate: true,
50+
loadAs: "plugin",
51+
cleanupPaths: [],
52+
provenance: { kind: "local", original: pluginDir },
53+
}
54+
const installCode = installPluginComposerAutoloadersCode([plugin])
55+
assert.ok(installCode)
56+
const { stdout } = await execFile("php", ["-r", `function wp_json_encode($value, $options = 0) { return json_encode($value, $options); } define('ABSPATH', ${JSON.stringify(`${root}/`)}); define('WP_PLUGIN_DIR', ${JSON.stringify(join(root, "plugins"))}); define('WPMU_PLUGIN_DIR', ${JSON.stringify(muPluginDir)}); ${installCode} require WPMU_PLUGIN_DIR . '/wp-codebox-composer-autoloaders.php'; echo Composer\\InstalledVersions::getReference('${packageName}');`])
57+
return stdout.slice(stdout.lastIndexOf("}") + 1).trim()
58+
}
59+
60+
try {
61+
assert.equal(await run("overlay-reference"), "overlay-reference", "the nested Composer dataset exposes the overlay reference")
62+
assert.equal(await run(null), "", "a nested Composer dataset without a reference remains observable as null")
63+
console.log("composer-installed-versions-loader-order-smoke: ok")
64+
} finally {
65+
await rm(root, { recursive: true, force: true })
66+
}

0 commit comments

Comments
 (0)