From 7365228630c6190bb4e225645533ed87bfd05781 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Thu, 30 Apr 2026 00:58:36 -0300 Subject: [PATCH 1/7] Fix global GrumPHP hook fallback rendering --- CHANGELOG.md | 1 + docs/getting-started/installation.rst | 3 ++- docs/running/specialized-commands.rst | 4 ++-- docs/usage/syncing-consumer-projects.rst | 4 ++-- src/Console/Command/GitHooksCommand.php | 3 ++- src/GitHooks/HookContentRenderer.php | 7 +++--- src/Path/DevToolsPathResolver.php | 22 +++++++++++++++++++ tests/Console/Command/GitHooksCommandTest.php | 4 +++- tests/GitHooks/HookContentRendererTest.php | 11 ++++++++-- tests/Path/DevToolsPathResolverTest.php | 20 +++++++++++++++++ 10 files changed, 67 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97706a1b27..e890ed3097 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Render managed GrumPHP hook fallback paths relative to the consumer project so global `dev-tools:sync` installs keep local GrumPHP hook execution working (#305) - Restore global DevTools new-version notifications by using a supported Symfony Process success check during release lookups (#300) ## [1.24.3] - 2026-04-30 diff --git a/docs/getting-started/installation.rst b/docs/getting-started/installation.rst index 3b7a96a2b9..03c42f6de7 100644 --- a/docs/getting-started/installation.rst +++ b/docs/getting-started/installation.rst @@ -38,7 +38,8 @@ following steps: ``.editorconfig``, and ``.github/dependabot.yml``, and refreshes ``.gitignore``, ``.gitattributes``, the project license, and packaged Git hooks that prefer a project-local ``grumphp.yml`` override and otherwise - use the active packaged DevTools ``grumphp.yml`` path. + use a project-relative reference to the active packaged DevTools + ``grumphp.yml`` path. 6. If ``.github/wiki`` is missing, ``dev-tools:sync`` adds it as a Git submodule that points to the repository wiki. 7. ``dev-tools:sync`` runs ``gitignore`` to merge canonical ignore rules into diff --git a/docs/running/specialized-commands.rst b/docs/running/specialized-commands.rst index 7208ae3a63..fc4d83b277 100644 --- a/docs/running/specialized-commands.rst +++ b/docs/running/specialized-commands.rst @@ -390,8 +390,8 @@ Important details: .gitignore; - it calls ``gitattributes`` to manage export-ignore rules in .gitattributes; - it refreshes packaged Git hooks that prefer a local ``grumphp.yml`` - override and otherwise use the active packaged DevTools ``grumphp.yml`` - path resolved when sync installs them; + override and otherwise use a project-relative reference to the active + packaged DevTools ``grumphp.yml`` path resolved when sync installs them; - it calls ``skills`` so ``.agents/skills`` contains links to the packaged skill set; - it calls ``agents`` so ``.agents/agents`` contains links to the packaged diff --git a/docs/usage/syncing-consumer-projects.rst b/docs/usage/syncing-consumer-projects.rst index d2223fa79a..09196cd7e0 100644 --- a/docs/usage/syncing-consumer-projects.rst +++ b/docs/usage/syncing-consumer-projects.rst @@ -53,8 +53,8 @@ What the Command Changes - Only when missing. * - ``.git/hooks/*`` - Copies packaged hooks that prefer a local ``grumphp.yml`` override and - otherwise use the active packaged DevTools ``grumphp.yml`` path - resolved when sync installs them. + otherwise use a project-relative reference to the active packaged + DevTools ``grumphp.yml`` path resolved when sync installs them. - Replaced when drift is detected. When to Run It diff --git a/src/Console/Command/GitHooksCommand.php b/src/Console/Command/GitHooksCommand.php index ae169c290b..79bbdb440b 100644 --- a/src/Console/Command/GitHooksCommand.php +++ b/src/Console/Command/GitHooksCommand.php @@ -131,6 +131,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int { $sourcePath = $this->fileLocator->locate((string) $input->getOption('source')); $targetPath = (string) $this->filesystem->getAbsolutePath((string) $input->getOption('target')); + $projectPath = Path::canonicalize(Path::join($targetPath, '..', '..')); $overwrite = ! $input->getOption('no-overwrite'); $dryRun = (bool) $input->getOption('dry-run'); $check = (bool) $input->getOption('check'); @@ -147,7 +148,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int foreach ($files as $file) { $sourcePath = $file->getRealPath(); $sourceContents = $this->filesystem->readFile($sourcePath); - $renderedSourceContents = $this->hookContentRenderer->render($sourceContents); + $renderedSourceContents = $this->hookContentRenderer->render($sourceContents, $projectPath); $hookPath = Path::join($targetPath, $file->getRelativePathname()); if (! $overwrite && ! $dryRun && ! $check && ! $interactive && $this->filesystem->exists($hookPath)) { diff --git a/src/GitHooks/HookContentRenderer.php b/src/GitHooks/HookContentRenderer.php index 65e063a8db..f608a3b670 100644 --- a/src/GitHooks/HookContentRenderer.php +++ b/src/GitHooks/HookContentRenderer.php @@ -27,7 +27,7 @@ final class HookContentRenderer { /** - * Placeholder replaced with the active packaged GrumPHP config path. + * Placeholder replaced with the project-relative packaged GrumPHP config path. */ public const string MANAGED_GRUMPHP_CONFIG_PLACEHOLDER = '__DEV_TOOLS_GRUMPHP_CONFIG__'; @@ -35,14 +35,15 @@ final class HookContentRenderer * Renders the hook contents for the active DevTools runtime. * * @param string $contents the packaged hook contents + * @param string $projectPath the consumer project root that will own the synchronized hook * * @return string the rendered hook contents */ - public function render(string $contents): string + public function render(string $contents, string $projectPath = ''): string { return str_replace( self::MANAGED_GRUMPHP_CONFIG_PLACEHOLDER, - escapeshellarg(DevToolsPathResolver::getPackagePath('grumphp.yml')), + escapeshellarg(DevToolsPathResolver::getPackagePathRelativeToProject('grumphp.yml', $projectPath)), $contents, ); } diff --git a/src/Path/DevToolsPathResolver.php b/src/Path/DevToolsPathResolver.php index 021c280afa..eb7377deeb 100644 --- a/src/Path/DevToolsPathResolver.php +++ b/src/Path/DevToolsPathResolver.php @@ -88,6 +88,28 @@ public static function getResourcesPath(string $path = ''): string return self::getPackagePath(Path::join(self::RESOURCES, $path)); } + /** + * Returns a package-relative path rendered relative to the active project root. + * + * @param string $path the relative path under the package root + * @param string $projectPath an optional project root path; defaults to the working project root + * @param string $packagePath an optional package root path; defaults to the current package root + */ + public static function getPackagePathRelativeToProject( + string $path, + string $projectPath = '', + string $packagePath = '', + ): string { + if (Path::isAbsolute($path)) { + throw new InvalidArgumentException('The DevTools package path MUST be relative to the package root.'); + } + + $projectPath = Path::canonicalize(WorkingProjectPathResolver::getProjectPath($projectPath)); + $packagePath = Path::canonicalize('' === $packagePath ? self::getPackagePath() : $packagePath); + + return Path::makeRelative(Path::join($packagePath, $path), $projectPath); + } + /** * Returns the active Composer autoload file for the current DevTools installation mode. * diff --git a/tests/Console/Command/GitHooksCommandTest.php b/tests/Console/Command/GitHooksCommandTest.php index 11afe1b407..d88e262051 100644 --- a/tests/Console/Command/GitHooksCommandTest.php +++ b/tests/Console/Command/GitHooksCommandTest.php @@ -25,6 +25,7 @@ use FastForward\DevTools\Filesystem\FilesystemInterface; use FastForward\DevTools\GitHooks\HookContentRenderer; use FastForward\DevTools\Path\DevToolsPathResolver; +use FastForward\DevTools\Path\WorkingProjectPathResolver; use FastForward\DevTools\Resource\FileDiff; use FastForward\DevTools\Resource\FileDiffer; use PHPUnit\Framework\Attributes\CoversClass; @@ -54,6 +55,7 @@ #[CoversClass(GitHooksCommand::class)] #[UsesClass(DevToolsPathResolver::class)] +#[UsesClass(WorkingProjectPathResolver::class)] #[UsesClass(FileDiff::class)] #[UsesClass(HookContentRenderer::class)] #[UsesTrait(LogsCommandResults::class)] @@ -230,7 +232,7 @@ public function executeWillRenderManagedGrumPhpConfigIntoPlaceholderHooks(): voi Argument::that( static fn(string $contents): bool => str_contains( $contents, - escapeshellarg(DevToolsPathResolver::getPackagePath('grumphp.yml')) + escapeshellarg(DevToolsPathResolver::getPackagePathRelativeToProject('grumphp.yml', '/app')) ) ), )->shouldBeCalledOnce(); diff --git a/tests/GitHooks/HookContentRendererTest.php b/tests/GitHooks/HookContentRendererTest.php index b58308d1b4..3155b8cc1d 100644 --- a/tests/GitHooks/HookContentRendererTest.php +++ b/tests/GitHooks/HookContentRendererTest.php @@ -21,6 +21,7 @@ use FastForward\DevTools\GitHooks\HookContentRenderer; use FastForward\DevTools\Path\DevToolsPathResolver; +use FastForward\DevTools\Path\WorkingProjectPathResolver; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\UsesClass; @@ -28,6 +29,7 @@ #[CoversClass(HookContentRenderer::class)] #[UsesClass(DevToolsPathResolver::class)] +#[UsesClass(WorkingProjectPathResolver::class)] final class HookContentRendererTest extends TestCase { /** @@ -39,8 +41,13 @@ public function renderWillReplaceTheManagedGrumPhpConfigPlaceholder(): void $renderer = new HookContentRenderer(); self::assertSame( - 'DEVTOOLS_GRUMPHP_CONFIG=' . escapeshellarg(DevToolsPathResolver::getPackagePath('grumphp.yml')), - $renderer->render('DEVTOOLS_GRUMPHP_CONFIG=' . HookContentRenderer::MANAGED_GRUMPHP_CONFIG_PLACEHOLDER), + 'DEVTOOLS_GRUMPHP_CONFIG=' . escapeshellarg( + DevToolsPathResolver::getPackagePathRelativeToProject('grumphp.yml', '/workspaces/project') + ), + $renderer->render( + 'DEVTOOLS_GRUMPHP_CONFIG=' . HookContentRenderer::MANAGED_GRUMPHP_CONFIG_PLACEHOLDER, + '/workspaces/project' + ), ); } } diff --git a/tests/Path/DevToolsPathResolverTest.php b/tests/Path/DevToolsPathResolverTest.php index c82ff3f9f2..f104e6422f 100644 --- a/tests/Path/DevToolsPathResolverTest.php +++ b/tests/Path/DevToolsPathResolverTest.php @@ -46,6 +46,10 @@ public function itWillExposeCanonicalPackagePaths(): void self::assertSame(\dirname(__DIR__, 2), DevToolsPathResolver::getPackagePath()); self::assertSame(\dirname(__DIR__, 2) . '/bin/dev-tools', DevToolsPathResolver::getBinaryPath()); self::assertSame(\dirname(__DIR__, 2) . '/resources', DevToolsPathResolver::getResourcesPath()); + self::assertSame( + 'grumphp.yml', + DevToolsPathResolver::getPackagePathRelativeToProject('grumphp.yml', \dirname(__DIR__, 2)) + ); self::assertSame(\dirname(__DIR__, 2) . '/vendor/autoload.php', DevToolsPathResolver::getRuntimeAutoloadPath()); self::assertSame( \dirname(__DIR__, 2) . '/vendor/bin/ecs', @@ -114,6 +118,22 @@ public function itWillResolveRuntimeAutoloadAndVendorPathsForRepositoryAndDepend '/workspaces/project/vendor/fast-forward/dev-tools' ) ); + self::assertSame( + 'vendor/fast-forward/dev-tools/grumphp.yml', + DevToolsPathResolver::getPackagePathRelativeToProject( + 'grumphp.yml', + '/workspaces/project', + '/workspaces/project/vendor/fast-forward/dev-tools' + ) + ); + self::assertSame( + '../../Users/example/.composer/vendor/fast-forward/dev-tools/grumphp.yml', + DevToolsPathResolver::getPackagePathRelativeToProject( + 'grumphp.yml', + '/workspaces/project', + '/Users/example/.composer/vendor/fast-forward/dev-tools' + ) + ); } /** From a9194ce7244ecceac94e35c0a97349bdbf59f238 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 04:00:57 +0000 Subject: [PATCH 2/7] Update wiki submodule pointer for PR #306 --- .github/wiki | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/wiki b/.github/wiki index 6feadca091..7217a39c6c 160000 --- a/.github/wiki +++ b/.github/wiki @@ -1 +1 @@ -Subproject commit 6feadca0911ba4de9a4a7e711d591df343c0a06e +Subproject commit 7217a39c6c8a13d944cf19ea74834452a859aa02 From d8ec99740d422daf2a0f8e3d945815221d402967 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Thu, 30 Apr 2026 01:08:36 -0300 Subject: [PATCH 3/7] Resolve git hooks project path from working directory --- src/Console/Command/GitHooksCommand.php | 2 +- tests/Console/Command/GitHooksCommandTest.php | 16 +++++++++------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/Console/Command/GitHooksCommand.php b/src/Console/Command/GitHooksCommand.php index 79bbdb440b..9b08e94ec5 100644 --- a/src/Console/Command/GitHooksCommand.php +++ b/src/Console/Command/GitHooksCommand.php @@ -130,8 +130,8 @@ protected function configure(): void protected function execute(InputInterface $input, OutputInterface $output): int { $sourcePath = $this->fileLocator->locate((string) $input->getOption('source')); + $projectPath = (string) $this->filesystem->getAbsolutePath('.'); $targetPath = (string) $this->filesystem->getAbsolutePath((string) $input->getOption('target')); - $projectPath = Path::canonicalize(Path::join($targetPath, '..', '..')); $overwrite = ! $input->getOption('no-overwrite'); $dryRun = (bool) $input->getOption('dry-run'); $check = (bool) $input->getOption('check'); diff --git a/tests/Console/Command/GitHooksCommandTest.php b/tests/Console/Command/GitHooksCommandTest.php index d88e262051..17d014b5b9 100644 --- a/tests/Console/Command/GitHooksCommandTest.php +++ b/tests/Console/Command/GitHooksCommandTest.php @@ -116,6 +116,8 @@ protected function setUp(): void ->willReturn(false); $this->input->isInteractive() ->willReturn(false); + $this->filesystem->getAbsolutePath('.') + ->willReturn('/app'); $this->filesystem->readFile(Argument::containingString('/post-merge')) ->willReturn('#!/bin/sh'); @@ -208,7 +210,7 @@ public function executeWillRenderManagedGrumPhpConfigIntoPlaceholderHooks(): voi $this->input->getOption('source') ->willReturn('resources/git-hooks'); $this->input->getOption('target') - ->willReturn('.git/hooks'); + ->willReturn('.githooks'); $this->input->getOption('no-overwrite') ->willReturn(false); @@ -217,18 +219,18 @@ public function executeWillRenderManagedGrumPhpConfigIntoPlaceholderHooks(): voi $this->finderFactory->create() ->willReturn(new Finder()) ->shouldBeCalledOnce(); - $this->filesystem->getAbsolutePath('.git/hooks') - ->willReturn('/app/.git/hooks'); - $this->filesystem->exists('/app/.git/hooks/post-merge') + $this->filesystem->getAbsolutePath('.githooks') + ->willReturn('/app/.githooks'); + $this->filesystem->exists('/app/.githooks/post-merge') ->willReturn(false); - $this->filesystem->exists('/app/.git/hooks/pre-commit') + $this->filesystem->exists('/app/.githooks/pre-commit') ->willReturn(false); $this->filesystem->readFile(Argument::containingString('/pre-commit')) ->willReturn("DEVTOOLS_GRUMPHP_CONFIG=__DEV_TOOLS_GRUMPHP_CONFIG__\n"); - $this->filesystem->copy(Argument::containingString('/post-merge'), '/app/.git/hooks/post-merge', false) + $this->filesystem->copy(Argument::containingString('/post-merge'), '/app/.githooks/post-merge', false) ->shouldBeCalledOnce(); $this->filesystem->dumpFile( - '/app/.git/hooks/pre-commit', + '/app/.githooks/pre-commit', Argument::that( static fn(string $contents): bool => str_contains( $contents, From 35cbaa08ce7cf0906bb2e8ee010b578c5ceda7eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Thu, 30 Apr 2026 01:35:12 -0300 Subject: [PATCH 4/7] Restore unreleased changelog entry for #305 --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e890ed3097..9af6c6449a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Render managed GrumPHP hook fallback paths relative to the consumer project so global `dev-tools:sync` installs keep local GrumPHP hook execution working (#305) + ## [1.24.4] - 2026-04-30 ### Fixed -- Render managed GrumPHP hook fallback paths relative to the consumer project so global `dev-tools:sync` installs keep local GrumPHP hook execution working (#305) - Restore global DevTools new-version notifications by using a supported Symfony Process success check during release lookups (#300) ## [1.24.3] - 2026-04-30 From 19a2d5c069def1fca2d6542207dc1040d8ffa0bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Thu, 30 Apr 2026 01:44:52 -0300 Subject: [PATCH 5/7] Fallback to absolute packaged hook paths across roots --- src/GitHooks/HookContentRenderer.php | 2 +- src/Path/DevToolsPathResolver.php | 13 +++++++++++-- tests/Path/DevToolsPathResolverTest.php | 8 ++++++++ 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/GitHooks/HookContentRenderer.php b/src/GitHooks/HookContentRenderer.php index f608a3b670..3757b16e3e 100644 --- a/src/GitHooks/HookContentRenderer.php +++ b/src/GitHooks/HookContentRenderer.php @@ -27,7 +27,7 @@ final class HookContentRenderer { /** - * Placeholder replaced with the project-relative packaged GrumPHP config path. + * Placeholder replaced with the packaged GrumPHP config path rendered relative to the project when possible. */ public const string MANAGED_GRUMPHP_CONFIG_PLACEHOLDER = '__DEV_TOOLS_GRUMPHP_CONFIG__'; diff --git a/src/Path/DevToolsPathResolver.php b/src/Path/DevToolsPathResolver.php index eb7377deeb..444e515697 100644 --- a/src/Path/DevToolsPathResolver.php +++ b/src/Path/DevToolsPathResolver.php @@ -89,7 +89,11 @@ public static function getResourcesPath(string $path = ''): string } /** - * Returns a package-relative path rendered relative to the active project root. + * Returns a packaged path rendered relative to the active project root when possible. + * + * When the project root and package root do not share a filesystem root, + * the packaged absolute path MUST be returned unchanged so globally + * installed DevTools can still point hooks at the packaged fallback file. * * @param string $path the relative path under the package root * @param string $projectPath an optional project root path; defaults to the working project root @@ -106,8 +110,13 @@ public static function getPackagePathRelativeToProject( $projectPath = Path::canonicalize(WorkingProjectPathResolver::getProjectPath($projectPath)); $packagePath = Path::canonicalize('' === $packagePath ? self::getPackagePath() : $packagePath); + $packageFilePath = Path::canonicalize(Path::join($packagePath, $path)); - return Path::makeRelative(Path::join($packagePath, $path), $projectPath); + try { + return Path::makeRelative($packageFilePath, $projectPath); + } catch (InvalidArgumentException) { + return $packageFilePath; + } } /** diff --git a/tests/Path/DevToolsPathResolverTest.php b/tests/Path/DevToolsPathResolverTest.php index f104e6422f..5bd1fc77a1 100644 --- a/tests/Path/DevToolsPathResolverTest.php +++ b/tests/Path/DevToolsPathResolverTest.php @@ -134,6 +134,14 @@ public function itWillResolveRuntimeAutoloadAndVendorPathsForRepositoryAndDepend '/Users/example/.composer/vendor/fast-forward/dev-tools' ) ); + self::assertSame( + 'C:/Users/example/.composer/vendor/fast-forward/dev-tools/grumphp.yml', + DevToolsPathResolver::getPackagePathRelativeToProject( + 'grumphp.yml', + 'D:/workspaces/project', + 'C:/Users/example/.composer/vendor/fast-forward/dev-tools' + ) + ); } /** From 4ade07c560742098cb37df6d0e9ac20493281e02 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 04:46:42 +0000 Subject: [PATCH 6/7] Update wiki submodule pointer for PR #306 --- .github/wiki | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/wiki b/.github/wiki index 7217a39c6c..d3bec40285 160000 --- a/.github/wiki +++ b/.github/wiki @@ -1 +1 @@ -Subproject commit 7217a39c6c8a13d944cf19ea74834452a859aa02 +Subproject commit d3bec40285be65e5502a5b522c7b395c87fae7a1 From 18e1d9d38b93a3bc1145bf804c819a5f9443862e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Thu, 30 Apr 2026 01:50:06 -0300 Subject: [PATCH 7/7] Simplify DevTools path resolution helpers --- src/Path/DevToolsPathResolver.php | 184 ++++++++++++++++++++---------- 1 file changed, 122 insertions(+), 62 deletions(-) diff --git a/src/Path/DevToolsPathResolver.php b/src/Path/DevToolsPathResolver.php index 444e515697..29f9d34d44 100644 --- a/src/Path/DevToolsPathResolver.php +++ b/src/Path/DevToolsPathResolver.php @@ -49,13 +49,7 @@ final class DevToolsPathResolver */ public static function getPackagePath(string $path = ''): string { - $packageDirectory = \dirname(__DIR__, 2); - - if ('' !== $path && Path::isAbsolute($path)) { - throw new InvalidArgumentException('The DevTools package path MUST be relative to the package root.'); - } - - return Path::join($packageDirectory, $path); + return self::resolvePackageRelativePath($path); } /** @@ -104,19 +98,10 @@ public static function getPackagePathRelativeToProject( string $projectPath = '', string $packagePath = '', ): string { - if (Path::isAbsolute($path)) { - throw new InvalidArgumentException('The DevTools package path MUST be relative to the package root.'); - } - - $projectPath = Path::canonicalize(WorkingProjectPathResolver::getProjectPath($projectPath)); - $packagePath = Path::canonicalize('' === $packagePath ? self::getPackagePath() : $packagePath); - $packageFilePath = Path::canonicalize(Path::join($packagePath, $path)); - - try { - return Path::makeRelative($packageFilePath, $projectPath); - } catch (InvalidArgumentException) { - return $packageFilePath; - } + return self::relativizePathFromProject( + self::resolvePackageRelativePath($path, $packagePath), + self::resolveProjectPath($projectPath), + ); } /** @@ -130,13 +115,7 @@ public static function getPackagePathRelativeToProject( */ public static function getRuntimeAutoloadPath(string $packagePath = ''): string { - $packagePath = Path::canonicalize('' === $packagePath ? self::getPackagePath() : $packagePath); - - if (self::isInstalledAsDependency($packagePath)) { - return Path::canonicalize(Path::join($packagePath, '..', '..', 'autoload.php')); - } - - return Path::join($packagePath, 'vendor', 'autoload.php'); + return Path::join(self::getRuntimeVendorRoot($packagePath), 'autoload.php'); } /** @@ -150,13 +129,7 @@ public static function getRuntimeAutoloadPath(string $packagePath = ''): string */ public static function getRuntimeToolBinaryPath(string $binary, string $packagePath = ''): string { - $packagePath = Path::canonicalize('' === $packagePath ? self::getPackagePath() : $packagePath); - - if (self::isInstalledAsDependency($packagePath)) { - return Path::canonicalize(Path::join($packagePath, '..', '..', 'bin', $binary)); - } - - return Path::join($packagePath, 'vendor', 'bin', $binary); + return self::getRuntimeVendorPath(Path::join('bin', $binary), $packagePath); } /** @@ -170,14 +143,7 @@ public static function getRuntimeToolBinaryPath(string $binary, string $packageP */ public static function getRuntimeVendorPath(string $path, string $packagePath = ''): string { - $packagePath = Path::canonicalize('' === $packagePath ? self::getPackagePath() : $packagePath); - $vendorPath = self::normalizeVendorRelativePath($path); - - if (self::isInstalledAsDependency($packagePath)) { - return Path::canonicalize(Path::join($packagePath, '..', '..', $vendorPath)); - } - - return Path::join($packagePath, 'vendor', $vendorPath); + return Path::join(self::getRuntimeVendorRoot($packagePath), self::normalizeVendorRelativePath($path)); } /** @@ -196,14 +162,10 @@ public static function getPreferredToolBinaryPath( string $projectPath = '', string $packagePath = '', ): string { - $projectPath = '' === $projectPath ? WorkingProjectPathResolver::getProjectPath() : $projectPath; - $projectBinaryPath = Path::join($projectPath, 'vendor', 'bin', $binary); - - if (file_exists($projectBinaryPath)) { - return $projectBinaryPath; - } - - return self::getRuntimeToolBinaryPath($binary, $packagePath); + return self::preferExistingPath( + self::getProjectVendorPath(Path::join('bin', $binary), $projectPath), + self::getRuntimeToolBinaryPath($binary, $packagePath), + ); } /** @@ -222,15 +184,10 @@ public static function getPreferredVendorPath( string $projectPath = '', string $packagePath = '', ): string { - $projectPath = '' === $projectPath ? WorkingProjectPathResolver::getProjectPath() : $projectPath; - $vendorPath = self::normalizeVendorRelativePath($path); - $projectVendorPath = Path::join($projectPath, 'vendor', $vendorPath); - - if (file_exists($projectVendorPath)) { - return $projectVendorPath; - } - - return self::getRuntimeVendorPath($vendorPath, $packagePath); + return self::preferExistingPath( + self::getProjectVendorPath($path, $projectPath), + self::getRuntimeVendorPath($path, $packagePath), + ); } /** @@ -240,9 +197,7 @@ public static function getPreferredVendorPath( */ public static function isInstalledAsDependency(string $packagePath = ''): bool { - $packagePath = Path::canonicalize('' === $packagePath ? self::getPackagePath() : $packagePath); - - return str_contains($packagePath, self::VENDOR_PACKAGE_PATH); + return str_contains(self::resolvePackageRoot($packagePath), self::VENDOR_PACKAGE_PATH); } /** @@ -270,4 +225,109 @@ private static function normalizeVendorRelativePath(string $path): string return $path; } + + /** + * Ensures packaged paths stay relative to the DevTools package root. + * + * @param string $path the package-relative path to validate + */ + private static function assertRelativePackagePath(string $path): void + { + if ('' !== $path && Path::isAbsolute($path)) { + throw new InvalidArgumentException('The DevTools package path MUST be relative to the package root.'); + } + } + + /** + * Returns a canonical path under the DevTools package root. + * + * @param string $path the package-relative path to resolve + * @param string $packagePath an optional package root path; defaults to the current package root + */ + private static function resolvePackageRelativePath(string $path = '', string $packagePath = ''): string + { + self::assertRelativePackagePath($path); + + return Path::canonicalize(Path::join(self::resolvePackageRoot($packagePath), $path)); + } + + /** + * Returns the canonical DevTools package root. + * + * @param string $packagePath an optional package root path; defaults to the current package root + */ + private static function resolvePackageRoot(string $packagePath = ''): string + { + return Path::canonicalize('' === $packagePath ? \dirname(__DIR__, 2) : $packagePath); + } + + /** + * Returns the canonical working project root. + * + * @param string $projectPath an optional project root path; defaults to the working project root + */ + private static function resolveProjectPath(string $projectPath = ''): string + { + return Path::canonicalize(WorkingProjectPathResolver::getProjectPath($projectPath)); + } + + /** + * Returns the active Composer vendor root for the current DevTools installation mode. + * + * @param string $packagePath an optional package root path; defaults to the current package root + */ + private static function getRuntimeVendorRoot(string $packagePath = ''): string + { + $packagePath = self::resolvePackageRoot($packagePath); + + if (self::isInstalledAsDependency($packagePath)) { + return Path::canonicalize(Path::join($packagePath, '..', '..')); + } + + return Path::join($packagePath, 'vendor'); + } + + /** + * Returns a vendor path under the active project root. + * + * @param string $path the vendor-relative path to resolve + * @param string $projectPath an optional project root path; defaults to the working project root + */ + private static function getProjectVendorPath(string $path, string $projectPath = ''): string + { + return Path::join(self::resolveProjectPath($projectPath), 'vendor', self::normalizeVendorRelativePath($path)); + } + + /** + * Returns the preferred path when a project-local candidate exists. + * + * @param string $preferredPath the project-local candidate path + * @param string $fallbackPath the runtime fallback path + */ + private static function preferExistingPath(string $preferredPath, string $fallbackPath): string + { + if (file_exists($preferredPath)) { + return $preferredPath; + } + + return $fallbackPath; + } + + /** + * Returns a path relative to the project root when possible. + * + * When paths do not share the same filesystem root, the original absolute + * path MUST be returned unchanged so callers still receive a usable path. + * + * @param string $path the absolute path to relativize + * @param string $projectPath the absolute project root used as base path + */ + private static function relativizePathFromProject(string $path, string $projectPath): string + { + try { + return Path::makeRelative($path, $projectPath); + } catch (InvalidArgumentException) { + return $path; + } + } }