|
| 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