A messy CLI is not a refactor target.
It is a process contract you have not measured.
Freeze exit status, stdout bytes, and stderr class first.
Then change one parse concern. Stop after that extract.
Users depend on process results, not function names.
An argparse split without that table remains a guess.
This walkthrough uses a constructed Python example.
It is a method, not a claimed production incident.
Swap the fixtures for your corpus before you trust it.
What the table measures
Measure three fields for every fixture row.
Record the integer process exit code first.
Then hash stdout bytes and classify stderr.
Do not start with class extraction work.
Do not start with clean architecture diagrams.
Those moves enlarge the diff without a check.
The outcome table is the only oracle.
The extract is allowed only when rows stay equal.
One parse concern is the change budget here.
The artifact
The artifact is a frozen process contract table.
Each row stores one fixture id and three results.
Tests replay the same launcher and compare fields.
Label: the files below are a worked example.
They are not copied from a customer repository.
Rename paths when you apply the same method.
Constructed messy entrypoint
# cli_messy.py — constructed example, not production code
import argparse
import sys
from pathlib import Path
def main(argv=None):
argv = argv if argv is not None else sys.argv[1:]
parser = argparse.ArgumentParser(add_help=False)
parser.add_argument("path")
parser.add_argument("-q", action="store_true")
parser.add_argument("--strict", action="store_true")
parser.add_argument("--limit", default="10")
args, unknown = parser.parse_known_args(argv)
if unknown:
sys.stderr.write("unknown-flag\n")
return 2
path = Path(args.path)
if not path.exists():
sys.stderr.write("missing-input\n")
return 2
data = path.read_bytes()
if args.strict and b"\x00" in data:
sys.stderr.write("nul-byte\n")
return 1
text = data.decode("utf-8", errors="replace")
lines = [ln for ln in text.splitlines() if ln.strip()]
try:
limit = int(args.limit)
except ValueError:
sys.stderr.write("bad-limit\n")
return 2
selected = lines[:limit]
if not args.q:
sys.stdout.write("\n".join(selected) + ("\n" if selected else ""))
return 0
if __name__ == "__main__":
raise SystemExit(main())
Parser, validator, and printer share one function.
That mix is why later extracts drift user output.
This pass will not move the printer at all.
Step 1: Isolate one launcher
Pick a single invocation shape and keep it.
Use one interpreter binary for every table row.
Do not mix wrappers inside the same contract file.
python cli_messy.py FIXTURE --strict --limit 10
Write that launcher into the recorder script.
Changing the launcher later invalidates stdout hashes.
Treat the launcher as part of the measured contract.
Step 2: Freeze a fixture corpus
Create a directory of small deterministic inputs.
Keep a short file, a blank file, and a NUL file.
Keep missing-path and unknown-flag rows in the runner.
fixtures/
ok_short.txt
ok_blank.txt
has_nul.bin
You choose the corpus. Tests only record outcomes.
A model may suggest extra cases. You still own files.
Hash the corpus after the set is frozen.
find fixtures -type f -print0 | sort -z | xargs -0 sha256sum > fixtures.sha256
Commit that listing beside the contract tests.
A silent fixture edit breaks the oracle first.
Catch that before you blame the argparse extract.
Step 3: Record bytes, not imported functions
Run one subprocess per row in the table.
Do not import main for this first oracle.
Users run a process, so the oracle runs one.
Capture stdout as raw bytes before hashing.
Text mode can rewrite newlines across platforms.
That rewrite would look like an extract failure.
# record_contract.py — constructed example
import hashlib
import json
import subprocess
import sys
from pathlib import Path
CASES = [
{"id": "ok_short", "args": ["fixtures/ok_short.txt", "--strict", "--limit", "10"]},
{"id": "ok_quiet", "args": ["fixtures/ok_short.txt", "-q", "--limit", "10"]},
{"id": "blank", "args": ["fixtures/ok_blank.txt", "--limit", "3"]},
{"id": "missing", "args": ["fixtures/nope.txt", "--strict"]},
{"id": "bad_limit", "args": ["fixtures/ok_short.txt", "--limit", "x"]},
{"id": "unknown", "args": ["fixtures/ok_short.txt", "--explode"]},
{"id": "nul", "args": ["fixtures/has_nul.bin", "--strict"]},
]
ALLOWED = {"unknown-flag", "missing-input", "nul-byte", "bad-limit"}
def classify_stderr(raw: bytes) -> str:
text = raw.decode("utf-8", errors="replace").strip()
if not text:
return "empty"
first = text.splitlines()[0]
return first if first in ALLOWED else "other"
def run_case(args):
proc = subprocess.run(
[sys.executable, "cli_messy.py", *args],
capture_output=True,
)
return {
"exit": proc.returncode,
"stdout_sha16": hashlib.sha256(proc.stdout).hexdigest()[:16],
"stderr_class": classify_stderr(proc.stderr),
}
def main():
table = []
for case in CASES:
row = {"id": case["id"], **run_case(case["args"])}
table.append(row)
print(json.dumps(row, sort_keys=True))
Path("contract_table.json").write_text(
json.dumps(table, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
Path("contract_cases.json").write_text(
json.dumps(CASES, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
if __name__ == "__main__":
main()
stderr classification uses a closed string set.
Unknown text becomes class other on purpose.
Wording churn cannot hide inside a free-form blob.
Step 4: Commit the table, then test it
Commit contract_table.json as expected values.
The test file must not refresh those values itself.
A failing row is a contract break. Investigate it.
# test_contract.py — constructed example
import hashlib
import json
import subprocess
import sys
from pathlib import Path
CASES = json.loads(Path("contract_cases.json").read_text(encoding="utf-8"))
EXPECTED = {
row["id"]: row
for row in json.loads(Path("contract_table.json").read_text(encoding="utf-8"))
}
ALLOWED = {"unknown-flag", "missing-input", "nul-byte", "bad-limit"}
def classify_stderr(raw: bytes) -> str:
text = raw.decode("utf-8", errors="replace").strip()
if not text:
return "empty"
first = text.splitlines()[0]
return first if first in ALLOWED else "other"
def test_process_contract():
failures = []
for case in CASES:
proc = subprocess.run(
[sys.executable, "cli_messy.py", *case["args"]],
capture_output=True,
)
got = {
"id": case["id"],
"exit": proc.returncode,
"stdout_sha16": hashlib.sha256(proc.stdout).hexdigest()[:16],
"stderr_class": classify_stderr(proc.stderr),
}
exp = EXPECTED[case["id"]]
if got != exp:
failures.append({"got": got, "exp": exp})
assert failures == [], failures
Run the recorder once before any source edit.
python record_contract.py
python -m pytest test_contract.py -q
If this run is red, stop the refactor.
Your recorder and your tests already disagree.
Fix the harness. Do not extract argparse yet.
Step 5: Extract one parse concern
The allowed change is --limit integer parsing.
Leave quiet mode and strict mode inside main.
Leave unknown-flag handling on parse_known_args.
# labeled extract — still a constructed example
def parse_limit(raw: str):
try:
return 0, int(raw)
except ValueError:
return 2, None
Wire that helper at one call site only.
code, limit = parse_limit(args.limit)
if code != 0:
sys.stderr.write("bad-limit\n")
return code
Do not rename flags during this pass.
Do not add help text during this pass.
Do not replace parse_known_args during this pass.
Those edits change user-visible process behavior.
They need new rows, not a reused contract file.
This pass isolates integer parsing and nothing else.
Step 6: Replay the same rows
python -m pytest test_contract.py -q
Green means the process contract still holds.
Red means the extract leaked into user results.
Revert, then shrink the helper until rows match.
If stdout hashes drift, inspect the raw bytes.
Trailing newlines are a common silent contract break.
Do not rewrite contract_table.json to hide drift.
Decision table
| Fixture id | Expected exit | stderr class | Gate after extract |
|---|---|---|---|
| ok_short | 0 | empty | stdout hash unchanged |
| ok_quiet | 0 | empty | stdout stays empty |
| blank | 0 | empty | newline policy unchanged |
| missing | 2 | missing-input | class unchanged |
| bad_limit | 2 | bad-limit | helper returns 2 |
| unknown | 2 | unknown-flag | argparse path untouched |
| nul | 1 | nul-byte | strict check stays in main |
Read the last column as a gate, not a wish.
If a row needs a new stderr class, scope grew.
Open a different table for that later change.
Optional remote loop
Local pytest is enough for this method.
Remote compute is optional. It is not an oracle.
The committed table remains the authority either way.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
MonkeyCode offers free model access and a free server option.
You can place the messy file and frozen table there.
Ask only for the --limit extract, then paste locally.
Re-run test_contract.py on your machine before keeping it.
Do not upload secrets inside fixture files.
Do not treat model prose as a passing test.
If the remote loop is down, the local table still works.
Limitations
This oracle ignores wall clock and memory use.
It ignores logs written to sidecar files.
It ignores environment variables you did not pass.
stderr classification collapses unknown text on purpose.
A useful traceback can become class other.
Tighten the classifier only after adding explicit rows.
Process tests are slower than in-process unit tests.
Keep the corpus small, local, and deterministic.
Do not characterize a live network CLI this way.
Hashing stdout is brittle when clocks are printed.
Strip timestamps before hashing, or skip those tools.
If clocks cannot be stripped, this method does not apply.
Who should skip this
Skip this if a stable CLI suite already exists.
Skip this if the tool still has no users.
Greenfield parsers can use ordinary unit tests.
Skip this if you cannot execute the binary.
Skip this if fixtures contain production records.
Skip this if the flag set must change in this commit.
A contract table protects behavior you intend to keep.
It is the wrong tool for an intentional flag break.
Version that break. Do not hide it inside an extract.
Close the loop
Keep the table after the --limit extract lands.
The next concern might be the NUL check alone.
It is not a blob rewrite of argparse in one diff.
Narrow diffs fail on one obvious table row.
Wide diffs fail everywhere and teach nothing.
Replay the same three fields until the row is boring.
Top comments (0)