Sprouting Instead of Editing
Feathers' escape hatch for the worst case: code you cannot pin must not be edited in place. Put the new behavior in a new unit you can TDD from scratch, and touch the legacy code at exactly one line.
The Iron Law
IF YOU CANNOT PIN IT, DO NOT EDIT IT — SPROUT BESIDE IT
Decision Table
| Situation | Move |
|---|---|
Seam total_score ≥ 0.5 and output observable |
Not this skill — REQUIRED SUB-SKILL: pinning-down-behavior |
| Adding behavior inside an unpinnable function | Sprout method/class: new code in a new unit, called from one new line in the legacy body |
| Behavior must run before/after an unpinnable function | Wrap method: rename old → _old_name, create same-signature name() that calls _old_name() plus the addition |
God file flagged in specs/brownfield/skeletons/ |
Default to sprout; never inline new logic into the god body |
Process
- Write the sprout as a brand-new module/class/function — the existing TDD gate applies in full (failing test first;
superpowers:test-driven-development). - Touch the legacy file at exactly one call line (or the rename pair for wrap). Verify mechanically: the diff to the legacy file intersects one symbol; for the call-line check use the symbol ranges in
specs/brownfield/code-graph.json. This is now enforced, not just claimed: pre-commit'ssprout-diff-one-symbolgate (gap G30,.claude/scripts/sprout-diff-gate.js) BLOCKs when the legacy file's staged diff touches more than 2 distinct symbols (1 for a plain sprout call line, up to 2 for a legitimate wrap rename pair) — it fires only whenlegacy-discipline-gate.js(G17) already sees this file as UNCOVERED-with-evidence AND the evidence is a genuinely new production file (a sprout, not a pin-down). - Confirm with the code graph that no other caller of the legacy symbol changes behavior unintentionally (
edgeswheretarget= the legacy file,symbol_to= the symbol). - Run the full suite plus any oracle tests from checking-coverage-before-change.
Common Rationalizations
| Excuse | Reality |
|---|---|
| "I'll just quickly inline it" | Inlining into unpinned code is an unobserved behavior change. That is the definition of risk. |
| "The function is only 30 lines, editing is fine" | Size is not coverage. Unpinned is unpinned. |
| "A sprout adds indirection" | One extra call is cheaper than one silent regression. Fold it in later, under tests. |
| "The new lines match the existing style exactly" | Style fit is not coverage. Five untestable lines next to other untestable lines are still untestable. |
| "Last review mocked my 'enterprise abstractions'" | A sprout forced by an unpinnable call site is a coverage requirement, not architecture theater — say exactly that in the PR description, preemptively. |
Red Flags — STOP
- More than one changed line in the legacy file (excluding the wrap rename pair)
- New logic appearing inside the legacy function body
- Sprout code written before its failing test
- Re-running seam-finder hoping for a score above the threshold — 0.31 is the answer, not a negotiation
Checklist
- Sprout/wrap chosen via the decision table
- Sprout fully TDD'd as new code
- Legacy diff = one call line (or rename pair), verified against symbol ranges
- Full suite + oracle tests green
- The sprout's new/modified test file is staged in the SAME commit as the one-line legacy edit — satisfies the pre-commit
legacy-discipline-proofgate (G17)'s evidence requirement for an UNCOVERED-verdict legacy file
New code gets tests; old code gets one line. No exceptions without your human partner's permission.