Skip to content

Commit 3deba8d

Browse files
committed
Automatically delete orphaned baseline files
The split-phpstan-baseline command now automatically deletes baseline files that no longer have corresponding errors in the generated baseline. This eliminates the need for the manual `find baselines/ -type f -not -name _loader.neon -delete` step before running the splitter. The deletion is extension-aware: only files with the same extension as the loader file are considered for deletion (e.g., .neon files when using a .neon loader).
1 parent 717cd3f commit 3deba8d

4 files changed

Lines changed: 194 additions & 3 deletions

File tree

README.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,6 @@ _(optional)_ You can simplify generation with e.g. composer script:
5959
"scripts": {
6060
"generate:baseline:phpstan": [
6161
"phpstan --generate-baseline=baselines/_loader.neon",
62-
"find baselines/ -type f -not -name _loader.neon -delete",
6362
"split-phpstan-baseline baselines/_loader.neon"
6463
]
6564
}

bin/split-phpstan-baseline

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,13 @@ try {
5656
$writtenBaselines = $splitter->split($loaderFile);
5757

5858
foreach ($writtenBaselines as $writtenBaseline => $errorsCount) {
59-
echo "Writing baseline file $writtenBaseline" . ($errorsCount !== null ? " with $errorsCount errors\n" : "\n");
59+
if ($errorsCount === false) {
60+
echo "Deleting baseline file $writtenBaseline\n";
61+
} elseif ($errorsCount !== null) {
62+
echo "Writing baseline file $writtenBaseline with $errorsCount errors\n";
63+
} else {
64+
echo "Writing baseline file $writtenBaseline\n";
65+
}
6066
}
6167

6268
} catch (ErrorException $e) {

src/BaselineSplitter.php

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,14 @@
88
use ShipMonk\PHPStan\Baseline\Handler\HandlerFactory;
99
use SplFileInfo;
1010
use function array_reduce;
11+
use function basename;
1112
use function dirname;
1213
use function file_put_contents;
14+
use function glob;
1315
use function is_file;
1416
use function ksort;
1517
use function str_replace;
18+
use function unlink;
1619

1720
class BaselineSplitter
1821
{
@@ -31,7 +34,7 @@ public function __construct(
3134
}
3235

3336
/**
34-
* @return array<string, int|null>
37+
* @return array<string, int|false|null> file path => error count (null for loader, false for deleted)
3538
*
3639
* @throws ErrorException
3740
*/
@@ -45,6 +48,7 @@ public function split(string $loaderFilePath): array
4548
}
4649

4750
$folder = dirname($realPath);
51+
$loaderFileName = $splFile->getFilename();
4852
$extension = $splFile->getExtension();
4953

5054
$handler = HandlerFactory::create($extension);
@@ -53,6 +57,7 @@ public function split(string $loaderFilePath): array
5357

5458
$outputInfo = [];
5559
$baselineFiles = [];
60+
$writtenFiles = [];
5661
$totalErrorCount = 0;
5762

5863
foreach ($groupedErrors as $identifier => $newErrors) {
@@ -67,6 +72,7 @@ public function split(string $loaderFilePath): array
6772

6873
$outputInfo[$filePath] = $errorsCount;
6974
$baselineFiles[] = $fileName;
75+
$writtenFiles[$filePath] = true;
7076

7177
$plural = $errorsCount === 1 ? '' : 's';
7278
$prefix = $this->includeCount ? "total $errorsCount error$plural" : null;
@@ -82,6 +88,13 @@ public function split(string $loaderFilePath): array
8288

8389
$outputInfo[$realPath] = null;
8490

91+
// Delete orphaned baseline files
92+
$deletedFiles = $this->deleteOrphanedFiles($folder, $extension, $loaderFileName, $writtenFiles);
93+
94+
foreach ($deletedFiles as $deletedFile) {
95+
$outputInfo[$deletedFile] = false;
96+
}
97+
8598
return $outputInfo;
8699
}
87100

@@ -220,4 +233,44 @@ private function sortErrors(
220233
return $result;
221234
}
222235

236+
/**
237+
* @param array<string, true> $writtenFiles
238+
* @return list<string>
239+
*/
240+
private function deleteOrphanedFiles(
241+
string $folder,
242+
string $extension,
243+
string $loaderFileName,
244+
array $writtenFiles
245+
): array
246+
{
247+
$deletedFiles = [];
248+
$existingFiles = glob($folder . '/*.' . $extension);
249+
250+
if ($existingFiles === false) {
251+
return [];
252+
}
253+
254+
foreach ($existingFiles as $existingFile) {
255+
$fileName = basename($existingFile);
256+
257+
// Skip the loader file
258+
if ($fileName === $loaderFileName) {
259+
continue;
260+
}
261+
262+
// Skip files that were written in this run
263+
if (isset($writtenFiles[$existingFile])) {
264+
continue;
265+
}
266+
267+
// Delete orphaned file
268+
if (unlink($existingFile)) {
269+
$deletedFiles[] = $existingFile;
270+
}
271+
}
272+
273+
return $deletedFiles;
274+
}
275+
223276
}

tests/SplitterTest.php

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,139 @@ public function testFirstRunCreatesFileSorted(): void
334334
self::assertStringContainsString('Error C', $result);
335335
}
336336

