Orchestrata Ultimate › Plans › bodymatcher-split-plan
BodyMatcher split plan (cohesion-driven, behavior-preserving)
Source: plans/bodymatcher-split-plan.md at 8a2cb42ad · last changed 2026-07-27 · first committed 2026-07-09 · 205 lines.
Prose pending.
Plan text
BodyMatcher split plan (cohesion-driven, behavior-preserving)
Status: EXECUTED 2026-07-10 (commits 5c7b74f6.. on master), with two
mid-flight user rulings that superseded parts of this plan:
- "Why not AST?" →
InheritanceMapsources the serializer's owninheritance_graph(resolved bases) instead of re-walkingClassInfo.bases; the walk survives only as the fallback for synthetic codebases. Byte-identical on all gates. - "Separate the AST generation into its own component" + "no more
writing inheritance checks, just ast — enhance that if it needs it" →
the generation core (models, CodebaseAnalyzer, serialize) was extracted
from scripts/serialize_codebase_ast.py into the new stdlib-only
src/orchestrata_ast/, andInheritanceMapmoved INTO that component (orchestrata_ast.inheritance) instead of living in orchestrata_tools as §2 below planned. The script re-exports every moved name; serialization output is byte-identical.
Outcome vs targets: body_matcher.py 542→360 lines; worst function
complexity 35→23 (_parse_file, target ≤15 NOT reached — the remaining
bulk is file-discovery/orchestration; acceptable residual). All
equivalence gates (find_matches rows, direct index, writer dry-run,
serialize JSON) byte-identical at every step. Follow-up debt recorded:
scripts/serialize_codebase_ast.py carries 195 pre-existing ERROR-level
mypy findings (was 282 before extraction) — separate cleanup.
The original plan below is retained for the record.
Why (measured, not vibes)
src/orchestrata_tools/test_analyzer/body_matcher.py — 542 lines, one class,
18 methods, four distinct responsibilities. Findings that motivated this:
| finding | value |
|---|---|
_parse_file cyclomatic complexity |
35 (worst in orchestrata_tools) |
_build_index complexity |
18 |
_extract_calls_and_types complexity |
17 |
_index_test parameters |
9 (three of them the same tuple[set, set, dict] shape) |
private reach-ins from matcher.py |
_test_imports (line 113), _is_subclass (line 119) |
| duplicated logic elsewhere | scripts/add_traces_from_evidence.py::_ancestor_methods re-implements the ancestor walk |
The (calls, used_imports, var_types) anonymous 3-tuple is threaded through
five signatures and unpacked as fc, fi, ft three times in _index_test
alone — that shape IS the hidden seam between the responsibilities.
Current responsibilities (the cohesion map)
- Per-file AST extraction (pure; no object state):
_parse_file's walk,_extract_calls_and_types,_get_call_name,_get_decorator_name,_get_annotation_name,_is_pytest_fixture,_get_fixture_params. - Fixture registry + resolution:
_module_fixtures, class-fixture collection in_parse_file,_find_conftest_fixture, and the merge loop in_index_test. - Inheritance knowledge:
_build_inheritance_map,_is_subclass,is_subclass_of, plus the test-file base-class collection in_parse_file. - Index + query facade:
_build_index(file discovery),_calls,_test_imports,_direct_calls,find_tests_for_method,is_tested,direct_call_index.
Target layout (3 files, 1 shared dataclass)
1. test_analyzer/call_extraction.py (~200 lines, all pure)
@dataclass(frozen=True)
class CallProfile:
"""What one function's body does: resolved call names, the imported
classes it uses, and inferred variable types."""
calls: frozenset[str]
used_imports: frozenset[str]
var_types: Mapping[str, str] # var name -> class name
def merged_with(self, other: "CallProfile") -> "CallProfile": ...
class CallExtractor:
"""Stateless per-node extraction. Everything currently in
_extract_calls_and_types + the _get_* name helpers + fixture-param
detection."""
def extract(self, node, imported_names, imported_classes,
local_classes) -> CallProfile: ...
def fixture_params(self, node) -> list[str]: ...
def is_pytest_fixture(self, node) -> bool: ...
# module-level helpers: get_call_name(), get_decorator_name(),
# get_annotation_name()
CallProfile replaces every tuple[set[str], set[str], dict[str, str]]
annotation and the fc, fi, ft unpacking. _index_test drops from 9
parameters to 4 (node, mod_path, class_name, profile-sources).
2. test_analyzer/inheritance.py (~60 lines)
class InheritanceMap:
"""class name -> ancestor names, built from a source ASTCodebase and
augmented with class defs found in test files."""
@classmethod
def from_codebase(cls, source_ast: ASTCodebase | None) -> "InheritanceMap": ...
def add(self, child: str, bases: Iterable[str]) -> None: ...
def is_subclass(self, child: str, parent: str) -> bool: ... # child==parent counts
def ancestors(self, cls_name: str) -> frozenset[str]: ...
Kills two duplications at once:
TestMatcher.is_imported's reach-in to_body_matcher._is_subclassbecomes a call on the shared map.scripts/add_traces_from_evidence.py::_ancestor_methodsswaps its hand-rolled recursive base walk forancestors()+ a class lookup.
3. test_analyzer/body_matcher.py (facade, ~250 lines)
Keeps the name, the public API, and the indexes:
__init__(test_ast, source_ast=None)— signature unchanged._build_index/_parse_filestay here but shrink to orchestration: discover files → collect imports/local classes →CallExtractor.extractper function →FixtureRegistry.register→_index_test(now: subtract decorator names, store direct profile, merge fixture profiles, fill_calls/_test_imports).FixtureRegistry(private class, ~50 lines, lives in this file): wraps_module_fixtures+_find_conftest_fixture+ the three-way class/module/conftest lookup, returningCallProfile | Nonefor a param. Not worth its own file until a second consumer exists.- Public, unchanged:
find_tests_for_method,is_tested,direct_call_index,is_subclass_of(delegates toInheritanceMap). - New public (to delete the matcher.py reach-in):
class_used_by_test(test_module, class_name) -> bool— encapsulates the_test_importsscan + subclass acceptance thatTestMatcher.is_importedcurrently does by hand.
Consumer diffs (complete list — verified by grep, no other importers)
| consumer | change |
|---|---|
test_analyzer/matcher.py |
is_imported lines 107–120: replace the _test_imports/_is_subclass reach-ins with self._body_matcher.class_used_by_test(...). No behavior change: same scan, same subclass rule. |
scripts/add_traces_from_evidence.py |
_ancestor_methods reuses InheritanceMap.ancestors(); bm.is_subclass_of call site unchanged. |
tests/test_orchestrata_tools_body_matcher.py |
unchanged (public API only). |
What does NOT change
- Class name
BodyMatcher, module path, constructor signature. - Every public method signature and return shape.
- Matching semantics: same patterns 1/1b/2/3 in
find_tests_for_method, same fixture-resolution order (class → module → conftest nearest-first), same decorator subtraction in the direct index. - No new dependencies; no changes outside
src/orchestrata_tools+ the one helper swap inscripts/add_traces_from_evidence.py.
Equivalence gates (before AND after, must be identical)
- Match-set byte equality: dump
TestMatcher(test_ast).find_matches(source_index)on the real snapshots (plans/ast-tests.json×plans/ast-ultimate.json) to a sorted JSON list of(test_module, test_name, method_fqn, strategy, confidence);diffpre vs post must be empty. - Writer dry-run equality:
python scripts/add_traces_from_evidence.pystdout must be byte-identical pre vs post (currently0 new, 0 merges, 43 already-present, across 27 files). - Direct-index equality: serialize
direct_call_index()sorted; diff empty. - Test suites green both sides:
tests/test_orchestrata_tools.py(55),tests/test_orchestrata_tools_body_matcher.py(6),tests/scripts/test_add_traces_from_evidence.py(12),tests/scripts/test_generate_rtm_evidence.py(12), plus the full-suite run before commit. - Static gates: ruff, black, pyright ratchet ≤283, untyped ≤0, code-review ERRORs = 0 on the touched files; complexity re-measured — target: no function ≥ 15 (from 35/18/17 today).
New tests owed by the split
CallProfile.merged_with(union semantics, var_types precedence = incoming overrides — matches today'sdict.updateorder).InheritanceMap: transitive ancestors, cycle tolerance (A→B→A must not recurse forever — today's_build_inheritance_mapguards via membership check; keep a regression test), test-file augmentation.class_used_by_test: direct import, subclass-of-imported, absent.
Execution order (single PR, ~6 commits)
inheritance.py+ tests; BodyMatcher delegates; matcher.py reach-in for_is_subclassreplaced. Gates.CallProfile+call_extraction.py+ tests; BodyMatcher delegates extraction; tuple threading replaced. Gates.FixtureRegistryprivate class;_index_testslims to ≤5 params. Gates.class_used_by_testpublic API + matcher.py_test_importsreach-in removed + tests. Gates.add_traces_from_evidence._ancestor_methods→InheritanceMap. Gates incl. writer dry-run equality.- Full suite + equivalence dumps + complexity re-measure; commit series.
Risks
_parse_filecomplexity-35 untangling is the only genuinely risky step; mitigation is gate #1 (match-set byte equality) which covers every semantic path the parser feeds.- Frozen
CallProfilevs today's in-place|=mutation in the fixture merge loop: merged_with allocates per fixture param. Cost is negligible (hundreds of tests × ≤4 fixtures), and immutability is what stops the aliasing bug class where a fixture's sets get mutated by a later test. - Formatter hook strips imports that are momentarily unused — apply each commit's import + usage in the same edit (known failure mode, hit twice this week).