Skip to content

Commit 6772bae

Browse files
Goosterhofclaude
andcommitted
fix: narrow LogBuilderTruncateRule chain-walk to table() only
The initial implementation extended the chain-walk's table-setting vocabulary to recognise both table() and from() so the Eloquent\Builder positive fixture (AuditLog::query()->from('audit_logs')->truncate()) would fire. That divergence from the orders' strict table()-only scope relied on PHPStan resolving AuditLog::query()->from(...) as Eloquent\Builder consistently across environments; CI proved it does not (PHP 8.4 / 8.5 fresh vendor + PCOV resolves the chain differently than PHP 8.5.5 local vendor, and the test failed only in CI). Reverting to the orders' table()-only scope: - src/Rules/LogBuilderTruncateRule.php: TABLE_SETTING_METHODS array collapses to a single TABLE_SETTING_METHOD = 'table' constant; the chain-walk no longer accepts from() as a table-name source. The Eloquent\Builder receiver-type branch remains live for the rare-but-coherent $eloquentBuilder->table('logs')->truncate() shape. - tests/Fixtures/LogBuilderTruncateRule/TruncatesLogsViaEloquentBuilder.php: reshaped from a positive to an acceptable-miss negative fixture. The same AuditLog::query()->from('audit_logs')->truncate() chain documents that Eloquent's from() vocabulary is out of scope, in the same family as variable table names and Model-$table-driven tables. - tests/Rules/LogBuilderTruncateRuleTest.php: testFlagsTruncateLogsViaEloquentBuilder → testIgnoresTruncateLogsViaEloquentFrom, expects zero errors. - CHANGELOG.md / README.md: updated rule contract description to remove the from() claim and to enumerate the three acceptable misses (variable table names, Eloquent from() chains, Model-$table-property drive). Local gate results post-fix: - composer test: 50 tests, 86 assertions, all pass - composer phpstan: clean - composer format:check: pint clean - composer coverage:check: 86.50% (+0.25pp; threshold 83%) - composer mutation:ci: MSI 81.24% (+6.24pp over 75% floor) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d1010f7 commit 6772bae

5 files changed

