Skip to content

engineer: add LogBuilderTruncateRule (closes #8)#22

Open
Goosterhof wants to merge 2 commits into
mainfrom
engineer/log-builder-truncate-rule
Open

engineer: add LogBuilderTruncateRule (closes #8)#22
Goosterhof wants to merge 2 commits into
mainfrom
engineer/log-builder-truncate-rule

Conversation

@Goosterhof
Copy link
Copy Markdown
Contributor

Summary

New sibling rule LogBuilderTruncateRule 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.

Detection contract

Fires when all three hold:

  1. The MethodCall name is truncate.
  2. The receiver type is a (subtype of) Illuminate\Database\Query\Builder or Illuminate\Database\Eloquent\Builder — type-based via ObjectType::isSuperTypeOf(), mirroring EnforceAuditSnapshotOnRetryRule's ConnectionInterface pattern.
  3. Walking back through the chain, the most recent table() or from() call has a Scalar\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 the logRule.logModification identifier so consumer phpstan.neon ignoreErrors entries cover both rules with one entry — the unity contract.

Fixtures (7 total)

Positive (4):

  • TruncatesLogsViaFacade.phpDB::table('audit_logs')->truncate()
  • TruncatesLogsViaConnection.phpDB::connection('central')->table('logs')->truncate()
  • TruncatesLogsViaInjectedDb.php$this->db->table('logs')->where('id', 1)->truncate() (instance-injected ConnectionInterface, with where() hop to exercise the chain-walk advance branch)
  • TruncatesLogsViaEloquentBuilder.phpAuditLog::query()->from('audit_logs')->truncate() (Eloquent\Builder branch)

Negative (3):

  • TruncatesRegularTable.phpDB::table('users')->truncate() (non-Log table)
  • TruncatesDynamicTable.php$t = 'logs'; DB::table($t)->truncate() (variable table name — acceptable miss)
  • TruncatesUnrelatedReceiver.php — custom service exposing table() + truncate() methods (guards against false-positive on method-name alone — receiver-type gate short-circuits before chain walk)

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.
  • Other write methods on Builder (->update(), ->delete()) on Log-named tables — distinct shape, not in issue LogRule: cover Builder->truncate() on Log-named tables #8.
  • Pre-cascade audit across consumer territories (emmie, kendo, entreezuil, ublgenie, brick-inventory) — DEFERRED to the release PR per package convention.

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 LogRule static-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, clean
  • composer format:check: Pint clean
  • composer coverage:check: 86.25% (≥83% threshold)
  • composer mutation:ci: MSI 81.15%, Covered MSI 81.15% (≥75% / 75% thresholds)

Test plan

  • CI: PHPUnit (8.4 + 8.5 matrix legs)
  • CI: PHPStan self-analysis at level max
  • CI: Pint format check
  • CI: Coverage threshold gate (83%)
  • CI: Infection mutation gate (75% MSI / 75% Covered MSI)
  • Reviewer: confirm LogRule and LogBuilderTruncateRule both fire logRule.logModification on their respective fixtures (suppressing this identifier in a consumer phpstan.neon silences both)
  • Reviewer: confirm chain-walk's from() recognition (judgment call beyond strict order text — see "Notes" below)

Notes

  • Resolves issue LogRule: cover Builder->truncate() on Log-named tables #8.
  • The chain-walk recognises both table() and from() as table-name sources. The orders specified table() only, but Eloquent\Builder chains naturally hop through from() while Query\Builder chains hop through table() — same intent, different fluent vocabulary. The Eloquent\Builder positive fixture (AuditLog::query()->from('audit_logs')->truncate()) hits the from() path; without this extension the Eloquent\Builder branch of receiver detection would be structurally unreachable under typical Laravel call shapes (Model::query()->truncate() has no table() call in the chain).

Goosterhof and others added 2 commits May 13, 2026 09:53
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>
@Goosterhof Goosterhof mentioned this pull request May 13, 2026
10 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant