diff --git a/README.md b/README.md index 7a6946c..c2ce579 100644 --- a/README.md +++ b/README.md @@ -84,8 +84,6 @@ composer ic:init ```text Install CaptainHook pre-commit config (validate, audit, parallel CI)? Install GitHub Actions workflow wrapper (parallel CI, SARIF, SVG report)? -PHPForge checker config preset -Install Deptrac architecture config (deptrac.yaml)? Install GitLab CI pipeline (.gitlab-ci.yml)? Install Bitbucket pipeline (bitbucket-pipelines.yml)? Install Forgejo workflow (.forgejo/workflows/security-standards.yml)? @@ -113,7 +111,6 @@ Selector presets include: | Extra Composer flags | `none` => `""`, `with-all-dependencies` => `--with-all-dependencies`, `ignore-ext-redis` => `--ignore-platform-req=ext-redis`, or custom. Prompt explains each option effect. | | PHPStan memory limit | `1G`, `2G`, `4G`, or custom | | Psalm threads | `1`, `2`, `4`, or custom | -| PHPForge checker config preset | `phpstorm`, `standard`, or `strict`. Asked only when publishing `phpforge.json`. | `supported` includes non-EOL PHP minor cycles (>= `8.2`), `current` uses the latest two supported cycles, and `stable` uses the latest supported cycle. PHP version, dependency matrix, PHP extensions, and Composer flags selectors show resolved values in the prompt and print the final resolved value after selection. @@ -122,15 +119,13 @@ Depending on your selections, `ic:init` can generate: ```text captainhook.json -phpforge.json -deptrac.yaml .github/workflows/security-standards.yml .gitlab-ci.yml bitbucket-pipelines.yml .forgejo/workflows/security-standards.yml ``` -Interactive init asks for a PHPForge checker config preset and publishes `phpforge.json` from that choice. It also asks whether to publish `deptrac.yaml`. Non-interactive default init still keeps bundled fallbacks unless `--phpforge` or `--deptrac` is passed. Use `composer ic:publish-config phpforge.json deptrac.yaml` when you only want to publish those files outside init. +`ic:init` sets up hook/workflow wrappers only. Publish checker or architecture config separately with `composer ic:publish-config phpprobe.json deptrac.yaml` when customization is needed. After `ic:init`, run: @@ -147,8 +142,6 @@ Use targeted or non-interactive init commands when needed: ```bash composer ic:init --captainhook -composer ic:init --phpforge -composer ic:init --deptrac composer ic:init --workflow --workflow-ref=main composer ic:init --gitlab-ci composer ic:init --bitbucket-ci @@ -167,36 +160,36 @@ composer ic:init --force | `composer ic:tests:all` | Alias of `ic:tests`. | | `composer ic:tests:parallel` | Runs syntax first, then executes the remaining quality checks with bounded parallelism and a buffered PASS/FAIL summary. | | `composer ic:tests:details` | Runs detailed checks without the parallel Pest shortcut. | -| `composer ic:test:syntax` | Runs the PHP syntax checker using `phpforge.json`, Git ignores, and configured excludes. | +| `composer ic:test:syntax` | Runs the PHP syntax checker using `phpprobe.json`, Git ignores, and configured excludes. | | `composer ic:test:code` | Runs Pest. | | `composer ic:test:lint` | Runs Pint in check mode. | | `composer ic:test:sniff` | Runs PHPCS with a full report against the project root and bundled/project excludes. | -| `composer ic:test:duplicates` | Runs duplicate detection using `phpforge.json`. | +| `composer ic:test:duplicates` | Runs duplicate detection using `phpprobe.json`. | | `composer ic:test:architecture` | Runs Deptrac architecture checks using `deptrac.yaml`. | | `composer ic:test:static` | Runs PHPStan. | | `composer ic:test:security` | Runs Psalm security analysis. | | `composer ic:test:refactor` | Runs Rector in dry-run mode. | | `composer ic:test:bench` | Runs PHPBench aggregate benchmarks. | -Syntax and duplicate settings live in `phpforge.json`, with the bundled default used when a project-local file is not present. +Syntax and duplicate settings live in `phpprobe.json`, with the bundled default used when a project-local file is not present. PHPForge delegates these checks to `vendor/bin/phpprobe`; the `phpforge syntax` and `phpforge duplicates` commands are thin compatibility gateways that pass the same config to PHPProbe. Both checks are root-scoped by default because their bundled `paths` lists are empty; Git-aware PHP discovery is then filtered by the configured `exclude` lists. Duplicate detection defaults are aligned with PhpStorm-style clone analysis: variable/literal normalization, fuzzy identifier/call anonymization, structural audit mode, near-miss matching, and a mid-sensitivity token window are enabled. Use the lower-level binary for custom scans; CLI paths override configured paths, while CLI excludes are added to configured excludes: ```bash -php vendor/bin/phpprobe syntax --config=phpforge.json --exclude=storage -php vendor/bin/phpprobe duplicates --config=phpforge.json --min-lines=5 --min-tokens=70 -php vendor/bin/phpprobe duplicates --config=phpforge.json --mode=audit --near-miss --json --exclude=tests -php vendor/bin/phpprobe duplicates --config=phpforge.json --write-baseline=.phpforge-duplicates-baseline.json -php vendor/bin/phpprobe duplicates --config=phpforge.json --baseline=.phpforge-duplicates-baseline.json +php vendor/bin/phpprobe syntax --config=phpprobe.json --exclude=storage +php vendor/bin/phpprobe duplicates --config=phpprobe.json --min-lines=5 --min-tokens=70 +php vendor/bin/phpprobe duplicates --config=phpprobe.json --mode=audit --near-miss --json --exclude=tests +php vendor/bin/phpprobe duplicates --config=phpprobe.json --write-baseline=.phpprobe-duplicates-baseline.json +php vendor/bin/phpprobe duplicates --config=phpprobe.json --baseline=.phpprobe-duplicates-baseline.json ``` Useful checker options: | Option | Applies To | Purpose | | --------------------------- | ------------------ | ----------------------------------------------------------------------- | -| `--config=FILE` | Syntax, duplicates | Reads checker settings from a custom `phpforge.json` file. | +| `--config=FILE` | Syntax, duplicates | Reads checker settings from a custom `phpprobe.json` file. | | `--exclude=PATH` | Syntax, duplicates | Excludes one path; repeat it for multiple one-off exclusions. | | `--exact` | Duplicates | Disables variable/literal normalization. | | `--fuzzy` | Duplicates | Also normalizes identifiers and calls for renamed-code scans. | @@ -248,11 +241,8 @@ Useful checker options: | Command | Purpose | | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | -| `composer ic:init` | Interactively sets up CaptainHook pre-commit, workflow wrappers, and checker config publishing. | +| `composer ic:init` | Interactively sets up CaptainHook pre-commit and workflow wrappers. | | `composer ic:init --captainhook` | Copies only the CaptainHook pre-commit config. | -| `composer ic:init --phpforge` | Publishes `phpforge.json` only when a project needs custom syntax or duplicate detection settings. | -| `composer ic:init --phpforge --phpforge-preset=standard` | Publishes `phpforge.json` with a named preset: `phpstorm`, `standard`, or `strict`. | -| `composer ic:init --deptrac` | Copies only the Deptrac architecture config. | | `composer ic:init --workflow --workflow-ref=main` | Copies only the GitHub Actions wrapper and points it at the given PHPForge ref. | | `composer ic:init --gitlab-ci` | Copies `.gitlab-ci.yml` starter pipeline. | | `composer ic:init --bitbucket-ci` | Copies `bitbucket-pipelines.yml` starter pipeline. | @@ -265,6 +255,7 @@ Useful checker options: | `composer ic:list-config` | Lists config files and their resolution source. | | `composer ic:list-config --json` | Outputs config resolution as JSON. | | `composer ic:publish-config [file...]` | Copies selected bundled config files into the project. | +| `composer ic:publish-config phpprobe.json --phpprobe-preset=strict` | Publishes `phpprobe.json` using a named duplicate-detection preset (`phpstorm`, `standard`, `strict`). | | `composer ic:publish-config --all` | Copies every bundled config file into the project. | | `composer ic:publish-config --all --force` | Overwrites all project config files with bundled defaults. | | `composer ic:clean` | Removes known PHPForge output files and cache directories. | @@ -286,7 +277,7 @@ If none of those exists outside the PHPForge source project, PHPForge fails inst | ---------------------- | ---------------------------------------------------------------------------------------------------------------- | | Pest / PHPUnit | `pest.xml`, then `phpunit.xml`, then `pest.xml.dist`, then `phpunit.xml.dist`, then bundled `pest.xml` | | PHPBench | `phpbench.json`, then bundled `phpbench.json` | -| PHPProbe checker tasks | `phpforge.json`, then bundled `phpforge.json` | +| PHPProbe checker tasks | `phpprobe.json`, then bundled `phpprobe.json` | | Deptrac | `deptrac.yaml`, then bundled `deptrac.yaml` | | PHPCS / PHPCBF | `phpcs.xml.dist`, then bundled `phpcs.xml.dist` | | PHPStan | `phpstan.neon.dist`, then bundled `phpstan.neon.dist` | @@ -297,7 +288,7 @@ If none of those exists outside the PHPForge source project, PHPForge fails inst ### PHPProbe Checker Config -`phpforge.json` configures PHPProbe syntax and duplicate-code checks. +`phpprobe.json` configures PHPProbe syntax and duplicate-code checks. Both sections use root-scoped discovery when `paths` is empty: PHPProbe asks Git for tracked/unignored PHP files, then falls back to recursively scanning the project root if Git is unavailable. Use `exclude` to keep tests, generated files, caches, vendor packages, and other noisy paths out of the checker tasks. @@ -384,7 +375,7 @@ Bundled default: Snake case, kebab case, and camel case are accepted for checker config keys, so `min_tokens`, `min-tokens`, and `minTokens` resolve to the same setting. Explicit CLI paths override configured `paths`; configured and CLI excludes are combined. -PHPForge checker config presets are available when publishing `phpforge.json` through `ic:init --phpforge`: +Presets for `phpprobe.json` publishing: | Preset | Duplicate Policy | | ----------- | -------------------------------------------------------------------------------- | @@ -393,8 +384,7 @@ PHPForge checker config presets are available when publishing `phpforge.json` th | `strict` | More sensitive audit mode: near-miss matching enabled with lower size thresholds, `min_tokens: 70`. | ```bash -composer ic:init --phpforge -composer ic:init --phpforge --phpforge-preset=standard +composer ic:publish-config phpprobe.json --phpprobe-preset=standard ``` ### Deptrac Architecture Config @@ -403,7 +393,6 @@ composer ic:init --phpforge --phpforge-preset=standard ```bash composer ic:test:architecture -composer ic:init --deptrac composer ic:publish-config deptrac.yaml ``` @@ -417,7 +406,7 @@ composer ic:list-config --json Publish config only when a project needs custom rules: ```bash -composer ic:publish-config phpforge.json pint.json phpstan.neon.dist +composer ic:publish-config phpprobe.json pint.json phpstan.neon.dist composer ic:publish-config --all ``` @@ -430,7 +419,7 @@ pest.xml phpunit.xml phpbench.json phpcs.xml.dist -phpforge.json +phpprobe.json phpstan.neon.dist pint.json psalm.xml @@ -913,10 +902,10 @@ Then open the workflow run and download `security-report`. Publish the relevant config and edit it in the project: ```bash -composer ic:publish-config phpforge.json +composer ic:publish-config phpprobe.json composer ic:publish-config phpstan.neon.dist composer ic:publish-config psalm.xml ``` Project config files always take priority over bundled defaults. -For syntax or duplicate detector noise, adjust `phpforge.json` paths/excludes or duplicate thresholds first. +For syntax or duplicate detector noise, adjust `phpprobe.json` paths/excludes or duplicate thresholds first. diff --git a/resources/AGENTS.md b/resources/AGENTS.md index 30d6312..0af1106 100644 --- a/resources/AGENTS.md +++ b/resources/AGENTS.md @@ -16,7 +16,7 @@ - `composer ic:release:guard` - release gate. - `composer ic:ci` / `composer ic:ci --prefer-lowest` - CI parity. - `composer ic:init` / `composer ic:hooks` - project setup and hooks. -- `composer ic:publish-config phpforge.json` - customize syntax/duplicate scan policy. +- `composer ic:publish-config phpprobe.json` - customize syntax/duplicate scan policy. ## Resolution Flow @@ -29,7 +29,7 @@ ## Config And CI - Config priority: project root config first, then `vendor/infocyph/phpforge/resources`, then source-tree `resources/` only when the current project is `infocyph/phpforge`; otherwise missing bundled configs hard fail. -- `phpforge.json` controls PHPProbe syntax and duplicate paths/excludes; empty `paths` means project-root discovery through Git-aware PHP file finding. +- `phpprobe.json` controls PHPProbe syntax and duplicate paths/excludes; empty `paths` means project-root discovery through Git-aware PHP file finding. - Syntax and duplicate scans run through `vendor/bin/phpprobe` and respect Git ignores plus configured `exclude`/`exclude_paths` entries. - Checker CLI paths override configured `paths`; CLI `--exclude` values are added to configured excludes. - `deptrac.yaml` controls architecture boundary checks. diff --git a/resources/phpforge.json b/resources/phpprobe.json similarity index 100% rename from resources/phpforge.json rename to resources/phpprobe.json diff --git a/src/Composer/InitCommand.php b/src/Composer/InitCommand.php index b5bc57d..780453e 100644 --- a/src/Composer/InitCommand.php +++ b/src/Composer/InitCommand.php @@ -35,9 +35,6 @@ protected function configure(): void ->addOption('workflow', null, InputOption::VALUE_NONE, 'Copy the Security & Standards GitHub Actions workflow wrapper.') ->addOption('workflow-ref', null, InputOption::VALUE_REQUIRED, 'PHPForge Git ref used by generated workflow wrappers.', 'main') ->addOption('captainhook', null, InputOption::VALUE_NONE, 'Copy the default CaptainHook pre-commit configuration.') - ->addOption('phpforge', null, InputOption::VALUE_NONE, 'Copy the default syntax and duplicate detector configuration.') - ->addOption('phpforge-preset', null, InputOption::VALUE_REQUIRED, 'Syntax and duplicate detector preset: phpstorm, standard, or strict.') - ->addOption('deptrac', null, InputOption::VALUE_NONE, 'Copy the default Deptrac architecture configuration.') ->addOption('gitlab-ci', null, InputOption::VALUE_NONE, 'Copy a GitLab CI pipeline.') ->addOption('bitbucket-ci', null, InputOption::VALUE_NONE, 'Copy a Bitbucket Pipelines configuration.') ->addOption('forgejo-workflow', null, InputOption::VALUE_NONE, 'Copy a Forgejo Actions workflow.') @@ -65,52 +62,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int return 0; } - /** - * @return array{duplicates: array{mode: string, normalize: bool, fuzzy: bool, near_miss: bool, min_lines: int, min_tokens: int, min_statements: int, min_similarity: float}} - */ - private static function phpforgePreset(string $preset): array - { - return match ($preset) { - 'phpstorm' => [ - 'duplicates' => [ - 'mode' => 'audit', - 'normalize' => true, - 'fuzzy' => true, - 'near_miss' => true, - 'min_lines' => 5, - 'min_tokens' => 90, - 'min_statements' => 4, - 'min_similarity' => 0.85, - ], - ], - 'standard' => [ - 'duplicates' => [ - 'mode' => 'gate', - 'normalize' => true, - 'fuzzy' => true, - 'near_miss' => false, - 'min_lines' => 6, - 'min_tokens' => 100, - 'min_statements' => 5, - 'min_similarity' => 0.90, - ], - ], - 'strict' => [ - 'duplicates' => [ - 'mode' => 'audit', - 'normalize' => true, - 'fuzzy' => true, - 'near_miss' => true, - 'min_lines' => 4, - 'min_tokens' => 70, - 'min_statements' => 3, - 'min_similarity' => 0.80, - ], - ], - default => throw new \InvalidArgumentException(sprintf('Unknown PHPForge preset "%s". Expected phpstorm, standard, or strict.', $preset)), - }; - } - /** * @param array $settings * @return array @@ -231,9 +182,6 @@ private function askInstallTargets( ): array { $settings['captainhook'] = (bool) $helper->ask($input, $output, new ConfirmationQuestion('Install CaptainHook pre-commit config (validate, audit, parallel CI)? [Y/n] ', true)); $settings['workflow'] = (bool) $helper->ask($input, $output, new ConfirmationQuestion('Install GitHub Actions workflow wrapper (parallel CI, SARIF, SVG report)? [Y/n] ', true)); - $settings['phpforge'] = true; - $settings['phpforge_preset'] = $this->askPhpforgePreset($input, $output, (string) $settings['phpforge_preset']); - $settings['deptrac'] = (bool) $helper->ask($input, $output, new ConfirmationQuestion('Install Deptrac architecture config (deptrac.yaml)? [Y/n] ', true)); $settings['gitlab_ci'] = (bool) $helper->ask($input, $output, new ConfirmationQuestion('Install GitLab CI pipeline (.gitlab-ci.yml)? [y/N] ', false)); $settings['bitbucket_ci'] = (bool) $helper->ask($input, $output, new ConfirmationQuestion('Install Bitbucket pipeline (bitbucket-pipelines.yml)? [y/N] ', false)); $settings['forgejo_workflow'] = (bool) $helper->ask($input, $output, new ConfirmationQuestion('Install Forgejo workflow (.forgejo/workflows/security-standards.yml)? [y/N] ', false)); @@ -313,32 +261,6 @@ private function askPhpExtensions( ], $phpExtensionPresets, $extensionChoiceMap); } - private function askPhpforgePreset(InputInterface $input, OutputInterface $output, string $defaultPreset): string - { - $helper = $this->getHelper('question'); - - if (!$helper instanceof QuestionHelper) { - return $defaultPreset; - } - - $choices = [ - 'phpstorm (PhpStorm-like duplicate detection; recommended)' => 'phpstorm', - 'standard (balanced deterministic CI gate)' => 'standard', - 'strict (more sensitive audit mode)' => 'strict', - ]; - $choice = $this->stringValue($helper->ask($input, $output, new ChoiceQuestion( - 'PHPForge checker config preset', - array_keys($choices), - match ($defaultPreset) { - 'standard' => 'standard (balanced deterministic CI gate)', - 'strict' => 'strict (more sensitive audit mode)', - default => 'phpstorm (PhpStorm-like duplicate detection; recommended)', - }, - )), $defaultPreset); - - return $choices[$choice] ?? $defaultPreset; - } - private function askPhpstanMemoryLimit( QuestionHelper $helper, InputInterface $input, @@ -464,45 +386,6 @@ private function copy(string $source, string $target, bool $force, OutputInterfa return $this->write($contents, $target, $force, $output); } - private function copyPhpforgeConfig(string $source, string $target, string $preset, bool $force, OutputInterface $output): int - { - if ($preset === 'phpstorm') { - return $this->copy($source, $target, $force, $output); - } - - if (!is_file($source)) { - $output->writeln(sprintf('Missing template: %s', $source)); - - return 0; - } - - $contents = file_get_contents($source); - - if (!is_string($contents)) { - $output->writeln(sprintf('Unable to read template: %s', $source)); - - return 0; - } - - $config = json_decode($contents, true); - - if (!is_array($config)) { - $output->writeln(sprintf('Unable to decode template: %s', $source)); - - return 0; - } - - $contents = json_encode(array_replace_recursive($config, self::phpforgePreset($preset)), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - - if (!is_string($contents)) { - $output->writeln(sprintf('Unable to encode preset config: %s', $preset)); - - return 0; - } - - return $this->write($contents . PHP_EOL, $target, $force, $output); - } - /** * @param array $flags * @param array $settings @@ -521,12 +404,10 @@ private function copySelectedTargets(array $flags, array $settings, bool $force, ); } - foreach (['captainhook', 'phpforge', 'deptrac', 'gitlab_ci', 'bitbucket_ci', 'forgejo_workflow'] as $flag) { + foreach (['captainhook', 'gitlab_ci', 'bitbucket_ci', 'forgejo_workflow'] as $flag) { if ($flags[$flag]) { [$source, $target] = $this->target($flag); - $copied += $flag === 'phpforge' - ? $this->copyPhpforgeConfig($source, $target, (string) $settings['phpforge_preset'], $force, $output) - : $this->copy($source, $target, $force, $output); + $copied += $this->copy($source, $target, $force, $output); } } @@ -570,8 +451,6 @@ private function copyWorkflow(string $source, string $target, array $settings, b * @return array{ * workflow: bool, * captainhook: bool, - * phpforge: bool, - * deptrac: bool, * gitlab_ci: bool, * bitbucket_ci: bool, * forgejo_workflow: bool, @@ -581,7 +460,6 @@ private function copyWorkflow(string $source, string $target, array $settings, b * php_extensions: string, * coverage: string, * composer_flags: string, - * phpforge_preset: string, * phpstan_memory_limit: string, * psalm_threads: string, * run_analysis: bool, @@ -593,8 +471,6 @@ private function defaultSettings(string $workflowRef): array return [ 'workflow' => true, 'captainhook' => true, - 'phpforge' => false, - 'deptrac' => false, 'gitlab_ci' => false, 'bitbucket_ci' => false, 'forgejo_workflow' => false, @@ -604,7 +480,6 @@ private function defaultSettings(string $workflowRef): array 'php_extensions' => '', 'coverage' => 'none', 'composer_flags' => '', - 'phpforge_preset' => 'phpstorm', 'phpstan_memory_limit' => '1G', 'psalm_threads' => '1', 'run_analysis' => true, @@ -733,8 +608,6 @@ private function renderNextSteps(array $flags, OutputInterface $output): void { $messages = [ 'workflow' => ' - Review and commit .github/workflows/security-standards.yml (parallel CI, SARIF, SVG settings)', - 'phpforge' => ' - Review and commit phpforge.json (syntax and duplicate policy)', - 'deptrac' => ' - Review and commit deptrac.yaml (architecture boundaries)', 'gitlab_ci' => ' - Review and commit .gitlab-ci.yml', 'bitbucket_ci' => ' - Review and commit bitbucket-pipelines.yml', 'forgejo_workflow' => ' - Review and commit .forgejo/workflows/security-standards.yml', @@ -762,13 +635,9 @@ private function renderNextSteps(array $flags, OutputInterface $output): void */ private function resolvedSelection(InputInterface $input, OutputInterface $output, array $settings): array { - $requestedPhpforgePreset = $this->stringValue($input->getOption('phpforge-preset'), ''); - $settings['phpforge_preset'] = $requestedPhpforgePreset !== '' ? $requestedPhpforgePreset : $settings['phpforge_preset']; $flags = [ 'workflow' => (bool) $input->getOption('workflow'), 'captainhook' => (bool) $input->getOption('captainhook'), - 'phpforge' => (bool) $input->getOption('phpforge'), - 'deptrac' => (bool) $input->getOption('deptrac'), 'gitlab_ci' => (bool) $input->getOption('gitlab-ci'), 'bitbucket_ci' => (bool) $input->getOption('bitbucket-ci'), 'forgejo_workflow' => (bool) $input->getOption('forgejo-workflow'), @@ -786,10 +655,6 @@ private function resolvedSelection(InputInterface $input, OutputInterface $outpu $flags['captainhook'] = true; } - if ($explicit && $flags['phpforge'] && $input->isInteractive() && $requestedPhpforgePreset === '' && !(bool) $input->getOption('no-interaction-defaults')) { - $settings['phpforge_preset'] = $this->askPhpforgePreset($input, $output, (string) $settings['phpforge_preset']); - } - return ['flags' => $flags, 'settings' => $settings]; } @@ -883,8 +748,6 @@ private function target(string $flag): array { return match ($flag) { 'captainhook' => [Paths::bundledConfigFile('captainhook.json'), Paths::projectRootPath() . DIRECTORY_SEPARATOR . 'captainhook.json'], - 'phpforge' => [Paths::bundledConfigFile('phpforge.json'), Paths::projectRootPath() . DIRECTORY_SEPARATOR . 'phpforge.json'], - 'deptrac' => [Paths::bundledConfigFile('deptrac.yaml'), Paths::projectRootPath() . DIRECTORY_SEPARATOR . 'deptrac.yaml'], 'gitlab_ci' => [Paths::packageFile('resources/ci/gitlab-ci.yml'), Paths::projectRootPath() . DIRECTORY_SEPARATOR . '.gitlab-ci.yml'], 'bitbucket_ci' => [Paths::packageFile('resources/ci/bitbucket-pipelines.yml'), Paths::projectRootPath() . DIRECTORY_SEPARATOR . 'bitbucket-pipelines.yml'], 'forgejo_workflow' => [Paths::packageFile('resources/ci/forgejo-security-standards.yml'), Paths::projectRootPath() . DIRECTORY_SEPARATOR . '.forgejo' . DIRECTORY_SEPARATOR . 'workflows' . DIRECTORY_SEPARATOR . 'security-standards.yml'], diff --git a/src/Composer/PublishConfigCommand.php b/src/Composer/PublishConfigCommand.php index 8e4daf5..fe210f7 100644 --- a/src/Composer/PublishConfigCommand.php +++ b/src/Composer/PublishConfigCommand.php @@ -14,6 +14,11 @@ final class PublishConfigCommand extends Command { + /** + * @var non-empty-list + */ + private const PHPPROBE_PRESETS = ['phpstorm', 'standard', 'strict']; + public function __construct() { parent::__construct('ic:publish-config'); @@ -25,47 +30,187 @@ protected function configure(): void ->setDescription('Publish bundled PHPForge config files into the project.') ->addArgument('files', InputArgument::IS_ARRAY, 'Specific config files to publish.') ->addOption('all', null, InputOption::VALUE_NONE, 'Publish all bundled config files.') + ->addOption('phpprobe-preset', null, InputOption::VALUE_REQUIRED, 'Apply a PHPProbe preset when publishing phpprobe.json: phpstorm, standard, or strict.') ->addOption('force', 'f', InputOption::VALUE_NONE, 'Overwrite existing project files.'); } protected function execute(InputInterface $input, OutputInterface $output): int { - $files = $input->getArgument('files'); + $files = $this->resolveFiles($input); + $force = (bool) $input->getOption('force'); - if (!is_array($files) || $files === [] || (bool) $input->getOption('all')) { - $files = ConfigInventory::files(); + $phpprobePreset = $this->resolvePhpprobePreset($input, $output); + + if ($phpprobePreset === null) { + return 1; } - $force = (bool) $input->getOption('force'); $published = 0; foreach ($files as $file) { - if (!is_string($file) || $file === '') { - continue; + if ($this->publishFile($file, $force, $phpprobePreset, $output)) { + $published++; } + } - $source = Paths::bundledConfigFile($file); - $target = Paths::projectRootPath() . DIRECTORY_SEPARATOR . $file; + $output->writeln(sprintf('Published %d config file(s).', $published)); - if (!is_file($source)) { - $output->writeln(sprintf('Missing bundled config: %s', $file)); + return 0; + } - continue; - } + /** + * @return array{duplicates: array{mode: string, normalize: bool, fuzzy: bool, near_miss: bool, min_lines: int, min_tokens: int, min_statements: int, min_similarity: float}} + */ + private static function phpprobePreset(string $preset): array + { + return match ($preset) { + 'phpstorm' => [ + 'duplicates' => [ + 'mode' => 'audit', + 'normalize' => true, + 'fuzzy' => true, + 'near_miss' => true, + 'min_lines' => 5, + 'min_tokens' => 90, + 'min_statements' => 4, + 'min_similarity' => 0.85, + ], + ], + 'standard' => [ + 'duplicates' => [ + 'mode' => 'gate', + 'normalize' => true, + 'fuzzy' => true, + 'near_miss' => false, + 'min_lines' => 6, + 'min_tokens' => 100, + 'min_statements' => 5, + 'min_similarity' => 0.90, + ], + ], + 'strict' => [ + 'duplicates' => [ + 'mode' => 'audit', + 'normalize' => true, + 'fuzzy' => true, + 'near_miss' => true, + 'min_lines' => 4, + 'min_tokens' => 70, + 'min_statements' => 3, + 'min_similarity' => 0.80, + ], + ], + default => throw new \InvalidArgumentException(sprintf('Unknown PHPProbe preset "%s". Expected phpstorm, standard, or strict.', $preset)), + }; + } + + private function applyPhpprobePreset(string $contents, string $preset): ?string + { + $decoded = json_decode($contents, true); + + if (!is_array($decoded)) { + return null; + } + + $patched = array_replace_recursive($decoded, self::phpprobePreset($preset)); + $encoded = json_encode($patched, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + + if (!is_string($encoded)) { + return null; + } + + return $encoded . PHP_EOL; + } + + private function publishFile( + string $file, + bool $force, + string $phpprobePreset, + OutputInterface $output, + ): bool { + $source = Paths::bundledConfigFile($file); + $target = Paths::projectRootPath() . DIRECTORY_SEPARATOR . $file; + + if (!is_file($source)) { + $output->writeln(sprintf('Missing bundled config: %s', $file)); + + return false; + } + + if (is_file($target) && !$force) { + $output->writeln(sprintf('Skipped existing config: %s', $file)); + + return false; + } + + $contents = file_get_contents($source); - if (is_file($target) && !$force) { - $output->writeln(sprintf('Skipped existing config: %s', $file)); + if (!is_string($contents)) { + $output->writeln(sprintf('Unable to read bundled config: %s', $file)); + + return false; + } - continue; + if ($file === 'phpprobe.json' && $phpprobePreset !== '') { + $contents = $this->applyPhpprobePreset($contents, $phpprobePreset); + + if (!is_string($contents)) { + $output->writeln(sprintf( + 'Unable to apply PHPProbe preset "%s".', + $phpprobePreset, + )); + + return false; } + } + + file_put_contents($target, $contents); + + $output->writeln(sprintf('Published config: %s', $file)); + + return true; + } + + /** + * @return list + */ + private function resolveFiles(InputInterface $input): array + { + $files = $input->getArgument('files'); - copy($source, $target); - $published++; - $output->writeln(sprintf('Published config: %s', $file)); + if (!is_array($files) || $files === [] || (bool) $input->getOption('all')) { + return ConfigInventory::files(); } - $output->writeln(sprintf('Published %d config file(s).', $published)); + return array_values(array_filter( + $files, + static fn(mixed $file): bool => is_string($file) && $file !== '', + )); + } - return 0; + private function resolvePhpprobePreset(InputInterface $input, OutputInterface $output): ?string + { + $preset = $this->stringOption($input->getOption('phpprobe-preset')); + + if ($preset === '') { + return ''; + } + + if (in_array($preset, self::PHPPROBE_PRESETS, true)) { + return $preset; + } + + $output->writeln(sprintf( + 'Invalid --phpprobe-preset "%s". Expected one of: %s.', + $preset, + implode(', ', self::PHPPROBE_PRESETS), + )); + + return null; + } + + private function stringOption(mixed $value): string + { + return is_string($value) ? $value : ''; } } diff --git a/src/Composer/TaskCatalog.php b/src/Composer/TaskCatalog.php index 0558a9f..7c4bdd7 100644 --- a/src/Composer/TaskCatalog.php +++ b/src/Composer/TaskCatalog.php @@ -67,7 +67,7 @@ public static function ci(bool $skipHeavyAnalysis = false): array */ public static function duplicates(): array { - return [[Paths::php(), Paths::bin('phpprobe'), 'duplicates', '--config', Paths::config('phpforge.json')]]; + return [[Paths::php(), Paths::bin('phpprobe'), 'duplicates', '--config', Paths::config('phpprobe.json')]]; } /** @@ -190,7 +190,7 @@ public static function staticAnalysis(): array */ public static function syntax(): array { - return [[Paths::php(), Paths::bin('phpprobe'), 'syntax', '--config', Paths::config('phpforge.json')]]; + return [[Paths::php(), Paths::bin('phpprobe'), 'syntax', '--config', Paths::config('phpprobe.json')]]; } /** diff --git a/src/Support/Cli.php b/src/Support/Cli.php index fd959e2..c594685 100644 --- a/src/Support/Cli.php +++ b/src/Support/Cli.php @@ -91,6 +91,6 @@ private function withDefaultProbeConfig(array $args): array } } - return ['--config', Paths::config('phpforge.json'), ...$args]; + return ['--config', Paths::config('phpprobe.json'), ...$args]; } } diff --git a/src/Support/ConfigInventory.php b/src/Support/ConfigInventory.php index 44b0f64..04f99b5 100644 --- a/src/Support/ConfigInventory.php +++ b/src/Support/ConfigInventory.php @@ -54,7 +54,7 @@ public static function tools(): array return [ 'pest' => ['pest.xml', 'phpunit.xml'], 'phpbench' => ['phpbench.json'], - 'phpforge' => ['phpforge.json'], + 'phpprobe' => ['phpprobe.json'], 'phpcs' => ['phpcs.xml.dist'], 'phpstan' => ['phpstan.neon.dist'], 'pint' => ['pint.json'], diff --git a/tests/Composer/PublishConfigCommandTest.php b/tests/Composer/PublishConfigCommandTest.php new file mode 100644 index 0000000..59198e8 --- /dev/null +++ b/tests/Composer/PublishConfigCommandTest.php @@ -0,0 +1,39 @@ +setAccessible(true); + $result = $method->invoke($command, $source, 'strict'); + + $decoded = is_string($result) ? json_decode($result, true) : null; + + expect(is_array($decoded))->toBeTrue(); + expect($decoded['duplicates']['mode'] ?? null)->toBe('audit'); + expect($decoded['duplicates']['near_miss'] ?? null)->toBeTrue(); + expect($decoded['duplicates']['min_lines'] ?? null)->toBe(4); + expect($decoded['duplicates']['min_tokens'] ?? null)->toBe(70); + expect($decoded['duplicates']['min_statements'] ?? null)->toBe(3); + expect($decoded['duplicates']['min_similarity'] ?? null)->toBe(0.80); +}); + +it('returns null when phpprobe config content is invalid json', function (): void { + $command = new PublishConfigCommand(); + $method = new ReflectionMethod(PublishConfigCommand::class, 'applyPhpprobePreset'); + $method->setAccessible(true); + $result = $method->invoke($command, '{broken', 'strict'); + + expect($result)->toBeNull(); +}); + +it('throws for unknown phpprobe preset definitions', function (): void { + $method = new ReflectionMethod(PublishConfigCommand::class, 'phpprobePreset'); + $method->setAccessible(true); + + expect(fn () => $method->invoke(null, 'nope'))->toThrow(InvalidArgumentException::class); +}); diff --git a/tests/Composer/TaskCatalogTest.php b/tests/Composer/TaskCatalogTest.php index f0f3dfe..c2d1a75 100644 --- a/tests/Composer/TaskCatalogTest.php +++ b/tests/Composer/TaskCatalogTest.php @@ -54,7 +54,7 @@ function removeTaskCatalogTree(string $path): void expect(basename(str_replace('\\', '/', $command[1])))->toBe('phpprobe') ->and($command)->toContain('duplicates') ->and(TaskCatalog::duplicates()[0])->toContain('--config') - ->and(TaskCatalog::duplicates()[0])->toContain(Paths::packageFile('resources/phpforge.json')) + ->and(TaskCatalog::duplicates()[0])->toContain(Paths::packageFile('resources/phpprobe.json')) ->and(TaskCatalog::duplicates()[0])->not()->toContain('tests'); }); @@ -64,7 +64,7 @@ function removeTaskCatalogTree(string $path): void expect(basename(str_replace('\\', '/', $command[1])))->toBe('phpprobe') ->and($command)->toContain('syntax') ->and(TaskCatalog::syntax()[0])->toContain('--config') - ->and(TaskCatalog::syntax()[0])->toContain(Paths::packageFile('resources/phpforge.json')); + ->and(TaskCatalog::syntax()[0])->toContain(Paths::packageFile('resources/phpprobe.json')); }); it('runs architecture checks with the bundled deptrac config', function (): void { diff --git a/tests/Support/ConfigInventoryTest.php b/tests/Support/ConfigInventoryTest.php index 3ac1e84..26a5a7e 100644 --- a/tests/Support/ConfigInventoryTest.php +++ b/tests/Support/ConfigInventoryTest.php @@ -4,11 +4,35 @@ use Infocyph\PHPForge\Support\ConfigInventory; +function removeConfigInventoryTree(string $path): void +{ + if (!is_dir($path)) { + return; + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST, + ); + + foreach ($iterator as $item) { + if ($item->isDir()) { + rmdir($item->getPathname()); + + continue; + } + + unlink($item->getPathname()); + } + + rmdir($path); +} + it('lists bundled config files without duplicates', function (): void { expect(ConfigInventory::files()) ->toContain('pest.xml') ->toContain('phpunit.xml') - ->toContain('phpforge.json') + ->toContain('phpprobe.json') ->toContain('pint.json') ->toContain('phpstan.neon.dist') ->toContain('psalm.xml') @@ -38,3 +62,24 @@ expect(ConfigInventory::source('pint.json'))->toBe('phpforge'); expect(ConfigInventory::source('missing-tool.xml'))->toBe('missing'); }); + +it('treats phpprobe.json as the project source when present', function (): void { + $originalCwd = getcwd(); + $projectRoot = sys_get_temp_dir().DIRECTORY_SEPARATOR.'phpforge-config-inventory-'.uniqid('', true); + + mkdir($projectRoot, 0755, true); + file_put_contents($projectRoot.DIRECTORY_SEPARATOR.'phpprobe.json', '{}'); + + chdir($projectRoot); + + try { + expect(ConfigInventory::source('phpprobe.json'))->toBe('project'); + expect(ConfigInventory::resolvedPath('phpprobe.json'))->toBe($projectRoot.DIRECTORY_SEPARATOR.'phpprobe.json'); + } finally { + if (is_string($originalCwd)) { + chdir($originalCwd); + } + + removeConfigInventoryTree($projectRoot); + } +}); diff --git a/tests/Support/PathsTest.php b/tests/Support/PathsTest.php index 1713e96..c56af0c 100644 --- a/tests/Support/PathsTest.php +++ b/tests/Support/PathsTest.php @@ -34,8 +34,8 @@ function removePathsTestTree(string $path): void }); it('falls back to bundled PHPProbe checker config', function (): void { - expect(Paths::config('phpforge.json')) - ->toBe(Paths::packageFile('resources/phpforge.json')); + expect(Paths::config('phpprobe.json')) + ->toBe(Paths::packageFile('resources/phpprobe.json')); }); it('uses bundled config when project config from list is missing', function (): void {