Legacy Code Characterization skill for Codex

Pin what old code does before you touch it. Codex gets old code under test before changing it. It finds where effects are observable, pins clocks, seeds and ordering, opens seams with default-preserving edits, and writes tests that record current behaviour, bugs included. A bundled golden-master script captures whole-program output, refuses unstable recordings, and diffs every input after the change.

  • Engineering Workflow
  • $legacy-code-characterization
  • 167 lines
  • 3 files
  • ~2,050 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- legacy-code-characterization

Installs to ~/.agents/skills/legacy-code-characterization/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $legacy-code-characterization, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Gets untested legacy code under test before changing it - writing characterization tests that pin what the code does today (bugs included), recording golden-master snapshots of whole outputs with a bundled stdlib script, pinning time, randomness and ordering, and opening seams (parameterize constructor, extract and override, sprout and wrap, module-level substitution) with minimal mechanical edits. Use when asked to change old or untested code, when nobody knows what a module really does, before refactoring a legacy area, or when a change keeps breaking things nobody expected.

Use it when

  • Code must change but has little or no test coverage
  • Nobody can say for sure what a module does in all cases
  • A legacy area keeps breaking in unexpected ways after edits

Use something else when

How to check the result

Characterization tests and golden_master.py verify pass on untouched code, and a deliberate one-line change in the target region makes at least one of them fail.

The mistake it prevents

Fixing an apparent bug while writing the characterization tests, which breaks callers that silently depended on the old behaviour.

~/.agents/skills/legacy-code-characterization/SKILL.md

--- name: legacy-code-characterization description: Gets untested legacy code under test before changing it - writing characterization tests that pin what the code does today (bugs included), recording… license: MIT ---

Before the first behavioural edit, the area you will change is covered by tests that describe what the code does now, not what anyone thinks it should do. They pass on the untouched code, they fail if the region’s behaviour shifts, and the only production edits made to get there were small, mechanical seam-openers.

Workflow

- [ ] 1. Mark the change point and the observable effects
- [ ] 2. Choose the test level: unit, module, or whole-program golden master
- [ ] 3. Pin nondeterminism
- [ ] 4. Open the minimum seams needed
- [ ] 5. Write characterization tests and record goldens
- [ ] 6. Prove the tests can detect change
- [ ] 7. Make the requested change (or hand off to safe-refactoring)

1. Change point and effects

Read AGENTS.md, then find exactly which function or file the requested change touches. Trace outward to where its effects become observable: return values, files written, rows inserted, HTTP calls made, messages logged or printed. Those are your test points.

rg -n "def calculate_invoice|calculate_invoice\(" --type py   # definition and callers
git log --format='%h %ad %s' --date=short -15 -- billing/invoice.py   # who touches it and why

2. Pick the test level

SituationLevel
Function is callable with plain argumentsUnit characterization tests
Logic is buried in a class with heavy dependenciesOpen a seam, then unit tests
Behaviour is spread across a script or CLI producing reports or filesGolden master over real inputs
Too entangled to isolate within a dayGolden master now; finer tests later as seams appear

Golden masters are fast to create and catch wide drift; unit characterization tests tell you which behaviour moved. Use both when the change is risky.

3. Pin nondeterminism

Characterization fails if the same input does not give the same output. Control:

SourcePin it with
Wall clockInject a clock; or freezegun.freeze_time, vi.useFakeTimers() + vi.setSystemTime(), jest.useFakeTimers() + jest.setSystemTime()
Randomness / UUIDsSeed (random.seed(0)) or inject a generator
Time zone and localeRun with TZ=UTC LC_ALL=C.UTF-8
Hash / set orderingSort before asserting; PYTHONHASHSEED=0 for Python str hashing
Environment and configExplicit env in the test; never rely on the developer’s shell
Network and databasesFake at the boundary; the sandbox usually has no network anyway

Anything you cannot pin (timestamps embedded in output, temp paths, PIDs) gets scrubbed to a placeholder before comparison.

4. Open seams with minimal edits

A seam is a place where you can change behaviour for a test without editing the code at that place. Prefer the least invasive one that works:

SeamHowTypical use
Module substitutionmonkeypatch.setattr, unittest.mock.patch, vi.mock, jest.mockPython and JS modules calling clients directly
Parameterize constructorAdd an optional parameter defaulting to the current hard-coded dependencyClasses that new their own collaborators
Parameterize functionAdd now=datetime.now or client=None with the old value as defaultFree functions using globals
Extract and overrideMove the awkward call into a method; subclass in the test and override itOO code without DI
Function variablevar sendEmail = smtp.Send at package level, swapped in testsGo packages
Interface extractionIntroduce an interface the real type already satisfiesGo, Java, C#

Rules for seam edits, because they happen before tests exist:

  • Mechanical only: add a parameter with a default, extract a method verbatim, add an interface. No logic changes, no renames beyond what is needed.
  • Lean on the compiler and type checker after each edit (tsc --noEmit, mypy, go build ./...), since tests cannot protect you yet.
  • Every existing call site must keep working unchanged thanks to defaults.

