From 4cefe6b2890e1ad8118069b62473cd29db7d6e61 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Fri, 24 Jul 2026 22:04:39 -0400 Subject: [PATCH] Preserve wrapped Composer overlay package layouts --- packages/cli/src/recipe-sources.ts | 55 ++++++++++++++----- ...r-package-overlay-autoload-layout-smoke.ts | 20 ++++++- 2 files changed, 60 insertions(+), 15 deletions(-) diff --git a/packages/cli/src/recipe-sources.ts b/packages/cli/src/recipe-sources.ts index 7957dddfb..8a5c93bdb 100644 --- a/packages/cli/src/recipe-sources.ts +++ b/packages/cli/src/recipe-sources.ts @@ -469,10 +469,11 @@ async function prepareRecipeDependencyOverlay(overlay: WorkspaceRecipeDependency // consumer's autoloader resolves, keeping the single vendor-path mount intact. // Never mutate the caller's checkout: stage a copy first when the hydrated // source is still the original directory. - const preparedSource = await reconcileOverlayAutoloadLayout(hydratedSource, source, stagingRoot, consumer.source, overlay.package) + const prepared = await reconcileOverlayAutoloadLayout(hydratedSource, source, stagingRoot, consumer.source, overlay.package) + const preparedSource = prepared.source if (reference) { await preserveComposerDependencyReference(consumer, overlay.package, reference, stagedConsumers) - await preserveComposerPackageReference(preparedSource, overlay.package, reference) + await preserveComposerPackageReference(prepared.packageRoot, overlay.package, reference) } const target = `${consumer.target}/vendor/${composerPackageVendorPath(overlay.package)}` const digest = await directoryContentDigest(preparedSource) @@ -503,22 +504,21 @@ async function prepareRecipeDependencyOverlay(overlay: WorkspaceRecipeDependency } /** - * Move the overlay's PSR-4 source directories to the paths the consumer's - * committed autoloader resolves for the same package. The overlay is mounted at - * the consumer's `vendor/` path and loaded via the consumer's PSR-4 - * map; when the override ships a different source layout than the consumer - * recorded (e.g. `src/` vs `php-transformer/src/`), the mounted classes must be - * relocated to the consumer's recorded layout or they are never autoloaded. + * Reconcile the overlay with the paths the consumer's committed autoloader + * resolves for the same package. A common wrapper prefix relocates the complete + * hydrated package so package-local bootstrap state stays coherent; other PSR-4 + * layout changes retain the existing source-directory relocation. */ -async function reconcileOverlayAutoloadLayout(hydratedSource: string, originalSource: string, stagingRoot: string, consumerSource: string, packageName: string): Promise { +async function reconcileOverlayAutoloadLayout(hydratedSource: string, originalSource: string, stagingRoot: string, consumerSource: string, packageName: string): Promise<{ source: string; packageRoot: string }> { const consumerPsr4 = await composerPackagePsr4FromInstalled(join(consumerSource, "vendor", "composer", "installed.json"), packageName) const overridePsr4 = await composerPackagePsr4FromInstalled(join(hydratedSource, "vendor", "composer", "installed.json"), packageName) ?? await composerPackagePsr4FromComposerJson(join(hydratedSource, "composer.json")) if (!consumerPsr4 || !overridePsr4) { - return hydratedSource + return { source: hydratedSource, packageRoot: hydratedSource } } const moves = new Map() + const layouts: Array<[string, string]> = [] for (const [namespace, consumerDir] of Object.entries(consumerPsr4)) { const overrideDir = overridePsr4[namespace] if (undefined === overrideDir) { @@ -526,13 +526,26 @@ async function reconcileOverlayAutoloadLayout(hydratedSource: string, originalSo } const from = normalizeOverlayRelativeDir(overrideDir) const to = normalizeOverlayRelativeDir(consumerDir) - if ("" === from || "" === to || from === to) { + if ("" === from || "" === to) { + continue + } + layouts.push([from, to]) + if (from === to) { continue } moves.set(from, to) } if (0 === moves.size) { - return hydratedSource + return { source: hydratedSource, packageRoot: hydratedSource } + } + + const wrapper = commonOverlayWrapperPrefix(layouts) + if (wrapper) { + const effectiveSource = join(stagingRoot, "reconciled-source") + const packageRoot = join(effectiveSource, wrapper) + await mkdir(dirname(packageRoot), { recursive: true }) + await cp(hydratedSource, packageRoot, { recursive: true }) + return { source: effectiveSource, packageRoot } } // Copy into the overlay staging root before moving directories so the caller's @@ -553,7 +566,23 @@ async function reconcileOverlayAutoloadLayout(hydratedSource: string, originalSo await mkdir(dirname(toPath), { recursive: true }) await rename(fromPath, toPath) } - return effectiveSource + return { source: effectiveSource, packageRoot: effectiveSource } +} + +function commonOverlayWrapperPrefix(changes: Array<[string, string]>): string | undefined { + let wrapper: string | undefined + for (const [from, to] of changes) { + const suffix = `/${from}` + if (!to.endsWith(suffix)) { + return undefined + } + const candidate = to.slice(0, -suffix.length) + if (!candidate || normalizeOverlayRelativeDir(candidate) !== candidate || (wrapper && wrapper !== candidate)) { + return undefined + } + wrapper = candidate + } + return wrapper } /** @return namespace-prefix -> normalized relative source dir, or undefined. */ diff --git a/scripts/composer-package-overlay-autoload-layout-smoke.ts b/scripts/composer-package-overlay-autoload-layout-smoke.ts index 0aeeb66e3..06609e4c4 100644 --- a/scripts/composer-package-overlay-autoload-layout-smoke.ts +++ b/scripts/composer-package-overlay-autoload-layout-smoke.ts @@ -1,7 +1,9 @@ import assert from "node:assert/strict" +import { execFile } from "node:child_process" import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" +import { promisify } from "node:util" import { prepareRecipeDependencyOverlays } from "../packages/cli/src/recipe-sources.js" import type { PreparedExtraPlugin } from "../packages/cli/src/recipe-sources.js" @@ -13,6 +15,7 @@ import type { PreparedExtraPlugin } from "../packages/cli/src/recipe-sources.js" // released version keeps running. const root = await mkdtemp(join(tmpdir(), "wp-codebox-overlay-autoload-layout-")) +const execFileAsync = promisify(execFile) async function exists(path: string): Promise { try { @@ -28,11 +31,14 @@ async function exists(path: string): Promise { const overrideSource = join(root, "blocks-engine-php-transformer") await mkdir(join(overrideSource, "src", "ArtifactCompiler"), { recursive: true }) await writeFile(join(overrideSource, "src", "ArtifactCompiler", "ArtifactCompiler.php"), " $baseDir . '/src/ArtifactCompiler/ArtifactCompiler.php'];\n") await writeFile(join(overrideSource, "vendor", "composer", "installed.json"), JSON.stringify({ packages: [ { name: "automattic/blocks-engine-php-transformer", autoload: { "psr-4": { "Automattic\\BlocksEngine\\PhpTransformer\\": "src/" } } }, ] }, null, 2)) @@ -70,14 +76,24 @@ const dependencyOverlays = await prepareRecipeDependencyOverlays({ try { assert.equal(dependencyOverlays.length, 1) const staged = dependencyOverlays[0].source - // The class must be relocated to the consumer's recorded PSR-4 path so the - // consumer autoloader resolves it after the vendor-path mount. + // A shared wrapper layout moves the complete hydrated package, keeping its + // bootstrap, package-local autoloader, and classmap base directory coherent. assert.equal(await exists(join(staged, "php-transformer", "src", "ArtifactCompiler", "ArtifactCompiler.php")), true, "override class relocated to consumer PSR-4 layout") + assert.equal(await exists(join(staged, "php-transformer", "php-transformer.php")), true, "package bootstrap moves with its source") + assert.equal(await exists(join(staged, "php-transformer", "vendor", "autoload.php")), true, "package-local autoloader moves with its source") + assert.equal(await exists(join(staged, "php-transformer", "vendor", "composer", "autoload_classmap.php")), true, "package-local classmap moves with its source") assert.equal(await exists(join(staged, "src", "ArtifactCompiler", "ArtifactCompiler.php")), false, "override source layout no longer shadows the consumer path") + assert.equal(await exists(join(staged, "php-transformer.php")), false, "bootstrap is not stranded at the old package root") + assert.equal(await exists(join(staged, "vendor", "autoload.php")), false, "package-local autoloader is not stranded at the old package root") + const classmap = await readFile(join(staged, "php-transformer", "vendor", "composer", "autoload_classmap.php"), "utf8") + assert.match(classmap, /\$baseDir \. '\/src\/ArtifactCompiler\/ArtifactCompiler\.php'/, "classmap remains relative to the relocated complete package") + await execFileAsync("php", [join(staged, "php-transformer", "php-transformer.php")]) assert.equal(dependencyOverlays[0].target, "/wordpress/wp-content/plugins/consumer-plugin/vendor/automattic/blocks-engine-php-transformer") // The override checkout itself is never mutated. assert.equal(await exists(join(overrideSource, "php-transformer")), false, "override checkout is not restructured in place") assert.equal(await exists(join(overrideSource, "src", "ArtifactCompiler", "ArtifactCompiler.php")), true, "override checkout keeps its own layout") + assert.equal(await exists(join(overrideSource, "php-transformer.php")), true, "override checkout keeps its bootstrap") + assert.equal(await exists(join(overrideSource, "vendor", "autoload.php")), true, "override checkout keeps its package-local autoloader") } finally { await Promise.all([...dependencyOverlays, ...consumers].flatMap((overlay) => overlay.cleanupPaths).map((path) => rm(path, { recursive: true, force: true }))) await rm(root, { recursive: true, force: true })