Skip to content

Commit af06a37

Browse files
committed
Reconcile dependency overlay to consumer autoload layout
A composer-package dependency overlay is mounted at the consumer's vendor/<package> path and autoloaded through the consumer's committed PSR-4 map, not the override's own. When the override ships a different PSR-4 source layout than the consumer recorded (e.g. a repo published as src/ but consumed as php-transformer/src/), the mounted class files landed where the consumer autoloader never looks, so the override was silently ignored and the pinned released version kept running. Relocate the staged overlay's PSR-4 source directories to the paths the consumer's autoloader resolves before mounting, staging a copy so the caller's checkout is never restructured in place. Add a regression smoke.
1 parent 52ecc5f commit af06a37

4 files changed

Lines changed: 202 additions & 1 deletion

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,7 @@
229229
"test:evidence-bundle-digest-and-recipe-artifact": "tsx tests/evidence-bundle-digest-and-recipe-artifact.test.ts",
230230
"test:runtime-overlay-descriptors": "tsx tests/runtime-overlay-descriptors.test.ts",
231231
"test:composer-package-overlay-revision": "tsx scripts/composer-backed-source-hydration-smoke.ts",
232+
"test:composer-package-overlay-autoload-layout": "tsx scripts/composer-package-overlay-autoload-layout-smoke.ts",
232233
"test:composer-installed-versions-loader-order": "tsx scripts/composer-installed-versions-loader-order-smoke.ts",
233234
"test:runtime-preset-registry": "tsx tests/runtime-preset-registry.test.ts",
234235
"test:generic-ability-runtime-run": "tsx tests/generic-ability-runtime-run.test.ts",

packages/cli/src/recipe-sources.ts

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -454,7 +454,18 @@ async function prepareRecipeDependencyOverlay(overlay: WorkspaceRecipeDependency
454454
await validateExistingDirectoryForOverlay(source, overlay.source)
455455
const reference = await resolvedGitSourceReference(source)
456456
const stagingRoot = await mkdtemp(join(tmpdir(), "wp-codebox-dependency-overlay-"))
457-
const preparedSource = await prepareComposerBackedSource(source, stagingRoot, `dependency overlay ${overlay.package}`)
457+
const hydratedSource = await prepareComposerBackedSource(source, stagingRoot, `dependency overlay ${overlay.package}`)
458+
// The overlay is mounted at the consumer's vendor path for the package and is
459+
// autoloaded through the consumer's committed PSR-4 map, not the override's
460+
// own. When the consumer records the package under a different PSR-4 source
461+
// layout than the override ships (e.g. a repo that publishes `src/` but is
462+
// consumed as `php-transformer/src/`), the mounted class files would sit where
463+
// the consumer autoloader never looks and the override is silently ignored.
464+
// Reconcile the staged source so each namespace's classes land at the path the
465+
// consumer's autoloader resolves, keeping the single vendor-path mount intact.
466+
// Never mutate the caller's checkout: stage a copy first when the hydrated
467+
// source is still the original directory.
468+
const preparedSource = await reconcileOverlayAutoloadLayout(hydratedSource, source, stagingRoot, consumer.source, overlay.package)
458469
if (reference) {
459470
await preserveComposerDependencyReference(consumer, overlay.package, reference, stagedConsumers)
460471
await preserveComposerPackageReference(preparedSource, overlay.package, reference)
@@ -487,6 +498,108 @@ async function prepareRecipeDependencyOverlay(overlay: WorkspaceRecipeDependency
487498
}
488499
}
489500

501+
/**
502+
* Move the overlay's PSR-4 source directories to the paths the consumer's
503+
* committed autoloader resolves for the same package. The overlay is mounted at
504+
* the consumer's `vendor/<package>` path and loaded via the consumer's PSR-4
505+
* map; when the override ships a different source layout than the consumer
506+
* recorded (e.g. `src/` vs `php-transformer/src/`), the mounted classes must be
507+
* relocated to the consumer's recorded layout or they are never autoloaded.
508+
*/
509+
async function reconcileOverlayAutoloadLayout(hydratedSource: string, originalSource: string, stagingRoot: string, consumerSource: string, packageName: string): Promise<string> {
510+
const consumerPsr4 = await composerPackagePsr4FromInstalled(join(consumerSource, "vendor", "composer", "installed.json"), packageName)
511+
const overridePsr4 = await composerPackagePsr4FromInstalled(join(hydratedSource, "vendor", "composer", "installed.json"), packageName)
512+
?? await composerPackagePsr4FromComposerJson(join(hydratedSource, "composer.json"))
513+
if (!consumerPsr4 || !overridePsr4) {
514+
return hydratedSource
515+
}
516+
517+
const moves = new Map<string, string>()
518+
for (const [namespace, consumerDir] of Object.entries(consumerPsr4)) {
519+
const overrideDir = overridePsr4[namespace]
520+
if (undefined === overrideDir) {
521+
continue
522+
}
523+
const from = normalizeOverlayRelativeDir(overrideDir)
524+
const to = normalizeOverlayRelativeDir(consumerDir)
525+
if ("" === from || "" === to || from === to) {
526+
continue
527+
}
528+
moves.set(from, to)
529+
}
530+
if (0 === moves.size) {
531+
return hydratedSource
532+
}
533+
534+
// Copy into the overlay staging root before moving directories so the caller's
535+
// checkout is never restructured in place (the hydrated source may still be
536+
// the original when it already carried a vendor/ directory).
537+
let effectiveSource = hydratedSource
538+
if (effectiveSource === originalSource) {
539+
effectiveSource = join(stagingRoot, "reconciled-source")
540+
await cp(originalSource, effectiveSource, { recursive: true })
541+
}
542+
543+
for (const [from, to] of moves) {
544+
const fromPath = join(effectiveSource, from)
545+
const toPath = join(effectiveSource, to)
546+
if (fromPath === toPath || !await pathIsDirectory(fromPath) || await pathExists(toPath)) {
547+
continue
548+
}
549+
await mkdir(dirname(toPath), { recursive: true })
550+
await rename(fromPath, toPath)
551+
}
552+
return effectiveSource
553+
}
554+
555+
/** @return namespace-prefix -> normalized relative source dir, or undefined. */
556+
async function composerPackagePsr4FromInstalled(installedPath: string, packageName: string): Promise<Record<string, string> | undefined> {
557+
try {
558+
const pkg = composerInstalledPackageRecords(JSON.parse(await readFile(installedPath, "utf8"))).find((candidate) => candidate.name === packageName)
559+
return pkg ? psr4SingleDirMap((pkg as ComposerInstalledPackage).autoload?.["psr-4"]) : undefined
560+
} catch {
561+
return undefined
562+
}
563+
}
564+
565+
async function composerPackagePsr4FromComposerJson(composerPath: string): Promise<Record<string, string> | undefined> {
566+
try {
567+
const composer = JSON.parse(await readFile(composerPath, "utf8")) as { autoload?: { "psr-4"?: Record<string, string | string[]> } }
568+
return psr4SingleDirMap(composer.autoload?.["psr-4"])
569+
} catch {
570+
return undefined
571+
}
572+
}
573+
574+
/** Keep only single-directory PSR-4 mappings; multi-dir prefixes are ambiguous to relocate. */
575+
function psr4SingleDirMap(psr4: Record<string, string | string[]> | undefined): Record<string, string> | undefined {
576+
if (!psr4) {
577+
return undefined
578+
}
579+
const map: Record<string, string> = {}
580+
for (const [namespace, dirs] of Object.entries(psr4)) {
581+
const list = Array.isArray(dirs) ? dirs : [dirs]
582+
if (1 === list.length && "string" === typeof list[0]) {
583+
map[namespace] = list[0]
584+
}
585+
}
586+
return Object.keys(map).length > 0 ? map : undefined
587+
}
588+
589+
function normalizeOverlayRelativeDir(dir: string): string {
590+
const normalized = dir.replace(/\\/g, "/").replace(/^\.\//, "").replace(/^\/+/, "").replace(/\/+$/, "")
591+
return normalized.includes("..") ? "" : normalized
592+
}
593+
594+
async function pathExists(path: string): Promise<boolean> {
595+
try {
596+
await stat(path)
597+
return true
598+
} catch {
599+
return false
600+
}
601+
}
602+
490603
/**
491604
* Capture the clean checkout commit before Composer staging so runtime
492605
* provenance remains tied to the source revision rather than its staged path.
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import assert from "node:assert/strict"
2+
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"
3+
import { tmpdir } from "node:os"
4+
import { join } from "node:path"
5+
import { prepareRecipeDependencyOverlays } from "../packages/cli/src/recipe-sources.js"
6+
import type { PreparedExtraPlugin } from "../packages/cli/src/recipe-sources.js"
7+
8+
// Proves the dependency overlay reconciles its on-disk PSR-4 layout to the
9+
// path the consumer's committed autoloader resolves. A repo published as
10+
// `src/` but consumed as `php-transformer/src/` (Blocks Engine's transformer,
11+
// mounted into Static Site Importer) would otherwise land where the consumer
12+
// autoloader never looks, so the override is silently ignored and the pinned
13+
// released version keeps running.
14+
15+
const root = await mkdtemp(join(tmpdir(), "wp-codebox-overlay-autoload-layout-"))
16+
17+
async function exists(path: string): Promise<boolean> {
18+
try {
19+
await stat(path)
20+
return true
21+
} catch {
22+
return false
23+
}
24+
}
25+
26+
// Override checkout ships its class under the package-canonical `src/` layout,
27+
// with a prebuilt vendor/ so no Composer hydration is needed.
28+
const overrideSource = join(root, "blocks-engine-php-transformer")
29+
await mkdir(join(overrideSource, "src", "ArtifactCompiler"), { recursive: true })
30+
await writeFile(join(overrideSource, "src", "ArtifactCompiler", "ArtifactCompiler.php"), "<?php\nnamespace Automattic\\BlocksEngine\\PhpTransformer\\ArtifactCompiler;\nfinal class ArtifactCompiler {}\n")
31+
await writeFile(join(overrideSource, "composer.json"), JSON.stringify({
32+
name: "automattic/blocks-engine-php-transformer",
33+
autoload: { "psr-4": { "Automattic\\BlocksEngine\\PhpTransformer\\": "src/" } },
34+
}, null, 2))
35+
await mkdir(join(overrideSource, "vendor", "composer"), { recursive: true })
36+
await writeFile(join(overrideSource, "vendor", "composer", "installed.json"), JSON.stringify({ packages: [
37+
{ name: "automattic/blocks-engine-php-transformer", autoload: { "psr-4": { "Automattic\\BlocksEngine\\PhpTransformer\\": "src/" } } },
38+
] }, null, 2))
39+
40+
// Consumer records the same package under the repo-relative `php-transformer/src/`
41+
// layout its release zip extracts to.
42+
const consumerSource = join(root, "consumer-plugin")
43+
await mkdir(join(consumerSource, "vendor", "composer"), { recursive: true })
44+
await writeFile(join(consumerSource, "vendor", "composer", "installed.json"), JSON.stringify({ packages: [
45+
{ name: "automattic/blocks-engine-php-transformer", version: "0.4.4", autoload: { "psr-4": { "Automattic\\BlocksEngine\\PhpTransformer\\": "php-transformer/src/" } } },
46+
] }, null, 2))
47+
48+
const consumers: PreparedExtraPlugin[] = [{
49+
source: consumerSource,
50+
slug: "consumer-plugin",
51+
target: "/wordpress/wp-content/plugins/consumer-plugin",
52+
pluginFile: "consumer-plugin.php",
53+
activate: true,
54+
loadAs: "plugin",
55+
cleanupPaths: [],
56+
provenance: { kind: "local", original: consumerSource },
57+
}]
58+
59+
const dependencyOverlays = await prepareRecipeDependencyOverlays({
60+
inputs: {
61+
dependency_overlays: [{
62+
kind: "composer-package",
63+
package: "automattic/blocks-engine-php-transformer",
64+
source: overrideSource,
65+
consumer: "consumer-plugin",
66+
}],
67+
},
68+
}, root, consumers)
69+
70+
try {
71+
assert.equal(dependencyOverlays.length, 1)
72+
const staged = dependencyOverlays[0].source
73+
// The class must be relocated to the consumer's recorded PSR-4 path so the
74+
// consumer autoloader resolves it after the vendor-path mount.
75+
assert.equal(await exists(join(staged, "php-transformer", "src", "ArtifactCompiler", "ArtifactCompiler.php")), true, "override class relocated to consumer PSR-4 layout")
76+
assert.equal(await exists(join(staged, "src", "ArtifactCompiler", "ArtifactCompiler.php")), false, "override source layout no longer shadows the consumer path")
77+
assert.equal(dependencyOverlays[0].target, "/wordpress/wp-content/plugins/consumer-plugin/vendor/automattic/blocks-engine-php-transformer")
78+
// The override checkout itself is never mutated.
79+
assert.equal(await exists(join(overrideSource, "php-transformer")), false, "override checkout is not restructured in place")
80+
assert.equal(await exists(join(overrideSource, "src", "ArtifactCompiler", "ArtifactCompiler.php")), true, "override checkout keeps its own layout")
81+
} finally {
82+
await Promise.all([...dependencyOverlays, ...consumers].flatMap((overlay) => overlay.cleanupPaths).map((path) => rm(path, { recursive: true, force: true })))
83+
await rm(root, { recursive: true, force: true })
84+
}
85+
86+
console.log("composer-package-overlay-autoload-layout-smoke: ok")

scripts/smoke-manifest.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ export const smokeGroups = {
122122
npmScript("test:runtime-php-snippets"),
123123
npmScript("test:php-runtime-provider-registry"),
124124
tsxSmoke("composer-backed-source-hydration-smoke"),
125+
tsxSmoke("composer-package-overlay-autoload-layout-smoke"),
125126
tsxSmoke("recipe-run-composer-autoload-extra-plugin-smoke"),
126127
tsxSmoke("runtime-component-lifecycle-replay-smoke"),
127128
],

0 commit comments

Comments
 (0)