Mixed inventory CLIs break after a tidy extract. Pin extension totals and sort keys first. Then move one pure classifier function only.
The failure mode
A report script often walks, classifies, and prints together. Most tests check only the process exit code. The printed table still drifts after small refactors. Unknown extensions jump buckets after a helper move.
Empty files also vanish from size totals silently. Locale-aware sorting reorders rows without a comment. You need frozen bytes, not a prettier function.
Four observables to freeze
This workflow pins four observables before any extract. Skip this order and the extract will lie.
- Record frozen extension totals as integer maps now.
- Count empty, tiny, and large size buckets.
- Sort every table row by a stable key.
- Keep exit codes for clean and dirty trees.
The original artifact is a tiny Python CLI. Characterization tests compare stdout, stderr, and returncode together. Later structure may change during a small extract. Those four observables must stay byte stable afterward.
Proposed fixture tree
Treat this tree as a proposed local fixture. It is not production telemetry from a fleet.
fixtures/inventory_tree/
README.md
app.py
app.py.bak
data/empty.csv
data/notes.TXT
data/photo.jpeg
build/out.o
.hidden.env
Create it with a short shell sequence. Pin file sizes, not only the names.
mkdir -p fixtures/inventory_tree/data fixtures/inventory_tree/build
printf 'hello\n' > fixtures/inventory_tree/README.md
printf 'print(1)\n' > fixtures/inventory_tree/app.py
printf 'print(1)\n' > fixtures/inventory_tree/app.py.bak
: > fixtures/inventory_tree/data/empty.csv
printf 'note\n' > fixtures/inventory_tree/data/notes.TXT
printf 'JPEG' > fixtures/inventory_tree/data/photo.jpeg
printf 'obj\n' > fixtures/inventory_tree/build/out.o
printf 'X=1\n' > fixtures/inventory_tree/.hidden.env
Record sizes with a deterministic command after creation. Do not eyeball the histogram later by hand.
python - <<'PY'
from pathlib import Path
root = Path('fixtures/inventory_tree')
for p in sorted(root.rglob('*')):
if p.is_file():
print(f"{p.relative_to(root)} {p.stat().st_size}")
PY
Why file counts are not the spec
Green file counts still hide silent bucket drift. A backup suffix can leave the table entirely. The count stays constant while the totals move.
Sort keys hide inside the printed rows. Two equal counts can swap table lines. Golden stdout catches that row swap immediately. A Counter equality test may not catch it.
Why walk order is not enough here
This script sorts rows after the walk. Walk order can still change hashes elsewhere though. It should not change this report output. If goldens flip, your sort key is incomplete.
Include hidden files in the fixture on purpose. Exclude rules belong in a later extract only. Do not mix ignore policy with size classification.
Fixture sizes must match the constants
The proposed fixture files are small on purpose. Eight bytes is the tiny ceiling in this script. A larger corpus would hide the overlap quickly.
Measure sizes after printf, not before writes. Newline characters can change the size bucket. Windows line endings can move a file across buckets.
Pin the platform or strip newlines in fixtures. Characterization tests inherit that fixture choice fully. Document it in the golden commit message clearly.
Step 1: Capture the messy baseline
The following script is a proposed characterization target. Keep classification, bucketing, and printing in one file.
Resist the urge to extract helpers now. A premature helper often changes unknown-extension policy silently.
# inventory_report.py — proposed messy baseline
from __future__ import annotations
import sys
from collections import defaultdict
from pathlib import Path
TINY_MAX = 8
LARGE_MIN = 8
def main(argv: list[str]) -> int:
if len(argv) != 2:
sys.stderr.write("usage: inventory_report.py ROOT\n")
return 2
root = Path(argv[1])
if not root.is_dir():
sys.stderr.write("root missing\n")
return 2
ext_totals: dict[str, int] = defaultdict(int)
buckets = {"empty": 0, "tiny": 0, "large": 0}
rows: list[tuple[str, str, int]] = []
for path in root.rglob("*"):
if not path.is_file():
continue
rel = path.relative_to(root).as_posix()
size = path.stat().st_size
ext = path.suffix.lower() or "<none>"
if ext == ".bak":
ext = "<backup>"
ext_totals[ext] += size
if size == 0:
buckets["empty"] += 1
elif size <= TINY_MAX:
buckets["tiny"] += 1
else:
buckets["large"] += 1
rows.append((ext, rel, size))
rows.sort(key=lambda item: (item[0], item[1]))
sys.stdout.write("ext\tpath\tsize\n")
for ext, rel, size in rows:
sys.stdout.write(f"{ext}\t{rel}\t{size}\n")
sys.stdout.write("TOTALS\n")
for ext in sorted(ext_totals):
sys.stdout.write(f"{ext}\t{ext_totals[ext]}\n")
sys.stdout.write("BUCKETS\n")
for name in ("empty", "tiny", "large"):
sys.stdout.write(f"{name}\t{buckets[name]}\n")
return 1 if buckets["empty"] else 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))
Notice bak files map to a synthetic backup bucket. Hidden files still enter the walk on purpose. The notes.TXT name lowercases to a txt suffix. Those three rules are easy to lose.
Step 2: Write characterization tests first
Do not assert pretty object graphs yet here. Capture returncode, stdout, and stderr without parsing. Store golden files beside the test module.
# test_inventory_report_char.py — proposed characterization harness
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent
SCRIPT = ROOT / "inventory_report.py"
FIXTURE = ROOT / "fixtures" / "inventory_tree"
GOLDEN = ROOT / "goldens" / "inventory_tree.stdout"
STDERR_GOLDEN = ROOT / "goldens" / "inventory_tree.stderr"
def run(root: Path) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[sys.executable, str(SCRIPT), str(root)],
text=True,
capture_output=True,
check=False,
)
def test_usage_exit_code() -> None:
proc = subprocess.run(
[sys.executable, str(SCRIPT)],
text=True,
capture_output=True,
check=False,
)
assert proc.returncode == 2
assert proc.stderr == "usage: inventory_report.py ROOT\n"
assert proc.stdout == ""
def test_fixture_matches_golden() -> None:
proc = run(FIXTURE)
assert proc.returncode == 1
assert proc.stderr == STDERR_GOLDEN.read_text(encoding="utf-8")
assert proc.stdout == GOLDEN.read_text(encoding="utf-8")
Generate goldens once from the messy script. Commit those goldens before any function extract.
mkdir -p goldens
python inventory_report.py fixtures/inventory_tree > goldens/inventory_tree.stdout
python inventory_report.py fixtures/inventory_tree 2> goldens/inventory_tree.stderr
python -m pytest test_inventory_report_char.py -q
If pytest is missing, use unittest instead today. The assertion target stays the same either way. Do not regenerate goldens after a failed extract.
Step 3: Freeze sort keys and buckets
Build a decision table before touching any helpers. Each row is a contract, not a vibe.
| Input | Rule | Observable |
|---|---|---|
suffix .TXT
|
lowercase before grouping |
.txt total includes it |
suffix .bak
|
map to <backup>
|
not listed as .bak
|
| suffix missing | use <none>
|
README.md groups there |
size 0
|
empty bucket plus exit 1
|
empty count rises |
size 1..8
|
tiny bucket |
tiny count rises |
size >8
|
large bucket |
large count rises |
| walk order | sort (ext, path)
|
table rows stay stable |
| hidden file | include in the walk |
.hidden.env listed |
| missing root | stderr plus exit 2
|
stdout stays empty |
The TINY_MAX and LARGE_MIN constants currently match. That numeric overlap is a silent extract landmine. Characterization tests should lock the present behavior exactly. Do not fix the overlap during this extract.
A second test should pin the overlap on purpose. Label that test as a proposed pin. Wire it only after the function exists.
# Proposed pin after classify_size exists.
# assert classify_size(8) == "tiny"
# assert classify_size(9) == "large"
Step 4: Extract one classifier only
Keep walking and printing inside the main function. Move size classification alone in this diff.
Leave extension mapping in the script for now. Allow only one behavior change per diff.
def classify_size(size: int) -> str:
if size == 0:
return "empty"
if size <= TINY_MAX:
return "tiny"
return "large"
Then replace the inline if and elif chain. Re-run the golden tests immediately after that.
If stdout drifts, revert the extract at once. Do not stack an extension-mapping extract in the same diff.
bucket = classify_size(size)
buckets[bucket] += 1
That extract is the smallest safe change. Extension policy stays in the loop for now.
Rendering stays in the loop as well. Sort keys stay in the main function.
Step 5: Re-run the same goldens
Use the same commands after the extract. Any extra row means the extract leaked.
python -m pytest test_inventory_report_char.py -q
diff -u goldens/inventory_tree.stdout <(python inventory_report.py fixtures/inventory_tree)
Add a negative fixture in the next test. Point the CLI at a missing path. Confirm stderr still matches the missing root text.
Confirm that stdout stays completely empty here. Missing roots must not print a table.
def test_missing_root() -> None:
proc = run(ROOT / "fixtures" / "nope")
assert proc.returncode == 2
assert proc.stdout == ""
assert proc.stderr == "root missing\n"
Only then consider extracting a normalize_ext helper. Repeat the same golden command after that extract. Never extract renderer and classifier together now.
Where a free model session fits
A free coding model can draft the first test file. Disclosure: This article was prepared as part of MonkeyCode's product outreach.
MonkeyCode offers free model access and a free server option. Use that session only to draft characterization tests. Keep every golden file in your own repository.
Paste the messy script and the decision table. Ask for tests that lock stdout bytes.
Reject any patch that rewrites bucket policy. The server session does not replace local goldens.
Limitations
This method assumes a deterministic filesystem walk only. Network inventory jobs do not belong in this method.
Clock-stamped reports also break these byte goldens. Symlink cycles need a separate pin first here.
Binary files with unstable metadata need size-only checks. Do not golden entire encoded dumps blindly ever.
The overlap between tiny and large is frozen, not blessed. A later product change needs a new golden on purpose.
Do not hide that change inside a rename. Call it a policy change in the diff. Update goldens in that same commit.
Characterization tests do not prove real design quality. They only prove you did not change behavior. Reviewers still need to read the extract diff.
Who should skip this approach
Skip this if the CLI has no stable output contract. Skip this if stakeholders want a new histogram now. Skip this if the tree mutates during the test run.
Do not use byte goldens for shuffled JSON objects. Do not use them for wall-clock duration fields. Do not use them as a substitute for fuzzing parsers.
Teams shipping a public report schema need schema tests too. This workflow is for messy internal scripts only. It is a brake, not a product vision.
Practical close
Freeze extension totals before you extract a classifier. Freeze sort keys before you touch rendering.
Change only one pure function per diff. Keep goldens committed beside the messy script always.
Let a draft session write tests, not policy. The inventory output is the spec until you replace it.
If you try this on another report CLI, start with returncode. Then lock stdout before any helper extract. Extract only after both locks stay green.
Top comments (0)