Skip to content

Commit 74c8acf

Browse files
committed
Add integrity tests for known problems and stubs cache validation
- Introduce `KnownProblemsIntegrityTest` to ensure known problem suppressions reference existing entities in stubs. - Add `StubsCacheIntegrityTest` to validate the stubs and reflection caches contain plausible data thresholds. - Update `phpunit.xml.dist` to include new tests in the `Structure` suite. - Enhance `JsonParsedDataStorage` to throw exceptions for invalid or corrupted cache files to prevent silent test failures.
1 parent ce0b207 commit 74c8acf

5 files changed

Lines changed: 492144 additions & 8 deletions

File tree

phpunit.xml.dist

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@
4242
<testsuite name="Structure">
4343
<file>tests/StubsStructureValidatorTest.php</file>
4444
<file>tests/PhpVersionsSyncTest.php</file>
45+
<file>tests/KnownProblemsIntegrityTest.php</file>
46+
<file>tests/StubsCacheIntegrityTest.php</file>
4547
</testsuite>
4648
<testsuite name="Unit">
4749
<directory>tests/Unit</directory>

tests/Framework/Storage/JsonParsedDataStorage.php

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,22 @@ public function save(): void
102102
}
103103
}
104104

105+
/**
106+
* Load entities from the cache file.
107+
*
108+
* A missing file yields no entities and is not an error: callers decide whether to
109+
* regenerate (see `Runner::isStubsCacheComplete()`), and `MultiFileJsonStorage` constructs a
110+
* storage per entity type whether or not every file has been written yet.
111+
*
112+
* A file that *exists* but cannot be read as JSON is a different matter and throws. Returning
113+
* no entities there is indistinguishable from a legitimately empty cache, and the
114+
* consequences are silent: a truncated `StubsClasses.json` yielded zero classes while the
115+
* other four type files loaded normally, so every class-level check iterated an empty list
116+
* and reported success. The suite stayed green while validating nothing. This happened —
117+
* commit 51b2a776 carried a 2.3 MB prefix of a 20.4 MB file.
118+
*
119+
* @throws \RuntimeException if the file exists but is empty, unreadable, or not valid JSON
120+
*/
105121
public function load(): void
106122
{
107123
if ($this->loaded) {
@@ -115,17 +131,18 @@ public function load(): void
115131
}
116132

117133
$jsonContent = file_get_contents($this->pathToJsonFile);
118-
if ($jsonContent === false || trim($jsonContent) === '') {
119-
$this->entities = [];
120-
$this->loaded = true;
121-
return;
134+
if ($jsonContent === false) {
135+
throw new \RuntimeException($this->corruptCacheMessage('could not be read'));
136+
}
137+
138+
if (trim($jsonContent) === '') {
139+
// An empty entity type serialises to "[]", never to an empty file.
140+
throw new \RuntimeException($this->corruptCacheMessage('is empty'));
122141
}
123142

124143
$data = json_decode($jsonContent, true);
125144
if (!is_array($data)) {
126-
$this->entities = [];
127-
$this->loaded = true;
128-
return;
145+
throw new \RuntimeException($this->corruptCacheMessage(sprintf('is not valid JSON (%s)', json_last_error_msg())));
129146
}
130147

131148
foreach ($data as $entityData) {
@@ -136,4 +153,21 @@ public function load(): void
136153

137154
$this->loaded = true;
138155
}
156+
157+
private function corruptCacheMessage(string $problem): string
158+
{
159+
$size = @filesize($this->pathToJsonFile);
160+
161+
return sprintf(
162+
"Cache file %s %s (%s bytes).\n"
163+
. "It exists, so nothing will regenerate it automatically, and treating it as empty "
164+
. "would let checks pass while validating nothing.\n"
165+
. "Regenerate with: docker compose -f docker-compose.yml run --rm test_runner php "
166+
. "tests/run-stubs-parser.php\n"
167+
. "(for a Reflection*.json file, use tests/run-all-reflection-parsers.sh instead)",
168+
$this->pathToJsonFile,
169+
$problem,
170+
$size === false ? 'unknown' : $size
171+
);
172+
}
139173
}
Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
<?php
2+
3+
namespace StubTests;
4+
5+
use PHPUnit\Framework\Attributes\DataProvider;
6+
use PHPUnit\Framework\TestCase;
7+
use StubTests\Framework\Model\PHPClass;
8+
use StubTests\Framework\Model\PHPClassLikeObject;
9+
use StubTests\Framework\Model\PHPInterface;
10+
use StubTests\Framework\Runner\PhpVersions;
11+
use StubTests\Framework\Runner\RunnerScope;
12+
use StubTests\Framework\Validator\KnownProblems\DefaultKnownProblemsProvider;
13+
use StubTests\Framework\Validator\KnownProblems\EntityType;
14+
use StubTests\Framework\Validator\KnownProblems\ProblemDefinition;
15+
16+
/**
17+
* Guards `DefaultKnownProblemsProvider` against becoming dead config.
18+
*
19+
* A known problem suppresses a check for a named entity. If that entity is later renamed or
20+
* removed from the stubs, the entry stops matching anything — but nothing fails, because a
21+
* suppression that never fires looks exactly like a suppression that was not needed. The entry
22+
* survives as documentation of a problem that no longer exists at an id that no longer exists,
23+
* and the next reader trusts it.
24+
*
25+
* A typo made when *writing* an entry is already caught: the failure it was meant to suppress
26+
* simply stays red. This test covers the other direction — an entry that was correct when
27+
* written and has since drifted.
28+
*
29+
* Scope, stated plainly: this asserts the entity **exists**. It does not assert the suppression
30+
* is still **needed** — an entry whose underlying divergence PHP has since fixed keeps passing
31+
* here. Detecting that means re-running each affected check with the suppression lifted, which
32+
* is a larger piece of work; see T-W4 in `.claude/reviews/REVIEW-2026-08-04-deprecated-since.md`.
33+
*/
34+
class KnownProblemsIntegrityTest extends TestCase
35+
{
36+
/**
37+
* @return array<string, array{ProblemDefinition, string}>
38+
*/
39+
public static function knownProblemEntityIdProvider(): array
40+
{
41+
$cases = [];
42+
43+
foreach ((new DefaultKnownProblemsProvider())->getProblems() as $problem) {
44+
// $entityIds, when present, replaces $entityId — which is then only a grouping label
45+
// and must not be resolved as an entity in its own right.
46+
foreach ($problem->entityIds ?: [$problem->entityId] as $entityId) {
47+
$cases[$problem->entityType->value . ' ' . $entityId] = [$problem, $entityId];
48+
}
49+
}
50+
51+
return $cases;
52+
}
53+
54+
#[DataProvider('knownProblemEntityIdProvider')]
55+
public function testKnownProblemEntityStillExistsInStubs(ProblemDefinition $problem, string $entityId): void
56+
{
57+
$exists = self::entityExistsInStubs($problem->entityType, $entityId);
58+
59+
// Some problems describe an entity reflection reports but the stubs deliberately omit —
60+
// \TRUE, \FALSE and \NULL are language keywords the runtime lists as constants. Those
61+
// must not be read as dead config, so reflection is consulted before failing.
62+
if ($exists === false && self::entityExistsInReflection($problem, $entityId)) {
63+
$exists = true;
64+
}
65+
66+
if ($exists === null) {
67+
// The model cannot answer for this entity kind. Failing would blame the entry for a
68+
// framework gap, so the gap is surfaced as a skip instead.
69+
self::markTestSkipped(sprintf(
70+
"Cannot resolve %s '%s': PHPInterface and PHPEnum do not model properties, and "
71+
. "StubInterfaceParser does not parse them. PHP 8.4 permits property declarations "
72+
. "in interfaces, so this is a modelling gap, not a stale entry.",
73+
$problem->entityType->value,
74+
$entityId
75+
));
76+
}
77+
78+
self::assertTrue(
79+
$exists,
80+
sprintf(
81+
"Known problem references %s '%s', which no longer exists in the stubs.\n"
82+
. "The suppression can never fire, so it is dead config. Either the entity was "
83+
. "renamed/removed and the entry should follow it, or the entry should be deleted.\n"
84+
. "Reason recorded on the entry: %s",
85+
$problem->entityType->value,
86+
$entityId,
87+
$problem->reason
88+
)
89+
);
90+
}
91+
92+
/**
93+
* Does the entity appear in the reflection data for any version the problem covers?
94+
*
95+
* Only the versions in the entry's own range are loaded: a problem scoped to 5.6 says
96+
* nothing about 8.6, and loading all 13 caches to answer would be wasted work.
97+
*/
98+
private static function entityExistsInReflection(ProblemDefinition $problem, string $entityId): bool
99+
{
100+
foreach (PhpVersions::cases() as $version) {
101+
if (!$problem->versionRange->includes($version->value)) {
102+
continue;
103+
}
104+
105+
$reflection = RunnerScope::get()->getReflection($version->value);
106+
107+
$found = match ($problem->entityType) {
108+
EntityType::FUNCTION => self::hasId($reflection->getFunctions(), $entityId),
109+
EntityType::GLOBAL_CONSTANT => self::hasId($reflection->getConstants(), $entityId),
110+
EntityType::CLASS_TYPE => $reflection->hasClass($entityId),
111+
EntityType::INTERFACE_TYPE => $reflection->hasInterface($entityId),
112+
EntityType::ENUM_TYPE => $reflection->hasEnum($entityId),
113+
// Members are only cross-checked at the class-like level here; a member entry
114+
// whose owner is missing from the stubs is reported against the stubs.
115+
default => false,
116+
};
117+
118+
if ($found) {
119+
return true;
120+
}
121+
}
122+
123+
return false;
124+
}
125+
126+
/**
127+
* @return bool|null True/false when the stubs can answer, null when the model cannot
128+
* represent the entity kind at all (see the PROPERTY branch).
129+
*/
130+
private static function entityExistsInStubs(EntityType $entityType, string $entityId): ?bool
131+
{
132+
$stubs = RunnerScope::get()->getStubs();
133+
134+
return match ($entityType) {
135+
EntityType::FUNCTION => self::hasId($stubs->getFunctions(), $entityId),
136+
EntityType::GLOBAL_CONSTANT => self::hasId($stubs->getConstants(), $entityId),
137+
EntityType::CLASS_TYPE => $stubs->hasClass($entityId),
138+
EntityType::INTERFACE_TYPE => $stubs->hasInterface($entityId),
139+
EntityType::ENUM_TYPE => $stubs->hasEnum($entityId),
140+
EntityType::METHOD => self::hasMember($entityId, 'method'),
141+
EntityType::PROPERTY => self::hasMember($entityId, 'property'),
142+
EntityType::CLASS_CONSTANT,
143+
EntityType::INTERFACE_CONSTANT,
144+
EntityType::ENUM_CONSTANT => self::hasMember($entityId, 'constant'),
145+
};
146+
}
147+
148+
/**
149+
* @param array<mixed> $entities
150+
*/
151+
private static function hasId(array $entities, string $entityId): bool
152+
{
153+
foreach ($entities as $entity) {
154+
if ($entity->getId() === $entityId) {
155+
return true;
156+
}
157+
}
158+
159+
return false;
160+
}
161+
162+
/**
163+
* Resolve `\Owner::member` against the class, interface and enum tables.
164+
*
165+
* Inheritance is followed. Validators resolve a member through the stub hierarchy and then
166+
* report it under the id they were asked about, so `\SplTempFileObject::fgetss` is a correct
167+
* entry id even though only `\SplFileObject` declares the method. Looking at the declaring
168+
* type alone would report every such entry as dead config.
169+
*/
170+
private static function hasMember(string $entityId, string $kind): ?bool
171+
{
172+
$separator = strpos($entityId, '::');
173+
if ($separator === false) {
174+
return false;
175+
}
176+
177+
$ownerId = substr($entityId, 0, $separator);
178+
$memberName = substr($entityId, $separator + 2);
179+
180+
$owner = self::findClassLike($ownerId);
181+
if ($owner === null) {
182+
return false;
183+
}
184+
185+
$sawNonClassOwner = false;
186+
187+
foreach (self::selfAndAncestors($owner) as $type) {
188+
$found = match ($kind) {
189+
// Method names are case-insensitive in PHP.
190+
'method' => self::hasNamed($type->getMethods(), $memberName, caseInsensitive: true),
191+
'constant' => self::hasNamed($type->getConstants(), $memberName),
192+
// Since PHP 8.4 an interface may declare properties too, but only PHPClass models
193+
// them and StubInterfaceParser never parses them. Reporting false for an
194+
// interface would blame the entry for a framework gap, so that case is tracked
195+
// and surfaced as "unknown" if nothing else matches.
196+
default => $type instanceof PHPClass
197+
? self::hasNamed($type->getProperties(), ltrim($memberName, '$'))
198+
: self::note($sawNonClassOwner),
199+
};
200+
201+
if ($found) {
202+
return true;
203+
}
204+
}
205+
206+
return $sawNonClassOwner ? null : false;
207+
}
208+
209+
/**
210+
* Flags that a property lookup hit a type the model cannot answer for, and reports "not
211+
* found" so the walk continues to the remaining ancestors.
212+
*/
213+
private static function note(bool &$sawNonClassOwner): bool
214+
{
215+
$sawNonClassOwner = true;
216+
217+
return false;
218+
}
219+
220+
/**
221+
* The type itself plus every ancestor reachable through `extends` and `implements`.
222+
*
223+
* @return list<PHPClassLikeObject>
224+
*/
225+
private static function selfAndAncestors(PHPClassLikeObject $type): array
226+
{
227+
$seen = [];
228+
$queue = [$type];
229+
230+
while ($queue !== []) {
231+
$current = array_shift($queue);
232+
$id = $current->getId() ?? spl_object_hash($current);
233+
if (isset($seen[$id])) {
234+
continue; // guards against a malformed cyclic hierarchy
235+
}
236+
$seen[$id] = $current;
237+
238+
if ($current instanceof PHPClass && $current->getParentClass() !== null) {
239+
$queue[] = $current->getParentClass();
240+
}
241+
if ($current instanceof PHPInterface) {
242+
foreach ($current->getParentInterfaces() as $parent) {
243+
$queue[] = $parent;
244+
}
245+
}
246+
foreach ($current->getImplementedInterfaces() as $interface) {
247+
// Implemented interfaces may be stored as names rather than resolved objects.
248+
$resolved = is_string($interface) ? self::findClassLike($interface) : $interface;
249+
if ($resolved !== null) {
250+
$queue[] = $resolved;
251+
}
252+
}
253+
}
254+
255+
return array_values($seen);
256+
}
257+
258+
private static function findClassLike(string $ownerId): ?PHPClassLikeObject
259+
{
260+
$stubs = RunnerScope::get()->getStubs();
261+
262+
foreach ([$stubs->getClasses(), $stubs->getInterfaces(), $stubs->getEnums()] as $table) {
263+
foreach ($table as $entity) {
264+
if ($entity->getId() === $ownerId) {
265+
return $entity;
266+
}
267+
}
268+
}
269+
270+
return null;
271+
}
272+
273+
/**
274+
* @param array<mixed> $members
275+
*/
276+
private static function hasNamed(array $members, string $name, bool $caseInsensitive = false): bool
277+
{
278+
foreach ($members as $member) {
279+
$matches = $caseInsensitive
280+
? strcasecmp($member->getName(), $name) === 0
281+
: $member->getName() === $name;
282+
if ($matches) {
283+
return true;
284+
}
285+
}
286+
287+
return false;
288+
}
289+
}

0 commit comments

Comments
 (0)