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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@
"test:evidence-bundle-digest-and-recipe-artifact": "tsx tests/evidence-bundle-digest-and-recipe-artifact.test.ts",
"test:runtime-overlay-descriptors": "tsx tests/runtime-overlay-descriptors.test.ts",
"test:composer-package-overlay-revision": "tsx scripts/composer-backed-source-hydration-smoke.ts",
"test:composer-package-overlay-autoload-layout": "tsx scripts/composer-package-overlay-autoload-layout-smoke.ts",
"test:composer-installed-versions-loader-order": "tsx scripts/composer-installed-versions-loader-order-smoke.ts",
"test:runtime-preset-registry": "tsx tests/runtime-preset-registry.test.ts",
"test:generic-ability-runtime-run": "tsx tests/generic-ability-runtime-run.test.ts",
Expand Down
115 changes: 114 additions & 1 deletion packages/cli/src/recipe-sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -454,7 +454,18 @@ async function prepareRecipeDependencyOverlay(overlay: WorkspaceRecipeDependency
await validateExistingDirectoryForOverlay(source, overlay.source)
const reference = await resolvedGitSourceReference(source)
const stagingRoot = await mkdtemp(join(tmpdir(), "wp-codebox-dependency-overlay-"))
const preparedSource = await prepareComposerBackedSource(source, stagingRoot, `dependency overlay ${overlay.package}`)
const hydratedSource = await prepareComposerBackedSource(source, stagingRoot, `dependency overlay ${overlay.package}`)
// The overlay is mounted at the consumer's vendor path for the package and is
// autoloaded through the consumer's committed PSR-4 map, not the override's
// own. When the consumer records the package under a different PSR-4 source
// layout than the override ships (e.g. a repo that publishes `src/` but is
// consumed as `php-transformer/src/`), the mounted class files would sit where
// the consumer autoloader never looks and the override is silently ignored.
// Reconcile the staged source so each namespace's classes land at the path the
// 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)
if (reference) {
await preserveComposerDependencyReference(consumer, overlay.package, reference, stagedConsumers)
await preserveComposerPackageReference(preparedSource, overlay.package, reference)
Expand Down Expand Up @@ -487,6 +498,108 @@ 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.
*/
async function reconcileOverlayAutoloadLayout(hydratedSource: string, originalSource: string, stagingRoot: string, consumerSource: string, packageName: string): Promise<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
}

const moves = new Map<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) {
continue
}
moves.set(from, to)
}
if (0 === moves.size) {
return hydratedSource
}

// Copy into the overlay staging root before moving directories so the caller's
// checkout is never restructured in place (the hydrated source may still be
// the original when it already carried a vendor/ directory).
let effectiveSource = hydratedSource
if (effectiveSource === originalSource) {
effectiveSource = join(stagingRoot, "reconciled-source")
await cp(originalSource, effectiveSource, { recursive: true })
}

for (const [from, to] of moves) {
const fromPath = join(effectiveSource, from)
const toPath = join(effectiveSource, to)
if (fromPath === toPath || !await pathIsDirectory(fromPath) || await pathExists(toPath)) {
continue
}
await mkdir(dirname(toPath), { recursive: true })
await rename(fromPath, toPath)
}
return effectiveSource
}

/** @return namespace-prefix -> normalized relative source dir, or undefined. */
async function composerPackagePsr4FromInstalled(installedPath: string, packageName: string): Promise<Record<string, string> | undefined> {
try {
const pkg = composerInstalledPackageRecords(JSON.parse(await readFile(installedPath, "utf8"))).find((candidate) => candidate.name === packageName)
return pkg ? psr4SingleDirMap((pkg as ComposerInstalledPackage).autoload?.["psr-4"]) : undefined
} catch {
return undefined
}
}

async function composerPackagePsr4FromComposerJson(composerPath: string): Promise<Record<string, string> | undefined> {
try {
const composer = JSON.parse(await readFile(composerPath, "utf8")) as { autoload?: { "psr-4"?: Record<string, string | string[]> } }
return psr4SingleDirMap(composer.autoload?.["psr-4"])
} catch {
return undefined
}
}

