DEV Community

Dakota Huang
Dakota Huang

Posted on

A Two-Commit Gate for a Messy Batch Module

A messy module should change only after today's outputs are frozen. A coding model may draft that freeze in a scratch repo. It should not ship a wide rewrite in the same commit.

Core conclusion

Land characterization tests before any production edit lands. Extract one pure function in the next commit. Reject the patch when production lines outgrow the test diff.

Why this gate exists

Wide cleanups mix renames with real behavior changes. A quiet print tweak can break downstream parsers. Review time then goes to style, not to locked outputs.

Side effects hide inside helper functions that look pure. Directory lists, file writes, and clocks are observations. Each one needs a golden assertion before a move.

Proposed fixture

The module below is an unexecuted proposal for a scratch repo. It is not a log from a real service. Paste it only where you can delete the tree.

# proposal: batch_roll.py — unexecuted example
import json
import os
import time
from pathlib import Path

def run(src, dst, now=None):
    now = now or time.time()
    rows = []
    for name in sorted(os.listdir(src)):
        path = Path(src) / name
        if path.suffix != ".json":
            continue
        data = json.loads(path.read_text())
        status = "late" if data.get("due", now) < now else "open"
        rows.append({"id": data["id"], "status": status, "name": name})
        out = Path(dst) / (data["id"] + ".out")
        out.write_text(status + "\n")
    print(len(rows))
    return rows
Enter fullscreen mode Exit fullscreen mode

Four jobs currently share this one function in the sample. It filters names, classifies due times, writes files, and prints a count. That mix is the reason the gate exists.

Observations to freeze

Freeze public outputs rather than private variable names. Local names can change without changing external behavior. Bytes, exit paths, and prints cannot move silently.

Non-JSON files must never create an output file. A due value below now yields late, and every other due stays open.

Each id writes an out file containing the status and a newline. The printed integer must equal the number of accepted rows.

Missing keys should stay as today's crash until a later bugfix. Do not improve KeyError handling during an extract commit. A new error type is a behavior change.

Side-effect map

Map each side effect to the test that locks it. Unmapped side effects stay out of scope for this extract. Add a new row before you move that line.

Side effect Lock in the test Extract commit
print(len(rows)) captured stdout is 2\n not allowed
write_text(status) a.out and b.out bytes not allowed
suffix filter on listdir notes.out is absent not allowed
due compared with now row status fields allowed via classify
time.time() default test passes now=200 not allowed

Review table

Apply this table before you merge any model patch. Read each row as a hard reject rule for this pass.

Patch shape Tests already green Production edit Decision
Tests only this commit none land
Tests plus comments this commit comments only reject
One pure extract previous commit smaller than test diff review
Extract plus print change any any reject
Extract plus path change any any reject
Clock read moved into helper any any reject
Assertions edited to match model any any reject

This table is a review rule for humans. It is not a model score and not a timing result. No pass rate is claimed for any vendor.

1. Create a disposable tree

Work only inside a directory you can delete. Do not point these commands at a shared checkout. The steps are a proposed workflow, not a recorded run.

mkdir -p scratch_gate/src scratch_gate/tests
cd scratch_gate
python -m venv .venv
. .venv/bin/activate
python -m pip install pytest
Enter fullscreen mode Exit fullscreen mode

Keep the virtualenv inside the disposable scratch tree. That choice avoids touching a global interpreter install. Commit neither the virtualenv nor real customer files.

2. Plant inputs with a pinned clock

Pin now at 200 so status does not depend on the wall clock. File a is late because its due value is below two hundred. File b stays open because 300 is not less than 200.

mkdir -p src
printf '%s\n' '{"id":"a","due":100}' > src/a.json
printf '%s\n' '{"id":"b","due":300}' > src/b.json
printf '%s\n' 'not-json' > src/notes.txt
Enter fullscreen mode Exit fullscreen mode

Sorted names matter because listdir order is not your contract. The test should assert the returned list order explicitly. If sort order is accidental, say so in the test name.

3. Add the characterization test

Add one test that locks files, rows, and the printed count. Keep the test free of network calls and of sleeps. Label the file as a proposal until you execute it.

# proposal: tests/test_batch_roll.py — unexecuted example
import batch_roll

def test_freeze_files_status_and_print(tmp_path, capsys):
    src = tmp_path / "src"
    dst = tmp_path / "dst"
    src.mkdir()
    dst.mkdir()
    (src / "a.json").write_text('{"id":"a","due":100}\n')
    (src / "b.json").write_text('{"id":"b","due":300}\n')
    (src / "notes.txt").write_text("not-json\n")

    rows = batch_roll.run(src, dst, now=200)
    printed = capsys.readouterr().out

    assert rows == [
        {"id": "a", "status": "late", "name": "a.json"},
        {"id": "b", "status": "open", "name": "b.json"},
    ]
    assert (dst / "a.out").read_text() == "late\n"
    assert (dst / "b.out").read_text() == "open\n"
    assert not (dst / "notes.out").exists()
    assert printed == "2\n"
Enter fullscreen mode Exit fullscreen mode

