Skip to content

Commit cfc032c

Browse files
authored
Merge pull request #7 from script-development/feat/logrule-static-call-coverage
feat(LogRule): cover Model::destroy / Model::forceDestroy static calls
2 parents 9a2d8c5 + be9d86a commit cfc032c

8 files changed

Lines changed: 172 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
88

99
### Changed
1010

11+
- **`LogRule` (BREAKING):** extended to cover the static-call shapes `Model::destroy(...)` and `Model::forceDestroy(...)` on Log-named classes. `getNodeType()` broadened from `MethodCall::class` to `CallLike::class` and `processNode` branches on `MethodCall` vs `StaticCall`. Both shapes emit the same `logRule.logModification` identifier so consumer `phpstan.neon` `ignoreErrors` entries cover the whole rule with one identifier (the previous rule's compliance teeth depended on `delete`/`forceDelete` instance shapes; on a non-soft-delete log model `Model::destroy([1])` purges and `Model::forceDestroy([1])` always purges — both slipped through). `DB::table('logs')->truncate()` is intentionally still out of scope — Builder receiver type carries no Log-named class reference and the table name lives in a string argument; matching that needs a shape-specific call-chain rule. Tracked separately. Versioning: per ADR-0021 §Versioning, this is a Major bump (new errors in code that previously passed); within 0.x this ships as `v0.3.0`. **Pre-cascade audit required across emmie, kendo, entreezuil, ublgenie before tagging** — surface any `::destroy(`/`::forceDestroy(` calls on Log-named classes and route operational-log false positives to consumer-side `phpstan.neon` `ignoreErrors` (same convention used in v0.2.0 for `ublgenie/app/Actions/DeleteBranch.php`). Resolves issue #4.
1112
- **CI:** added PHP 8.5 to the `ci.yml` and `release.yml` test matrices alongside 8.4 (`['8.4']``['8.4', '8.5']`). PHP 8.5.0 was released 2025-11-20; the war-room dev environment already runs 8.5.5 locally, so PRs were getting ad-hoc 8.5 coverage during pre-push but no CI signal. Adding (rather than replacing) keeps 8.4 — the `composer.json` `^8.4` contractual minimum — covered. `shivammathur/setup-php@v2` supports 8.5 since GA. Resolves issue #5.
1213

1314
## [0.2.0] — 2026-05-04

src/Rules/LogRule.php

Lines changed: 66 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,22 @@
55
namespace ScriptDevelopment\PhpstanWarroomRules\Rules;
66

77
use PhpParser\Node;
8+
use PhpParser\Node\Expr\CallLike;
89
use PhpParser\Node\Expr\MethodCall;
10+
use PhpParser\Node\Expr\StaticCall;
911
use PhpParser\Node\Identifier;
12+
use PhpParser\Node\Name;
1013
use PHPStan\Analyser\Scope;
14+
use PHPStan\Rules\IdentifierRuleError;
1115
use PHPStan\Rules\Rule;
1216
use PHPStan\Rules\RuleErrorBuilder;
1317

1418
use function in_array;
1519

1620
/**
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".
1924
*
2025
* Doctrine source: ADR-0001 §Append-only — audit records have no UPDATE, no DELETE.
2126
*
@@ -25,39 +30,89 @@
2530
* shouldn't depend on the migration-time convention that audit-log models never
2631
* adopt SoftDeletes.
2732
*
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+
*
2839
* Substring matching is intentionally broad. False positives on classes like
2940
* "Catalog", "Blog", "Terminology", or domain models that include "log" in the
3041
* name should be suppressed per-territory via phpstan.neon ignoreErrors,
3142
* scoped to the offending path.
3243
*
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.
3648
*
37-
* @implements Rule<MethodCall>
49+
* @implements Rule<CallLike>
3850
*/
3951
final class LogRule implements Rule
4052
{
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'];
4256

4357
public function getNodeType(): string
4458
{
45-
return MethodCall::class;
59+
return CallLike::class;
4660
}
4761

4862
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
4979
{
5080
if (
5181
!$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)
5383
) {
5484
return [];
5585
}
5686

57-
$calledOnType = $scope->getType($node->var);
87+
return $this->errorIfReceiverIsLog($scope->getType($node->var)->getReferencedClasses());
88+
}
5889

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();
60105

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+
{
61116
foreach ($referencedClasses as $referencedClass) {
62117
if (
63118
mb_stripos($referencedClass, 'Log') !== false

tests/Fixtures/LogRule/AuditLog.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,20 @@ public function forceDeleteQuietly(): bool
2525
{
2626
return true;
2727
}
28+
29+
/**
30+
* @param array<int, int> $ids
31+
*/
32+
public static function destroy(array $ids): int
33+
{
34+
return 0;
35+
}
36+
37+
/**
38+
* @param array<int, int> $ids
39+
*/
40+
public static function forceDestroy(array $ids): int
41+
{
42+
return 0;
43+
}
2844
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
3+
declare(strict_types = 1);
4+
5+
use App\Models\AuditLog;
6+
7+
final class DestroysAuditLog
8+
{
9+
public function tamper(): void
10+
{
11+
AuditLog::destroy([1]);
12+
}
13+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
3+
declare(strict_types = 1);
4+
5+
use App\Models\RegularModel;
6+
7+
final class DestroysRegularModel
8+
{
9+
public function purge(): void
10+
{
11+
RegularModel::destroy([1]);
12+
}
13+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
3+
declare(strict_types = 1);
4+
5+
use App\Models\AuditLog;
6+
7+
final class ForceDestroysAuditLog
8+
{
9+
public function tamper(): void
10+
{
11+
AuditLog::forceDestroy([1]);
12+
}
13+
}

tests/Fixtures/LogRule/RegularModel.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,20 @@ public function forceDeleteQuietly(): bool
2525
{
2626
return true;
2727
}
28+
29+
/**
30+
* @param array<int, int> $ids
31+
*/
32+
public static function destroy(array $ids): int
33+
{
34+
return 0;
35+
}
36+
37+
/**
38+
* @param array<int, int> $ids
39+
*/
40+
public static function forceDestroy(array $ids): int
41+
{
42+
return 0;
43+
}
2844
}

tests/Rules/LogRuleTest.php

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,32 @@ public function testFlagsForceDeleteQuietlyOnLogClass(): void
6565
);
6666
}
6767

68+
public function testFlagsDestroyOnLogClass(): void
69+
{
70+
$this->analyse(
71+
[__DIR__ . '/../Fixtures/LogRule/DestroysAuditLog.php'],
72+
[
73+
[
74+
'Logs should not be updated or deleted.',
75+
11,
76+
],
77+
],
78+
);
79+
}
80+
81+
public function testFlagsForceDestroyOnLogClass(): void
82+
{
83+
$this->analyse(
84+
[__DIR__ . '/../Fixtures/LogRule/ForceDestroysAuditLog.php'],
85+
[
86+
[
87+
'Logs should not be updated or deleted.',
88+
11,
89+
],
90+
],
91+
);
92+
}
93+
6894
public function testIgnoresUpdateOnRegularClass(): void
6995
{
7096
$this->analyse(
@@ -81,6 +107,14 @@ public function testIgnoresForceDeleteOnRegularClass(): void
81107
);
82108
}
83109

110+
public function testIgnoresDestroyOnRegularClass(): void
111+
{
112+
$this->analyse(
113+
[__DIR__ . '/../Fixtures/LogRule/DestroysRegularModel.php'],
114+
[],
115+
);
116+
}
117+
84118
protected function getRule(): Rule
85119
{
86120
return new LogRule;

0 commit comments

Comments
 (0)