Lines changed: 38 additions & 28 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
1919
- **Doctrine:** corrected publish-channel framing in `CLAUDE.md` (L11 and the Release process section) and the `release.yml` header comment. Public packagist.org has no OIDC Trusted Publishing option today — OIDC is a Private Packagist–only feature (`packagist/artifact-publish-github-action`, GA February 2026). The package's actual publish channel is the standard `https://packagist.org/api/github` push-event webhook (`dev-*` aliases on branch push, versioned releases on tag push via `release.yml`). Migration to Private Packagist would change ally-side Composer consumption (private repo URL + token in `composer.json`) and is a commercial decision; tracking continues on Issue #11. Closes Sapper M1 Finding #2 (doctrine drift on publish channel) and resolves Issue #11 audit. **Versioning:** none (doctrine alignment, no consumer-visible behaviour).
2020
- **Governance:** added `.github/CODEOWNERS` routing all changes to `@script-development/phpstan-warroom-admins`. A separate rule-authors team is intentionally not split out today — the admins team and the rule-design reviewer set are identical at the current shop size; revisit if the contributor base grows or rule-design review becomes a distinct concern from operational repo administration. Pairs with branch-protection update enabling `require_code_owner_reviews=true`. Closes Sapper M1 Finding #5 (no CODEOWNERS file). **Versioning:** none (governance change, no consumer-visible surface).
2121
- **`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.
22-
- **`LogBuilderTruncateRule` (BREAKING):** new sibling rule to `LogRule`, sharing the `logRule.logModification` identifier so consumer `phpstan.neon` `ignoreErrors` entries cover the whole append-only doctrine with one entry. Flags `Builder->truncate()` calls where the fluent chain's most recent `table()` / `from()` invocation targets a Log-named table (string-literal first argument containing `'log'` / `'logs'`, case-insensitive substring match). Covers `DB::table('logs')->truncate()`, `DB::connection('central')->table('logs')->truncate()`, `$this->db->table('logs')->truncate()` (instance-injected `ConnectionInterface`), and `Model::query()->from('logs')->truncate()` (Eloquent\Builder shape). Receiver detection is type-based (`Illuminate\Database\Query\Builder` OR `Illuminate\Database\Eloquent\Builder` subtype via `ObjectType::isSuperTypeOf()`) — mirrors `EnforceAuditSnapshotOnRetryRule`'s `ConnectionInterface` pattern. Doctrine: ADR-0001 §Append-only — `truncate()` is the bluntest delete and bypasses Eloquent events, observers, and audit triggers entirely. Out of scope: variable table names (`$t = 'logs'; DB::table($t)->truncate()`) — would need value-flow analysis, acceptable miss, rely on reviewer + consumer-side `phpstan.neon` `ignoreErrors`. Implementation note: chain-walk recognises both `table()` and `from()` as table-name sources because Eloquent\Builder chains naturally hop through `from()` while Query\Builder chains hop through `table()` — same intent, different fluent vocabulary; the rule's contract is "find the table name in the chain," not "match the exact method name." **Versioning: per ADR-0021 §Versioning, this is a Major bump (new errors in code that previously passed); within 0.x this rolls into v0.3.0 alongside the `LogRule` static-call expansion** — both ride the same release. **Pre-cascade audit required across emmie, kendo, entreezuil, ublgenie, brick-inventory before tagging** — same convention as v0.2.0 / the LogRule static-call expansion. Surface any `DB::table('<log-named>')->truncate()` / `Model::query()->from('<log-named>')->truncate()` calls; operational-log false positives route to consumer-side `phpstan.neon` `ignoreErrors`. Resolves issue #8.
22+
- **`LogBuilderTruncateRule` (BREAKING):** new sibling rule to `LogRule`, sharing the `logRule.logModification` identifier so consumer `phpstan.neon` `ignoreErrors` entries cover the whole append-only doctrine with one entry. Flags `Builder->truncate()` calls where the fluent chain's most recent `table()` invocation targets a Log-named table (string-literal first argument containing `'log'` / `'logs'`, case-insensitive substring match). Covers `DB::table('logs')->truncate()`, `DB::connection('central')->table('logs')->truncate()`, and `$this->db->table('logs')->truncate()` (instance-injected `ConnectionInterface`). Receiver detection is type-based (`Illuminate\Database\Query\Builder` OR `Illuminate\Database\Eloquent\Builder` subtype via `ObjectType::isSuperTypeOf()`) — mirrors `EnforceAuditSnapshotOnRetryRule`'s `ConnectionInterface` pattern. The Eloquent\Builder receiver branch covers the rare-but-coherent `$eloquentBuilder->table('logs')->truncate()` shape; Eloquent chains that set the table via the Model's `$table` property (`AuditLog::query()->truncate()`) or via Eloquent's `from()` vocabulary (`AuditLog::query()->from('logs')->truncate()`) are an acceptable miss in the same family as variable table names — the table name does not appear as a `table()`-call string-literal in the chain. Doctrine: ADR-0001 §Append-only — `truncate()` is the bluntest delete and bypasses Eloquent events, observers, and audit triggers entirely. Out of scope: variable table names (`$t = 'logs'; DB::table($t)->truncate()`), Eloquent `from('logs')` chains, and Model-property-driven tables — all would need value-flow or model-graph inspection; acceptable misses, rely on reviewer + consumer-side `phpstan.neon` `ignoreErrors`. **Versioning: per ADR-0021 §Versioning, this is a Major bump (new errors in code that previously passed); within 0.x this rolls into v0.3.0 alongside the `LogRule` static-call expansion** — both ride the same release. **Pre-cascade audit required across emmie, kendo, entreezuil, ublgenie, brick-inventory before tagging** — same convention as v0.2.0 / the LogRule static-call expansion. Surface any `DB::table('<log-named>')->truncate()` / `$db->table('<log-named>')->truncate()` calls; operational-log false positives route to consumer-side `phpstan.neon` `ignoreErrors`. Resolves issue #8.
2323
- **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.
2424
- **CI:** added line-coverage measurement and a threshold gate. `ci.yml` switches `coverage: none` → `coverage: pcov` on both 8.4 and 8.5 matrix legs (PCOV is line-coverage-only and faster than Xdebug — debugger features aren't needed). New composer scripts: `test:coverage` (runs PHPUnit with `--coverage-clover=build/logs/clover.xml --coverage-text`) and `coverage:check` (runs `bin/coverage-check.php`, a standalone clover parser — no extra runtime dependency added to a static-analysis package for a single CI gate). Two new CI steps replace the `Tests` step: **Tests with coverage** and **Coverage threshold gate**. Clover XML is uploaded as a per-leg artifact (`clover-php-${{ matrix.php }}`, 14-day retention) so reviewers can inspect uncovered lines without spelunking through workflow logs. **Initial threshold: 83%** — the measured baseline is 83.92% (240/286 lines across `src/`), set 0.92 percentage points lower to absorb trivial fluctuation on equivalent-but-renamed code. Class coverage (0/6) and method coverage (39%) are intentionally unmeasured by the gate v1; per the issue's deliberation, line coverage is the right v1 signal and branch/method coverage is a follow-up after the line gate is bedded in. The 16-percentage-point gap to 100% audits as defensive guard clauses on unexpected node shapes (the kind of branch the issue itself flagged as "genuinely hard to fixture" — `LogRule`'s static-call branch falls back when `$node->class` is `Expr` rather than `Name`); a follow-up issue will audit and ratchet the threshold upward to 90%+. Versioning: none (pure CI/test-infra, no consumer-visible behaviour). Resolves issue #9.
2525
- **CI:** added Infection mutation testing gate, layered on top of the line-coverage gate. New `infection/infection ^0.32.7` dev dependency, `infection.json5` config (`@default` mutator profile, `src/` source scope, fixtures stay out via PHPUnit's existing `<source>` block, `--testsuite=Rules`), and two new composer scripts: `mutation` (local, `--threads=max --show-mutations` for inspecting escaped mutants) and `mutation:ci` (CI: `--threads=4 --no-progress --logger-github --min-msi=75 --min-covered-msi=75` — GitHub annotations on escaped mutants surface inline in PR diffs). Two new CI steps after the coverage gate: **Mutation testing** and **Upload mutation report** (per-leg `infection-php-${{ matrix.php }}` artifact, 14-day retention). `composer config allow-plugins.infection/extension-installer true` was set to permit the framework-adapter installer plugin. **Initial thresholds: 75% MSI and 75% Covered Code MSI** — measured baseline is 78.5% MSI (241 killed / 307 mutants, 100% Mutation Code Coverage), set 3.5 percentage points lower to absorb mutator-shape fluctuation on equivalent code. Same shape as the line-coverage gate: lock in current state, audit gaps, ratchet upward. The 22% surviving-mutant population audits as a mix of (a) genuinely-equivalent mutants the issue itself anticipated — `mb_stripos` ↔ `stripos` on PSR-4 ASCII-only class names in `LogRule`, defensive guard inversions (`LogicalNot`/`IfNegation`) on early returns that filter the same nodes by either condition — and (b) genuinely-uncovered branch logic that warrants new fixtures. A follow-up issue will audit each survivor, kill where realistic, `@infection-ignore-for-mutator`-annotate where equivalent, and ratchet thresholds to the issue's target of 80% MSI / 90% Covered Code MSI. Versioning: none (pure CI/test-infra, no consumer-visible behaviour; `infection` is `require-dev` only). Resolves issue #10.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ includes:
4141
| `ForbidDatabaseManagerInActionsRule` | `forbidDatabaseManager.inAction` | Action constructors | Constructor parameter typed `DatabaseManager` is an error. Inject `ConnectionInterface` instead. |
4242
| `ForbidAbortHelperRule` | `forbidAbortHelper.abortUsed` | Function calls | `abort()`, `abort_if()`, `abort_unless()` are errors. Throw an explicit `HttpException` subclass instead. |
4343
| `LogRule` | `logRule.logModification` | `update()` / `delete()` calls | If the receiver type's class name contains `"Log"` or `"logs"` (case-insensitive), error. |
44-
| `LogBuilderTruncateRule` | `logRule.logModification` | `Builder->truncate()` calls | If the fluent chain's most recent `table()` / `from()` call targets a Log-named table (string-literal argument matching `"log"` / `"logs"`, case-insensitive), error. Sibling rule to `LogRule`; shares the `logRule.logModification` identifier so a single `ignoreErrors` entry covers both. Doctrine: ADR-0001 §Append-only. |
44+
| `LogBuilderTruncateRule` | `logRule.logModification` | `Builder->truncate()` calls | If the fluent chain's most recent `table()` call targets a Log-named table (string-literal argument matching `"log"` / `"logs"`, case-insensitive), error. Sibling rule to `LogRule`; shares the `logRule.logModification` identifier so a single `ignoreErrors` entry covers both. Eloquent `from()` chains and Model-`$table`-property-driven tables are acceptable misses. Doctrine: ADR-0001 §Append-only. |
4545
| `EnforceAuditSnapshotOnRetryRule` | `enforceAuditSnapshotOnRetry.firstStatementMustResetState` | `App\Actions\*` whose constructor injects an entity audit logger | The first statement inside `$connection->transaction(...)` must reset the model's in-memory state (`$model->refresh()`, fresh fetch, or fresh instantiation). Doctrine: ADR-0001 §Snapshot-on-Retry Safety. |
4646
| `EnforceResourceDataValidatorOptInRule` | `enforceResourceDataValidatorOptIn.missingValidatorCall` | Classes extending `App\Http\Resources\ResourceData` | If the class declares a non-empty `EAGER_LOAD_COUNT` / `EAGER_LOAD_SUM` constant but never calls `validateRelationsLoaded()` in any method, error. |
4747

src/Rules/LogBuilderTruncateRule.php

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
use PHPStan\Rules\RuleErrorBuilder;
1818
use PHPStan\Type\ObjectType;
1919

20-
use function in_array;
2120
use function mb_stripos;
2221

2322
/**
@@ -44,18 +43,23 @@
4443
* 2. The receiver type is a (subtype of) `Illuminate\Database\Query\Builder`
4544
* or `Illuminate\Database\Eloquent\Builder` — type-based via
4645
* `ObjectType::isSuperTypeOf()`, not property-name string matching.
47-
* 3. Walking back through the chain, the most recent `table()` or `from()`
48-
* call (whether `MethodCall` like `$db->table('x')` or `StaticCall` like
46+
* 3. Walking back through the chain, the most recent `table()` call (whether
47+
* `MethodCall` like `$db->table('x')` or `StaticCall` like
4948
* `DB::table('x')`) has a `Scalar\String_` first argument whose value
50-
* matches `'log'` / `'logs'` case-insensitively. Both `table()` and
51-
* `from()` are recognised because Eloquent\Builder chains naturally
52-
* hop through `from()` while Query\Builder chains hop through
53-
* `table()` — same intent, different fluent vocabulary.
49+
* matches `'log'` / `'logs'` case-insensitively.
5450
*
5551
* Variable table names (`$t = 'logs'; DB::table($t)->truncate()`) are out of
5652
* scope — would require value-flow analysis. Acceptable miss; rely on
5753
* reviewer + consumer-side `phpstan.neon` `ignoreErrors`.
5854
*
55+
* Eloquent\Builder chains that set the table via `from('logs')` rather than
56+
* `table('logs')` are also out of scope — `from()` is Eloquent's fluent
57+
* vocabulary and is not recognised by the chain-walk. Model-property-driven
58+
* tables (`$table = 'audit_logs'` on the Model class) are likewise an
59+
* acceptable miss because the table name does not appear in the call chain.
60+
* The Eloquent\Builder receiver-type branch remains live for the rare but
61+
* coherent shape `$eloquentBuilder->table('logs')->truncate()`.
62+
*
5963
* Substring matching is intentionally broad. False positives on tables named
6064
* `catalog`, `blog`, `terminology`, or domain tables that include `log` in
6165
* the name should be suppressed per-territory via `phpstan.neon`
@@ -71,7 +75,7 @@ final class LogBuilderTruncateRule implements Rule
7175

7276
private const array LOG_NEEDLES = ['log', 'logs'];
7377

74-
private const array TABLE_SETTING_METHODS = ['table', 'from'];
78+
private const string TABLE_SETTING_METHOD = 'table';
7579

7680
public function getNodeType(): string
7781
{
@@ -121,10 +125,10 @@ private function receiverIsBuilder(MethodCall $node, Scope $scope): bool
121125

122126
/**
123127
* Walk back through the fluent chain looking for the most recent
124-
* `table()` or `from()` call (`MethodCall` or `StaticCall`). Inspect its
125-
* first argument: fire on a Log-named `Scalar\String_`; otherwise
126-
* (variable, concat, function call) do not fire. If no table-setting
127-
* call is found in the chain, do not fire.
128+
* `table()` call (`MethodCall` or `StaticCall`). Inspect its first
129+
* argument: fire on a Log-named `Scalar\String_`; otherwise (variable,
130+
* concat, function call) do not fire. If no `table()` call is found in
131+
* the chain, do not fire.
128132
*/
129133
private function chainTargetsLogNamedTable(Expr $receiver): bool
130134
{
@@ -133,7 +137,7 @@ private function chainTargetsLogNamedTable(Expr $receiver): bool
133137
while ($current instanceof MethodCall || $current instanceof StaticCall) {
134138
if (
135139
$current->name instanceof Identifier
136-
&& in_array($current->name->toString(), self::TABLE_SETTING_METHODS, true)
140+
&& $current->name->toString() === self::TABLE_SETTING_METHOD
137141
) {
138142
return $this->firstArgIsLogNamedString($current);
139143
}

tests/Fixtures/LogBuilderTruncateRule/TruncatesLogsViaEloquentBuilder.php

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,20 @@
77
use Illuminate\Database\Eloquent\Model;
88

99
/**
10-
* Minimal Eloquent model. `AuditLog::query()` returns
11-
* `Illuminate\Database\Eloquent\Builder<AuditLog>`. The fixture exercises the
12-
* Eloquent\Builder branch of receiver-type detection. The chain hops through
13-
* `from('audit_logs')` (Eloquent\Builder's table-setting vocabulary, proxying
14-
* to Query\Builder->from()) — same intent as Query\Builder->table(), and
15-
* recognised by the rule's chain-walk.
10+
* Acceptable-miss negative fixture. `AuditLog::query()->from('audit_logs')->truncate()`
11+
* uses Eloquent's `from()` vocabulary to set the table rather than the
12+
* Query\Builder `table()` vocabulary. The rule's chain-walk recognises
13+
* `table()` only — `from()`-set tables are an acceptable miss in the same
14+
* family as variable table names. The receiver-type gate still passes
15+
* (Eloquent\Builder is a supported receiver), but the chain walk finds no
16+
* `table()` call and therefore does not fire.
17+
*
18+
* Model-property-driven tables (`$table = 'audit_logs'` on the Model itself,
19+
* with no `table()`/`from()` in the chain) are likewise an acceptable miss —
20+
* the table name never appears in the call chain.
21+
*
22+
* Rare-but-coherent shape `$eloquentBuilder->table('logs')->truncate()`
23+
* would still fire (Eloquent\Builder receiver + `table()` call in chain).
1624
*/
1725
final class AuditLog extends Model
1826
{

tests/Rules/LogBuilderTruncateRuleTest.php

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -52,16 +52,14 @@ public function testFlagsTruncateLogsViaInjectedDb(): void
5252
);
5353
}
5454

55-
public function testFlagsTruncateLogsViaEloquentBuilder(): void
55+
public function testIgnoresTruncateLogsViaEloquentFrom(): void
5656
{
57+
// Eloquent's `from()` is not recognised — acceptable miss in the same
58+
// family as variable table names. Receiver-type gate still passes
59+
// (Eloquent\Builder), chain walk finds no `table()` call.
5760
$this->analyse(
5861
[__DIR__ . '/../Fixtures/LogBuilderTruncateRule/TruncatesLogsViaEloquentBuilder.php'],
59-
[
60-
[
61-
'Logs should not be updated or deleted.',
62-
26,
63-
],
64-
],
62+
[],
6563
);
6664
}
6765

0 commit comments

Comments
 (0)