The test uses tmp_path so writes stay inside pytest. It passes now so the clock is not read. It captures stdout so a print change fails.

Place batch_roll.py at the scratch repo root before pytest. Pytest imports that module from the current working directory. A package layout needs pythonpath, which this scratch tree skips.

4. Commit tests with a clean production tree

Run the new test before you invite a wider edit. The first commit should contain tests and fixtures only. A production diff in this commit fails the gate.

python -m pytest -q tests/test_batch_roll.py
git add tests/test_batch_roll.py
git commit -m "test: freeze batch_roll outputs"
git show --stat --name-only HEAD
Enter fullscreen mode Exit fullscreen mode

If a model already edited batch_roll.py, restore that file. Then commit the characterization test without that file. Do not squash these two intentions into one later.

git restore --source=HEAD -- batch_roll.py
Enter fullscreen mode Exit fullscreen mode

git restore needs a committed baseline for that path. If the file is still untracked, leave it untouched. The point of this step is a test-only commit.

5. Allow one pure extract

The smallest safe change is a classifier with two arguments. It returns late or open and performs no I/O. The caller still owns prints, writes, and directory reads.

# proposal: smallest extract — unexecuted
def classify(due, now):
    if due < now:
        return "late"
    return "open"
Enter fullscreen mode Exit fullscreen mode

Replace only the inline status expression with a call. Pass the same now value the test already pins. Do not add a default clock read inside classify.

# proposal: call site — unexecuted
status = classify(data.get("due", now), now)
Enter fullscreen mode Exit fullscreen mode

That one line is the whole production behavior change. If the patch adds helpers for paths, reject it. Path policy belongs in a later gated commit.

6. Score the second diff

Re-run the same test file with no assertion edits. Then compare production line changes with the test commit size. Accept only when production stays smaller and tests stay identical.

python -m pytest -q tests/test_batch_roll.py
git diff --numstat -- batch_roll.py
git diff --numstat -- tests/test_batch_roll.py
Enter fullscreen mode Exit fullscreen mode

A zero on the test numstat means assertions did not move. A non-zero test numstat means the model rewrote the freeze. Revert the production file and keep the original test.

# proposal: score_gate.py — unexecuted checker
def allow_extract(prod_lines, prior_test_lines, assertions_changed, test_lines_now):
    if assertions_changed or test_lines_now != 0:
        return False
    if prod_lines <= 0:
        return False
    return prod_lines < prior_test_lines
Enter fullscreen mode Exit fullscreen mode

The checker compares production lines with the prior test commit. A larger production diff fails even when pytest is green. Run the function on your numstat values before you merge.

An illustrative split is thirty test lines and eight production lines. That ratio is an example budget, not a measured result. Replace the numbers with your own git numstat output.

7. Failure cases to reject

A patch that formats JSON while extracting classify fails the gate. Formatting is a second behavior, even if tests still pass. Add a byte-level fixture before you allow a formatter.

A patch that swaps print for a logger fails the gate. The frozen stream is part of the contract for this pass. Logging policy can be its own later commit.

A patch that catches KeyError fails the gate. Today's crash is frozen until you schedule a bugfix. Write the new exception test in a separate commit first.

Where free model access fits

The gate stands if you draft every line yourself. A coding model is optional labor, not the source of truth. You still own the commit split and the revert.

Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode offers free model access and a free server option. Those are the only product facts used here.

This article states no model name, quota, hardware shape, or time limit. Use the free model access to draft the test and the one extract. Use the free server option to run the draft off a work laptop.

This article claims no isolation guarantee for that server. Treat the server as convenience, not as a security boundary. Do not upload secrets just because the option is free.

Paste the proposal function, not live customer files. Keep tokens, keys, and private paths out of the prompt. Run pytest only on fixtures you just created locally.

If that free access is already available, try this split. Use a scratch tree and wait for the review table. Keep the merge until the table says review or land.

Limits

Golden tests preserve bugs as well as features. A wrong late label becomes the new contract. Schedule behavior fixes in a later commit with a stated reason.

tmp_path does not match production permissions or mounts. A green local run can still fail on a read-only volume. Copy one real directory into a sandbox before release.

Pinned integers hide string dates and time zones. If real files store due as text, this fixture is too kind. Capture one redacted real shape before you trust classify.

No runtime numbers are reported because this draft was not executed. Your machine, your pytest, and your data will differ. Treat every command as a template to run locally.

Who should skip it

Skip the gate when the script has no stable output yet. Exploratory notebooks need a written spec before golden files. Freezing that churn would only lock noise in place.

Skip it when the task is an intentional behavior change. Put the new expected bytes in their own commit first. Then change production to match that new freeze.

Skip it during an active security incident that must change failures now. A locked bug is the wrong priority in that window. Patch the incident first, then rebuild the freeze later.

Skip it if you lack a disposable copy of the repo. Models can widen a diff faster than you can read it. Use a disposable clone or do not start.

Close

Freeze files, status, and prints in one commit. Extract one pure function in the next commit. Reject every patch that moves a second side effect.

Top comments (0)