337+
public function testOrphanedFilesAreDeleted(): void
338+
{
339+
$folder = $this->prepareSampleFolder();
340+
$loaderPath = $folder . '/baselines/loader.neon';
341+
342+
// Create existing baseline files for two identifiers
343+
$existingBaseline1 = <<<'NEON'
344+
parameters:
345+
ignoreErrors:
346+
-
347+
message: '#^Error A$#'
348+
count: 1
349+
path: ../app/a.php
350+
351+
NEON;
352+
$existingBaseline2 = <<<'NEON'
353+
parameters:
354+
ignoreErrors:
355+
-
356+
message: '#^Error B$#'
357+
count: 1
358+
path: ../app/b.php
359+
360+
NEON;
361+
file_put_contents($folder . '/baselines/first.identifier.neon', $existingBaseline1);
362+
file_put_contents($folder . '/baselines/second.identifier.neon', $existingBaseline2);
363+
364+
// Now regenerate with only first.identifier (second.identifier should be deleted)
365+
$inputErrors = [
366+
'parameters' => [
367+
'ignoreErrors' => [
368+
['message' => '#^Error A$#', 'count' => 1, 'path' => '../app/a.php', 'identifier' => 'first.identifier'],
369+
],
370+
],
371+
];
372+
file_put_contents($loaderPath, Neon::encode($inputErrors));
373+
374+
$splitter = new BaselineSplitter("\t", true);
375+
$result = $splitter->split($loaderPath);
376+
377+
$firstIdentifierPath = $folder . '/baselines/first.identifier.neon';
378+
$secondIdentifierPath = $folder . '/baselines/second.identifier.neon';
379+
380+
// first.identifier.neon should still exist
381+
self::assertFileExists($firstIdentifierPath);
382+
self::assertArrayHasKey($firstIdentifierPath, $result);
383+
self::assertSame(1, $result[$firstIdentifierPath]);
384+
385+
// second.identifier.neon should be deleted
386+
self::assertFileDoesNotExist($secondIdentifierPath);
387+
self::assertArrayHasKey($secondIdentifierPath, $result);
388+
self::assertFalse($result[$secondIdentifierPath]);
389+
390+
// loader should still exist
391+
self::assertFileExists($loaderPath);
392+
self::assertArrayHasKey($loaderPath, $result);
393+
self::assertNull($result[$loaderPath]);
394+
}
395+
396+
public function testLoaderFileIsNotDeleted(): void
397+
{
398+
$folder = $this->prepareSampleFolder();
399+
$loaderPath = $folder . '/baselines/loader.neon';
400+
401+
// Create an empty baseline (no errors)
402+
$inputErrors = [
403+
'parameters' => [
404+
'ignoreErrors' => [],
405+
],
406+
];
407+
file_put_contents($loaderPath, Neon::encode($inputErrors));
408+
409+
// Create an orphaned file that happens to exist
410+
file_put_contents($folder . '/baselines/orphan.neon', 'some content');
411+
412+
$splitter = new BaselineSplitter("\t", true);
413+
$result = $splitter->split($loaderPath);
414+
415+
$orphanPath = $folder . '/baselines/orphan.neon';
416+
417+
// Loader should still exist
418+
self::assertFileExists($loaderPath);
419+
self::assertArrayHasKey($loaderPath, $result);
420+
self::assertNull($result[$loaderPath]);
421+
422+
// Orphaned file should be deleted
423+
self::assertFileDoesNotExist($orphanPath);
424+
self::assertArrayHasKey($orphanPath, $result);
425+
self::assertFalse($result[$orphanPath]);
426+
}
427+
428+
public function testOnlyFilesWithMatchingExtensionAreDeleted(): void
429+
{
430+
$folder = $this->prepareSampleFolder();
431+
$loaderPath = $folder . '/baselines/loader.neon';
432+
433+
// Create a .neon baseline file and a .php file
434+
$existingBaseline = <<<'NEON'
435+
parameters:
436+
ignoreErrors:
437+
-
438+
message: '#^Error A$#'
439+
count: 1
440+
path: ../app/a.php
441+
442+
NEON;
443+
file_put_contents($folder . '/baselines/old.identifier.neon', $existingBaseline);
444+
file_put_contents($folder . '/baselines/some.file.php', '<?php // some php file');
445+
446+
// Regenerate with empty baseline
447+
$inputErrors = [
448+
'parameters' => [
449+
'ignoreErrors' => [],
450+
],
451+
];
452+
file_put_contents($loaderPath, Neon::encode($inputErrors));
453+
454+
$splitter = new BaselineSplitter("\t", true);
455+
$result = $splitter->split($loaderPath);
456+
457+
$oldIdentifierPath = $folder . '/baselines/old.identifier.neon';
458+
$phpFilePath = $folder . '/baselines/some.file.php';
459+
460+
// .neon file should be deleted
461+
self::assertFileDoesNotExist($oldIdentifierPath);
462+
self::assertArrayHasKey($oldIdentifierPath, $result);
463+
self::assertFalse($result[$oldIdentifierPath]);
464+
465+
// .php file should NOT be deleted (wrong extension)
466+
self::assertFileExists($phpFilePath);
467+
self::assertArrayNotHasKey($phpFilePath, $result);
468+
}
469+
337470
/**
338471
* @return array{parameters: array{ignoreErrors: array{0: array{message?: string, rawMessage?: string, count: int, path: string, identifier?: string}}}}
339472
*/

0 commit comments

Comments
 (0)