When new behaviour can live separately, do not modify the legacy function at all:

  • Sprout: write the new logic as a new, fully tested function or class; call it from one line in the legacy code.
  • Wrap: rename the old method, create a new one with the old name that calls the old one plus the new behaviour before or after it.

5. Write characterization tests

The procedure: call the code, assert a placeholder, run, read the actual value from the failure, paste it in. The code tells you what it does.

def test_invoice_total_bulk_order_characterization():
    # Pins current behaviour (2026-10). Value looks wrong: tiers stack. See note in PR.
    invoice = make_invoice(quantity=100, unit_price=Decimal("10.00"))
    assert calculate_invoice(invoice).total == Decimal("836.00")
  • Name and comment them as characterization so nobody mistakes them for a spec.
  • Cover the paths the change will touch: run coverage on the target and add cases until the relevant branches are hit (pytest --cov=billing.invoice --cov-report=term-missing).
  • Choose inputs from real usage where possible (sanitised fixtures, log samples), plus boundaries: empty, zero, one, maximum, invalid.
  • If you find a probable bug, do not fix it now. Pin it, comment it, and tell the user. Callers may depend on it.

For whole-program behaviour, record a golden master with the bundled script (standard library only; runs any command per input file and stores stdout, stderr and exit code):

python3 scripts/golden_master.py record \
  --cmd "python3 legacy/report.py --format csv {input}" \
  --inputs "tests/fixtures/orders/*.csv" --out tests/golden/report \
  --runs 3 --scrub 'Generated at [0-9:T.-]+=>Generated at <TS>'

python3 scripts/golden_master.py verify \
  --cmd "python3 legacy/report.py --format csv {input}" \
  --inputs "tests/fixtures/orders/*.csv" --out tests/golden/report \
  --scrub 'Generated at [0-9:T.-]+=>Generated at <TS>'

--runs 3 refuses to record if any input produces different output across runs, which means step 3 is not finished. verify prints a unified diff per changed input and exits

  1. Use --stdin for programs that read input from standard input. Commit the golden files with the tests, and wire verify into the test command if the user agrees.

6. Prove the tests can see change

Tests that pass are not yet proof. Make a deliberate small change in the target region (flip a comparison, change a constant, skip a branch), run the characterization tests, confirm at least one fails, then revert with git checkout -- <file> or git restore <file>. If nothing fails, the tests do not cover what you are about to change; go back to step 5.

7. Change the code

Now the requested change can proceed. Expected test failures should be exactly the characterization tests that pinned the behaviour being changed; update those deliberately and say so. Any other failure is an unintended side effect. For restructuring without behaviour change, continue with safe-refactoring.

Hard rules

  • Never “fix” behaviour while characterizing. Pin first, change later, in separate commits.
  • Never delete or rewrite a characterization test because it fails after your change without explaining which behaviour changed and why that is intended.
  • Keep seam edits and characterization tests in their own commits, before the change.
  • Do not record goldens from production data containing personal information; sanitise fixtures first.

Report

### Characterization of billing/invoice.py
Seams added: `calculate_invoice(..., now=datetime.now)` (default keeps behaviour)
Tests: 14 characterization tests, branch coverage of target 38% -> 91%
Golden master: 22 order fixtures, stable over 3 runs (timestamps scrubbed)
Sensitivity check: changing `>=` to `>` at invoice.py:118 fails 3 tests
Suspected bugs pinned, not fixed: tiers stack for quantities >= 100 (invoice.py:131)
Next: implement single-tier rule; expect test_invoice_total_bulk_order_characterization to change

What the zip contains

Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Legacy Code Characterization"
  short_description: "Pin what old code does before you touch it"
  default_prompt: "$legacy-code-characterization I need to change this 15-year-old billing script that has no tests, get it under test first"
scripts/golden_master.py169 lines, 6.6 KB
#!/usr/bin/env python3
"""Golden-master (approval) testing for any command-line entry point.

Record what legacy code does today for a set of inputs, then verify that later
changes keep the output identical. Captures stdout, stderr and exit code.

  python3 golden_master.py record --cmd "python3 legacy/report.py {input}" \
      --inputs "fixtures/*.csv" --out golden/ --runs 2
  python3 golden_master.py verify --cmd "python3 legacy/report.py {input}" \
      --inputs "fixtures/*.csv" --out golden/

Options:
  --cmd        command template; {input} is replaced by the quoted input path.
               Without {input} the path is appended as the last argument.
  --inputs     glob (repeatable); recursive ** is supported
  --stdin      feed the input file on stdin instead of passing its path
  --scrub      REGEX or REGEX=>REPLACEMENT applied to stdout and stderr before
               storing or comparing (repeatable); use for timestamps, PIDs, temp paths
  --runs N     record only: run each input N times and refuse to record if the
               outputs differ (detects nondeterminism you must pin first)
  --timeout S  per-run timeout in seconds (default 60)

Exit status: 0 all match / recorded, 1 mismatch, missing golden or nondeterminism,
2 usage error. Standard library only (Python 3.10+).
"""
from __future__ import annotations

import argparse
import difflib
import glob
import hashlib
import json
import os
import re
import shlex
import subprocess
import sys


def parse_scrubs(items: list[str]) -> list[tuple[re.Pattern[str], str]]:
    rules = []
    for item in items:
        pattern, sep, repl = item.partition("=>")
        try:
            rules.append((re.compile(pattern), repl if sep else "<SCRUBBED>"))
        except re.error as exc:
            sys.exit(f"error: bad --scrub regex {pattern!r}: {exc}")
    return rules


def scrub(text: str, rules) -> str:
    for rx, repl in rules:
        text = rx.sub(repl, text)
    return text


def golden_name(path: str) -> str:
    rel = os.path.normpath(path).replace(os.sep, "__")
    safe = re.sub(r"[^A-Za-z0-9._-]", "_", rel)
    if len(safe) > 120:
        safe = safe[:100] + "-" + hashlib.sha1(rel.encode()).hexdigest()[:12]
    return safe + ".golden.json"


def run_one(cmd_tpl: str, path: str, use_stdin: bool, timeout: float, rules) -> dict:
    if use_stdin:
        argv = shlex.split(cmd_tpl.replace("{input}", ""))
    elif "{input}" in cmd_tpl:
        argv = shlex.split(cmd_tpl.replace("{input}", shlex.quote(path)))
    else:
        argv = shlex.split(cmd_tpl) + [path]
    stdin_data = None
    if use_stdin:
        with open(path, "rb") as fh:
            stdin_data = fh.read()
    try:
        proc = subprocess.run(argv, input=stdin_data, capture_output=True, timeout=timeout)
    except FileNotFoundError:
        sys.exit(f"error: command not found: {argv[0]}")
    except subprocess.TimeoutExpired:
        return {"exit_code": "timeout", "stdout": "", "stderr": f"timed out after {timeout}s"}
    decode = lambda b: b.decode("utf-8", errors="backslashreplace")
    return {
        "exit_code": proc.returncode,
        "stdout": scrub(decode(proc.stdout), rules),
        "stderr": scrub(decode(proc.stderr), rules),
    }


def show_diff(label: str, old: str, new: str, limit: int = 40) -> None:
    lines = list(difflib.unified_diff(old.splitlines(), new.splitlines(),
                                      f"golden/{label}", f"current/{label}", lineterm=""))
    for line in lines[:limit]:
        print("    " + line)
    if len(lines) > limit:
        print(f"    ... {len(lines) - limit} more diff lines")


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("mode", choices=["record", "verify"])
    ap.add_argument("--cmd", required=True)
    ap.add_argument("--inputs", action="append", required=True)
    ap.add_argument("--out", required=True, help="directory holding golden files")
    ap.add_argument("--stdin", action="store_true")
    ap.add_argument("--scrub", action="append", default=[])
    ap.add_argument("--runs", type=int, default=1)
    ap.add_argument("--timeout", type=float, default=60.0)
    args = ap.parse_args()

    paths = sorted({p for g in args.inputs for p in glob.glob(g, recursive=True) if os.path.isfile(p)})
    if not paths:
        print("error: --inputs matched no files", file=sys.stderr)
        return 2
    rules = parse_scrubs(args.scrub)
    if args.mode == "record":
        os.makedirs(args.out, exist_ok=True)

    problems = 0
    for path in paths:
        gfile = os.path.join(args.out, golden_name(path))
        result = run_one(args.cmd, path, args.stdin, args.timeout, rules)

        if args.mode == "record":
            unstable = False
            for _ in range(max(args.runs, 1) - 1):
                again = run_one(args.cmd, path, args.stdin, args.timeout, rules)
                if again != result:
                    unstable = True
                    print(f"UNSTABLE {path}: output differs between runs; pin time/random/order or add --scrub")
                    for key in ("stdout", "stderr"):
                        if again[key] != result[key]:
                            show_diff(key, result[key], again[key])
                    break
            if unstable:
                problems += 1
                continue
            record = {"input": path, "cmd": args.cmd, **result}
            with open(gfile, "w", encoding="utf-8") as fh:
                json.dump(record, fh, indent=2, ensure_ascii=False)
                fh.write("\n")
            print(f"recorded {path} (exit {result['exit_code']}, {len(result['stdout'])} bytes stdout)")
            continue

        if not os.path.exists(gfile):
            print(f"MISSING  {path}: no golden file; run record first")
            problems += 1
            continue
        with open(gfile, encoding="utf-8") as fh:
            golden = json.load(fh)
        diffs = [k for k in ("exit_code", "stdout", "stderr") if golden.get(k) != result[k]]
        if not diffs:
            print(f"ok       {path}")
            continue
        problems += 1
        print(f"CHANGED  {path}: {', '.join(diffs)}")
        if "exit_code" in diffs:
            print(f"    exit code {golden.get('exit_code')} -> {result['exit_code']}")
        for key in ("stdout", "stderr"):
            if key in diffs:
                show_diff(key, golden.get(key, ""), result[key])

    verb = "recorded" if args.mode == "record" else "verified"
    print(f"\n{len(paths) - problems}/{len(paths)} inputs {verb} cleanly")
    return 1 if problems else 0


if __name__ == "__main__":
    sys.exit(main())

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem