diff --git a/.github/wiki b/.github/wiki index caa2be040..2182ccf4b 160000 --- a/.github/wiki +++ b/.github/wiki @@ -1 +1 @@ -Subproject commit caa2be04015083a8008d2d0029ebaf0d523324f7 +Subproject commit 2182ccf4b9a6ef8dbe0b8ad188df507a9adee909 diff --git a/CHANGELOG.md b/CHANGELOG.md index fd016c6f7..ff50d90d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,9 +17,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Preserve literal angle brackets around maintainer emails when generating LICENSE files from composer metadata (#179) - Keep packaged `.agents` payloads exportable and synchronize packaged skills and agents with repository-relative symlink targets so consumer repositories no longer receive broken absolute machine paths (#188) - Rewrite drifted Git hooks by removing the previous target first, restore the intended `0o755` executable mode, and report unwritable hook replacements cleanly when `.git/hooks` stays locked (#190) - Keep Composer plugin command discovery compatible with consumer environments by moving unsupported Symfony Console named parameters out of command metadata/configuration and by decoupling the custom filesystem wrapper from Composer's bundled Symfony Filesystem signatures (#185) +- Keep Composer autoload, Rector, and ECS from traversing nested fixture `vendor` directories when the composer-plugin consumer fixture has installed dependencies (#179) ## [1.20.0] - 2026-04-23 diff --git a/composer.json b/composer.json index 4de93f217..0362b1ec7 100644 --- a/composer.json +++ b/composer.json @@ -93,7 +93,7 @@ "FastForward\\DevTools\\Tests\\": "tests/" }, "classmap": [ - "tests/Fixtures/" + "tests/Fixtures/Console/" ] }, "bin": "bin/dev-tools", diff --git a/src/Changelog/DependabotChangelogEntryMessageResolver.php b/src/Changelog/DependabotChangelogEntryMessageResolver.php index dae971b4c..fa4017ca7 100644 --- a/src/Changelog/DependabotChangelogEntryMessageResolver.php +++ b/src/Changelog/DependabotChangelogEntryMessageResolver.php @@ -19,6 +19,9 @@ namespace FastForward\DevTools\Changelog; +use function Safe\preg_replace; +use function Safe\preg_match; + /** * Normalizes minimal changelog entry messages for Dependabot pull requests. */ @@ -32,10 +35,10 @@ */ public function resolve(string $title, int $pullRequestNumber): string { - $message = \preg_replace('/\s+/', ' ', \trim($title)) ?? \trim($title); - $message = \rtrim($message, " \t\n\r\0\x0B."); + $message = preg_replace('/\s+/', ' ', trim($title)) ?? trim($title); + $message = rtrim($message, " \t\n\r\0\x0B."); - if (\preg_match('/\(#\d+\)$/', $message) === 1) { + if (1 === preg_match('/\(#\d+\)$/', $message)) { return $message; } diff --git a/src/Config/ECSConfig.php b/src/Config/ECSConfig.php index dad276e45..e14bb7803 100644 --- a/src/Config/ECSConfig.php +++ b/src/Config/ECSConfig.php @@ -127,10 +127,11 @@ public static function applyDefaultPathsAndSkips( ECSConfigBuilder $config, string $workingDirectory ): ECSConfigBuilder { - $skipPaths = WorkingProjectPathResolver::getToolingExcludedDirectories($workingDirectory); + $paths = WorkingProjectPathResolver::getToolingSourcePaths($workingDirectory); + $skipPaths = WorkingProjectPathResolver::getToolingExcludedDirectories(); return $config - ->withPaths([$workingDirectory]) + ->withPaths($paths) ->withSkip([...$skipPaths, ...self::DEFAULT_SKIPPED_RULES]); } diff --git a/src/Config/RectorConfig.php b/src/Config/RectorConfig.php index c20a35d61..4dd0e4748 100644 --- a/src/Config/RectorConfig.php +++ b/src/Config/RectorConfig.php @@ -92,11 +92,12 @@ public static function configure(?callable $customize = null): callable { return static function (RectorConfigInterface $rectorConfig) use ($customize): void { $workingDirectory = getcwd(); - $skipPaths = WorkingProjectPathResolver::getToolingExcludedDirectories($workingDirectory); + $paths = WorkingProjectPathResolver::getToolingSourcePaths(); + $skipPaths = WorkingProjectPathResolver::getToolingExcludedDirectories(); $skipRules = self::DEFAULT_SKIPPED_RULES; $rectorConfig->sets(self::DEFAULT_SETS); - $rectorConfig->paths([$workingDirectory]); + $rectorConfig->paths($paths); $rectorConfig->skip([...$skipPaths, ...$skipRules]); $rectorConfig->cacheDirectory( ManagedWorkspace::getCacheDirectory(ManagedWorkspace::RECTOR, $workingDirectory) diff --git a/src/Console/Command/AgentsCommand.php b/src/Console/Command/AgentsCommand.php index 58de617a8..dc0cc2028 100644 --- a/src/Console/Command/AgentsCommand.php +++ b/src/Console/Command/AgentsCommand.php @@ -33,10 +33,7 @@ /** * Synchronizes packaged Fast Forward project agents into the consumer repository. */ -#[AsCommand( - name: 'agents', - description: 'Synchronizes Fast Forward project agents into .agents/agents directory.' -)] +#[AsCommand(name: 'agents', description: 'Synchronizes Fast Forward project agents into .agents/agents directory.')] final class AgentsCommand extends BaseCommand implements LoggerAwareCommandInterface { use HasJsonOption; diff --git a/src/Console/Command/ChangelogShowCommand.php b/src/Console/Command/ChangelogShowCommand.php index df98fc046..9d04e9488 100644 --- a/src/Console/Command/ChangelogShowCommand.php +++ b/src/Console/Command/ChangelogShowCommand.php @@ -35,10 +35,7 @@ /** * Prints the rendered notes body for a released changelog version. */ -#[AsCommand( - name: 'changelog:show', - description: 'Prints the notes body for a released changelog version.' -)] +#[AsCommand(name: 'changelog:show', description: 'Prints the notes body for a released changelog version.')] final class ChangelogShowCommand extends BaseCommand implements LoggerAwareCommandInterface { use HasJsonOption; diff --git a/src/Console/Command/CodeOwnersCommand.php b/src/Console/Command/CodeOwnersCommand.php index 68731b36a..5173ac63a 100644 --- a/src/Console/Command/CodeOwnersCommand.php +++ b/src/Console/Command/CodeOwnersCommand.php @@ -35,10 +35,7 @@ /** * Generates and synchronizes CODEOWNERS files from local project metadata. */ -#[AsCommand( - name: 'codeowners', - description: 'Generates .github/CODEOWNERS from local project metadata.' -)] +#[AsCommand(name: 'codeowners', description: 'Generates .github/CODEOWNERS from local project metadata.')] final class CodeOwnersCommand extends BaseCommand implements LoggerAwareCommandInterface { use HasJsonOption; diff --git a/src/Console/Command/CopyResourceCommand.php b/src/Console/Command/CopyResourceCommand.php index 095c20767..329f69e41 100644 --- a/src/Console/Command/CopyResourceCommand.php +++ b/src/Console/Command/CopyResourceCommand.php @@ -37,10 +37,7 @@ /** * Copies packaged or local resources into the consumer repository. */ -#[AsCommand( - name: 'copy-resource', - description: 'Copies a file or directory resource into the current project.' -)] +#[AsCommand(name: 'copy-resource', description: 'Copies a file or directory resource into the current project.')] final class CopyResourceCommand extends BaseCommand implements LoggerAwareCommandInterface { use HasJsonOption; diff --git a/src/Console/Command/DocsCommand.php b/src/Console/Command/DocsCommand.php index 257c90058..79de40b8c 100644 --- a/src/Console/Command/DocsCommand.php +++ b/src/Console/Command/DocsCommand.php @@ -46,10 +46,7 @@ * queue so logging and grouped output stay consistent with the rest of the * command surface. */ -#[AsCommand( - name: 'docs', - description: 'Generates API documentation.' -)] +#[AsCommand(name: 'docs', description: 'Generates API documentation.')] final class DocsCommand extends BaseCommand implements LoggerAwareCommandInterface { use HasCacheOption; diff --git a/src/Console/Command/GitHooksCommand.php b/src/Console/Command/GitHooksCommand.php index 32c1b1bdc..82c4b8d1f 100644 --- a/src/Console/Command/GitHooksCommand.php +++ b/src/Console/Command/GitHooksCommand.php @@ -37,10 +37,7 @@ /** * Installs packaged Git hooks for the consumer repository. */ -#[AsCommand( - name: 'git-hooks', - description: 'Installs Fast Forward Git hooks.' -)] +#[AsCommand(name: 'git-hooks', description: 'Installs Fast Forward Git hooks.')] final class GitHooksCommand extends BaseCommand implements LoggerAwareCommandInterface { use HasJsonOption; @@ -276,8 +273,12 @@ private function shouldReplaceHook(string $hookPath): bool * * @return bool true when the hook was installed successfully */ - private function installHook(string $sourcePath, string $hookPath, bool $replaceExisting, InputInterface $input): bool - { + private function installHook( + string $sourcePath, + string $hookPath, + bool $replaceExisting, + InputInterface $input + ): bool { try { if ($replaceExisting && $this->filesystem->exists($hookPath)) { $this->filesystem->remove($hookPath); @@ -287,15 +288,15 @@ private function installHook(string $sourcePath, string $hookPath, bool $replace $this->filesystem->chmod(files: $hookPath, mode: 0o755); return true; - } catch (IOExceptionInterface $exception) { + } catch (IOExceptionInterface $ioException) { $this->logger->error( 'Failed to install {hook_name} hook automatically. Remove or unlock {hook_path} and rerun git-hooks.', [ 'input' => $input, 'hook_name' => $this->filesystem->basename($hookPath), 'hook_path' => $hookPath, - 'error' => $exception->getMessage(), - 'file' => $exception->getPath() ?? $hookPath, + 'error' => $ioException->getMessage(), + 'file' => $ioException->getPath() ?? $hookPath, 'line' => null, ], ); diff --git a/src/Console/Command/GitIgnoreCommand.php b/src/Console/Command/GitIgnoreCommand.php index faac813bb..92be06ebb 100644 --- a/src/Console/Command/GitIgnoreCommand.php +++ b/src/Console/Command/GitIgnoreCommand.php @@ -44,10 +44,7 @@ * The command accepts two options: --source and --target to specify the paths * to the canonical and project .gitignore files respectively. */ -#[AsCommand( - name: 'gitignore', - description: 'Merges and synchronizes .gitignore files.' -)] +#[AsCommand(name: 'gitignore', description: 'Merges and synchronizes .gitignore files.')] final class GitIgnoreCommand extends BaseCommand implements LoggerAwareCommandInterface { use HasJsonOption; @@ -87,7 +84,9 @@ public function __construct( */ protected function configure(): void { - $this->setHelp("This command merges the canonical .gitignore from dev-tools with the project's existing .gitignore."); + $this->setHelp( + "This command merges the canonical .gitignore from dev-tools with the project's existing .gitignore." + ); $this->addJsonOption() ->addOption( diff --git a/src/Console/Command/LicenseCommand.php b/src/Console/Command/LicenseCommand.php index 04d72a85d..79aa829a0 100644 --- a/src/Console/Command/LicenseCommand.php +++ b/src/Console/Command/LicenseCommand.php @@ -37,10 +37,7 @@ * This command generates a LICENSE file if one does not exist and a supported * license is declared in composer.json. */ -#[AsCommand( - name: 'license', - description: 'Generates a LICENSE file from composer.json license information.' -)] +#[AsCommand(name: 'license', description: 'Generates a LICENSE file from composer.json license information.')] final class LicenseCommand extends BaseCommand implements LoggerAwareCommandInterface { use HasJsonOption; diff --git a/src/Console/Command/MetricsCommand.php b/src/Console/Command/MetricsCommand.php index d44288b2e..d668b03a5 100644 --- a/src/Console/Command/MetricsCommand.php +++ b/src/Console/Command/MetricsCommand.php @@ -34,10 +34,7 @@ use function rtrim; -#[AsCommand( - name: 'metrics', - description: 'Analyzes code metrics with PhpMetrics.' -)] +#[AsCommand(name: 'metrics', description: 'Analyzes code metrics with PhpMetrics.')] final class MetricsCommand extends BaseCommand implements LoggerAwareCommandInterface { use HasJsonOption; diff --git a/src/Console/Command/PhpDocCommand.php b/src/Console/Command/PhpDocCommand.php index 9dda01c51..1892c62e3 100644 --- a/src/Console/Command/PhpDocCommand.php +++ b/src/Console/Command/PhpDocCommand.php @@ -44,10 +44,7 @@ * Provides operations to inspect, lint, and repair PHPDoc comments across the project. * The class MUST NOT be extended and SHALL coordinate tools like PHP-CS-Fixer and Rector. */ -#[AsCommand( - name: 'phpdoc', - description: 'Checks and fixes PHPDocs.' -)] +#[AsCommand(name: 'phpdoc', description: 'Checks and fixes PHPDocs.')] final class PhpDocCommand extends BaseCommand implements LoggerAwareCommandInterface { use HasCacheOption; diff --git a/src/Console/Command/RefactorCommand.php b/src/Console/Command/RefactorCommand.php index f29cd23e2..abc768c5f 100644 --- a/src/Console/Command/RefactorCommand.php +++ b/src/Console/Command/RefactorCommand.php @@ -36,11 +36,7 @@ * Provides functionality to execute automated code refactoring using Rector. * This class MUST NOT be extended and SHALL encapsulate the logic for Rector invocation. */ -#[AsCommand( - name: 'refactor', - description: 'Runs Rector for code refactoring.', - aliases: ['rector'] -)] +#[AsCommand(name: 'refactor', description: 'Runs Rector for code refactoring.', aliases: ['rector'])] final class RefactorCommand extends BaseCommand implements LoggerAwareCommandInterface { use HasJsonOption; diff --git a/src/Console/Command/ReportsCommand.php b/src/Console/Command/ReportsCommand.php index 2c9aaee5a..9a56a576a 100644 --- a/src/Console/Command/ReportsCommand.php +++ b/src/Console/Command/ReportsCommand.php @@ -37,10 +37,7 @@ * Coordinates the generation of Fast Forward documentation frontpage and related reports. * This class MUST NOT be overridden and SHALL securely combine docs and testing commands. */ -#[AsCommand( - name: 'reports', - description: 'Generates the frontpage for Fast Forward documentation.' -)] +#[AsCommand(name: 'reports', description: 'Generates the frontpage for Fast Forward documentation.')] final class ReportsCommand extends BaseCommand implements LoggerAwareCommandInterface { use HasCacheOption; diff --git a/src/Console/Command/SkillsCommand.php b/src/Console/Command/SkillsCommand.php index 4c2f6aa51..5a0cf0be2 100644 --- a/src/Console/Command/SkillsCommand.php +++ b/src/Console/Command/SkillsCommand.php @@ -44,10 +44,7 @@ * target paths, triggers synchronization, and translates the resulting status * into Symfony Console output and process exit codes. */ -#[AsCommand( - name: 'skills', - description: 'Synchronizes Fast Forward skills into .agents/skills directory.' -)] +#[AsCommand(name: 'skills', description: 'Synchronizes Fast Forward skills into .agents/skills directory.')] final class SkillsCommand extends BaseCommand implements LoggerAwareCommandInterface { use HasJsonOption; diff --git a/src/Console/Command/StandardsCommand.php b/src/Console/Command/StandardsCommand.php index 3be8b0c95..655ef48b4 100644 --- a/src/Console/Command/StandardsCommand.php +++ b/src/Console/Command/StandardsCommand.php @@ -37,10 +37,7 @@ * Executes the full suite of Fast Forward code standard checks. * This class MUST NOT be modified through inheritance and SHALL streamline code validation workflows. */ -#[AsCommand( - name: 'standards', - description: 'Runs Fast Forward code standards checks.' -)] +#[AsCommand(name: 'standards', description: 'Runs Fast Forward code standards checks.')] final class StandardsCommand extends BaseCommand implements LoggerAwareCommandInterface { use HasCacheOption; @@ -73,9 +70,7 @@ protected function configure(): void ); $this ->addJsonOption() - ->addCacheOption( - 'Whether to enable cache writes in nested cache-aware standards commands.' - ) + ->addCacheOption('Whether to enable cache writes in nested cache-aware standards commands.') ->addCacheDirOption( description: 'Base cache directory used for nested cache-aware standards commands.', default: ManagedWorkspace::getCacheDirectory('standards'), diff --git a/src/Console/Command/TestsCommand.php b/src/Console/Command/TestsCommand.php index f584ddf8b..42bec6dcd 100644 --- a/src/Console/Command/TestsCommand.php +++ b/src/Console/Command/TestsCommand.php @@ -46,10 +46,7 @@ * Facilitates the execution of the PHPUnit testing framework. * This class MUST NOT be overridden and SHALL configure testing parameters dynamically. */ -#[AsCommand( - name: 'tests', - description: 'Runs PHPUnit tests.' -)] +#[AsCommand(name: 'tests', description: 'Runs PHPUnit tests.')] final class TestsCommand extends BaseCommand implements LoggerAwareCommandInterface { use HasCacheOption; @@ -187,10 +184,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int if ($cacheEnabled) { $processBuilder = $processBuilder->withArgument( '--cache-result', - )->withArgument( - '--cache-directory', - $this->resolvePath($input, 'cache-dir') - ); + )->withArgument('--cache-directory', $this->resolvePath($input, 'cache-dir')); } else { $processBuilder = $processBuilder->withArgument('--do-not-cache-result'); } diff --git a/src/Console/Command/WikiCommand.php b/src/Console/Command/WikiCommand.php index 80e408464..7c0d8ba9f 100644 --- a/src/Console/Command/WikiCommand.php +++ b/src/Console/Command/WikiCommand.php @@ -43,10 +43,7 @@ * Handles the generation of API documentation for the project. * This class MUST NOT be extended and SHALL utilize phpDocumentor to accomplish its task. */ -#[AsCommand( - name: 'wiki', - description: 'Generates API documentation in Markdown format.' -)] +#[AsCommand(name: 'wiki', description: 'Generates API documentation in Markdown format.')] final class WikiCommand extends BaseCommand implements LoggerAwareCommandInterface { use HasCacheOption; diff --git a/src/Console/Input/HasCacheOption.php b/src/Console/Input/HasCacheOption.php index 24fabe38e..8f7ebbe9f 100644 --- a/src/Console/Input/HasCacheOption.php +++ b/src/Console/Input/HasCacheOption.php @@ -19,6 +19,7 @@ namespace FastForward\DevTools\Console\Input; +use Throwable; use InvalidArgumentException; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -140,7 +141,7 @@ private function hasExplicitCacheDirArgument(InputInterface $input): bool { try { return $input->hasParameterOption('--cache-dir', true); - } catch (\Throwable) { + } catch (Throwable) { return false; } } diff --git a/src/Filesystem/Filesystem.php b/src/Filesystem/Filesystem.php index 59dc53f0d..f96217f31 100644 --- a/src/Filesystem/Filesystem.php +++ b/src/Filesystem/Filesystem.php @@ -31,10 +31,13 @@ * converting provided paths to absolute representations when a base path is supplied or * dynamically inferred from the generic working directory. */ -final class Filesystem implements FilesystemInterface +final readonly class Filesystem implements FilesystemInterface { + /** + * @param SymfonyFilesystem $filesystem + */ public function __construct( - private readonly SymfonyFilesystem $filesystem = new SymfonyFilesystem(), + private SymfonyFilesystem $filesystem = new SymfonyFilesystem(), ) {} /** @@ -84,7 +87,11 @@ public function dumpFile(string $filename, mixed $content, ?string $path = null) */ public function copy(string $originFile, string $targetFile, bool $overwriteNewerFiles = false): void { - $this->filesystem->copy($this->getAbsolutePath($originFile), $this->getAbsolutePath($targetFile), $overwriteNewerFiles); + $this->filesystem->copy( + $this->getAbsolutePath($originFile), + $this->getAbsolutePath($targetFile), + $overwriteNewerFiles + ); } /** diff --git a/src/GitAttributes/Merger.php b/src/GitAttributes/Merger.php index 379f0ec88..ce6bfcae8 100644 --- a/src/GitAttributes/Merger.php +++ b/src/GitAttributes/Merger.php @@ -194,7 +194,7 @@ private function keepInExportLookup(array $keepInExportPaths): array */ private function isKeptPath(string $pathKey, array $keptExportLookup): bool { - foreach ($keptExportLookup as $keptPath => $_) { + foreach (array_keys($keptExportLookup) as $keptPath) { if ($pathKey === $keptPath || str_starts_with($pathKey . '/', $keptPath . '/')) { return true; } diff --git a/src/GitAttributes/Writer.php b/src/GitAttributes/Writer.php index 555e2f956..a4be853a1 100644 --- a/src/GitAttributes/Writer.php +++ b/src/GitAttributes/Writer.php @@ -116,8 +116,14 @@ private function format(string $content): string ]; } - if ([] !== $rows && 'raw' === $rows[array_key_last($rows)]['type'] && '' === $rows[array_key_last($rows)]['line']) { - array_pop($rows); + $lastRowKey = array_key_last($rows); + + if (null !== $lastRowKey) { + $lastRow = $rows[$lastRowKey]; + + if ('raw' === $lastRow['type'] && '' === $lastRow['line']) { + array_pop($rows); + } } $formattedLines = []; diff --git a/src/License/Generator.php b/src/License/Generator.php index 59c2e2807..12b6af1d7 100644 --- a/src/License/Generator.php +++ b/src/License/Generator.php @@ -24,6 +24,7 @@ use FastForward\DevTools\Filesystem\FilesystemInterface; use Psr\Clock\ClockInterface; use Twig\Environment; +use Twig\Markup; /** * Generates LICENSE files from composer.json metadata. @@ -91,7 +92,7 @@ public function generateContent(): ?string try { $content = $this->renderer->render('licenses/' . $templateFilename, [ - 'copyright_holder' => (string) $this->composer->getAuthors(true), + 'copyright_holder' => new Markup((string) $this->composer->getAuthors(true), 'UTF-8'), 'year' => $this->clock->now() ->format('Y'), ]); diff --git a/src/Path/WorkingProjectPathResolver.php b/src/Path/WorkingProjectPathResolver.php index bca41e92b..df013c6fb 100644 --- a/src/Path/WorkingProjectPathResolver.php +++ b/src/Path/WorkingProjectPathResolver.php @@ -19,6 +19,7 @@ namespace FastForward\DevTools\Path; +use Symfony\Component\Finder\Finder; use Symfony\Component\Filesystem\Path; use function Safe\getcwd; @@ -53,8 +54,48 @@ public static function getToolingExcludedDirectories(string $baseDir = ''): arra { return [ ManagedWorkspace::getOutputDirectory(baseDir: $baseDir), + Path::join($baseDir, 'backup'), + Path::join($baseDir, 'cache'), + Path::join($baseDir, 'public'), Path::join($baseDir, 'resources'), + Path::join($baseDir, 'tmp'), Path::join($baseDir, 'vendor'), + Path::join($baseDir, '*/vendor'), + Path::join($baseDir, '*/vendor/*'), + Path::join($baseDir, '**/vendor'), + Path::join($baseDir, '**/vendor/*'), ]; } + + /** + * Returns PHP source files that tooling SHOULD inspect without traversing generated directories. + * + * @param string $baseDir the optional repository base directory used to materialize absolute paths + * + * @return list + */ + public static function getToolingSourcePaths(string $baseDir = ''): array + { + $workingDirectory = '' === $baseDir ? getcwd() : $baseDir; + $finder = Finder::create() + ->files() + ->name('*.php') + ->in($workingDirectory) + ->exclude(['.dev-tools', 'backup', 'cache', 'public', 'resources', 'tmp', 'vendor']) + ->sortByName(); + $paths = []; + + foreach ($finder as $file) { + $realPath = $file->getRealPath(); + + if (false === $realPath) { + continue; + } + + $relativePath = Path::makeRelative($realPath, $workingDirectory); + $paths[] = Path::join($baseDir, $relativePath); + } + + return $paths; + } } diff --git a/src/Sync/PackagedDirectorySynchronizer.php b/src/Sync/PackagedDirectorySynchronizer.php index ec4cfea97..521231e2c 100644 --- a/src/Sync/PackagedDirectorySynchronizer.php +++ b/src/Sync/PackagedDirectorySynchronizer.php @@ -100,6 +100,7 @@ public function synchronize(string $targetDir, string $packagePath, string $dire * @param string $targetLink Absolute path where the symlink should exist * @param string $sourcePath Absolute path to the packaged source directory * @param SynchronizeResult $result Result tracker for reporting outcomes + * @param bool $isDirectory */ private function processLink( string $entryName, @@ -130,6 +131,7 @@ private function processLink( * @param string $targetLink Absolute path where the symlink will be created * @param string $sourcePath Absolute path to the packaged directory * @param SynchronizeResult $result Result object for tracking creation + * @param bool $isDirectory */ private function createNewLink( string $entryName, @@ -169,6 +171,7 @@ private function preserveExistingNonSymlink(string $entryName, SynchronizeResult * @param string $targetLink Absolute path to the existing symlink * @param string $sourcePath Absolute path to the expected source directory * @param SynchronizeResult $result Result tracker for preserved or removed links + * @param bool $isDirectory */ private function processExistingSymlink( string $entryName, @@ -196,6 +199,7 @@ private function processExistingSymlink( * @param string $targetLink Absolute path to the broken symlink * @param string $sourcePath Absolute path to the current packaged directory * @param SynchronizeResult $result Result tracker for removed and created items + * @param bool $isDirectory */ private function repairBrokenLink( string $entryName, diff --git a/tests/Changelog/DependabotChangelogEntryMessageResolverTest.php b/tests/Changelog/DependabotChangelogEntryMessageResolverTest.php index 90c7d6da8..70b7b2bde 100644 --- a/tests/Changelog/DependabotChangelogEntryMessageResolverTest.php +++ b/tests/Changelog/DependabotChangelogEntryMessageResolverTest.php @@ -27,6 +27,9 @@ #[CoversClass(DependabotChangelogEntryMessageResolver::class)] final class DependabotChangelogEntryMessageResolverTest extends TestCase { + /** + * @return void + */ #[Test] public function resolveWillAppendThePullRequestNumberWhenTheTitleHasNoSuffix(): void { @@ -38,6 +41,9 @@ public function resolveWillAppendThePullRequestNumberWhenTheTitleHasNoSuffix(): ); } + /** + * @return void + */ #[Test] public function resolveWillPreserveAnExistingPullRequestSuffix(): void { @@ -49,6 +55,9 @@ public function resolveWillPreserveAnExistingPullRequestSuffix(): void ); } + /** + * @return void + */ #[Test] public function resolveWillTrimTrailingPunctuationBeforeAppendingTheSuffix(): void { diff --git a/tests/Config/ECSConfigTest.php b/tests/Config/ECSConfigTest.php index b93ce33bf..d2e6849c0 100644 --- a/tests/Config/ECSConfigTest.php +++ b/tests/Config/ECSConfigTest.php @@ -34,8 +34,6 @@ use FastForward\DevTools\Path\ManagedWorkspace; use FastForward\DevTools\Path\WorkingProjectPathResolver; -use function Safe\getcwd; - #[CoversClass(ECSConfig::class)] #[UsesClass(ManagedWorkspace::class)] #[UsesClass(WorkingProjectPathResolver::class)] diff --git a/tests/Config/RectorConfigTest.php b/tests/Config/RectorConfigTest.php index f577538ee..536fd4715 100644 --- a/tests/Config/RectorConfigTest.php +++ b/tests/Config/RectorConfigTest.php @@ -103,10 +103,13 @@ public function configureWillApplyTheDefaultRectorConfigurationAndCustomizationC $callback(new RectorConfigInterface()); self::assertTrue($customizeWasCalled); - self::assertSame([getcwd()], SimpleParameterProvider::provideArrayParameter(Option::PATHS)); + self::assertSame( + WorkingProjectPathResolver::getToolingSourcePaths(), + SimpleParameterProvider::provideArrayParameter(Option::PATHS) + ); self::assertSame( [ - ...WorkingProjectPathResolver::getToolingExcludedDirectories(getcwd()), + ...WorkingProjectPathResolver::getToolingExcludedDirectories(), RemoveUselessReturnTagRector::class, RemoveUselessParamTagRector::class, ], diff --git a/tests/Console/Command/CommandAttributeCompatibilityTest.php b/tests/Console/Command/CommandAttributeCompatibilityTest.php index eaddaaaac..f9c71e740 100644 --- a/tests/Console/Command/CommandAttributeCompatibilityTest.php +++ b/tests/Console/Command/CommandAttributeCompatibilityTest.php @@ -23,6 +23,8 @@ use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; +use function Safe\glob; +use function Safe\preg_match_all; use function Safe\file_get_contents; #[CoversNothing] @@ -45,7 +47,10 @@ public function asCommandAttributesWillNotUseTheHelpNamedParameter(): void self::assertStringNotContainsString( 'help:', $attribute, - sprintf('The command attribute in %s MUST remain compatible with Composer-discovered Symfony Console versions.', basename($commandFile)), + \sprintf( + 'The command attribute in %s MUST remain compatible with Composer-discovered Symfony Console versions.', + basename($commandFile) + ), ); } } diff --git a/tests/Console/Command/GitHooksCommandTest.php b/tests/Console/Command/GitHooksCommandTest.php index 4343b32a9..c450b9f6e 100644 --- a/tests/Console/Command/GitHooksCommandTest.php +++ b/tests/Console/Command/GitHooksCommandTest.php @@ -351,7 +351,7 @@ public function executeWillRemoveDriftedHookBeforeReplacingIt(): void $this->filesystem->exists('/app/.git/hooks/post-merge') ->willReturn(true); $this->fileDiffer->diff(Argument::containingString('/post-merge'), '/app/.git/hooks/post-merge') - ->willReturn(new FileDiff(FileDiff::STATUS_CHANGED, 'Changed summary', null)) + ->willReturn(new FileDiff(FileDiff::STATUS_CHANGED, 'Changed summary')) ->shouldBeCalledOnce(); $this->filesystem->remove('/app/.git/hooks/post-merge') ->shouldBeCalledOnce(); @@ -390,12 +390,14 @@ public function executeWillReportInstallFailureWhenReplacementStillCannotBeWritt $this->filesystem->exists('/app/.git/hooks/post-merge') ->willReturn(true); $this->fileDiffer->diff(Argument::containingString('/post-merge'), '/app/.git/hooks/post-merge') - ->willReturn(new FileDiff(FileDiff::STATUS_CHANGED, 'Changed summary', null)) + ->willReturn(new FileDiff(FileDiff::STATUS_CHANGED, 'Changed summary')) ->shouldBeCalledOnce(); $this->filesystem->remove('/app/.git/hooks/post-merge') ->shouldBeCalledOnce(); $this->filesystem->copy(Argument::containingString('/post-merge'), '/app/.git/hooks/post-merge', false) - ->willThrow(new IOException('Target file could not be opened for writing.', 0, null, '/app/.git/hooks/post-merge')) + ->willThrow( + new IOException('Target file could not be opened for writing.', 0, null, '/app/.git/hooks/post-merge') + ) ->shouldBeCalledOnce(); $this->filesystem->basename('/app/.git/hooks/post-merge') ->willReturn('post-merge') @@ -408,7 +410,7 @@ public function executeWillReportInstallFailureWhenReplacementStillCannotBeWritt && '/app/.git/hooks/post-merge' === $context['hook_path'] && '/app/.git/hooks/post-merge' === $context['file'] && null === $context['line'] - && str_contains($context['error'], 'Target file could not be opened for writing.') + && str_contains((string) $context['error'], 'Target file could not be opened for writing.') ), )->shouldBeCalledOnce(); $this->logger->error('One or more Git hooks could not be installed automatically.', Argument::type('array')) diff --git a/tests/Console/Command/TestsCommandTest.php b/tests/Console/Command/TestsCommandTest.php index 441b4d5ec..daeaf13c7 100644 --- a/tests/Console/Command/TestsCommandTest.php +++ b/tests/Console/Command/TestsCommandTest.php @@ -134,10 +134,7 @@ public function executeWillRunPhpUnitProcessWithConfigFile(): void $this->processQueue->add(Argument::that(static fn(Process $process): bool => str_contains( $process->getCommandLine(), '--configuration=' . getcwd() . '/' . TestsCommand::CONFIG, - ) && str_contains( - $process->getCommandLine(), - '--cache-result', - ) && str_contains( + ) && str_contains($process->getCommandLine(), '--cache-result') && str_contains( $process->getCommandLine(), '--cache-directory=' . getcwd() . '/.dev-tools/cache/phpunit', )))->shouldBeCalled(); @@ -169,10 +166,7 @@ public function executeWithNoCacheWillDisablePhpUnitResultCache(): void $this->processQueue->add(Argument::that(static fn(Process $process): bool => str_contains( $process->getCommandLine(), '--do-not-cache-result', - ) && ! str_contains( - $process->getCommandLine(), - '--cache-directory=', - )))->shouldBeCalled(); + ) && ! str_contains($process->getCommandLine(), '--cache-directory=')))->shouldBeCalled(); $this->processQueue->run($this->output->reveal()) ->willReturn(TestsCommand::SUCCESS)->shouldBeCalled(); diff --git a/tests/Console/Command/WikiCommandTest.php b/tests/Console/Command/WikiCommandTest.php index a6c72ba03..05ea89bc1 100644 --- a/tests/Console/Command/WikiCommandTest.php +++ b/tests/Console/Command/WikiCommandTest.php @@ -125,7 +125,10 @@ protected function setUp(): void #[Test] public function executeWillReturnSuccessWhenProcessQueueSucceeds(): void { - $this->processBuilder->withArgument('--cache-folder', ManagedWorkspace::getCacheDirectory(ManagedWorkspace::PHPDOC)) + $this->processBuilder->withArgument( + '--cache-folder', + ManagedWorkspace::getCacheDirectory(ManagedWorkspace::PHPDOC) + ) ->willReturn($this->processBuilder->reveal()) ->shouldBeCalled(); $this->processQueue->add($this->process->reveal()) diff --git a/tests/Filesystem/FilesystemTest.php b/tests/Filesystem/FilesystemTest.php index 31c822f85..7f315cd8e 100644 --- a/tests/Filesystem/FilesystemTest.php +++ b/tests/Filesystem/FilesystemTest.php @@ -25,6 +25,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Filesystem\Path; +use function Safe\chdir; use function Safe\fileperms; use function Safe\file_put_contents; use function Safe\getcwd; diff --git a/tests/Fixtures/composer-plugin-consumer/composer.json b/tests/Fixtures/composer-plugin-consumer/composer.json index eea9164d2..765085a09 100644 --- a/tests/Fixtures/composer-plugin-consumer/composer.json +++ b/tests/Fixtures/composer-plugin-consumer/composer.json @@ -1,5 +1,7 @@ { "name": "fast-forward/dev-tools-composer-plugin-consumer-fixture", + "description": "Fixture project used to verify DevTools Composer plugin consumer behavior.", + "license": "MIT", "type": "project", "require-dev": { "fast-forward/dev-tools": "*@dev" @@ -17,20 +19,20 @@ "prefer-stable": true, "config": { "allow-plugins": { - "fast-forward/dev-tools": true, "ergebnis/composer-normalize": true, + "fast-forward/dev-tools": true, "phpdocumentor/shim": true, "phpro/grumphp-shim": true, "pyrech/composer-changelogs": true } }, - "scripts": { - "dev-tools": "dev-tools", - "dev-tools:fix": "@dev-tools --fix" - }, "extra": { "grumphp": { "config-default-path": "../../../grumphp.yml" } + }, + "scripts": { + "dev-tools": "dev-tools", + "dev-tools:fix": "@dev-tools --fix" } } diff --git a/tests/GitAttributes/ExportIgnoreFilterTest.php b/tests/GitAttributes/ExportIgnoreFilterTest.php index 7b388a68f..e19f0e5a6 100644 --- a/tests/GitAttributes/ExportIgnoreFilterTest.php +++ b/tests/GitAttributes/ExportIgnoreFilterTest.php @@ -64,10 +64,7 @@ public function filterWillKeepNestedCandidatesWhenParentPathIsConfigured(): void { $filter = new ExportIgnoreFilter(); - $result = $filter->filter( - ['/.agents/agents/', '/.agents/skills/', '/tests/'], - ['/.agents/'], - ); + $result = $filter->filter(['/.agents/agents/', '/.agents/skills/', '/tests/'], ['/.agents/']); self::assertSame(['/tests/'], $result); } diff --git a/tests/License/GeneratorTest.php b/tests/License/GeneratorTest.php index 096ade37d..358bcb9d0 100644 --- a/tests/License/GeneratorTest.php +++ b/tests/License/GeneratorTest.php @@ -22,20 +22,24 @@ use Exception; use DateTimeImmutable; use FastForward\DevTools\Composer\Json\ComposerJsonInterface; +use FastForward\DevTools\Composer\Json\Schema\Author; use FastForward\DevTools\Composer\Json\Schema\AuthorInterface; use FastForward\DevTools\Filesystem\FilesystemInterface; use FastForward\DevTools\License\Generator; use FastForward\DevTools\License\ResolverInterface; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\Prophecy\ObjectProphecy; use Prophecy\PhpUnit\ProphecyTrait; use Psr\Clock\ClockInterface; use Twig\Environment; +use Twig\Loader\ArrayLoader; #[CoversClass(Generator::class)] +#[UsesClass(Author::class)] final class GeneratorTest extends TestCase { use ProphecyTrait; @@ -144,6 +148,40 @@ public function generateWithValidLicenseWillCreateFile(): void self::assertSame($renderedContent, $result); } + /** + * @return void + */ + #[Test] + public function generateContentWillPreserveLiteralAuthorEmailAngleBrackets(): void + { + $generator = new Generator( + $this->resolver->reveal(), + $this->composer->reveal(), + $this->clock->reveal(), + new Environment(new ArrayLoader([ + 'licenses/mit.txt' => 'Copyright (c) {{ year }} {{ copyright_holder }}', + ]), [ + 'autoescape' => 'html', + ]), + $this->filesystem->reveal(), + ); + + $this->composer->getLicense() + ->willReturn('MIT'); + $this->resolver->resolve('MIT') + ->willReturn('mit.txt'); + $this->composer->getAuthors(true) + ->willReturn(new Author('Felipe Sayão Lobato Abreu', 'github@mentordosnerds.com')); + $this->clock->now() + ->willReturn(new DateTimeImmutable('2026-04-23')); + + $content = $generator->generateContent(); + + self::assertSame('Copyright (c) 2026 Felipe Sayão Lobato Abreu ', $content); + self::assertStringNotContainsString('<', $content); + self::assertStringNotContainsString('>', $content); + } + /** * @return void */ diff --git a/tests/Path/WorkingProjectPathResolverTest.php b/tests/Path/WorkingProjectPathResolverTest.php index 040ac1a6c..c872e5f24 100644 --- a/tests/Path/WorkingProjectPathResolverTest.php +++ b/tests/Path/WorkingProjectPathResolverTest.php @@ -26,7 +26,12 @@ use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; +use function Safe\file_put_contents; use function Safe\getcwd; +use function Safe\mkdir; +use function Safe\realpath; +use function sys_get_temp_dir; +use function uniqid; #[CoversClass(WorkingProjectPathResolver::class)] #[UsesClass(ManagedWorkspace::class)] @@ -39,7 +44,19 @@ final class WorkingProjectPathResolverTest extends TestCase public function itWillExposeCanonicalRepositoryRootPaths(): void { self::assertSame( - ['repo/.dev-tools', 'repo/resources', 'repo/vendor'], + [ + 'repo/.dev-tools', + 'repo/backup', + 'repo/cache', + 'repo/public', + 'repo/resources', + 'repo/tmp', + 'repo/vendor', + 'repo/*/vendor', + 'repo/*/vendor/*', + 'repo/**/vendor', + 'repo/**/vendor/*', + ], WorkingProjectPathResolver::getToolingExcludedDirectories('repo') ); } @@ -51,11 +68,77 @@ public function itWillExposeCanonicalRepositoryRootPaths(): void public function itWillNormalizePathSeparatorsWhenJoiningProjectPaths(): void { self::assertSame( - ['tmp/.dev-tools', 'tmp/resources', 'tmp/vendor'], + [ + 'tmp/.dev-tools', + 'tmp/backup', + 'tmp/cache', + 'tmp/public', + 'tmp/resources', + 'tmp/tmp', + 'tmp/vendor', + 'tmp/*/vendor', + 'tmp/*/vendor/*', + 'tmp/**/vendor', + 'tmp/**/vendor/*', + ], WorkingProjectPathResolver::getToolingExcludedDirectories('tmp/') ); } + /** + * @return void + */ + #[Test] + public function itWillExposeRelativeToolingSkipPatternsByDefault(): void + { + self::assertSame( + [ + '.dev-tools', + 'backup', + 'cache', + 'public', + 'resources', + 'tmp', + 'vendor', + '*/vendor', + '*/vendor/*', + '**/vendor', + '**/vendor/*', + ], + WorkingProjectPathResolver::getToolingExcludedDirectories() + ); + } + + /** + * @return void + */ + #[Test] + public function itWillExposeToolingSourcePathsWithoutTraversingVendorDirectories(): void + { + $fixtureDirectory = sys_get_temp_dir() . '/dev-tools-path-resolver-' . uniqid(); + + mkdir($fixtureDirectory . '/src', recursive: true); + mkdir($fixtureDirectory . '/tests/Fixtures/consumer/vendor/package/src', recursive: true); + mkdir($fixtureDirectory . '/resources', recursive: true); + mkdir($fixtureDirectory . '/backup', recursive: true); + mkdir($fixtureDirectory . '/.dev-tools/cache', recursive: true); + + file_put_contents($fixtureDirectory . '/src/Example.php', ' */ @@ -55,6 +51,11 @@ final class ProcessQueueTest extends TestCase */ private ObjectProphecy $errorOutput; + /** + * @var ObjectProphecy + */ + private ObjectProphecy $githubActionOutput; + private ProcessQueue $queue; /** @@ -62,15 +63,16 @@ final class ProcessQueueTest extends TestCase */ protected function setUp(): void { - $this->composerTestsAreRunningEnv = getenv('COMPOSER_TESTS_ARE_RUNNING'); - putenv('COMPOSER_TESTS_ARE_RUNNING=1'); - $this->output = $this->prophesize(ConsoleOutputInterface::class); $this->errorOutput = $this->prophesize(OutputInterface::class); $this->output->getErrorOutput() ->willReturn($this->errorOutput->reveal()); - $this->queue = new ProcessQueue(new GithubActionOutput($this->output->reveal())); + $this->githubActionOutput = $this->prophesize(GithubActionOutput::class); + $this->githubActionOutput->group(Argument::type('string'), Argument::type(Closure::class)) + ->will(static fn(array $arguments): mixed => $arguments[1]()); + + $this->queue = new ProcessQueue($this->githubActionOutput->reveal()); } /** @@ -388,12 +390,6 @@ public function waitWillFlushFinishedDetachedOutputWithoutWaitingForEveryProcess */ protected function tearDown(): void { - if (false === $this->composerTestsAreRunningEnv) { - putenv('COMPOSER_TESTS_ARE_RUNNING'); - - return; - } - - putenv('COMPOSER_TESTS_ARE_RUNNING=' . $this->composerTestsAreRunningEnv); + unset($this->queue); } } diff --git a/tests/Sync/PackagedDirectorySynchronizerTest.php b/tests/Sync/PackagedDirectorySynchronizerTest.php index e02d881db..c5db7bbea 100644 --- a/tests/Sync/PackagedDirectorySynchronizerTest.php +++ b/tests/Sync/PackagedDirectorySynchronizerTest.php @@ -326,6 +326,7 @@ private function mockFinder(SplFileInfo ...$entries): void /** * @param string $entryName * @param string $sourcePath + * @param bool $isDirectory * * @return SplFileInfo */