Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 42 additions & 13 deletions packages/cli/src/recipe-sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -503,36 +504,48 @@ 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/<package>` 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<string> {
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<string, string>()
const layouts: Array<[string, string]> = []
for (const [namespace, consumerDir] of Object.entries(consumerPsr4)) {
const overrideDir = overridePsr4[namespace]
if (undefined === overrideDir) {
continue
}
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
Expand All @@ -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. */
Expand Down
20 changes: 18 additions & 2 deletions scripts/composer-package-overlay-autoload-layout-smoke.ts
Original file line number Diff line number Diff line change
@@ -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"

Expand All @@ -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<boolean> {
try {
Expand All @@ -28,11 +31,14 @@ async function exists(path: string): Promise<boolean> {
const overrideSource = join(root, "blocks-engine-php-transformer")
await mkdir(join(overrideSource, "src", "ArtifactCompiler"), { recursive: true })
await writeFile(join(overrideSource, "src", "ArtifactCompiler", "ArtifactCompiler.php"), "<?php\nnamespace Automattic\\BlocksEngine\\PhpTransformer\\ArtifactCompiler;\nfinal class ArtifactCompiler {}\n")
await writeFile(join(overrideSource, "php-transformer.php"), "<?php\nrequire __DIR__ . '/vendor/autoload.php';\nif (!class_exists(Automattic\\BlocksEngine\\PhpTransformer\\ArtifactCompiler\\ArtifactCompiler::class)) { throw new RuntimeException('classmap bootstrap failed'); }\n")
await writeFile(join(overrideSource, "composer.json"), JSON.stringify({
name: "automattic/blocks-engine-php-transformer",
autoload: { "psr-4": { "Automattic\\BlocksEngine\\PhpTransformer\\": "src/" } },
}, null, 2))
await mkdir(join(overrideSource, "vendor", "composer"), { recursive: true })
await writeFile(join(overrideSource, "vendor", "autoload.php"), "<?php\nforeach (require __DIR__ . '/composer/autoload_classmap.php' as $path) { require $path; }\n")
await writeFile(join(overrideSource, "vendor", "composer", "autoload_classmap.php"), "<?php\n$vendorDir = dirname(__DIR__);\n$baseDir = dirname($vendorDir);\nreturn ['Automattic\\\\BlocksEngine\\\\PhpTransformer\\\\ArtifactCompiler\\\\ArtifactCompiler' => $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))
Expand Down Expand Up @@ -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 })
Expand Down