|
5 | 5 | namespace ScriptDevelopment\PhpstanWarroomRules\Rules; |
6 | 6 |
|
7 | 7 | use PhpParser\Node; |
| 8 | +use PhpParser\Node\Expr\CallLike; |
8 | 9 | use PhpParser\Node\Expr\MethodCall; |
| 10 | +use PhpParser\Node\Expr\StaticCall; |
9 | 11 | use PhpParser\Node\Identifier; |
| 12 | +use PhpParser\Node\Name; |
10 | 13 | use PHPStan\Analyser\Scope; |
| 14 | +use PHPStan\Rules\IdentifierRuleError; |
11 | 15 | use PHPStan\Rules\Rule; |
12 | 16 | use PHPStan\Rules\RuleErrorBuilder; |
13 | 17 |
|
14 | 18 | use function in_array; |
15 | 19 |
|
16 | 20 | /** |
17 | | - * Forbids update() / delete() / forceDelete() / forceDeleteQuietly() calls on |
18 | | - * classes whose name contains "Log" or "logs". |
| 21 | + * Forbids update / delete / forceDelete / forceDeleteQuietly instance calls and |
| 22 | + * destroy / forceDestroy static calls on classes whose name contains "Log" or |
| 23 | + * "logs". |
19 | 24 | * |
20 | 25 | * Doctrine source: ADR-0001 §Append-only — audit records have no UPDATE, no DELETE. |
21 | 26 | * |
|
25 | 30 | * shouldn't depend on the migration-time convention that audit-log models never |
26 | 31 | * adopt SoftDeletes. |
27 | 32 | * |
| 33 | + * `Model::destroy()` / `Model::forceDestroy()` static-call shapes are covered |
| 34 | + * for the same reason: on a non-soft-delete log model `Model::destroy([1])` |
| 35 | + * does purge, and `Model::forceDestroy([1])` always purges. Both shapes share |
| 36 | + * the `logRule.logModification` identifier so consumer suppressions cover the |
| 37 | + * whole rule with one entry. |
| 38 | + * |
28 | 39 | * Substring matching is intentionally broad. False positives on classes like |
29 | 40 | * "Catalog", "Blog", "Terminology", or domain models that include "log" in the |
30 | 41 | * name should be suppressed per-territory via phpstan.neon ignoreErrors, |
31 | 42 | * scoped to the offending path. |
32 | 43 | * |
33 | | - * Static-call shapes (`Model::destroy()`, `Model::forceDestroy()`, |
34 | | - * `DB::table('logs')->truncate()`) are not covered — `getNodeType()` returns |
35 | | - * `MethodCall::class`. Static-call coverage is a separate rule expansion. |
| 44 | + * `DB::table('logs')->truncate()` is intentionally not covered — the receiver |
| 45 | + * type is `Illuminate\Database\Query\Builder` (no Log-named class reference), |
| 46 | + * the table name lives in a string argument, and matching that requires a |
| 47 | + * shape-specific rule that inspects the call chain. Tracked separately. |
36 | 48 | * |
37 | | - * @implements Rule<MethodCall> |
| 49 | + * @implements Rule<CallLike> |
38 | 50 | */ |
39 | 51 | final class LogRule implements Rule |
40 | 52 | { |
41 | | - private const array FORBIDDEN_METHODS = ['delete', 'forceDelete', 'forceDeleteQuietly', 'update']; |
| 53 | + private const array FORBIDDEN_INSTANCE_METHODS = ['delete', 'forceDelete', 'forceDeleteQuietly', 'update']; |
| 54 | + |
| 55 | + private const array FORBIDDEN_STATIC_METHODS = ['destroy', 'forceDestroy']; |
42 | 56 |
|
43 | 57 | public function getNodeType(): string |
44 | 58 | { |
45 | | - return MethodCall::class; |
| 59 | + return CallLike::class; |
46 | 60 | } |
47 | 61 |
|
48 | 62 | public function processNode(Node $node, Scope $scope): array |
| 63 | + { |
| 64 | + if ($node instanceof MethodCall) { |
| 65 | + return $this->processMethodCall($node, $scope); |
| 66 | + } |
| 67 | + |
| 68 | + if ($node instanceof StaticCall) { |
| 69 | + return $this->processStaticCall($node, $scope); |
| 70 | + } |
| 71 | + |
| 72 | + return []; |
| 73 | + } |
| 74 | + |
| 75 | + /** |
| 76 | + * @return list<IdentifierRuleError> |
| 77 | + */ |
| 78 | + private function processMethodCall(MethodCall $node, Scope $scope): array |
49 | 79 | { |
50 | 80 | if ( |
51 | 81 | !$node->name instanceof Identifier |
52 | | - || !in_array($node->name->toString(), self::FORBIDDEN_METHODS, true) |
| 82 | + || !in_array($node->name->toString(), self::FORBIDDEN_INSTANCE_METHODS, true) |
53 | 83 | ) { |
54 | 84 | return []; |
55 | 85 | } |
56 | 86 |
|
57 | | - $calledOnType = $scope->getType($node->var); |
| 87 | + return $this->errorIfReceiverIsLog($scope->getType($node->var)->getReferencedClasses()); |
| 88 | + } |
58 | 89 |
|
59 | | - $referencedClasses = $calledOnType->getReferencedClasses(); |
| 90 | + /** |
| 91 | + * @return list<IdentifierRuleError> |
| 92 | + */ |
| 93 | + private function processStaticCall(StaticCall $node, Scope $scope): array |
| 94 | + { |
| 95 | + if ( |
| 96 | + !$node->name instanceof Identifier |
| 97 | + || !in_array($node->name->toString(), self::FORBIDDEN_STATIC_METHODS, true) |
| 98 | + ) { |
| 99 | + return []; |
| 100 | + } |
| 101 | + |
| 102 | + $referencedClasses = $node->class instanceof Name |
| 103 | + ? [$scope->resolveName($node->class)] |
| 104 | + : $scope->getType($node->class)->getReferencedClasses(); |
60 | 105 |
|
| 106 | + return $this->errorIfReceiverIsLog($referencedClasses); |
| 107 | + } |
| 108 | + |
| 109 | + /** |
| 110 | + * @param array<int, string> $referencedClasses |
| 111 | + * |
| 112 | + * @return list<IdentifierRuleError> |
| 113 | + */ |
| 114 | + private function errorIfReceiverIsLog(array $referencedClasses): array |
| 115 | + { |
61 | 116 | foreach ($referencedClasses as $referencedClass) { |
62 | 117 | if ( |
63 | 118 | mb_stripos($referencedClass, 'Log') !== false |
|
0 commit comments