From 425772cd7b7fd8c6fdf2817425c451ad0559758a Mon Sep 17 00:00:00 2001 From: Ben Date: Tue, 2 Jun 2026 07:21:31 +0100 Subject: [PATCH 1/6] fix(config): fall back to defaults and implement baseline config-drift detection Empty or non-string `baseline`/`cache` config values previously collapsed to the project root directory instead of the documented defaults, silently breaking baseline load/write. They now fall back to the defaults. The baseline already stored an include/exclude fingerprint under `config.hash`, but nothing ever read it, so the README's "helps catch accidental config drift" was unimplemented. The fingerprint is now centralised on BladeCoverageConfig, read back via BladeCoverageBaseline::loadConfigHash(), and a non-failing warning is emitted on a normal run when the current config no longer matches the fingerprint stored in the baseline. Legacy path-to-hash baselines are unaffected. Co-Authored-By: Claude Opus 4.8 --- README.md | 4 +- src/BladeCoverageBaseline.php | 67 ++++++++++++++++-------- src/BladeCoverageConfig.php | 16 ++++-- src/BladeCoverageOutput.php | 5 ++ src/BladeCoveragePlugin.php | 15 ++++++ tests/Unit/BladeCoverageServicesTest.php | 54 +++++++++++++++++++ 6 files changed, 135 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 09791b7..c5bb30c 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,9 @@ view through Laravel, or by deleting the Blade file if it is genuinely unused. ## Baseline Format The baseline stores uncovered views by normalized path and content hash. It also stores -metadata that helps catch accidental config drift: +the include/exclude fingerprint so the run can warn when the configuration has changed +since the baseline was generated (re-run with `--blade-coverage-update-baseline` to +refresh it). The warning is informational and does not change the exit code: ```json { diff --git a/src/BladeCoverageBaseline.php b/src/BladeCoverageBaseline.php index f0a5f6d..1319b4e 100644 --- a/src/BladeCoverageBaseline.php +++ b/src/BladeCoverageBaseline.php @@ -14,30 +14,16 @@ final class BladeCoverageBaseline */ public function load(string $path): array { - if (! is_file($path)) { - return []; - } + $decoded = $this->decode($path); - $contents = file_get_contents($path); - - if ($contents === false || trim($contents) === '') { + if ($decoded === null) { return []; } - try { - $decoded = json_decode($contents, true, 512, JSON_THROW_ON_ERROR); - } catch (JsonException $jsonException) { - throw new RuntimeException(sprintf('Unable to decode Blade coverage baseline [%s]: %s', $path, $jsonException->getMessage()), previous: $jsonException); - } - - $views = is_array($decoded) && isset($decoded['views']) && is_array($decoded['views']) + $views = isset($decoded['views']) && is_array($decoded['views']) ? $decoded['views'] : $decoded; - if (! is_array($views)) { - return []; - } - $baseline = []; foreach ($views as $view => $hash) { @@ -51,6 +37,24 @@ public function load(string $path): array return $baseline; } + /** + * Returns the config fingerprint stored in the baseline, if present. + * + * Used to detect when the include/exclude configuration has changed since + * the baseline was generated. Older path-to-hash baselines return null. + */ + public function loadConfigHash(string $path): ?string + { + $decoded = $this->decode($path); + $config = $decoded['config'] ?? null; + + if (! is_array($config)) { + return null; + } + + return isset($config['hash']) && is_string($config['hash']) ? $config['hash'] : null; + } + /** * @param array $uncoveredTargets */ @@ -91,10 +95,7 @@ public function write(string $path, array $uncoveredTargets, ?BladeCoverageResul $payload['config'] = [ 'include' => $config->include, 'exclude' => $config->exclude, - 'hash' => hash('sha256', json_encode([ - 'include' => $config->include, - 'exclude' => $config->exclude, - ], JSON_THROW_ON_ERROR)), + 'hash' => $config->fingerprint(), ]; } @@ -104,4 +105,28 @@ public function write(string $path, array $uncoveredTargets, ?BladeCoverageResul throw new RuntimeException(sprintf('Unable to write Blade coverage baseline [%s].', $path)); } } + + /** + * @return array|null + */ + private function decode(string $path): ?array + { + if (! is_file($path)) { + return null; + } + + $contents = file_get_contents($path); + + if ($contents === false || trim($contents) === '') { + return null; + } + + try { + $decoded = json_decode($contents, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $jsonException) { + throw new RuntimeException(sprintf('Unable to decode Blade coverage baseline [%s]: %s', $path, $jsonException->getMessage()), previous: $jsonException); + } + + return is_array($decoded) ? $decoded : null; + } } diff --git a/src/BladeCoverageConfig.php b/src/BladeCoverageConfig.php index 67663c9..2bb71d6 100644 --- a/src/BladeCoverageConfig.php +++ b/src/BladeCoverageConfig.php @@ -28,12 +28,20 @@ public static function fromArray(array $config, string $rootPath): self $include = self::stringList($config['include'] ?? ['packages/*/resources/views/**/*.blade.php']); $exclude = self::stringList($config['exclude'] ?? []); - $baselinePath = self::resolvePath($rootPath, self::stringValue($config['baseline'] ?? $config['baseline_path'] ?? 'tests/BladeCoverage/baseline.json')); - $cachePath = self::resolvePath($rootPath, self::stringValue($config['cache'] ?? $config['cache_path'] ?? '.cache/pest-blade-coverage')); + $baselinePath = self::resolvePath($rootPath, self::stringValue($config['baseline'] ?? $config['baseline_path'] ?? null, 'tests/BladeCoverage/baseline.json')); + $cachePath = self::resolvePath($rootPath, self::stringValue($config['cache'] ?? $config['cache_path'] ?? null, '.cache/pest-blade-coverage')); return new self($rootPath, $include, $exclude, $baselinePath, $cachePath); } + public function fingerprint(): string + { + return hash('sha256', (string) json_encode([ + 'include' => $this->include, + 'exclude' => $this->exclude, + ], JSON_THROW_ON_ERROR)); + } + private static function resolvePath(string $rootPath, string $path): string { if (Path::isAbsolute($path)) { @@ -55,8 +63,8 @@ private static function stringList(mixed $value): array return array_values(array_filter($value, is_string(...))); } - private static function stringValue(mixed $value): string + private static function stringValue(mixed $value, string $default): string { - return is_string($value) && $value !== '' ? $value : ''; + return is_string($value) && $value !== '' ? $value : $default; } } diff --git a/src/BladeCoverageOutput.php b/src/BladeCoverageOutput.php index 1c65a77..8676740 100644 --- a/src/BladeCoverageOutput.php +++ b/src/BladeCoverageOutput.php @@ -40,6 +40,11 @@ public function renderError(string $message): void $this->output->writeln(sprintf(' %s', $message)); } + public function renderWarning(string $message): void + { + $this->output->writeln(sprintf(' %s', $message)); + } + public function renderJsonReport(string $path): void { $this->output->writeln(sprintf(' JSON report: %s', $path)); diff --git a/src/BladeCoveragePlugin.php b/src/BladeCoveragePlugin.php index 2d8a2e5..8655083 100644 --- a/src/BladeCoveragePlugin.php +++ b/src/BladeCoveragePlugin.php @@ -169,6 +169,14 @@ public function addOutput(int $exitCode): int } $output->render($result, $baselineUpdated, $config->baselinePath); + + if (! $baselineUpdated && $this->baselineConfigDrifted($config)) { + $output->renderWarning(sprintf( + 'Blade coverage config changed since the baseline was generated. Re-run with %s to refresh it.', + self::UPDATE_BASELINE_OPTION, + )); + } + $this->writeJsonReport($result, $baselineUpdated, $config->baselinePath, $output); if ($baselineUpdated || ! $result->failed()) { @@ -246,6 +254,13 @@ private function config(): BladeCoverageConfig return $this->cachedConfig ??= $this->configLoader->load($this->configPath); } + private function baselineConfigDrifted(BladeCoverageConfig $config): bool + { + $storedHash = $this->baseline->loadConfigHash($config->baselinePath); + + return $storedHash !== null && $storedHash !== $config->fingerprint(); + } + private function writeJsonReport(BladeCoverageResult $result, bool $baselineUpdated, string $baselinePath, BladeCoverageOutput $output): void { if ($this->jsonPath === null) { diff --git a/tests/Unit/BladeCoverageServicesTest.php b/tests/Unit/BladeCoverageServicesTest.php index 24ffab8..819f561 100644 --- a/tests/Unit/BladeCoverageServicesTest.php +++ b/tests/Unit/BladeCoverageServicesTest.php @@ -49,6 +49,60 @@ } }); +it('falls back to default baseline and cache paths for empty or invalid values', function (): void { + $root = '/srv/app'; + + $empty = BladeCoverageConfig::fromArray(['baseline' => '', 'cache' => ''], $root); + $invalid = BladeCoverageConfig::fromArray(['baseline' => ['x'], 'cache' => 123], $root); + $custom = BladeCoverageConfig::fromArray(['baseline' => 'custom/baseline.json'], $root); + + expect($empty->baselinePath)->toBe('/srv/app/tests/BladeCoverage/baseline.json') + ->and($empty->cachePath)->toBe('/srv/app/.cache/pest-blade-coverage') + ->and($invalid->baselinePath)->toBe('/srv/app/tests/BladeCoverage/baseline.json') + ->and($invalid->cachePath)->toBe('/srv/app/.cache/pest-blade-coverage') + ->and($custom->baselinePath)->toBe('/srv/app/custom/baseline.json'); +}); + +it('fingerprints include and exclude config and reads it back from the baseline', function (): void { + $root = bladeCoverageTempRoot(); + + try { + $path = $root.'/tests/BladeCoverage/baseline.json'; + $config = BladeCoverageConfig::fromArray([ + 'include' => ['packages/*/resources/views/**/*.blade.php'], + 'exclude' => ['packages/demo/resources/views/ignored.blade.php'], + ], $root); + $changedConfig = BladeCoverageConfig::fromArray([ + 'include' => ['packages/*/resources/views/**/*.blade.php'], + 'exclude' => [], + ], $root); + + (new BladeCoverageBaseline)->write($path, [], null, $config); + + $storedHash = (new BladeCoverageBaseline)->loadConfigHash($path); + + expect($storedHash)->toBe($config->fingerprint()) + ->and($storedHash)->not->toBe($changedConfig->fingerprint()); + } finally { + bladeCoverageDeleteDirectory($root); + } +}); + +it('returns no config fingerprint for legacy path-to-hash baselines', function (): void { + $root = bladeCoverageTempRoot(); + + try { + $path = $root.'/baseline.json'; + file_put_contents($path, json_encode(['packages/blog/resources/views/index.blade.php' => 'hash'])); + + expect((new BladeCoverageBaseline)->loadConfigHash($path))->toBeNull() + ->and(array_keys((new BladeCoverageBaseline)->load($path))) + ->toBe(['packages/blog/resources/views/index.blade.php']); + } finally { + bladeCoverageDeleteDirectory($root); + } +}); + it('evaluates new changed and baseline-allowed uncovered views', function (): void { $targets = [ 'covered.blade.php' => new BladeViewTarget('covered.blade.php', 'covered-hash'), From 5872a323f8361ebe428683149c699e07a279cb7c Mon Sep 17 00:00:00 2001 From: Ben Date: Tue, 2 Jun 2026 07:21:37 +0100 Subject: [PATCH 2/6] ci: add GitHub Actions workflow for tests and Pint Runs the Pest suite across PHP 8.3/8.4 with prefer-lowest and prefer-stable dependencies, plus a Pint style check. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/tests.yml | 56 +++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..d3029e5 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,56 @@ +name: tests + +on: + push: + branches: + - main + pull_request: + +jobs: + tests: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + php: ['8.3', '8.4'] + dependency-version: [prefer-lowest, prefer-stable] + + name: PHP ${{ matrix.php }} - ${{ matrix.dependency-version }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + + - name: Install dependencies + run: composer update --${{ matrix.dependency-version }} --no-interaction --no-progress --prefer-dist + + - name: Run tests + run: vendor/bin/pest --colors=always + + lint: + runs-on: ubuntu-latest + + name: Pint + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + coverage: none + + - name: Install dependencies + run: composer update --prefer-stable --no-interaction --no-progress --prefer-dist + + - name: Check code style + run: vendor/bin/pint --test From 7a132e172cdb5ce2ea575f0458cb4f31a3fd5ee0 Mon Sep 17 00:00:00 2001 From: Ben Date: Tue, 2 Jun 2026 07:21:37 +0100 Subject: [PATCH 3/6] docs: add MIT LICENSE file composer.json declares the MIT license but no LICENSE file was present. Co-Authored-By: Claude Opus 4.8 --- LICENSE | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9cc3a41 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Capell + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. From 586c72f81c2cc79c2dbe1bce6b2f23013fc83d0c Mon Sep 17 00:00:00 2001 From: Ben Date: Tue, 2 Jun 2026 07:26:35 +0100 Subject: [PATCH 4/6] ci: pin actions to full commit SHAs The org requires all GitHub Actions to be pinned to a full-length commit SHA; tag refs (@v4, @v2) were rejected at job setup. Pin actions/checkout to v4.3.1 and shivammathur/setup-php to v2.37.1 by SHA, with version comments. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/tests.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d3029e5..61aeb10 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -20,10 +20,10 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # v2.37.1 with: php-version: ${{ matrix.php }} coverage: none @@ -41,10 +41,10 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # v2.37.1 with: php-version: '8.4' coverage: none From 60b5963952932bf0140ca886a76988c0c38516b6 Mon Sep 17 00:00:00 2001 From: Ben Date: Tue, 2 Jun 2026 07:30:54 +0100 Subject: [PATCH 5/6] test: make view-assertion test work on lowest Testbench MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'records blade views rendered through Laravel view assertions' test calls $this->view(), which newer Testbench exposes on its base TestCase but the lowest-pinned version (Laravel 11.0 / Testbench 9.0) does not — so the prefer-lowest CI matrix errored with 'Call to undefined method ...::view()'. Apply the InteractsWithViews trait explicitly so the helper is available regardless of the resolved Laravel/Testbench version. Co-Authored-By: Claude Opus 4.8 --- tests/Feature/BladeRenderCollectorTest.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/Feature/BladeRenderCollectorTest.php b/tests/Feature/BladeRenderCollectorTest.php index 5c7fb99..cd8739f 100644 --- a/tests/Feature/BladeRenderCollectorTest.php +++ b/tests/Feature/BladeRenderCollectorTest.php @@ -7,9 +7,15 @@ use Capell\PestBladeCoverage\BladeViewRenderCollector; use Illuminate\Container\Container; use Illuminate\Events\Dispatcher; +use Illuminate\Foundation\Testing\Concerns\InteractsWithViews; use Illuminate\Support\Facades\Route; use Illuminate\View\Factory; +// Older Testbench TestCase versions do not pull in InteractsWithViews, which +// provides the $this->view() helper. Apply it explicitly so the helper exists +// regardless of the resolved Laravel/Testbench version. +uses(InteractsWithViews::class); + beforeEach(function (): void { $this->bladeCoverageRoot = bladeCoverageTempRoot(); $this->bladeCoverageViews = $this->bladeCoverageRoot.'/packages/example/resources/views'; From 4ca2af3b67583ac6948d0391b61c37cedfddc03e Mon Sep 17 00:00:00 2001 From: Ben Date: Tue, 2 Jun 2026 07:41:51 +0100 Subject: [PATCH 6/6] ci: bump actions/checkout to v5.0.1 (Node 24) actions/checkout v4 runs on the deprecated Node 20 runtime, which GitHub flags on every job (forced removal Sept 2026). v5.0.1 runs on Node 24. setup-php 2.37.1 is already on Node 24, so checkout was the only source of the warning. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 61aeb10..ce8c24f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -20,7 +20,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Setup PHP uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # v2.37.1 @@ -41,7 +41,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Setup PHP uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # v2.37.1