From b3ccff9bdd5aab7cda987bda801bc7ec236e983f Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 6 May 2026 13:16:14 +0600 Subject: [PATCH 1/6] comment detect --- .gitattributes | 10 + .github/workflows/ci.yml | 110 +++ .gitignore | 29 +- README.md | 145 +++- bin/phpprobe | 6 + composer.json | 13 +- resources/.phpprobe-duplicates-baseline.json | 36 + src/ApiSnapshotChecker.php | 84 +- src/Comment/CommentFinding.php | 58 ++ src/Comment/CommentScanner.php | 779 +++++++++++++++++++ src/Comment/PhpCommentExtractor.php | 51 ++ src/CommentChecker.php | 428 ++++++++++ src/Config/PhpProbeConfig.php | 201 +++++ src/Console/Ansi.php | 66 ++ src/Console/Cli.php | 4 +- src/Detection/DuplicateDetectionEngine.php | 114 ++- src/DuplicateChecker.php | 144 +++- src/SyntaxChecker.php | 187 ++--- src/Util/ProjectPath.php | 26 + tests/Feature/ApiSnapshotCheckerTest.php | 50 ++ tests/Feature/CommentCheckerTest.php | 290 +++++++ tests/Feature/DuplicateCheckerTest.php | 52 ++ tests/Feature/SyntaxCheckerTest.php | 13 + 23 files changed, 2650 insertions(+), 246 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 resources/.phpprobe-duplicates-baseline.json create mode 100644 src/Comment/CommentFinding.php create mode 100644 src/Comment/CommentScanner.php create mode 100644 src/Comment/PhpCommentExtractor.php create mode 100644 src/CommentChecker.php create mode 100644 src/Console/Ansi.php create mode 100644 src/Util/ProjectPath.php create mode 100644 tests/Feature/CommentCheckerTest.php diff --git a/.gitattributes b/.gitattributes index 4c46e99..2755315 100644 --- a/.gitattributes +++ b/.gitattributes @@ -8,4 +8,14 @@ tests export-ignore .gitattributes export-ignore .gitignore export-ignore .readthedocs.yaml export-ignore +captainhook.json export-ignore +pest.xml export-ignore +phpbench.json export-ignore +phpcs.xml.dist export-ignore +phpstan.neon.dist export-ignore +phpunit.xml export-ignore +pint.json export-ignore +psalm.xml export-ignore +rector.php export-ignore + * text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ea01fae --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,110 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + workflow_dispatch: + inputs: + phpforge_ref: + description: "PHPForge git ref (branch, tag, or SHA)" + required: false + default: "main" + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + phpprobe: + name: PHPProbe (PHP ${{ matrix.php }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ["8.2", "8.3", "8.4", "8.5"] + + steps: + - name: Checkout PHPProbe + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + tools: composer:v2 + + - name: Validate composer.json + run: composer validate --strict + + - name: Install dependencies + run: composer install --prefer-dist --no-interaction --no-progress + + - name: Run tests + run: composer test + + - name: Run syntax checker + run: composer lint + + - name: Run duplicate checker + run: composer duplicates + + - name: Run API checker + run: composer api + + - name: Run comment checker + run: composer comments + + phpforge-integration: + name: PHPForge Integration + runs-on: ubuntu-latest + needs: phpprobe + env: + PHPFORGE_REPOSITORY: infocyph/phpforge + PHPFORGE_REF: ${{ github.event.inputs.phpforge_ref || 'main' }} + + steps: + - name: Checkout PHPProbe + uses: actions/checkout@v4 + with: + path: phpprobe + + - name: Checkout PHPForge + uses: actions/checkout@v4 + with: + repository: ${{ env.PHPFORGE_REPOSITORY }} + ref: ${{ env.PHPFORGE_REF }} + path: phpforge + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.2" + coverage: none + tools: composer:v2 + + - name: Configure PHPForge to use local PHPProbe + working-directory: phpforge + run: | + composer config repositories.local-phpprobe '{"type":"path","url":"../phpprobe","options":{"symlink":true}}' + composer require --dev infocyph/phpprobe:@dev --no-update --no-interaction + + - name: Install PHPForge dependencies + working-directory: phpforge + run: composer update --prefer-dist --no-interaction --no-progress + + - name: Run PHPForge tests + working-directory: phpforge + run: | + if composer run --list | grep -qE '^\s+test\s'; then + composer test + elif [ -x vendor/bin/pest ]; then + vendor/bin/pest -c pest.xml + elif [ -x vendor/bin/phpunit ]; then + vendor/bin/phpunit + else + echo "No supported PHPForge test command found." >&2 + exit 1 + fi diff --git a/.gitignore b/.gitignore index 95bcad9..39a1b07 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,21 @@ -/vendor/ -/.phpunit.cache/ -/.pest.cache/ -/coverage/ -/build/ -/dist/ -/tmp/ -/var/ .DS_Store Thumbs.db -/.idea/ - +composer.lock +.idea +.psalm-cache +.phpunit.cache +.vscode +.windsurf +.codex +*~ +*.patch +*.txt +!docs/requirements.txt +example +example.php +git-story_media +patch.php +test.php +var +vendor +d2utmp* diff --git a/README.md b/README.md index 2ba8a3c..20fa78f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # PHPProbe -Standalone PHP checker for syntax validation, duplicate-code detection, and public API snapshot checks. +Standalone PHP checker for syntax validation, duplicate-code detection, public API snapshot checks and comment policy checks. PHPProbe is the checker runtime. It can be used directly as `phpprobe`, required by tool-combiner packages such as PHPForge, or called from PHP code through the public gateway classes. @@ -27,11 +27,13 @@ php vendor/bin/phpprobe php vendor/bin/phpprobe syntax [options] [paths...] php vendor/bin/phpprobe duplicates [options] [paths...] php vendor/bin/phpprobe api [options] [paths...] +php vendor/bin/phpprobe comments [options] [paths...] php vendor/bin/phpprobe presets php vendor/bin/phpprobe preset ``` Unknown commands print the top-level usage and exit `0`. There is no separate `--version` command. +For checker subcommands (`syntax`, `duplicates`, `api`, `comments`), unknown options fail with exit `2`. ## Quick Start @@ -42,6 +44,8 @@ php vendor/bin/phpprobe duplicates --json php vendor/bin/phpprobe duplicates --preset=strict --json src php vendor/bin/phpprobe api --write-baseline=.phpprobe-api-baseline.json src php vendor/bin/phpprobe api --baseline=.phpprobe-api-baseline.json src +php vendor/bin/phpprobe comments --fail-on=warning src +php vendor/bin/phpprobe comments --strict --json src php vendor/bin/phpprobe presets php vendor/bin/phpprobe preset phpstorm ``` @@ -53,6 +57,7 @@ The package-facing checker gateways live directly under `src/`: - `Infocyph\PHPProbe\SyntaxChecker` - `Infocyph\PHPProbe\DuplicateChecker` - `Infocyph\PHPProbe\ApiSnapshotChecker` +- `Infocyph\PHPProbe\CommentChecker` All expose: @@ -64,12 +69,14 @@ public function run(array $args): int ```php use Infocyph\PHPProbe\ApiSnapshotChecker; +use Infocyph\PHPProbe\CommentChecker; use Infocyph\PHPProbe\DuplicateChecker; use Infocyph\PHPProbe\SyntaxChecker; $syntaxCode = (new SyntaxChecker())->run(['--config=phpprobe.json', 'src']); $duplicateCode = (new DuplicateChecker())->run(['--preset=strict', '--json', 'src']); $apiCode = (new ApiSnapshotChecker())->run(['--baseline=.phpprobe-api-baseline.json', 'src']); +$commentCode = (new CommentChecker())->run(['--strict', '--fail-on=warning', 'src']); ``` Everything else is internal implementation detail, grouped by role: @@ -78,8 +85,8 @@ Everything else is internal implementation detail, grouped by role: | --- | --- | | `Api` | Public API snapshot extraction from parser ASTs. | | `Console` | CLI dispatch for `bin/phpprobe`. | -| `Config` | Config lookup, preset lookup, JSON parsing, config merging, and shared CLI option handling. | -| `Detection` | Duplicate-code token indexing, AST block indexing, scoring, grouping, and pruning. | +| `Config` | Config lookup, preset lookup, JSON parsing, config merging and shared CLI option handling. | +| `Detection` | Duplicate-code token indexing, AST block indexing, scoring, grouping and pruning. | | `Filesystem` | Git-aware PHP file discovery and path exclusion. | | `Process` | Small `proc_open` runner wrappers. | | `Util` | Narrow shared helpers. | @@ -150,11 +157,11 @@ A full project config may override any part of the selected preset: } ``` -Config keys accept snake case, kebab case, and camel case. For example, `min_tokens`, `min-tokens`, and `minTokens` are equivalent. Excludes can be configured as either `exclude` or `exclude_paths`. +Config keys accept snake case, kebab case and camel case. For example, `min_tokens`, `min-tokens` and `minTokens` are equivalent. Excludes can be configured as either `exclude` or `exclude_paths`. -Internal duplicate defaults, before the bundled `phpstorm` config is applied, are `mode=gate`, `normalize=true`, `fuzzy=false`, `near_miss=false`, `min_lines=5`, `min_tokens=70`, `min_statements=4`, `min_similarity=0.85`, no baseline, no JSON output, and no configured paths or excludes. +Internal duplicate defaults, before the bundled `phpstorm` config is applied, are `mode=gate`, `normalize=true`, `fuzzy=false`, `near_miss=false`, `min_lines=5`, `min_tokens=70`, `min_statements=4`, `min_similarity=0.85`, no baseline, no JSON output and no configured paths or excludes. -Internal API defaults are `include_protected=true`, no baseline, no JSON output, and no configured paths or excludes. +Internal API defaults are `include_protected=true`, no baseline, no JSON output and no configured paths or excludes. Config merge order is: @@ -178,7 +185,7 @@ Available presets: | `standard` | Quieter CI gate. `gate` mode, normalized tokens, fuzzy identifiers, no near-miss matching, `min_lines=6`, `min_tokens=100`, `min_statements=5`, `min_similarity=0.9`. | Includes protected members. | | `strict` | Sensitive audit. `audit` mode, normalized tokens, fuzzy identifiers, near-miss matching, `min_lines=4`, `min_tokens=70`, `min_statements=3`, `min_similarity=0.8`. | Includes protected members. | -All presets include the same default syntax, duplicate, and API excludes: +All presets include the same default syntax, duplicate and API excludes: ```text tests, vendor, node_modules, .git, .idea, .vscode, coverage, @@ -234,11 +241,67 @@ Output and exits: | No PHP files found | `stdout`: `No PHP files found.` | `0` | | All files pass | `stdout`: `Syntax OK: N PHP files checked.` | `0` | | One or more files fail | `stderr`: failing file list plus lint output | `1` | +| Unknown option or runtime config error | `stderr`: error | `2` | | Unknown preset | `stderr`: preset error | `2` | +## Comment Policy Checker + +The comment checker scans PHP comments using `token_get_all()` and reports marker tags and commented-out code policy findings. + +Command: + +```bash +php vendor/bin/phpprobe comments [options] [paths...] +``` + +Options: + +| Option | Form | Meaning | +| --- | --- | --- | +| `--config` | `--config=FILE` or `--config FILE` | Read checker settings from a specific config file. | +| `--preset` | `--preset=NAME` or `--preset NAME` | Apply `phpstorm`, `standard`, or `strict` as a run-level preset. | +| `--exclude` | `--exclude=PATH` or `--exclude PATH` | Exclude a path. Repeatable. | +| `--json` | flag | Emit machine-readable JSON to `stdout`. | +| `--strict` | flag | Escalate commented-out-code policy severities. | +| `--fail-on` | `--fail-on=error|warning|info` | Control failure threshold (default: `error`). | +| `--tags` | `--tags=TODO,FIXME,...` | Override marker tags for marker detection. | +| `--help`, `-h` | flag | Print comments checker help and exit `0`. | + +### Four enforced policies + +1. Marker detection: tags like `TODO`, `FIXME`, `BUG`, `HACK`, `SECURITY`, `REVIEW`, `DEPRECATED`. +2. Commented-out code requires directly attached tagged reason. +3. Long commented-out blocks require an issue reference. +4. Oversized commented-out blocks are always reported. + +Default thresholds: + +- `min_reason_length = 12` +- `require_issue_for_blocks_longer_than = 3` +- `max_allowed_block_lines = 10` + +Policy-to-finding mapping: + +| Policy | Finding types | +| --- | --- | +| Marker detection | `comment_marker` | +| Tagged reason required for commented-out code | `commented_out_code_without_reason`, `commented_out_code_without_valid_tag`, `commented_out_code_without_valid_reason`, `commented_out_code_with_weak_reason` | +| Issue reference required for long blocks | `commented_out_code_requires_issue_reference` | +| Oversized block disallowed | `commented_out_code_block_too_large` | +| PHPDoc code without clear example label | `commented_out_code_in_phpdoc_without_example_label` | +| Explicitly valid tagged reason (informational) | `commented_out_code_with_valid_reason` | + +Output and exits: + +| Condition | Stream | Exit | +| --- | --- | --- | +| No failing findings at threshold | `stdout`: summary (or JSON) | `0` | +| Findings at or above threshold | `stderr`: text report (or JSON on `stdout`) | `1` | +| Unknown option or runtime config error | `stderr`: error | `2` | + ## Public API Snapshot Checker -The API checker parses PHP files with `nikic/php-parser`, extracts the package-visible surface, and can compare it with a saved snapshot. It is intended for library BC drift checks, not type analysis. +The API checker parses PHP files with `nikic/php-parser`, extracts the package-visible surface and can compare it with a saved snapshot. It is intended for library BC drift checks, not type analysis. Command: @@ -269,12 +332,12 @@ Path behavior: Snapshot contents: -- named classes, interfaces, traits, and enums +- named classes, interfaces, traits and enums - top-level namespaced functions - top-level namespaced constants - public members always - protected members unless `--public-only` is used -- class modifiers, inheritance, implemented interfaces, method signatures, property signatures, constants, enum cases, function signatures, and stable fingerprints +- class modifiers, inheritance, implemented interfaces, method signatures, property signatures, constants, enum cases, function signatures and stable fingerprints Output and exits: @@ -285,11 +348,12 @@ Output and exits: | Baseline differs | `stderr`: added/removed/changed symbol list | `1` | | `--json` | `stdout`: JSON result | `0` or `1`, depending on drift | | `--write-baseline` | `stdout`: baseline message or JSON result | `0` | +| Unknown option or runtime config/baseline error | `stderr`: error | `2` | | Unknown preset | `stderr`: preset error | `2` | ## Duplicate Checker -The duplicate checker combines token fingerprints, AST block structure, statement windows, near-miss similarity, grouping, pruning, ranking, and optional baseline suppression. +The duplicate checker combines token fingerprints, AST block structure, statement windows, near-miss similarity, grouping, pruning, ranking and optional baseline suppression. Command: @@ -318,7 +382,7 @@ Options: | `--json` | flag | Emit machine-readable JSON to `stdout`. | | `--help`, `-h` | flag | Print duplicate checker help and exit `0`. | -Exact accepted forms matter: numeric options, `--mode`, `--baseline`, and valued `--write-baseline=FILE` are parsed in equals form. `--config`, `--preset`, and `--exclude` also accept split form. `--write-baseline` may also be passed as a bare flag. +Exact accepted forms matter: numeric options, `--mode`, `--baseline` and valued `--write-baseline=FILE` are parsed in equals form. `--config`, `--preset` and `--exclude` also accept split form. `--write-baseline` may also be passed as a bare flag. Path behavior: @@ -330,7 +394,7 @@ Path behavior: Mode behavior: - `gate`: token-window duplicate detection only, unless `--near-miss` is explicitly passed. -- `audit`: token-window matching plus statement-window matching, and near-miss matching is enabled automatically. +- `audit`: token-window matching plus statement-window matching and near-miss matching is enabled automatically. Output and exits: @@ -341,6 +405,7 @@ Output and exits: | `--json` with no clones | `stdout`: JSON result | `0` | | `--json` with clones | `stdout`: JSON result | `1` | | `--write-baseline` | `stdout`: baseline message or JSON result | `0` | +| Unknown option or runtime config/baseline error | `stderr`: error | `2` | | Unknown preset | `stderr`: preset error | `2` | ## Duplicate Detection Details @@ -350,11 +415,11 @@ File discovery: - PHPProbe first tries `git ls-files -z --cached --others --exclude-standard`. - It filters discovered PHP files with `git check-ignore -z --stdin --no-index`. - If Git discovery is unavailable, it recursively scans the selected paths. -- Recursive fallback skips common infrastructure directories such as `.git`, `.idea`, `.phpunit.cache`, `.psalm-cache`, `.vscode`, `coverage`, `node_modules`, and `vendor`. +- Recursive fallback skips common infrastructure directories such as `.git`, `.idea`, `.phpunit.cache`, `.psalm-cache`, `.vscode`, `coverage`, `node_modules` and `vendor`. Token normalization: -- Whitespace, comments, doc comments, PHP open tags, and close tags are ignored. +- Whitespace, comments, doc comments, PHP open tags and close tags are ignored. - With `normalize=true`, variables become `VAR`, numbers become `NUM`, strings become `STR`. - With `fuzzy=true`, identifiers and names become `ID`. - With `--exact`, token values include token names and original text. @@ -370,7 +435,7 @@ Token clones: AST and statement matching: - PHPProbe uses `nikic/php-parser` to index structural blocks. -- Indexed blocks include functions, methods, closures, arrow functions, loops, branches, match arms, and try/catch/finally blocks. +- Indexed blocks include functions, methods, closures, arrow functions, loops, branches, match arms and try/catch/finally blocks. - Statement hashes are built from AST shape. - In `audit` mode, matching statement windows of `min_statements` statements are reported as statement clones. @@ -381,12 +446,12 @@ Near-miss matching: - Similarity is based on longest-common-subsequence ratio. - Matches below `min_similarity` are ignored. -Grouping, pruning, and scoring: +Grouping, pruning and scoring: - Duplicate pairs are grouped into clone families. - Contained/weaker clones are pruned. -- Results are ranked by score, line span, and similarity. -- Scoring rewards larger clones, more occurrences, higher similarity, structural completeness, and near-miss signal; small trivial clones are penalized. +- Results are ranked by score, line span and similarity. +- Scoring rewards larger clones, more occurrences, higher similarity, structural completeness and near-miss signal; small trivial clones are penalized. ## Duplicate JSON Result Shape @@ -488,6 +553,8 @@ php vendor/bin/phpprobe duplicates --baseline=.phpprobe-duplicates-baseline.json php vendor/bin/phpprobe api --baseline=.phpprobe-api-baseline.json ``` +This repository uses `resources/.phpprobe-duplicates-baseline.json` in `composer duplicates` so CI fails only on newly introduced clone groups. + Duplicate baseline files contain: ```json @@ -504,7 +571,39 @@ Duplicate baseline files contain: } ``` -API baseline files use the same top-level `version`, `generated_at`, and `symbols` shape emitted under the `snapshot` JSON key. When a baseline file is missing or unreadable, PHPProbe treats it as empty. +API baseline files use the same top-level `version`, `generated_at` and `symbols` shape emitted under the `snapshot` JSON key. Missing, unreadable, or invalid baseline files now fail with exit code `2`. +Duplicate baseline files follow the same strict behavior: missing, unreadable, or invalid baselines fail with exit code `2`. + +## Colored Output + +Checker text output is colorized on interactive terminals: + +- green: successful summaries +- yellow: warning/medium severity lines +- red: error/high/critical summaries +- cyan: baseline write notifications + +Color output is automatically disabled for non-TTY streams and when `NO_COLOR` is set (or `TERM=dumb`), so CI logs and JSON output stay clean. + +## CI / Cloud + +Workflow: [.github/workflows/ci.yml](.github/workflows/ci.yml) + +CI runs: + +1. PHPProbe matrix on PHP `8.2`, `8.3`, `8.4`, `8.5`: + - `composer validate --strict` + - `composer test` + - `composer lint` + - `composer duplicates` + - `composer api` + - `composer comments` +2. PHPForge integration: + - checks out `infocyph/phpforge` + - injects local `phpprobe` via Composer `path` repository + - runs PHPForge tests + +`workflow_dispatch` supports `phpforge_ref` to test a specific PHPForge branch/tag/SHA. ## Development @@ -514,8 +613,9 @@ Composer scripts: | --- | --- | | `composer test` | `vendor/bin/pest -c pest.xml` | | `composer lint` | `php bin/phpprobe syntax src tests` | -| `composer duplicates` | `php bin/phpprobe duplicates --preset=standard --config=resources/phpprobe.json src tests` | +| `composer duplicates` | `php bin/phpprobe duplicates --preset=standard --config=resources/phpprobe.json --baseline=resources/.phpprobe-duplicates-baseline.json src tests` | | `composer api` | `php bin/phpprobe api --config=resources/phpprobe.json src tests` | +| `composer comments` | `php bin/phpprobe comments --config=resources/phpprobe.json src tests` | Useful local checks: @@ -525,7 +625,6 @@ composer test composer lint composer duplicates composer api +composer comments git diff --check ``` - -The repository does not need a committed `composer.lock`; it is a library-style tool package, so dev dependencies can resolve for the active PHP version. diff --git a/bin/phpprobe b/bin/phpprobe index c343e56..4de5206 100644 --- a/bin/phpprobe +++ b/bin/phpprobe @@ -20,6 +20,8 @@ foreach ($autoloaders as $autoloader) { if (!class_exists(Cli::class)) { require __DIR__ . '/../src/Util/ArrayShape.php'; + require __DIR__ . '/../src/Util/ProjectPath.php'; + require __DIR__ . '/../src/Console/Ansi.php'; require __DIR__ . '/../src/Process/ProcessResult.php'; require __DIR__ . '/../src/Process/ProcRunner.php'; require __DIR__ . '/../src/Config/Paths.php'; @@ -27,6 +29,9 @@ if (!class_exists(Cli::class)) { require __DIR__ . '/../src/Config/CliOptions.php'; require __DIR__ . '/../src/Config/PresetRepository.php'; require __DIR__ . '/../src/Filesystem/PhpFileFinder.php'; + require __DIR__ . '/../src/Comment/CommentFinding.php'; + require __DIR__ . '/../src/Comment/PhpCommentExtractor.php'; + require __DIR__ . '/../src/Comment/CommentScanner.php'; require __DIR__ . '/../src/Api/ApiSnapshotIndex.php'; require __DIR__ . '/../src/Detection/DuplicateAstBlockIndex.php'; require __DIR__ . '/../src/Detection/DuplicateCloneReducer.php'; @@ -34,6 +39,7 @@ if (!class_exists(Cli::class)) { require __DIR__ . '/../src/Detection/DuplicateDetectionEngine.php'; require __DIR__ . '/../src/DuplicateChecker.php'; require __DIR__ . '/../src/ApiSnapshotChecker.php'; + require __DIR__ . '/../src/CommentChecker.php'; require __DIR__ . '/../src/SyntaxChecker.php'; require __DIR__ . '/../src/Console/Cli.php'; } diff --git a/composer.json b/composer.json index 84f499c..3f117d9 100644 --- a/composer.json +++ b/composer.json @@ -44,10 +44,11 @@ "optimize-autoloader": true, "sort-packages": true }, - "scripts": { - "test": "vendor/bin/pest -c pest.xml", - "lint": "@php bin/phpprobe syntax src tests", - "duplicates": "@php bin/phpprobe duplicates --preset=standard --config=resources/phpprobe.json src tests", - "api": "@php bin/phpprobe api --config=resources/phpprobe.json src tests" + "scripts": { + "test": "vendor/bin/pest -c pest.xml", + "lint": "@php bin/phpprobe syntax src tests", + "duplicates": "@php bin/phpprobe duplicates --preset=standard --config=resources/phpprobe.json --baseline=resources/.phpprobe-duplicates-baseline.json src tests", + "api": "@php bin/phpprobe api --config=resources/phpprobe.json src tests", + "comments": "@php bin/phpprobe comments --config=resources/phpprobe.json src tests" + } } -} diff --git a/resources/.phpprobe-duplicates-baseline.json b/resources/.phpprobe-duplicates-baseline.json new file mode 100644 index 0000000..1ccf747 --- /dev/null +++ b/resources/.phpprobe-duplicates-baseline.json @@ -0,0 +1,36 @@ +{ + "version": 1, + "generated_at": "2026-05-06T07:12:18+00:00", + "clones": [ + { + "fingerprint": "84cc019f15701ac134fd8026b7f6715b21317758be3b29d502b5840479e5bc9a", + "source": "tokens", + "score": 222.4 + }, + { + "fingerprint": "d19a26ec1dc7b405759627c036003d71f0fd0780e5ba4ac917ccb18149fa1374", + "source": "tokens", + "score": 215.4 + }, + { + "fingerprint": "e7e18beed9abb811f1a43e463f6adc8347e40e49b87e9f7373adf6c44ce39e74", + "source": "tokens", + "score": 172.6 + }, + { + "fingerprint": "c7b509af41b9ce03b9ae533ca1ada7e447e7fc7e6551b0434383aa00376eab1e", + "source": "tokens", + "score": 158.6 + }, + { + "fingerprint": "baabba091d3d58d65256e865b3033551632ad147f9e1d7b296e9787ed1f2d379", + "source": "tokens", + "score": 155.8 + }, + { + "fingerprint": "397f1a223697f40e31f1b81ec38a9dd0620f0a046a685e568f1d304793be7adc", + "source": "tokens", + "score": 153 + } + ] +} diff --git a/src/ApiSnapshotChecker.php b/src/ApiSnapshotChecker.php index 24b0bba..2a8ad85 100644 --- a/src/ApiSnapshotChecker.php +++ b/src/ApiSnapshotChecker.php @@ -6,6 +6,7 @@ use Infocyph\PHPProbe\Api\ApiSnapshotIndex; use Infocyph\PHPProbe\Config\CliOptions; +use Infocyph\PHPProbe\Console\Ansi; use Infocyph\PHPProbe\Config\Paths; use Infocyph\PHPProbe\Config\PhpProbeConfig; use Infocyph\PHPProbe\Filesystem\PhpFileFinder; @@ -26,7 +27,7 @@ public function run(array $args): int { try { return $this->runWithOptions($this->parseArgs($args)); - } catch (\InvalidArgumentException $exception) { + } catch (\InvalidArgumentException|\RuntimeException $exception) { fwrite(STDERR, $exception->getMessage() . PHP_EOL); return 2; @@ -114,14 +115,35 @@ private function keyedSymbols(array $snapshot): array */ private function loadBaseline(string $path): array { - if ($path === '' || !is_file($path) || !is_readable($path)) { + if ($path === '') { return ['version' => 1, 'generated_at' => '', 'symbols' => []]; } - $decoded = json_decode((string) file_get_contents($path), true); + if (!is_file($path)) { + throw new \RuntimeException(sprintf('API baseline file not found: %s', $path)); + } + + if (!is_readable($path)) { + throw new \RuntimeException(sprintf('API baseline file is not readable: %s', $path)); + } + + $contents = file_get_contents($path); + + if (!is_string($contents)) { + throw new \RuntimeException(sprintf('Failed to read API baseline file: %s', $path)); + } + + try { + $decoded = json_decode($contents, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException $exception) { + throw new \RuntimeException( + sprintf('Invalid API baseline JSON at %s: %s', $path, $exception->getMessage()), + previous: $exception, + ); + } if (!is_array($decoded)) { - return ['version' => 1, 'generated_at' => '', 'symbols' => []]; + throw new \RuntimeException(sprintf('API baseline payload must be a JSON object: %s', $path)); } $symbols = is_array($decoded['symbols'] ?? null) ? array_values(array_filter( @@ -148,6 +170,7 @@ private function parseArgs(array $args): array $options = $this->cli->mergeConfigWithPreset($config, $this->cli->presetName($args))->applyApiOptions($options); $configuredPaths = $options['paths']; $options['paths'] = []; + $collectingPathsOnly = false; $index = 0; $argCount = count($args); @@ -155,9 +178,27 @@ private function parseArgs(array $args): array while ($index < $argCount) { $arg = $args[$index]; + if ($collectingPathsOnly) { + $options['paths'][] = $arg; + $index++; + + continue; + } + + if ($arg === '--') { + $collectingPathsOnly = true; + $index++; + + continue; + } + if (!$this->cli->skipConfig($args, $index, $arg) && !$this->cli->skipPreset($args, $index, $arg) && !$this->parseCliOption($args, $index, $options, $arg)) { + if (str_starts_with($arg, '-')) { + throw new \InvalidArgumentException(sprintf('Unknown option for api command: %s', $arg)); + } + $options['paths'][] = $arg; } @@ -252,7 +293,18 @@ private function result(array $snapshot, string $baselinePath): array private function writeBaseline(array $snapshot, string $path): void { - file_put_contents($path, json_encode($snapshot, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL); + try { + $encoded = json_encode($snapshot, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . PHP_EOL; + } catch (\JsonException $exception) { + throw new \RuntimeException( + sprintf('Could not encode API baseline JSON for %s: %s', $path, $exception->getMessage()), + previous: $exception, + ); + } + + if (file_put_contents($path, $encoded) === false) { + throw new \RuntimeException(sprintf('Failed to write API baseline file: %s', $path)); + } } /** @@ -268,28 +320,38 @@ private function writeResult(array $result, array $options): void } if ($options['writeBaseline'] !== '') { - fwrite(STDOUT, sprintf('Public API baseline written: %s', $options['writeBaseline']) . PHP_EOL); + fwrite(STDOUT, Ansi::color(sprintf('Public API baseline written: %s', $options['writeBaseline']), 'cyan', STDOUT) . PHP_EOL); } $symbolCount = count($result['snapshot']['symbols'] ?? []); if ($options['baseline'] === '') { - fwrite(STDOUT, sprintf('Public API snapshot OK: %d symbol(s) scanned.', $symbolCount) . PHP_EOL); + fwrite(STDOUT, Ansi::color(sprintf('Public API snapshot OK: %d symbol(s) scanned.', $symbolCount), 'green', STDOUT) . PHP_EOL); return; } if (!$result['changed']) { - fwrite(STDOUT, sprintf('Public API unchanged: %d symbol(s) scanned.', $symbolCount) . PHP_EOL); + fwrite(STDOUT, Ansi::color(sprintf('Public API unchanged: %d symbol(s) scanned.', $symbolCount), 'green', STDOUT) . PHP_EOL); return; } - fwrite(STDERR, 'Public API snapshot changed:' . PHP_EOL); + fwrite(STDERR, Ansi::color('Public API snapshot changed:', 'red', STDERR) . PHP_EOL); + + $labels = ['added' => 'Added', 'removed' => 'Removed', 'changed' => 'Changed']; foreach (['added', 'removed', 'changed'] as $type) { - foreach ($result['changes'][$type] as $symbol) { - fwrite(STDERR, sprintf(' - %s: %s', $type, $symbol) . PHP_EOL); + $items = $result['changes'][$type]; + + if ($items === []) { + continue; + } + + fwrite(STDERR, sprintf(' %s (%d)', $labels[$type], count($items)) . PHP_EOL); + + foreach ($items as $symbol) { + fwrite(STDERR, sprintf(' - %s', $symbol) . PHP_EOL); } } } diff --git a/src/Comment/CommentFinding.php b/src/Comment/CommentFinding.php new file mode 100644 index 0000000..6ac7faf --- /dev/null +++ b/src/Comment/CommentFinding.php @@ -0,0 +1,58 @@ + $this->file, + 'line' => $this->line, + 'end_line' => $this->endLine, + 'type' => $this->type, + 'severity' => $this->severity, + 'message' => $this->message, + 'tag' => $this->tag, + 'scope' => $this->scope, + 'issue' => $this->issue, + 'owner' => $this->owner, + 'reason' => $this->reason, + 'raw' => $this->raw, + ]; + } +} + diff --git a/src/Comment/CommentScanner.php b/src/Comment/CommentScanner.php new file mode 100644 index 0000000..6597076 --- /dev/null +++ b/src/Comment/CommentScanner.php @@ -0,0 +1,779 @@ + $files + * @param array{ + * scanMarkers:bool, + * markerTags:list, + * markerSeverity:array, + * commentedOutEnabled:bool, + * allowedReasonTags:list, + * optionalReasonTags:list, + * allowOptionalReasonTagsInStrictMode:bool, + * minReasonLength:int, + * maxAllowedBlockLines:int, + * requireIssueForBlocksLongerThan:int, + * allowedIssuePatterns:list, + * allowBlankLineBetweenReasonAndCode:bool, + * allowReasonBeforeBlockComment:bool, + * allowBlankLineBetweenReasonAndCodeInBlock:bool, + * allowPhpdocExamples:bool, + * phpdocExampleLabels:list, + * strict:bool, + * typeSeverity:array, + * strictSeverity:array + * } $options + * @return array{files:int,findings:list} + */ + public function scan(array $files, array $options): array + { + $findings = []; + + foreach ($files as $file) { + $comments = (new PhpCommentExtractor())->extract($file); + $findings = [...$findings, ...$this->scanMarkers($file, $comments, $options), ...$this->scanCommentedOutCode($file, $comments, $options)]; + } + + usort( + $findings, + static fn(CommentFinding $left, CommentFinding $right): int => [$left->file, $left->line, $left->endLine, $left->type] <=> [$right->file, $right->line, $right->endLine, $right->type], + ); + + return ['files' => count($files), 'findings' => $findings]; + } + + /** + * @param array{type:string,raw:string,line:int,end_line:int} $comment + * @return list + */ + private function commentLines(array $comment): array + { + if ($comment['type'] === 'line_comment') { + return [[ + 'text' => $this->normalizeLineComment($comment['raw']), + 'line' => $comment['line'], + ]]; + } + + return $this->normalizeBlockCommentLines($comment['raw'], $comment['line']); + } + + private function detectReasonStatus( + string $file, + int $startLine, + int $endLine, + int $codeLines, + ?array $reasonCandidate, + bool $isPhpDoc, + bool $hasExampleLabel, + array $options, + ): ?CommentFinding { + if ($reasonCandidate === null) { + if ($isPhpDoc && !$hasExampleLabel) { + return $this->finding( + $file, + $startLine, + $endLine, + 'commented_out_code_in_phpdoc_without_example_label', + 'PHPDoc contains code-like lines without an example label or a tagged reason.', + $options, + ); + } + + return $this->finding( + $file, + $startLine, + $endLine, + 'commented_out_code_without_reason', + 'Commented-out code requires a directly attached tagged reason.', + $options, + ); + } + + $parsed = $this->parseMarker($reasonCandidate['text'], $this->allKnownTags($options)); + + if ($parsed === null) { + return $this->finding( + $file, + $startLine, + $endLine, + 'commented_out_code_without_valid_reason', + 'Attached comment is not a valid tagged reason.', + $options, + raw: $reasonCandidate['text'], + ); + } + + if (!$this->isAllowedReasonTag($parsed['tag'], $options)) { + return $this->finding( + $file, + $startLine, + $endLine, + 'commented_out_code_without_valid_tag', + sprintf('Tag "%s" is not allowed as a reason for commented-out code.', $parsed['tag']), + $options, + tag: $parsed['tag'], + scope: $parsed['scope'], + reason: $parsed['message'], + raw: $reasonCandidate['text'], + ); + } + + if ($this->isWeakReason($parsed['message'], $options['minReasonLength'])) { + return $this->finding( + $file, + $startLine, + $endLine, + 'commented_out_code_with_weak_reason', + 'Tagged reason is too weak; provide a clear explanation for why the code is disabled.', + $options, + tag: $parsed['tag'], + scope: $parsed['scope'], + reason: $parsed['message'], + raw: $reasonCandidate['text'], + ); + } + + if ($codeLines > $options['requireIssueForBlocksLongerThan'] && !$this->hasIssueReference($parsed, $options['allowedIssuePatterns'])) { + return $this->finding( + $file, + $startLine, + $endLine, + 'commented_out_code_requires_issue_reference', + 'Commented-out code blocks longer than the configured threshold must include an issue reference.', + $options, + tag: $parsed['tag'], + scope: $parsed['scope'], + reason: $parsed['message'], + raw: $reasonCandidate['text'], + ); + } + + return $this->finding( + $file, + $startLine, + $endLine, + 'commented_out_code_with_valid_reason', + 'Commented-out code is attached to a valid tagged reason.', + $options, + tag: $parsed['tag'], + scope: $parsed['scope'], + reason: $parsed['message'], + raw: $reasonCandidate['text'], + ); + } + + /** + * @param array{ + * tag:string, + * scope:?string, + * message:string + * } $parsed + * @param list $patterns + */ + private function hasIssueReference(array $parsed, array $patterns): bool + { + $candidate = trim(($parsed['scope'] ?? '') . ' ' . $parsed['message']); + + foreach ($patterns as $pattern) { + if (@preg_match($pattern, $candidate) === 1) { + return true; + } + } + + return false; + } + + /** + * @param list $tags + * @return array{tag:string,scope:?string,message:string}|null + */ + private function parseMarker(string $text, array $tags): ?array + { + if ($tags === []) { + return null; + } + + $escaped = array_map(static fn(string $tag): string => preg_quote($tag, '/'), $tags); + $pattern = '/^\s*(' . implode('|', $escaped) . ')(?:\(([^)]*)\))?:?\s*(.*)$/i'; + + if (preg_match($pattern, $text, $matches) !== 1) { + return null; + } + + return [ + 'tag' => strtoupper(trim($matches[1])), + 'scope' => isset($matches[2]) && trim($matches[2]) !== '' ? trim($matches[2]) : null, + 'message' => trim($matches[3] ?? ''), + ]; + } + + /** + * @param list $lines + * @param list $labels + */ + private function phpDocHasExampleLabel(array $lines, array $labels): bool + { + foreach ($lines as $line) { + if ($this->isExampleLabel($line['text'], $labels)) { + return true; + } + } + + return false; + } + + /** + * @param list $labels + */ + private function isExampleLabel(string $text, array $labels): bool + { + $normalizedText = strtolower(trim($text)); + + if ($normalizedText === '') { + return false; + } + + foreach ($labels as $label) { + if ($normalizedText === strtolower(trim($label))) { + return true; + } + } + + return false; + } + + private function finding( + string $file, + int $line, + int $endLine, + string $type, + string $message, + array $options, + ?string $tag = null, + ?string $scope = null, + ?string $issue = null, + ?string $owner = null, + ?string $reason = null, + ?string $raw = null, + ): CommentFinding { + return new CommentFinding( + file: $this->relativePath($file), + line: $line, + endLine: $endLine, + type: $type, + severity: $this->typeSeverity($type, $options), + message: $message, + tag: $tag, + scope: $scope, + issue: $issue, + owner: $owner, + reason: $reason, + raw: $raw, + ); + } + + /** + * @param array{type:string,raw:string,line:int,end_line:int} $comment + * @return list + */ + private function lastMeaningfulCommentLine(array $comment): array + { + $lines = $this->commentLines($comment); + + for ($index = count($lines) - 1; $index >= 0; $index--) { + if (trim($lines[$index]['text']) !== '') { + return [$lines[$index]]; + } + } + + return []; + } + + /** + * @param list $comments + * @param array{ + * commentedOutEnabled:bool, + * allowBlankLineBetweenReasonAndCode:bool, + * allowReasonBeforeBlockComment:bool, + * allowBlankLineBetweenReasonAndCodeInBlock:bool, + * allowPhpdocExamples:bool, + * phpdocExampleLabels:list, + * maxAllowedBlockLines:int + * } $options + * @return list + */ + private function scanCommentedOutCode(string $file, array $comments, array $options): array + { + if (!$options['commentedOutEnabled']) { + return []; + } + + return [ + ...$this->scanLineCommentedCode($file, $comments, $options), + ...$this->scanBlockCommentedCode($file, $comments, $options), + ]; + } + + /** + * @param list $comments + * @return list + */ + private function scanBlockCommentedCode(string $file, array $comments, array $options): array + { + $findings = []; + $commentCount = count($comments); + + for ($index = 0; $index < $commentCount; $index++) { + $comment = $comments[$index]; + + if (!in_array($comment['type'], ['block_comment', 'doc_comment'], true)) { + continue; + } + + $lines = $this->commentLines($comment); + $groups = $this->codeGroups($lines); + + if ($groups === []) { + continue; + } + + $hasExampleLabel = $comment['type'] === 'doc_comment' + && $options['allowPhpdocExamples'] + && $this->phpDocHasExampleLabel($lines, $options['phpdocExampleLabels']); + + foreach ($groups as $group) { + $reasonCandidate = $this->reasonBeforeIndex($lines, $group['start_index'], $options['allowBlankLineBetweenReasonAndCodeInBlock']); + + if ($reasonCandidate === null && $options['allowReasonBeforeBlockComment']) { + $previous = $comments[$index - 1] ?? null; + + if (is_array($previous) && ($previous['end_line'] + 1) === $comment['line']) { + $reasonCandidate = $this->lastMeaningfulCommentLine($previous)[0] ?? null; + } + } + + if ($comment['type'] === 'doc_comment' + && $hasExampleLabel + && ($reasonCandidate === null || $this->isExampleLabel($reasonCandidate['text'], $options['phpdocExampleLabels']))) { + continue; + } + + $reasonFinding = $this->detectReasonStatus( + $file, + $group['start_line'], + $group['end_line'], + $group['lines'], + $reasonCandidate, + $comment['type'] === 'doc_comment', + $hasExampleLabel, + $options, + ); + + if ($reasonFinding !== null && !($hasExampleLabel && $reasonFinding->type === 'commented_out_code_in_phpdoc_without_example_label')) { + $findings[] = $reasonFinding; + } + + if ($group['lines'] > $options['maxAllowedBlockLines']) { + $findings[] = $this->finding( + $file, + $group['start_line'], + $group['end_line'], + 'commented_out_code_block_too_large', + sprintf( + 'Commented-out code block has %d lines; maximum allowed is %d.', + $group['lines'], + $options['maxAllowedBlockLines'], + ), + $options, + ); + } + } + } + + return $findings; + } + + /** + * @param list $comments + * @return list + */ + private function scanLineCommentedCode(string $file, array $comments, array $options): array + { + $entries = []; + + foreach ($comments as $comment) { + if ($comment['type'] !== 'line_comment') { + continue; + } + + $entries[] = [ + 'line' => $comment['line'], + 'text' => $this->normalizeLineComment($comment['raw']), + 'raw' => $comment['raw'], + ]; + } + + usort($entries, static fn(array $left, array $right): int => $left['line'] <=> $right['line']); + $entryByLine = []; + + foreach ($entries as $entry) { + $entryByLine[$entry['line']] = $entry; + } + + $findings = []; + + foreach ($this->groupAdjacentCodeLines($entries) as $group) { + $reasonCandidate = null; + $reasonLine = $group['start_line'] - 1; + + if (isset($entryByLine[$reasonLine]) && ($options['allowBlankLineBetweenReasonAndCode'] || $reasonLine === ($group['start_line'] - 1))) { + if (trim($entryByLine[$reasonLine]['text']) !== '') { + $reasonCandidate = ['text' => $entryByLine[$reasonLine]['text'], 'line' => $reasonLine]; + } + } + + $reasonFinding = $this->detectReasonStatus( + $file, + $group['start_line'], + $group['end_line'], + $group['lines'], + $reasonCandidate, + false, + false, + $options, + ); + + if ($reasonFinding !== null) { + $findings[] = $reasonFinding; + } + + if ($group['lines'] > $options['maxAllowedBlockLines']) { + $findings[] = $this->finding( + $file, + $group['start_line'], + $group['end_line'], + 'commented_out_code_block_too_large', + sprintf( + 'Commented-out code block has %d lines; maximum allowed is %d.', + $group['lines'], + $options['maxAllowedBlockLines'], + ), + $options, + ); + } + } + + return $findings; + } + + /** + * @param list $comments + * @param array{ + * scanMarkers:bool, + * markerTags:list, + * markerSeverity:array + * } $options + * @return list + */ + private function scanMarkers(string $file, array $comments, array $options): array + { + if (!$options['scanMarkers']) { + return []; + } + + $findings = []; + + foreach ($comments as $comment) { + foreach ($this->commentLines($comment) as $line) { + $parsed = $this->parseMarker($line['text'], $options['markerTags']); + + if ($parsed === null) { + continue; + } + + $severity = $options['markerSeverity'][$parsed['tag']] ?? 'info'; + + $findings[] = new CommentFinding( + file: $this->relativePath($file), + line: $line['line'], + endLine: $line['line'], + type: 'comment_marker', + severity: $severity, + message: sprintf('Detected %s marker.', $parsed['tag']), + tag: $parsed['tag'], + scope: $parsed['scope'], + reason: $parsed['message'], + raw: $line['text'], + ); + } + } + + return $findings; + } + + /** + * @param list $lines + * @return list + */ + private function codeGroups(array $lines): array + { + return $this->groupAdjacentCodeLines($lines); + } + + /** + * @param list $lines + * @return list + */ + private function groupAdjacentCodeLines(array $lines): array + { + $groups = []; + $lineCount = count($lines); + + for ($index = 0; $index < $lineCount; $index++) { + if (!$this->looksLikeCode($lines[$index]['text'])) { + continue; + } + + $start = $index; + $end = $index; + + while (($end + 1) < $lineCount + && $this->looksLikeCode($lines[$end + 1]['text']) + && ($lines[$end + 1]['line'] === ($lines[$end]['line'] + 1))) { + $end++; + } + + $groups[] = [ + 'start_line' => $lines[$start]['line'], + 'end_line' => $lines[$end]['line'], + 'lines' => $end - $start + 1, + 'start_index' => $start, + ]; + + $index = $end; + } + + return $groups; + } + + /** + * @param array{text:string,line:int} $line + */ + private function isLikelyDocumentationLine(array $line): bool + { + $text = trim($line['text']); + + if ($text === '') { + return true; + } + + return str_starts_with($text, '@'); + } + + /** + * @param array{ + * allowedReasonTags:list, + * optionalReasonTags:list, + * strict:bool, + * allowOptionalReasonTagsInStrictMode:bool + * } $options + */ + private function isAllowedReasonTag(string $tag, array $options): bool + { + if (in_array($tag, $options['allowedReasonTags'], true)) { + return true; + } + + if (!in_array($tag, $options['optionalReasonTags'], true)) { + return false; + } + + if ($options['strict'] && !$options['allowOptionalReasonTagsInStrictMode']) { + return false; + } + + return true; + } + + private function isWeakReason(string $message, int $minReasonLength): bool + { + $reason = trim($message); + + if ($reason === '' || strlen($reason) < $minReasonLength) { + return true; + } + + $normalized = strtolower($reason); + $weakPhrases = [ + 'todo', + 'later', + 'for now', + 'old code', + 'disabled', + 'disabled for now', + 'temporary', + 'temp', + 'testing', + 'test', + 'fix later', + 'wip', + ]; + + foreach ($weakPhrases as $phrase) { + if ($normalized === $phrase || str_starts_with($normalized, $phrase . ' ')) { + return true; + } + } + + return str_word_count($reason) < 3; + } + + private function looksLikeCode(string $line): bool + { + $trimmed = trim($line); + + if ($trimmed === '') { + return false; + } + + if (preg_match('/^(TODO|FIXME|BUG|HACK|XXX|NOTE|OPTIMIZE|REFACTOR|DEPRECATED|SECURITY|REVIEW|QUESTION|WARNING)\b/i', $trimmed) === 1) { + return false; + } + + if (preg_match('/^\s*@\w+/', $trimmed) === 1) { + return false; + } + + if (preg_match('/^\s*(example|examples|usage|snippet|code sample)\s*:/i', $trimmed) === 1) { + return false; + } + + // Skip common PHPDoc shape/type lines such as "file:string,". + if (preg_match('/^[A-Za-z_][A-Za-z0-9_]*\s*:\s*[^;]+,?$/', $trimmed) === 1) { + return false; + } + + // Standalone braces in docs are often type-shape delimiters, not commented-out code. + if ($trimmed === '{' || $trimmed === '}') { + return false; + } + + $patterns = [ + '/^\s*\$[A-Za-z_][A-Za-z0-9_]*\s*=/', + '/^\s*(if|else|elseif|foreach|for|while|switch|try|catch|finally)\b/', + '/^\s*(return|throw|new|class|interface|trait|enum|function|namespace|use)\b/', + '/->|::/', + '/;\s*$/', + '/<\?php\b/', + '/^\s*[A-Za-z_][A-Za-z0-9_]*\s*\(/', + ]; + + foreach ($patterns as $pattern) { + if (preg_match($pattern, $trimmed) === 1) { + return true; + } + } + + return false; + } + + private function normalizeLineComment(string $raw): string + { + $text = preg_replace('/^\s*(\/\/+|#)\s?/', '', $raw); + + return is_string($text) ? trim($text) : trim($raw); + } + + /** + * @return list + */ + private function normalizeBlockCommentLines(string $raw, int $startLine): array + { + $rawLines = preg_split('/\R/', $raw) ?: []; + $lineCount = count($rawLines); + $normalized = []; + + foreach ($rawLines as $index => $line) { + $content = $line; + + if ($index === 0) { + $content = preg_replace('/^\s*\/\*\*?/', '', $content) ?? $content; + } + + if ($index === $lineCount - 1) { + $content = preg_replace('/\*\/\s*$/', '', $content) ?? $content; + } + + $content = ltrim($content); + + if (str_starts_with($content, '*')) { + $content = ltrim(substr($content, 1)); + } + + $normalized[] = [ + 'text' => trim($content), + 'line' => $startLine + $index, + ]; + } + + return $normalized; + } + + /** + * @param list $lines + * @return array{text:string,line:int}|null + */ + private function reasonBeforeIndex(array $lines, int $index, bool $allowBlankLine): ?array + { + if ($index <= 0) { + return null; + } + + if (!$allowBlankLine) { + return trim($lines[$index - 1]['text']) !== '' ? $lines[$index - 1] : null; + } + + for ($scan = $index - 1; $scan >= 0; $scan--) { + if (trim($lines[$scan]['text']) !== '') { + return $lines[$scan]; + } + } + + return null; + } + + private function relativePath(string $path): string + { + return ProjectPath::relative($path); + } + + /** + * @param array{ + * markerTags:list, + * optionalReasonTags:list + * } $options + * @return list + */ + private function allKnownTags(array $options): array + { + return array_values(array_unique([...$options['markerTags'], ...$options['optionalReasonTags']])); + } + + private function typeSeverity(string $type, array $options): string + { + if ($options['strict'] && isset($options['strictSeverity'][$type])) { + return $options['strictSeverity'][$type]; + } + + return $options['typeSeverity'][$type] ?? 'info'; + } +} diff --git a/src/Comment/PhpCommentExtractor.php b/src/Comment/PhpCommentExtractor.php new file mode 100644 index 0000000..10dcb2f --- /dev/null +++ b/src/Comment/PhpCommentExtractor.php @@ -0,0 +1,51 @@ + + */ + public function extract(string $file): array + { + $contents = file_get_contents($file); + + if (!is_string($contents)) { + return []; + } + + $comments = []; + + foreach (token_get_all($contents) as $token) { + if (!is_array($token) || !in_array($token[0], [T_COMMENT, T_DOC_COMMENT], true)) { + continue; + } + + $raw = $token[1]; + $line = $token[2]; + $endLine = $line + substr_count($raw, "\n"); + + $comments[] = [ + 'type' => $token[0] === T_DOC_COMMENT ? 'doc_comment' : $this->commentType($raw), + 'raw' => $raw, + 'line' => $line, + 'end_line' => $endLine, + ]; + } + + return $comments; + } + + private function commentType(string $raw): string + { + if (str_starts_with($raw, '/*')) { + return 'block_comment'; + } + + return 'line_comment'; + } +} + diff --git a/src/CommentChecker.php b/src/CommentChecker.php new file mode 100644 index 0000000..7b9f43c --- /dev/null +++ b/src/CommentChecker.php @@ -0,0 +1,428 @@ +cli = new CliOptions(); + } + + /** + * @param list $args + */ + public function run(array $args): int + { + try { + $options = $this->parseArgs($args); + + if ($options['help']) { + return $this->help(); + } + + $result = (new CommentScanner())->scan((new PhpFileFinder())->find($options['paths'], $options['excludes']), $options); + $this->writeResult($result, $options); + + return $this->shouldFail($result['findings'], $options['failOn']) ? 1 : 0; + } catch (\InvalidArgumentException|\RuntimeException $exception) { + fwrite(STDERR, $exception->getMessage() . PHP_EOL); + + return 2; + } + } + + /** + * @return array{ + * help:bool, + * json:bool, + * strict:bool, + * failOn:string, + * config:string, + * paths:list, + * excludes:list, + * scanMarkers:bool, + * markerTags:list, + * markerSeverity:array, + * commentedOutEnabled:bool, + * allowedReasonTags:list, + * optionalReasonTags:list, + * allowOptionalReasonTagsInStrictMode:bool, + * minReasonLength:int, + * maxAllowedBlockLines:int, + * requireIssueForBlocksLongerThan:int, + * allowedIssuePatterns:list, + * allowBlankLineBetweenReasonAndCode:bool, + * allowReasonBeforeBlockComment:bool, + * allowBlankLineBetweenReasonAndCodeInBlock:bool, + * allowPhpdocExamples:bool, + * phpdocExampleLabels:list, + * typeSeverity:array, + * strictSeverity:array + * } + */ + private function defaultOptions(): array + { + return [ + 'help' => false, + 'json' => false, + 'strict' => false, + 'failOn' => 'error', + 'config' => Paths::config('phpprobe.json'), + 'paths' => [], + 'excludes' => [], + 'scanMarkers' => true, + 'markerTags' => [ + 'TODO', + 'FIXME', + 'BUG', + 'HACK', + 'XXX', + 'NOTE', + 'OPTIMIZE', + 'REFACTOR', + 'DEPRECATED', + 'SECURITY', + 'REVIEW', + 'QUESTION', + 'WARNING', + ], + 'markerSeverity' => [ + 'SECURITY' => 'critical', + 'BUG' => 'high', + 'FIXME' => 'high', + 'HACK' => 'medium', + 'XXX' => 'medium', + 'WARNING' => 'medium', + 'TODO' => 'low', + 'OPTIMIZE' => 'low', + 'REFACTOR' => 'low', + 'DEPRECATED' => 'low', + 'REVIEW' => 'info', + 'QUESTION' => 'info', + 'NOTE' => 'info', + ], + 'commentedOutEnabled' => true, + 'allowedReasonTags' => ['TODO', 'FIXME', 'BUG', 'HACK', 'SECURITY', 'REVIEW', 'DEPRECATED'], + 'optionalReasonTags' => ['TEMP', 'DEBUG', 'EXPERIMENTAL'], + 'allowOptionalReasonTagsInStrictMode' => false, + 'minReasonLength' => 12, + 'maxAllowedBlockLines' => 10, + 'requireIssueForBlocksLongerThan' => 3, + 'allowedIssuePatterns' => ['/#\d+/', '/[A-Z]+-\d+/'], + 'allowBlankLineBetweenReasonAndCode' => false, + 'allowReasonBeforeBlockComment' => true, + 'allowBlankLineBetweenReasonAndCodeInBlock' => true, + 'allowPhpdocExamples' => true, + 'phpdocExampleLabels' => ['Example:', 'Examples:', 'Usage:', 'Snippet:', 'Code sample:'], + 'typeSeverity' => [ + 'comment_marker' => 'info', + 'commented_out_code_without_reason' => 'warning', + 'commented_out_code_without_valid_tag' => 'warning', + 'commented_out_code_without_valid_reason' => 'warning', + 'commented_out_code_with_weak_reason' => 'warning', + 'commented_out_code_with_valid_reason' => 'info', + 'commented_out_code_block_too_large' => 'error', + 'commented_out_code_requires_issue_reference' => 'warning', + 'commented_out_code_in_phpdoc_without_example_label' => 'warning', + ], + 'strictSeverity' => [ + 'commented_out_code_without_reason' => 'error', + 'commented_out_code_without_valid_tag' => 'error', + 'commented_out_code_without_valid_reason' => 'error', + 'commented_out_code_with_weak_reason' => 'error', + 'commented_out_code_block_too_large' => 'error', + ], + ]; + } + + private function help(): int + { + fwrite(STDOUT, implode(PHP_EOL, [ + 'Usage: phpprobe comments [options] [paths...]', + '', + 'Options:', + ' --config=FILE read PHPProbe checker settings', + ' --preset=NAME apply preset: phpstorm, standard, or strict', + ' --exclude=PATH skip a path (repeatable)', + ' --json output machine-readable JSON', + ' --strict enforce strict policy severities', + ' --fail-on=error|warning|info minimum severity level to fail', + ' --tags=TODO,FIXME,... override marker tags', + ' --help show this help', + ]) . PHP_EOL); + + return 0; + } + + /** + * @param list $args + * @return array{ + * help:bool, + * json:bool, + * strict:bool, + * failOn:string, + * config:string, + * paths:list, + * excludes:list, + * scanMarkers:bool, + * markerTags:list, + * markerSeverity:array, + * commentedOutEnabled:bool, + * allowedReasonTags:list, + * optionalReasonTags:list, + * allowOptionalReasonTagsInStrictMode:bool, + * minReasonLength:int, + * maxAllowedBlockLines:int, + * requireIssueForBlocksLongerThan:int, + * allowedIssuePatterns:list, + * allowBlankLineBetweenReasonAndCode:bool, + * allowReasonBeforeBlockComment:bool, + * allowBlankLineBetweenReasonAndCodeInBlock:bool, + * allowPhpdocExamples:bool, + * phpdocExampleLabels:list, + * typeSeverity:array, + * strictSeverity:array + * } + */ + private function parseArgs(array $args): array + { + $options = $this->defaultOptions(); + $options['config'] = $this->cli->configPath($args, $options['config']); + $config = $this->cli->mergeConfigWithPreset(PhpProbeConfig::fromFile($options['config']), $this->cli->presetName($args)); + $options = $config->applyCommentOptions($options); + $configuredPaths = $options['paths']; + $options['paths'] = []; + $collectingPathsOnly = false; + $argCount = count($args); + + for ($index = 0; $index < $argCount; $index++) { + $arg = $args[$index]; + + if ($collectingPathsOnly) { + $options['paths'][] = $arg; + + continue; + } + + if ($arg === '--') { + $collectingPathsOnly = true; + + continue; + } + + if ($this->cli->skipConfig($args, $index, $arg) || $this->cli->skipPreset($args, $index, $arg)) { + continue; + } + + if ($this->parseCliOption($args, $index, $options, $arg)) { + continue; + } + + if (str_starts_with($arg, '-')) { + throw new \InvalidArgumentException(sprintf('Unknown option for comments command: %s', $arg)); + } + + $options['paths'][] = $arg; + } + + if ($options['paths'] === []) { + $options['paths'] = $configuredPaths; + } + + return $options; + } + + /** + * @param list $args + * @param array{ + * help:bool, + * json:bool, + * strict:bool, + * failOn:string, + * excludes:list, + * markerTags:list + * } $options + */ + private function parseCliOption(array $args, int &$index, array &$options, string $arg): bool + { + if ($this->cli->parseExclude($args, $index, $options, $arg)) { + return true; + } + + if ($arg === '--help' || $arg === '-h') { + $options['help'] = true; + + return true; + } + + if ($arg === '--json') { + $options['json'] = true; + + return true; + } + + if ($arg === '--strict') { + $options['strict'] = true; + + return true; + } + + $failOn = $this->cli->optionValue($arg, '--fail-on'); + + if ($failOn !== null) { + $normalized = strtolower(trim($failOn)); + + if (!in_array($normalized, ['error', 'warning', 'info'], true)) { + throw new \InvalidArgumentException(sprintf('Invalid --fail-on value "%s". Expected: error, warning, info.', $failOn)); + } + + $options['failOn'] = $normalized; + + return true; + } + + $tags = $this->cli->optionValue($arg, '--tags'); + + if ($tags !== null) { + $options['markerTags'] = array_values(array_filter( + array_map( + static fn(string $tag): string => strtoupper(trim($tag)), + explode(',', $tags), + ), + static fn(string $tag): bool => $tag !== '', + )); + + return true; + } + + return false; + } + + /** + * @param list $findings + */ + private function shouldFail(array $findings, string $failOn): bool + { + $threshold = match ($failOn) { + 'info' => 1, + 'warning' => 4, + default => 6, + }; + + foreach ($findings as $finding) { + if ($this->severityRank($finding->severity) >= $threshold) { + return true; + } + } + + return false; + } + + private function severityRank(string $severity): int + { + return match (strtolower($severity)) { + 'error' => 7, + 'critical' => 6, + 'high' => 5, + 'warning' => 4, + 'medium' => 3, + 'low' => 2, + default => 1, + }; + } + + /** + * @param array{files:int,findings:list} $result + * @param array{json:bool} $options + */ + private function writeResult(array $result, array $options): void + { + if ($options['json']) { + fwrite(STDOUT, json_encode([ + 'files' => $result['files'], + 'findings' => array_map( + static fn(CommentFinding $finding): array => $finding->toArray(), + $result['findings'], + ), + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL); + + return; + } + + if ($result['findings'] === []) { + fwrite(STDOUT, Ansi::color(sprintf('No comment policy findings (%d PHP files scanned).', $result['files']), 'green', STDOUT) . PHP_EOL); + + return; + } + + $grouped = []; + + foreach ($result['findings'] as $finding) { + $grouped[$finding->file][] = $finding; + } + + fwrite( + STDERR, + Ansi::color( + sprintf( + 'Comment policy findings: %d issue(s) in %d file(s) scanned.', + count($result['findings']), + count($grouped), + ), + 'red', + STDERR, + ) . PHP_EOL, + ); + + foreach ($grouped as $file => $findings) { + fwrite(STDERR, Ansi::color($file, 'cyan', STDERR) . PHP_EOL); + + foreach ($findings as $finding) { + $lineLabel = $finding->line === $finding->endLine + ? (string) $finding->line + : sprintf('%d-%d', $finding->line, $finding->endLine); + $title = $this->findingTitle($finding->type); + + fwrite( + STDERR, + sprintf( + ' %s L%s %s', + Ansi::severity($finding->severity, STDERR), + $lineLabel, + $title, + ) . PHP_EOL, + ); + fwrite(STDERR, ' ' . $finding->message . PHP_EOL); + } + } + } + + private function findingTitle(string $type): string + { + return match ($type) { + 'comment_marker' => 'Comment Marker', + 'commented_out_code_without_reason' => 'Commented-out Code Without Reason', + 'commented_out_code_without_valid_tag' => 'Commented-out Code Uses Invalid Reason Tag', + 'commented_out_code_without_valid_reason' => 'Commented-out Code Without Valid Reason', + 'commented_out_code_with_weak_reason' => 'Commented-out Code With Weak Reason', + 'commented_out_code_with_valid_reason' => 'Commented-out Code With Valid Reason', + 'commented_out_code_block_too_large' => 'Commented-out Code Block Too Large', + 'commented_out_code_requires_issue_reference' => 'Commented-out Code Requires Issue Reference', + 'commented_out_code_in_phpdoc_without_example_label' => 'PHPDoc Code Without Example Label', + default => str_replace('_', ' ', ucfirst($type)), + }; + } +} diff --git a/src/Config/PhpProbeConfig.php b/src/Config/PhpProbeConfig.php index ee1b90f..78aadcc 100644 --- a/src/Config/PhpProbeConfig.php +++ b/src/Config/PhpProbeConfig.php @@ -137,6 +137,186 @@ public function applyApiOptions(array $options): array return $options; } + + /** + * @param array{help:bool,config:string,paths:list,excludes:list} $options + * @return array{help:bool,config:string,paths:list,excludes:list} + */ + public function applySyntaxOptions(array $options): array + { + $section = $this->section('syntax'); + $paths = $this->stringList($this->value($section, 'paths')); + $excludes = $this->excludePaths($section); + + if ($paths !== []) { + $options['paths'] = $paths; + } + + if ($excludes !== []) { + $options['excludes'] = $excludes; + } + + return $options; + } + + /** + * @param array{ + * paths:list, + * excludes:list, + * scanMarkers:bool, + * markerTags:list, + * markerSeverity:array, + * commentedOutEnabled:bool, + * allowedReasonTags:list, + * optionalReasonTags:list, + * allowOptionalReasonTagsInStrictMode:bool, + * minReasonLength:int, + * maxAllowedBlockLines:int, + * requireIssueForBlocksLongerThan:int, + * allowedIssuePatterns:list, + * allowBlankLineBetweenReasonAndCode:bool, + * allowReasonBeforeBlockComment:bool, + * allowBlankLineBetweenReasonAndCodeInBlock:bool, + * allowPhpdocExamples:bool, + * phpdocExampleLabels:list, + * typeSeverity:array, + * strictSeverity:array + * } $options + * @return array{ + * paths:list, + * excludes:list, + * scanMarkers:bool, + * markerTags:list, + * markerSeverity:array, + * commentedOutEnabled:bool, + * allowedReasonTags:list, + * optionalReasonTags:list, + * allowOptionalReasonTagsInStrictMode:bool, + * minReasonLength:int, + * maxAllowedBlockLines:int, + * requireIssueForBlocksLongerThan:int, + * allowedIssuePatterns:list, + * allowBlankLineBetweenReasonAndCode:bool, + * allowReasonBeforeBlockComment:bool, + * allowBlankLineBetweenReasonAndCodeInBlock:bool, + * allowPhpdocExamples:bool, + * phpdocExampleLabels:list, + * typeSeverity:array, + * strictSeverity:array + * } + */ + public function applyCommentOptions(array $options): array + { + $comments = $this->section('comments'); + $commentedOut = $this->section('commented_out_code'); + $paths = $this->stringList($this->value($comments, 'paths')); + $excludes = $this->excludePaths($comments); + $scanMarkers = $this->boolValue($comments, 'scan_markers'); + $markerTags = array_map('strtoupper', $this->stringList($this->value($comments, 'marker_tags'))); + $markerSeverity = $this->stringMap($this->value($comments, 'marker_severity'), true); + $commentedEnabled = $this->boolValue($commentedOut, 'enabled'); + $allowedReasonTags = array_map('strtoupper', $this->stringList($this->value($commentedOut, 'allowed_reason_tags'))); + $optionalReasonTags = array_map('strtoupper', $this->stringList($this->value($commentedOut, 'optional_reason_tags'))); + $allowOptionalInStrict = $this->boolValue($commentedOut, 'allow_optional_reason_tags_in_strict_mode'); + $minReasonLength = $this->intValue($commentedOut, 'min_reason_length'); + $maxBlockLines = $this->intValue($commentedOut, 'max_allowed_block_lines'); + $requireIssue = $this->intValue($commentedOut, 'require_issue_for_blocks_longer_than'); + $issuePatterns = $this->stringList($this->value($commentedOut, 'allowed_issue_patterns')); + $singleLine = ArrayShape::stringKeyed($this->value($commentedOut, 'single_line_comments')); + $block = ArrayShape::stringKeyed($this->value($commentedOut, 'block_comments')); + $phpdoc = ArrayShape::stringKeyed($this->value($commentedOut, 'phpdoc_comments')); + $typeSeverity = $this->stringMap($this->value($commentedOut, 'finding_severity')); + $strictSeverity = $this->stringMap($this->value($commentedOut, 'finding_severity_strict')); + + if ($paths !== []) { + $options['paths'] = $paths; + } + + if ($excludes !== []) { + $options['excludes'] = $excludes; + } + + if ($scanMarkers !== null) { + $options['scanMarkers'] = $scanMarkers; + } + + if ($markerTags !== []) { + $options['markerTags'] = $markerTags; + } + + if ($markerSeverity !== []) { + $options['markerSeverity'] = $markerSeverity; + } + + if ($commentedEnabled !== null) { + $options['commentedOutEnabled'] = $commentedEnabled; + } + + if ($allowedReasonTags !== []) { + $options['allowedReasonTags'] = $allowedReasonTags; + } + + if ($optionalReasonTags !== []) { + $options['optionalReasonTags'] = $optionalReasonTags; + } + + if ($allowOptionalInStrict !== null) { + $options['allowOptionalReasonTagsInStrictMode'] = $allowOptionalInStrict; + } + + if ($minReasonLength !== null) { + $options['minReasonLength'] = max(1, $minReasonLength); + } + + if ($maxBlockLines !== null) { + $options['maxAllowedBlockLines'] = max(1, $maxBlockLines); + } + + if ($requireIssue !== null) { + $options['requireIssueForBlocksLongerThan'] = max(1, $requireIssue); + } + + if ($issuePatterns !== []) { + $options['allowedIssuePatterns'] = $issuePatterns; + } + + $singleAllowBlank = $this->boolValue($singleLine, 'allow_blank_line_between_reason_and_code'); + $blockAllowBefore = $this->boolValue($block, 'allow_reason_before_block_comment'); + $blockAllowBlank = $this->boolValue($block, 'allow_blank_line_between_reason_and_code'); + $phpdocAllowExamples = $this->boolValue($phpdoc, 'allow_documentation_examples'); + $phpdocLabels = $this->stringList($this->value($phpdoc, 'example_labels')); + + if ($singleAllowBlank !== null) { + $options['allowBlankLineBetweenReasonAndCode'] = $singleAllowBlank; + } + + if ($blockAllowBefore !== null) { + $options['allowReasonBeforeBlockComment'] = $blockAllowBefore; + } + + if ($blockAllowBlank !== null) { + $options['allowBlankLineBetweenReasonAndCodeInBlock'] = $blockAllowBlank; + } + + if ($phpdocAllowExamples !== null) { + $options['allowPhpdocExamples'] = $phpdocAllowExamples; + } + + if ($phpdocLabels !== []) { + $options['phpdocExampleLabels'] = $phpdocLabels; + } + + if ($typeSeverity !== []) { + $options['typeSeverity'] = $typeSeverity; + } + + if ($strictSeverity !== []) { + $options['strictSeverity'] = $strictSeverity; + } + + return $options; + } + public function merge(self $override): self { return new self($this->mergeArrays($this->config, $override->config)); @@ -292,6 +472,27 @@ private function normalizeSimilarity(float $value): float return $value > 1.0 ? min(100.0, $value) / 100.0 : max(0.0, min(1.0, $value)); } + /** + * @param array $value + * @return array + */ + private function stringMap(mixed $value, bool $uppercaseKeys = false): array + { + if (!is_array($value)) { + return []; + } + + $map = []; + + foreach ($value as $key => $item) { + if (is_string($key) && is_string($item) && $item !== '') { + $map[$uppercaseKeys ? strtoupper($key) : $key] = $item; + } + } + + return $map; + } + /** * @return array */ diff --git a/src/Console/Ansi.php b/src/Console/Ansi.php new file mode 100644 index 0000000..16f2285 --- /dev/null +++ b/src/Console/Ansi.php @@ -0,0 +1,66 @@ + '31', + 'green' => '32', + 'yellow' => '33', + 'blue' => '34', + 'magenta' => '35', + 'cyan' => '36', + 'gray' => '90', + 'bold' => '1', + ]; + + $code = $codes[$name] ?? null; + + if (!is_string($code)) { + return $text; + } + + return "\033[" . $code . 'm' . $text . "\033[0m"; + } + + public static function severity(string $severity, mixed $stream): string + { + return match (strtolower($severity)) { + 'error', 'critical', 'high' => self::color(strtoupper($severity), 'red', $stream), + 'warning', 'medium' => self::color(strtoupper($severity), 'yellow', $stream), + 'low' => self::color(strtoupper($severity), 'blue', $stream), + default => self::color(strtoupper($severity), 'gray', $stream), + }; + } + + private static function enabled(mixed $stream): bool + { + $noColor = getenv('NO_COLOR'); + + if (is_string($noColor) && $noColor !== '') { + return false; + } + + $term = getenv('TERM'); + + if (is_string($term) && strtolower($term) === 'dumb') { + return false; + } + + if (!is_resource($stream)) { + return false; + } + + return function_exists('stream_isatty') ? stream_isatty($stream) : false; + } +} + diff --git a/src/Console/Cli.php b/src/Console/Cli.php index dfa6fc0..38bcb4a 100644 --- a/src/Console/Cli.php +++ b/src/Console/Cli.php @@ -5,6 +5,7 @@ namespace Infocyph\PHPProbe\Console; use Infocyph\PHPProbe\ApiSnapshotChecker; +use Infocyph\PHPProbe\CommentChecker; use Infocyph\PHPProbe\Config\PresetRepository; use Infocyph\PHPProbe\DuplicateChecker; use Infocyph\PHPProbe\SyntaxChecker; @@ -22,6 +23,7 @@ public function run(array $argv): int 'syntax' => (new SyntaxChecker())->run(array_slice($argv, 2)), 'duplicates' => (new DuplicateChecker())->run(array_slice($argv, 2)), 'api' => (new ApiSnapshotChecker())->run(array_slice($argv, 2)), + 'comments' => (new CommentChecker())->run(array_slice($argv, 2)), 'presets' => $this->presets(), 'preset' => $this->preset((string) ($argv[2] ?? '')), default => $this->help(), @@ -56,7 +58,7 @@ private function presets(): int private function help(): int { - fwrite(STDOUT, 'Usage: phpprobe syntax|duplicates|api [options] [paths...] | presets | preset ' . PHP_EOL); + fwrite(STDOUT, 'Usage: phpprobe syntax|duplicates|api|comments [options] [paths...] | presets | preset ' . PHP_EOL); return 0; } diff --git a/src/Detection/DuplicateDetectionEngine.php b/src/Detection/DuplicateDetectionEngine.php index 2f29a69..f8faab2 100644 --- a/src/Detection/DuplicateDetectionEngine.php +++ b/src/Detection/DuplicateDetectionEngine.php @@ -4,6 +4,8 @@ namespace Infocyph\PHPProbe\Detection; +use Infocyph\PHPProbe\Util\ProjectPath; + final class DuplicateDetectionEngine { /** @@ -54,19 +56,19 @@ private function addStatementClone(array &$cloneMap, string $hash, array $block, /** * @param array,shape:list}>> $blocks - * @return array{id:string,type:string,file:string,start_line:int,end_line:int,token_start:int,token_end:int,statement_hashes:list,shape:list} + * @return array,shape:list}> */ - private function blockById(array $blocks, string $id): array + private function blockIdIndex(array $blocks): array { + $index = []; + foreach ($blocks as $fileBlocks) { foreach ($fileBlocks as $block) { - if ($block['id'] === $id) { - return $block; - } + $index[$block['id']] = $block; } } - return ['id' => $id, 'type' => 'unknown', 'file' => '', 'start_line' => 0, 'end_line' => 0, 'token_start' => 0, 'token_end' => 0, 'statement_hashes' => [], 'shape' => []]; + return $index; } /** @@ -268,14 +270,26 @@ private function nearMissClones(array $blocks, array $options, DuplicateCloneRed private function nearMissPairs(array $blocks, array $options, DuplicateCloneReducer $reducer): array { $clones = []; - $blockCount = count($blocks); + $grouped = []; - for ($left = 0; $left < $blockCount - 1; $left++) { - for ($right = $left + 1; $right < $blockCount; $right++) { - $clone = $this->nearMissClone($blocks[$left], $blocks[$right], $options, $reducer); + foreach ($blocks as $block) { + $grouped[$block['type']][] = $block; + } - if ($clone !== null) { - $clones[] = $clone; + foreach ($grouped as $typedBlocks) { + $blockCount = count($typedBlocks); + + for ($left = 0; $left < $blockCount - 1; $left++) { + for ($right = $left + 1; $right < $blockCount; $right++) { + if (!$this->canReachNearMissSimilarity($typedBlocks[$left], $typedBlocks[$right], $options['minSimilarity'])) { + continue; + } + + $clone = $this->nearMissClone($typedBlocks[$left], $typedBlocks[$right], $options, $reducer); + + if ($clone !== null) { + $clones[] = $clone; + } } } } @@ -293,17 +307,7 @@ private function occurrenceKey(array $occurrence): string private function relativePath(string $path): string { - $root = realpath(getcwd() ?: '.'); - $realPath = realpath($path); - - if (!is_string($root) || !is_string($realPath)) { - return str_replace('\\', '/', $path); - } - - $normalizedRoot = rtrim(str_replace('\\', '/', $root), '/'); - $normalizedPath = str_replace('\\', '/', $realPath); - - return str_starts_with($normalizedPath, $normalizedRoot . '/') ? substr($normalizedPath, strlen($normalizedRoot) + 1) : $normalizedPath; + return ProjectPath::relative($path); } /** @@ -315,6 +319,11 @@ private function sequenceSimilarity(array $left, array $right): float return $left === [] || $right === [] ? 0.0 : $this->lcsLength($left, $right) / max(count($left), count($right)); } + private function similarityUpperBound(int $leftLength, int $rightLength): float + { + return min($leftLength, $rightLength) / max(1, max($leftLength, $rightLength)); + } + /** * @param list,shape:list}> $blocks * @return array{id:string,type:string,file:string,start_line:int,end_line:int,token_start:int,token_end:int,statement_hashes:list,shape:list}|null @@ -360,12 +369,16 @@ private function statementClones(array $blocks, array $options, DuplicateCloneRe return []; } + $blockIndex = $this->blockIdIndex($blocks); $cloneMap = []; foreach ($this->statementWindows($blocks, $options['minStatements']) as $occurrences) { foreach ($occurrences as $occurrence) { - $block = $this->blockById($blocks, $occurrence['block']); - $this->addStatementClone($cloneMap, $occurrence['hash'], $block, $options); + $block = $blockIndex[$occurrence['block']] ?? null; + + if ($block !== null) { + $this->addStatementClone($cloneMap, $occurrence['hash'], $block, $options); + } } } @@ -378,7 +391,8 @@ private function statementClones(array $blocks, array $options, DuplicateCloneRe */ private function statementWindows(array $blocks, int $minStatements): array { - $windows = []; + $firstOccurrences = []; + $duplicateWindows = []; foreach ($blocks as $fileBlocks) { foreach ($fileBlocks as $block) { @@ -386,12 +400,24 @@ private function statementWindows(array $blocks, int $minStatements): array for ($index = 0; $index <= $statementWindowLimit; $index++) { $hash = hash('sha256', implode("\0", array_slice($block['statement_hashes'], $index, $minStatements))); - $windows[$hash][] = ['block' => $block['id'], 'hash' => $hash]; + $occurrence = ['block' => $block['id'], 'hash' => $hash]; + + if (!isset($firstOccurrences[$hash])) { + $firstOccurrences[$hash] = $occurrence; + + continue; + } + + if (!isset($duplicateWindows[$hash])) { + $duplicateWindows[$hash] = [$firstOccurrences[$hash]]; + } + + $duplicateWindows[$hash][] = $occurrence; } } } - return array_filter($windows, static fn(array $occurrences): bool => count($occurrences) > 1); + return $duplicateWindows; } /** @@ -473,16 +499,42 @@ private function tokenValueHash(array $tokens, int $start, int $length): string */ private function tokenWindows(array $streams, int $minTokens): array { - $windows = []; + $firstOccurrences = []; + $duplicateWindows = []; foreach ($streams as $file => $tokens) { $tokenWindowLimit = count($tokens) - $minTokens; for ($index = 0; $index <= $tokenWindowLimit; $index++) { - $windows[$this->tokenValueHash($tokens, $index, $minTokens)][] = ['file' => $file, 'index' => $index]; + $hash = $this->tokenValueHash($tokens, $index, $minTokens); + $occurrence = ['file' => $file, 'index' => $index]; + + if (!isset($firstOccurrences[$hash])) { + $firstOccurrences[$hash] = $occurrence; + + continue; + } + + if (!isset($duplicateWindows[$hash])) { + $duplicateWindows[$hash] = [$firstOccurrences[$hash]]; + } + + $duplicateWindows[$hash][] = $occurrence; } } - return array_filter($windows, static fn(array $occurrences): bool => count($occurrences) > 1); + return $duplicateWindows; + } + + /** + * @param array{id:string,type:string,file:string,start_line:int,end_line:int,token_start:int,token_end:int,statement_hashes:list,shape:list} $left + * @param array{id:string,type:string,file:string,start_line:int,end_line:int,token_start:int,token_end:int,statement_hashes:list,shape:list} $right + */ + private function canReachNearMissSimilarity(array $left, array $right, float $minSimilarity): bool + { + $statementBound = $this->similarityUpperBound(count($left['statement_hashes']), count($right['statement_hashes'])); + $shapeBound = $this->similarityUpperBound(count($left['shape']), count($right['shape'])); + + return round(($statementBound * 0.72) + ($shapeBound * 0.28), 4) >= $minSimilarity; } } diff --git a/src/DuplicateChecker.php b/src/DuplicateChecker.php index 2446334..9a26ef8 100644 --- a/src/DuplicateChecker.php +++ b/src/DuplicateChecker.php @@ -5,6 +5,7 @@ namespace Infocyph\PHPProbe; use Infocyph\PHPProbe\Config\CliOptions; +use Infocyph\PHPProbe\Console\Ansi; use Infocyph\PHPProbe\Config\Paths; use Infocyph\PHPProbe\Config\PhpProbeConfig; use Infocyph\PHPProbe\Detection\DuplicateDetectionEngine; @@ -26,29 +27,28 @@ public function run(array $args): int { try { $options = $this->parseArgs($args); - } catch (\InvalidArgumentException $exception) { - fwrite(STDERR, $exception->getMessage() . PHP_EOL); - - return 2; - } + if ($options['help']) { + return $this->help(); + } - if ($options['help']) { - return $this->help(); - } + $result = (new DuplicateDetectionEngine())->analyze((new PhpFileFinder())->find($options['paths'], $options['excludes']), $options); - $result = (new DuplicateDetectionEngine())->analyze((new PhpFileFinder())->find($options['paths'], $options['excludes']), $options); + if ($options['baseline'] !== '') { + $result = $this->withoutBaselineClones($result, $options['baseline']); + } - if ($options['baseline'] !== '') { - $result = $this->withoutBaselineClones($result, $options['baseline']); - } + if ($options['writeBaseline'] !== '') { + $this->writeBaseline($result, $options['writeBaseline']); + } - if ($options['writeBaseline'] !== '') { - $this->writeBaseline($result, $options['writeBaseline']); - } + $this->writeResult($result, $options); - $this->writeResult($result, $options); + return $options['writeBaseline'] !== '' || $result['clones'] === [] ? 0 : 1; + } catch (\InvalidArgumentException|\RuntimeException $exception) { + fwrite(STDERR, $exception->getMessage() . PHP_EOL); - return $options['writeBaseline'] !== '' || $result['clones'] === [] ? 0 : 1; + return 2; + } } /** @@ -106,17 +106,40 @@ private function help(): int private function knownFingerprints(string $baselinePath): array { if (!is_file($baselinePath)) { - return []; + throw new \RuntimeException(sprintf('Duplicate baseline file not found: %s', $baselinePath)); } - $decoded = json_decode((string) file_get_contents($baselinePath), true); - $clones = is_array($decoded) ? ($decoded['clones'] ?? []) : []; - $known = []; + if (!is_readable($baselinePath)) { + throw new \RuntimeException(sprintf('Duplicate baseline file is not readable: %s', $baselinePath)); + } + + $contents = file_get_contents($baselinePath); + + if (!is_string($contents)) { + throw new \RuntimeException(sprintf('Failed to read duplicate baseline file: %s', $baselinePath)); + } + + try { + $decoded = json_decode($contents, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException $exception) { + throw new \RuntimeException( + sprintf('Invalid duplicate baseline JSON at %s: %s', $baselinePath, $exception->getMessage()), + previous: $exception, + ); + } + + if (!is_array($decoded)) { + throw new \RuntimeException(sprintf('Duplicate baseline payload must be a JSON object: %s', $baselinePath)); + } + + $clones = $decoded['clones'] ?? null; if (!is_array($clones)) { - return []; + throw new \RuntimeException(sprintf('Duplicate baseline is missing a valid "clones" array: %s', $baselinePath)); } + $known = []; + foreach ($clones as $clone) { if (is_array($clone) && is_string($clone['fingerprint'] ?? null)) { $known[$clone['fingerprint']] = true; @@ -154,12 +177,25 @@ private function parseArgs(array $args): array $options = $this->normalizeMode($options); $configuredPaths = $options['paths']; $options['paths'] = []; + $collectingPathsOnly = false; $argCount = count($args); for ($index = 0; $index < $argCount; $index++) { $arg = $args[$index]; + if ($collectingPathsOnly) { + $options['paths'][] = $arg; + + continue; + } + + if ($arg === '--') { + $collectingPathsOnly = true; + + continue; + } + if ($this->cli->skipConfig($args, $index, $arg) || $this->cli->skipPreset($args, $index, $arg)) { continue; } @@ -168,6 +204,10 @@ private function parseArgs(array $args): array continue; } + if (str_starts_with($arg, '-')) { + throw new \InvalidArgumentException(sprintf('Unknown option for duplicates command: %s', $arg)); + } + $options['paths'][] = $arg; } @@ -285,17 +325,27 @@ private function withoutBaselineClones(array $result, string $baselinePath): arr */ private function writeBaseline(array $result, string $path): void { - $payload = [ - 'version' => 1, - 'generated_at' => gmdate('c'), - 'clones' => array_map(static fn(array $clone): array => [ - 'fingerprint' => $clone['fingerprint'], - 'source' => $clone['source'], - 'score' => $clone['score'], - ], $result['clones']), - ]; + try { + $payload = [ + 'version' => 1, + 'generated_at' => gmdate('c'), + 'clones' => array_map(static fn(array $clone): array => [ + 'fingerprint' => $clone['fingerprint'], + 'source' => $clone['source'], + 'score' => $clone['score'], + ], $result['clones']), + ]; + $encoded = json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . PHP_EOL; + } catch (\JsonException $exception) { + throw new \RuntimeException( + sprintf('Could not encode duplicate baseline JSON for %s: %s', $path, $exception->getMessage()), + previous: $exception, + ); + } - file_put_contents($path, json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL); + if (file_put_contents($path, $encoded) === false) { + throw new \RuntimeException(sprintf('Failed to write duplicate baseline file: %s', $path)); + } } /** @@ -311,11 +361,15 @@ private function writeResult(array $result, array $options): void } if ($options['writeBaseline'] !== '') { - fwrite(STDOUT, sprintf('Duplicate baseline written: %s', $options['writeBaseline']) . PHP_EOL); + fwrite(STDOUT, Ansi::color(sprintf('Duplicate baseline written: %s', $options['writeBaseline']), 'cyan', STDOUT) . PHP_EOL); } if ($result['clones'] === []) { - fwrite(STDOUT, sprintf('No new duplicated code found (%d PHP files, %d lines checked).', $result['files'], $result['total_lines']) . PHP_EOL); + fwrite(STDOUT, Ansi::color( + sprintf('No new duplicated code found (%d PHP files, %d lines checked).', $result['files'], $result['total_lines']), + 'green', + STDOUT, + ) . PHP_EOL); return; } @@ -328,31 +382,35 @@ private function writeResult(array $result, array $options): void */ private function writeTextClones(array $result): void { - fwrite(STDERR, sprintf( + fwrite(STDERR, Ansi::color(sprintf( 'Found %d clone group(s) with %d duplicated lines in %d PHP files:', count($result['clones']), $result['duplicated_lines'], $result['files'], - ) . PHP_EOL); + ), 'red', STDERR) . PHP_EOL); - foreach ($result['clones'] as $clone) { + foreach ($result['clones'] as $index => $clone) { $first = $clone['occurrences'][0]; fwrite(STDERR, sprintf( - ' - %s:%d-%d (%d lines, %.0f%% similar, %s, score %.1f)', - $first['file'], - $first['start_line'], - $first['end_line'], + ' %d) %d lines, %.0f%% similar, %s, score %.1f', + $index + 1, $clone['lines'], $clone['similarity'] * 100, $clone['source'], $clone['score'], ) . PHP_EOL); + fwrite(STDERR, sprintf( + ' %s:%d-%d', + $first['file'], + $first['start_line'], + $first['end_line'], + ) . PHP_EOL); foreach (array_slice($clone['occurrences'], 1) as $occurrence) { - fwrite(STDERR, sprintf(' %s:%d-%d', $occurrence['file'], $occurrence['start_line'], $occurrence['end_line']) . PHP_EOL); + fwrite(STDERR, sprintf(' %s:%d-%d', $occurrence['file'], $occurrence['start_line'], $occurrence['end_line']) . PHP_EOL); } } - fwrite(STDERR, sprintf('%.2f%% duplicated lines.', $result['duplicate_percentage']) . PHP_EOL); + fwrite(STDERR, Ansi::color(sprintf('%.2f%% duplicated lines.', $result['duplicate_percentage']), 'yellow', STDERR) . PHP_EOL); } } diff --git a/src/SyntaxChecker.php b/src/SyntaxChecker.php index 9bf07fd..9b5d2d7 100644 --- a/src/SyntaxChecker.php +++ b/src/SyntaxChecker.php @@ -4,15 +4,23 @@ namespace Infocyph\PHPProbe; +use Infocyph\PHPProbe\Config\CliOptions; +use Infocyph\PHPProbe\Console\Ansi; use Infocyph\PHPProbe\Config\Paths; use Infocyph\PHPProbe\Config\PhpProbeConfig; -use Infocyph\PHPProbe\Config\PresetRepository; use Infocyph\PHPProbe\Filesystem\PhpFileFinder; use Infocyph\PHPProbe\Process\ProcessResult; use Infocyph\PHPProbe\Process\ProcRunner; final class SyntaxChecker { + private CliOptions $cli; + + public function __construct() + { + $this->cli = new CliOptions(); + } + /** * @param list $paths */ @@ -20,7 +28,7 @@ public function run(array $paths): int { try { $options = $this->parseArgs($paths); - } catch (\InvalidArgumentException $exception) { + } catch (\InvalidArgumentException|\RuntimeException $exception) { fwrite(STDERR, $exception->getMessage() . PHP_EOL); return 2; @@ -41,95 +49,6 @@ public function run(array $paths): int return $this->lintFiles($files); } - /** - * @param list $args - * @param array{help:bool,config:string,preset:string,paths:list,excludes:list} $options - */ - private function consumeConfigOption(array $args, int &$index, string $arg, array &$options): bool - { - $config = $this->optionValue($arg, '--config'); - - if ($config !== null) { - $options['config'] = $config; - - return true; - } - - if ($arg !== '--config') { - return false; - } - - if (isset($args[$index + 1])) { - $options['config'] = $args[++$index]; - } - - return true; - } - - /** - * @param list $args - * @param array{help:bool,config:string,preset:string,paths:list,excludes:list} $options - */ - private function consumeExcludeOption(array $args, int &$index, string $arg, array &$options): bool - { - $exclude = $this->optionValue($arg, '--exclude'); - - if ($exclude !== null) { - if ($exclude !== '') { - $options['excludes'][] = $exclude; - } - - return true; - } - - if ($arg !== '--exclude') { - return false; - } - - if (isset($args[$index + 1]) && $args[$index + 1] !== '') { - $options['excludes'][] = $args[++$index]; - } - - return true; - } - - /** - * @param list $args - * @param array{help:bool,config:string,preset:string,paths:list,excludes:list} $options - */ - private function consumePresetOption(array $args, int &$index, string $arg, array &$options): bool - { - $preset = $this->optionValue($arg, '--preset'); - - if ($preset !== null) { - $options['preset'] = $preset; - - return true; - } - - if ($arg !== '--preset') { - return false; - } - - if (isset($args[$index + 1])) { - $options['preset'] = $args[++$index]; - } - - return true; - } - - private function configWithPreset(PhpProbeConfig $config, string $cliPreset): PhpProbeConfig - { - $repository = new PresetRepository(); - $configPreset = $config->preset(); - - if (is_string($configPreset) && $configPreset !== '') { - $config = $repository->config($configPreset)->merge($config); - } - - return $cliPreset !== '' ? $config->merge($repository->config($cliPreset)) : $config; - } - private function help(): int { fwrite(STDOUT, implode(PHP_EOL, [ @@ -178,74 +97,100 @@ private function lintFiles(array $files): int } if ($failures === []) { - fwrite(STDOUT, sprintf('Syntax OK: %d PHP files checked.', count($files)) . PHP_EOL); + fwrite(STDOUT, Ansi::color(sprintf('Syntax OK: %d PHP files checked.', count($files)), 'green', STDOUT) . PHP_EOL); return 0; } - fwrite(STDERR, sprintf('Syntax errors in %d file(s):', count($failures)) . PHP_EOL); + fwrite(STDERR, Ansi::color(sprintf('Syntax errors in %d file(s):', count($failures)), 'red', STDERR) . PHP_EOL); foreach ($failures as [$file, $message]) { - fwrite(STDERR, "- {$file}" . PHP_EOL . $message . PHP_EOL); - } - - return 1; - } + fwrite(STDERR, ' ' . Ansi::color($file, 'cyan', STDERR) . PHP_EOL); - private function optionValue(string $arg, string $name): ?string - { - if (!str_starts_with($arg, $name . '=')) { - return null; + foreach (preg_split('/\R/', trim($message)) ?: [] as $line) { + if ($line !== '') { + fwrite(STDERR, ' ' . $line . PHP_EOL); + } + } } - return substr($arg, strlen($name) + 1); + return 1; } /** * @param list $args - * @return array{help:bool,config:string,preset:string,paths:list,excludes:list} + * @return array{help:bool,config:string,paths:list,excludes:list} */ private function parseArgs(array $args): array { $options = [ 'help' => false, 'config' => Paths::config('phpprobe.json'), - 'preset' => '', 'paths' => [], 'excludes' => [], ]; - + $options['config'] = $this->cli->configPath($args, $options['config']); + $config = $this->cli->mergeConfigWithPreset(PhpProbeConfig::fromFile($options['config']), $this->cli->presetName($args)); + $options = $config->applySyntaxOptions($options); + $configuredPaths = $options['paths']; + $options['paths'] = []; + $index = 0; $argCount = count($args); + $collectingPathsOnly = false; - for ($index = 0; $index < $argCount; $index++) { + while ($index < $argCount) { $arg = $args[$index]; - if ($arg === '--help' || $arg === '-h') { - $options['help'] = true; + if ($collectingPathsOnly) { + $options['paths'][] = $arg; + $index++; continue; } - if ($this->consumeConfigOption($args, $index, $arg, $options) - || $this->consumePresetOption($args, $index, $arg, $options) - || $this->consumeExcludeOption($args, $index, $arg, $options)) { + if ($arg === '--') { + $collectingPathsOnly = true; + $index++; + continue; } - $options['paths'][] = $arg; - } + if (!$this->cli->skipConfig($args, $index, $arg) + && !$this->cli->skipPreset($args, $index, $arg) + && !$this->parseCliOption($args, $index, $options, $arg)) { + if (str_starts_with($arg, '-')) { + throw new \InvalidArgumentException(sprintf('Unknown option for syntax command: %s', $arg)); + } - $config = $this->configWithPreset(PhpProbeConfig::fromFile($options['config']), $options['preset']); + $options['paths'][] = $arg; + } - if ($options['paths'] === []) { - $options['paths'] = $config->syntaxPaths(); + $index++; } - $options['excludes'] = array_values(array_unique([ - ...$config->syntaxExcludes(), - ...$options['excludes'], - ])); + if ($options['paths'] === []) { + $options['paths'] = $configuredPaths; + } return $options; } + + /** + * @param list $args + * @param array{help:bool,config:string,paths:list,excludes:list} $options + */ + private function parseCliOption(array $args, int &$index, array &$options, string $arg): bool + { + if ($this->cli->parseExclude($args, $index, $options, $arg)) { + return true; + } + + if ($arg === '--help' || $arg === '-h') { + $options['help'] = true; + + return true; + } + + return false; + } } diff --git a/src/Util/ProjectPath.php b/src/Util/ProjectPath.php new file mode 100644 index 0000000..b94dde4 --- /dev/null +++ b/src/Util/ProjectPath.php @@ -0,0 +1,26 @@ +and($ids)->not()->toContain('class Demo\Hidden'); }); +it('rejects unknown api command options', function (): void { + $root = makeApiSnapshotCheckerFixture(); + + try { + $run = runApiSnapshotCheckerCommand($root, ['--does-not-exist']); + } finally { + removeApiSnapshotCheckerFixture($root); + } + + expect($run['exitCode'])->toBe(2) + ->and($run['stderr'])->toContain('Unknown option for api command: --does-not-exist'); +}); + +it('fails when api baseline file is missing', function (): void { + $root = makeApiSnapshotCheckerFixture(); + $src = $root.DIRECTORY_SEPARATOR.'src'; + $missingBaseline = $root.DIRECTORY_SEPARATOR.'missing-api-baseline.json'; + + mkdir($src, 0755, true); + file_put_contents($src.DIRECTORY_SEPARATOR.'Contract.php', apiContractFixture('string')); + + try { + $run = runApiSnapshotCheckerCommand($root, ['--json', '--baseline='.$missingBaseline, 'src']); + } finally { + removeApiSnapshotCheckerFixture($root); + } + + expect($run['exitCode'])->toBe(2) + ->and($run['stderr'])->toContain('API baseline file not found'); +}); + +it('fails when api baseline JSON is invalid', function (): void { + $root = makeApiSnapshotCheckerFixture(); + $src = $root.DIRECTORY_SEPARATOR.'src'; + $baseline = $root.DIRECTORY_SEPARATOR.'api-baseline.json'; + + mkdir($src, 0755, true); + file_put_contents($src.DIRECTORY_SEPARATOR.'Contract.php', apiContractFixture('string')); + file_put_contents($baseline, '{invalid'); + + try { + $run = runApiSnapshotCheckerCommand($root, ['--json', '--baseline='.$baseline, 'src']); + } finally { + removeApiSnapshotCheckerFixture($root); + } + + expect($run['exitCode'])->toBe(2) + ->and($run['stderr'])->toContain('Invalid API baseline JSON'); +}); + function apiContractFixture(string $returnType, string $protectedReturnType = 'int'): string { return <<toBe(1) + ->and($result['findings'])->not()->toBeEmpty() + ->and($result['findings'][0]['type'])->toBe('comment_marker') + ->and($result['findings'][0]['tag'])->toBe('SECURITY'); +}); + +it('reports commented-out code without reason', function (): void { + $root = makeCommentCheckerFixture(); + $src = $root.DIRECTORY_SEPARATOR.'src'; + + mkdir($src, 0755, true); + file_put_contents($src.DIRECTORY_SEPARATOR.'NoReason.php', <<<'PHP' +toBe(1) + ->and(array_column($result['findings'], 'type'))->toContain('commented_out_code_without_reason'); +}); + +it('accepts commented-out code with a valid tagged reason', function (): void { + $root = makeCommentCheckerFixture(); + $src = $root.DIRECTORY_SEPARATOR.'src'; + + mkdir($src, 0755, true); + file_put_contents($src.DIRECTORY_SEPARATOR.'ValidReason.php', <<<'PHP' +issue($payload); +final class ValidReason +{ +} +PHP); + + try { + $run = runCommentCheckerCommand($root, ['--json', '--fail-on=warning', 'src']); + } finally { + removeCommentCheckerFixture($root); + } + + $result = json_decode($run['stdout'], true); + $types = array_column($result['findings'], 'type'); + + expect($run['exitCode'])->toBe(0) + ->and($types)->toContain('commented_out_code_with_valid_reason'); +}); + +it('reports oversized commented-out blocks', function (): void { + $root = makeCommentCheckerFixture(); + $src = $root.DIRECTORY_SEPARATOR.'src'; + + mkdir($src, 0755, true); + $lines = implode(PHP_EOL, array_map(static fn(int $line): string => '// $value'.$line.' = '.$line.';', range(1, 11))); + file_put_contents($src.DIRECTORY_SEPARATOR.'TooLarge.php', <<toBe(1) + ->and(array_column($result['findings'], 'type'))->toContain('commented_out_code_block_too_large'); +}); + +it('reports weak tagged reasons', function (): void { + $root = makeCommentCheckerFixture(); + $src = $root.DIRECTORY_SEPARATOR.'src'; + + mkdir($src, 0755, true); + file_put_contents($src.DIRECTORY_SEPARATOR.'WeakReason.php', <<<'PHP' +oldMethod(); +final class WeakReason +{ +} +PHP); + + try { + $run = runCommentCheckerCommand($root, ['--json', '--fail-on=warning', 'src']); + } finally { + removeCommentCheckerFixture($root); + } + + $result = json_decode($run['stdout'], true); + + expect($run['exitCode'])->toBe(1) + ->and(array_column($result['findings'], 'type'))->toContain('commented_out_code_with_weak_reason'); +}); + +it('requires an issue reference for long commented-out blocks', function (): void { + $root = makeCommentCheckerFixture(); + $src = $root.DIRECTORY_SEPARATOR.'src'; + + mkdir($src, 0755, true); + file_put_contents($src.DIRECTORY_SEPARATOR.'IssueRequired.php', <<<'PHP' +setMode('safe'); +// $gateway->charge($invoice); +// $gateway->close(); +final class IssueRequired +{ +} +PHP); + + try { + $run = runCommentCheckerCommand($root, ['--json', '--fail-on=warning', 'src']); + } finally { + removeCommentCheckerFixture($root); + } + + $result = json_decode($run['stdout'], true); + + expect($run['exitCode'])->toBe(1) + ->and(array_column($result['findings'], 'type'))->toContain('commented_out_code_requires_issue_reference'); +}); + +it('ignores PHPDoc usage examples with an example label', function (): void { + $root = makeCommentCheckerFixture(); + $src = $root.DIRECTORY_SEPARATOR.'src'; + + mkdir($src, 0755, true); + file_put_contents($src.DIRECTORY_SEPARATOR.'DocExample.php', <<<'PHP' +toString(); + */ +final class DocExample +{ +} +PHP); + + try { + $run = runCommentCheckerCommand($root, ['--json', '--fail-on=warning', 'src']); + } finally { + removeCommentCheckerFixture($root); + } + + $result = json_decode($run['stdout'], true); + $types = array_column($result['findings'], 'type'); + + expect($run['exitCode'])->toBe(0) + ->and($types)->not()->toContain('commented_out_code_in_phpdoc_without_example_label'); +}); + +it('rejects unknown comment checker options', function (): void { + $root = makeCommentCheckerFixture(); + + try { + $run = runCommentCheckerCommand($root, ['--does-not-exist']); + } finally { + removeCommentCheckerFixture($root); + } + + expect($run['exitCode'])->toBe(2) + ->and($run['stderr'])->toContain('Unknown option for comments command: --does-not-exist'); +}); + +function makeCommentCheckerFixture(): string +{ + $root = sys_get_temp_dir().DIRECTORY_SEPARATOR.'phpprobe-comments-'.uniqid('', true); + $resources = $root.DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR.'infocyph'.DIRECTORY_SEPARATOR.'phpprobe'.DIRECTORY_SEPARATOR.'resources'; + + mkdir($root, 0755, true); + mkdir($resources, 0755, true); + copy( + dirname(__DIR__, 2).DIRECTORY_SEPARATOR.'resources'.DIRECTORY_SEPARATOR.'phpprobe.json', + $resources.DIRECTORY_SEPARATOR.'phpprobe.json', + ); + + $presetSource = dirname(__DIR__, 2).DIRECTORY_SEPARATOR.'resources'.DIRECTORY_SEPARATOR.'presets'; + $presetTarget = $resources.DIRECTORY_SEPARATOR.'presets'; + mkdir($presetTarget, 0755, true); + + foreach (glob($presetSource.DIRECTORY_SEPARATOR.'*.json') ?: [] as $preset) { + copy($preset, $presetTarget.DIRECTORY_SEPARATOR.basename($preset)); + } + + return $root; +} + +function removeCommentCheckerFixture(string $root): void +{ + if (!is_dir($root)) { + return; + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST, + ); + + foreach ($iterator as $item) { + if ($item->isDir()) { + rmdir($item->getPathname()); + + continue; + } + + unlink($item->getPathname()); + } + + rmdir($root); +} + +/** + * @param list $args + * @return array{exitCode:int,stdout:string,stderr:string} + */ +function runCommentCheckerCommand(string $cwd, array $args): array +{ + $binary = dirname(__DIR__, 2).DIRECTORY_SEPARATOR.'bin' . DIRECTORY_SEPARATOR . 'phpprobe'; + $process = proc_open([PHP_BINARY, $binary, 'comments', ...$args], [ + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ], $pipes, $cwd); + + if (! is_resource($process)) { + throw new RuntimeException('Could not start comment checker.'); + } + + $stdout = stream_get_contents($pipes[1]) ?: ''; + $stderr = stream_get_contents($pipes[2]) ?: ''; + fclose($pipes[1]); + fclose($pipes[2]); + + return [ + 'exitCode' => proc_close($process), + 'stdout' => $stdout, + 'stderr' => $stderr, + ]; +} diff --git a/tests/Feature/DuplicateCheckerTest.php b/tests/Feature/DuplicateCheckerTest.php index 0f43a96..6aa6077 100644 --- a/tests/Feature/DuplicateCheckerTest.php +++ b/tests/Feature/DuplicateCheckerTest.php @@ -345,6 +345,58 @@ public function value(): int ->and($run['stderr'])->toContain('Unknown PHPProbe preset "unknown"'); }); +it('rejects unknown duplicate command options', function (): void { + $root = makeDuplicateCheckerFixture(); + + try { + $run = runDuplicateCheckerCommand($root, ['--does-not-exist']); + } finally { + removeDuplicateCheckerFixture($root); + } + + expect($run['exitCode'])->toBe(2) + ->and($run['stderr'])->toContain('Unknown option for duplicates command: --does-not-exist'); +}); + +it('fails when duplicate baseline file is missing', function (): void { + $root = makeDuplicateCheckerFixture(); + $src = $root.DIRECTORY_SEPARATOR.'src'; + $missingBaseline = $root.DIRECTORY_SEPARATOR.'missing-baseline.json'; + + mkdir($src, 0755, true); + file_put_contents($src.DIRECTORY_SEPARATOR.'One.php', duplicateBaselineFixture('One')); + file_put_contents($src.DIRECTORY_SEPARATOR.'Two.php', duplicateBaselineFixture('Two')); + + try { + $run = runDuplicateCheckerCommand($root, ['--json', '--fuzzy', '--min-lines=5', '--min-tokens=20', '--baseline='.$missingBaseline, 'src']); + } finally { + removeDuplicateCheckerFixture($root); + } + + expect($run['exitCode'])->toBe(2) + ->and($run['stderr'])->toContain('Duplicate baseline file not found'); +}); + +it('fails when duplicate baseline JSON is invalid', function (): void { + $root = makeDuplicateCheckerFixture(); + $src = $root.DIRECTORY_SEPARATOR.'src'; + $baseline = $root.DIRECTORY_SEPARATOR.'duplicates-baseline.json'; + + mkdir($src, 0755, true); + file_put_contents($src.DIRECTORY_SEPARATOR.'One.php', duplicateBaselineFixture('One')); + file_put_contents($src.DIRECTORY_SEPARATOR.'Two.php', duplicateBaselineFixture('Two')); + file_put_contents($baseline, '{invalid'); + + try { + $run = runDuplicateCheckerCommand($root, ['--json', '--fuzzy', '--min-lines=5', '--min-tokens=20', '--baseline='.$baseline, 'src']); + } finally { + removeDuplicateCheckerFixture($root); + } + + expect($run['exitCode'])->toBe(2) + ->and($run['stderr'])->toContain('Invalid duplicate baseline JSON'); +}); + function duplicateBaselineFixture(string $class): string { return <<and($run['stdout'])->toContain('Syntax OK: 1 PHP files checked.'); }); +it('rejects unknown syntax command options', function (): void { + $root = makeSyntaxCheckerFixture(); + + try { + $run = runSyntaxCheckerCommand($root, ['--does-not-exist']); + } finally { + removeSyntaxCheckerFixture($root); + } + + expect($run['exitCode'])->toBe(2) + ->and($run['stderr'])->toContain('Unknown option for syntax command: --does-not-exist'); +}); + /** * @return array{exitCode:int,stdout:string,stderr:string} */ From 58a4c6b2884341e6ecaca9ad50efc5b76ae954a8 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 6 May 2026 16:05:27 +0600 Subject: [PATCH 2/6] comment detect --- .github/workflows/ci.yml | 18 +- .gitignore | 2 + README.md | 52 ++- bin/phpprobe | 1 + resources/.phpprobe-duplicates-baseline.json | 59 ++- src/ApiSnapshotChecker.php | 222 +++++++++-- src/Comment/CommentScanner.php | 128 +++++- src/CommentChecker.php | 356 ++++++++++++----- src/Config/CliOptions.php | 112 ++++++ src/Config/PhpProbeConfig.php | 203 +++++++--- src/DuplicateChecker.php | 399 ++++++++++++++++--- src/Filesystem/PhpFileFinder.php | 78 +++- src/SyntaxChecker.php | 337 ++++++++++++++-- src/Util/SummaryJson.php | 30 ++ tests/Feature/ApiSnapshotCheckerTest.php | 42 ++ tests/Feature/CommentCheckerTest.php | 25 ++ tests/Feature/DuplicateCheckerTest.php | 28 ++ tests/Feature/SyntaxCheckerTest.php | 32 ++ 18 files changed, 1829 insertions(+), 295 deletions(-) create mode 100644 src/Util/SummaryJson.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea01fae..ed1147c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,17 +45,27 @@ jobs: - name: Run tests run: composer test + - name: Prepare report directory + run: mkdir -p build/reports + - name: Run syntax checker - run: composer lint + run: php bin/phpprobe syntax --config=resources/phpprobe.json --format=markdown --summary-json=build/reports/syntax-summary.json src tests > build/reports/syntax.md - name: Run duplicate checker - run: composer duplicates + run: php bin/phpprobe duplicates --preset=standard --config=resources/phpprobe.json --baseline=resources/.phpprobe-duplicates-baseline.json --format=markdown --summary-json=build/reports/duplicates-summary.json src tests > build/reports/duplicates.md - name: Run API checker - run: composer api + run: php bin/phpprobe api --config=resources/phpprobe.json --format=markdown --summary-json=build/reports/api-summary.json src tests > build/reports/api.md - name: Run comment checker - run: composer comments + run: php bin/phpprobe comments --config=resources/phpprobe.json --format=markdown --summary-json=build/reports/comments-summary.json src tests > build/reports/comments.md + + - name: Upload checker reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: phpprobe-reports-php-${{ matrix.php }} + path: build/reports/ phpforge-integration: name: PHPForge Integration diff --git a/.gitignore b/.gitignore index 39a1b07..7e797b2 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,5 @@ test.php var vendor d2utmp* +.phpprobe-duplicates-cache.json +build/reports/ diff --git a/README.md b/README.md index 20fa78f..10fbf65 100644 --- a/README.md +++ b/README.md @@ -39,13 +39,17 @@ For checker subcommands (`syntax`, `duplicates`, `api`, `comments`), unknown opt ```bash php vendor/bin/phpprobe syntax +php vendor/bin/phpprobe syntax --format=markdown --parallel=4 src php vendor/bin/phpprobe duplicates php vendor/bin/phpprobe duplicates --json +php vendor/bin/phpprobe duplicates --summary-json=build/duplicates-summary.json src php vendor/bin/phpprobe duplicates --preset=strict --json src php vendor/bin/phpprobe api --write-baseline=.phpprobe-api-baseline.json src php vendor/bin/phpprobe api --baseline=.phpprobe-api-baseline.json src +php vendor/bin/phpprobe api --fail-on=error --format=markdown --baseline=.phpprobe-api-baseline.json src php vendor/bin/phpprobe comments --fail-on=warning src php vendor/bin/phpprobe comments --strict --json src +php vendor/bin/phpprobe comments --policy=strict --format=markdown src php vendor/bin/phpprobe presets php vendor/bin/phpprobe preset phpstorm ``` @@ -225,6 +229,12 @@ Options: | `--config` | `--config=FILE` or `--config FILE` | Read checker settings from a specific config file. | | `--preset` | `--preset=NAME` or `--preset NAME` | Apply `phpstorm`, `standard`, or `strict` as a run-level preset. | | `--exclude` | `--exclude=PATH` or `--exclude PATH` | Exclude a path. Repeatable. | +| `--format` | `--format=text|json|markdown|sarif` | Output format. Default is `text`. | +| `--json` | flag | Alias for `--format=json`. | +| `--summary-json` | `--summary-json=FILE` | Write a machine-readable run summary JSON. | +| `--changed-only` | flag | Scan only changed PHP files from Git diff. | +| `--changed-base` | `--changed-base=REF` | Base ref used with `--changed-only`. | +| `--parallel` | `--parallel=N` | Parallel lint worker count. Default is `1`. | | `--help`, `-h` | flag | Print syntax checker help and exit `0`. | Path behavior: @@ -238,8 +248,8 @@ Output and exits: | Condition | Stream | Exit | | --- | --- | --- | -| No PHP files found | `stdout`: `No PHP files found.` | `0` | -| All files pass | `stdout`: `Syntax OK: N PHP files checked.` | `0` | +| No PHP files found | `stdout`: `No PHP files found.` plus summary | `0` | +| All files pass | `stdout`: `Syntax OK: N PHP files checked.` plus summary | `0` | | One or more files fail | `stderr`: failing file list plus lint output | `1` | | Unknown option or runtime config error | `stderr`: error | `2` | | Unknown preset | `stderr`: preset error | `2` | @@ -261,9 +271,14 @@ Options: | `--config` | `--config=FILE` or `--config FILE` | Read checker settings from a specific config file. | | `--preset` | `--preset=NAME` or `--preset NAME` | Apply `phpstorm`, `standard`, or `strict` as a run-level preset. | | `--exclude` | `--exclude=PATH` or `--exclude PATH` | Exclude a path. Repeatable. | -| `--json` | flag | Emit machine-readable JSON to `stdout`. | +| `--format` | `--format=text|json|markdown|sarif` | Output format. Default is `text`. | +| `--json` | flag | Alias for `--format=json`. | | `--strict` | flag | Escalate commented-out-code policy severities. | +| `--policy` | `--policy=relaxed|standard|strict` | Comment policy profile. | | `--fail-on` | `--fail-on=error|warning|info` | Control failure threshold (default: `error`). | +| `--summary-json` | `--summary-json=FILE` | Write a machine-readable run summary JSON. | +| `--changed-only` | flag | Scan only changed PHP files from Git diff. | +| `--changed-base` | `--changed-base=REF` | Base ref used with `--changed-only`. | | `--tags` | `--tags=TODO,FIXME,...` | Override marker tags for marker detection. | | `--help`, `-h` | flag | Print comments checker help and exit `0`. | @@ -289,13 +304,14 @@ Policy-to-finding mapping: | Issue reference required for long blocks | `commented_out_code_requires_issue_reference` | | Oversized block disallowed | `commented_out_code_block_too_large` | | PHPDoc code without clear example label | `commented_out_code_in_phpdoc_without_example_label` | +| Invalid suppression directive | `invalid_suppression_rule` | | Explicitly valid tagged reason (informational) | `commented_out_code_with_valid_reason` | Output and exits: | Condition | Stream | Exit | | --- | --- | --- | -| No failing findings at threshold | `stdout`: summary (or JSON) | `0` | +| No failing findings at threshold | `stdout`: summary (or JSON/markdown/SARIF) | `0` | | Findings at or above threshold | `stderr`: text report (or JSON on `stdout`) | `1` | | Unknown option or runtime config error | `stderr`: error | `2` | @@ -320,7 +336,12 @@ Options: | `--include-protected` | flag | Include protected members. This is the default. | | `--baseline` | `--baseline=FILE` | Compare the current API against a snapshot file. | | `--write-baseline` | `--write-baseline`, `--write-baseline=FILE` | Write the current API snapshot and exit `0`. Bare flag writes `.phpprobe-api-baseline.json`. | -| `--json` | flag | Emit machine-readable JSON to `stdout`. | +| `--format` | `--format=text|json|markdown|sarif` | Output format. Default is `text`. | +| `--json` | flag | Alias for `--format=json`. | +| `--fail-on` | `--fail-on=error|warning|info` | Failure threshold for API drift. Default is `warning`. | +| `--summary-json` | `--summary-json=FILE` | Write a machine-readable run summary JSON. | +| `--changed-only` | flag | Scan only changed PHP files from Git diff. | +| `--changed-base` | `--changed-base=REF` | Base ref used with `--changed-only`. | | `--help`, `-h` | flag | Print API checker help and exit `0`. | Path behavior: @@ -345,8 +366,8 @@ Output and exits: | --- | --- | --- | | No baseline passed | `stdout`: `Public API snapshot OK: N symbol(s) scanned.` | `0` | | Baseline matches | `stdout`: `Public API unchanged: N symbol(s) scanned.` | `0` | -| Baseline differs | `stderr`: added/removed/changed symbol list | `1` | -| `--json` | `stdout`: JSON result | `0` or `1`, depending on drift | +| Baseline differs | `stderr`: added/removed/changed symbol list | `1` by default, `0` when `--fail-on=error` | +| `--format=json|markdown|sarif` | `stdout`: selected format payload | `0` or `1`, depending on drift and fail-on | | `--write-baseline` | `stdout`: baseline message or JSON result | `0` | | Unknown option or runtime config/baseline error | `stderr`: error | `2` | | Unknown preset | `stderr`: preset error | `2` | @@ -379,7 +400,15 @@ Options: | `--no-fuzzy` | flag | Disable fuzzy identifier/call normalization. | | `--baseline` | `--baseline=FILE` | Suppress clone groups whose fingerprints are already in a baseline file. | | `--write-baseline` | `--write-baseline`, `--write-baseline=FILE` | Write current clone fingerprints to a baseline and exit `0`. Bare flag writes `.phpprobe-duplicates-baseline.json`. | -| `--json` | flag | Emit machine-readable JSON to `stdout`. | +| `--format` | `--format=text|json|markdown|sarif` | Output format. Default is `text`. | +| `--json` | flag | Alias for `--format=json`. | +| `--fail-on` | `--fail-on=error|warning|info` | Failure threshold. Default is `warning`. | +| `--error-duplicate-percentage` | `--error-duplicate-percentage=N` | Error threshold used when `--fail-on=error`. Default `20`. | +| `--summary-json` | `--summary-json=FILE` | Write a machine-readable run summary JSON. | +| `--changed-only` | flag | Scan only changed PHP files from Git diff. | +| `--changed-base` | `--changed-base=REF` | Base ref used with `--changed-only`. | +| `--no-cache` | flag | Disable duplicate result cache. | +| `--cache-file` | `--cache-file=FILE` | Duplicate result cache path. | | `--help`, `-h` | flag | Print duplicate checker help and exit `0`. | Exact accepted forms matter: numeric options, `--mode`, `--baseline` and valued `--write-baseline=FILE` are parsed in equals form. `--config`, `--preset` and `--exclude` also accept split form. `--write-baseline` may also be passed as a bare flag. @@ -400,10 +429,9 @@ Output and exits: | Condition | Stream | Exit | | --- | --- | --- | -| No clone groups after baseline suppression | `stdout`: `No new duplicated code found (...)` | `0` | -| Clone groups found | `stderr`: text report | `1` | -| `--json` with no clones | `stdout`: JSON result | `0` | -| `--json` with clones | `stdout`: JSON result | `1` | +| No clone groups after baseline suppression | `stdout`: `No new duplicated code found (...)` plus summary | `0` | +| Clone groups found | `stderr`: text report plus summary | `1` by default | +| `--format=json|markdown|sarif` | `stdout`: selected format payload | depends on clone findings and fail-on | | `--write-baseline` | `stdout`: baseline message or JSON result | `0` | | Unknown option or runtime config/baseline error | `stderr`: error | `2` | | Unknown preset | `stderr`: preset error | `2` | diff --git a/bin/phpprobe b/bin/phpprobe index 4de5206..85b4489 100644 --- a/bin/phpprobe +++ b/bin/phpprobe @@ -21,6 +21,7 @@ foreach ($autoloaders as $autoloader) { if (!class_exists(Cli::class)) { require __DIR__ . '/../src/Util/ArrayShape.php'; require __DIR__ . '/../src/Util/ProjectPath.php'; + require __DIR__ . '/../src/Util/SummaryJson.php'; require __DIR__ . '/../src/Console/Ansi.php'; require __DIR__ . '/../src/Process/ProcessResult.php'; require __DIR__ . '/../src/Process/ProcRunner.php'; diff --git a/resources/.phpprobe-duplicates-baseline.json b/resources/.phpprobe-duplicates-baseline.json index 1ccf747..4c04718 100644 --- a/resources/.phpprobe-duplicates-baseline.json +++ b/resources/.phpprobe-duplicates-baseline.json @@ -1,36 +1,71 @@ { "version": 1, - "generated_at": "2026-05-06T07:12:18+00:00", + "generated_at": "2026-05-06T09:35:59+00:00", "clones": [ { - "fingerprint": "84cc019f15701ac134fd8026b7f6715b21317758be3b29d502b5840479e5bc9a", + "fingerprint": "65edf497c75023b2bd013f4bb1f770a5e43e88ce8e37922c7f9d4d0e88b063c7", "source": "tokens", - "score": 222.4 + "score": 224.4 }, { - "fingerprint": "d19a26ec1dc7b405759627c036003d71f0fd0780e5ba4ac917ccb18149fa1374", + "fingerprint": "0bffdd382bce7ed3caff6476cc3321d143a17c67653db094cd405fb72538effe", "source": "tokens", - "score": 215.4 + "score": 188.6 }, { - "fingerprint": "e7e18beed9abb811f1a43e463f6adc8347e40e49b87e9f7373adf6c44ce39e74", + "fingerprint": "6e7956bf0217046b225214da8669f784cb404b1f309f5cd33ea2b077b803733a", + "source": "tokens", + "score": 183.6 + }, + { + "fingerprint": "ea76cd0d5b42c4d4120d00eabbfb0953f78ffd1782abe77c74ddd84933ed6153", + "source": "tokens", + "score": 181.8 + }, + { + "fingerprint": "4d68193edc1c2b035b5b86e760e702616040481cfb62627858ed7341d948ec05", + "source": "tokens", + "score": 175.2 + }, + { + "fingerprint": "c18a55081eea7f8ed993650c968c9d325c43c289854a86d33d22019b90166751", "source": "tokens", "score": 172.6 }, { - "fingerprint": "c7b509af41b9ce03b9ae533ca1ada7e447e7fc7e6551b0434383aa00376eab1e", + "fingerprint": "de63e6b88a5859c1e66539bb6d56ed4433bd1611a4560b921ea7769de1f415ee", + "source": "tokens", + "score": 166.4 + }, + { + "fingerprint": "96021426570e587f8cf858fcc573ee46596dbe0ba36a7fc0d75f8ab8eeb81999", + "source": "tokens", + "score": 160.8 + }, + { + "fingerprint": "7295e29cf724e60bc160914ab685e55f33fc7286fb20c4ed5c4e0bd112125019", + "source": "tokens", + "score": 155.6 + }, + { + "fingerprint": "f5baf6b06984fc54d1f8233149c95bb135f50198fe1e8937c55780c73788c967", + "source": "tokens", + "score": 154.6 + }, + { + "fingerprint": "b27cfdf7ec1935f38205ad2bd03851bc6db371a5e4a4ad2b6c3a2d05baceb414", "source": "tokens", - "score": 158.6 + "score": 150 }, { - "fingerprint": "baabba091d3d58d65256e865b3033551632ad147f9e1d7b296e9787ed1f2d379", + "fingerprint": "8d1cdda29cbcb2080259ca519efd72373807ff055705bb866c3089e3f94156a9", "source": "tokens", - "score": 155.8 + "score": 137.4 }, { - "fingerprint": "397f1a223697f40e31f1b81ec38a9dd0620f0a046a685e568f1d304793be7adc", + "fingerprint": "988166654f6fea820c5213dbe12fc4ef36a8bf8c93fb28170493d7069920479b", "source": "tokens", - "score": 153 + "score": 131.6 } ] } diff --git a/src/ApiSnapshotChecker.php b/src/ApiSnapshotChecker.php index 2a8ad85..dd79017 100644 --- a/src/ApiSnapshotChecker.php +++ b/src/ApiSnapshotChecker.php @@ -6,10 +6,11 @@ use Infocyph\PHPProbe\Api\ApiSnapshotIndex; use Infocyph\PHPProbe\Config\CliOptions; -use Infocyph\PHPProbe\Console\Ansi; use Infocyph\PHPProbe\Config\Paths; use Infocyph\PHPProbe\Config\PhpProbeConfig; +use Infocyph\PHPProbe\Console\Ansi; use Infocyph\PHPProbe\Filesystem\PhpFileFinder; +use Infocyph\PHPProbe\Util\SummaryJson; final class ApiSnapshotChecker { @@ -35,7 +36,7 @@ public function run(array $args): int } /** - * @param array{help:bool,json:bool,config:string,preset:string,includeProtected:bool,baseline:string,writeBaseline:string,paths:list,excludes:list} $options + * @param array $options */ private function runWithOptions(array $options): int { @@ -43,7 +44,11 @@ private function runWithOptions(array $options): int return $this->help(); } - $files = (new PhpFileFinder())->find($options['paths'], $options['excludes']); + $files = (new PhpFileFinder())->find( + $options['paths'], + $options['excludes'], + ['changedOnly' => $options['changedOnly'], 'changedBase' => $options['changedBase']], + ); $snapshot = (new ApiSnapshotIndex())->build($files, ['includeProtected' => $options['includeProtected']]); $result = $this->result($snapshot, $options['baseline']); @@ -51,18 +56,26 @@ private function runWithOptions(array $options): int $this->writeBaseline($snapshot, $options['writeBaseline']); } - $this->writeResult($result, $options); + $failed = $this->shouldFail($result, $options); + $exitCode = $options['writeBaseline'] !== '' ? 0 : ($failed ? 1 : 0); + $this->writeResult($result, $options, $failed); + $this->writeSummaryJson($result, $options, $exitCode); - return $options['writeBaseline'] !== '' || !$result['changed'] ? 0 : 1; + return $exitCode; } + /** - * @return array{help:bool,json:bool,config:string,preset:string,includeProtected:bool,baseline:string,writeBaseline:string,paths:list,excludes:list} + * @return array */ private function defaultOptions(): array { return [ 'help' => false, - 'json' => false, + 'format' => 'text', + 'failOn' => 'warning', + 'summaryJson' => '', + 'changedOnly' => false, + 'changedBase' => '', 'config' => Paths::config('phpprobe.json'), 'preset' => '', 'includeProtected' => true, @@ -79,15 +92,20 @@ private function help(): int 'Usage: phpprobe api [options] [paths...]', '', 'Options:', - ' --config=FILE read PHPProbe checker settings', - ' --preset=NAME apply preset: phpstorm, standard, or strict', - ' --exclude=PATH skip a path (repeatable)', - ' --public-only ignore protected members', - ' --include-protected include protected members (default)', - ' --baseline=FILE compare against a public API snapshot', - ' --write-baseline[=FILE] write the current public API snapshot and exit 0', - ' --json output machine-readable JSON', - ' --help show this help', + ' --config=FILE read PHPProbe checker settings', + ' --preset=NAME apply preset: phpstorm, standard, or strict', + ' --exclude=PATH skip a path (repeatable)', + ' --public-only ignore protected members', + ' --include-protected include protected members (default)', + ' --baseline=FILE compare against a public API snapshot', + ' --write-baseline[=FILE] write the current public API snapshot and exit 0', + ' --format=text|json|markdown|sarif output format (default: text)', + ' --json alias for --format=json', + ' --fail-on=error|warning|info failure threshold (default: warning)', + ' --summary-json=FILE write machine-readable run summary', + ' --changed-only scan only changed PHP files from Git diff', + ' --changed-base=REF Git base ref used with --changed-only', + ' --help show this help', ]) . PHP_EOL); return 0; @@ -160,7 +178,7 @@ private function loadBaseline(string $path): array /** * @param list $args - * @return array{help:bool,json:bool,config:string,preset:string,includeProtected:bool,baseline:string,writeBaseline:string,paths:list,excludes:list} + * @return array */ private function parseArgs(array $args): array { @@ -214,7 +232,7 @@ private function parseArgs(array $args): array /** * @param list $args - * @param array{help:bool,json:bool,config:string,preset:string,includeProtected:bool,baseline:string,writeBaseline:string,paths:list,excludes:list} $options + * @param array $options */ private function parseCliOption(array $args, int &$index, array &$options, string $arg): bool { @@ -228,9 +246,19 @@ private function parseCliOption(array $args, int &$index, array &$options, strin return true; } - if ($arg === '--json') { - $options['json'] = true; + if ($this->cli->parseOutputFormat($options, $arg)) { + return true; + } + + if ($this->cli->parseFailOn($options, $arg)) { + return true; + } + + if ($this->cli->parseSummaryJson($options, $arg)) { + return true; + } + if ($this->cli->parseChangedOptions($options, $arg)) { return true; } @@ -291,6 +319,19 @@ private function result(array $snapshot, string $baselinePath): array ]; } + private function shouldFail(array $result, array $options): bool + { + if (($options['baseline'] ?? '') === '' || ($result['changed'] ?? false) !== true) { + return false; + } + + return match ($options['failOn']) { + 'error' => false, + 'warning', 'info' => true, + default => true, + }; + } + private function writeBaseline(array $snapshot, string $path): void { try { @@ -309,16 +350,32 @@ private function writeBaseline(array $snapshot, string $path): void /** * @param array{snapshot:array,baseline:array,changed:bool,changes:array{added:list,removed:list,changed:list}} $result - * @param array{json:bool,baseline:string,writeBaseline:string} $options + * @param array $options */ - private function writeResult(array $result, array $options): void + private function writeResult(array $result, array $options, bool $failed): void { - if ($options['json']) { - fwrite(STDOUT, json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL); + match ($options['format']) { + 'json' => $this->writeJson($result), + 'markdown' => $this->writeMarkdown($result, $options, $failed), + 'sarif' => $this->writeSarif($result), + default => $this->writeText($result, $options, $failed), + }; + } - return; - } + /** + * @param array{snapshot:array,baseline:array,changed:bool,changes:array{added:list,removed:list,changed:list}} $result + */ + private function writeJson(array $result): void + { + fwrite(STDOUT, json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL); + } + /** + * @param array{snapshot:array,baseline:array,changed:bool,changes:array{added:list,removed:list,changed:list}} $result + * @param array{baseline:string,writeBaseline:string,failOn:string} $options + */ + private function writeText(array $result, array $options, bool $failed): void + { if ($options['writeBaseline'] !== '') { fwrite(STDOUT, Ansi::color(sprintf('Public API baseline written: %s', $options['writeBaseline']), 'cyan', STDOUT) . PHP_EOL); } @@ -327,12 +384,14 @@ private function writeResult(array $result, array $options): void if ($options['baseline'] === '') { fwrite(STDOUT, Ansi::color(sprintf('Public API snapshot OK: %d symbol(s) scanned.', $symbolCount), 'green', STDOUT) . PHP_EOL); + fwrite(STDOUT, $this->summaryFooter($result, $options, $failed) . PHP_EOL); return; } if (!$result['changed']) { fwrite(STDOUT, Ansi::color(sprintf('Public API unchanged: %d symbol(s) scanned.', $symbolCount), 'green', STDOUT) . PHP_EOL); + fwrite(STDOUT, $this->summaryFooter($result, $options, $failed) . PHP_EOL); return; } @@ -354,5 +413,116 @@ private function writeResult(array $result, array $options): void fwrite(STDERR, sprintf(' - %s', $symbol) . PHP_EOL); } } + + fwrite(STDERR, $this->summaryFooter($result, $options, $failed) . PHP_EOL); + } + + /** + * @param array{snapshot:array,baseline:array,changed:bool,changes:array{added:list,removed:list,changed:list}} $result + * @param array{baseline:string,failOn:string} $options + */ + private function writeMarkdown(array $result, array $options, bool $failed): void + { + $symbolCount = count($result['snapshot']['symbols'] ?? []); + $lines = [ + '# PHPProbe API Snapshot Report', + '', + sprintf('- Symbols scanned: `%d`', $symbolCount), + sprintf('- Baseline: `%s`', $options['baseline'] !== '' ? $options['baseline'] : '(none)'), + sprintf('- Changed: `%s`', $result['changed'] ? 'yes' : 'no'), + sprintf('- Fail-on: `%s`', $options['failOn']), + sprintf('- Status: `%s`', $failed ? 'FAIL' : 'PASS'), + '', + ]; + + $lines[] = '| Change Type | Symbol |'; + $lines[] = '| --- | --- |'; + + foreach (['added', 'removed', 'changed'] as $type) { + foreach ($result['changes'][$type] as $symbol) { + $lines[] = sprintf('| %s | `%s` |', ucfirst($type), $symbol); + } + } + + if ($result['changes']['added'] === [] && $result['changes']['removed'] === [] && $result['changes']['changed'] === []) { + $lines[] = '| None | - |'; + } + + fwrite(STDOUT, implode(PHP_EOL, $lines) . PHP_EOL); + } + + /** + * @param array{snapshot:array,baseline:array,changed:bool,changes:array{added:list,removed:list,changed:list}} $result + */ + private function writeSarif(array $result): void + { + $results = []; + + foreach (['added', 'removed', 'changed'] as $type) { + foreach ($result['changes'][$type] as $symbol) { + $results[] = [ + 'ruleId' => 'api_snapshot_' . $type, + 'level' => 'warning', + 'message' => ['text' => sprintf('Public API %s symbol: %s', $type, $symbol)], + ]; + } + } + + $payload = [ + 'version' => '2.1.0', + '$schema' => 'https://json.schemastore.org/sarif-2.1.0.json', + 'runs' => [[ + 'tool' => [ + 'driver' => [ + 'name' => 'PHPProbe', + 'informationUri' => 'https://github.com/infocyph/phpprobe', + ], + ], + 'results' => $results, + ]], + ]; + + fwrite(STDOUT, json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL); + } + + /** + * @param array{snapshot:array,baseline:array,changed:bool,changes:array{added:list,removed:list,changed:list}} $result + * @param array{baseline:string,failOn:string} $options + */ + private function summaryFooter(array $result, array $options, bool $failed): string + { + return sprintf( + 'Summary: symbols=%d changes=%d fail-on=%s baseline=%s status=%s', + count($result['snapshot']['symbols'] ?? []), + count($result['changes']['added']) + count($result['changes']['removed']) + count($result['changes']['changed']), + $options['failOn'], + $options['baseline'] !== '' ? 'on' : 'off', + $failed ? 'FAIL' : 'PASS', + ); + } + + /** + * @param array{snapshot:array,baseline:array,changed:bool,changes:array{added:list,removed:list,changed:list}} $result + * @param array{summaryJson:string,baseline:string,failOn:string} $options + */ + private function writeSummaryJson(array $result, array $options, int $exitCode): void + { + if ($options['summaryJson'] === '') { + return; + } + + SummaryJson::write($options['summaryJson'], [ + 'checker' => 'api', + 'exit_code' => $exitCode, + 'fail_on' => $options['failOn'], + 'has_baseline' => $options['baseline'] !== '', + 'symbols' => count($result['snapshot']['symbols'] ?? []), + 'changed' => $result['changed'], + 'changes' => [ + 'added' => count($result['changes']['added']), + 'removed' => count($result['changes']['removed']), + 'changed' => count($result['changes']['changed']), + ], + ]); } } diff --git a/src/Comment/CommentScanner.php b/src/Comment/CommentScanner.php index 6597076..47fc268 100644 --- a/src/Comment/CommentScanner.php +++ b/src/Comment/CommentScanner.php @@ -27,19 +27,25 @@ final class CommentScanner * allowBlankLineBetweenReasonAndCodeInBlock:bool, * allowPhpdocExamples:bool, * phpdocExampleLabels:list, + * suppressionEnabled?:bool, + * suppressionDirective?:string, * strict:bool, * typeSeverity:array, * strictSeverity:array * } $options - * @return array{files:int,findings:list} + * @return array{files:int,findings:list,suppressed_count:int} */ public function scan(array $files, array $options): array { $findings = []; + $suppressedCount = 0; foreach ($files as $file) { $comments = (new PhpCommentExtractor())->extract($file); - $findings = [...$findings, ...$this->scanMarkers($file, $comments, $options), ...$this->scanCommentedOutCode($file, $comments, $options)]; + $fileFindings = [...$this->scanMarkers($file, $comments, $options), ...$this->scanCommentedOutCode($file, $comments, $options)]; + ['findings' => $activeFindings, 'suppressed' => $suppressed] = $this->applySuppressions($file, $comments, $fileFindings, $options); + $suppressedCount += $suppressed; + $findings = [...$findings, ...$activeFindings]; } usort( @@ -47,7 +53,104 @@ public function scan(array $files, array $options): array static fn(CommentFinding $left, CommentFinding $right): int => [$left->file, $left->line, $left->endLine, $left->type] <=> [$right->file, $right->line, $right->endLine, $right->type], ); - return ['files' => count($files), 'findings' => $findings]; + return ['files' => count($files), 'findings' => $findings, 'suppressed_count' => $suppressedCount]; + } + + /** + * @param list $comments + * @param list $findings + * @param array{suppressionEnabled?:bool,suppressionDirective?:string,typeSeverity:array,strictSeverity:array,strict:bool} $options + * @return array{findings:list,suppressed:int} + */ + private function applySuppressions(string $file, array $comments, array $findings, array $options): array + { + if (($options['suppressionEnabled'] ?? true) !== true) { + return ['findings' => $findings, 'suppressed' => 0]; + } + + $directive = trim((string) ($options['suppressionDirective'] ?? '@phpprobe-ignore')); + $entries = []; + $knownRules = $this->knownSuppressionRules(); + $invalids = []; + + foreach ($comments as $comment) { + foreach ($this->commentLines($comment) as $line) { + if (!str_contains($line['text'], $directive)) { + continue; + } + + if (preg_match('/' . preg_quote($directive, '/') . '\s+([A-Za-z0-9_\-*.,]+)/', $line['text'], $matches) !== 1) { + $invalids[] = $this->finding( + $file, + $line['line'], + $line['line'], + 'invalid_suppression_rule', + sprintf('Invalid suppression directive format. Expected "%s RULE_ID".', $directive), + $options, + raw: $line['text'], + ); + + continue; + } + + $tokens = array_values(array_filter(array_map('trim', explode(',', $matches[1])), static fn(string $value): bool => $value !== '')); + $rules = []; + + foreach ($tokens as $token) { + if ($token === '*') { + $rules[] = '*'; + + continue; + } + + if (!in_array($token, $knownRules, true)) { + $invalids[] = $this->finding( + $file, + $line['line'], + $line['line'], + 'invalid_suppression_rule', + sprintf('Unknown suppression rule id "%s".', $token), + $options, + raw: $line['text'], + ); + + continue; + } + + $rules[] = $token; + } + + if ($rules !== []) { + $entries[] = ['line' => $line['line'], 'end_line' => $line['line'] + 1, 'rules' => $rules]; + } + } + } + + $active = []; + $suppressed = 0; + + foreach ($findings as $finding) { + $matched = false; + + foreach ($entries as $entry) { + if ($finding->line < $entry['line'] || $finding->line > $entry['end_line']) { + continue; + } + + if (in_array('*', $entry['rules'], true) || in_array($finding->type, $entry['rules'], true)) { + $matched = true; + $suppressed++; + + break; + } + } + + if (!$matched) { + $active[] = $finding; + } + } + + return ['findings' => [...$active, ...$invalids], 'suppressed' => $suppressed]; } /** @@ -776,4 +879,23 @@ private function typeSeverity(string $type, array $options): string return $options['typeSeverity'][$type] ?? 'info'; } + + /** + * @return list + */ + private function knownSuppressionRules(): array + { + return [ + 'comment_marker', + 'commented_out_code_without_reason', + 'commented_out_code_without_valid_tag', + 'commented_out_code_without_valid_reason', + 'commented_out_code_with_weak_reason', + 'commented_out_code_with_valid_reason', + 'commented_out_code_block_too_large', + 'commented_out_code_requires_issue_reference', + 'commented_out_code_in_phpdoc_without_example_label', + 'invalid_suppression_rule', + ]; + } } diff --git a/src/CommentChecker.php b/src/CommentChecker.php index 7b9f43c..9607c7d 100644 --- a/src/CommentChecker.php +++ b/src/CommentChecker.php @@ -7,10 +7,11 @@ use Infocyph\PHPProbe\Comment\CommentFinding; use Infocyph\PHPProbe\Comment\CommentScanner; use Infocyph\PHPProbe\Config\CliOptions; -use Infocyph\PHPProbe\Console\Ansi; use Infocyph\PHPProbe\Config\Paths; use Infocyph\PHPProbe\Config\PhpProbeConfig; +use Infocyph\PHPProbe\Console\Ansi; use Infocyph\PHPProbe\Filesystem\PhpFileFinder; +use Infocyph\PHPProbe\Util\SummaryJson; final class CommentChecker { @@ -33,10 +34,19 @@ public function run(array $args): int return $this->help(); } - $result = (new CommentScanner())->scan((new PhpFileFinder())->find($options['paths'], $options['excludes']), $options); - $this->writeResult($result, $options); + $files = (new PhpFileFinder())->find( + $options['paths'], + $options['excludes'], + ['changedOnly' => $options['changedOnly'], 'changedBase' => $options['changedBase']], + ); + $result = (new CommentScanner())->scan($files, $options); + $failed = $this->shouldFail($result['findings'], $options['failOn']); + $exitCode = $failed ? 1 : 0; + + $this->writeResult($result, $options, $failed); + $this->writeSummaryJson($result, $options, $exitCode); - return $this->shouldFail($result['findings'], $options['failOn']) ? 1 : 0; + return $exitCode; } catch (\InvalidArgumentException|\RuntimeException $exception) { fwrite(STDERR, $exception->getMessage() . PHP_EOL); @@ -45,44 +55,24 @@ public function run(array $args): int } /** - * @return array{ - * help:bool, - * json:bool, - * strict:bool, - * failOn:string, - * config:string, - * paths:list, - * excludes:list, - * scanMarkers:bool, - * markerTags:list, - * markerSeverity:array, - * commentedOutEnabled:bool, - * allowedReasonTags:list, - * optionalReasonTags:list, - * allowOptionalReasonTagsInStrictMode:bool, - * minReasonLength:int, - * maxAllowedBlockLines:int, - * requireIssueForBlocksLongerThan:int, - * allowedIssuePatterns:list, - * allowBlankLineBetweenReasonAndCode:bool, - * allowReasonBeforeBlockComment:bool, - * allowBlankLineBetweenReasonAndCodeInBlock:bool, - * allowPhpdocExamples:bool, - * phpdocExampleLabels:list, - * typeSeverity:array, - * strictSeverity:array - * } + * @return array */ private function defaultOptions(): array { return [ 'help' => false, - 'json' => false, + 'format' => 'text', 'strict' => false, 'failOn' => 'error', + 'summaryJson' => '', + 'changedOnly' => false, + 'changedBase' => '', 'config' => Paths::config('phpprobe.json'), 'paths' => [], 'excludes' => [], + 'policy' => 'standard', + 'suppressionEnabled' => true, + 'suppressionDirective' => '@phpprobe-ignore', 'scanMarkers' => true, 'markerTags' => [ 'TODO', @@ -137,6 +127,7 @@ private function defaultOptions(): array 'commented_out_code_block_too_large' => 'error', 'commented_out_code_requires_issue_reference' => 'warning', 'commented_out_code_in_phpdoc_without_example_label' => 'warning', + 'invalid_suppression_rule' => 'warning', ], 'strictSeverity' => [ 'commented_out_code_without_reason' => 'error', @@ -144,6 +135,7 @@ private function defaultOptions(): array 'commented_out_code_without_valid_reason' => 'error', 'commented_out_code_with_weak_reason' => 'error', 'commented_out_code_block_too_large' => 'error', + 'invalid_suppression_rule' => 'error', ], ]; } @@ -154,14 +146,19 @@ private function help(): int 'Usage: phpprobe comments [options] [paths...]', '', 'Options:', - ' --config=FILE read PHPProbe checker settings', - ' --preset=NAME apply preset: phpstorm, standard, or strict', - ' --exclude=PATH skip a path (repeatable)', - ' --json output machine-readable JSON', - ' --strict enforce strict policy severities', - ' --fail-on=error|warning|info minimum severity level to fail', - ' --tags=TODO,FIXME,... override marker tags', - ' --help show this help', + ' --config=FILE read PHPProbe checker settings', + ' --preset=NAME apply preset: phpstorm, standard, or strict', + ' --exclude=PATH skip a path (repeatable)', + ' --format=text|json|markdown|sarif output format (default: text)', + ' --json alias for --format=json', + ' --summary-json=FILE write machine-readable run summary', + ' --strict enforce strict policy severities', + ' --policy=relaxed|standard|strict comment policy profile', + ' --fail-on=error|warning|info minimum severity level to fail', + ' --changed-only scan only changed PHP files from Git diff', + ' --changed-base=REF Git base ref used with --changed-only', + ' --tags=TODO,FIXME,... override marker tags', + ' --help show this help', ]) . PHP_EOL); return 0; @@ -169,33 +166,7 @@ private function help(): int /** * @param list $args - * @return array{ - * help:bool, - * json:bool, - * strict:bool, - * failOn:string, - * config:string, - * paths:list, - * excludes:list, - * scanMarkers:bool, - * markerTags:list, - * markerSeverity:array, - * commentedOutEnabled:bool, - * allowedReasonTags:list, - * optionalReasonTags:list, - * allowOptionalReasonTagsInStrictMode:bool, - * minReasonLength:int, - * maxAllowedBlockLines:int, - * requireIssueForBlocksLongerThan:int, - * allowedIssuePatterns:list, - * allowBlankLineBetweenReasonAndCode:bool, - * allowReasonBeforeBlockComment:bool, - * allowBlankLineBetweenReasonAndCodeInBlock:bool, - * allowPhpdocExamples:bool, - * phpdocExampleLabels:list, - * typeSeverity:array, - * strictSeverity:array - * } + * @return array */ private function parseArgs(array $args): array { @@ -242,19 +213,14 @@ private function parseArgs(array $args): array $options['paths'] = $configuredPaths; } + $options = $this->applyPolicyPreset($options); + return $options; } /** * @param list $args - * @param array{ - * help:bool, - * json:bool, - * strict:bool, - * failOn:string, - * excludes:list, - * markerTags:list - * } $options + * @param array $options */ private function parseCliOption(array $args, int &$index, array &$options, string $arg): bool { @@ -268,28 +234,32 @@ private function parseCliOption(array $args, int &$index, array &$options, strin return true; } - if ($arg === '--json') { - $options['json'] = true; + if ($arg === '--strict') { + $options['strict'] = true; return true; } - if ($arg === '--strict') { - $options['strict'] = true; + if ($this->cli->parseOutputFormat($options, $arg)) { + return true; + } + if ($this->cli->parseFailOn($options, $arg)) { return true; } - $failOn = $this->cli->optionValue($arg, '--fail-on'); + if ($this->cli->parseSummaryJson($options, $arg)) { + return true; + } - if ($failOn !== null) { - $normalized = strtolower(trim($failOn)); + if ($this->cli->parseChangedOptions($options, $arg)) { + return true; + } - if (!in_array($normalized, ['error', 'warning', 'info'], true)) { - throw new \InvalidArgumentException(sprintf('Invalid --fail-on value "%s". Expected: error, warning, info.', $failOn)); - } + $policy = $this->cli->optionValue($arg, '--policy'); - $options['failOn'] = $normalized; + if ($policy !== null) { + $options['policy'] = strtolower(trim($policy)); return true; } @@ -311,6 +281,35 @@ private function parseCliOption(array $args, int &$index, array &$options, strin return false; } + /** + * @param array $options + * @return array + */ + private function applyPolicyPreset(array $options): array + { + return match ($options['policy']) { + 'relaxed' => [ + ...$options, + 'minReasonLength' => max(8, (int) $options['minReasonLength']), + 'maxAllowedBlockLines' => max(15, (int) $options['maxAllowedBlockLines']), + 'requireIssueForBlocksLongerThan' => max(5, (int) $options['requireIssueForBlocksLongerThan']), + ], + 'standard' => $options, + 'strict' => [ + ...$options, + 'strict' => true, + 'allowOptionalReasonTagsInStrictMode' => false, + 'minReasonLength' => max(16, (int) $options['minReasonLength']), + 'maxAllowedBlockLines' => min(6, (int) $options['maxAllowedBlockLines']), + 'requireIssueForBlocksLongerThan' => min(2, (int) $options['requireIssueForBlocksLongerThan']), + ], + default => throw new \InvalidArgumentException(sprintf( + 'Invalid --policy value "%s". Expected: relaxed, standard, strict.', + (string) $options['policy'], + )), + }; + } + /** * @param list $findings */ @@ -345,25 +344,64 @@ private function severityRank(string $severity): int } /** - * @param array{files:int,findings:list} $result - * @param array{json:bool} $options + * @param array{files:int,findings:list,suppressed_count:int} $result + * @param array{summaryJson:string,format:string,failOn:string} $options */ - private function writeResult(array $result, array $options): void + private function writeSummaryJson(array $result, array $options, int $exitCode): void { - if ($options['json']) { - fwrite(STDOUT, json_encode([ - 'files' => $result['files'], - 'findings' => array_map( - static fn(CommentFinding $finding): array => $finding->toArray(), - $result['findings'], - ), - ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL); - + if ($options['summaryJson'] === '') { return; } + SummaryJson::write($options['summaryJson'], [ + 'checker' => 'comments', + 'exit_code' => $exitCode, + 'fail_on' => $options['failOn'], + 'files' => $result['files'], + 'findings' => count($result['findings']), + 'format' => $options['format'], + 'suppressed_count' => $result['suppressed_count'], + ]); + } + + /** + * @param array{files:int,findings:list,suppressed_count:int} $result + * @param array{format:string,failOn:string} $options + */ + private function writeResult(array $result, array $options, bool $failed): void + { + match ($options['format']) { + 'json' => $this->writeJson($result), + 'markdown' => $this->writeMarkdown($result, $options, $failed), + 'sarif' => $this->writeSarif($result), + default => $this->writeText($result, $options, $failed), + }; + } + + /** + * @param array{files:int,findings:list,suppressed_count:int} $result + */ + private function writeJson(array $result): void + { + fwrite(STDOUT, json_encode([ + 'files' => $result['files'], + 'suppressed_count' => $result['suppressed_count'], + 'findings' => array_map( + static fn(CommentFinding $finding): array => $finding->toArray(), + $result['findings'], + ), + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL); + } + + /** + * @param array{files:int,findings:list,suppressed_count:int} $result + * @param array{failOn:string} $options + */ + private function writeText(array $result, array $options, bool $failed): void + { if ($result['findings'] === []) { fwrite(STDOUT, Ansi::color(sprintf('No comment policy findings (%d PHP files scanned).', $result['files']), 'green', STDOUT) . PHP_EOL); + fwrite(STDOUT, $this->summaryFooter($result, $options, $failed) . PHP_EOL); return; } @@ -394,20 +432,119 @@ private function writeResult(array $result, array $options): void $lineLabel = $finding->line === $finding->endLine ? (string) $finding->line : sprintf('%d-%d', $finding->line, $finding->endLine); - $title = $this->findingTitle($finding->type); - - fwrite( - STDERR, - sprintf( - ' %s L%s %s', - Ansi::severity($finding->severity, STDERR), - $lineLabel, - $title, - ) . PHP_EOL, - ); + + fwrite(STDERR, sprintf( + ' %s L%s %s', + Ansi::severity($finding->severity, STDERR), + $lineLabel, + $this->findingTitle($finding->type), + ) . PHP_EOL); fwrite(STDERR, ' ' . $finding->message . PHP_EOL); } } + + fwrite(STDERR, $this->summaryFooter($result, $options, $failed) . PHP_EOL); + } + + /** + * @param array{files:int,findings:list,suppressed_count:int} $result + * @param array{failOn:string} $options + */ + private function writeMarkdown(array $result, array $options, bool $failed): void + { + $lines = [ + '# PHPProbe Comment Report', + '', + sprintf('- Files scanned: `%d`', $result['files']), + sprintf('- Findings: `%d`', count($result['findings'])), + sprintf('- Suppressed: `%d`', $result['suppressed_count']), + sprintf('- Fail-on: `%s`', $options['failOn']), + sprintf('- Status: `%s`', $failed ? 'FAIL' : 'PASS'), + '', + ]; + + if ($result['findings'] === []) { + $lines[] = 'No comment policy findings.'; + } else { + $lines[] = '| Severity | Type | Location | Message |'; + $lines[] = '| --- | --- | --- | --- |'; + + foreach ($result['findings'] as $finding) { + $lines[] = sprintf( + '| %s | `%s` | `%s:%d` | %s |', + strtoupper($finding->severity), + $finding->type, + $finding->file, + $finding->line, + str_replace('|', '\|', $finding->message), + ); + } + } + + fwrite(STDOUT, implode(PHP_EOL, $lines) . PHP_EOL); + } + + /** + * @param array{files:int,findings:list,suppressed_count:int} $result + */ + private function writeSarif(array $result): void + { + $results = []; + + foreach ($result['findings'] as $finding) { + $results[] = [ + 'ruleId' => $finding->type, + 'level' => $this->sarifLevel($finding->severity), + 'message' => ['text' => $finding->message], + 'locations' => [[ + 'physicalLocation' => [ + 'artifactLocation' => ['uri' => $finding->file], + 'region' => ['startLine' => $finding->line], + ], + ]], + ]; + } + + $payload = [ + 'version' => '2.1.0', + '$schema' => 'https://json.schemastore.org/sarif-2.1.0.json', + 'runs' => [[ + 'tool' => [ + 'driver' => [ + 'name' => 'PHPProbe', + 'informationUri' => 'https://github.com/infocyph/phpprobe', + ], + ], + 'results' => $results, + ]], + ]; + + fwrite(STDOUT, json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL); + } + + private function sarifLevel(string $severity): string + { + return match (strtolower($severity)) { + 'error', 'critical', 'high' => 'error', + 'warning', 'medium' => 'warning', + default => 'note', + }; + } + + /** + * @param array{files:int,findings:list,suppressed_count:int} $result + * @param array{failOn:string} $options + */ + private function summaryFooter(array $result, array $options, bool $failed): string + { + return sprintf( + 'Summary: files=%d findings=%d suppressed=%d fail-on=%s status=%s', + $result['files'], + count($result['findings']), + $result['suppressed_count'], + $options['failOn'], + $failed ? 'FAIL' : 'PASS', + ); } private function findingTitle(string $type): string @@ -422,6 +559,7 @@ private function findingTitle(string $type): string 'commented_out_code_block_too_large' => 'Commented-out Code Block Too Large', 'commented_out_code_requires_issue_reference' => 'Commented-out Code Requires Issue Reference', 'commented_out_code_in_phpdoc_without_example_label' => 'PHPDoc Code Without Example Label', + 'invalid_suppression_rule' => 'Invalid Suppression Rule', default => str_replace('_', ' ', ucfirst($type)), }; } diff --git a/src/Config/CliOptions.php b/src/Config/CliOptions.php index f6b55a3..d5c313a 100644 --- a/src/Config/CliOptions.php +++ b/src/Config/CliOptions.php @@ -6,6 +6,32 @@ final readonly class CliOptions { + /** + * @param list $allowed + */ + public function isAllowedFormat(string $format, array $allowed = ['text', 'json', 'markdown', 'sarif']): bool + { + return in_array(strtolower(trim($format)), $allowed, true); + } + + /** + * @param list $allowed + */ + public function normalizeFormat(string $format, array $allowed = ['text', 'json', 'markdown', 'sarif']): string + { + $normalized = strtolower(trim($format)); + + if (!$this->isAllowedFormat($normalized, $allowed)) { + throw new \InvalidArgumentException(sprintf( + 'Invalid --format value "%s". Expected one of: %s.', + $format, + implode(', ', $allowed), + )); + } + + return $normalized; + } + /** * @param list $args */ @@ -31,6 +57,28 @@ public function optionValue(string $arg, string $name): ?string return str_starts_with($arg, $name . '=') ? substr($arg, strlen($name) + 1) : null; } + /** + * @param array{changedOnly:bool,changedBase:string} $options + */ + public function parseChangedOptions(array &$options, string $arg): bool + { + if ($arg === '--changed-only') { + $options['changedOnly'] = true; + + return true; + } + + $changedBase = $this->optionValue($arg, '--changed-base'); + + if ($changedBase === null) { + return false; + } + + $options['changedBase'] = trim($changedBase); + + return true; + } + /** * @param list $args * @param array{excludes:list} $options @@ -64,6 +112,70 @@ public function parseSnapshotFileOptions(array &$options, string $arg, string $d return false; } + /** + * @param array{summaryJson:string} $options + */ + public function parseSummaryJson(array &$options, string $arg): bool + { + $summaryJson = $this->optionValue($arg, '--summary-json'); + + if ($summaryJson === null) { + return false; + } + + $options['summaryJson'] = trim($summaryJson); + + return true; + } + + /** + * @param array{format:string} $options + * @param list $allowed + */ + public function parseOutputFormat(array &$options, string $arg, array $allowed = ['text', 'json', 'markdown', 'sarif']): bool + { + if ($arg === '--json') { + $options['format'] = 'json'; + + return true; + } + + $format = $this->optionValue($arg, '--format'); + + if ($format === null) { + return false; + } + + $options['format'] = $this->normalizeFormat($format, $allowed); + + return true; + } + + /** + * @param array{failOn:string} $options + */ + public function parseFailOn(array &$options, string $arg): bool + { + $failOn = $this->optionValue($arg, '--fail-on'); + + if ($failOn === null) { + return false; + } + + $normalized = strtolower(trim($failOn)); + + if (!in_array($normalized, ['error', 'warning', 'info'], true)) { + throw new \InvalidArgumentException(sprintf( + 'Invalid --fail-on value "%s". Expected: error, warning, info.', + $failOn, + )); + } + + $options['failOn'] = $normalized; + + return true; + } + /** * @param list $args * @param list $target diff --git a/src/Config/PhpProbeConfig.php b/src/Config/PhpProbeConfig.php index 78aadcc..17cc57b 100644 --- a/src/Config/PhpProbeConfig.php +++ b/src/Config/PhpProbeConfig.php @@ -47,8 +47,8 @@ public function asArray(): array } /** - * @param array{help:bool,json:bool,config:string,mode:string,normalize:bool,fuzzy:bool,nearMiss:bool,minLines:int,minTokens:int,minStatements:int,minSimilarity:float,baseline:string,writeBaseline:string,paths:list,excludes:list} $options - * @return array{help:bool,json:bool,config:string,mode:string,normalize:bool,fuzzy:bool,nearMiss:bool,minLines:int,minTokens:int,minStatements:int,minSimilarity:float,baseline:string,writeBaseline:string,paths:list,excludes:list} + * @param array $options + * @return array */ public function applyDuplicateOptions(array $options): array { @@ -56,6 +56,7 @@ public function applyDuplicateOptions(array $options): array $mode = $this->stringValue($section, 'mode'); $json = $this->boolValue($section, 'json'); + $format = $this->stringValue($section, 'format'); $normalize = $this->boolValue($section, 'normalize'); $fuzzy = $this->boolValue($section, 'fuzzy'); $nearMiss = $this->boolValue($section, 'near_miss'); @@ -65,6 +66,12 @@ public function applyDuplicateOptions(array $options): array $minSimilarity = $this->floatValue($section, 'min_similarity'); $baseline = $this->stringValue($section, 'baseline'); $writeBaseline = $this->stringValue($section, 'write_baseline'); + $failOn = $this->stringValue($section, 'fail_on'); + $summaryJson = $this->stringValue($section, 'summary_json'); + $changedOnly = $this->boolValue($section, 'changed_only'); + $changedBase = $this->stringValue($section, 'changed_base'); + $cacheEnabled = $this->boolValue(ArrayShape::stringKeyed($this->value($section, 'cache')), 'enabled'); + $cacheFile = $this->stringValue(ArrayShape::stringKeyed($this->value($section, 'cache')), 'file'); $paths = $this->stringList($this->value($section, 'paths')); $excludes = $this->excludePaths($section); @@ -81,10 +88,40 @@ public function applyDuplicateOptions(array $options): array ); $this->applyDuplicateThresholdOptions($options, $minLines, $minTokens, $minStatements); + if ($format !== null && $format !== '') { + $options['format'] = strtolower(trim($format)); + } elseif ($json !== null) { + $options['format'] = $json ? 'json' : 'text'; + } + if ($minSimilarity !== null) { $options['minSimilarity'] = $this->normalizeSimilarity($minSimilarity); } + if ($failOn !== null && $failOn !== '') { + $options['failOn'] = strtolower(trim($failOn)); + } + + if ($summaryJson !== null) { + $options['summaryJson'] = $summaryJson; + } + + if ($changedOnly !== null) { + $options['changedOnly'] = $changedOnly; + } + + if ($changedBase !== null) { + $options['changedBase'] = $changedBase; + } + + if ($cacheEnabled !== null) { + $options['cacheEnabled'] = $cacheEnabled; + } + + if ($cacheFile !== null && $cacheFile !== '') { + $options['cacheFile'] = $cacheFile; + } + if ($paths !== []) { $options['paths'] = $paths; } @@ -97,22 +134,29 @@ public function applyDuplicateOptions(array $options): array } /** - * @param array{help:bool,json:bool,config:string,preset:string,includeProtected:bool,baseline:string,writeBaseline:string,paths:list,excludes:list} $options - * @return array{help:bool,json:bool,config:string,preset:string,includeProtected:bool,baseline:string,writeBaseline:string,paths:list,excludes:list} + * @param array $options + * @return array */ public function applyApiOptions(array $options): array { $section = $this->section('api'); $json = $this->boolValue($section, 'json'); + $format = $this->stringValue($section, 'format'); $includeProtected = $this->boolValue($section, 'include_protected'); $baseline = $this->stringValue($section, 'baseline'); $writeBaseline = $this->stringValue($section, 'write_baseline'); + $failOn = $this->stringValue($section, 'fail_on'); + $summaryJson = $this->stringValue($section, 'summary_json'); + $changedOnly = $this->boolValue($section, 'changed_only'); + $changedBase = $this->stringValue($section, 'changed_base'); $paths = $this->stringList($this->value($section, 'paths')); $excludes = $this->excludePaths($section); - if ($json !== null) { - $options['json'] = $json; + if ($format !== null && $format !== '') { + $options['format'] = strtolower(trim($format)); + } elseif ($json !== null) { + $options['format'] = $json ? 'json' : 'text'; } if ($includeProtected !== null) { @@ -127,6 +171,22 @@ public function applyApiOptions(array $options): array $options['writeBaseline'] = $writeBaseline; } + if ($failOn !== null && $failOn !== '') { + $options['failOn'] = strtolower(trim($failOn)); + } + + if ($summaryJson !== null) { + $options['summaryJson'] = $summaryJson; + } + + if ($changedOnly !== null) { + $options['changedOnly'] = $changedOnly; + } + + if ($changedBase !== null) { + $options['changedBase'] = $changedBase; + } + if ($paths !== []) { $options['paths'] = $paths; } @@ -139,15 +199,43 @@ public function applyApiOptions(array $options): array } /** - * @param array{help:bool,config:string,paths:list,excludes:list} $options - * @return array{help:bool,config:string,paths:list,excludes:list} + * @param array $options + * @return array */ public function applySyntaxOptions(array $options): array { $section = $this->section('syntax'); + $format = $this->stringValue($section, 'format'); + $json = $this->boolValue($section, 'json'); + $summaryJson = $this->stringValue($section, 'summary_json'); + $changedOnly = $this->boolValue($section, 'changed_only'); + $changedBase = $this->stringValue($section, 'changed_base'); + $parallel = $this->intValue($section, 'parallel'); $paths = $this->stringList($this->value($section, 'paths')); $excludes = $this->excludePaths($section); + if ($format !== null && $format !== '') { + $options['format'] = strtolower(trim($format)); + } elseif ($json !== null) { + $options['format'] = $json ? 'json' : 'text'; + } + + if ($summaryJson !== null) { + $options['summaryJson'] = $summaryJson; + } + + if ($changedOnly !== null) { + $options['changedOnly'] = $changedOnly; + } + + if ($changedBase !== null) { + $options['changedBase'] = $changedBase; + } + + if ($parallel !== null) { + $options['parallel'] = max(1, $parallel); + } + if ($paths !== []) { $options['paths'] = $paths; } @@ -160,50 +248,8 @@ public function applySyntaxOptions(array $options): array } /** - * @param array{ - * paths:list, - * excludes:list, - * scanMarkers:bool, - * markerTags:list, - * markerSeverity:array, - * commentedOutEnabled:bool, - * allowedReasonTags:list, - * optionalReasonTags:list, - * allowOptionalReasonTagsInStrictMode:bool, - * minReasonLength:int, - * maxAllowedBlockLines:int, - * requireIssueForBlocksLongerThan:int, - * allowedIssuePatterns:list, - * allowBlankLineBetweenReasonAndCode:bool, - * allowReasonBeforeBlockComment:bool, - * allowBlankLineBetweenReasonAndCodeInBlock:bool, - * allowPhpdocExamples:bool, - * phpdocExampleLabels:list, - * typeSeverity:array, - * strictSeverity:array - * } $options - * @return array{ - * paths:list, - * excludes:list, - * scanMarkers:bool, - * markerTags:list, - * markerSeverity:array, - * commentedOutEnabled:bool, - * allowedReasonTags:list, - * optionalReasonTags:list, - * allowOptionalReasonTagsInStrictMode:bool, - * minReasonLength:int, - * maxAllowedBlockLines:int, - * requireIssueForBlocksLongerThan:int, - * allowedIssuePatterns:list, - * allowBlankLineBetweenReasonAndCode:bool, - * allowReasonBeforeBlockComment:bool, - * allowBlankLineBetweenReasonAndCodeInBlock:bool, - * allowPhpdocExamples:bool, - * phpdocExampleLabels:list, - * typeSeverity:array, - * strictSeverity:array - * } + * @param array $options + * @return array */ public function applyCommentOptions(array $options): array { @@ -211,12 +257,23 @@ public function applyCommentOptions(array $options): array $commentedOut = $this->section('commented_out_code'); $paths = $this->stringList($this->value($comments, 'paths')); $excludes = $this->excludePaths($comments); + $format = $this->stringValue($comments, 'format'); + $json = $this->boolValue($comments, 'json'); + $failOn = $this->stringValue($comments, 'fail_on'); + $summaryJson = $this->stringValue($comments, 'summary_json'); + $changedOnly = $this->boolValue($comments, 'changed_only'); + $changedBase = $this->stringValue($comments, 'changed_base'); $scanMarkers = $this->boolValue($comments, 'scan_markers'); $markerTags = array_map('strtoupper', $this->stringList($this->value($comments, 'marker_tags'))); $markerSeverity = $this->stringMap($this->value($comments, 'marker_severity'), true); $commentedEnabled = $this->boolValue($commentedOut, 'enabled'); $allowedReasonTags = array_map('strtoupper', $this->stringList($this->value($commentedOut, 'allowed_reason_tags'))); $optionalReasonTags = array_map('strtoupper', $this->stringList($this->value($commentedOut, 'optional_reason_tags'))); + $ignorePaths = $this->stringList($this->value($commentedOut, 'ignore_paths')); + $suppression = ArrayShape::stringKeyed($this->value($commentedOut, 'suppression')); + $suppressionEnabled = $this->boolValue($suppression, 'enabled'); + $suppressionDirective = $this->stringValue($suppression, 'directive'); + $policy = $this->stringValue($commentedOut, 'policy'); $allowOptionalInStrict = $this->boolValue($commentedOut, 'allow_optional_reason_tags_in_strict_mode'); $minReasonLength = $this->intValue($commentedOut, 'min_reason_length'); $maxBlockLines = $this->intValue($commentedOut, 'max_allowed_block_lines'); @@ -228,6 +285,28 @@ public function applyCommentOptions(array $options): array $typeSeverity = $this->stringMap($this->value($commentedOut, 'finding_severity')); $strictSeverity = $this->stringMap($this->value($commentedOut, 'finding_severity_strict')); + if ($format !== null && $format !== '') { + $options['format'] = strtolower(trim($format)); + } elseif ($json !== null) { + $options['format'] = $json ? 'json' : 'text'; + } + + if ($failOn !== null && $failOn !== '') { + $options['failOn'] = strtolower(trim($failOn)); + } + + if ($summaryJson !== null) { + $options['summaryJson'] = $summaryJson; + } + + if ($changedOnly !== null) { + $options['changedOnly'] = $changedOnly; + } + + if ($changedBase !== null) { + $options['changedBase'] = $changedBase; + } + if ($paths !== []) { $options['paths'] = $paths; } @@ -236,6 +315,10 @@ public function applyCommentOptions(array $options): array $options['excludes'] = $excludes; } + if ($ignorePaths !== []) { + $options['excludes'] = array_values(array_unique([...$options['excludes'], ...$ignorePaths])); + } + if ($scanMarkers !== null) { $options['scanMarkers'] = $scanMarkers; } @@ -260,6 +343,18 @@ public function applyCommentOptions(array $options): array $options['optionalReasonTags'] = $optionalReasonTags; } + if ($suppressionEnabled !== null) { + $options['suppressionEnabled'] = $suppressionEnabled; + } + + if ($suppressionDirective !== null && trim($suppressionDirective) !== '') { + $options['suppressionDirective'] = trim($suppressionDirective); + } + + if ($policy !== null && trim($policy) !== '') { + $options['policy'] = strtolower(trim($policy)); + } + if ($allowOptionalInStrict !== null) { $options['allowOptionalReasonTagsInStrictMode'] = $allowOptionalInStrict; } @@ -344,7 +439,7 @@ public function syntaxPaths(): array } /** - * @param array{help:bool,json:bool,config:string,mode:string,normalize:bool,fuzzy:bool,nearMiss:bool,minLines:int,minTokens:int,minStatements:int,minSimilarity:float,baseline:string,writeBaseline:string,paths:list,excludes:list} $options + * @param array $options */ private function applyDuplicateScalarOptions( array &$options, @@ -386,7 +481,7 @@ private function applyDuplicateScalarOptions( } /** - * @param array{help:bool,json:bool,config:string,mode:string,normalize:bool,fuzzy:bool,nearMiss:bool,minLines:int,minTokens:int,minStatements:int,minSimilarity:float,baseline:string,writeBaseline:string,paths:list,excludes:list} $options + * @param array $options */ private function applyDuplicateThresholdOptions( array &$options, diff --git a/src/DuplicateChecker.php b/src/DuplicateChecker.php index 9a26ef8..e583786 100644 --- a/src/DuplicateChecker.php +++ b/src/DuplicateChecker.php @@ -5,11 +5,13 @@ namespace Infocyph\PHPProbe; use Infocyph\PHPProbe\Config\CliOptions; -use Infocyph\PHPProbe\Console\Ansi; use Infocyph\PHPProbe\Config\Paths; use Infocyph\PHPProbe\Config\PhpProbeConfig; +use Infocyph\PHPProbe\Console\Ansi; +use Infocyph\PHPProbe\Detection\DuplicateCloneReducer; use Infocyph\PHPProbe\Detection\DuplicateDetectionEngine; use Infocyph\PHPProbe\Filesystem\PhpFileFinder; +use Infocyph\PHPProbe\Util\SummaryJson; final class DuplicateChecker { @@ -31,7 +33,12 @@ public function run(array $args): int return $this->help(); } - $result = (new DuplicateDetectionEngine())->analyze((new PhpFileFinder())->find($options['paths'], $options['excludes']), $options); + $files = (new PhpFileFinder())->find( + $options['paths'], + $options['excludes'], + ['changedOnly' => $options['changedOnly'], 'changedBase' => $options['changedBase']], + ); + $result = $this->analyzeWithCache($files, $options); if ($options['baseline'] !== '') { $result = $this->withoutBaselineClones($result, $options['baseline']); @@ -41,9 +48,12 @@ public function run(array $args): int $this->writeBaseline($result, $options['writeBaseline']); } - $this->writeResult($result, $options); + $failed = $this->shouldFail($result, $options); + $exitCode = $options['writeBaseline'] !== '' ? 0 : ($failed ? 1 : 0); + $this->writeResult($result, $options, $failed); + $this->writeSummaryJson($result, $options, $exitCode); - return $options['writeBaseline'] !== '' || $result['clones'] === [] ? 0 : 1; + return $exitCode; } catch (\InvalidArgumentException|\RuntimeException $exception) { fwrite(STDERR, $exception->getMessage() . PHP_EOL); @@ -52,13 +62,20 @@ public function run(array $args): int } /** - * @return array{help:bool,json:bool,config:string,mode:string,normalize:bool,fuzzy:bool,nearMiss:bool,minLines:int,minTokens:int,minStatements:int,minSimilarity:float,baseline:string,writeBaseline:string,paths:list,excludes:list} + * @return array */ private function defaultOptions(): array { return [ 'help' => false, - 'json' => false, + 'format' => 'text', + 'failOn' => 'warning', + 'summaryJson' => '', + 'changedOnly' => false, + 'changedBase' => '', + 'cacheEnabled' => true, + 'cacheFile' => '.phpprobe-duplicates-cache.json', + 'errorDuplicatePercentage' => 20.0, 'config' => Paths::config('phpprobe.json'), 'mode' => 'gate', 'normalize' => true, @@ -81,20 +98,29 @@ private function help(): int 'Usage: phpprobe duplicates [options] [paths...]', '', 'Options:', - ' --mode=gate|audit gate is deterministic; audit enables structural matching', - ' --config=FILE read PHPProbe checker settings', - ' --preset=NAME apply preset: phpstorm, standard, or strict', - ' --exclude=PATH skip a path (repeatable)', - ' --min-lines=N minimum duplicated lines (default: 5)', - ' --min-tokens=N token fingerprint window size (default: 70)', - ' --min-statements=N statement window size for audit mode (default: 4)', - ' --min-similarity=N near-miss threshold, 0.0-1.0 or 0-100 (default: 0.85)', - ' --near-miss enable bounded statement/shape similarity matching', - ' --exact do not normalize variables/literals', - ' --fuzzy also normalize identifiers/calls', - ' --baseline=FILE suppress clone groups already in a baseline', - ' --write-baseline[=FILE] write current clone groups to a baseline and exit 0', - ' --json output machine-readable JSON', + ' --mode=gate|audit gate is deterministic; audit enables structural matching', + ' --config=FILE read PHPProbe checker settings', + ' --preset=NAME apply preset: phpstorm, standard, or strict', + ' --exclude=PATH skip a path (repeatable)', + ' --min-lines=N minimum duplicated lines (default: 5)', + ' --min-tokens=N token fingerprint window size (default: 70)', + ' --min-statements=N statement window size for audit mode (default: 4)', + ' --min-similarity=N near-miss threshold, 0.0-1.0 or 0-100 (default: 0.85)', + ' --near-miss enable bounded statement/shape similarity matching', + ' --exact do not normalize variables/literals', + ' --fuzzy also normalize identifiers/calls', + ' --baseline=FILE suppress clone groups already in a baseline', + ' --write-baseline[=FILE] write current clone groups to a baseline and exit 0', + ' --format=text|json|markdown|sarif output format (default: text)', + ' --json alias for --format=json', + ' --fail-on=error|warning|info failure threshold (default: warning)', + ' --summary-json=FILE write machine-readable run summary', + ' --changed-only scan only changed PHP files from Git diff', + ' --changed-base=REF Git base ref used with --changed-only', + ' --no-cache disable duplicate result cache', + ' --cache-file=FILE duplicate result cache file path', + ' --error-duplicate-percentage=N error threshold used with --fail-on=error', + ' --help show this help', ]) . PHP_EOL); return 0; @@ -150,8 +176,8 @@ private function knownFingerprints(string $baselinePath): array } /** - * @param array{help:bool,json:bool,config:string,mode:string,normalize:bool,fuzzy:bool,nearMiss:bool,minLines:int,minTokens:int,minStatements:int,minSimilarity:float,baseline:string,writeBaseline:string,paths:list,excludes:list} $options - * @return array{help:bool,json:bool,config:string,mode:string,normalize:bool,fuzzy:bool,nearMiss:bool,minLines:int,minTokens:int,minStatements:int,minSimilarity:float,baseline:string,writeBaseline:string,paths:list,excludes:list} + * @param array $options + * @return array */ private function normalizeMode(array $options): array { @@ -166,7 +192,7 @@ private function normalizeMode(array $options): array /** * @param list $args - * @return array{help:bool,json:bool,config:string,mode:string,normalize:bool,fuzzy:bool,nearMiss:bool,minLines:int,minTokens:int,minStatements:int,minSimilarity:float,baseline:string,writeBaseline:string,paths:list,excludes:list} + * @return array */ private function parseArgs(array $args): array { @@ -220,7 +246,7 @@ private function parseArgs(array $args): array /** * @param list $args - * @param array{help:bool,json:bool,config:string,mode:string,normalize:bool,fuzzy:bool,nearMiss:bool,minLines:int,minTokens:int,minStatements:int,minSimilarity:float,baseline:string,writeBaseline:string,paths:list,excludes:list} $options + * @param array $options */ private function parseCliOption(array $args, int &$index, array &$options, string $arg): bool { @@ -233,21 +259,46 @@ private function parseCliOption(array $args, int &$index, array &$options, strin return true; } + $errorDuplicatePercentage = $this->cli->optionValue($arg, '--error-duplicate-percentage'); + + if ($errorDuplicatePercentage !== null) { + $options['errorDuplicatePercentage'] = max(0.0, min(100.0, (float) $errorDuplicatePercentage)); + + return true; + } + + $cacheFile = $this->cli->optionValue($arg, '--cache-file'); + + if ($cacheFile !== null) { + $options['cacheFile'] = trim($cacheFile); + + return true; + } + + if ($arg === '--no-cache') { + $options['cacheEnabled'] = false; + + return true; + } + return $this->cli->parseExclude($args, $index, $options, $arg) || $this->parseFlag($options, $arg) || $this->parseNumericOption($options, $arg) + || $this->cli->parseOutputFormat($options, $arg) + || $this->cli->parseFailOn($options, $arg) + || $this->cli->parseSummaryJson($options, $arg) + || $this->cli->parseChangedOptions($options, $arg) || $this->cli->parseSnapshotFileOptions($options, $arg, '.phpprobe-duplicates-baseline.json'); } /** - * @param array{help:bool,json:bool,config:string,mode:string,normalize:bool,fuzzy:bool,nearMiss:bool,minLines:int,minTokens:int,minStatements:int,minSimilarity:float,baseline:string,writeBaseline:string,paths:list,excludes:list} $options + * @param array $options */ private function parseFlag(array &$options, string $arg): bool { $flagMap = [ '--help' => 'help', '-h' => 'help', - '--json' => 'json', '--fuzzy' => 'fuzzy', '--near-miss' => 'nearMiss', ]; @@ -275,7 +326,7 @@ private function parseFlag(array &$options, string $arg): bool } /** - * @param array{help:bool,json:bool,config:string,mode:string,normalize:bool,fuzzy:bool,nearMiss:bool,minLines:int,minTokens:int,minStatements:int,minSimilarity:float,baseline:string,writeBaseline:string,paths:list,excludes:list} $options + * @param array $options */ private function parseNumericOption(array &$options, string $arg): bool { @@ -302,8 +353,110 @@ private function parseNumericOption(array &$options, string $arg): bool } /** - * @param array{files:int,total_lines:int,duplicated_lines:int,duplicate_percentage:float,known_clones:int,new_clones:int,clones:list}>} $result - * @return array{files:int,total_lines:int,duplicated_lines:int,duplicate_percentage:float,known_clones:int,new_clones:int,clones:list}>} + * @param array $options + * @return array{files:int,total_lines:int,duplicated_lines:int,duplicate_percentage:float,known_clones:int,new_clones:int,cache_hit:bool,clones:list}>} + */ + private function analyzeWithCache(array $files, array $options): array + { + $engineOptions = [ + 'mode' => $options['mode'], + 'normalize' => $options['normalize'], + 'fuzzy' => $options['fuzzy'], + 'nearMiss' => $options['nearMiss'], + 'minLines' => $options['minLines'], + 'minTokens' => $options['minTokens'], + 'minStatements' => $options['minStatements'], + 'minSimilarity' => $options['minSimilarity'], + ]; + $cacheKey = $this->cacheKey($files, $engineOptions); + + if ($options['cacheEnabled']) { + $cache = $this->loadCache($options['cacheFile']); + + if (isset($cache[$cacheKey]) && is_array($cache[$cacheKey])) { + $hit = $cache[$cacheKey]; + + return [ + ...$hit, + 'cache_hit' => true, + ]; + } + } + + $result = (new DuplicateDetectionEngine())->analyze($files, $engineOptions); + $result['cache_hit'] = false; + + if ($options['cacheEnabled']) { + $cache = $this->loadCache($options['cacheFile']); + $cache[$cacheKey] = [ + ...$result, + 'cache_hit' => false, + ]; + $this->saveCache($options['cacheFile'], $cache); + } + + return $result; + } + + /** + * @param array $engineOptions + */ + private function cacheKey(array $files, array $engineOptions): string + { + $fingerprint = []; + + foreach ($files as $file) { + $fingerprint[] = [$file, @filesize($file) ?: 0, @filemtime($file) ?: 0]; + } + + return hash('sha256', json_encode([$engineOptions, $fingerprint], JSON_UNESCAPED_SLASHES) ?: ''); + } + + /** + * @return array> + */ + private function loadCache(string $path): array + { + if ($path === '' || !is_file($path) || !is_readable($path)) { + return []; + } + + $contents = file_get_contents($path); + + if (!is_string($contents) || trim($contents) === '') { + return []; + } + + try { + $decoded = json_decode($contents, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return []; + } + + return is_array($decoded) ? $decoded : []; + } + + /** + * @param array> $cache + */ + private function saveCache(string $path, array $cache): void + { + if ($path === '') { + return; + } + + try { + $encoded = json_encode($cache, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . PHP_EOL; + } catch (\JsonException) { + return; + } + + @file_put_contents($path, $encoded); + } + + /** + * @param array{files:int,total_lines:int,duplicated_lines:int,duplicate_percentage:float,known_clones:int,new_clones:int,cache_hit:bool,clones:list}>} $result + * @return array{files:int,total_lines:int,duplicated_lines:int,duplicate_percentage:float,known_clones:int,new_clones:int,cache_hit:bool,clones:list}>} */ private function withoutBaselineClones(array $result, string $baselinePath): array { @@ -316,12 +469,16 @@ private function withoutBaselineClones(array $result, string $baselinePath): arr $result['clones'] = array_values(array_filter($result['clones'], static fn(array $clone): bool => !isset($known[$clone['fingerprint']]))); $result['known_clones'] = count($known); $result['new_clones'] = count($result['clones']); + $result['duplicated_lines'] = (new DuplicateCloneReducer())->uniqueDuplicatedLines($result['clones']); + $result['duplicate_percentage'] = $result['total_lines'] > 0 + ? round(($result['duplicated_lines'] / $result['total_lines']) * 100, 2) + : 0.0; return $result; } /** - * @param array{files:int,total_lines:int,duplicated_lines:int,duplicate_percentage:float,known_clones:int,new_clones:int,clones:list}>} $result + * @param array{files:int,total_lines:int,duplicated_lines:int,duplicate_percentage:float,known_clones:int,new_clones:int,cache_hit:bool,clones:list}>} $result */ private function writeBaseline(array $result, string $path): void { @@ -349,17 +506,50 @@ private function writeBaseline(array $result, string $path): void } /** - * @param array{files:int,total_lines:int,duplicated_lines:int,duplicate_percentage:float,known_clones:int,new_clones:int,clones:list}>} $result - * @param array{json:bool,writeBaseline:string} $options + * @param array{files:int,total_lines:int,duplicated_lines:int,duplicate_percentage:float,known_clones:int,new_clones:int,cache_hit:bool,clones:list}>} $result + * @param array $options */ - private function writeResult(array $result, array $options): void + private function shouldFail(array $result, array $options): bool { - if ($options['json']) { - fwrite(STDOUT, json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL); - - return; + if ($result['clones'] === []) { + return false; } + return match ($options['failOn']) { + 'error' => $result['duplicate_percentage'] >= $options['errorDuplicatePercentage'], + 'warning', 'info' => true, + default => true, + }; + } + + /** + * @param array{files:int,total_lines:int,duplicated_lines:int,duplicate_percentage:float,known_clones:int,new_clones:int,cache_hit:bool,clones:list}>} $result + * @param array $options + */ + private function writeResult(array $result, array $options, bool $failed): void + { + match ($options['format']) { + 'json' => $this->writeJson($result), + 'markdown' => $this->writeMarkdown($result, $options, $failed), + 'sarif' => $this->writeSarif($result), + default => $this->writeText($result, $options, $failed), + }; + } + + /** + * @param array{files:int,total_lines:int,duplicated_lines:int,duplicate_percentage:float,known_clones:int,new_clones:int,cache_hit:bool,clones:list}>} $result + */ + private function writeJson(array $result): void + { + fwrite(STDOUT, json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL); + } + + /** + * @param array{files:int,total_lines:int,duplicated_lines:int,duplicate_percentage:float,known_clones:int,new_clones:int,cache_hit:bool,clones:list}>} $result + * @param array{writeBaseline:string,failOn:string} $options + */ + private function writeText(array $result, array $options, bool $failed): void + { if ($options['writeBaseline'] !== '') { fwrite(STDOUT, Ansi::color(sprintf('Duplicate baseline written: %s', $options['writeBaseline']), 'cyan', STDOUT) . PHP_EOL); } @@ -370,18 +560,11 @@ private function writeResult(array $result, array $options): void 'green', STDOUT, ) . PHP_EOL); + fwrite(STDOUT, $this->summaryFooter($result, $options, $failed) . PHP_EOL); return; } - $this->writeTextClones($result); - } - - /** - * @param array{files:int,total_lines:int,duplicated_lines:int,duplicate_percentage:float,known_clones:int,new_clones:int,clones:list}>} $result - */ - private function writeTextClones(array $result): void - { fwrite(STDERR, Ansi::color(sprintf( 'Found %d clone group(s) with %d duplicated lines in %d PHP files:', count($result['clones']), @@ -412,5 +595,131 @@ private function writeTextClones(array $result): void } fwrite(STDERR, Ansi::color(sprintf('%.2f%% duplicated lines.', $result['duplicate_percentage']), 'yellow', STDERR) . PHP_EOL); + fwrite(STDERR, $this->summaryFooter($result, $options, $failed) . PHP_EOL); + } + + /** + * @param array{files:int,total_lines:int,duplicated_lines:int,duplicate_percentage:float,known_clones:int,new_clones:int,cache_hit:bool,clones:list}>} $result + * @param array{failOn:string} $options + */ + private function writeMarkdown(array $result, array $options, bool $failed): void + { + $lines = [ + '# PHPProbe Duplicate Report', + '', + sprintf('- Files scanned: `%d`', $result['files']), + sprintf('- Total lines: `%d`', $result['total_lines']), + sprintf('- Duplicate lines: `%d`', $result['duplicated_lines']), + sprintf('- Duplicate percentage: `%.2f%%`', $result['duplicate_percentage']), + sprintf('- Clone groups: `%d`', count($result['clones'])), + sprintf('- Cache hit: `%s`', $result['cache_hit'] ? 'yes' : 'no'), + sprintf('- Fail-on: `%s`', $options['failOn']), + sprintf('- Status: `%s`', $failed ? 'FAIL' : 'PASS'), + '', + ]; + + if ($result['clones'] === []) { + $lines[] = 'No duplicate clone groups found.'; + } else { + $lines[] = '| # | Source | Similarity | Lines | Location |'; + $lines[] = '| --- | --- | --- | --- | --- |'; + + foreach ($result['clones'] as $index => $clone) { + $first = $clone['occurrences'][0]; + $lines[] = sprintf( + '| %d | `%s` | %.0f%% | %d | `%s:%d-%d` |', + $index + 1, + $clone['source'], + $clone['similarity'] * 100, + $clone['lines'], + $first['file'], + $first['start_line'], + $first['end_line'], + ); + } + } + + fwrite(STDOUT, implode(PHP_EOL, $lines) . PHP_EOL); + } + + /** + * @param array{files:int,total_lines:int,duplicated_lines:int,duplicate_percentage:float,known_clones:int,new_clones:int,cache_hit:bool,clones:list}>} $result + */ + private function writeSarif(array $result): void + { + $results = []; + + foreach ($result['clones'] as $clone) { + foreach ($clone['occurrences'] as $occurrence) { + $results[] = [ + 'ruleId' => 'duplicate_code_clone', + 'level' => 'warning', + 'message' => ['text' => sprintf('Duplicate clone group (%s, %.0f%% similar).', $clone['source'], $clone['similarity'] * 100)], + 'locations' => [[ + 'physicalLocation' => [ + 'artifactLocation' => ['uri' => $occurrence['file']], + 'region' => ['startLine' => $occurrence['start_line']], + ], + ]], + ]; + } + } + + $payload = [ + 'version' => '2.1.0', + '$schema' => 'https://json.schemastore.org/sarif-2.1.0.json', + 'runs' => [[ + 'tool' => [ + 'driver' => [ + 'name' => 'PHPProbe', + 'informationUri' => 'https://github.com/infocyph/phpprobe', + ], + ], + 'results' => $results, + ]], + ]; + + fwrite(STDOUT, json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL); + } + + /** + * @param array{files:int,total_lines:int,duplicated_lines:int,duplicate_percentage:float,known_clones:int,new_clones:int,cache_hit:bool,clones:list}>} $result + * @param array{failOn:string} $options + */ + private function summaryFooter(array $result, array $options, bool $failed): string + { + return sprintf( + 'Summary: files=%d clone-groups=%d duplicated-lines=%d duplicate%%=%.2f fail-on=%s cache=%s status=%s', + $result['files'], + count($result['clones']), + $result['duplicated_lines'], + $result['duplicate_percentage'], + $options['failOn'], + $result['cache_hit'] ? 'HIT' : 'MISS', + $failed ? 'FAIL' : 'PASS', + ); + } + + /** + * @param array{files:int,total_lines:int,duplicated_lines:int,duplicate_percentage:float,known_clones:int,new_clones:int,cache_hit:bool,clones:list}>} $result + * @param array{summaryJson:string,failOn:string} $options + */ + private function writeSummaryJson(array $result, array $options, int $exitCode): void + { + if ($options['summaryJson'] === '') { + return; + } + + SummaryJson::write($options['summaryJson'], [ + 'checker' => 'duplicates', + 'exit_code' => $exitCode, + 'fail_on' => $options['failOn'], + 'files' => $result['files'], + 'total_lines' => $result['total_lines'], + 'duplicated_lines' => $result['duplicated_lines'], + 'duplicate_percentage' => $result['duplicate_percentage'], + 'clone_groups' => count($result['clones']), + 'cache_hit' => $result['cache_hit'], + ]); } } diff --git a/src/Filesystem/PhpFileFinder.php b/src/Filesystem/PhpFileFinder.php index cede7c0..010a061 100644 --- a/src/Filesystem/PhpFileFinder.php +++ b/src/Filesystem/PhpFileFinder.php @@ -12,11 +12,17 @@ final class PhpFileFinder /** * @param list $paths * @param list $excludes + * @param array{changedOnly?:bool,changedBase?:string} $options * @return list */ - public function find(array $paths, array $excludes = []): array + public function find(array $paths, array $excludes = [], array $options = []): array { $files = $this->gitAwarePhpFiles($paths); + + if (($options['changedOnly'] ?? false) === true) { + $files = $this->changedFilesSubset($files, $paths, (string) ($options['changedBase'] ?? '')); + } + $files = $this->withoutExcludedPaths($files, $excludes); $files = array_values(array_unique($files)); @@ -25,6 +31,28 @@ public function find(array $paths, array $excludes = []): array return $files; } + /** + * @param list $files + * @param list $paths + * @return list + */ + private function changedFilesSubset(array $files, array $paths, string $base): array + { + $changed = $this->gitChangedPhpFiles($paths, $base); + + if ($changed === null) { + return $files; + } + + if ($changed === []) { + return []; + } + + $changedLookup = array_fill_keys($changed, true); + + return array_values(array_filter($files, static fn(string $file): bool => isset($changedLookup[$file]))); + } + private function absolutePath(string $path): string { if (preg_match('/^[A-Za-z]:[\/\\\\]/', $path) === 1 || str_starts_with($path, DIRECTORY_SEPARATOR)) { @@ -80,6 +108,54 @@ private function gitAwarePhpFiles(array $paths): array return $this->recursivePhpFiles($paths === [] ? ['.'] : $paths); } + /** + * @param list $paths + * @return list|null + */ + private function gitChangedPhpFiles(array $paths, string $base): ?array + { + $command = ['git', 'diff', '--name-only', '--diff-filter=ACMRTUXB']; + $baseRef = trim($base); + + if ($baseRef !== '') { + $command[] = $baseRef . '...HEAD'; + } else { + $command[] = 'HEAD~1...HEAD'; + } + + if ($paths !== []) { + $command[] = '--'; + + foreach ($paths as $path) { + if ($path !== '') { + $command[] = $path; + } + } + } + + $result = (new ProcRunner())->run($command); + + if (!$result instanceof ProcessResult || !$result->successful()) { + return null; + } + + $files = []; + + foreach (preg_split('/\R/', trim($result->stdout)) ?: [] as $path) { + if ($path === '' || !str_ends_with($path, '.php')) { + continue; + } + + $absolute = $this->absolutePath($path); + + if (is_file($absolute)) { + $files[] = $absolute; + } + } + + return array_values(array_unique($files)); + } + /** * @param list $paths * @return array diff --git a/src/SyntaxChecker.php b/src/SyntaxChecker.php index 9b5d2d7..553c7e1 100644 --- a/src/SyntaxChecker.php +++ b/src/SyntaxChecker.php @@ -5,12 +5,13 @@ namespace Infocyph\PHPProbe; use Infocyph\PHPProbe\Config\CliOptions; -use Infocyph\PHPProbe\Console\Ansi; use Infocyph\PHPProbe\Config\Paths; use Infocyph\PHPProbe\Config\PhpProbeConfig; +use Infocyph\PHPProbe\Console\Ansi; use Infocyph\PHPProbe\Filesystem\PhpFileFinder; use Infocyph\PHPProbe\Process\ProcessResult; use Infocyph\PHPProbe\Process\ProcRunner; +use Infocyph\PHPProbe\Util\SummaryJson; final class SyntaxChecker { @@ -22,12 +23,12 @@ public function __construct() } /** - * @param list $paths + * @param list $args */ - public function run(array $paths): int + public function run(array $args): int { try { - $options = $this->parseArgs($paths); + $options = $this->parseArgs($args); } catch (\InvalidArgumentException|\RuntimeException $exception) { fwrite(STDERR, $exception->getMessage() . PHP_EOL); @@ -38,15 +39,21 @@ public function run(array $paths): int return $this->help(); } - $files = (new PhpFileFinder())->find($options['paths'], $options['excludes']); - - if ($files === []) { - fwrite(STDOUT, 'No PHP files found.' . PHP_EOL); - - return 0; - } - - return $this->lintFiles($files); + $files = (new PhpFileFinder())->find( + $options['paths'], + $options['excludes'], + ['changedOnly' => $options['changedOnly'], 'changedBase' => $options['changedBase']], + ); + $result = $files === [] + ? ['files_checked' => 0, 'failures' => []] + : $this->lintFiles($files, $options['parallel']); + $failed = $result['failures'] !== []; + $exitCode = $failed ? 1 : 0; + + $this->writeResult($result, $options, $failed); + $this->writeSummaryJson($result, $options, $exitCode); + + return $exitCode; } private function help(): int @@ -55,10 +62,16 @@ private function help(): int 'Usage: phpprobe syntax [options] [paths...]', '', 'Options:', - ' --config=FILE read PHPProbe checker settings', - ' --preset=NAME apply preset: phpstorm, standard, or strict', - ' --exclude=PATH skip a path (repeatable)', - ' --help show this help', + ' --config=FILE read PHPProbe checker settings', + ' --preset=NAME apply preset: phpstorm, standard, or strict', + ' --exclude=PATH skip a path (repeatable)', + ' --format=text|json|markdown|sarif output format (default: text)', + ' --json alias for --format=json', + ' --summary-json=FILE write machine-readable run summary', + ' --changed-only scan only changed PHP files from Git diff', + ' --changed-base=REF Git base ref used with --changed-only', + ' --parallel=N parallel lint worker count (default: 1)', + ' --help show this help', ]) . PHP_EOL); return 0; @@ -83,8 +96,22 @@ private function lintFile(string $file): ?string /** * @param list $files + * @return array{files_checked:int,failures:list} */ - private function lintFiles(array $files): int + private function lintFiles(array $files, int $parallel): array + { + if ($parallel <= 1 || count($files) <= 1) { + return $this->lintFilesSequential($files); + } + + return $this->lintFilesParallel($files, $parallel); + } + + /** + * @param list $files + * @return array{files_checked:int,failures:list} + */ + private function lintFilesSequential(array $files): array { $failures = []; @@ -92,39 +119,269 @@ private function lintFiles(array $files): int $failure = $this->lintFile($file); if (is_string($failure)) { - $failures[] = [$file, $failure]; + $failures[] = ['file' => $file, 'message' => $failure]; + } + } + + return ['files_checked' => count($files), 'failures' => $failures]; + } + + /** + * @param list $files + * @return array{files_checked:int,failures:list} + */ + private function lintFilesParallel(array $files, int $parallel): array + { + $queue = array_values($files); + $limit = max(1, min($parallel, count($queue))); + $running = []; + $failures = []; + + while ($queue !== [] || $running !== []) { + while ($queue !== [] && count($running) < $limit) { + $file = array_shift($queue); + + if (!is_string($file)) { + continue; + } + + $running[] = $this->startLintProcess($file); } + + foreach ($running as $key => $job) { + $job['stdout'] .= stream_get_contents($job['pipes'][1]) ?: ''; + $job['stderr'] .= stream_get_contents($job['pipes'][2]) ?: ''; + $status = proc_get_status($job['process']); + + if (($status['running'] ?? false) === true) { + $running[$key] = $job; + + continue; + } + + fclose($job['pipes'][0]); + fclose($job['pipes'][1]); + fclose($job['pipes'][2]); + $exitCode = proc_close($job['process']); + + if ($exitCode !== 0) { + $message = trim($job['stdout'] . PHP_EOL . $job['stderr']); + $failures[] = [ + 'file' => $job['file'], + 'message' => $message !== '' ? $message : 'Unknown lint failure', + ]; + } + + unset($running[$key]); + } + + if ($running !== []) { + usleep(10000); + } + } + + return ['files_checked' => count($files), 'failures' => array_values($failures)]; + } + + /** + * @return array{file:string,process:resource,pipes:array{0:resource,1:resource,2:resource},stdout:string,stderr:string} + */ + private function startLintProcess(string $file): array + { + $process = proc_open([PHP_BINARY, '-d', 'display_errors=1', '-l', $file], [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ], $pipes); + + if (!is_resource($process) || !is_array($pipes)) { + throw new \RuntimeException(sprintf('Could not start syntax lint process for %s', $file)); + } + + stream_set_blocking($pipes[1], false); + stream_set_blocking($pipes[2], false); + + return [ + 'file' => $file, + 'process' => $process, + 'pipes' => $pipes, + 'stdout' => '', + 'stderr' => '', + ]; + } + + /** + * @param array{files_checked:int,failures:list} $result + * @param array{format:string} $options + */ + private function writeResult(array $result, array $options, bool $failed): void + { + match ($options['format']) { + 'json' => $this->writeJson($result), + 'markdown' => $this->writeMarkdown($result, $failed), + 'sarif' => $this->writeSarif($result), + default => $this->writeText($result, $options, $failed), + }; + } + + /** + * @param array{files_checked:int,failures:list} $result + */ + private function writeJson(array $result): void + { + fwrite(STDOUT, json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL); + } + + /** + * @param array{files_checked:int,failures:list} $result + */ + private function writeText(array $result, array $options, bool $failed): void + { + if ($result['files_checked'] === 0) { + fwrite(STDOUT, 'No PHP files found.' . PHP_EOL); + fwrite(STDOUT, $this->summaryFooter($result, $options, $failed) . PHP_EOL); + + return; } - if ($failures === []) { - fwrite(STDOUT, Ansi::color(sprintf('Syntax OK: %d PHP files checked.', count($files)), 'green', STDOUT) . PHP_EOL); + if ($result['failures'] === []) { + fwrite(STDOUT, Ansi::color(sprintf('Syntax OK: %d PHP files checked.', $result['files_checked']), 'green', STDOUT) . PHP_EOL); + fwrite(STDOUT, $this->summaryFooter($result, $options, $failed) . PHP_EOL); - return 0; + return; } - fwrite(STDERR, Ansi::color(sprintf('Syntax errors in %d file(s):', count($failures)), 'red', STDERR) . PHP_EOL); + fwrite(STDERR, Ansi::color(sprintf('Syntax errors in %d file(s):', count($result['failures'])), 'red', STDERR) . PHP_EOL); - foreach ($failures as [$file, $message]) { - fwrite(STDERR, ' ' . Ansi::color($file, 'cyan', STDERR) . PHP_EOL); + foreach ($result['failures'] as $failure) { + fwrite(STDERR, ' ' . Ansi::color($failure['file'], 'cyan', STDERR) . PHP_EOL); - foreach (preg_split('/\R/', trim($message)) ?: [] as $line) { + foreach (preg_split('/\R/', trim($failure['message'])) ?: [] as $line) { if ($line !== '') { fwrite(STDERR, ' ' . $line . PHP_EOL); } } } - return 1; + fwrite(STDERR, $this->summaryFooter($result, $options, $failed) . PHP_EOL); + } + + /** + * @param array{files_checked:int,failures:list} $result + */ + private function writeMarkdown(array $result, bool $failed): void + { + $lines = [ + '# PHPProbe Syntax Report', + '', + sprintf('- Files checked: `%d`', $result['files_checked']), + sprintf('- Failures: `%d`', count($result['failures'])), + sprintf('- Status: `%s`', $failed ? 'FAIL' : 'PASS'), + '', + ]; + + if ($result['failures'] === []) { + $lines[] = 'No syntax errors found.'; + } else { + $lines[] = '| File | Message |'; + $lines[] = '| --- | --- |'; + + foreach ($result['failures'] as $failure) { + $lines[] = sprintf( + '| `%s` | %s |', + $failure['file'], + str_replace('|', '\|', trim(preg_replace('/\s+/', ' ', $failure['message']) ?? $failure['message'])), + ); + } + } + + fwrite(STDOUT, implode(PHP_EOL, $lines) . PHP_EOL); + } + + /** + * @param array{files_checked:int,failures:list} $result + */ + private function writeSarif(array $result): void + { + $results = []; + + foreach ($result['failures'] as $failure) { + $results[] = [ + 'ruleId' => 'php_syntax_error', + 'level' => 'error', + 'message' => ['text' => trim($failure['message'])], + 'locations' => [[ + 'physicalLocation' => [ + 'artifactLocation' => ['uri' => $failure['file']], + 'region' => ['startLine' => 1], + ], + ]], + ]; + } + + $payload = [ + 'version' => '2.1.0', + '$schema' => 'https://json.schemastore.org/sarif-2.1.0.json', + 'runs' => [[ + 'tool' => [ + 'driver' => [ + 'name' => 'PHPProbe', + 'informationUri' => 'https://github.com/infocyph/phpprobe', + ], + ], + 'results' => $results, + ]], + ]; + + fwrite(STDOUT, json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL); + } + + /** + * @param array{files_checked:int,failures:list} $result + */ + private function summaryFooter(array $result, array $options, bool $failed): string + { + return sprintf( + 'Summary: files=%d failures=%d parallel=%d status=%s', + $result['files_checked'], + count($result['failures']), + $options['parallel'], + $failed ? 'FAIL' : 'PASS', + ); + } + + /** + * @param array{files_checked:int,failures:list} $result + * @param array{summaryJson:string,parallel:int} $options + */ + private function writeSummaryJson(array $result, array $options, int $exitCode): void + { + if ($options['summaryJson'] === '') { + return; + } + + SummaryJson::write($options['summaryJson'], [ + 'checker' => 'syntax', + 'exit_code' => $exitCode, + 'files_checked' => $result['files_checked'], + 'failures' => count($result['failures']), + 'parallel' => $options['parallel'], + ]); } /** * @param list $args - * @return array{help:bool,config:string,paths:list,excludes:list} + * @return array{help:bool,format:string,summaryJson:string,changedOnly:bool,changedBase:string,parallel:int,config:string,paths:list,excludes:list} */ private function parseArgs(array $args): array { $options = [ 'help' => false, + 'format' => 'text', + 'summaryJson' => '', + 'changedOnly' => false, + 'changedBase' => '', + 'parallel' => 1, 'config' => Paths::config('phpprobe.json'), 'paths' => [], 'excludes' => [], @@ -172,12 +429,14 @@ private function parseArgs(array $args): array $options['paths'] = $configuredPaths; } + $options['parallel'] = max(1, (int) $options['parallel']); + return $options; } /** * @param list $args - * @param array{help:bool,config:string,paths:list,excludes:list} $options + * @param array{help:bool,format:string,summaryJson:string,changedOnly:bool,changedBase:string,parallel:int,config:string,paths:list,excludes:list} $options */ private function parseCliOption(array $args, int &$index, array &$options, string $arg): bool { @@ -191,6 +450,26 @@ private function parseCliOption(array $args, int &$index, array &$options, strin return true; } + if ($this->cli->parseOutputFormat($options, $arg)) { + return true; + } + + if ($this->cli->parseSummaryJson($options, $arg)) { + return true; + } + + if ($this->cli->parseChangedOptions($options, $arg)) { + return true; + } + + $parallel = $this->cli->optionValue($arg, '--parallel'); + + if ($parallel !== null) { + $options['parallel'] = max(1, (int) $parallel); + + return true; + } + return false; } } diff --git a/src/Util/SummaryJson.php b/src/Util/SummaryJson.php new file mode 100644 index 0000000..202dcd4 --- /dev/null +++ b/src/Util/SummaryJson.php @@ -0,0 +1,30 @@ + $summary + */ + public static function write(string $path, array $summary): void + { + ksort($summary); + + try { + $encoded = json_encode($summary, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . PHP_EOL; + } catch (\JsonException $exception) { + throw new \RuntimeException( + sprintf('Could not encode summary JSON for %s: %s', $path, $exception->getMessage()), + previous: $exception, + ); + } + + if (file_put_contents($path, $encoded) === false) { + throw new \RuntimeException(sprintf('Failed to write summary JSON file: %s', $path)); + } + } +} + diff --git a/tests/Feature/ApiSnapshotCheckerTest.php b/tests/Feature/ApiSnapshotCheckerTest.php index 6232a6c..8902532 100644 --- a/tests/Feature/ApiSnapshotCheckerTest.php +++ b/tests/Feature/ApiSnapshotCheckerTest.php @@ -49,6 +49,28 @@ ->and($result['changes']['changed'])->toContain('class Demo\Contract'); }); +it('supports fail-on=error to report drift without failing exit code', function (): void { + $root = makeApiSnapshotCheckerFixture(); + $src = $root.DIRECTORY_SEPARATOR.'src'; + $baseline = $root.DIRECTORY_SEPARATOR.'api-baseline.json'; + + mkdir($src, 0755, true); + file_put_contents($src.DIRECTORY_SEPARATOR.'Contract.php', apiContractFixture('string')); + + try { + runApiSnapshotCheckerCommand($root, ['--write-baseline='.$baseline, 'src']); + file_put_contents($src.DIRECTORY_SEPARATOR.'Contract.php', apiContractFixture('int')); + $run = runApiSnapshotCheckerCommand($root, ['--json', '--fail-on=error', '--baseline='.$baseline, 'src']); + } finally { + removeApiSnapshotCheckerFixture($root); + } + + $result = json_decode($run['stdout'], true); + + expect($run['exitCode'])->toBe(0) + ->and($result['changed'])->toBeTrue(); +}); + it('can ignore protected members for public-only snapshots', function (): void { $root = makeApiSnapshotCheckerFixture(); $src = $root.DIRECTORY_SEPARATOR.'src'; @@ -163,6 +185,26 @@ public function name(): string ->and($run['stderr'])->toContain('Invalid API baseline JSON'); }); +it('writes summary json output when requested', function (): void { + $root = makeApiSnapshotCheckerFixture(); + $src = $root.DIRECTORY_SEPARATOR.'src'; + $summary = $root.DIRECTORY_SEPARATOR.'summary.json'; + + mkdir($src, 0755, true); + file_put_contents($src.DIRECTORY_SEPARATOR.'Contract.php', apiContractFixture('string')); + + try { + $run = runApiSnapshotCheckerCommand($root, ['--summary-json='.$summary, 'src']); + $payload = json_decode(file_get_contents($summary) ?: 'null', true); + } finally { + removeApiSnapshotCheckerFixture($root); + } + + expect($run['exitCode'])->toBe(0) + ->and($payload['checker'])->toBe('api') + ->and($payload['exit_code'])->toBe(0); +}); + function apiContractFixture(string $returnType, string $protectedReturnType = 'int'): string { return <<and($run['stderr'])->toContain('Unknown option for comments command: --does-not-exist'); }); +it('supports markdown output format', function (): void { + $root = makeCommentCheckerFixture(); + $src = $root.DIRECTORY_SEPARATOR.'src'; + + mkdir($src, 0755, true); + file_put_contents($src.DIRECTORY_SEPARATOR.'Marker.php', <<<'PHP' +toBe(1) + ->and($run['stdout'])->toContain('# PHPProbe Comment Report') + ->and($run['stdout'])->toContain('`comment_marker`'); +}); + function makeCommentCheckerFixture(): string { $root = sys_get_temp_dir().DIRECTORY_SEPARATOR.'phpprobe-comments-'.uniqid('', true); diff --git a/tests/Feature/DuplicateCheckerTest.php b/tests/Feature/DuplicateCheckerTest.php index 6aa6077..c4f5f2e 100644 --- a/tests/Feature/DuplicateCheckerTest.php +++ b/tests/Feature/DuplicateCheckerTest.php @@ -84,6 +84,34 @@ public function value(): int ->and($result['clones'])->toBe([]); }); +it('supports fail-on=error threshold for duplicate percentage', function (): void { + $root = makeDuplicateCheckerFixture(); + $src = $root.DIRECTORY_SEPARATOR.'src'; + + mkdir($src, 0755, true); + file_put_contents($src.DIRECTORY_SEPARATOR.'Alpha.php', duplicateBaselineFixture('Alpha')); + file_put_contents($src.DIRECTORY_SEPARATOR.'Beta.php', duplicateBaselineFixture('Beta')); + + try { + $run = runDuplicateCheckerCommand($root, [ + '--json', + '--fuzzy', + '--min-lines=5', + '--min-tokens=20', + '--fail-on=error', + '--error-duplicate-percentage=100', + 'src', + ]); + } finally { + removeDuplicateCheckerFixture($root); + } + + $result = json_decode($run['stdout'], true); + + expect($run['exitCode'])->toBe(0) + ->and($result['clones'])->not()->toBeEmpty(); +}); + it('detects near-miss block clones in audit mode', function (): void { $root = makeDuplicateCheckerFixture(); $src = $root.DIRECTORY_SEPARATOR.'src'; diff --git a/tests/Feature/SyntaxCheckerTest.php b/tests/Feature/SyntaxCheckerTest.php index c90c6ff..de69c46 100644 --- a/tests/Feature/SyntaxCheckerTest.php +++ b/tests/Feature/SyntaxCheckerTest.php @@ -70,6 +70,38 @@ final class Broken ->and($run['stdout'])->toContain('Syntax OK: 1 PHP files checked.'); }); +it('supports parallel syntax linting with json output', function (): void { + $root = makeSyntaxCheckerFixture(); + $src = $root.DIRECTORY_SEPARATOR.'src'; + + mkdir($src, 0755, true); + file_put_contents($src.DIRECTORY_SEPARATOR.'One.php', <<<'PHP' +toBe(1) + ->and($result['files_checked'])->toBe(2) + ->and($result['failures'])->toHaveCount(1); +}); + it('rejects unknown syntax command options', function (): void { $root = makeSyntaxCheckerFixture(); From a624278f686be957ada19266e280fa97c0d3394b Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 6 May 2026 16:08:35 +0600 Subject: [PATCH 3/6] comment detect --- .github/workflows/ci.yml | 2 +- composer.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ed1147c..dec0a59 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -111,7 +111,7 @@ jobs: if composer run --list | grep -qE '^\s+test\s'; then composer test elif [ -x vendor/bin/pest ]; then - vendor/bin/pest -c pest.xml + vendor/bin/pest --configuration=pest.xml elif [ -x vendor/bin/phpunit ]; then vendor/bin/phpunit else diff --git a/composer.json b/composer.json index 3f117d9..81dea7e 100644 --- a/composer.json +++ b/composer.json @@ -45,7 +45,7 @@ "sort-packages": true }, "scripts": { - "test": "vendor/bin/pest -c pest.xml", + "test": "vendor/bin/pest --configuration=pest.xml", "lint": "@php bin/phpprobe syntax src tests", "duplicates": "@php bin/phpprobe duplicates --preset=standard --config=resources/phpprobe.json --baseline=resources/.phpprobe-duplicates-baseline.json src tests", "api": "@php bin/phpprobe api --config=resources/phpprobe.json src tests", From 0d9ada44bd17a518c8ac44da38e98629fb376fdf Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 6 May 2026 16:17:43 +0600 Subject: [PATCH 4/6] ic fix --- bin/phpprobe | 1 + composer.json | 99 ++++++++++---------- resources/.phpprobe-duplicates-baseline.json | 32 +++---- src/ApiSnapshotChecker.php | 63 ++----------- src/CommentChecker.php | 61 ++---------- src/Config/CliOptions.php | 54 +++++++++++ src/DuplicateChecker.php | 62 ++---------- src/SyntaxChecker.php | 66 +++---------- src/Util/Sarif.php | 29 ++++++ 9 files changed, 184 insertions(+), 283 deletions(-) create mode 100644 src/Util/Sarif.php diff --git a/bin/phpprobe b/bin/phpprobe index 85b4489..5f274f9 100644 --- a/bin/phpprobe +++ b/bin/phpprobe @@ -21,6 +21,7 @@ foreach ($autoloaders as $autoloader) { if (!class_exists(Cli::class)) { require __DIR__ . '/../src/Util/ArrayShape.php'; require __DIR__ . '/../src/Util/ProjectPath.php'; + require __DIR__ . '/../src/Util/Sarif.php'; require __DIR__ . '/../src/Util/SummaryJson.php'; require __DIR__ . '/../src/Console/Ansi.php'; require __DIR__ . '/../src/Process/ProcessResult.php'; diff --git a/composer.json b/composer.json index 81dea7e..507e23f 100644 --- a/composer.json +++ b/composer.json @@ -1,54 +1,55 @@ { - "name": "infocyph/phpprobe", - "description": "Standalone PHP syntax and duplicate code checker.", - "license": "MIT", - "type": "library", - "keywords": [ - "infocyph", - "php", - "syntax", - "duplicate-code", - "copy-paste-detection" - ], - "authors": [ - { - "name": "infocyph", - "email": "infocyph@gmail.com" + "name": "infocyph/phpprobe", + "description": "Standalone PHP syntax and duplicate code checker.", + "license": "MIT", + "type": "library", + "keywords": [ + "infocyph", + "php", + "syntax", + "duplicate-code", + "copy-paste-detection" + ], + "authors": [ + { + "name": "infocyph", + "email": "infocyph@gmail.com" + }, + { + "name": "abmmhasan", + "email": "abmmhasan@gmail.com" + } + ], + "require": { + "php": ">=8.2", + "nikic/php-parser": "^5.7" }, - { - "name": "abmmhasan", - "email": "abmmhasan@gmail.com" - } - ], - "require": { - "php": ">=8.2", - "nikic/php-parser": "^5.7" - }, - "require-dev": { - "pestphp/pest": ">=3.0 <5.0" - }, - "minimum-stability": "dev", - "prefer-stable": true, - "autoload": { - "psr-4": { - "Infocyph\\PHPProbe\\": "src/" - } - }, - "bin": [ - "bin/phpprobe" - ], - "config": { - "allow-plugins": { - "pestphp/pest-plugin": true + "require-dev": { + "pestphp/pest": ">=3.0 <5.0" + }, + "minimum-stability": "dev", + "prefer-stable": true, + "autoload": { + "psr-4": { + "Infocyph\\PHPProbe\\": "src/" + } + }, + "bin": [ + "bin/phpprobe" + ], + "config": { + "allow-plugins": { + "pestphp/pest-plugin": true + }, + "optimize-autoloader": true, + "sort-packages": true }, - "optimize-autoloader": true, - "sort-packages": true - }, "scripts": { - "test": "vendor/bin/pest --configuration=pest.xml", - "lint": "@php bin/phpprobe syntax src tests", - "duplicates": "@php bin/phpprobe duplicates --preset=standard --config=resources/phpprobe.json --baseline=resources/.phpprobe-duplicates-baseline.json src tests", - "api": "@php bin/phpprobe api --config=resources/phpprobe.json src tests", - "comments": "@php bin/phpprobe comments --config=resources/phpprobe.json src tests" + "test": "vendor/bin/pest --configuration=pest.xml", + "lint": "@php bin/phpprobe syntax src tests", + "duplicates": "@php bin/phpprobe duplicates --preset=standard --config=resources/phpprobe.json --baseline=resources/.phpprobe-duplicates-baseline.json src tests", + "api": "@php bin/phpprobe api --config=resources/phpprobe.json src tests", + "comments": "@php bin/phpprobe comments --config=resources/phpprobe.json src tests", + "tests" : ["@test", "@lint", "@duplicates", "@api", "@comments"] } - } +} diff --git a/resources/.phpprobe-duplicates-baseline.json b/resources/.phpprobe-duplicates-baseline.json index 4c04718..5833e00 100644 --- a/resources/.phpprobe-duplicates-baseline.json +++ b/resources/.phpprobe-duplicates-baseline.json @@ -1,17 +1,7 @@ { "version": 1, - "generated_at": "2026-05-06T09:35:59+00:00", + "generated_at": "2026-05-06T10:13:40+00:00", "clones": [ - { - "fingerprint": "65edf497c75023b2bd013f4bb1f770a5e43e88ce8e37922c7f9d4d0e88b063c7", - "source": "tokens", - "score": 224.4 - }, - { - "fingerprint": "0bffdd382bce7ed3caff6476cc3321d143a17c67653db094cd405fb72538effe", - "source": "tokens", - "score": 188.6 - }, { "fingerprint": "6e7956bf0217046b225214da8669f784cb404b1f309f5cd33ea2b077b803733a", "source": "tokens", @@ -23,17 +13,17 @@ "score": 181.8 }, { - "fingerprint": "4d68193edc1c2b035b5b86e760e702616040481cfb62627858ed7341d948ec05", + "fingerprint": "24303addd6d51326dbd02139a76a1b51cdace40a834a0b36c3f3f1373ccd1977", "source": "tokens", - "score": 175.2 + "score": 177.8 }, { - "fingerprint": "c18a55081eea7f8ed993650c968c9d325c43c289854a86d33d22019b90166751", + "fingerprint": "af8c2c9cf92601883292cc5c73bf0a815828ec9dd830d2278800f4b63f40f008", "source": "tokens", "score": 172.6 }, { - "fingerprint": "de63e6b88a5859c1e66539bb6d56ed4433bd1611a4560b921ea7769de1f415ee", + "fingerprint": "fd87aa84cb9d10b70f9127f74a8a6bfd62ec98a38263c0f3d83f6db556a42f9e", "source": "tokens", "score": 166.4 }, @@ -43,19 +33,19 @@ "score": 160.8 }, { - "fingerprint": "7295e29cf724e60bc160914ab685e55f33fc7286fb20c4ed5c4e0bd112125019", + "fingerprint": "090072e42a39aac6498f4b466da4e71f5143ebf493057a4ad859741444d34083", "source": "tokens", - "score": 155.6 + "score": 159 }, { - "fingerprint": "f5baf6b06984fc54d1f8233149c95bb135f50198fe1e8937c55780c73788c967", + "fingerprint": "7295e29cf724e60bc160914ab685e55f33fc7286fb20c4ed5c4e0bd112125019", "source": "tokens", - "score": 154.6 + "score": 155.6 }, { - "fingerprint": "b27cfdf7ec1935f38205ad2bd03851bc6db371a5e4a4ad2b6c3a2d05baceb414", + "fingerprint": "f5f8ab66610450e003dd3c0efb98467154e24dc0d60e702d541c9a278a2ba0d0", "source": "tokens", - "score": 150 + "score": 138 }, { "fingerprint": "8d1cdda29cbcb2080259ca519efd72373807ff055705bb866c3089e3f94156a9", diff --git a/src/ApiSnapshotChecker.php b/src/ApiSnapshotChecker.php index dd79017..94b639d 100644 --- a/src/ApiSnapshotChecker.php +++ b/src/ApiSnapshotChecker.php @@ -10,6 +10,7 @@ use Infocyph\PHPProbe\Config\PhpProbeConfig; use Infocyph\PHPProbe\Console\Ansi; use Infocyph\PHPProbe\Filesystem\PhpFileFinder; +use Infocyph\PHPProbe\Util\Sarif; use Infocyph\PHPProbe\Util\SummaryJson; final class ApiSnapshotChecker @@ -187,45 +188,13 @@ private function parseArgs(array $args): array $config = PhpProbeConfig::fromFile($options['config']); $options = $this->cli->mergeConfigWithPreset($config, $this->cli->presetName($args))->applyApiOptions($options); $configuredPaths = $options['paths']; - $options['paths'] = []; - $collectingPathsOnly = false; - - $index = 0; - $argCount = count($args); - - while ($index < $argCount) { - $arg = $args[$index]; - - if ($collectingPathsOnly) { - $options['paths'][] = $arg; - $index++; - - continue; - } - - if ($arg === '--') { - $collectingPathsOnly = true; - $index++; - - continue; - } - - if (!$this->cli->skipConfig($args, $index, $arg) - && !$this->cli->skipPreset($args, $index, $arg) - && !$this->parseCliOption($args, $index, $options, $arg)) { - if (str_starts_with($arg, '-')) { - throw new \InvalidArgumentException(sprintf('Unknown option for api command: %s', $arg)); - } - - $options['paths'][] = $arg; - } - - $index++; - } - - if ($options['paths'] === []) { - $options['paths'] = $configuredPaths; - } + $this->cli->collectPaths( + $args, + $options, + $configuredPaths, + fn(string $arg, int &$index, array &$items): bool => $this->parseCliOption($args, $index, $items, $arg), + 'Unknown option for api command: %s', + ); return $options; } @@ -468,21 +437,7 @@ private function writeSarif(array $result): void } } - $payload = [ - 'version' => '2.1.0', - '$schema' => 'https://json.schemastore.org/sarif-2.1.0.json', - 'runs' => [[ - 'tool' => [ - 'driver' => [ - 'name' => 'PHPProbe', - 'informationUri' => 'https://github.com/infocyph/phpprobe', - ], - ], - 'results' => $results, - ]], - ]; - - fwrite(STDOUT, json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL); + fwrite(STDOUT, json_encode(Sarif::payload($results), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL); } /** diff --git a/src/CommentChecker.php b/src/CommentChecker.php index 9607c7d..03a2e7e 100644 --- a/src/CommentChecker.php +++ b/src/CommentChecker.php @@ -11,6 +11,7 @@ use Infocyph\PHPProbe\Config\PhpProbeConfig; use Infocyph\PHPProbe\Console\Ansi; use Infocyph\PHPProbe\Filesystem\PhpFileFinder; +use Infocyph\PHPProbe\Util\Sarif; use Infocyph\PHPProbe\Util\SummaryJson; final class CommentChecker @@ -175,43 +176,13 @@ private function parseArgs(array $args): array $config = $this->cli->mergeConfigWithPreset(PhpProbeConfig::fromFile($options['config']), $this->cli->presetName($args)); $options = $config->applyCommentOptions($options); $configuredPaths = $options['paths']; - $options['paths'] = []; - $collectingPathsOnly = false; - $argCount = count($args); - - for ($index = 0; $index < $argCount; $index++) { - $arg = $args[$index]; - - if ($collectingPathsOnly) { - $options['paths'][] = $arg; - - continue; - } - - if ($arg === '--') { - $collectingPathsOnly = true; - - continue; - } - - if ($this->cli->skipConfig($args, $index, $arg) || $this->cli->skipPreset($args, $index, $arg)) { - continue; - } - - if ($this->parseCliOption($args, $index, $options, $arg)) { - continue; - } - - if (str_starts_with($arg, '-')) { - throw new \InvalidArgumentException(sprintf('Unknown option for comments command: %s', $arg)); - } - - $options['paths'][] = $arg; - } - - if ($options['paths'] === []) { - $options['paths'] = $configuredPaths; - } + $this->cli->collectPaths( + $args, + $options, + $configuredPaths, + fn(string $arg, int &$index, array &$items): bool => $this->parseCliOption($args, $index, $items, $arg), + 'Unknown option for comments command: %s', + ); $options = $this->applyPolicyPreset($options); @@ -505,21 +476,7 @@ private function writeSarif(array $result): void ]; } - $payload = [ - 'version' => '2.1.0', - '$schema' => 'https://json.schemastore.org/sarif-2.1.0.json', - 'runs' => [[ - 'tool' => [ - 'driver' => [ - 'name' => 'PHPProbe', - 'informationUri' => 'https://github.com/infocyph/phpprobe', - ], - ], - 'results' => $results, - ]], - ]; - - fwrite(STDOUT, json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL); + fwrite(STDOUT, json_encode(Sarif::payload($results), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL); } private function sarifLevel(string $severity): string diff --git a/src/Config/CliOptions.php b/src/Config/CliOptions.php index d5c313a..049c08a 100644 --- a/src/Config/CliOptions.php +++ b/src/Config/CliOptions.php @@ -6,6 +6,60 @@ final readonly class CliOptions { + /** + * @param list $args + * @param array $options + * @param callable(string,int,array):bool $parseCliOption + */ + public function collectPaths(array $args, array &$options, array $configuredPaths, callable $parseCliOption, string $unknownOptionMessage): void + { + $options['paths'] = []; + $collectingPathsOnly = false; + $index = 0; + $argCount = count($args); + + while ($index < $argCount) { + $arg = $args[$index]; + + if ($collectingPathsOnly) { + $options['paths'][] = $arg; + $index++; + + continue; + } + + if ($arg === '--') { + $collectingPathsOnly = true; + $index++; + + continue; + } + + if ($this->skipConfig($args, $index, $arg) || $this->skipPreset($args, $index, $arg)) { + $index++; + + continue; + } + + if ($parseCliOption($arg, $index, $options)) { + $index++; + + continue; + } + + if (str_starts_with($arg, '-')) { + throw new \InvalidArgumentException(sprintf($unknownOptionMessage, $arg)); + } + + $options['paths'][] = $arg; + $index++; + } + + if ($options['paths'] === []) { + $options['paths'] = $configuredPaths; + } + } + /** * @param list $allowed */ diff --git a/src/DuplicateChecker.php b/src/DuplicateChecker.php index e583786..10313b6 100644 --- a/src/DuplicateChecker.php +++ b/src/DuplicateChecker.php @@ -11,6 +11,7 @@ use Infocyph\PHPProbe\Detection\DuplicateCloneReducer; use Infocyph\PHPProbe\Detection\DuplicateDetectionEngine; use Infocyph\PHPProbe\Filesystem\PhpFileFinder; +use Infocyph\PHPProbe\Util\Sarif; use Infocyph\PHPProbe\Util\SummaryJson; final class DuplicateChecker @@ -202,44 +203,13 @@ private function parseArgs(array $args): array $options = $this->cli->mergeConfigWithPreset($config, $this->cli->presetName($args))->applyDuplicateOptions($options); $options = $this->normalizeMode($options); $configuredPaths = $options['paths']; - $options['paths'] = []; - $collectingPathsOnly = false; - - $argCount = count($args); - - for ($index = 0; $index < $argCount; $index++) { - $arg = $args[$index]; - - if ($collectingPathsOnly) { - $options['paths'][] = $arg; - - continue; - } - - if ($arg === '--') { - $collectingPathsOnly = true; - - continue; - } - - if ($this->cli->skipConfig($args, $index, $arg) || $this->cli->skipPreset($args, $index, $arg)) { - continue; - } - - if ($this->parseCliOption($args, $index, $options, $arg)) { - continue; - } - - if (str_starts_with($arg, '-')) { - throw new \InvalidArgumentException(sprintf('Unknown option for duplicates command: %s', $arg)); - } - - $options['paths'][] = $arg; - } - - if ($options['paths'] === []) { - $options['paths'] = $configuredPaths; - } + $this->cli->collectPaths( + $args, + $options, + $configuredPaths, + fn(string $arg, int &$index, array &$items): bool => $this->parseCliOption($args, $index, $items, $arg), + 'Unknown option for duplicates command: %s', + ); return $options; } @@ -665,21 +635,7 @@ private function writeSarif(array $result): void } } - $payload = [ - 'version' => '2.1.0', - '$schema' => 'https://json.schemastore.org/sarif-2.1.0.json', - 'runs' => [[ - 'tool' => [ - 'driver' => [ - 'name' => 'PHPProbe', - 'informationUri' => 'https://github.com/infocyph/phpprobe', - ], - ], - 'results' => $results, - ]], - ]; - - fwrite(STDOUT, json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL); + fwrite(STDOUT, json_encode(Sarif::payload($results), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL); } /** diff --git a/src/SyntaxChecker.php b/src/SyntaxChecker.php index 553c7e1..3a440c5 100644 --- a/src/SyntaxChecker.php +++ b/src/SyntaxChecker.php @@ -11,6 +11,7 @@ use Infocyph\PHPProbe\Filesystem\PhpFileFinder; use Infocyph\PHPProbe\Process\ProcessResult; use Infocyph\PHPProbe\Process\ProcRunner; +use Infocyph\PHPProbe\Util\Sarif; use Infocyph\PHPProbe\Util\SummaryJson; final class SyntaxChecker @@ -162,7 +163,9 @@ private function lintFilesParallel(array $files, int $parallel): array fclose($job['pipes'][0]); fclose($job['pipes'][1]); fclose($job['pipes'][2]); - $exitCode = proc_close($job['process']); + $closeExitCode = proc_close($job['process']); + $statusExitCode = is_int($status['exitcode'] ?? null) ? $status['exitcode'] : -1; + $exitCode = $statusExitCode !== -1 ? $statusExitCode : $closeExitCode; if ($exitCode !== 0) { $message = trim($job['stdout'] . PHP_EOL . $job['stderr']); @@ -319,21 +322,7 @@ private function writeSarif(array $result): void ]; } - $payload = [ - 'version' => '2.1.0', - '$schema' => 'https://json.schemastore.org/sarif-2.1.0.json', - 'runs' => [[ - 'tool' => [ - 'driver' => [ - 'name' => 'PHPProbe', - 'informationUri' => 'https://github.com/infocyph/phpprobe', - ], - ], - 'results' => $results, - ]], - ]; - - fwrite(STDOUT, json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL); + fwrite(STDOUT, json_encode(Sarif::payload($results), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL); } /** @@ -390,44 +379,13 @@ private function parseArgs(array $args): array $config = $this->cli->mergeConfigWithPreset(PhpProbeConfig::fromFile($options['config']), $this->cli->presetName($args)); $options = $config->applySyntaxOptions($options); $configuredPaths = $options['paths']; - $options['paths'] = []; - $index = 0; - $argCount = count($args); - $collectingPathsOnly = false; - - while ($index < $argCount) { - $arg = $args[$index]; - - if ($collectingPathsOnly) { - $options['paths'][] = $arg; - $index++; - - continue; - } - - if ($arg === '--') { - $collectingPathsOnly = true; - $index++; - - continue; - } - - if (!$this->cli->skipConfig($args, $index, $arg) - && !$this->cli->skipPreset($args, $index, $arg) - && !$this->parseCliOption($args, $index, $options, $arg)) { - if (str_starts_with($arg, '-')) { - throw new \InvalidArgumentException(sprintf('Unknown option for syntax command: %s', $arg)); - } - - $options['paths'][] = $arg; - } - - $index++; - } - - if ($options['paths'] === []) { - $options['paths'] = $configuredPaths; - } + $this->cli->collectPaths( + $args, + $options, + $configuredPaths, + fn(string $arg, int &$index, array &$items): bool => $this->parseCliOption($args, $index, $items, $arg), + 'Unknown option for syntax command: %s', + ); $options['parallel'] = max(1, (int) $options['parallel']); diff --git a/src/Util/Sarif.php b/src/Util/Sarif.php new file mode 100644 index 0000000..8aa76b5 --- /dev/null +++ b/src/Util/Sarif.php @@ -0,0 +1,29 @@ +> $results + * @return array + */ + public static function payload(array $results): array + { + return [ + 'version' => '2.1.0', + '$schema' => 'https://json.schemastore.org/sarif-2.1.0.json', + 'runs' => [[ + 'tool' => [ + 'driver' => [ + 'name' => 'PHPProbe', + 'informationUri' => 'https://github.com/infocyph/phpprobe', + ], + ], + 'results' => $results, + ]], + ]; + } +} From ed2f5e70391ef4b72ff934412c037a02ce689c6a Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 6 May 2026 16:32:40 +0600 Subject: [PATCH 5/6] ic fix --- .github/workflows/ci.yml | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dec0a59..9a3522c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -108,12 +108,26 @@ jobs: - name: Run PHPForge tests working-directory: phpforge run: | - if composer run --list | grep -qE '^\s+test\s'; then - composer test - elif [ -x vendor/bin/pest ]; then - vendor/bin/pest --configuration=pest.xml + if [ -x vendor/bin/pest ]; then + if [ -f pest.xml ]; then + vendor/bin/pest --configuration=pest.xml + elif [ -f phpunit.xml ]; then + vendor/bin/pest --configuration=phpunit.xml + elif [ -f phpunit.xml.dist ]; then + vendor/bin/pest --configuration=phpunit.xml.dist + else + vendor/bin/pest + fi elif [ -x vendor/bin/phpunit ]; then - vendor/bin/phpunit + if [ -f phpunit.xml ]; then + vendor/bin/phpunit --configuration=phpunit.xml + elif [ -f phpunit.xml.dist ]; then + vendor/bin/phpunit --configuration=phpunit.xml.dist + else + vendor/bin/phpunit + fi + elif composer run --list | grep -qE '^\s+test\s'; then + composer test else echo "No supported PHPForge test command found." >&2 exit 1 From c6c39beae87d5e3b527a331cf665aec7061743bc Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 6 May 2026 16:41:42 +0600 Subject: [PATCH 6/6] ic fix --- .github/workflows/ci.yml | 32 +++++++++++--------------------- 1 file changed, 11 insertions(+), 21 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a3522c..e9e9b1a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,7 +27,7 @@ jobs: steps: - name: Checkout PHPProbe - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -62,7 +62,7 @@ jobs: - name: Upload checker reports if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: phpprobe-reports-php-${{ matrix.php }} path: build/reports/ @@ -77,12 +77,12 @@ jobs: steps: - name: Checkout PHPProbe - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: path: phpprobe - name: Checkout PHPForge - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: repository: ${{ env.PHPFORGE_REPOSITORY }} ref: ${{ env.PHPFORGE_REF }} @@ -108,24 +108,14 @@ jobs: - name: Run PHPForge tests working-directory: phpforge run: | - if [ -x vendor/bin/pest ]; then - if [ -f pest.xml ]; then - vendor/bin/pest --configuration=pest.xml - elif [ -f phpunit.xml ]; then - vendor/bin/pest --configuration=phpunit.xml - elif [ -f phpunit.xml.dist ]; then - vendor/bin/pest --configuration=phpunit.xml.dist - else - vendor/bin/pest - fi + if composer run --list | grep -qE '^\s+ic:test:code\s'; then + composer ic:test:code + elif composer run --list | grep -qE '^\s+ic:tests\s'; then + composer ic:tests + elif [ -x vendor/bin/pest ]; then + vendor/bin/pest elif [ -x vendor/bin/phpunit ]; then - if [ -f phpunit.xml ]; then - vendor/bin/phpunit --configuration=phpunit.xml - elif [ -f phpunit.xml.dist ]; then - vendor/bin/phpunit --configuration=phpunit.xml.dist - else - vendor/bin/phpunit - fi + vendor/bin/phpunit elif composer run --list | grep -qE '^\s+test\s'; then composer test else