Legacy Code Rescue skill for Claude Code

Change untested code without breaking production. When code has no tests, Claude stops guessing and follows Feathers-style technique: rank hotspots by churn times size, find test points, break dependencies at seams with minimal mechanical edits, and pin current behavior with characterization or golden-master tests. New behavior is sprouted into fresh tested units, and larger modernization happens in strangler-fig slices, never a big-bang rewrite.

  • namelegacy-code-rescue
  • size177 lines · ≈2,150 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Makes changes to untested, tangled legacy code safely by finding hotspots, breaking dependencies at seams, pinning current behavior with characterization and golden-master tests, adding new code via sprout and wrap techniques, and modernizing incrementally with the strangler fig and branch by abstraction. Includes a churn-times-size hotspot script. Use when the user must change code with no tests, inherits an old or fragile system, says "nobody dares touch this", or plans a rewrite or gradual modernization.

Try asking

  • I need to add a surcharge rule to this 3,000-line OrderProcessor with zero tests
  • Which files in this old repo should we get under test first?
  • Plan how to replace our legacy billing module gradually instead of rewriting it

Use it when

  • You must change code that has no tests or cannot be instantiated in a test
  • A module is fragile and people avoid touching it
  • You need to decide where to add tests first in an old codebase
  • A rewrite is being proposed and you want an incremental alternative

Skip it when

How to check the result

Characterization tests around the change area fail when a deliberate bug is introduced, and new behavior lives in separately tested units with only a few lines edited in legacy code.

The common mistake it prevents

Fixing odd behavior discovered while writing characterization tests in the same change, breaking callers that quietly depended on it.

~/.claude/skills/legacy-code-rescue/SKILL.md

--- name: legacy-code-rescue description: Makes changes to untested, tangled legacy code safely by finding hotspots, breaking dependencies at seams, pinning current behavior with… license: MIT ---

Legacy code is code without tests. Success is not a rewrite; it is being able to make the change the business needs today, with tests around what you touched, leaving the code slightly easier to change next time. Every step keeps the system shippable.

Ground rules

  • Preserve behavior first, improve it second. Characterize what the code does now, including its bugs, before deciding what it should do.
  • Make the smallest dependency-breaking edits needed to get code under test, using mechanical, low-risk moves (often without tests yet). Keep those edits obvious.
  • Cover the area you are changing, not the whole system. Test where the change lands and at the nearest pinch point.
  • New code goes into new, tested units (sprout) rather than deeper into the tangle.
  • No big-bang rewrites. Replace incrementally behind a stable interface, with old and new runnable side by side and a way to switch back.
  • Bugs found while characterizing are recorded and reported, not silently fixed in the same change. Callers may depend on them.

Workflow

- [ ] 1. Define the change and find the change points
- [ ] 2. Prioritize with hotspots and risk
- [ ] 3. Find test points (where effects can be observed)
- [ ] 4. Break dependencies at seams to get the code into a harness
- [ ] 5. Write characterization tests
- [ ] 6. Make the change with sprout/wrap or TDD
- [ ] 7. Refactor the covered area a little
- [ ] 8. Plan incremental modernization if larger work is needed

1. Change points

Write the change in one sentence, then locate every place that must change (grep, trace from the entry point). Draw a quick effect sketch: which values and outputs does each change point affect, and who reads those?

2. Hotspots

Prioritize by where change and complexity overlap:

python3 scripts/hotspots.py <repo> --since "12 months ago" --top 20
python3 scripts/hotspots.py <repo> --ext .java,.kt --top 30

Score = commits in the window x current lines. Files with high score and many authors are where characterization tests pay off first; files that never change can stay ugly.

3. Test points

Find the narrowest place where the effects of the change can be observed:

  • A pure return value is best. Next: a public method’s observable state change.
  • A pinch point is a function or class through which many effects funnel (e.g. a generateInvoice() that all paths call); one set of tests there covers a lot.
  • At the far end: HTTP responses, files written, DB rows, emitted messages. Slow but needs no code change; use for golden masters.

4. Break dependencies at seams

A seam is a place where behavior can change without editing the code at that place.

