Skip to content

Commit 3a59aa8

Browse files
sylfabreclaude
andcommitted
Add origin field to baseline entries for trait errors, report unmatched rules when origin is analysed
When an ignore rule has a specific path and an origin (the trait file where the error actually originates), PHPStan can now determine whether the origin file was part of the analysis. If both path and origin were analysed and the rule was still unmatched, it is now reported — fixing a gap where unmatched baseline entries were silently suppressed in onlyFiles mode even when PHPStan had full information. Baseline formatters (NEON and PHP) now emit an `origin` field for errors that come from trait files, so baseline entries carry the necessary context for this check. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 04a99c1 commit 3a59aa8

File tree

6 files changed

+145
-44
lines changed

6 files changed

+145
-44
lines changed

phpstan-baseline.neon

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,13 +159,13 @@ parameters:
159159
-
160160
rawMessage: 'Call to static method escape() of internal class Nette\DI\Helpers from outside its root namespace Nette.'
161161
identifier: staticMethod.internalClass
162-
count: 4
162+
count: 6
163163
path: src/Command/ErrorFormatter/BaselineNeonErrorFormatter.php
164164

165165
-
166166
rawMessage: 'Call to static method escape() of internal class Nette\DI\Helpers from outside its root namespace Nette.'
167167
identifier: staticMethod.internalClass
168-
count: 5
168+
count: 12
169169
path: src/Command/ErrorFormatter/BaselinePhpErrorFormatter.php
170170

171171
-

