@@ -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.
0 commit comments