Obstacle to testingTechnique
Constructor creates DB/HTTP clientsParameterize Constructor (default to the old concrete, allow injection)
Static/global call (Clock.now(), Config.get)Wrap in an instance method; override in a test subclass, or inject
Huge class, need one methodExtract the method to a pure function taking only what it uses
Method calls a hard dependency deep insideExtract and Override Call: move the call into a protected method, subclass in tests
Hidden singletonIntroduce a static setter for tests (temporary, documented)
Language with modules (JS/Python)Module-level seam: inject via parameter; as a last resort vi.mock/unittest.mock.patch
Link-time (C/C++/Go build tags)Link seam: alternative implementation at build time

Example: parameterize constructor without changing callers.

class InvoiceService:
    def __init__(self, db=None, mailer=None, clock=None):
        self.db = db or PostgresDb(os.environ["DATABASE_URL"])   # old behavior by default
        self.mailer = mailer or SmtpMailer()
        self.clock = clock or SystemClock()

Rules for these pre-test edits: one technique at a time, preserve signatures, lean on the compiler/IDE refactorings, and keep each edit reviewable in under a minute.

5. Characterization tests

  1. Write a test that calls the code with a realistic input and asserts something obviously wrong (assert result == "x").
  2. Run it; the failure message shows the actual value.
  3. Paste the actual value into the assertion. The test now documents current behavior.
  4. Repeat for inputs that cover branches near your change: empty, boundaries, error paths, each branch in the condition you will modify.
def test_characterizes_late_fee_for_31_days_overdue():
    # Current behavior: fee is charged per started week, not per day. Possibly a bug (#1290).
    assert compute_late_fee(amount_cents=10_000, days_overdue=31) == 2_500

For large outputs, use a golden master: generate many varied inputs (combinations of parameters, recorded production requests with PII removed), save the outputs once, and assert future outputs match byte-for-byte. Tools: ApprovalTests (many languages), snapshot tests (toMatchSnapshot, syrupy for pytest). Normalize timestamps, IDs, and ordering before comparing.

Check the net: introduce a deliberate bug in the change area and confirm a test fails.

6. Make the change

SituationTechnique
New behavior, can be computed separatelySprout Method/Class: write new code test-first in a new unit; call it from the legacy code with a one-line edit
New behavior before/after existing behaviorWrap Method: rename old method, new method with old name calls old + new logic
Same for a whole classWrap Class (decorator): same interface, delegates, adds behavior
Modifying existing logic that is now characterizedChange it test-first: update/add the characterization test to the new expected value, see red, then edit
// Sprout: new tested function; legacy method gets a single call
export function shippingSurcharge(region: Region, weightKg: number): Cents { /* TDD'd */ }

// in LegacyOrderProcessor.process(), one added line:
total += shippingSurcharge(order.region, order.weightKg);

7. Refactor a little

With tests in place, improve only what you touched: extract the long branch you modified, rename misleading variables, delete dead code you can prove is dead (coverage plus grep, logs, or feature-usage metrics). Leave it better, not finished. For larger restructuring, switch to safe-refactoring.

8. Incremental modernization

GoalPattern
Replace a subsystem or service graduallyStrangler fig: route a slice of traffic or one endpoint to the new implementation via a facade/proxy; grow the slice; delete the old path when traffic is zero
Replace an internal component in-processBranch by abstraction: introduce an interface, make old code implement it, build new implementation behind it, switch via flag, remove old
Verify new implementation matches oldParallel run / shadowing: run both, return old result, log diffs; switch when diff rate is acceptably low
Migrate data storeDual-write, backfill, verify, switch reads, stop old writes (expand/contract)

Each slice must be deployable alone, with a flag or route switch to roll back. Track the remaining legacy surface (endpoints, call sites) so progress is measurable.

Gotchas

  • Rewrite temptation. Full rewrites rediscover every edge case the old code handled silently, usually after launch. Propose strangler slices instead and say why.
  • Characterizing with mocks everywhere pins the implementation, not the behavior; prefer real collaborators or fakes and observe outputs.
  • Time, randomness, locale, and ordering make golden masters flaky; inject or normalize them first.
  • Dead-looking code may be called by reflection, cron, config-driven dispatch, or another repo. Prove it is unused before deleting.
  • Test-only hooks (static setters, subclass overrides) are scaffolding. Leave a comment and remove them once real injection exists.
  • Upgrading the runtime and changing logic together makes failures unattributable. Separate PRs.

Verification

  • The change area has characterization tests that fail when a deliberate bug is introduced.
  • New behavior lives in new tested units; the legacy edit is a few lines.
  • Full existing suite (if any), plus a manual or golden-master comparison of key flows, shows no unintended behavior change.
  • Report lists seams introduced, behaviors characterized, suspected bugs found (with issue links), and the next modernization slice if relevant.

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

scripts/hotspots.py 97 lines · 3.3 KB
#!/usr/bin/env python3
"""Rank files by change frequency x size to find legacy hotspots.

Usage:
  python3 hotspots.py [repo_dir] [--since "12 months ago"] [--top 20] [--ext .py,.ts]

Score = commits touching the file in the window * current line count, normalized
to the top file (100). High churn on a large file is where defects concentrate and
where characterization tests pay off first. Also prints distinct authors per file.
Standard library only; needs git on PATH. Exits 1 on git errors.
"""
from __future__ import annotations

import argparse
import subprocess
import sys
from collections import Counter, defaultdict
from pathlib import Path

DEFAULT_SKIP = ("lock", ".min.", "generated", "__snapshots__", "vendor/", "dist/", "node_modules/")


def git(repo: Path, *args: str) -> str:
    try:
        return subprocess.run(
            ["git", "-C", str(repo), *args], check=True, capture_output=True, text=True
        ).stdout
    except FileNotFoundError:
        print("git not found on PATH", file=sys.stderr)
        sys.exit(1)
    except subprocess.CalledProcessError as exc:
        print(f"git {' '.join(args)} failed: {exc.stderr.strip()}", file=sys.stderr)
        sys.exit(1)


def line_count(path: Path) -> int:
    try:
        with path.open("rb") as fh:
            return sum(1 for _ in fh)
    except OSError:
        return 0


def main(argv: list[str]) -> int:
    ap = argparse.ArgumentParser(description="Churn x size hotspot ranking")
    ap.add_argument("repo", nargs="?", default=".")
    ap.add_argument("--since", default="12 months ago")
    ap.add_argument("--top", type=int, default=20)
    ap.add_argument("--ext", default="", help="comma-separated extensions to include, e.g. .py,.ts")
    ns = ap.parse_args(argv)

    repo = Path(git(Path(ns.repo).resolve(), "rev-parse", "--show-toplevel").strip())
    exts = tuple(e.strip() for e in ns.ext.split(",") if e.strip())

    log = git(repo, "log", f"--since={ns.since}", "--no-merges", "--format=@@%an", "--name-only")
    churn: Counter[str] = Counter()
    authors: dict[str, set[str]] = defaultdict(set)
    author = ""
    for line in log.splitlines():
        if line.startswith("@@"):
            author = line[2:]
            continue
        f = line.strip()
        if not f:
            continue
        if any(s in f for s in DEFAULT_SKIP):
            continue
        if exts and not f.endswith(exts):
            continue
        churn[f] += 1
        authors[f].add(author)

    tracked = set(git(repo, "ls-files").splitlines())
    rows = []
    for f, n in churn.items():
        if f not in tracked:
            continue  # deleted or renamed away
        loc = line_count(repo / f)
        if loc == 0:
            continue
        rows.append((n * loc, f, n, loc, len(authors[f])))

    if not rows:
        print(f"no tracked source files changed since {ns.since!r}")
        return 0

    rows.sort(reverse=True)
    top_score = rows[0][0]
    print(f"Hotspots in {repo} since {ns.since} (score = commits x lines, top = 100)\n")
    print(f"{'score':>5}  {'commits':>7}  {'lines':>6}  {'authors':>7}  file")
    for score, f, n, loc, a in rows[: ns.top]:
        print(f"{round(100 * score / top_score):>5}  {n:>7}  {loc:>6}  {a:>7}  {f}")
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc