diff --git a/package.json b/package.json index 6198c1999..88fdcb4b3 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/cli/src/recipe-sources.ts b/packages/cli/src/recipe-sources.ts index ec20c9668..5058374ad 100644 --- a/packages/cli/src/recipe-sources.ts +++ b/packages/cli/src/recipe-sources.ts @@ -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) @@ -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/` 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 { + 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() + 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 | 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 | undefined> { + try { + const composer = JSON.parse(await readFile(composerPath, "utf8")) as { autoload?: { "psr-4"?: Record } } + 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 | undefined): Record | undefined { + if (!psr4) { + return undefined + } + const map: Record = {} + 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 { + 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. diff --git a/scripts/composer-package-overlay-autoload-layout-smoke.ts b/scripts/composer-package-overlay-autoload-layout-smoke.ts new file mode 100644 index 000000000..0aeeb66e3 --- /dev/null +++ b/scripts/composer-package-overlay-autoload-layout-smoke.ts @@ -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 { + 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"), " 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") diff --git a/scripts/smoke-manifest.ts b/scripts/smoke-manifest.ts index a72ca5bb4..b6c4088c5 100644 --- a/scripts/smoke-manifest.ts +++ b/scripts/smoke-manifest.ts @@ -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"), ],