A messy dispatcher is not an editing surface. It is a table of untested branches. Pin the observed table before any extraction.
Change only one case after that pin. Neighbor branches often lack any direct test.
String-keyed handlers hide control flow from ordinary search. One extracted helper can alter a neighbor branch.
This method targets finite command sets only. Infinite input spaces need another oracle. Do not stretch this tape onto free-text parsers.
Why dispatchers rot under small edits
A typical runner starts as a short chain. Years add flags, aliases, and quiet no-ops.
Call sites pass strings from checked-in configs. Most tests cover one happy path only.
Automated renames still do not prove string branches. A coding assistant will drop supposedly unused cases.
Unused remains a guess without traffic evidence. Missing keys then fail only in production.
Characterization records what HEAD does today. It does not bless the current design.
You may delete a branch later. Delete it after the table shows the gap.
The four fields that matter
Store only stable, JSON-safe facts. Four fields keep the tape small.
-
argvholds the finite command vector. -
resultholds a JSON-safe return, or null. -
exc_typeholds the exception class, or null. -
exc_msgholds the exception text, or null.
Skip stack traces in the committed table. Skip wall-clock times and object ids. Those fields churn during a clean extract.
Churn without behavior change is a false failure. False failures train people to ignore the tape.
Normalize whitespace in exception messages. Strip trailing newlines only. Keep internal punctuation intact.
Message text is a contract for many runners. Callers parse it. Silent rewording is a behavior change.
Sample messy dispatcher
The module below is a proposed example. Treat it as unexecuted sample code.
Real dispatchers are longer than this stand-in. The control-flow shape stays the same.
# dispatcher.py — proposed example
from __future__ import annotations
from typing import Any
class DispatchError(RuntimeError):
pass
def dispatch(argv: list[str]) -> dict[str, Any] | None:
if not argv:
raise DispatchError("empty argv")
cmd = argv[0]
args = argv[1:]
if cmd == "status":
return {"ok": True, "args": args}
if cmd == "count":
if len(args) != 1 or not args[0].isdigit():
raise DispatchError("count expects one integer")
n = int(args[0])
return {"ok": True, "n": n, "n2": n * n}
if cmd in {"help", "-h"}:
return {"ok": True, "text": "status|count|help|noop"}
if cmd == "noop":
return None
if cmd == "alias-status":
return dispatch(["status", *args])
raise DispatchError(f"unknown command: {cmd}")
Aliases are first-class rows in the table. Recursive cases are first-class rows too.
Empty argv is a first-class row. Omitting it makes later extracts look safe.
How to freeze CASES
Start from commands the repo already documents. Then add strings from checked-in configs.
Production logs can supply extra argv vectors. Deduplicate them before freezing CASES.
Add invalid strings you already handle. Do not add random fuzz in pass one.
Pass one must stay exhaustive and tiny. Tiny tables fail loudly. Loud failures localize the extract.
Enumerator that writes the table
Point the enumerator at that frozen argv list. Do not discover argv by fuzzing here.
Fuzzing is a later expansion, not this pass. This pass needs a known finite set.
# enumerate_branches.py — proposed example
from __future__ import annotations
import json
from pathlib import Path
from dispatcher import dispatch
CASES: list[list[str]] = [
[],
["status"],
["status", "verbose"],
["count"],
["count", "4"],
["count", "x"],
["help"],
["-h"],
["noop"],
["alias-status", "x"],
["unknown"],
["UNKNOWN"],
]
def canon_msg(msg: str) -> str:
return msg.replace("\\", "/").strip()
def row_for(argv: list[str]) -> dict:
result = None
exc_type = None
exc_msg = None
try:
result = dispatch(list(argv))
except Exception as exc:
exc_type = type(exc).__name__
exc_msg = canon_msg(str(exc))
return {
"argv": argv,
"exc_msg": exc_msg,
"exc_type": exc_type,
"result": result,
}
def main() -> None:
table = [row_for(argv) for argv in CASES]
Path("branch_table.json").write_text(
json.dumps(table, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
if __name__ == "__main__":
main()
Run it once against current HEAD. Commit branch_table.json beside the module.
That commit is the oracle for later diffs. Each drifted field then needs an explicit explanation.
python enumerate_branches.py
git add dispatcher.py enumerate_branches.py branch_table.json
git commit -m "pin dispatcher branch table before extract"
Do not mix code edits into that commit. Mixed commits destroy the later blame trail.
Test that fails on silent drift
The test loads the committed table. It reruns each argv vector.
It compares the four fields exactly. Any mismatch is a failed characterization.
# test_branch_table.py — proposed example
import json
from pathlib import Path
from enumerate_branches import CASES, row_for
def test_dispatcher_matches_committed_table() -> None:
committed = json.loads(
Path("branch_table.json").read_text(encoding="utf-8")
)
observed = [row_for(argv) for argv in CASES]
assert observed == committed
Run the test before the first edit. Run the test after the extract.
Green-to-green is the only allowed path. A red table means the extract is too large.
Numbered workflow
Follow this order. Do not skip the freeze commit.
- List every command string the dispatcher accepts.
- Add aliases that production configs still send.
- Include known invalid strings that must raise.
- Run the enumerator on a clean checkout.
- Commit
branch_table.jsonwith no code edits. - Extract one case into a named helper.
- Keep the dispatch function as the public seam.
- Rerun the characterization test.
- Stop if any row drifts.
- Open a second change only after green.
The smallest safe change is one extracted case. Two cases form a second change.
Bundle them and drift becomes unlocalizable. Unlocalizable drift gets force-matched into the JSON.
The extract itself
Move one branch. Leave every other branch untouched.
Do not rename payload keys in the same patch. Key renames are product changes, not extracts.
def _count(args: list[str]) -> dict[str, int | bool]:
if len(args) != 1 or not args[0].isdigit():
raise DispatchError("count expects one integer")
n = int(args[0])
return {"ok": True, "n": n, "n2": n * n}
def dispatch(argv: list[str]) -> dict[str, Any] | None:
if not argv:
raise DispatchError("empty argv")
cmd = argv[0]
args = argv[1:]
if cmd == "status":
return {"ok": True, "args": args}
if cmd == "count":
return _count(args)
if cmd in {"help", "-h"}:
return {"ok": True, "text": "status|count|help|noop"}
if cmd == "noop":
return None
if cmd == "alias-status":
return dispatch(["status", *args])
raise DispatchError(f"unknown command: {cmd}")
_count is an implementation detail after the move. Callers still enter through dispatch.
The table still keys on argv, not helper names. That split is the point of the pin.
Where a coding assistant fits
A model can propose the helper. It cannot invent the oracle.
Write the table first. Then ask for a one-case extract.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
MonkeyCode provides free model access and a free server option. Run the enumerator on that server against the checkout. Keep branch_table.json out of the model's write set.
The model drafts _count. The table judges the draft.
Do not prompt for unused-branch deletion. Unused is not proven by this tape. Absence from CASES is not absence from production.
Decision table: use this oracle or skip it
| Situation | Use the branch table | Reason |
|---|---|---|
| Finite command strings | Yes | Rows can be exhaustive |
| Arbitrary user text | No | Domain is not finite |
| Handlers that call the network | Not yet | Fake the IO seam first |
| Time-based branches | Only with a clock seam | Wall clock is unstable |
| Random ids inside payloads | No, unless seeded | Ids churn the JSON |
If a cell says no, pick another oracle. Do not weaken the comparator instead.
Weak comparators hide the defect under review. Hidden defects reappear after the next extract.
Limitations
This tape does not prove correctness. It proves stability of listed rows.
Unlisted argv can still change under you. Production can send unlisted argv tomorrow.
Shared mutable state inside handlers can leak. Two rows may pass in isolation.
The same rows may fail in sequence. Restart the process per row if leaks appear.
Exception messages can include filesystem paths. Paths differ across developer machines.
Normalize slashes before storing the row. Otherwise CI fails on directory layout.
JSON drops tuple versus list distinctions. It also drops custom class instances.
Convert results to a canonical form first. Do not dump live ORM objects into the table.
Concurrency is out of scope here. A threaded dispatcher needs another harness. This article does not provide that harness.
Who should not use this
Skip this method if commands are not enumerable. Skip it if every call hits a live billing API.
Skip it if time and randomness cannot be seeded. Skip it if unit tests already pin every branch.
Do not treat the table as a freeze on bad names. After the extract is green, add intent tests.
Characterization is a bridge. It is not the destination.
Row count is not logical coverage. Nested branches inside one case remain unpinned.
Add unit tests for those nests after the extract. Do not stop at the table length.
What smallest means in practice
Smallest means one observable case. It does not mean one physical line.
A case may move twenty lines. The public seam stays dispatch.
Do not reformat JSON in the same patch. Do not retouch logging in the same patch.
Extra edits mix signal with noise. Mixed patches hide the drifting row.
If the test fails, revert the extract. Do not edit the table to match new behavior.
Table edits are product decisions. They need a separate review.
Dispatchers look like private implementation details. They are user-facing tables of strings.
Lock the four fields. Extract one case. Recheck the committed JSON.
Behavior diffs then stay local to one row. That locality is the safety property this workflow buys.
Top comments (0)