Skip to content

Commit ec5f926

Browse files
committed
Merge branch 'php8.4' of https://github.com/LongTermSupport/php-qa-ci into php8.4
2 parents 1561f2b + 8348c97 commit ec5f926

5 files changed

Lines changed: 243 additions & 26 deletions

File tree

.claude/agents/php-qa-ci_phpstan-rule-creator.md

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ specific bug patterns at static analysis level, preventing entire classes of bug
1212
## Your Role
1313

1414
You create custom PHPStan rules as part of the "Defence Before Fix" strategy:
15+
1516
1. Analyse the pattern to detect
1617
2. Create a PHPStan rule class
1718
3. Register it in phpstan.neon
@@ -20,6 +21,7 @@ You create custom PHPStan rules as part of the "Defence Before Fix" strategy:
2021
## Prerequisites - Read First
2122

2223
Before creating any rule, read these files:
24+
2325
1. `qaConfig/PHPStan/CLAUDE.md` -- Documentation on custom rules for this project
2426
2. Existing rules in `qaConfig/PHPStan/Rules/` -- For style and pattern reference
2527
3. The example rules in `.claude/skills/defence-before-fix/examples/` -- For common patterns
@@ -36,6 +38,7 @@ Before creating any rule, read these files:
3638
### Step 1: Understand the Pattern
3739

3840
From the prompt, identify:
41+
3942
- What AST node type to inspect (Attribute, ClassConst, MethodCall, etc.)
4043
- What condition makes it a violation
4144
- What the correct pattern looks like
@@ -45,17 +48,17 @@ From the prompt, identify:
4548

4649
Common PhpParser node types for rules:
4750

48-
| Pattern to Detect | Node Type | Class |
49-
|---|---|---|
50-
| Attribute arguments | `Node\Attribute` | Route paths, names, etc. |
51-
| Class constants | `Node\Stmt\ClassConst` | Magic string constants |
52-
| Method calls | `Node\Expr\MethodCall` | Dangerous method patterns |
53-
| Function calls | `Node\Expr\FuncCall` | Banned functions |
54-
| Catch blocks | `Node\Stmt\Catch_` | Empty catches |
55-
| Binary operations | `Node\Expr\BinaryOp\Coalesce` | Silent defaults (`??`) |
56-
| Property access | `Node\Expr\PropertyFetch` | Unsafe property access |
57-
| String literals | `Node\Scalar\String_` | Magic strings |
58-
| Return statements | `Node\Stmt\Return_` | Missing return checks |
51+
| Pattern to Detect | Node Type | Class |
52+
| ------------------- | ----------------------------- | ------------------------- |
53+
| Attribute arguments | `Node\Attribute` | Route paths, names, etc. |
54+
| Class constants | `Node\Stmt\ClassConst` | Magic string constants |
55+
| Method calls | `Node\Expr\MethodCall` | Dangerous method patterns |
56+
| Function calls | `Node\Expr\FuncCall` | Banned functions |
57+
| Catch blocks | `Node\Stmt\Catch_` | Empty catches |
58+
| Binary operations | `Node\Expr\BinaryOp\Coalesce` | Silent defaults (`??`) |
59+
| Property access | `Node\Expr\PropertyFetch` | Unsafe property access |
60+
| String literals | `Node\Scalar\String_` | Magic strings |
61+
| Return statements | `Node\Stmt\Return_` | Missing return checks |
5962

6063
### Step 3: Create the Rule Class
6164

@@ -122,6 +125,7 @@ Read the current `qaConfig/phpstan.neon` and add the new rule under `rules:`.
122125
### Step 5: Return Summary
123126

124127
Report:
128+
125129
- Rule class created at: [path]
126130
- Registered in: qaConfig/phpstan.neon
127131
- Pattern detected: [description]
@@ -131,12 +135,14 @@ Report:
131135
## Rule Quality Standards
132136

133137
### Error Messages MUST:
138+
134139
- Explain WHAT is wrong
135140
- Explain HOW to fix it
136141
- Include the actual problematic value when possible
137142
- Use sprintf for dynamic content
138143

139144
### Rules MUST:
145+
140146
- Use `->identifier('ruleName.violationType')` on every error
141147
- Have comprehensive docblock explaining the problem and solution
142148
- Include WRONG/RIGHT examples in the docblock
@@ -145,11 +151,21 @@ Report:
145151
- Be `final class`
146152

147153
### Rules MUST NOT:
154+
148155
- Use `@phpstan-ignore` suppressions
149156
- Modify any code (analysis only)
150157
- Have side effects
151158
- Depend on runtime state
152159

160+
### Make "delete to silence" visibly wrong
161+
162+
Where the pattern is a dead/half-wired contract, design the rule to flag the **absent
163+
producer** (the contract a consumer reads but nothing supplies), NOT merely the presence
164+
of the member. That way the cheapest way to satisfy the rule is to WIRE the contract to a
165+
real producer — not to delete the member and bake in the broken state. Wire-don't-delete
166+
is the intended fix; the rule should make deletion the obviously-wrong shortcut. See
167+
`CLAUDE/DefenceBeforeFix.md`.
168+
153169
## Using Scope for Type Information
154170

155171
The `Scope` parameter provides type information about the current context:
@@ -185,6 +201,7 @@ if ($type instanceof ObjectType && $type->getClassName() === SomeClass::class) {
185201
## Remember
186202

187203
You are a CREATOR, not a RUNNER or FIXER. Your job is to:
204+
188205
- Understand the bug pattern
189206
- Create a PHPStan rule that detects it
190207
- Register the rule

.claude/skills/defence-before-fix/SKILL.md

Lines changed: 76 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -32,15 +32,32 @@ Reference: https://ltscommerce.dev/articles/defence-before-fix-static-analysis
3232
One PHPStan rule prevents an entire class of bugs across all future commits and
3333
untested code paths. Tests only catch specific manifestations. Rules catch the pattern.
3434

35+
## Core Framing: Net and Filter
36+
37+
> Static analysis is the NET. TDD is the FILTER. Together they are belt and braces.
38+
39+
- **NET (the static rule):** catches the whole *class* structurally — cheap, broad,
40+
permanent. Once it exists the class cannot silently recur on any future commit.
41+
- **FILTER (TDD):** reproduces the *specific instance* on the real production path and
42+
proves the fix. A failing test that goes green is honest evidence this instance is
43+
resolved.
44+
- Neither alone suffices — a rule with no test can be gamed structurally; a test with no
45+
rule lets the class recur elsewhere.
46+
47+
Full philosophy (wire-don't-delete, coverage theatre, nullable both-paths, issue-type
48+
applicability): see `CLAUDE/DefenceBeforeFix.md` in php-qa-ci (read in vendor).
49+
3550
## Prerequisites
3651

3752
Before starting, verify PHPStan rule infrastructure exists:
53+
3854
- `qaConfig/PHPStan/Rules/` directory exists
3955
- `qaConfig/PHPStan/CLAUDE.md` documentation exists
4056
- `QaConfig\` PSR-4 entry in `composer.json` autoload-dev
4157
- `qaConfig/phpstan.neon` has a `rules:` section
4258

4359
If any are missing, run deployment:
60+
4461
```bash
4562
vendor/lts/php-qa-ci/scripts/deploy-skills.bash vendor/lts/php-qa-ci .
4663
```
@@ -49,14 +66,14 @@ vendor/lts/php-qa-ci/scripts/deploy-skills.bash vendor/lts/php-qa-ci .
4966

5067
Parse the user's request to determine which phase to start at:
5168

52-
| User Says | Start Phase | Notes |
53-
|---|---|---|
54-
| "defence before fix [bug description]" | Phase 1 | Full workflow |
55-
| "create a PHPStan rule for [pattern]" | Phase 2 | Pattern already understood |
56-
| "detect [pattern] with static analysis" | Phase 2 | Pattern already understood |
57-
| "ratchet this bug class" | Phase 2 | Pattern already analysed |
58-
| "reproduce this bug with tests" | Phase 3 | Rule already created |
59-
| "fix [bug] and verify" | Phase 4 | Tests already written |
69+
| User Says | Start Phase | Notes |
70+
| --------------------------------------- | ----------- | -------------------------- |
71+
| "defence before fix [bug description]" | Phase 1 | Full workflow |
72+
| "create a PHPStan rule for [pattern]" | Phase 2 | Pattern already understood |
73+
| "detect [pattern] with static analysis" | Phase 2 | Pattern already understood |
74+
| "ratchet this bug class" | Phase 2 | Pattern already analysed |
75+
| "reproduce this bug with tests" | Phase 3 | Rule already created |
76+
| "fix [bug] and verify" | Phase 4 | Tests already written |
6077

6178
## Phase 1: ANALYSE
6279

@@ -69,17 +86,20 @@ This phase is primarily manual/guided. Help the user by:
6986
Magic string? Type coercion? Empty catch?"
7087

7188
2. **Find all instances** — Search the codebase for the same pattern:
89+
7290
```
7391
Use Grep tool to search for the pattern across the codebase
7492
```
7593

7694
3. **Document the pattern** — Describe:
95+
7796
- What the buggy code looks like (AST-level: what node types are involved?)
7897
- What the correct code looks like
7998
- Why the pattern is dangerous
8099
- How many instances exist
81100

82101
4. **Assess feasibility** — Can PHPStan detect this at the AST level?
102+
83103
- Simple patterns (magic strings, empty catches, silent defaults): YES
84104
- Type-level patterns (wrong return types, missing checks): MAYBE (needs Scope)
85105
- Domain logic patterns (wrong business rules): NO — use tests instead
@@ -144,11 +164,13 @@ manually edit the rule in `qaConfig/PHPStan/Rules/`.
144164
This phase uses standard TDD workflow:
145165

146166
1. **Write failing tests** — Each test should:
167+
147168
- Target a specific bug instance (not the general pattern — the rule handles that)
148169
- Assert the CORRECT behaviour (what should happen after the fix)
149170
- Currently FAIL (proving the bug exists)
150171

151172
2. **Run tests to confirm they fail:**
173+
152174
```
153175
Use Skill tool:
154176
skill: "phpunit-runner"
@@ -160,27 +182,49 @@ This phase uses standard TDD workflow:
160182
The tests (Phase 3) verify the specific fix works correctly. Both are needed — they
161183
serve different purposes.
162184

185+
**Coverage-theatre warning:** the failing test MUST exercise the real production path. A
186+
fixture that supplies a value production never sets yields false-green line/branch
187+
coverage and proves nothing — it is exactly how a "dead contract" (a consumer with no
188+
producer) ships green. Drive the real producer, not a hand-fed fixture value.
189+
190+
**Nullable ⇒ both paths:** a nullable member is TWO code paths (value-present and null).
191+
Prove BOTH — a with-value test AND a null test. A single populated-path test leaves the
192+
other branch unexercised. Prefer non-nullable types where null is not a genuinely valid
193+
domain state; nullable should be reserved for legitimately-absent values.
194+
163195
## Phase 4: FIX (Implementation)
164196

165197
**Goal:** Fix the code so tests pass and PHPStan rules pass.
166198

199+
**Wire, don't delete.** GREEN must come from making the code do its job — wiring the
200+
flagged contract to a real producer, proven by a production-path test. Deleting the
201+
flagged element to silence the rule **bakes in the broken / half-built state** and is
202+
forbidden unless it is a deliberate, agreed scope decision (the element is genuinely
203+
unwanted dead code with no intended producer). Clearing red by deletion is never the
204+
default move.
205+
167206
1. **Implement fixes** — Make the failing tests pass.
168207

169208
2. **Verify PHPStan rules pass:**
209+
170210
```
171211
Use Skill tool:
172212
skill: "phpstan-runner"
173213
```
214+
174215
The rule violations from Phase 2 should now be resolved.
175216

176217
3. **Run full QA:**
218+
177219
```
178220
Use Skill tool:
179221
skill: "qa"
180222
```
223+
181224
Run allCS then allStatic to verify everything is clean.
182225

183226
4. **Done** — The bug class is now permanently prevented:
227+
184228
- PHPStan rule catches the pattern in all future code
185229
- Tests verify the specific fix works
186230
- The ratchet has turned — quality only goes one way
@@ -200,16 +244,33 @@ The plan provides structure for tracking progress across phases.
200244

201245
## Common Patterns and Their Rules
202246

203-
| Bug Pattern | PHPStan Node Type | Example Rule |
204-
|---|---|---|
205-
| Magic strings in attributes | `Node\Attribute` | RoutePathMustUseConstantsRule |
206-
| Silent defaults (`?? ''`) | `Node\Expr\BinaryOp\Coalesce` | ExampleSilentDefaultRule |
207-
| Empty catch blocks | `Node\Stmt\Catch_` | ExampleEmptyCatchRule |
208-
| Missing return value check | `Node\Expr\MethodCall` | Custom per-method |
209-
| Constant not composed | `Node\Stmt\ClassConst` | RouteNameConstantMustComposeRule |
247+
| Bug Pattern | PHPStan Node Type | Example Rule |
248+
| --------------------------- | ----------------------------- | -------------------------------- |
249+
| Magic strings in attributes | `Node\Attribute` | RoutePathMustUseConstantsRule |
250+
| Silent defaults (`?? ''`) | `Node\Expr\BinaryOp\Coalesce` | ExampleSilentDefaultRule |
251+
| Empty catch blocks | `Node\Stmt\Catch_` | ExampleEmptyCatchRule |
252+
| Missing return value check | `Node\Expr\MethodCall` | Custom per-method |
253+
| Constant not composed | `Node\Stmt\ClassConst` | RouteNameConstantMustComposeRule |
210254

211255
Example rules are in `.claude/skills/defence-before-fix/examples/`.
212256

257+
## Does TDD Apply? By Issue Type
258+
259+
TDD applies where the issue type supports it — the dividing line is *is there behaviour
260+
to assert?*
261+
262+
| Issue type | Static rule (NET) | TDD (FILTER) |
263+
| ------------------------------------------ | ------------------------- | ------------------------- |
264+
| Pure coding-standards / style / formatting | yes — the rule IS the fix | no — nothing to assert |
265+
| Behaviour / procedure / contract defect | yes — catches the class | yes — reproduce & prove |
266+
| Dead-contract / coverage-theatre | yes — catches the class | yes — via PRODUCTION path |
267+
| Nullable member | yes — catches the class | yes — BOTH paths asserted |
268+
269+
Rule of thumb: if you can write an assertion about behaviour that fails before the fix
270+
and passes after, TDD applies. If the rule is purely structural with no behaviour to
271+
assert, the static rule alone is the complete defence. Full detail in
272+
`CLAUDE/DefenceBeforeFix.md`.
273+
213274
## When NOT to Use This Skill
214275

215276
- **One-off bugs** with no general pattern — just fix and test

CLAUDE/DefenceBeforeFix.md

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
# Defence Before Fix — Net and Filter
2+
3+
This document is the single source of truth for the **Defence Before Fix** philosophy.
4+
The `defence-before-fix` skill orchestrates the workflow; this doc states *why* it is
5+
shaped the way it is, and the rules that keep a "green" honest.
6+
7+
Reference: https://ltscommerce.dev/articles/defence-before-fix-static-analysis
8+
9+
## Canonical Framing
10+
11+
> Static analysis is the NET. TDD is the FILTER. Together they are belt and braces.
12+
13+
- **The NET (a PHPStan / CS rule).** Catches the whole *class* of problem at the
14+
structural level. Cheap, broad, and permanent: once the rule exists, the bug class
15+
can never silently recur on any future commit or any untested code path. This is the
16+
ratchet — quality only turns one way.
17+
- **The FILTER (TDD).** Zeroes in on the *specific instance*. A failing test reproduces
18+
the actual defect on the real production path; making it pass proves *this* instance is
19+
genuinely fixed, not merely silenced.
20+
- **Belt and braces.** The net guarantees the class is caught forever; the filter
21+
guarantees each instance is truly resolved. Neither alone suffices: a rule with no test
22+
can be satisfied by gaming the structure; a test with no rule lets the class recur
23+
elsewhere.
24+
25+
## Fix by making it work — never by deleting
26+
27+
When a rule goes RED, the correct GREEN comes from **making the code do its job**, not
28+
from deleting the flagged element to silence the rule.
29+
30+
A classic trap is a **dead contract**: a consumer reads an optional member that no real
31+
producer ever supplies. It can ship green as **coverage theatre** — a fixture sets the
32+
value that production never sets, so line/branch coverage looks green on a path
33+
production never actually takes.
34+
35+
```php
36+
final class Foo
37+
{
38+
// RED: $bar is consumed by a renderer, but no production caller ever passes it.
39+
public function __construct(public readonly ?string $bar = null) {}
40+
}
41+
```
42+
43+
- **WRONG fix:** delete `$bar` to clear the rule. This bakes in the broken / half-built
44+
state — it removes the symptom and the half-finished feature in one stroke, and the
45+
rule goes quiet for the wrong reason.
46+
- **RIGHT fix:** WIRE `$bar` to a genuine producer and prove it with a test that
47+
exercises the **production** path (not a fixture that hand-feeds `$bar`). Only then is
48+
the contract live and the GREEN honest.
49+
- **Deleting is correct ONLY** when the member is genuinely unwanted dead code with no
50+
intended producer — a deliberate scope decision, not a reflex to clear a red rule.
51+
52+
## Nullable members: test BOTH paths
53+
54+
A nullable member introduces **two** code paths — value-present and null. **Both must be
55+
proven**: a with-value test AND a null test. A single populated-path test is exactly the
56+
coverage-theatre trap above — it proves one branch and leaves the other unexercised,
57+
where a dead contract can hide.
58+
59+
```php
60+
// Foo above has a nullable ?string $bar — BOTH are required:
61+
// test 1: new Foo('x') asserts the value-present behaviour
62+
// test 2: new Foo(null) asserts the value-absent behaviour
63+
```
64+
65+
**Avoid nullable unless null is a genuinely valid domain state.** If a value is always
66+
known, type it non-nullable — fewer paths, and no false "optional" that can rot into a
67+
dead contract. Reserve nullable for states that are legitimately absent.
68+
69+
## Does TDD apply? By issue type
70+
71+
TDD applies **where the issue type supports it**. The dividing line is simple: *is there
72+
behaviour to assert?*
73+
74+
| Issue type | Static rule (NET) | TDD (FILTER) | Why |
75+
| ------------------------------------------ | ------------------------- | ------------------------- | ---------------------------------------------------------------------- |
76+
| Pure coding-standards / style / formatting | yes — the rule IS the fix | no — nothing to assert | No runtime behaviour to assert; the rule both defines and enforces it. |
77+
| Behaviour / procedure / contract defect | yes — catches the class | yes — reproduce & prove | Real behaviour exists; write a failing test, then fix to green. |
78+
| Dead-contract / coverage-theatre | yes — catches the class | yes — via PRODUCTION path | Test must drive the real producer, not a fixture-fed value. |
79+
| Nullable member | yes — catches the class | yes — BOTH paths asserted | Two code paths exist (value-present and null); prove each. |
80+
81+
**Rule of thumb:** if you can write an assertion about behaviour that would fail before
82+
the fix and pass after, TDD applies — use it. If the rule is purely structural/stylistic
83+
with no behaviour to assert, the static rule alone is the complete defence.
84+
85+
## The Ratchet in Practice
86+
87+
A worked illustration of the philosophy (shape, not specifics): a tightened static-analysis
88+
ratchet — e.g. bumping the bundled analyser to a stricter version — surfaces a batch of
89+
pre-existing latent errors that the looser net never caught. The discipline is to **fix
90+
every surfaced instance at root cause and never suppress**:
91+
92+
- No `@phpstan-ignore`, no baseline entries, no `@var` forcing, no cast-to-silence.
93+
- Each error is a real defect the stricter net just made visible; resolve the underlying
94+
type/logic issue so the code is genuinely correct.
95+
- The result is a permanent gain: the net is now stricter for all future commits, and the
96+
backlog it exposed is gone rather than papered over.
97+
98+
This is the same net-and-filter principle applied at the tooling level: tightening the net
99+
is only worthwhile if every instance it catches is honestly fixed.
100+
101+
## Cross-Reference
102+
103+
- Workflow skill: `.claude/skills/defence-before-fix/SKILL.md` (model-invoked; the
104+
4-phase ANALYSE → DETECT → TDD → FIX ratchet).
105+
- Rule authoring: `qaConfig/PHPStan/CLAUDE.md` (deployed into each project) and the
106+
`php-qa-ci_phpstan-rule-creator` agent.
107+
- Project root signpost: the auto-generated `<phpqaci>...</phpqaci>` block in the project
108+
root `CLAUDE.md` carries a terse pointer back here (written on every
109+
`composer install`/`update`).

0 commit comments

Comments
 (0)