Orchestrata Ultimate › Plans › dispatch-decomposition-design
_dispatch() decomposition — design of record
Source: plans/dispatch-decomposition-design.md at 8a2cb42ad · last changed 2026-08-07 · first committed 2026-08-07 · 176 lines.
Prose pending.
Plan text
_dispatch() decomposition — design of record
Status: APPLIED 2026-08-07. See §Execution result at the end. Driver: 109 of the repo's 237 mypy errors, all inside one function. Ruling: RUL-022 (design before build), RUL-013 (behaviour from spec//contract, not guesswork).
1. The defect, measured
scripts/serialize_codebase_ast.py::_dispatch() spans lines 10681–12526
(1,846 lines) and contains a 46-branch if/elif chain on
args.command, from line 11370 to the end.
Every branch binds its outcome to the same local name, result:
| line | assignment | inferred type |
|---|---|---|
| 11384 | result = analyzer.analyze() |
CodebaseAST ← first, so mypy pins this |
| 11470 | result = deep_compare(ast_a, ast_b, mapping_data) |
dict → [assignment] |
| 11599 | result = rename_symbol(...) |
dict → [assignment] |
| 11675 | result = ast_to_kg(...) |
dict → [assignment] |
| … | 14 more | dict → [assignment] |
mypy narrows a local to its first assignment in the function body. Because
the 46 branches are mutually exclusive but share one scope, every later
result = <dict> is an [assignment] error, and every subsequent
result["summary"] / result.get(...) cascades into
Value of type "CodebaseAST" is not indexable [index].
Measured breakdown of the 109:
- 83 ×
[index] - 18 ×
[assignment](variable has type "CodebaseAST") - 8 × the remainder (
[attr-defined]"CodebaseAST" has no attribute "get", etc.)
This is one defect, not 109. No individual annotation is wrong; the scope is.
2. Why not the cheap fixes
result: Any— deletes the contract to silence the checker. Forbidden by the same reasoning as# type: ignore.- Rename per branch (
compare_result,rename_result, …) — satisfies mypy and is behaviour-identical, but leaves a 1,846-line function. It treats the symptom (name collision) and keeps the cause (one scope for 46 commands). cast()at each use — 83 casts to hide one structural problem.
3. The change
Each branch becomes a module-level handler, and the chain becomes a table.
def _cmd_deep_compare(args: argparse.Namespace) -> None:
...body moved verbatim from the branch...
_COMMANDS: dict[str, Callable[[argparse.Namespace], None]] = {
"serialize": _cmd_serialize,
"compare": _cmd_compare,
"deep-compare": _cmd_deep_compare,
...
}
def _dispatch() -> None:
args = _build_parser().parse_args()
if hasattr(args, "cache"): # shared pre-chain setup, stays here
...
handler = _COMMANDS.get(args.command)
if handler is None:
...existing unknown-command behaviour...
handler(args)
Each handler gets its own scope, so each result is independent and typed by
what that command actually produces. The 109 errors go to 0 because the cause
is gone, not because the checker was told to stop looking.
Secondary: _dispatch also contains the parser construction (10681–11369,
~686 lines) ahead of the chain. That splits out as _build_parser(), which is
what makes the remaining _dispatch readable at a glance.
4. Constraints
- Bodies move verbatim. Re-indentation only. Any change of a statement during the move is a separate, separately-justified edit.
- Pre-chain shared state stays in
_dispatch— notably thehasattr(args, "cache")block at 11367-11368. Anything a branch reads that is computed before the chain must be passed as a parameter, not recomputed. sys.exit/returnsemantics preserved. A branch that returns early must produce the same process exit code through the handler boundary.- This file is the project's core tooling.
gates.py, the codebase map,code_intel, and the session hooks all shell out to it. A regression here is not contained.
5. Verification
Batched, ~5 branches at a time; after each batch:
uv run mypy --cache-dir <own> src/ scripts/serialize_codebase_ast.py— error count strictly decreasing, no new error anywhere.- The toolkit's own suite, which is the real safety net (~30 modules):
tests/scripts/test_ast_main_cli.py(the dispatcher itself),test_ast_core_compare.py,test_check_deps.py,test_ast_refactor.py,test_ast_source.py,test_ast_check_traces.py,test_ast_trace_audit.py, and thetest_ast_gen_*.pyfamily. - Behavioural spot-check per extracted command — run the real subcommand and diff its output against the pre-change output, byte for byte.
Final gate: python scripts/gates.py (10/10) plus the full suite, since the
toolkit is what several gates invoke.
6. Sequencing
Blocked until src/ is quiescent. Five agents are concurrently fixing the
other 128 mypy errors across 34 src/ files; a test run against a
half-edited src/ tree produces failures that belong to neither change.
7. Execution result (2026-08-07)
Applied in one deterministic AST-driven pass rather than the planned five-branch batches. The batching in §5 existed to bound risk from hand-editing 1,846 lines; a mechanical transform removes that risk class entirely, so the stronger guarantee replaced the weaker procedure.
What shipped
| before | after |
|---|---|
_dispatch() — 1,846 lines, 45-branch elif chain |
_build_parser() (686 lines) + 45 _cmd_<name>(args) functions + _COMMANDS table + a 12-line _dispatch() |
_dispatch() is now:
def _dispatch() -> None:
"""Parse argv and run the selected subcommand."""
parser = _build_parser()
args = parser.parse_args()
if hasattr(args, "cache"):
configure_parse_cache(args.cache, enabled=not args.no_cache)
handler = _COMMANDS.get(args.command)
if handler is None:
parser.print_help()
sys.exit(1)
handler(args)
Only three names crossed the old function boundary. A static read of the
pre-chain scope found 48 locals, of which the chain loads exactly args, mv
and parser — and mv is a false positive (the chain's mv is a for target
inside the rename branch, unrelated to the mv = sub.add_parser(...) above
it). So args became each handler's parameter, parser stayed in _dispatch
for the print_help() fallback, and nothing else needed threading through.
Verbatim proof, not a spot-check. §5's step 3 asked for byte-diffing each subcommand's output. The transform admits a stronger and exhaustive check: every extracted body was compared to its original by normalized AST dump.
- 45/45 branch bodies AST-identical to the originals.
_build_parser()'s body AST-identical to the original parser construction.- No statement existed after the chain, so nothing was orphaned.
Measured after
mypy src/(the exact CI lint command): 237 errors → 0, across the same 899 source files. This was the last lint blocker on the branch.ruff checkpass,black --checkpass.- AST-toolkit suite: 34/34 modules, 0 failures, 32s — including
test_ast_main_cli.py(52 tests over the dispatcher itself),test_ast_gen_skeleton.py(90),test_ast_gen_serde.py(62),test_ast_skeleton_gates.py(51),test_ast_trace_audit.py(36). scripts/gates.py --all: 10/10 PASS, 30.0s wall, 0 reused.
Note on the §1 error accounting. The 109 were reachable from mypy src/
even though they live under scripts/ — that run checks 899 files and follows
imports out of src/ into the toolkit. The pre-change 237 and the post-change 0
are therefore the same measurement, not two different ones.