/** Keep only single-directory PSR-4 mappings; multi-dir prefixes are ambiguous to relocate. */
function psr4SingleDirMap(psr4: Record<string, string | string[]> | undefined): Record<string, string> | undefined {
if (!psr4) {
return undefined
}
const map: Record<string, string> = {}
for (const [namespace, dirs] of Object.entries(psr4)) {
const list = Array.isArray(dirs) ? dirs : [dirs]
if (1 === list.length && "string" === typeof list[0]) {
map[namespace] = list[0]
}
}
return Object.keys(map).length > 0 ? map : undefined
}

function normalizeOverlayRelativeDir(dir: string): string {
const normalized = dir.replace(/\\/g, "/").replace(/^\.\//, "").replace(/^\/+/, "").replace(/\/+$/, "")
return normalized.includes("..") ? "" : normalized
}

async function pathExists(path: string): Promise<boolean> {
try {
await stat(path)
return true
} catch {
return false
}
}

/**
* Capture the clean checkout commit before Composer staging so runtime
* provenance remains tied to the source revision rather than its staged path.
Expand Down
86 changes: 86 additions & 0 deletions scripts/composer-package-overlay-autoload-layout-smoke.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import assert from "node:assert/strict"
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { prepareRecipeDependencyOverlays } from "../packages/cli/src/recipe-sources.js"
import type { PreparedExtraPlugin } from "../packages/cli/src/recipe-sources.js"

// Proves the dependency overlay reconciles its on-disk PSR-4 layout to the
// path the consumer's committed autoloader resolves. A repo published as
// `src/` but consumed as `php-transformer/src/` (Blocks Engine's transformer,
// mounted into Static Site Importer) would otherwise land where the consumer
// autoloader never looks, so the override is silently ignored and the pinned
// released version keeps running.

const root = await mkdtemp(join(tmpdir(), "wp-codebox-overlay-autoload-layout-"))

async function exists(path: string): Promise<boolean> {
try {
await stat(path)
return true
} catch {
return false
}
}

// Override checkout ships its class under the package-canonical `src/` layout,
// with a prebuilt vendor/ so no Composer hydration is needed.
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, "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", "composer", "installed.json"), JSON.stringify({ packages: [
{ name: "automattic/blocks-engine-php-transformer", autoload: { "psr-4": { "Automattic\\BlocksEngine\\PhpTransformer\\": "src/" } } },
] }, null, 2))

// Consumer records the same package under the repo-relative `php-transformer/src/`
// layout its release zip extracts to.
const consumerSource = join(root, "consumer-plugin")
await mkdir(join(consumerSource, "vendor", "composer"), { recursive: true })
await writeFile(join(consumerSource, "vendor", "composer", "installed.json"), JSON.stringify({ packages: [
{ name: "automattic/blocks-engine-php-transformer", version: "0.4.4", autoload: { "psr-4": { "Automattic\\BlocksEngine\\PhpTransformer\\": "php-transformer/src/" } } },
] }, null, 2))

const consumers: PreparedExtraPlugin[] = [{
source: consumerSource,
slug: "consumer-plugin",
target: "/wordpress/wp-content/plugins/consumer-plugin",
pluginFile: "consumer-plugin.php",
activate: true,
loadAs: "plugin",
cleanupPaths: [],
provenance: { kind: "local", original: consumerSource },
}]

const dependencyOverlays = await prepareRecipeDependencyOverlays({
inputs: {
dependency_overlays: [{
kind: "composer-package",
package: "automattic/blocks-engine-php-transformer",
source: overrideSource,
consumer: "consumer-plugin",
}],
},
}, root, consumers)

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.
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, "src", "ArtifactCompiler", "ArtifactCompiler.php")), false, "override source layout no longer shadows the consumer path")
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")
} 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 })
}

console.log("composer-package-overlay-autoload-layout-smoke: ok")
1 change: 1 addition & 0 deletions scripts/smoke-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ export const smokeGroups = {
npmScript("test:runtime-php-snippets"),
npmScript("test:php-runtime-provider-registry"),
tsxSmoke("composer-backed-source-hydration-smoke"),
tsxSmoke("composer-package-overlay-autoload-layout-smoke"),
tsxSmoke("recipe-run-composer-autoload-extra-plugin-smoke"),
tsxSmoke("runtime-component-lifecycle-replay-smoke"),
],
Expand Down
Loading