From 19c096759e2ec4384b0a3d5d20541a26980e763d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Thu, 23 Apr 2026 22:58:58 -0300 Subject: [PATCH 1/9] [composer] Restore consumer command discovery compatibility (#185) --- CHANGELOG.md | 1 + src/Console/Command/AgentsCommand.php | 4 +- src/Console/Command/ChangelogCheckCommand.php | 4 +- src/Console/Command/ChangelogEntryCommand.php | 8 +-- .../Command/ChangelogNextVersionCommand.php | 4 +- .../Command/ChangelogPromoteCommand.php | 4 +- src/Console/Command/ChangelogShowCommand.php | 4 +- src/Console/Command/CodeOwnersCommand.php | 4 +- src/Console/Command/CodeStyleCommand.php | 4 +- src/Console/Command/CopyResourceCommand.php | 4 +- src/Console/Command/DependenciesCommand.php | 4 +- src/Console/Command/DocsCommand.php | 4 +- src/Console/Command/FundingCommand.php | 4 +- src/Console/Command/GitAttributesCommand.php | 9 ++-- src/Console/Command/GitHooksCommand.php | 4 +- src/Console/Command/GitIgnoreCommand.php | 4 +- src/Console/Command/LicenseCommand.php | 4 +- src/Console/Command/MetricsCommand.php | 4 +- src/Console/Command/PhpDocCommand.php | 4 +- src/Console/Command/RefactorCommand.php | 4 +- src/Console/Command/ReportsCommand.php | 4 +- src/Console/Command/SkillsCommand.php | 4 +- src/Console/Command/StandardsCommand.php | 4 +- src/Console/Command/SyncCommand.php | 4 +- src/Console/Command/TestsCommand.php | 4 +- .../Command/UpdateComposerJsonCommand.php | 4 +- src/Console/Command/WikiCommand.php | 5 +- src/Filesystem/Filesystem.php | 42 ++++++--------- src/Filesystem/FilesystemInterface.php | 3 +- .../CommandAttributeCompatibilityTest.php | 53 +++++++++++++++++++ tests/Filesystem/FilesystemTest.php | 16 ++++-- .../composer-plugin-consumer/.gitignore | 13 +++++ .../composer-plugin-consumer/composer.json | 36 +++++++++++++ 33 files changed, 189 insertions(+), 89 deletions(-) create mode 100644 tests/Console/Command/CommandAttributeCompatibilityTest.php create mode 100644 tests/Fixtures/composer-plugin-consumer/.gitignore create mode 100644 tests/Fixtures/composer-plugin-consumer/composer.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 872df1fa7..fd016c6f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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) ## [1.20.0] - 2026-04-23 diff --git a/src/Console/Command/AgentsCommand.php b/src/Console/Command/AgentsCommand.php index 1737c3906..4a49ca1e7 100644 --- a/src/Console/Command/AgentsCommand.php +++ b/src/Console/Command/AgentsCommand.php @@ -35,8 +35,7 @@ */ #[AsCommand( name: 'agents', - description: 'Synchronizes Fast Forward project agents into .agents/agents directory.', - help: 'This command ensures the consumer repository contains linked Fast Forward project agents by creating symlinks to the packaged prompts and removing broken links.' + description: 'Synchronizes Fast Forward project agents into .agents/agents directory.' )] final class AgentsCommand extends BaseCommand implements LoggerAwareCommandInterface { @@ -63,6 +62,7 @@ public function __construct( */ protected function configure(): void { + $this->setHelp('This command ensures the consumer repository contains linked Fast Forward project agents by creating symlinks to the packaged prompts and removing broken links.'); $this->addJsonOption(); } diff --git a/src/Console/Command/ChangelogCheckCommand.php b/src/Console/Command/ChangelogCheckCommand.php index 34bda1c6d..ccdb0bc02 100644 --- a/src/Console/Command/ChangelogCheckCommand.php +++ b/src/Console/Command/ChangelogCheckCommand.php @@ -35,8 +35,7 @@ */ #[AsCommand( name: 'changelog:check', - description: 'Checks whether a changelog file contains meaningful unreleased entries.', - help: 'This command validates the current Unreleased section and may compare it against a base git reference to enforce pull request changelog updates.' + description: 'Checks whether a changelog file contains meaningful unreleased entries.' )] final class ChangelogCheckCommand extends BaseCommand implements LoggerAwareCommandInterface { @@ -61,6 +60,7 @@ public function __construct( */ protected function configure(): void { + $this->setHelp('This command validates the current Unreleased section and may compare it against a base git reference to enforce pull request changelog updates.'); $this->addJsonOption() ->addOption( name: 'against', diff --git a/src/Console/Command/ChangelogEntryCommand.php b/src/Console/Command/ChangelogEntryCommand.php index c1199999d..ac7d5e680 100644 --- a/src/Console/Command/ChangelogEntryCommand.php +++ b/src/Console/Command/ChangelogEntryCommand.php @@ -38,8 +38,7 @@ */ #[AsCommand( name: 'changelog:entry', - description: 'Adds a changelog entry to Unreleased or a specific version section.', - help: 'This command appends one categorized changelog entry to the selected changelog file so it can be reused by local authoring flows and skills.' + description: 'Adds a changelog entry to Unreleased or a specific version section.' )] final class ChangelogEntryCommand extends BaseCommand implements LoggerAwareCommandInterface { @@ -64,6 +63,7 @@ public function __construct( */ protected function configure(): void { + $this->setHelp('This command appends one categorized changelog entry to the selected changelog file so it can be reused by local authoring flows and skills.'); $this->addJsonOption() ->addArgument( name: 'message', @@ -76,10 +76,6 @@ protected function configure(): void mode: InputOption::VALUE_REQUIRED, description: 'The changelog category (added, changed, deprecated, removed, fixed, security).', default: 'added', - suggestedValues: array_map( - static fn(ChangelogEntryType $type): string => strtolower($type->value), - ChangelogEntryType::ordered() - ), ) ->addOption( name: 'release', diff --git a/src/Console/Command/ChangelogNextVersionCommand.php b/src/Console/Command/ChangelogNextVersionCommand.php index 902920887..3edaff80e 100644 --- a/src/Console/Command/ChangelogNextVersionCommand.php +++ b/src/Console/Command/ChangelogNextVersionCommand.php @@ -36,8 +36,7 @@ */ #[AsCommand( name: 'changelog:next-version', - description: 'Infers the next semantic version from the Unreleased changelog section.', - help: 'This command inspects Unreleased changelog categories and prints the next semantic version inferred from the current changelog state.' + description: 'Infers the next semantic version from the Unreleased changelog section.' )] final class ChangelogNextVersionCommand extends BaseCommand implements LoggerAwareCommandInterface { @@ -62,6 +61,7 @@ public function __construct( */ protected function configure(): void { + $this->setHelp('This command inspects Unreleased changelog categories and prints the next semantic version inferred from the current changelog state.'); $this->addJsonOption() ->addOption( name: 'file', diff --git a/src/Console/Command/ChangelogPromoteCommand.php b/src/Console/Command/ChangelogPromoteCommand.php index f8730ad43..fc25bc19a 100644 --- a/src/Console/Command/ChangelogPromoteCommand.php +++ b/src/Console/Command/ChangelogPromoteCommand.php @@ -38,8 +38,7 @@ */ #[AsCommand( name: 'changelog:promote', - description: 'Promotes Unreleased entries into a published changelog version.', - help: 'This command moves the current Unreleased entries into a released version section, records the release date, and restores an empty Unreleased section.' + description: 'Promotes Unreleased entries into a published changelog version.' )] final class ChangelogPromoteCommand extends BaseCommand implements LoggerAwareCommandInterface { @@ -66,6 +65,7 @@ public function __construct( */ protected function configure(): void { + $this->setHelp('This command moves the current Unreleased entries into a released version section, records the release date, and restores an empty Unreleased section.'); $this->addJsonOption() ->addArgument( name: 'version', diff --git a/src/Console/Command/ChangelogShowCommand.php b/src/Console/Command/ChangelogShowCommand.php index fd283450f..dd9435130 100644 --- a/src/Console/Command/ChangelogShowCommand.php +++ b/src/Console/Command/ChangelogShowCommand.php @@ -37,8 +37,7 @@ */ #[AsCommand( name: 'changelog:show', - description: 'Prints the notes body for a released changelog version.', - help: 'This command renders the body of one released changelog section so it can be reused for GitHub release notes.' + description: 'Prints the notes body for a released changelog version.' )] final class ChangelogShowCommand extends BaseCommand implements LoggerAwareCommandInterface { @@ -63,6 +62,7 @@ public function __construct( */ protected function configure(): void { + $this->setHelp('This command renders the body of one released changelog section so it can be reused for GitHub release notes.'); $this->addJsonOption() ->addArgument( name: 'version', diff --git a/src/Console/Command/CodeOwnersCommand.php b/src/Console/Command/CodeOwnersCommand.php index c99a1fe9b..178fff05d 100644 --- a/src/Console/Command/CodeOwnersCommand.php +++ b/src/Console/Command/CodeOwnersCommand.php @@ -37,8 +37,7 @@ */ #[AsCommand( name: 'codeowners', - description: 'Generates .github/CODEOWNERS from local project metadata.', - help: 'This command infers CODEOWNERS entries from composer.json metadata, falls back to a commented template, and supports drift-aware preview and overwrite flows.' + description: 'Generates .github/CODEOWNERS from local project metadata.' )] final class CodeOwnersCommand extends BaseCommand implements LoggerAwareCommandInterface { @@ -67,6 +66,7 @@ public function __construct( */ protected function configure(): void { + $this->setHelp('This command infers CODEOWNERS entries from composer.json metadata, falls back to a commented template, and supports drift-aware preview and overwrite flows.'); $this->addJsonOption() ->addOption( name: 'file', diff --git a/src/Console/Command/CodeStyleCommand.php b/src/Console/Command/CodeStyleCommand.php index c829ad8e5..c285c0a0f 100644 --- a/src/Console/Command/CodeStyleCommand.php +++ b/src/Console/Command/CodeStyleCommand.php @@ -38,8 +38,7 @@ */ #[AsCommand( name: 'code-style', - description: 'Checks and fixes code style issues using EasyCodingStandard and Composer Normalize.', - help: 'This command runs EasyCodingStandard and Composer Normalize to check and fix code style issues.' + description: 'Checks and fixes code style issues using EasyCodingStandard and Composer Normalize.' )] final class CodeStyleCommand extends BaseCommand implements LoggerAwareCommandInterface { @@ -83,6 +82,7 @@ public function __construct( */ protected function configure(): void { + $this->setHelp('This command runs EasyCodingStandard and Composer Normalize to check and fix code style issues.'); $this->addJsonOption() ->addOption( name: 'progress', diff --git a/src/Console/Command/CopyResourceCommand.php b/src/Console/Command/CopyResourceCommand.php index d8057d6ab..2e1734058 100644 --- a/src/Console/Command/CopyResourceCommand.php +++ b/src/Console/Command/CopyResourceCommand.php @@ -39,8 +39,7 @@ */ #[AsCommand( name: 'copy-resource', - description: 'Copies a file or directory resource into the current project.', - help: 'This command copies a configured source file or every file in a source directory into the target path.' + description: 'Copies a file or directory resource into the current project.' )] final class CopyResourceCommand extends BaseCommand implements LoggerAwareCommandInterface { @@ -71,6 +70,7 @@ public function __construct( */ protected function configure(): void { + $this->setHelp('This command copies a configured source file or every file in a source directory into the target path.'); $this->addJsonOption() ->addOption( name: 'source', diff --git a/src/Console/Command/DependenciesCommand.php b/src/Console/Command/DependenciesCommand.php index 3d287bdae..e58769940 100644 --- a/src/Console/Command/DependenciesCommand.php +++ b/src/Console/Command/DependenciesCommand.php @@ -44,8 +44,7 @@ #[AsCommand( name: 'dependencies', description: 'Analyzes missing, unused, misplaced, and outdated Composer dependencies.', - aliases: ['deps'], - help: 'This command runs composer-dependency-analyser and Jack to report missing, unused, misplaced, and outdated Composer dependencies.' + aliases: ['deps'] )] final class DependenciesCommand extends BaseCommand implements LoggerAwareCommandInterface { @@ -76,6 +75,7 @@ public function __construct( */ protected function configure(): void { + $this->setHelp('This command runs composer-dependency-analyser and Jack to report missing, unused, misplaced, and outdated Composer dependencies.'); $this->addJsonOption() ->addOption( name: 'max-outdated', diff --git a/src/Console/Command/DocsCommand.php b/src/Console/Command/DocsCommand.php index dc90675da..257c90058 100644 --- a/src/Console/Command/DocsCommand.php +++ b/src/Console/Command/DocsCommand.php @@ -48,8 +48,7 @@ */ #[AsCommand( name: 'docs', - description: 'Generates API documentation.', - help: 'This command generates API documentation using phpDocumentor.', + description: 'Generates API documentation.' )] final class DocsCommand extends BaseCommand implements LoggerAwareCommandInterface { @@ -83,6 +82,7 @@ public function __construct( */ protected function configure(): void { + $this->setHelp('This command generates API documentation using phpDocumentor.'); $this ->addJsonOption() ->addCacheOption('Whether to enable phpDocumentor caching.') diff --git a/src/Console/Command/FundingCommand.php b/src/Console/Command/FundingCommand.php index d7309ff26..39675dbd0 100644 --- a/src/Console/Command/FundingCommand.php +++ b/src/Console/Command/FundingCommand.php @@ -41,8 +41,7 @@ */ #[AsCommand( name: 'funding', - description: 'Synchronizes funding metadata between composer.json and .github/FUNDING.yml.', - help: 'This command merges supported funding entries across composer.json and .github/FUNDING.yml while preserving unsupported providers.' + description: 'Synchronizes funding metadata between composer.json and .github/FUNDING.yml.' )] final class FundingCommand extends BaseCommand implements LoggerAwareCommandInterface { @@ -79,6 +78,7 @@ public function __construct( */ protected function configure(): void { + $this->setHelp('This command merges supported funding entries across composer.json and .github/FUNDING.yml while preserving unsupported providers.'); $this->addJsonOption() ->addOption( name: 'composer-file', diff --git a/src/Console/Command/GitAttributesCommand.php b/src/Console/Command/GitAttributesCommand.php index 08c1cf230..91aee9aef 100644 --- a/src/Console/Command/GitAttributesCommand.php +++ b/src/Console/Command/GitAttributesCommand.php @@ -47,9 +47,7 @@ */ #[AsCommand( name: 'gitattributes', - description: 'Manages .gitattributes export-ignore rules for leaner package archives.', - help: 'This command adds export-ignore entries for repository-only files and directories to keep them out of Composer package archives. ' - . 'Only paths that exist in the repository are added, existing custom rules are preserved, and "extra.gitattributes.keep-in-export" paths stay in exported archives.' + description: 'Manages .gitattributes export-ignore rules for leaner package archives.' )] final class GitAttributesCommand extends BaseCommand implements LoggerAwareCommandInterface { @@ -98,6 +96,11 @@ public function __construct( */ protected function configure(): void { + $this->setHelp( + 'This command adds export-ignore entries for repository-only files and directories to keep them out of Composer package archives. ' + . 'Only paths that exist in the repository are added, existing custom rules are preserved, and ' + . '"extra.gitattributes.keep-in-export" paths stay in exported archives.' + ); $this->addJsonOption() ->addOption( name: 'dry-run', diff --git a/src/Console/Command/GitHooksCommand.php b/src/Console/Command/GitHooksCommand.php index cdd8ade55..74dc77443 100644 --- a/src/Console/Command/GitHooksCommand.php +++ b/src/Console/Command/GitHooksCommand.php @@ -39,8 +39,7 @@ */ #[AsCommand( name: 'git-hooks', - description: 'Installs Fast Forward Git hooks.', - help: 'This command copies packaged Git hooks into the current repository.' + description: 'Installs Fast Forward Git hooks.' )] final class GitHooksCommand extends BaseCommand implements LoggerAwareCommandInterface { @@ -71,6 +70,7 @@ public function __construct( */ protected function configure(): void { + $this->setHelp('This command copies packaged Git hooks into the current repository.'); $this->addJsonOption() ->addOption( name: 'source', diff --git a/src/Console/Command/GitIgnoreCommand.php b/src/Console/Command/GitIgnoreCommand.php index 68a136740..202745fed 100644 --- a/src/Console/Command/GitIgnoreCommand.php +++ b/src/Console/Command/GitIgnoreCommand.php @@ -46,8 +46,7 @@ */ #[AsCommand( name: 'gitignore', - description: 'Merges and synchronizes .gitignore files.', - help: "This command merges the canonical .gitignore from dev-tools with the project's existing .gitignore." + description: 'Merges and synchronizes .gitignore files.' )] final class GitIgnoreCommand extends BaseCommand implements LoggerAwareCommandInterface { @@ -88,6 +87,7 @@ 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->addJsonOption() ->addOption( name: 'source', diff --git a/src/Console/Command/LicenseCommand.php b/src/Console/Command/LicenseCommand.php index 316df0186..7206dcc2f 100644 --- a/src/Console/Command/LicenseCommand.php +++ b/src/Console/Command/LicenseCommand.php @@ -39,8 +39,7 @@ */ #[AsCommand( name: 'license', - description: 'Generates a LICENSE file from composer.json license information.', - help: 'This command generates a LICENSE file if one does not exist and a supported license is declared in composer.json.' + description: 'Generates a LICENSE file from composer.json license information.' )] final class LicenseCommand extends BaseCommand implements LoggerAwareCommandInterface { @@ -69,6 +68,7 @@ public function __construct( */ protected function configure(): void { + $this->setHelp('This command generates a LICENSE file if one does not exist and a supported license is declared in composer.json.'); $this->addJsonOption() ->addOption( name: 'target', diff --git a/src/Console/Command/MetricsCommand.php b/src/Console/Command/MetricsCommand.php index 4cdcd9cd7..7aaadeaee 100644 --- a/src/Console/Command/MetricsCommand.php +++ b/src/Console/Command/MetricsCommand.php @@ -36,8 +36,7 @@ #[AsCommand( name: 'metrics', - description: 'Analyzes code metrics with PhpMetrics.', - help: 'This command runs PhpMetrics to analyze the current working directory.', + description: 'Analyzes code metrics with PhpMetrics.' )] final class MetricsCommand extends BaseCommand implements LoggerAwareCommandInterface { @@ -72,6 +71,7 @@ public function __construct( */ protected function configure(): void { + $this->setHelp('This command runs PhpMetrics to analyze the current working directory.'); $this->addJsonOption() ->addOption( name: 'progress', diff --git a/src/Console/Command/PhpDocCommand.php b/src/Console/Command/PhpDocCommand.php index 37fe2d6e6..9dda01c51 100644 --- a/src/Console/Command/PhpDocCommand.php +++ b/src/Console/Command/PhpDocCommand.php @@ -46,8 +46,7 @@ */ #[AsCommand( name: 'phpdoc', - description: 'Checks and fixes PHPDocs.', - help: 'This command checks and fixes PHPDocs in your PHP files.', + description: 'Checks and fixes PHPDocs.' )] final class PhpDocCommand extends BaseCommand implements LoggerAwareCommandInterface { @@ -105,6 +104,7 @@ public function __construct( */ protected function configure(): void { + $this->setHelp('This command checks and fixes PHPDocs in your PHP files.'); $this ->addJsonOption() ->addCacheOption('Whether to enable PHP-CS-Fixer caching.') diff --git a/src/Console/Command/RefactorCommand.php b/src/Console/Command/RefactorCommand.php index f3e473174..468d5c697 100644 --- a/src/Console/Command/RefactorCommand.php +++ b/src/Console/Command/RefactorCommand.php @@ -39,8 +39,7 @@ #[AsCommand( name: 'refactor', description: 'Runs Rector for code refactoring.', - aliases: ['rector'], - help: 'This command runs Rector to refactor your code.' + aliases: ['rector'] )] final class RefactorCommand extends BaseCommand implements LoggerAwareCommandInterface { @@ -79,6 +78,7 @@ public function __construct( */ protected function configure(): void { + $this->setHelp('This command runs Rector to refactor your code.'); $this->addJsonOption() ->addOption( name: 'progress', diff --git a/src/Console/Command/ReportsCommand.php b/src/Console/Command/ReportsCommand.php index 4585a4758..4208c8957 100644 --- a/src/Console/Command/ReportsCommand.php +++ b/src/Console/Command/ReportsCommand.php @@ -39,8 +39,7 @@ */ #[AsCommand( name: 'reports', - description: 'Generates the frontpage for Fast Forward documentation.', - help: 'This command generates the frontpage for Fast Forward documentation, including links to API documentation and test reports.' + description: 'Generates the frontpage for Fast Forward documentation.' )] final class ReportsCommand extends BaseCommand implements LoggerAwareCommandInterface { @@ -68,6 +67,7 @@ public function __construct( */ protected function configure(): void { + $this->setHelp('This command generates the frontpage for Fast Forward documentation, including links to API documentation and test reports.'); $this ->addJsonOption() ->addCacheOption('Whether to enable cache writes in nested docs and tests commands.') diff --git a/src/Console/Command/SkillsCommand.php b/src/Console/Command/SkillsCommand.php index ece030c5b..7160af336 100644 --- a/src/Console/Command/SkillsCommand.php +++ b/src/Console/Command/SkillsCommand.php @@ -46,8 +46,7 @@ */ #[AsCommand( name: 'skills', - description: 'Synchronizes Fast Forward skills into .agents/skills directory.', - help: 'This command ensures the consumer repository contains linked Fast Forward skills by creating symlinks to the packaged skills and removing broken links.' + description: 'Synchronizes Fast Forward skills into .agents/skills directory.' )] final class SkillsCommand extends BaseCommand implements LoggerAwareCommandInterface { @@ -80,6 +79,7 @@ public function __construct( */ protected function configure(): void { + $this->setHelp('This command ensures the consumer repository contains linked Fast Forward skills by creating symlinks to the packaged skills and removing broken links.'); $this->addJsonOption(); } diff --git a/src/Console/Command/StandardsCommand.php b/src/Console/Command/StandardsCommand.php index d4cab59d4..d12ec2759 100644 --- a/src/Console/Command/StandardsCommand.php +++ b/src/Console/Command/StandardsCommand.php @@ -39,8 +39,7 @@ */ #[AsCommand( name: 'standards', - description: 'Runs Fast Forward code standards checks.', - help: 'This command runs all Fast Forward code standards checks, including code refactoring, PHPDoc validation, code style checks, documentation generation, and tests execution.' + description: 'Runs Fast Forward code standards checks.' )] final class StandardsCommand extends BaseCommand implements LoggerAwareCommandInterface { @@ -68,6 +67,7 @@ public function __construct( */ protected function configure(): void { + $this->setHelp('This command runs all Fast Forward code standards checks, including code refactoring, PHPDoc validation, code style checks, documentation generation, and tests execution.'); $this ->addJsonOption() ->addCacheOption( diff --git a/src/Console/Command/SyncCommand.php b/src/Console/Command/SyncCommand.php index acb0c6002..d1e08df3d 100644 --- a/src/Console/Command/SyncCommand.php +++ b/src/Console/Command/SyncCommand.php @@ -37,8 +37,7 @@ */ #[AsCommand( name: 'dev-tools:sync', - description: 'Installs and synchronizes dev-tools scripts, GitHub Actions workflows, CODEOWNERS, .editorconfig, and .gitattributes in the root project.', - help: 'This command runs the dedicated synchronization commands for composer.json, resources, CODEOWNERS, funding metadata, wiki, git metadata, packaged skills, packaged agents, license, and Git hooks.' + description: 'Installs and synchronizes dev-tools scripts, GitHub Actions workflows, CODEOWNERS, .editorconfig, and .gitattributes in the root project.' )] final class SyncCommand extends BaseCommand implements LoggerAwareCommandInterface { @@ -63,6 +62,7 @@ public function __construct( */ protected function configure(): void { + $this->setHelp('This command runs the dedicated synchronization commands for composer.json, resources, CODEOWNERS, funding metadata, wiki, git metadata, packaged skills, packaged agents, license, and Git hooks.'); $this->addJsonOption() ->addOption( name: 'overwrite', diff --git a/src/Console/Command/TestsCommand.php b/src/Console/Command/TestsCommand.php index 5d4d41a17..f584ddf8b 100644 --- a/src/Console/Command/TestsCommand.php +++ b/src/Console/Command/TestsCommand.php @@ -48,8 +48,7 @@ */ #[AsCommand( name: 'tests', - description: 'Runs PHPUnit tests.', - help: 'This command runs PHPUnit to execute your tests.' + description: 'Runs PHPUnit tests.' )] final class TestsCommand extends BaseCommand implements LoggerAwareCommandInterface { @@ -93,6 +92,7 @@ public function __construct( */ protected function configure(): void { + $this->setHelp('This command runs PHPUnit to execute your tests.'); $this ->addJsonOption() ->addCacheOption('Whether to enable PHPUnit result caching.') diff --git a/src/Console/Command/UpdateComposerJsonCommand.php b/src/Console/Command/UpdateComposerJsonCommand.php index 4cad43eda..68b92350a 100644 --- a/src/Console/Command/UpdateComposerJsonCommand.php +++ b/src/Console/Command/UpdateComposerJsonCommand.php @@ -44,8 +44,7 @@ */ #[AsCommand( name: 'update-composer-json', - description: 'Updates composer.json with Fast Forward dev-tools scripts and metadata.', - help: 'This command adds or updates composer.json scripts and GrumPHP extra configuration required by dev-tools.' + description: 'Updates composer.json with Fast Forward dev-tools scripts and metadata.' )] final class UpdateComposerJsonCommand extends BaseCommand implements LoggerAwareCommandInterface { @@ -76,6 +75,7 @@ public function __construct( */ protected function configure(): void { + $this->setHelp('This command adds or updates composer.json scripts and GrumPHP extra configuration required by dev-tools.'); $this->addJsonOption() ->addOption( name: 'file', diff --git a/src/Console/Command/WikiCommand.php b/src/Console/Command/WikiCommand.php index 11dd73295..80e408464 100644 --- a/src/Console/Command/WikiCommand.php +++ b/src/Console/Command/WikiCommand.php @@ -45,9 +45,7 @@ */ #[AsCommand( name: 'wiki', - description: 'Generates API documentation in Markdown format.', - help: 'This command generates API documentation in Markdown format using phpDocumentor. ' - . 'It accepts an optional `--target` option to specify the output directory and `--init` to initialize the wiki submodule.' + description: 'Generates API documentation in Markdown format.' )] final class WikiCommand extends BaseCommand implements LoggerAwareCommandInterface { @@ -86,6 +84,7 @@ public function __construct( */ protected function configure(): void { + $this->setHelp('This command generates API documentation in Markdown format using phpDocumentor. '); $this ->addJsonOption() ->addCacheOption('Whether to enable phpDocumentor caching.') diff --git a/src/Filesystem/Filesystem.php b/src/Filesystem/Filesystem.php index abbc7a4d6..6a2157d35 100644 --- a/src/Filesystem/Filesystem.php +++ b/src/Filesystem/Filesystem.php @@ -19,7 +19,6 @@ namespace FastForward\DevTools\Filesystem; -use Override; use Symfony\Component\Filesystem\Filesystem as SymfonyFilesystem; use Symfony\Component\Filesystem\Path; @@ -32,8 +31,12 @@ * converting provided paths to absolute representations when a base path is supplied or * dynamically inferred from the generic working directory. */ -final class Filesystem extends SymfonyFilesystem implements FilesystemInterface +final class Filesystem implements FilesystemInterface { + public function __construct( + private readonly SymfonyFilesystem $filesystem = new SymfonyFilesystem(), + ) {} + /** * Checks whether a file or directory exists. * @@ -42,10 +45,9 @@ final class Filesystem extends SymfonyFilesystem implements FilesystemInterface * * @return bool true if the path exists, false otherwise */ - #[Override] public function exists(string|iterable $files, ?string $basePath = null): bool { - return parent::exists($this->getAbsolutePath($files, $basePath)); + return $this->filesystem->exists($this->getAbsolutePath($files, $basePath)); } /** @@ -56,10 +58,9 @@ public function exists(string|iterable $files, ?string $basePath = null): bool * * @return string the content of the file */ - #[Override] public function readFile(string $filename, ?string $path = null): string { - return parent::readFile($this->getAbsolutePath($filename, $path)); + return $this->filesystem->readFile($this->getAbsolutePath($filename, $path)); } /** @@ -69,10 +70,9 @@ public function readFile(string $filename, ?string $path = null): string * @param mixed $content the content to write * @param string|null $path the optional base path to resolve the filename against */ - #[Override] public function dumpFile(string $filename, mixed $content, ?string $path = null): void { - parent::dumpFile($this->getAbsolutePath($filename, $path), $content); + $this->filesystem->dumpFile($this->getAbsolutePath($filename, $path), $content); } /** @@ -82,10 +82,9 @@ public function dumpFile(string $filename, mixed $content, ?string $path = null) * @param string $targetFile the target file path to create * @param bool $overwriteNewerFiles whether newer target files MAY be overwritten */ - #[Override] public function copy(string $originFile, string $targetFile, bool $overwriteNewerFiles = false): void { - parent::copy($this->getAbsolutePath($originFile), $this->getAbsolutePath($targetFile), $overwriteNewerFiles); + $this->filesystem->copy($this->getAbsolutePath($originFile), $this->getAbsolutePath($targetFile), $overwriteNewerFiles); } /** @@ -96,10 +95,9 @@ public function copy(string $originFile, string $targetFile, bool $overwriteNewe * @param int $umask the umask to apply * @param bool $recursive whether permissions SHOULD be applied recursively */ - #[Override] public function chmod(string|iterable $files, int $mode, int $umask = 0o000, bool $recursive = false): void { - parent::chmod($this->getAbsolutePath($files), $mode, $umask, $recursive); + $this->filesystem->chmod($this->getAbsolutePath($files), $mode, $umask, $recursive); } /** @@ -107,10 +105,9 @@ public function chmod(string|iterable $files, int $mode, int $umask = 0o000, boo * * @param iterable|string $files the file(s), link(s), or directory(ies) to remove */ - #[Override] public function remove(string|iterable $files): void { - parent::remove($this->getAbsolutePath($files)); + $this->filesystem->remove($this->getAbsolutePath($files)); } /** @@ -120,10 +117,9 @@ public function remove(string|iterable $files): void * @param string $targetDir the link path to create * @param bool $copyOnWindows whether directories SHOULD be copied on Windows instead of linked */ - #[Override] public function symlink(string $originDir, string $targetDir, bool $copyOnWindows = false): void { - parent::symlink($this->getAbsolutePath($originDir), $this->getAbsolutePath($targetDir), $copyOnWindows); + $this->filesystem->symlink($this->getAbsolutePath($originDir), $this->getAbsolutePath($targetDir), $copyOnWindows); } /** @@ -134,10 +130,9 @@ public function symlink(string $originDir, string $targetDir, bool $copyOnWindow * * @return string|null the link target, or null when the path is not a symbolic link */ - #[Override] public function readlink(string $path, bool $canonicalize = false): ?string { - return parent::readlink($this->getAbsolutePath($path), $canonicalize); + return $this->filesystem->readlink($this->getAbsolutePath($path), $canonicalize); } /** @@ -152,7 +147,7 @@ public function getAbsolutePath(string|iterable $files, ?string $basePath = null { $basePath ??= getcwd(); - if (! $this->isAbsolutePath($basePath)) { + if (! Path::isAbsolute($basePath)) { $basePath = Path::makeAbsolute($basePath, getcwd()); } @@ -168,12 +163,10 @@ public function getAbsolutePath(string|iterable $files, ?string $basePath = null * * @param iterable|string $dirs the directory path(s) to create * @param int $mode the permissions mode (defaults to 0777) - * @param string|null $basePath the base path for relative path resolution */ - #[Override] - public function mkdir(string|iterable $dirs, int $mode = 0o777, ?string $basePath = null): void + public function mkdir(string|iterable $dirs, int $mode = 0o777): void { - parent::mkdir($this->getAbsolutePath($dirs, $basePath), $mode); + $this->filesystem->mkdir($this->getAbsolutePath($dirs), $mode); } /** @@ -184,10 +177,9 @@ public function mkdir(string|iterable $dirs, int $mode = 0o777, ?string $basePat * * @return string the computed relative path */ - #[Override] public function makePathRelative(string $path, ?string $basePath = null): string { - return parent::makePathRelative($this->getAbsolutePath($path, $basePath), $basePath ?? getcwd()); + return $this->filesystem->makePathRelative($this->getAbsolutePath($path, $basePath), $basePath ?? getcwd()); } /** diff --git a/src/Filesystem/FilesystemInterface.php b/src/Filesystem/FilesystemInterface.php index d9af5a1bb..a58d6b83e 100644 --- a/src/Filesystem/FilesystemInterface.php +++ b/src/Filesystem/FilesystemInterface.php @@ -119,9 +119,8 @@ public function getAbsolutePath(string|iterable $files, ?string $basePath = null * * @param iterable|string $dirs the directory path(s) to create * @param int $mode the permissions mode (defaults to 0777) - * @param string|null $basePath the base path for relative path resolution */ - public function mkdir(string|iterable $dirs, int $mode = 0o777, ?string $basePath = null): void; + public function mkdir(string|iterable $dirs, int $mode = 0o777): void; /** * Computes the relative path from the base path to the target path. diff --git a/tests/Console/Command/CommandAttributeCompatibilityTest.php b/tests/Console/Command/CommandAttributeCompatibilityTest.php new file mode 100644 index 000000000..eaddaaaac --- /dev/null +++ b/tests/Console/Command/CommandAttributeCompatibilityTest.php @@ -0,0 +1,53 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/ + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward/dev-tools/issues + * @see https://php-fast-forward.github.io/dev-tools/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Tests\Console\Command; + +use PHPUnit\Framework\Attributes\CoversNothing; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; + +use function Safe\file_get_contents; + +#[CoversNothing] +final class CommandAttributeCompatibilityTest extends TestCase +{ + /** + * @return void + */ + #[Test] + public function asCommandAttributesWillNotUseTheHelpNamedParameter(): void + { + foreach (glob(__DIR__ . '/../../../src/Console/Command/*.php') as $commandFile) { + self::assertIsString($commandFile); + + $content = file_get_contents($commandFile); + + preg_match_all('/#\[AsCommand\((.*?)\)\]/s', $content, $matches); + + foreach ($matches[0] as $attribute) { + self::assertStringNotContainsString( + 'help:', + $attribute, + sprintf('The command attribute in %s MUST remain compatible with Composer-discovered Symfony Console versions.', basename($commandFile)), + ); + } + } + } +} diff --git a/tests/Filesystem/FilesystemTest.php b/tests/Filesystem/FilesystemTest.php index d713840c6..908417cab 100644 --- a/tests/Filesystem/FilesystemTest.php +++ b/tests/Filesystem/FilesystemTest.php @@ -163,12 +163,20 @@ public function dumpFileAndReadFileWillWorkWithRelativePaths(): void #[Test] public function mkdirWillCreateDirectoryWithRelativePath(): void { - $dirName = 'nested/dir'; + $currentWorkingDirectory = getcwd(); - $this->filesystem->mkdir($dirName, 0o777, $this->tempDir); + chdir($this->tempDir); - self::assertTrue($this->filesystem->exists($dirName, $this->tempDir)); - self::assertDirectoryExists($this->tempDir . '/' . $dirName); + try { + $dirName = 'nested/dir'; + + $this->filesystem->mkdir($dirName); + + self::assertTrue($this->filesystem->exists($dirName, $this->tempDir)); + self::assertDirectoryExists($this->tempDir . '/' . $dirName); + } finally { + chdir($currentWorkingDirectory); + } } /** diff --git a/tests/Fixtures/composer-plugin-consumer/.gitignore b/tests/Fixtures/composer-plugin-consumer/.gitignore new file mode 100644 index 000000000..356acef1e --- /dev/null +++ b/tests/Fixtures/composer-plugin-consumer/.gitignore @@ -0,0 +1,13 @@ +.dev-tools/ +.idea/ +.vscode/ +/.agents/ +/.github/ +/vendor/ +backup/ +tmp/ +vendor/ +*.cache +.DS_Store +/composer.lock +composer.lock diff --git a/tests/Fixtures/composer-plugin-consumer/composer.json b/tests/Fixtures/composer-plugin-consumer/composer.json new file mode 100644 index 000000000..eea9164d2 --- /dev/null +++ b/tests/Fixtures/composer-plugin-consumer/composer.json @@ -0,0 +1,36 @@ +{ + "name": "fast-forward/dev-tools-composer-plugin-consumer-fixture", + "type": "project", + "require-dev": { + "fast-forward/dev-tools": "*@dev" + }, + "repositories": [ + { + "type": "path", + "url": "../../..", + "options": { + "symlink": true + } + } + ], + "minimum-stability": "dev", + "prefer-stable": true, + "config": { + "allow-plugins": { + "fast-forward/dev-tools": true, + "ergebnis/composer-normalize": 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" + } + } +} From 15e6f106bc4907d7cf9d943749dd674cf1e72539 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Thu, 23 Apr 2026 23:08:46 -0300 Subject: [PATCH 2/9] [agents] Synchronize top-level packaged agent files (#185) --- src/Sync/PackagedDirectorySynchronizer.php | 7 +-- .../PackagedDirectorySynchronizerTest.php | 63 ++++++++++++++----- 2 files changed, 51 insertions(+), 19 deletions(-) diff --git a/src/Sync/PackagedDirectorySynchronizer.php b/src/Sync/PackagedDirectorySynchronizer.php index 5e63726cd..1ac27e1c9 100644 --- a/src/Sync/PackagedDirectorySynchronizer.php +++ b/src/Sync/PackagedDirectorySynchronizer.php @@ -78,14 +78,13 @@ public function synchronize(string $targetDir, string $packagePath, string $dire $finder = $this->finderFactory ->create() - ->directories() ->in($packagePath) ->depth('== 0'); - foreach ($finder as $packagedDirectory) { - $entryName = $packagedDirectory->getFilename(); + foreach ($finder as $packagedEntry) { + $entryName = $packagedEntry->getFilename(); $targetLink = Path::makeAbsolute($entryName, $targetDir); - $sourcePath = $packagedDirectory->getRealPath(); + $sourcePath = $packagedEntry->getRealPath(); $this->processLink($entryName, $targetLink, $sourcePath, $result); } diff --git a/tests/Sync/PackagedDirectorySynchronizerTest.php b/tests/Sync/PackagedDirectorySynchronizerTest.php index bcc548a72..adcaa5741 100644 --- a/tests/Sync/PackagedDirectorySynchronizerTest.php +++ b/tests/Sync/PackagedDirectorySynchronizerTest.php @@ -123,7 +123,7 @@ public function synchronizeWithMissingTargetDirWillCreateItAndCreateLinks(): voi $entryPath = '/package/.agents/agents/issue-editor'; $relativeEntryPath = '../../../package/.agents/agents/issue-editor'; - $this->mockFinder($this->createDirectory('issue-editor', $entryPath)); + $this->mockFinder($this->createEntry('issue-editor', $entryPath)); $this->filesystem->exists('/package/.agents/agents') ->willReturn(true); @@ -161,7 +161,7 @@ public function synchronizeWillPreserveExistingValidSymlink(): void $entryPath = '/package/.agents/agents/issue-editor'; $targetLink = '/consumer/.agents/agents/issue-editor'; - $this->mockFinder($this->createDirectory('issue-editor', $entryPath)); + $this->mockFinder($this->createEntry('issue-editor', $entryPath)); $this->filesystem->exists('/package/.agents/agents') ->willReturn(true); @@ -197,7 +197,7 @@ public function synchronizeWillRepairBrokenSymlink(): void $brokenPath = '/obsolete/.agents/agents/issue-editor'; $relativeEntryPath = '../../../package/.agents/agents/issue-editor'; - $this->mockFinder($this->createDirectory('issue-editor', $entryPath)); + $this->mockFinder($this->createEntry('issue-editor', $entryPath)); $this->filesystem->exists('/package/.agents/agents') ->willReturn(true); @@ -243,7 +243,7 @@ public function synchronizeWillPreserveExistingNonSymlinkDirectory(): void $entryPath = '/package/.agents/agents/issue-editor'; $targetLink = '/consumer/.agents/agents/issue-editor'; - $this->mockFinder($this->createDirectory('issue-editor', $entryPath)); + $this->mockFinder($this->createEntry('issue-editor', $entryPath)); $this->filesystem->exists('/package/.agents/agents') ->willReturn(true); @@ -266,20 +266,53 @@ public function synchronizeWillPreserveExistingNonSymlinkDirectory(): void } /** - * @param SplFileInfo $directories + * @return void + */ + #[Test] + public function synchronizeWillCreateLinksForTopLevelFiles(): void + { + $entryPath = '/package/.agents/agents/issue-editor.md'; + $targetLink = '/consumer/.agents/agents/issue-editor.md'; + $relativeEntryPath = '../../../package/.agents/agents/issue-editor.md'; + + $this->mockFinder($this->createEntry('issue-editor.md', $entryPath)); + + $this->filesystem->exists('/package/.agents/agents') + ->willReturn(true); + $this->filesystem->exists('/consumer/.agents/agents') + ->willReturn(true); + $this->filesystem->exists($targetLink) + ->willReturn(false); + $this->filesystem->dirname($targetLink) + ->willReturn('/consumer/.agents/agents') + ->shouldBeCalledOnce(); + $this->filesystem->makePathRelative($entryPath, '/consumer/.agents/agents') + ->willReturn($relativeEntryPath) + ->shouldBeCalledOnce(); + $this->filesystem->symlink($relativeEntryPath, $targetLink) + ->shouldBeCalledOnce(); + $this->logger->info('Created link: issue-editor.md -> ' . $relativeEntryPath) + ->shouldBeCalledOnce(); + + $result = $this->createSynchronizer() + ->synchronize('/consumer/.agents/agents', '/package/.agents/agents', '.agents/agents'); + + self::assertFalse($result->failed()); + self::assertSame(['issue-editor.md'], $result->getCreatedLinks()); + } + + /** + * @param SplFileInfo $entries * * @return void */ - private function mockFinder(SplFileInfo ...$directories): void + private function mockFinder(SplFileInfo ...$entries): void { $finder = $this->finder->reveal(); $this->finderFactory->create() ->willReturn($finder) ->shouldBeCalledOnce(); - $this->finder->directories() - ->willReturn($finder) - ->shouldBeCalledOnce(); $this->finder->in('/package/.agents/agents') ->willReturn($finder) ->shouldBeCalledOnce(); @@ -287,7 +320,7 @@ private function mockFinder(SplFileInfo ...$directories): void ->willReturn($finder) ->shouldBeCalledOnce(); $this->finder->getIterator() - ->willReturn(new ArrayIterator($directories)); + ->willReturn(new ArrayIterator($entries)); } /** @@ -296,15 +329,15 @@ private function mockFinder(SplFileInfo ...$directories): void * * @return SplFileInfo */ - private function createDirectory(string $entryName, string $sourcePath): SplFileInfo + private function createEntry(string $entryName, string $sourcePath): SplFileInfo { - $directory = $this->prophesize(SplFileInfo::class); - $directory->getFilename() + $entry = $this->prophesize(SplFileInfo::class); + $entry->getFilename() ->willReturn($entryName); - $directory->getRealPath() + $entry->getRealPath() ->willReturn($sourcePath); - return $directory->reveal(); + return $entry->reveal(); } /** From a8e91de2a1c6827e662bb2f635b8dbf6a6063ed3 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 24 Apr 2026 02:10:22 +0000 Subject: [PATCH 3/9] Update wiki submodule pointer for PR #198 --- .github/wiki | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/wiki b/.github/wiki index 546706f28..caa2be040 160000 --- a/.github/wiki +++ b/.github/wiki @@ -1 +1 @@ -Subproject commit 546706f28694f18150a1aec0622da9be7f4ee710 +Subproject commit caa2be04015083a8008d2d0029ebaf0d523324f7 From 462235f933e0d4325bff12cc4bae73cd8279ecdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Thu, 23 Apr 2026 23:10:59 -0300 Subject: [PATCH 4/9] [tests] Narrow composer plugin fixture to tracked inputs (#185) --- .../Fixtures/composer-plugin-consumer/.gitignore | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/tests/Fixtures/composer-plugin-consumer/.gitignore b/tests/Fixtures/composer-plugin-consumer/.gitignore index 356acef1e..ed3675727 100644 --- a/tests/Fixtures/composer-plugin-consumer/.gitignore +++ b/tests/Fixtures/composer-plugin-consumer/.gitignore @@ -1,13 +1,3 @@ -.dev-tools/ -.idea/ -.vscode/ -/.agents/ -/.github/ -/vendor/ -backup/ -tmp/ -vendor/ -*.cache -.DS_Store -/composer.lock -composer.lock +* +!.gitignore +!composer.json From 20d42ec10e936558bd1c55c59f2bc765445d1b26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Thu, 23 Apr 2026 23:14:52 -0300 Subject: [PATCH 5/9] [sync] Preserve relative packaged symlink targets (#185) --- src/Filesystem/Filesystem.php | 6 ++- src/Sync/PackagedDirectorySynchronizer.php | 40 ++++++++++++++++--- tests/Filesystem/FilesystemTest.php | 24 +++++++++++ .../PackagedDirectorySynchronizerTest.php | 8 ++-- 4 files changed, 68 insertions(+), 10 deletions(-) diff --git a/src/Filesystem/Filesystem.php b/src/Filesystem/Filesystem.php index 6a2157d35..226edd388 100644 --- a/src/Filesystem/Filesystem.php +++ b/src/Filesystem/Filesystem.php @@ -119,7 +119,11 @@ public function remove(string|iterable $files): void */ public function symlink(string $originDir, string $targetDir, bool $copyOnWindows = false): void { - $this->filesystem->symlink($this->getAbsolutePath($originDir), $this->getAbsolutePath($targetDir), $copyOnWindows); + $origin = Path::isAbsolute($originDir) + ? $this->getAbsolutePath($originDir) + : $originDir; + + $this->filesystem->symlink($origin, $this->getAbsolutePath($targetDir), $copyOnWindows); } /** diff --git a/src/Sync/PackagedDirectorySynchronizer.php b/src/Sync/PackagedDirectorySynchronizer.php index 1ac27e1c9..ec4cfea97 100644 --- a/src/Sync/PackagedDirectorySynchronizer.php +++ b/src/Sync/PackagedDirectorySynchronizer.php @@ -85,8 +85,9 @@ public function synchronize(string $targetDir, string $packagePath, string $dire $entryName = $packagedEntry->getFilename(); $targetLink = Path::makeAbsolute($entryName, $targetDir); $sourcePath = $packagedEntry->getRealPath(); + $isDirectory = $packagedEntry->isDir(); - $this->processLink($entryName, $targetLink, $sourcePath, $result); + $this->processLink($entryName, $targetLink, $sourcePath, $isDirectory, $result); } return $result; @@ -104,10 +105,11 @@ private function processLink( string $entryName, string $targetLink, string $sourcePath, + bool $isDirectory, SynchronizeResult $result, ): void { if (! $this->filesystem->exists($targetLink)) { - $this->createNewLink($entryName, $targetLink, $sourcePath, $result); + $this->createNewLink($entryName, $targetLink, $sourcePath, $isDirectory, $result); return; } @@ -118,7 +120,7 @@ private function processLink( return; } - $this->processExistingSymlink($entryName, $targetLink, $sourcePath, $result); + $this->processExistingSymlink($entryName, $targetLink, $sourcePath, $isDirectory, $result); } /** @@ -133,9 +135,13 @@ private function createNewLink( string $entryName, string $targetLink, string $sourcePath, + bool $isDirectory, SynchronizeResult $result, ): void { - $relativeSourcePath = $this->filesystem->makePathRelative($sourcePath, $this->filesystem->dirname($targetLink)); + $relativeSourcePath = $this->normalizeRelativeSourcePath( + $this->filesystem->makePathRelative($sourcePath, $this->filesystem->dirname($targetLink)), + $isDirectory, + ); $this->filesystem->symlink($relativeSourcePath, $targetLink); $this->logger->info('Created link: ' . $entryName . ' -> ' . $relativeSourcePath); @@ -168,12 +174,13 @@ private function processExistingSymlink( string $entryName, string $targetLink, string $sourcePath, + bool $isDirectory, SynchronizeResult $result, ): void { $linkPath = $this->filesystem->readlink($targetLink, true); if (! $linkPath || ! $this->filesystem->exists($linkPath)) { - $this->repairBrokenLink($entryName, $targetLink, $sourcePath, $result); + $this->repairBrokenLink($entryName, $targetLink, $sourcePath, $isDirectory, $result); return; } @@ -194,13 +201,34 @@ private function repairBrokenLink( string $entryName, string $targetLink, string $sourcePath, + bool $isDirectory, SynchronizeResult $result, ): void { $this->filesystem->remove($targetLink); $this->logger->notice('Existing link is broken: ' . $entryName . ' (removing and recreating)'); $result->addRemovedBrokenLink($entryName); - $this->createNewLink($entryName, $targetLink, $sourcePath, $result); + $this->createNewLink($entryName, $targetLink, $sourcePath, $isDirectory, $result); + } + + /** + * Normalizes a relative symlink target emitted by Symfony path helpers. + * + * Files MUST NOT keep the trailing slash that directory-oriented path helpers + * may append, otherwise link creation treats them as non-existent directories. + * + * @param string $relativeSourcePath Relative path from the consumer target directory to the packaged source + * @param bool $isDirectory Whether the packaged source is a directory + * + * @return string Normalized relative symlink target + */ + private function normalizeRelativeSourcePath(string $relativeSourcePath, bool $isDirectory): string + { + if ($isDirectory) { + return $relativeSourcePath; + } + + return rtrim($relativeSourcePath, '/'); } /** diff --git a/tests/Filesystem/FilesystemTest.php b/tests/Filesystem/FilesystemTest.php index 908417cab..31c822f85 100644 --- a/tests/Filesystem/FilesystemTest.php +++ b/tests/Filesystem/FilesystemTest.php @@ -208,6 +208,30 @@ public function symlinkAndReadlinkWillUseAbsolutePaths(): void self::assertSame(realpath($origin), $this->filesystem->readlink($target, true)); } + /** + * @return void + */ + #[Test] + public function symlinkWillPreserveRelativeOrigins(): void + { + $currentWorkingDirectory = getcwd(); + $origin = $this->tempDir . '/origin'; + $target = $this->tempDir . '/target'; + $relativeOrigin = 'origin'; + + $this->filesystem->mkdir($origin); + chdir($this->tempDir); + + try { + $this->filesystem->symlink($relativeOrigin, $target); + } finally { + chdir($currentWorkingDirectory); + } + + self::assertSame($relativeOrigin, $this->filesystem->readlink($target)); + self::assertSame(realpath($origin), $this->filesystem->readlink($target, true)); + } + /** * @return void */ diff --git a/tests/Sync/PackagedDirectorySynchronizerTest.php b/tests/Sync/PackagedDirectorySynchronizerTest.php index adcaa5741..e02d881db 100644 --- a/tests/Sync/PackagedDirectorySynchronizerTest.php +++ b/tests/Sync/PackagedDirectorySynchronizerTest.php @@ -275,7 +275,7 @@ public function synchronizeWillCreateLinksForTopLevelFiles(): void $targetLink = '/consumer/.agents/agents/issue-editor.md'; $relativeEntryPath = '../../../package/.agents/agents/issue-editor.md'; - $this->mockFinder($this->createEntry('issue-editor.md', $entryPath)); + $this->mockFinder($this->createEntry('issue-editor.md', $entryPath, false)); $this->filesystem->exists('/package/.agents/agents') ->willReturn(true); @@ -287,7 +287,7 @@ public function synchronizeWillCreateLinksForTopLevelFiles(): void ->willReturn('/consumer/.agents/agents') ->shouldBeCalledOnce(); $this->filesystem->makePathRelative($entryPath, '/consumer/.agents/agents') - ->willReturn($relativeEntryPath) + ->willReturn($relativeEntryPath . '/') ->shouldBeCalledOnce(); $this->filesystem->symlink($relativeEntryPath, $targetLink) ->shouldBeCalledOnce(); @@ -329,13 +329,15 @@ private function mockFinder(SplFileInfo ...$entries): void * * @return SplFileInfo */ - private function createEntry(string $entryName, string $sourcePath): SplFileInfo + private function createEntry(string $entryName, string $sourcePath, bool $isDirectory = true): SplFileInfo { $entry = $this->prophesize(SplFileInfo::class); $entry->getFilename() ->willReturn($entryName); $entry->getRealPath() ->willReturn($sourcePath); + $entry->isDir() + ->willReturn($isDirectory); return $entry->reveal(); } From 7ca18de6222351f3f1b12ce96d785cc7ea61e99d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Thu, 23 Apr 2026 23:16:22 -0300 Subject: [PATCH 6/9] [style] Separate command help from option chains (#185) --- src/Console/Command/AgentsCommand.php | 1 + src/Console/Command/ChangelogCheckCommand.php | 1 + src/Console/Command/ChangelogEntryCommand.php | 1 + src/Console/Command/ChangelogNextVersionCommand.php | 1 + src/Console/Command/ChangelogPromoteCommand.php | 1 + src/Console/Command/ChangelogShowCommand.php | 1 + src/Console/Command/CodeOwnersCommand.php | 1 + src/Console/Command/CodeStyleCommand.php | 1 + src/Console/Command/CopyResourceCommand.php | 1 + src/Console/Command/DependenciesCommand.php | 1 + src/Console/Command/FundingCommand.php | 1 + src/Console/Command/GitAttributesCommand.php | 1 + src/Console/Command/GitHooksCommand.php | 1 + src/Console/Command/GitIgnoreCommand.php | 1 + src/Console/Command/LicenseCommand.php | 1 + src/Console/Command/MetricsCommand.php | 1 + src/Console/Command/RefactorCommand.php | 1 + src/Console/Command/SkillsCommand.php | 1 + src/Console/Command/SyncCommand.php | 1 + src/Console/Command/UpdateComposerJsonCommand.php | 1 + 20 files changed, 20 insertions(+) diff --git a/src/Console/Command/AgentsCommand.php b/src/Console/Command/AgentsCommand.php index 4a49ca1e7..01d19fe3f 100644 --- a/src/Console/Command/AgentsCommand.php +++ b/src/Console/Command/AgentsCommand.php @@ -63,6 +63,7 @@ public function __construct( protected function configure(): void { $this->setHelp('This command ensures the consumer repository contains linked Fast Forward project agents by creating symlinks to the packaged prompts and removing broken links.'); + $this->addJsonOption(); } diff --git a/src/Console/Command/ChangelogCheckCommand.php b/src/Console/Command/ChangelogCheckCommand.php index ccdb0bc02..60e1ea8f3 100644 --- a/src/Console/Command/ChangelogCheckCommand.php +++ b/src/Console/Command/ChangelogCheckCommand.php @@ -61,6 +61,7 @@ public function __construct( protected function configure(): void { $this->setHelp('This command validates the current Unreleased section and may compare it against a base git reference to enforce pull request changelog updates.'); + $this->addJsonOption() ->addOption( name: 'against', diff --git a/src/Console/Command/ChangelogEntryCommand.php b/src/Console/Command/ChangelogEntryCommand.php index ac7d5e680..ffbec3990 100644 --- a/src/Console/Command/ChangelogEntryCommand.php +++ b/src/Console/Command/ChangelogEntryCommand.php @@ -64,6 +64,7 @@ public function __construct( protected function configure(): void { $this->setHelp('This command appends one categorized changelog entry to the selected changelog file so it can be reused by local authoring flows and skills.'); + $this->addJsonOption() ->addArgument( name: 'message', diff --git a/src/Console/Command/ChangelogNextVersionCommand.php b/src/Console/Command/ChangelogNextVersionCommand.php index 3edaff80e..5d0148af6 100644 --- a/src/Console/Command/ChangelogNextVersionCommand.php +++ b/src/Console/Command/ChangelogNextVersionCommand.php @@ -62,6 +62,7 @@ public function __construct( protected function configure(): void { $this->setHelp('This command inspects Unreleased changelog categories and prints the next semantic version inferred from the current changelog state.'); + $this->addJsonOption() ->addOption( name: 'file', diff --git a/src/Console/Command/ChangelogPromoteCommand.php b/src/Console/Command/ChangelogPromoteCommand.php index fc25bc19a..bd4b9c436 100644 --- a/src/Console/Command/ChangelogPromoteCommand.php +++ b/src/Console/Command/ChangelogPromoteCommand.php @@ -66,6 +66,7 @@ public function __construct( protected function configure(): void { $this->setHelp('This command moves the current Unreleased entries into a released version section, records the release date, and restores an empty Unreleased section.'); + $this->addJsonOption() ->addArgument( name: 'version', diff --git a/src/Console/Command/ChangelogShowCommand.php b/src/Console/Command/ChangelogShowCommand.php index dd9435130..69ff81b36 100644 --- a/src/Console/Command/ChangelogShowCommand.php +++ b/src/Console/Command/ChangelogShowCommand.php @@ -63,6 +63,7 @@ public function __construct( protected function configure(): void { $this->setHelp('This command renders the body of one released changelog section so it can be reused for GitHub release notes.'); + $this->addJsonOption() ->addArgument( name: 'version', diff --git a/src/Console/Command/CodeOwnersCommand.php b/src/Console/Command/CodeOwnersCommand.php index 178fff05d..c956ec760 100644 --- a/src/Console/Command/CodeOwnersCommand.php +++ b/src/Console/Command/CodeOwnersCommand.php @@ -67,6 +67,7 @@ public function __construct( protected function configure(): void { $this->setHelp('This command infers CODEOWNERS entries from composer.json metadata, falls back to a commented template, and supports drift-aware preview and overwrite flows.'); + $this->addJsonOption() ->addOption( name: 'file', diff --git a/src/Console/Command/CodeStyleCommand.php b/src/Console/Command/CodeStyleCommand.php index c285c0a0f..45a842956 100644 --- a/src/Console/Command/CodeStyleCommand.php +++ b/src/Console/Command/CodeStyleCommand.php @@ -83,6 +83,7 @@ public function __construct( protected function configure(): void { $this->setHelp('This command runs EasyCodingStandard and Composer Normalize to check and fix code style issues.'); + $this->addJsonOption() ->addOption( name: 'progress', diff --git a/src/Console/Command/CopyResourceCommand.php b/src/Console/Command/CopyResourceCommand.php index 2e1734058..50d024a85 100644 --- a/src/Console/Command/CopyResourceCommand.php +++ b/src/Console/Command/CopyResourceCommand.php @@ -71,6 +71,7 @@ public function __construct( protected function configure(): void { $this->setHelp('This command copies a configured source file or every file in a source directory into the target path.'); + $this->addJsonOption() ->addOption( name: 'source', diff --git a/src/Console/Command/DependenciesCommand.php b/src/Console/Command/DependenciesCommand.php index e58769940..3f4871020 100644 --- a/src/Console/Command/DependenciesCommand.php +++ b/src/Console/Command/DependenciesCommand.php @@ -76,6 +76,7 @@ public function __construct( protected function configure(): void { $this->setHelp('This command runs composer-dependency-analyser and Jack to report missing, unused, misplaced, and outdated Composer dependencies.'); + $this->addJsonOption() ->addOption( name: 'max-outdated', diff --git a/src/Console/Command/FundingCommand.php b/src/Console/Command/FundingCommand.php index 39675dbd0..efaf08c89 100644 --- a/src/Console/Command/FundingCommand.php +++ b/src/Console/Command/FundingCommand.php @@ -79,6 +79,7 @@ public function __construct( protected function configure(): void { $this->setHelp('This command merges supported funding entries across composer.json and .github/FUNDING.yml while preserving unsupported providers.'); + $this->addJsonOption() ->addOption( name: 'composer-file', diff --git a/src/Console/Command/GitAttributesCommand.php b/src/Console/Command/GitAttributesCommand.php index 91aee9aef..37f9677f6 100644 --- a/src/Console/Command/GitAttributesCommand.php +++ b/src/Console/Command/GitAttributesCommand.php @@ -101,6 +101,7 @@ protected function configure(): void . 'Only paths that exist in the repository are added, existing custom rules are preserved, and ' . '"extra.gitattributes.keep-in-export" paths stay in exported archives.' ); + $this->addJsonOption() ->addOption( name: 'dry-run', diff --git a/src/Console/Command/GitHooksCommand.php b/src/Console/Command/GitHooksCommand.php index 74dc77443..32c1b1bdc 100644 --- a/src/Console/Command/GitHooksCommand.php +++ b/src/Console/Command/GitHooksCommand.php @@ -71,6 +71,7 @@ public function __construct( protected function configure(): void { $this->setHelp('This command copies packaged Git hooks into the current repository.'); + $this->addJsonOption() ->addOption( name: 'source', diff --git a/src/Console/Command/GitIgnoreCommand.php b/src/Console/Command/GitIgnoreCommand.php index 202745fed..faac813bb 100644 --- a/src/Console/Command/GitIgnoreCommand.php +++ b/src/Console/Command/GitIgnoreCommand.php @@ -88,6 +88,7 @@ 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->addJsonOption() ->addOption( name: 'source', diff --git a/src/Console/Command/LicenseCommand.php b/src/Console/Command/LicenseCommand.php index 7206dcc2f..92e0c9da1 100644 --- a/src/Console/Command/LicenseCommand.php +++ b/src/Console/Command/LicenseCommand.php @@ -69,6 +69,7 @@ public function __construct( protected function configure(): void { $this->setHelp('This command generates a LICENSE file if one does not exist and a supported license is declared in composer.json.'); + $this->addJsonOption() ->addOption( name: 'target', diff --git a/src/Console/Command/MetricsCommand.php b/src/Console/Command/MetricsCommand.php index 7aaadeaee..d44288b2e 100644 --- a/src/Console/Command/MetricsCommand.php +++ b/src/Console/Command/MetricsCommand.php @@ -72,6 +72,7 @@ public function __construct( protected function configure(): void { $this->setHelp('This command runs PhpMetrics to analyze the current working directory.'); + $this->addJsonOption() ->addOption( name: 'progress', diff --git a/src/Console/Command/RefactorCommand.php b/src/Console/Command/RefactorCommand.php index 468d5c697..f29cd23e2 100644 --- a/src/Console/Command/RefactorCommand.php +++ b/src/Console/Command/RefactorCommand.php @@ -79,6 +79,7 @@ public function __construct( protected function configure(): void { $this->setHelp('This command runs Rector to refactor your code.'); + $this->addJsonOption() ->addOption( name: 'progress', diff --git a/src/Console/Command/SkillsCommand.php b/src/Console/Command/SkillsCommand.php index 7160af336..353991e3e 100644 --- a/src/Console/Command/SkillsCommand.php +++ b/src/Console/Command/SkillsCommand.php @@ -80,6 +80,7 @@ public function __construct( protected function configure(): void { $this->setHelp('This command ensures the consumer repository contains linked Fast Forward skills by creating symlinks to the packaged skills and removing broken links.'); + $this->addJsonOption(); } diff --git a/src/Console/Command/SyncCommand.php b/src/Console/Command/SyncCommand.php index d1e08df3d..bc0ad0b6b 100644 --- a/src/Console/Command/SyncCommand.php +++ b/src/Console/Command/SyncCommand.php @@ -63,6 +63,7 @@ public function __construct( protected function configure(): void { $this->setHelp('This command runs the dedicated synchronization commands for composer.json, resources, CODEOWNERS, funding metadata, wiki, git metadata, packaged skills, packaged agents, license, and Git hooks.'); + $this->addJsonOption() ->addOption( name: 'overwrite', diff --git a/src/Console/Command/UpdateComposerJsonCommand.php b/src/Console/Command/UpdateComposerJsonCommand.php index 68b92350a..ead6c0136 100644 --- a/src/Console/Command/UpdateComposerJsonCommand.php +++ b/src/Console/Command/UpdateComposerJsonCommand.php @@ -76,6 +76,7 @@ public function __construct( protected function configure(): void { $this->setHelp('This command adds or updates composer.json scripts and GrumPHP extra configuration required by dev-tools.'); + $this->addJsonOption() ->addOption( name: 'file', From c28b62d285a21fb827cee15505e41d47eb73331a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Thu, 23 Apr 2026 23:16:55 -0300 Subject: [PATCH 7/9] [style] Keep single-option command setup compact (#185) --- src/Console/Command/AgentsCommand.php | 1 - src/Console/Command/SkillsCommand.php | 1 - 2 files changed, 2 deletions(-) diff --git a/src/Console/Command/AgentsCommand.php b/src/Console/Command/AgentsCommand.php index 01d19fe3f..4a49ca1e7 100644 --- a/src/Console/Command/AgentsCommand.php +++ b/src/Console/Command/AgentsCommand.php @@ -63,7 +63,6 @@ public function __construct( protected function configure(): void { $this->setHelp('This command ensures the consumer repository contains linked Fast Forward project agents by creating symlinks to the packaged prompts and removing broken links.'); - $this->addJsonOption(); } diff --git a/src/Console/Command/SkillsCommand.php b/src/Console/Command/SkillsCommand.php index 353991e3e..7160af336 100644 --- a/src/Console/Command/SkillsCommand.php +++ b/src/Console/Command/SkillsCommand.php @@ -80,7 +80,6 @@ public function __construct( protected function configure(): void { $this->setHelp('This command ensures the consumer repository contains linked Fast Forward skills by creating symlinks to the packaged skills and removing broken links.'); - $this->addJsonOption(); } From 9c490e538fd4894f9e1fb7bb9a2fc332d3fd791e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Thu, 23 Apr 2026 23:25:57 -0300 Subject: [PATCH 8/9] [style] Wrap long command help strings (#185) --- src/Console/Command/AgentsCommand.php | 5 ++++- src/Console/Command/ChangelogCheckCommand.php | 5 ++++- src/Console/Command/ChangelogEntryCommand.php | 5 ++++- src/Console/Command/ChangelogNextVersionCommand.php | 5 ++++- src/Console/Command/ChangelogPromoteCommand.php | 5 ++++- src/Console/Command/ChangelogShowCommand.php | 5 ++++- src/Console/Command/CodeOwnersCommand.php | 5 ++++- src/Console/Command/CodeStyleCommand.php | 4 +++- src/Console/Command/CopyResourceCommand.php | 5 ++++- src/Console/Command/DependenciesCommand.php | 5 ++++- src/Console/Command/FundingCommand.php | 5 ++++- src/Console/Command/LicenseCommand.php | 5 ++++- src/Console/Command/ReportsCommand.php | 5 ++++- src/Console/Command/SkillsCommand.php | 5 ++++- src/Console/Command/StandardsCommand.php | 5 ++++- src/Console/Command/SyncCommand.php | 5 ++++- src/Console/Command/UpdateComposerJsonCommand.php | 5 ++++- 17 files changed, 67 insertions(+), 17 deletions(-) diff --git a/src/Console/Command/AgentsCommand.php b/src/Console/Command/AgentsCommand.php index 4a49ca1e7..58de617a8 100644 --- a/src/Console/Command/AgentsCommand.php +++ b/src/Console/Command/AgentsCommand.php @@ -62,7 +62,10 @@ public function __construct( */ protected function configure(): void { - $this->setHelp('This command ensures the consumer repository contains linked Fast Forward project agents by creating symlinks to the packaged prompts and removing broken links.'); + $this->setHelp( + 'This command ensures the consumer repository contains linked Fast Forward project agents by creating' + . ' symlinks to the packaged prompts and removing broken links.' + ); $this->addJsonOption(); } diff --git a/src/Console/Command/ChangelogCheckCommand.php b/src/Console/Command/ChangelogCheckCommand.php index 60e1ea8f3..4b9aef41a 100644 --- a/src/Console/Command/ChangelogCheckCommand.php +++ b/src/Console/Command/ChangelogCheckCommand.php @@ -60,7 +60,10 @@ public function __construct( */ protected function configure(): void { - $this->setHelp('This command validates the current Unreleased section and may compare it against a base git reference to enforce pull request changelog updates.'); + $this->setHelp( + 'This command validates the current Unreleased section and may compare it against a base git' + . ' reference to enforce pull request changelog updates.' + ); $this->addJsonOption() ->addOption( diff --git a/src/Console/Command/ChangelogEntryCommand.php b/src/Console/Command/ChangelogEntryCommand.php index ffbec3990..827489b2c 100644 --- a/src/Console/Command/ChangelogEntryCommand.php +++ b/src/Console/Command/ChangelogEntryCommand.php @@ -63,7 +63,10 @@ public function __construct( */ protected function configure(): void { - $this->setHelp('This command appends one categorized changelog entry to the selected changelog file so it can be reused by local authoring flows and skills.'); + $this->setHelp( + 'This command appends one categorized changelog entry to the selected changelog file so it can be' + . ' reused by local authoring flows and skills.' + ); $this->addJsonOption() ->addArgument( diff --git a/src/Console/Command/ChangelogNextVersionCommand.php b/src/Console/Command/ChangelogNextVersionCommand.php index 5d0148af6..f3837a8be 100644 --- a/src/Console/Command/ChangelogNextVersionCommand.php +++ b/src/Console/Command/ChangelogNextVersionCommand.php @@ -61,7 +61,10 @@ public function __construct( */ protected function configure(): void { - $this->setHelp('This command inspects Unreleased changelog categories and prints the next semantic version inferred from the current changelog state.'); + $this->setHelp( + 'This command inspects Unreleased changelog categories and prints the next semantic version inferred' + . ' from the current changelog state.' + ); $this->addJsonOption() ->addOption( diff --git a/src/Console/Command/ChangelogPromoteCommand.php b/src/Console/Command/ChangelogPromoteCommand.php index bd4b9c436..4f7266ec0 100644 --- a/src/Console/Command/ChangelogPromoteCommand.php +++ b/src/Console/Command/ChangelogPromoteCommand.php @@ -65,7 +65,10 @@ public function __construct( */ protected function configure(): void { - $this->setHelp('This command moves the current Unreleased entries into a released version section, records the release date, and restores an empty Unreleased section.'); + $this->setHelp( + 'This command moves the current Unreleased entries into a released version section, records the' + . ' release date, and restores an empty Unreleased section.' + ); $this->addJsonOption() ->addArgument( diff --git a/src/Console/Command/ChangelogShowCommand.php b/src/Console/Command/ChangelogShowCommand.php index 69ff81b36..df98fc046 100644 --- a/src/Console/Command/ChangelogShowCommand.php +++ b/src/Console/Command/ChangelogShowCommand.php @@ -62,7 +62,10 @@ public function __construct( */ protected function configure(): void { - $this->setHelp('This command renders the body of one released changelog section so it can be reused for GitHub release notes.'); + $this->setHelp( + 'This command renders the body of one released changelog section so it can be reused for GitHub' + . ' release notes.' + ); $this->addJsonOption() ->addArgument( diff --git a/src/Console/Command/CodeOwnersCommand.php b/src/Console/Command/CodeOwnersCommand.php index c956ec760..68731b36a 100644 --- a/src/Console/Command/CodeOwnersCommand.php +++ b/src/Console/Command/CodeOwnersCommand.php @@ -66,7 +66,10 @@ public function __construct( */ protected function configure(): void { - $this->setHelp('This command infers CODEOWNERS entries from composer.json metadata, falls back to a commented template, and supports drift-aware preview and overwrite flows.'); + $this->setHelp( + 'This command infers CODEOWNERS entries from composer.json metadata, falls back to a commented' + . ' template, and supports drift-aware preview and overwrite flows.' + ); $this->addJsonOption() ->addOption( diff --git a/src/Console/Command/CodeStyleCommand.php b/src/Console/Command/CodeStyleCommand.php index 45a842956..f06dc6938 100644 --- a/src/Console/Command/CodeStyleCommand.php +++ b/src/Console/Command/CodeStyleCommand.php @@ -82,7 +82,9 @@ public function __construct( */ protected function configure(): void { - $this->setHelp('This command runs EasyCodingStandard and Composer Normalize to check and fix code style issues.'); + $this->setHelp( + 'This command runs EasyCodingStandard and Composer Normalize to check and fix code style issues.' + ); $this->addJsonOption() ->addOption( diff --git a/src/Console/Command/CopyResourceCommand.php b/src/Console/Command/CopyResourceCommand.php index 50d024a85..095c20767 100644 --- a/src/Console/Command/CopyResourceCommand.php +++ b/src/Console/Command/CopyResourceCommand.php @@ -70,7 +70,10 @@ public function __construct( */ protected function configure(): void { - $this->setHelp('This command copies a configured source file or every file in a source directory into the target path.'); + $this->setHelp( + 'This command copies a configured source file or every file in a source directory into the target' + . ' path.' + ); $this->addJsonOption() ->addOption( diff --git a/src/Console/Command/DependenciesCommand.php b/src/Console/Command/DependenciesCommand.php index 3f4871020..cbecf1820 100644 --- a/src/Console/Command/DependenciesCommand.php +++ b/src/Console/Command/DependenciesCommand.php @@ -75,7 +75,10 @@ public function __construct( */ protected function configure(): void { - $this->setHelp('This command runs composer-dependency-analyser and Jack to report missing, unused, misplaced, and outdated Composer dependencies.'); + $this->setHelp( + 'This command runs composer-dependency-analyser and Jack to report missing, unused, misplaced, and' + . ' outdated Composer dependencies.' + ); $this->addJsonOption() ->addOption( diff --git a/src/Console/Command/FundingCommand.php b/src/Console/Command/FundingCommand.php index efaf08c89..c2b254414 100644 --- a/src/Console/Command/FundingCommand.php +++ b/src/Console/Command/FundingCommand.php @@ -78,7 +78,10 @@ public function __construct( */ protected function configure(): void { - $this->setHelp('This command merges supported funding entries across composer.json and .github/FUNDING.yml while preserving unsupported providers.'); + $this->setHelp( + 'This command merges supported funding entries across composer.json and .github/FUNDING.yml while' + . ' preserving unsupported providers.' + ); $this->addJsonOption() ->addOption( diff --git a/src/Console/Command/LicenseCommand.php b/src/Console/Command/LicenseCommand.php index 92e0c9da1..04d72a85d 100644 --- a/src/Console/Command/LicenseCommand.php +++ b/src/Console/Command/LicenseCommand.php @@ -68,7 +68,10 @@ public function __construct( */ protected function configure(): void { - $this->setHelp('This command generates a LICENSE file if one does not exist and a supported license is declared in composer.json.'); + $this->setHelp( + 'This command generates a LICENSE file if one does not exist and a supported license is declared in' + . ' composer.json.' + ); $this->addJsonOption() ->addOption( diff --git a/src/Console/Command/ReportsCommand.php b/src/Console/Command/ReportsCommand.php index 4208c8957..2c9aaee5a 100644 --- a/src/Console/Command/ReportsCommand.php +++ b/src/Console/Command/ReportsCommand.php @@ -67,7 +67,10 @@ public function __construct( */ protected function configure(): void { - $this->setHelp('This command generates the frontpage for Fast Forward documentation, including links to API documentation and test reports.'); + $this->setHelp( + 'This command generates the frontpage for Fast Forward documentation, including links to API' + . ' documentation and test reports.' + ); $this ->addJsonOption() ->addCacheOption('Whether to enable cache writes in nested docs and tests commands.') diff --git a/src/Console/Command/SkillsCommand.php b/src/Console/Command/SkillsCommand.php index 7160af336..4c2f6aa51 100644 --- a/src/Console/Command/SkillsCommand.php +++ b/src/Console/Command/SkillsCommand.php @@ -79,7 +79,10 @@ public function __construct( */ protected function configure(): void { - $this->setHelp('This command ensures the consumer repository contains linked Fast Forward skills by creating symlinks to the packaged skills and removing broken links.'); + $this->setHelp( + 'This command ensures the consumer repository contains linked Fast Forward skills by creating' + . ' symlinks to the packaged skills and removing broken links.' + ); $this->addJsonOption(); } diff --git a/src/Console/Command/StandardsCommand.php b/src/Console/Command/StandardsCommand.php index d12ec2759..3be8b0c95 100644 --- a/src/Console/Command/StandardsCommand.php +++ b/src/Console/Command/StandardsCommand.php @@ -67,7 +67,10 @@ public function __construct( */ protected function configure(): void { - $this->setHelp('This command runs all Fast Forward code standards checks, including code refactoring, PHPDoc validation, code style checks, documentation generation, and tests execution.'); + $this->setHelp( + 'This command runs all Fast Forward code standards checks, including code refactoring, PHPDoc' + . ' validation, code style checks, documentation generation, and tests execution.' + ); $this ->addJsonOption() ->addCacheOption( diff --git a/src/Console/Command/SyncCommand.php b/src/Console/Command/SyncCommand.php index bc0ad0b6b..80f1fcf5a 100644 --- a/src/Console/Command/SyncCommand.php +++ b/src/Console/Command/SyncCommand.php @@ -62,7 +62,10 @@ public function __construct( */ protected function configure(): void { - $this->setHelp('This command runs the dedicated synchronization commands for composer.json, resources, CODEOWNERS, funding metadata, wiki, git metadata, packaged skills, packaged agents, license, and Git hooks.'); + $this->setHelp( + 'This command runs the dedicated synchronization commands for composer.json, resources, CODEOWNERS,' + . ' funding metadata, wiki, git metadata, packaged skills, packaged agents, license, and Git hooks.' + ); $this->addJsonOption() ->addOption( diff --git a/src/Console/Command/UpdateComposerJsonCommand.php b/src/Console/Command/UpdateComposerJsonCommand.php index ead6c0136..e4b4aa8fd 100644 --- a/src/Console/Command/UpdateComposerJsonCommand.php +++ b/src/Console/Command/UpdateComposerJsonCommand.php @@ -75,7 +75,10 @@ public function __construct( */ protected function configure(): void { - $this->setHelp('This command adds or updates composer.json scripts and GrumPHP extra configuration required by dev-tools.'); + $this->setHelp( + 'This command adds or updates composer.json scripts and GrumPHP extra configuration required by' + . ' dev-tools.' + ); $this->addJsonOption() ->addOption( From d975c7f29d3df14ce8d1695de0e52ffc27458624 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Thu, 23 Apr 2026 23:30:57 -0300 Subject: [PATCH 9/9] [refactor] Simplify symlink origin handling (#185) --- src/Filesystem/Filesystem.php | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/Filesystem/Filesystem.php b/src/Filesystem/Filesystem.php index 226edd388..59dc53f0d 100644 --- a/src/Filesystem/Filesystem.php +++ b/src/Filesystem/Filesystem.php @@ -119,11 +119,7 @@ public function remove(string|iterable $files): void */ public function symlink(string $originDir, string $targetDir, bool $copyOnWindows = false): void { - $origin = Path::isAbsolute($originDir) - ? $this->getAbsolutePath($originDir) - : $originDir; - - $this->filesystem->symlink($origin, $this->getAbsolutePath($targetDir), $copyOnWindows); + $this->filesystem->symlink($originDir, $this->getAbsolutePath($targetDir), $copyOnWindows); } /**