Audience: autonomous coding agents (and the humans who supervise them). Goal: by the end of this document you can recognise when DBF applies, run the decision procedure correctly, and — crucially — go green for the right reason instead of silencing the tool.
Primary / canonical source: Defence Before Fix: Preventing Bug Classes with
Static Analysis
— ltscommerce.dev, by Joseph Edmonds. DBF is one team's engineering discipline
(Joseph Edmonds / Edmonds Commerce / the lts/php-qa-ci toolchain). This gist is
an LLM-facing restatement of that methodology — attribute it accordingly. Read
the original for the rationale and worked PHP examples; read this for the
agent-facing algorithm.
Defence Before Fix says: when you encounter a bug, do not just fix the bug. First build a permanent structural defence against the entire class of bug — a static-analysis or lint rule that fails the build on every present and future instance — and then fix the specific instance with a test that proves it on the real production path. Prevention precedes repair. The defence outlives the fix: the bug you just fixed can never silently come back, and neither can any sibling of it lurking in untested code.
The article puts it bluntly:
"A test catches one bug in one file. A lint rule catches every future instance of that bug pattern — including instances that already exist in untested code paths, and instances that have not been written yet."
"Static analysis is preventive medicine. Tests are diagnostic."
This is the load-bearing metaphor. Internalise it.
| The NET | The FILTER | |
|---|---|---|
| What it is | A static rule (PHPStan / ESLint / architecture rule / CS rule) | A failing-then-passing TDD test |
| What it catches | The whole class of problem, structurally | The specific instance, behaviourally |
| Scope | Every file, every commit, forever — including code not yet written | This one defect, on the real production path |
| Cost | Cheap, broad, permanent | Targeted, proves the fix is real |
| It is the ratchet | Quality only turns one way: once the rule exists the class cannot recur | Proves this case is genuinely resolved, not merely silenced |
Why you need both — "belt and braces":
- A net with no filter can be satisfied by gaming the structure — making the shape pass the rule without the behaviour actually being correct.
- A filter with no net fixes this one case but lets the same bug class reappear in the next file, or in an untested branch nobody is watching.
The net guarantees the class is caught forever. The filter guarantees each instance is truly fixed. Neither alone is sufficient.
When a static rule goes RED, the correct GREEN comes from making the code do its job — NEVER from deleting, renaming, suppressing, or scoping-down the flagged element to silence the rule.
A red rule is information: it is telling you a contract is broken or unwired. The honest response is to wire the contract to a real producer/consumer and prove it. Deleting the flagged symbol removes both the symptom and the half-built feature in one stroke — the rule goes quiet for entirely the wrong reason, and the broken/unfinished state is now baked in and invisible.
Deleting is correct only when the element is genuinely unwanted dead code with no intended producer — a deliberate scope decision, not a reflex to clear red.
Follow these steps in order. Do not skip step 1.
STEP 1 — DIAGNOSE THE REAL CAUSE (empirically, no guessing)
Read the flagged code and the rule's message.
Determine what contract the rule is actually asserting.
Do NOT theorise a cause and code around the symptom — verify it.
STEP 2 — CLASSIFY THE ISSUE TYPE
Ask the single dividing question:
"Is there runtime BEHAVIOUR I could write an assertion about
that would FAIL before the fix and PASS after?"
├── NO → it is a PURE coding-standards / style / structural issue.
│ The rule IS the fix. There is nothing to assert.
│ → Go to STEP 4 (net only). Do NOT invent a hollow test.
│
└── YES → it is a BEHAVIOURAL / CONTRACT / dead-contract defect.
→ Go to STEP 3 (full net + filter).
STEP 3 — WIRE THE CONTRACT + ADD THE FILTER (behavioural defects)
a. WIRE: connect the flagged element to a GENUINE producer/consumer on the
real production path. Make the contract live.
b. FILTER: write a failing test that exercises the PRODUCTION path
(not a fixture that hand-feeds the value). RED first, then make it GREEN.
c. If the member is nullable → test BOTH paths (see §6).
STEP 4 — ENSURE THE NET EXISTS (every defect)
Confirm a permanent static rule catches this CLASS going forward.
If the bug class is novel, AUTHOR the rule (PHPStan/ESLint/arch test) so it
fails on every future instance. The net is the durable half of the work.
STEP 5 — VERIFY GREEN IS HONEST
Re-run the full gate. Confirm:
• the net is RED on the unfixed pattern and GREEN after the real fix
• the filter FAILED before the fix and PASSES after
• you did NOT add any suppression, baseline entry, cast-to-silence,
scope narrowing, or deletion-to-quiet
Never reach for any of these to clear a red rule: @phpstan-ignore,
eslint-disable, # type: ignore, @SuppressWarnings, baseline entries, @var
forcing, cast-to-silence, deleting the flagged symbol, or narrowing the gate's
scope. Each one converts a real, visible defect into a hidden one — strictly worse
than the original red.
A static rule flags an optional member that no real producer ever supplies — a dead contract. A consumer reads it, but production never writes it.
final class Foo
{
// RED: $bar is consumed by a renderer, but NO production caller passes it.
public function __construct(public readonly ?string $bar = null) {}
}❌ WRONG — delete to clear the rule:
final class Foo
{
public function __construct() {} // rule goes quiet…
}
// …but the half-built feature is gone, the contract is silently dropped,
// and the rule is satisfied for the WRONG reason.✅ RIGHT — wire it to a genuine producer, then prove it on the production path:
// 1. WIRE: a real production caller now supplies $bar.
$foo = new Foo($report->renderableSummary()); // genuine producer
// 2. FILTER: a test drives the PRODUCTION path (not a hand-fed fixture).
public function test_summary_flows_from_report_to_foo(): void
{
$report = ReportFactory::withSummary('Q3 exposure');
$foo = $this->service->buildFoo($report); // the real path
self::assertSame('Q3 exposure', $foo->bar);
}Now the contract is live, the rule is green for the right reason, and the test fails if anyone unwires it later.
// ❌ COVERAGE THEATRE: the fixture hand-feeds $bar, so the "green" covers a
// path production never actually takes. Line/branch coverage lies.
$foo = new Foo('hand-fed value');
self::assertSame('hand-fed value', $foo->bar); // proves nothing realThe filter must drive the real producer, or it is theatre.
// ❌ silence the rule
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const result = compute(); // the unused var is a REAL signal — wire it or remove the dead branchtotal = compute() # type: ignore # ❌ hides a genuine type mismatch — fix the typeIn every case: the directive is the bodge. Fix the code so the rule passes unaided.
A nullable member introduces two behaviours — value-present and value-absent. A single populated-path test is exactly the coverage-theatre trap: it proves one branch and leaves the other unexercised, which is precisely where a dead contract hides.
final class Foo
{
public function __construct(public readonly ?string $bar = null) {}
}
// BOTH are required:
public function test_with_value(): void { self::assertSame('x', (new Foo('x'))->describe()); } // value-present
public function test_with_null(): void { self::assertSame('(none)', (new Foo(null))->describe()); } // value-absentPrefer non-nullable where null is not a valid state. If a value is always known, type it non-nullable — fewer paths, and no false "optional" that can rot into a dead contract. Reserve nullable for states that are legitimately absent.
The dividing line is simple: is there behaviour to assert?
| Issue type | NET (static rule) | FILTER (TDD) | Why |
|---|---|---|---|
| Pure coding-standards / style / formatting | yes — the rule is the fix | no — nothing to assert | No runtime behaviour; the rule both defines and enforces it. |
| Behaviour / procedure / contract defect | yes — catches the class | yes — reproduce & prove | Real behaviour exists; write a failing test, fix to green. |
| Dead-contract / coverage-theatre | yes — catches the class | yes — via the production path | Test must drive the real producer, not a fixture-fed value. |
| Nullable member | yes — catches the class | yes — both paths asserted | Two code paths exist; prove each. |
Rule of thumb: if you can write an assertion about behaviour that fails before the fix and passes after — TDD applies, use it. If the issue is purely structural/stylistic with no behaviour to assert, the static rule alone is the complete defence; do not pad it with a hollow test.
DBF compounds. A worked illustration (shape, not specifics): you bump the bundled analyser to a stricter version, or raise the level. This surfaces a batch of pre-existing latent errors the looser net never caught. The discipline:
- Fix every surfaced instance at root cause. Never suppress. No baseline
entries, no
@phpstan-ignore, no@varforcing, no cast-to-silence. - Each error is a real defect the stricter net just made visible — resolve the underlying type/logic issue so the code is genuinely correct.
- The result is a permanent gain: the net is stricter for all future commits, and the exposed backlog is gone rather than papered over.
Tightening the net is only worthwhile if every instance it catches is honestly fixed. The ratchet only turns one way.
DO
- ✅ When a rule goes RED, diagnose the real cause empirically before touching code.
- ✅ Wire the flagged contract to a genuine producer/consumer on the production path.
- ✅ Write the failing test first (RED), then make it green — for any behavioural defect.
- ✅ For nullable members, assert both the value-present and the null path.
- ✅ Author a permanent net for any novel bug class so it can't recur.
- ✅ Treat a tightened ratchet as a backlog to fix at root cause, permanently.
- ✅ Prefer non-nullable types where null is not a valid domain state.
DON'T
- ❌ Delete / rename the flagged element just to make the rule quiet.
- ❌ Add any suppression directive (
@phpstan-ignore,eslint-disable,type: ignore,@SuppressWarnings,nolint, baseline entries). - ❌ Narrow a gate's scope, split out a failing tool, or lower a ratchet floor to go green.
- ❌ Write a fixture-fed test that fakes the production path (coverage theatre).
- ❌ Guess a cause and code around the symptom, or leave a comment claiming a cause you didn't verify.
- ❌ Pad a pure-style fix with a hollow behavioural test that asserts nothing real.
- ❌ Quietly drop a deferred capability — disclose it and assert it absent with a real seam test.
A workaround that hides a broken gate is worse than the original failure: it rots silently and defeats the gate's entire purpose. When the honest fix is large, do the large fix (or plan it explicitly) — do not shrink the gate to fit the bodge.
- Net — a static-analysis / lint / architecture rule that catches an entire class of bug structurally, on every commit, permanently.
- Filter — a TDD test that reproduces a specific instance on the real production path and proves the fix is genuine.
- Bug class vs instance — the class is the repeatable pattern (e.g. "consumer reads a member no producer sets"); the instance is one concrete occurrence. The net targets the class; the filter targets the instance.
- Ratchet — the property that quality only moves one way: once a net exists or is tightened, the bug class cannot silently return on any future commit.
- Dead contract — an optional member/parameter that some consumer reads but no real production producer ever supplies. Ships "green" as coverage theatre.
- Coverage theatre — green line/branch coverage on a path production never actually takes, usually because a fixture hand-feeds a value production never sets.
- Seam — the explicit boundary where a deferred or pluggable capability connects; a seam test asserts the capability's presence-or-deliberate-absence rather than stubbing it as real.
- Suppression — any directive (
@phpstan-ignore,eslint-disable,type: ignore, baseline entry,@varforcing, cast-to-silence) that quiets a rule without fixing the underlying code. Forbidden as a route to green. - Belt and braces — the principle that net and filter are both required: redundant, complementary guarantees at the class and instance levels.
If a user says "do DBF on this bug" or "apply Defence Before Fix", perform the §4 procedure: diagnose the real cause → classify (behavioural vs pure-style) → author/confirm the static net for the bug class → wire the contract and add a failing-then-passing filter on the production path → verify green is honest (no suppression, no deletion-to-quiet, no scope narrowing). For nullable members, always test both paths. Never silence a rule to make it pass.
Key phrases that should trigger this discipline: defence before fix, net and filter, bug class, ratchet, dead contract, coverage theatre, wire the contract, seam test, fix the cause not the symptom.
DBF is the discipline of Joseph Edmonds and the
lts/php-qa-ci toolchain. Canonical article:
https://ltscommerce.dev/articles/defence-before-fix-static-analysis.html.