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.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- legacy-code-characterization Installs to ~/.agents/skills/legacy-code-characterization/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install legacy-code-characterization The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project legacy-code-characterization Installs to .agents/skills/legacy-code-characterization/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/legacy-code-characterization.zipunzip -o legacy-code-characterization.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) legacy-code-characterization PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
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
- The code is already well covered and only needs restructuring: use safe-refactoring
- You are writing brand new code test-first: use test-driven-development
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.
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
| Situation | Level |
|---|---|
| Function is callable with plain arguments | Unit characterization tests |
| Logic is buried in a class with heavy dependencies | Open a seam, then unit tests |
| Behaviour is spread across a script or CLI producing reports or files | Golden master over real inputs |
| Too entangled to isolate within a day | Golden 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:
| Source | Pin it with |
|---|---|
| Wall clock | Inject a clock; or freezegun.freeze_time, vi.useFakeTimers() + vi.setSystemTime(), jest.useFakeTimers() + jest.setSystemTime() |
| Randomness / UUIDs | Seed (random.seed(0)) or inject a generator |
| Time zone and locale | Run with TZ=UTC LC_ALL=C.UTF-8 |
| Hash / set ordering | Sort before asserting; PYTHONHASHSEED=0 for Python str hashing |
| Environment and config | Explicit env in the test; never rely on the developer’s shell |
| Network and databases | Fake 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:
| Seam | How | Typical use |
|---|---|---|
| Module substitution | monkeypatch.setattr, unittest.mock.patch, vi.mock, jest.mock | Python and JS modules calling clients directly |
| Parameterize constructor | Add an optional parameter defaulting to the current hard-coded dependency | Classes that new their own collaborators |
| Parameterize function | Add now=datetime.now or client=None with the old value as default | Free functions using globals |
| Extract and override | Move the awkward call into a method; subclass in the test and override it | OO code without DI |
| Function variable | var sendEmail = smtp.Send at package level, swapped in tests | Go packages |
| Interface extraction | Introduce an interface the real type already satisfies | Go, 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
- Use
--stdinfor programs that read input from standard input. Commit the golden files with the tests, and wireverifyinto 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.yaml
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.py
#!/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.