14 KiB
name, description
| name | description |
|---|---|
| refactoring-safely | Use when refactoring code - test-preserving transformations in small steps, running tests between each change |
<skill_overview> Refactoring changes code structure without changing behavior; tests must stay green throughout or you're rewriting, not refactoring. </skill_overview>
<rigidity_level> MEDIUM FREEDOM - Follow the change→test→commit cycle strictly, but adapt the specific refactoring patterns to your language and codebase. </rigidity_level>
<quick_reference>
| Step | Action | Verify |
|---|---|---|
| 1 | Run full test suite | ALL pass |
| 2 | Create bd refactoring task | Track work |
| 3 | Make ONE small change | Compiles |
| 4 | Run tests immediately | ALL still pass |
| 5 | Commit with descriptive message | History clear |
| 6 | Repeat 3-5 until complete | Each step safe |
| 7 | Final verification & close bd | Done |
Core cycle: Change → Test → Commit (repeat until complete) </quick_reference>
<when_to_use>
- Improving code structure without changing functionality
- Extracting duplicated code into shared utilities
- Renaming for clarity
- Reorganizing file/module structure
- Simplifying complex code while preserving behavior
Don't use for:
- Changing functionality (use feature development)
- Fixing bugs (use hyperpowers:fixing-bugs)
- Adding features while restructuring (do separately)
- Code without tests (write tests first using hyperpowers:test-driven-development) </when_to_use>
<the_process>
1. Verify Tests Pass
BEFORE any refactoring:
# Use test-runner agent to keep context clean
Dispatch hyperpowers:test-runner agent: "Run: cargo test"
Verify: ALL tests pass. If any fail, fix them FIRST, then refactor.
Why: Failing tests mean you can't detect if refactoring breaks things.
2. Create bd Task for Refactoring
Track the refactoring work:
bd create "Refactor: Extract user validation logic" \
--type task \
--priority P2
bd edit bd-456 --design "
## Goal
Extract user validation logic from UserService into separate Validator class.
## Why
- Validation duplicated across 3 services
- Makes testing individual validations difficult
- Violates single responsibility
## Approach
1. Create UserValidator class
2. Extract email validation
3. Extract name validation
4. Extract age validation
5. Update UserService to use validator
6. Remove duplication from other services
## Success Criteria
- All existing tests still pass
- No behavior changes
- Validator has 100% test coverage
"
bd update bd-456 --status in_progress
3. Make ONE Small Change
The smallest transformation that compiles.
Examples of "small":
- Extract one method
- Rename one variable
- Move one function to different file
- Inline one constant
- Extract one interface
NOT small:
- Extracting multiple methods at once
- Renaming + moving + restructuring
- "While I'm here" improvements
Example:
// Before
fn create_user(name: &str, email: &str) -> Result<User> {
if email.is_empty() {
return Err(Error::InvalidEmail);
}
if !email.contains('@') {
return Err(Error::InvalidEmail);
}
let user = User { name, email };
Ok(user)
}
// After - ONE small change (extract email validation)
fn create_user(name: &str, email: &str) -> Result<User> {
validate_email(email)?;
let user = User { name, email };
Ok(user)
}
fn validate_email(email: &str) -> Result<()> {
if email.is_empty() {
return Err(Error::InvalidEmail);
}
if !email.contains('@') {
return Err(Error::InvalidEmail);
}
Ok(())
}
4. Run Tests Immediately
After EVERY small change:
Dispatch hyperpowers:test-runner agent: "Run: cargo test"
Verify: ALL tests still pass.
If tests fail:
- STOP
- Undo the change:
git restore src/file.rs - Understand why it broke
- Make smaller change
- Try again
Never proceed with failing tests.
5. Commit the Small Change
Commit each safe transformation:
Dispatch hyperpowers:test-runner agent: "Run: git add src/user_service.rs && git commit -m 'refactor(bd-456): extract email validation to function
No behavior change. All tests pass.
Part of bd-456'"
Why commit so often:
- Easy to undo if next step breaks
- Clear history of transformations
- Can review each step independently
- Proves tests passed at each point
6. Repeat Until Complete
Repeat steps 3-5 for each small transformation:
1. Extract validate_email() ✓ (committed)
2. Extract validate_name() ✓ (committed)
3. Extract validate_age() ✓ (committed)
4. Create UserValidator struct ✓ (committed)
5. Move validations into UserValidator ✓ (committed)
6. Update UserService to use validator ✓ (committed)
7. Remove validation from OrderService ✓ (committed)
8. Remove validation from AccountService ✓ (committed)
Pattern: change → test → commit (repeat)
7. Final Verification
After all transformations complete:
# Full test suite
Dispatch hyperpowers:test-runner agent: "Run: cargo test"
# Linter
Dispatch hyperpowers:test-runner agent: "Run: cargo clippy"
Review the changes:
# See all refactoring commits
git log --oneline | grep "bd-456"
# Review full diff
git diff main...HEAD
Checklist:
- All tests pass
- No new warnings
- No behavior changes
- Code is cleaner/simpler
- Each commit is small and safe
Close bd task:
bd edit bd-456 --design "
... (append to existing design)
## Completed
- Created UserValidator class with email, name, age validation
- Removed duplicated validation from 3 services
- All tests pass (verified)
- No behavior changes
- 8 small transformations, each tested
"
bd close bd-456
</the_process>
Developer changes behavior while "refactoring"
// Original code
fn validate_email(email: &str) -> Result<()> {
if email.is_empty() {
return Err(Error::InvalidEmail);
}
if !email.contains('@') {
return Err(Error::InvalidEmail);
}
Ok(())
}
// "Refactored" version
fn validate_email(email: &str) -> Result<()> {
if email.is_empty() {
return Err(Error::InvalidEmail);
}
if !email.contains('@') {
return Err(Error::InvalidEmail);
}
// NEW: Added extra validation
if !email.contains('.') { // BEHAVIOR CHANGE
return Err(Error::InvalidEmail);
}
Ok(())
}
<why_it_fails>
- This changes behavior (now rejects emails like "user@localhost")
- Tests might fail, or worse, pass and ship breaking change
- Not refactoring - this is modifying functionality
- Users who relied on old behavior experience regression </why_it_fails>
- Extract validation (pure refactoring, no behavior change)
- Commit with tests passing
- THEN add new validation as separate feature with new tests
- Two clear commits: refactoring vs. feature addition
What you gain:
- Clear history of what changed when
- Easy to revert feature without losing refactoring
- Tests document exact behavior changes
- No surprises in production
# Changes made all at once:
- Renamed 15 functions across 5 files
- Extracted 3 new classes
- Moved code between 10 files
- Reorganized module structure
- Updated all import statements
Then runs tests
$ cargo test
... 23 test failures ...
Now what? Which change broke what?
<why_it_fails>
- Can't identify which specific change broke tests
- Reverting means losing ALL work
- Fixing requires re-debugging entire refactoring
- Wastes hours trying to untangle failures
- Might give up and revert everything </why_it_fails>
- Rename ONE function → test → commit
- Extract ONE class → test → commit
- Move ONE file → test → commit
- Continue one change at a time
If test fails:
- Know exactly which change broke it
- Revert ONE commit, not all work
- Fix or make smaller change
- Continue from known-good state
What you gain:
- Tests break → immediately know why
- Each commit is reviewable independently
- Can stop halfway with useful progress
- Confidence from continuous green tests
- Clear history for future developers
// Legacy code with no tests
fn process_payment(amount: f64, user_id: i64) -> Result {
// 200 lines of complex payment logic
// Multiple edge cases
// No tests exist
}
// Developer refactors without tests:
// - Extracts 5 methods
// - Renames variables
// - Simplifies conditionals
// - "Looks good to me!"
// Deploys to production
// 💥 Payments fail for amounts over $1000
// Edge case handling was accidentally changed
<why_it_fails>
- No tests to verify behavior preserved
- Complex logic has hidden edge cases
- Subtle behavior changes go unnoticed
- Breaks in production, not development
- Costs customer trust and emergency debugging </why_it_fails>
-
Write tests FIRST (using hyperpowers:test-driven-development)
- Test happy path
- Test all edge cases (amounts over $1000, etc.)
- Test error conditions
- Run tests → all pass (documenting current behavior)
-
Then refactor with tests as safety net
- Extract method → run tests → commit
- Rename → run tests → commit
- Simplify → run tests → commit
-
Tests catch any behavior changes immediately
What you gain:
- Confidence behavior is preserved
- Edge cases documented in tests
- Catches subtle changes before production
- Future refactoring is also safe
- Tests serve as documentation
<refactor_vs_rewrite>
When to Refactor
- Tests exist and pass
- Changes are incremental
- Business logic stays same
- Can transform in small, safe steps
- Each step independently valuable
When to Rewrite
- No tests exist (write tests first, then refactor)
- Fundamental architecture change needed
- Easier to rebuild than modify
- Requirements changed significantly
- After 3+ failed refactoring attempts
Rule: If you need to change test assertions (not just add tests), you're rewriting, not refactoring.
Strangler Fig Pattern (Hybrid)
When to use:
- Need to replace legacy system but can't tolerate downtime
- Want incremental migration with continuous monitoring
- System too large to refactor in one go
How it works:
- Transform: Create modernized components alongside legacy
- Coexist: Both systems run in parallel (façade routes requests)
- Eliminate: Retire old functionality piece by piece
Example:
Legacy: Monolithic user service (50K LOC)
Goal: Microservices architecture
Step 1 (Transform):
- Create new UserService microservice
- Implement user creation endpoint
- Tests pass in isolation
Step 2 (Coexist):
- Add routing layer (façade)
- Route POST /users to new service
- Route GET /users to legacy service (for now)
- Monitor both, compare results
Step 3 (Eliminate):
- Once confident, migrate GET /users to new service
- Remove user creation from legacy
- Repeat for remaining endpoints
Benefits:
- Incremental replacement reduces risk
- Legacy continues operating during transition
- Can pause/rollback at any point
- Each migration step is independently valuable
Use refactoring within components, Strangler Fig for replacing systems. </refactor_vs_rewrite>
<critical_rules>
Rules That Have No Exceptions
- Tests must stay green throughout refactoring → If they fail, you changed behavior (stop and undo)
- Commit after each small change → Large commits hide which change broke what
- One transformation at a time → Multiple changes = impossible to debug failures
- Run tests after EVERY change → Delayed testing doesn't tell you which change broke it
- If tests fail 3+ times, question approach → Might need to rewrite instead, or add tests first
Common Excuses
All of these mean: Stop and return to the change→test→commit cycle
- "Small refactoring, don't need tests between steps"
- "I'll test at the end"
- "Tests are slow, I'll run once at the end"
- "Just fixing bugs while refactoring" (bug fixes = behavior changes = not refactoring)
- "Easier to do all at once"
- "I know it works without tests"
- "While I'm here, I'll also..." (scope creep during refactoring)
- "Tests will fail temporarily but I'll fix them" (tests must stay green) </critical_rules>
<verification_checklist> Before marking refactoring complete:
- All tests pass (verified with hyperpowers:test-runner agent)
- No new linter warnings
- No behavior changes introduced
- Code is cleaner/simpler than before
- Each commit in history is small and safe
- bd task documents what was done and why
- Can explain what each transformation did
Can't check all boxes? Return to process and fix before closing bd task. </verification_checklist>
**This skill requires:** - hyperpowers:test-driven-development (for writing tests before refactoring if none exist) - hyperpowers:verification-before-completion (for final verification) - hyperpowers:test-runner agent (for running tests without context pollution)This skill is called by:
- General development workflows when improving code structure
- After features are complete and working
- When preparing code for new features
Agents used:
- test-runner (runs tests/commits without polluting main context)
When stuck:
- Tests fail after change → Undo (git restore), make smaller change
- 3+ failures → Question if refactoring is right approach, consider rewrite
- No tests exist → Use hyperpowers:test-driven-development to write tests first
- Unsure how small → If it touches more than one function/file, it's too big