src/Analyser/Ignore/IgnoredErrorHelper.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,9 @@ public function initialize(): IgnoredErrorHelperResult
9797
if (isset($ignoreError['identifier'])) {
9898
$key = sprintf("%s\n%s", $key, $ignoreError['identifier']);
9999
}
100+
if (isset($ignoreError['origin'])) {
101+
$key = sprintf("%s\n%s", $key, $ignoreError['origin']);
102+
}
100103
if ($key === '') {
101104
throw new ShouldNotHappenException();
102105
}
@@ -115,6 +118,7 @@ public function initialize(): IgnoredErrorHelperResult
115118
'message' => $ignoreError['message'] ?? null,
116119
'rawMessage' => $ignoreError['rawMessage'] ?? null,
117120
'path' => $ignoreError['path'],
121+
'origin' => $ignoreError['origin'] ?? null,
118122
'identifier' => $ignoreError['identifier'] ?? null,
119123
'count' => ($uniquedExpandedIgnoreErrors[$key]['count'] ?? 1) + ($ignoreError['count'] ?? 1),
120124
'reportUnmatched' => $reportUnmatched,
@@ -144,6 +148,11 @@ public function initialize(): IgnoredErrorHelperResult
144148
$ignoreError['path'] = $normalizedPath;
145149
$ignoreErrorsByFile[$normalizedPath][] = $ignoreErrorEntry;
146150
$ignoreError['realPath'] = $normalizedPath;
151+
if (isset($ignoreError['origin']) && @is_file($ignoreError['origin'])) {
152+
$normalizedOrigin = $this->fileHelper->normalizePath($ignoreError['origin']);
153+
$ignoreError['origin'] = $normalizedOrigin;
154+
$ignoreError['realOrigin'] = $normalizedOrigin;
155+
}
147156
$expandedIgnoreErrors[$i] = $ignoreError;
148157
} else {
149158
$otherIgnoreErrors[] = $ignoreErrorEntry;

src/Analyser/Ignore/IgnoredErrorHelperResult.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,11 @@ public function process(
219219
continue;
220220
}
221221

222-
if ($onlyFiles) {
222+
if (isset($unmatchedIgnoredError['realOrigin'])) {
223+
if (!array_key_exists($unmatchedIgnoredError['realOrigin'], $analysedFilesKeys)) {
224+
continue;
225+
}
226+
} elseif ($onlyFiles) {
223227
continue;
224228
}
225229

src/Command/ErrorFormatter/BaselineNeonErrorFormatter.php

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -38,61 +38,76 @@ public function formatErrors(
3838
if (!$fileSpecificError->canBeIgnored()) {
3939
continue;
4040
}
41-
$fileErrors[$this->relativePathHelper->getRelativePath($fileSpecificError->getFilePath())][] = $fileSpecificError;
41+
$traitFilePath = $fileSpecificError->getTraitFilePath();
42+
$fileErrors[$this->relativePathHelper->getRelativePath($fileSpecificError->getFilePath())][] = [
43+
'error' => $fileSpecificError,
44+
'origin' => $traitFilePath !== null ? $this->relativePathHelper->getRelativePath($traitFilePath) : null,
45+
];
4246
}
4347
ksort($fileErrors, SORT_STRING);
4448

4549
$messageKey = $this->useRawMessage ? 'rawMessage' : 'message';
4650
$errorsToOutput = [];
47-
foreach ($fileErrors as $file => $errors) {
48-
$fileErrorsByMessage = [];
49-
foreach ($errors as $error) {
51+
foreach ($fileErrors as $file => $fileErrorEntries) {
52+
$fileErrorsByKey = [];
53+
foreach ($fileErrorEntries as ['error' => $error, 'origin' => $origin]) {
5054
$errorMessage = $error->getMessage();
5155
$identifier = $error->getIdentifier();
52-
if (!isset($fileErrorsByMessage[$errorMessage])) {
53-
$fileErrorsByMessage[$errorMessage] = [
54-
1,
55-
$identifier !== null ? [$identifier => 1] : [],
56+
$key = $errorMessage . "\0" . ($origin ?? '');
57+
if (!isset($fileErrorsByKey[$key])) {
58+
$fileErrorsByKey[$key] = [
59+
'message' => $errorMessage,
60+
'origin' => $origin,
61+
'count' => 1,
62+
'identifiers' => $identifier !== null ? [$identifier => 1] : [],
5663
];
5764
continue;
5865
}
5966

60-
$fileErrorsByMessage[$errorMessage][0]++;
67+
$fileErrorsByKey[$key]['count']++;
6168

6269
if ($identifier === null) {
6370
continue;
6471
}
6572

66-
if (!isset($fileErrorsByMessage[$errorMessage][1][$identifier])) {
67-
$fileErrorsByMessage[$errorMessage][1][$identifier] = 1;
73+
if (!isset($fileErrorsByKey[$key]['identifiers'][$identifier])) {
74+
$fileErrorsByKey[$key]['identifiers'][$identifier] = 1;
6875
continue;
6976
}
7077

71-
$fileErrorsByMessage[$errorMessage][1][$identifier]++;
78+
$fileErrorsByKey[$key]['identifiers'][$identifier]++;
7279
}
73-
ksort($fileErrorsByMessage, SORT_STRING);
80+
ksort($fileErrorsByKey, SORT_STRING);
7481

75-
foreach ($fileErrorsByMessage as $message => [$totalCount, $identifiers]) {
82+
foreach ($fileErrorsByKey as ['message' => $message, 'origin' => $origin, 'count' => $totalCount, 'identifiers' => $identifiers]) {
7683
if (!$this->useRawMessage) {
7784
$message = '#^' . preg_quote($message, '#') . '$#';
7885
}
7986

8087
ksort($identifiers, SORT_STRING);
8188
if (count($identifiers) > 0) {
8289
foreach ($identifiers as $identifier => $identifierCount) {
83-
$errorsToOutput[] = [
90+
$entry = [
8491
$messageKey => Helpers::escape($message),
8592
'identifier' => $identifier,
8693
'count' => $identifierCount,
8794
'path' => Helpers::escape($file),
8895
];
96+
if ($origin !== null) {
97+
$entry['origin'] = Helpers::escape($origin);
98+
}
99+
$errorsToOutput[] = $entry;
89100
}
90101
} else {
91-
$errorsToOutput[] = [
102+
$entry = [
92103
$messageKey => Helpers::escape($message),
93104
'count' => $totalCount,
94105
'path' => Helpers::escape($file),
95106
];
107+
if ($origin !== null) {
108+
$entry['origin'] = Helpers::escape($origin);
109+
}
110+
$errorsToOutput[] = $entry;
96111
}
97112
}
98113
}

src/Command/ErrorFormatter/BaselinePhpErrorFormatter.php

Lines changed: 55 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -39,43 +39,50 @@ public function formatErrors(
3939
if (!$fileSpecificError->canBeIgnored()) {
4040
continue;
4141
}
42-
$fileErrors['/' . $this->relativePathHelper->getRelativePath($fileSpecificError->getFilePath())][] = $fileSpecificError;
42+
$traitFilePath = $fileSpecificError->getTraitFilePath();
43+
$fileErrors['/' . $this->relativePathHelper->getRelativePath($fileSpecificError->getFilePath())][] = [
44+
'error' => $fileSpecificError,
45+
'origin' => $traitFilePath !== null ? '/' . $this->relativePathHelper->getRelativePath($traitFilePath) : null,
46+
];
4347
}
4448
ksort($fileErrors, SORT_STRING);
4549

4650
$php = '<?php declare(strict_types = 1);';
4751
$php .= "\n\n";
4852
$php .= '$ignoreErrors = [];';
4953
$php .= "\n";
50-
foreach ($fileErrors as $file => $errors) {
51-
$fileErrorsByMessage = [];
52-
foreach ($errors as $error) {
54+
foreach ($fileErrors as $file => $fileErrorEntries) {
55+
$fileErrorsByKey = [];
56+
foreach ($fileErrorEntries as ['error' => $error, 'origin' => $origin]) {
5357
$errorMessage = $error->getMessage();
5458
$identifier = $error->getIdentifier();
55-
if (!isset($fileErrorsByMessage[$errorMessage])) {
56-
$fileErrorsByMessage[$errorMessage] = [
57-
1,
58-
$identifier !== null ? [$identifier => 1] : [],
59+
$key = $errorMessage . "\0" . ($origin ?? '');
60+
if (!isset($fileErrorsByKey[$key])) {
61+
$fileErrorsByKey[$key] = [
62+
'message' => $errorMessage,
63+
'origin' => $origin,
64+
'count' => 1,
65+
'identifiers' => $identifier !== null ? [$identifier => 1] : [],
5966
];
6067
continue;
6168
}
6269

63-
$fileErrorsByMessage[$errorMessage][0]++;
70+
$fileErrorsByKey[$key]['count']++;
6471

6572
if ($identifier === null) {
6673
continue;
6774
}
6875

69-
if (!isset($fileErrorsByMessage[$errorMessage][1][$identifier])) {
70-
$fileErrorsByMessage[$errorMessage][1][$identifier] = 1;
76+
if (!isset($fileErrorsByKey[$key]['identifiers'][$identifier])) {
77+
$fileErrorsByKey[$key]['identifiers'][$identifier] = 1;
7178
continue;
7279
}
7380

74-
$fileErrorsByMessage[$errorMessage][1][$identifier]++;
81+
$fileErrorsByKey[$key]['identifiers'][$identifier]++;
7582
}
76-
ksort($fileErrorsByMessage, SORT_STRING);
83+
ksort($fileErrorsByKey, SORT_STRING);
7784

78-
foreach ($fileErrorsByMessage as $message => [$totalCount, $identifiers]) {
85+
foreach ($fileErrorsByKey as ['message' => $message, 'origin' => $origin, 'count' => $totalCount, 'identifiers' => $identifiers]) {
7986
if ($this->useRawMessage) {
8087
$messageKey = 'rawMessage';
8188
} else {
@@ -86,23 +93,46 @@ public function formatErrors(
8693
ksort($identifiers, SORT_STRING);
8794
if (count($identifiers) > 0) {
8895
foreach ($identifiers as $identifier => $identifierCount) {
96+
if ($origin !== null) {
97+
$php .= sprintf(
98+
"\$ignoreErrors[] = [\n\t%s => %s,\n\t'identifier' => %s,\n\t'count' => %s,\n\t'path' => __DIR__ . %s,\n\t'origin' => __DIR__ . %s,\n];\n",
99+
var_export($messageKey, true),
100+
var_export(Helpers::escape($message), true),
101+
var_export(Helpers::escape($identifier), true),
102+
var_export($identifierCount, true),
103+
var_export(Helpers::escape($file), true),
104+
var_export(Helpers::escape($origin), true),
105+
);
106+
} else {
107+
$php .= sprintf(
108+
"\$ignoreErrors[] = [\n\t%s => %s,\n\t'identifier' => %s,\n\t'count' => %s,\n\t'path' => __DIR__ . %s,\n];\n",
109+
var_export($messageKey, true),
110+
var_export(Helpers::escape($message), true),
111+
var_export(Helpers::escape($identifier), true),
112+
var_export($identifierCount, true),
113+
var_export(Helpers::escape($file), true),
114+
);
115+
}
116+
}
117+
} else {
118+
if ($origin !== null) {
119+
$php .= sprintf(
120+
"\$ignoreErrors[] = [\n\t%s => %s,\n\t'count' => %s,\n\t'path' => __DIR__ . %s,\n\t'origin' => __DIR__ . %s,\n];\n",
121+
var_export($messageKey, true),
122+
var_export(Helpers::escape($message), true),
123+
var_export($totalCount, true),
124+
var_export(Helpers::escape($file), true),
125+
var_export(Helpers::escape($origin), true),
126+
);
127+
} else {
89128
$php .= sprintf(
90-
"\$ignoreErrors[] = [\n\t%s => %s,\n\t'identifier' => %s,\n\t'count' => %s,\n\t'path' => __DIR__ . %s,\n];\n",
129+
"\$ignoreErrors[] = [\n\t%s => %s,\n\t'count' => %s,\n\t'path' => __DIR__ . %s,\n];\n",
91130
var_export($messageKey, true),
92131
var_export(Helpers::escape($message), true),
93-
var_export(Helpers::escape($identifier), true),
94-
var_export($identifierCount, true),
132+
var_export($totalCount, true),
95133
var_export(Helpers::escape($file), true),
96134
);
97135
}
98-
} else {
99-
$php .= sprintf(
100-
"\$ignoreErrors[] = [\n\t%s => %s,\n\t'count' => %s,\n\t'path' => __DIR__ . %s,\n];\n",
101-
var_export($messageKey, true),
102-
var_export(Helpers::escape($message), true),
103-
var_export($totalCount, true),
104-
var_export(Helpers::escape($file), true),
105-
);
106136
}
107137
}
108138
}

tests/PHPStan/Analyser/AnalyserTest.php

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,10 @@
2929
use PHPStan\Testing\PHPStanTestCase;
3030
use PHPStan\Type\FileTypeMapper;
3131
use PHPUnit\Framework\Attributes\DataProvider;
32+
use function array_filter;
3233
use function array_map;
3334
use function array_merge;
35+
use function array_values;
3436
use function assert;
3537
use function count;
3638
use function is_string;
@@ -609,6 +611,47 @@ public function testDoNotReportUnmatchedIgnoredErrorsFromPathWithCountIfPathWasN
609611
$this->assertNoErrors($result);
610612
}
611613

614+
public function testDoNotReportUnmatchedIgnoredErrorsFromPathWithoutOriginInPartialAnalysis(): void
615+
{
616+
$ignoreErrors = [
617+
[
618+
'message' => '#Unknown error#',
619+
'path' => __DIR__ . '/data/traits-ignore/Foo.php',
620+
],
621+
];
622+
$result = $this->runAnalyser($ignoreErrors, true, [
623+
__DIR__ . '/data/traits-ignore/Foo.php',
624+
], true);
625+
$this->assertNoErrors($result);
626+
}
627+
628+
public function testReturnUnmatchedIgnoredErrorFromPathWithOriginWhenOriginIsAnalysed(): void
629+
{
630+
$ignoreErrors = [
631+
[
632+
'message' => '#Unknown error#',
633+
'path' => __DIR__ . '/data/traits-ignore/Foo.php',
634+
'origin' => __DIR__ . '/data/traits-ignore/FooTrait.php',
635+
],
636+
];
637+
$result = $this->runAnalyser($ignoreErrors, true, [
638+
__DIR__ . '/data/traits-ignore/Foo.php',
639+
__DIR__ . '/data/traits-ignore/FooTrait.php',
640+
], true);
641+
// One result is the real fail() error (not suppressed by #Unknown error#),
642+
// the other is the unmatched ignore rule reported because origin was analysed.
643+
$this->assertCount(2, $result);
644+
$unmatchedErrors = array_values(array_filter(
645+
$result,
646+
static fn ($r) => $r instanceof Error && $r->getIdentifier() === 'ignore.unmatched',
647+
));
648+
$this->assertCount(1, $unmatchedErrors);
649+
$this->assertSame(
650+
'Ignored error pattern #Unknown error# in path ' . __DIR__ . '/data/traits-ignore/Foo.php was not matched in reported errors.',
651+
$unmatchedErrors[0]->getMessage(),
652+
);
653+
}
654+
612655
public function testIgnoreNextLine(): void
613656
{
614657
$result = $this->runAnalyser([], false, [

0 commit comments

Comments
 (0)