engineer: add LogBuilderTruncateRule (closes #8)#22
Open
Goosterhof wants to merge 2 commits into
Open
Conversation
New sibling rule to LogRule covering Builder->truncate() calls where the
fluent chain's most recent table() / from() invocation targets a Log-named
table (string-literal first argument matching 'log' / 'logs',
case-insensitive). Closes ADR-0001 §Append-only's bluntest delete surface:
DB::table('logs')->truncate() and its connection-aware / instance-shape /
Eloquent\Builder variants.
Strategy (b) — sibling rule, not LogRule extension — per Commander
disposition 2026-05-13 (chain-walk machinery genuinely different from
LogRule's class-name substring match; each rule stays one-job /
one-test-file / one-fixture-folder). Both rules share the
`logRule.logModification` identifier so consumer phpstan.neon ignoreErrors
entries cover the whole append-only doctrine with one entry.
Receiver detection is type-based (Query\Builder OR Eloquent\Builder subtype
via ObjectType::isSuperTypeOf()) — mirrors EnforceAuditSnapshotOnRetryRule's
ConnectionInterface pattern. 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.
Out of scope: variable table names ($t = 'logs'; DB::table($t)->truncate())
— would require value-flow analysis. Acceptable miss; rely on reviewer +
consumer-side 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 staged LogRule static-call expansion — both ride the same release.
Pre-cascade audit across emmie, kendo, entreezuil, ublgenie, brick-inventory
is required before tagging and is DEFERRED to the release PR (out of scope
for this dispatch, per package convention).
Includes:
- src/Rules/LogBuilderTruncateRule.php — new rule
- tests/Rules/LogBuilderTruncateRuleTest.php — 7 tests (4 positive, 3 negative)
- tests/Fixtures/LogBuilderTruncateRule/ — 7 fixtures
- extension.neon — register rule alongside LogRule
- src/Rules/LogRule.php — class-level docblock forward-reference to sibling rule
- CHANGELOG.md — [Unreleased] Changed entry with versioning + pre-cascade audit note
- CLAUDE.md — Rules shipped table row
- README.md — Rules table row + LogRule false-positives note about shared identifier
Verification gates (all green):
- composer test: 50 tests, 86 assertions
- composer phpstan: level max, clean
- composer format:check: Pint clean
- composer coverage:check: 86.25% (≥83%)
- composer mutation:ci: MSI 81.15% (≥75%), Covered MSI 81.15% (≥75%)
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
New sibling rule
LogBuilderTruncateRulecoveringBuilder->truncate()calls where the fluent chain's most recenttable()/from()invocation targets a Log-named table (string-literal first argument matching'log'/'logs', case-insensitive). Closes ADR-0001 §Append-only's bluntest delete surface:DB::table('logs')->truncate()and its connection-aware / instance-shape / Eloquent\Builder variants.Detection contract
Fires when all three hold:
MethodCallname istruncate.Illuminate\Database\Query\BuilderorIlluminate\Database\Eloquent\Builder— type-based viaObjectType::isSuperTypeOf(), mirroringEnforceAuditSnapshotOnRetryRule'sConnectionInterfacepattern.table()orfrom()call has aScalar\String_first argument whose value substring-matches'log'/'logs'case-insensitively.Strategy: sibling rule (not LogRule extension)
Per Commander disposition 2026-05-13, strategy (b). Rationale: the chain-walk machinery is genuinely different from
LogRule's class-name substring match; each rule stays one-job, one-test-file, one-fixture-folder. Both rules share thelogRule.logModificationidentifier so consumerphpstan.neonignoreErrorsentries cover both rules with one entry — the unity contract.Fixtures (7 total)
Positive (4):
TruncatesLogsViaFacade.php—DB::table('audit_logs')->truncate()TruncatesLogsViaConnection.php—DB::connection('central')->table('logs')->truncate()TruncatesLogsViaInjectedDb.php—$this->db->table('logs')->where('id', 1)->truncate()(instance-injectedConnectionInterface, withwhere()hop to exercise the chain-walk advance branch)TruncatesLogsViaEloquentBuilder.php—AuditLog::query()->from('audit_logs')->truncate()(Eloquent\Builder branch)Negative (3):
TruncatesRegularTable.php—DB::table('users')->truncate()(non-Log table)TruncatesDynamicTable.php—$t = 'logs'; DB::table($t)->truncate()(variable table name — acceptable miss)TruncatesUnrelatedReceiver.php— custom service exposingtable()+truncate()methods (guards against false-positive on method-name alone — receiver-type gate short-circuits before chain walk)Out of scope
$t = 'logs'; DB::table($t)->truncate()) — would need value-flow analysis. Acceptable miss; rely on reviewer + consumer-sidephpstan.neonignoreErrors.->update(),->delete()) on Log-named tables — distinct shape, not in issue LogRule: cover Builder->truncate() on Log-named tables #8.Versioning
Per ADR-0021 §Versioning, this is a candidate Major bump (new errors in code that previously passed). Within 0.x this rolls into v0.3.0 alongside the staged
LogRulestatic-call expansion — both ride the same release. The pre-cascade audit is mandated by the CHANGELOG[Unreleased]entry and must complete before tagging.Verification gates (all green locally)
composer test: 50 tests, 86 assertions (7 new)composer phpstan: level max, cleancomposer format:check: Pint cleancomposer coverage:check: 86.25% (≥83% threshold)composer mutation:ci: MSI 81.15%, Covered MSI 81.15% (≥75% / 75% thresholds)Test plan
LogRuleandLogBuilderTruncateRuleboth firelogRule.logModificationon their respective fixtures (suppressing this identifier in a consumerphpstan.neonsilences both)from()recognition (judgment call beyond strict order text — see "Notes" below)Notes
table()andfrom()as table-name sources. The orders specifiedtable()only, but Eloquent\Builder chains naturally hop throughfrom()while Query\Builder chains hop throughtable()— same intent, different fluent vocabulary. The Eloquent\Builder positive fixture (AuditLog::query()->from('audit_logs')->truncate()) hits thefrom()path; without this extension the Eloquent\Builder branch of receiver detection would be structurally unreachable under typical Laravel call shapes (Model::query()->truncate()has notable()call in the chain).