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:

This is one defect, not 109. No individual annotation is wrong; the scope is.

2. Why not the cheap fixes

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

5. Verification

Batched, ~5 branches at a time; after each batch:

  1. uv run mypy --cache-dir <own> src/ scripts/serialize_codebase_ast.py — error count strictly decreasing, no new error anywhere.
  2. 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 the test_ast_gen_*.py family.
  3. 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.

Measured after

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.