Orchestrata Ultimate › Plans › ctok-p3-migration-map
P3 Migration Map — Replacing LibCST in the Codemod Engine
Source: plans/ctok-p3-migration-map.md at 8a2cb42ad · last changed 2026-08-04 · first committed 2026-08-04 · 252 lines.
Prose pending.
Plan text
P3 Migration Map — Replacing LibCST in the Codemod Engine
Scope: every libcst/cst.* touch-point under src/orchestrata_ultimate/refactor/codemod/
(confirmed via grep -rl "^import libcst\|^from libcst\|import libcst as cst" src/ — the
LibCST footprint in src/ is exactly these 7 files, nothing outside refactor/codemod/,
and refactor/rename/ — including EditApplier — is already 100% LibCST-free, confirming the
contract's precedent claim):
passes.py(827 lines)review.py(534 lines)_cig_builder.py(558 lines) vs_cig_builder_ast.py(409 lines, already LibCST-free)workspace.py(428 lines)txn.py(349 lines)report.py(125 lines)_parse_cache.py(97 lines)
Read-only audit. Ratified design: plans/contracts/ctok-parser.contract.json +
plans/contracts/ctok-parser.mmd. No files under scripts/_ctok/,
src/orchestrata_ultimate/refactor/ctok/, or scripts/ctok_roundtrip_gate.py were touched or
read (out of scope, owned by the parallel C-tokenizer builder track).
0. Three findings that change the shape of P3 — read before the table
Finding 1 — "40 review checks" is spec drift, not implemented reality (HIGH)
specs/110-codemod-engine/spec.md:135 titles US-110-004 "Run 40-check in-memory code review
without subprocess calls", and spec.md:1462-1471 describes a 3-phase pipeline (Phase 1
structural, Phase 2 graph-dependent, Phase 3 Prolog) implying dozens of discrete checks. The
actual code ships exactly 4 ReviewPass classes — ParseIntegrity, BannedConstructs,
DiffBudget, PrologSemanticReview (review.py:148,201,298,361) — matching
spec.md:774 ("The engine SHALL ship 4 built-in review passes"), which directly contradicts the
US-110-004 title two pages earlier in the same spec file. BannedConstructs bundles 3
sub-checks (_check_os_environ, _check_mocks, _check_banned_imports,
review.py:225,250,272) with a docstring admission it "re-hosts checks from
verification/review/checks_safety.py" — but only that one file's checks, not the 40-ish
checks spread across verification/review/checks_{correctness,quality,safety,structure}.py.
P3 has 4 review-pass classes + ~6 sub-checks to port, not 40. The migration table below is
scoped to what exists, not the spec's aspirational title. Suggested owner: whoever owns
spec-doc hygiene should also resolve the US-110-004 title/REQ-110-012 contradiction — separate
from this port.
Finding 2 — cig_backend default is silently still "cst" in production paths (HIGH)
The ctok-parser.contract.json "reuse" section asserts _cig_builder_ast.py is "already prod
default cig_backend='ast', no libcst." This is false as of the code read for this audit:
CodemodWorkspace.load(..., cig_backend: str = "cst")—workspace.py:113— the parameter defaults to"cst"(the LibCST-walking_cig_builder.pypath), not"ast".scripts/codemod.pycalls.load()at 4 call sites (lines 226, 230, 249, 359), none passcig_backend→ all 4 silently use the"cst"(LibCST) backend today.server.py:394-397's"reload"JSON-RPC method also calls.load()with nocig_backend→ the live daemon's runtime reload path also uses the LibCST backend.- Only
_daemon_entry.py(2 call sites, initial boot + inotify-triggered incremental reload) explicitly passescig_backend="ast".
Net effect: the same running daemon process uses the AST backend at boot but silently regresses
to the LibCST backend on every manual "reload" RPC — an existing internal inconsistency,
independent of P3. And the CLI (scripts/codemod.py, the primary tool entry point per
docs/CODEBASE_MAP.md) is on the LibCST backend by default right now. P3 must not just delete
_cig_builder.py — it must also delete the cig_backend parameter entirely (once there is only
one backend, a selector parameter is dead weight) and verify no caller anywhere still assumes
cig_backend="cst" is available. This closes both the contract's false claim and the
boot/reload inconsistency in one motion.
Finding 3 — RemoveUnusedImportPass silently drops used names in multi-name imports, and the test suite has a coverage gap that hides it (HIGH — real bug, not a migration risk)
_ImportExtractor.visit_Import (passes.py:503-509) appends one (module_name, None, node)
tuple per alias, but node is always the same statement-level cst.Import/cst.ImportFrom
object — never the individual alias. RemoveUnusedImportPass.apply_file then collects any
statement into unused if any one of its aliases is unused (passes.py:557-565), and
_remove_imports deletes the whole statement by node identity (passes.py:588-614).
Concretely: import os, sys where only os is unused deletes both os and sys, silently
breaking any code that used sys. Same defect applies to from x import a, b.
The existing test suite (tests/refactor/codemod/test_transform_passes.py:240-300,
TestRemoveUnusedImportPass, 6 tests) never exercises this shape — test_multiple_unused
(line 273) uses three separate single-name statements (import os\nimport json\nimport sys\n), not one combined import os, sys. This is a textbook false-confidence gap: the pass
"looks" well-tested (6 green tests) but the one input shape that triggers the real defect is
never constructed. P3 must touch this exact logic to port it to ast/token walks — fix the
defect in the same motion (per this project's "fix on sight" standard) and add a regression
test for import os, sys / from x import a, b with mixed usage. Suggested owner: builder,
inside the passes.py work-unit (Unit 2 below) — do not defer to a separate ticket, the porting
work already requires rewriting _ImportExtractor/_remove_imports.
1. Migration table
| # | Symbol (file:line) | LibCST API used | What it does | Port category | Effort / risk | Suggested @traces |
|---|---|---|---|---|---|---|
| 1 | TransformPass (passes.py:36) |
type-only: cst.Module in signatures |
Abstract base for transform passes | render-new (type swap only) | S / none — no logic | REQ-110-126 |
| 2 | _StringLiteralTransformer(cst.CSTTransformer) (passes.py:71) |
leave_SimpleString, leave_ConcatenatedString, .with_changes(value=...) |
Same-span string-literal rewrite (exact or regex) | span-splice | S / low | REQ-110-124, REQ-110-126 |
| 3 | ReplaceStringLiteralPass (passes.py:108) |
drives #2; tree.visit(transformer), mutated_tree.deep_equals(tree) |
Per-file string replacement pass w/ keep-pattern glob guard | span-splice | S / low | REQ-110-124, REQ-110-126 |
| 4 | _ImportExistsVisitor(cst.CSTVisitor) (passes.py:181) |
visit_ImportFrom, visit_Import |
Read-only: does this import already exist? | ast-walk | S / low | REQ-110-123, REQ-110-126 |
| 5 | _extract_dotted_name (passes.py:221) |
isinstance(node, cst.Name|cst.Attribute) |
Helper: dotted-name extraction from Name/Attribute | ast-walk (trivial ast.Name/ast.Attribute swap) |
S / low | REQ-110-123, REQ-110-126 |
| 6 | _classify_import (passes.py:231) |
none — pure string classify | stdlib/third_party/local classification | no-op (zero cst dependency) | none | REQ-110-126 |
| 7 | AddImportPass + _add_import + _find_insert_position (passes.py:303,374,426) |
cst.Name, cst.Attribute, cst.ImportAlias, cst.ImportFrom, cst.SimpleStatementLine, tree.with_changes(body=...) |
Builds a new import-statement CST node, inserts at group-ordered position | render-new | M / medium — insertion-point logic must recompute byte offsets from token spans | REQ-110-125, REQ-110-126 |
| 8 | _NameCollector(cst.CSTVisitor) (passes.py:471) |
visit_Import/leave_Import (skip), visit_Name |
Read-only: collect all non-import Name uses | ast-walk | S / low | REQ-110-123, REQ-110-126 |
| 9 | _ImportExtractor(cst.CSTVisitor) (passes.py:497) |
visit_Import, visit_ImportFrom |
Read-only: extract imported names + owning statement node | ast-walk — also the site of Finding 3's bug; fix per-alias tracking while porting | M / medium (behavior fix, not just port) | REQ-110-123, REQ-110-126 |
| 10 | RemoveUnusedImportPass + _remove_imports (passes.py:525,588) |
drives #8/#9; stmt.with_changes(body=...), id(node) set-membership removal |
Removes unused import statements | render-new (statement/alias-level byte-span delete) | M / medium — fix Finding 3 here | REQ-110-125, REQ-110-126 |
| 11 | _DocstringCommentTransformer(cst.CSTTransformer) (passes.py:617) |
leave_SimpleString, leave_ConcatenatedString, leave_Comment |
Same-span vocabulary replacement in strings + comments | span-splice | S / low | REQ-110-124, REQ-110-126 |
| 12 | DocstringSweepPass (passes.py:659) |
drives #11 | Per-file docstring/comment sweep pass | span-splice | S / low | REQ-110-124, REQ-110-126 |
| 13 | _LSPToCSTTransformer(cst.CSTTransformer) (passes.py:715) |
METADATA_DEPENDENCIES = (cst.metadata.PositionProvider,), get_metadata(...), leave_Name, leave_Attribute, position-vs-edit line/col matching |
Translates LSP TextEdits (line/col) into CST node replacements by re-deriving position via metadata wrapper |
delete | — | REQ-110-127 (edit-safety) |
| 14 | RenameSymbolPass (passes.py:751) |
cst.metadata.MetadataWrapper(tree), wrapper.visit(transformer) (drives #13) |
Consumes SPEC-109 WorkspaceEditPlan, applies its TextEdits |
span-splice (after #13 deleted: apply TextEdit byte ranges directly via EditBuffer) |
M / medium — this is the SPEC-109 rename-engine integration seam, needs the closest testing | REQ-110-124, REQ-110-127, REQ-110-126 |
| 15 | ParseIntegrity (review.py:148) |
cst.parse_module(code), reparsed.code != code diff |
Re-parses mutated code, verifies round-trip fidelity byte-for-byte | ast-walk (semantics simplify: ast.parse succeeds check; full round-trip fidelity becomes a structural guarantee of EditBuffer, not a reparse-diff — REQ-110-124) |
S / low — meaning changes, not just API | REQ-110-124, REQ-110-126 |
| 16 | _OsEnvironVisitor(cst.CSTVisitor) (review.py:49) |
visit_Subscript, getattr(node, "_position", None) fallback-to-0 line lookup |
Detects bare os.environ["X"] reads |
ast-walk (also fixes a latent bug: node._position is not populated on a bare cst.parse_module tree without a MetadataWrapper + PositionProvider, so _get_line silently returns 0 for every finding today — ast.Subscript.lineno is populated unconditionally, so the port is a net correctness fix) |
S / low | REQ-110-123, REQ-110-126 |
| 17 | _BannedImportVisitor(cst.CSTVisitor) (review.py:88) |
visit_Import, visit_ImportFrom, same _position line-lookup pattern |
Detects banned-library imports | ast-walk (same line-number fix as #16) | S / low | REQ-110-123, REQ-110-126 |
| 18 | BannedConstructs (review.py:201) |
drives #16/#17; _check_mocks is regex-on-.code text, zero cst API use |
Orchestrates the 3 sub-checks (env, mocks, banned imports) | ast-walk for 2/3 sub-checks; no-op for _check_mocks |
S / low | REQ-110-123, REQ-110-126 |
| 19 | DiffBudget (review.py:298) |
entry.mutated_tree.code.count("\n"), changes.file_count |
File/line count budget guard | no-op (only needs .code, which LosslessModule also exposes per contract) |
none | REQ-110-126 |
| 20 | PrologSemanticReview (review.py:361) |
none — operates only on ChangeSet path/pass_name strings |
Asserts mutation facts, queries codemod_review.pl |
no-op (zero direct cst dependency) | none | REQ-110-126 |
| 21 | _cig_builder.py (whole file, build_cig, _CIGBuilder, _ModuleVisitor(cst.CSTVisitor), 39 symbols) |
cst.Module, visit_ClassDef/FunctionDef/Import/ImportFrom/Call, dotted-name/annotation extraction |
LibCST-walking CIG (Code Intelligence Graph) builder | delete — see §2, _cig_builder_ast.py is a proven drop-in |
— | REQ-110-125, REQ-110-126 |
| 22 | _parse_file / _parse_file_cached (workspace.py:36,53) |
cst.parse_module(code) |
Parse one file into a cst.Module (serial + cached variants) |
render-new (infra: parse into LosslessModule instead) |
S / low | REQ-110-122, REQ-110-125 |
| 23 | CodemodWorkspace.__init__/.forest (workspace.py:80,212) |
dict[str, cst.Module] typing |
Holds the parsed forest | render-new (type swap: dict[str, LosslessModule]) |
S / low | REQ-110-125 |
| 24 | CodemodWorkspace.load (workspace.py:107) |
cig_backend: str = "cst" parameter, dispatches to build_cig vs build_cig_ast |
Loads the forest + builds the CIG | render-new — also where Finding 2's cig_backend param must be deleted entirely |
M / medium (the wiring-correctness fix, not just the port) | REQ-110-125, REQ-110-126 |
| 25 | CodemodWorkspace.symbol_table (workspace.py:287) |
isinstance(stmt, cst.ClassDef|cst.FunctionDef|cst.SimpleStatementLine|cst.Assign) walk |
Read-only: top-level defined names per module | ast-walk | S / low | REQ-110-123, REQ-110-126 |
| 26 | _verify_post_write (txn.py:321) |
cst.parse_module(written_bytes.decode("utf-8")) — result discarded |
Post-commit re-parse validation of written bytes | render-new (trivial swap to ast.parse, same semantic EditApplier already uses per the contract) |
S / none | REQ-110-124 |
| 27 | ChangeEntry.original_tree/mutated_tree (report.py:38-39) + .original_bytes property (report.py:44-46) |
libcst.Module type annotation, .code.encode("utf-8") |
The central shared data type every pass/review/txn function depends on | render-new — type swap only; LosslessModule.code already matches the property's expectation per the contract |
S code / L coordination risk — must land before every other unit | REQ-110-122, REQ-110-125 |
| 28 | ParseCache.get/put/cache_key (_parse_cache.py:33,58,80) |
pickles cst.Module, cst._version.version embedded in cache-key format string |
Content-addressed disk parse cache | render-new (pickle LosslessModule/TokenStream; swap the version pin to a _ctok build id) |
S / low (cache is disposable/rebuildable) | REQ-110-122 |
2. _cig_builder_ast.py coverage of _cig_builder.py — verified, not assumed
tests/refactor/codemod/test_cig_builder_ast.py (292 lines) already contains a real parity
suite: test_rich_module_structure_identical, test_test_file_detection_identical,
test_multi_module_edges_identical, and — most directly —
test_properties_identical_except_complex_annotations (line 125) and
test_ast_and_cst_backends_produce_same_structure (line 247), which construct both a
build_cig(forest) and build_cig_ast(sources) from the same source and assert
set(cig_cst.nodes) == set(cig_ast.nodes) and cig_cst.key_to_id == cig_ast.key_to_id
(lines 100, 110). The one documented, tested, accepted divergence is complex type-annotation
strings (ast.unparse vs LibCST's own repr) — test_complex_annotation_uses_unparse (line 177).
Verdict: yes, _cig_builder_ast.py fully covers _cig_builder.py's output, with that one
named and already-tested exception. _cig_builder.py can be deleted outright once Finding 2's
cig_backend parameter is removed (§1 row 24) — there is nothing left in it worth keeping. Note
build_cig_ast(sources: dict[str, str], ...) takes source text, not a parsed tree, unlike
build_cig(forest: dict[str, cst.Module], ...) — the workspace-load wiring in row 22-24 already
has to produce/keep raw source text alongside the LosslessModule for this call, which it does
naturally (LosslessModule.stream.source per the contract's dataclass).
3. Fan-out plan — P3 swarm work-units
File-disjoint by construction (no two units write the same file). One serial gate, then four parallel units, then a non-editing integration/verification pass.
Unit 0 — report.py (serial gate, land first, blocks everything else)
Swap ChangeEntry.original_tree/mutated_tree from libcst.Module to LosslessModule
(row 27). Every other unit's pass/check/txn code references these two fields — this must merge
before Units 1-4 start, or all four get immediate type conflicts. ~30 min, effort S, but it is
the highest-blast-radius single edit in the whole migration (§1 row 27's risk note).
Unit 1 — workspace.py + _parse_cache.py + _cig_builder.py (delete) + _daemon_entry.py
(drop redundant kwarg)
Rows 21-25, 28. Owns "how the forest + CIG get built and cached." Deletes _cig_builder.py
outright (§2 verdict), deletes the cig_backend parameter from CodemodWorkspace.load
(closing Finding 2), updates the 2 _daemon_entry.py call sites (the cig_backend="ast" kwarg
becomes dead once there's only one backend). Tests: test_workspace_load.py,
test_benchmark_load.py, test_parse_cache.py, test_cig_builder_ast.py (repurpose the
cst-vs-ast parity tests into single-backend sanity tests once _cig_builder.py is gone).
Effort L — biggest structural unit, and the one that must re-verify scripts/codemod.py's 4
call sites and server.py's "reload" RPC no longer silently diverge (Finding 2).
Unit 2 — passes.py
Rows 1-14. All 7 transform-pass classes + private visitor/transformer helpers in one file.
Deletes _LSPToCSTTransformer; reworks RenameSymbolPass to consume SPEC-109 TextEdit
ranges directly. Fixes Finding 3 (_ImportExtractor/_remove_imports multi-alias bug) as
part of the same rewrite, plus adds the missing regression test. Test file:
test_transform_passes.py (538 lines) — full fixture rewrite from cst.parse_module(...) to
LosslessModule.parse(...). Effort L — largest file, most passes, the one real behavior fix.
Unit 3 — review.py
Rows 15-20. 4 review-pass classes + 2 visitors. Mostly straightforward ast-walk swaps; 3 of the
6 checks (_check_mocks, DiffBudget, PrologSemanticReview) need no logic change at all,
only need ChangeEntry (Unit 0) to already be LosslessModule-typed. Fixes the _position
line-number bug in _OsEnvironVisitor/_BannedImportVisitor as a free side effect of the ast
walk (row 16-17). Test file: test_review_passes.py (847 lines, the largest test file in
scope) — fixture rewrite. Effort M.
Unit 4 — txn.py
Row 26. One touch-point, trivial cst.parse_module → ast.parse swap. Test file:
test_transactional_writeback.py (484 lines) — fixture rewrite. Effort S, lowest risk unit;
good candidate to run first among the parallel four if the swarm wants an early green signal.
Integration pass (not a file-editing unit — verification only, run after Units 1-4 land)
Confirm scripts/codemod.py, server.py, client.py, persistence/repository.py need zero
code changes (confirmed in this audit: none of them import libcst directly — they're already
insulated by the report.py/workspace.py type boundary) and that the full
tests/refactor/codemod/ + tests/refactor/rename/ suites pass green, per REQ-110-126's
acceptance criterion. Also re-run grep -rl "^import libcst\|^from libcst" src/ and assert zero
results, then libcst removed from pyproject.toml:48 and check-deps green (REQ-110-125's
acceptance).
4. Port category counts
| Category | Count | Symbols |
|---|---|---|
span-splice |
5 | rows 2, 3, 11, 12, 14 |
ast-walk |
9 | rows 4, 5, 8, 9, 15, 16, 17, 18 (partial), 25 |
render-new |
9 | rows 7, 10, 22, 23, 24, 26, 27, 28 (+ row 1 type-only) |
delete |
2 | rows 13, 21 (_cig_builder.py, whole file) |
no-op (zero/near-zero cst dependency, already fine) |
3 | rows 6, 19, 20 |
(Row counts: 28 catalogued symbols/units across 7 files; _cig_builder.py's 39 internal
symbols are counted as one delete row since the whole file goes.)
5. Anything the token+ast model genuinely can't express?
No — none found. The two categories that looked hardest going in — AddImportPass (insert
a whole new statement) and RemoveUnusedImportPass (remove whole statements) — are both fully
expressible as EditBuffer byte-splices: an insertion is a zero-width splice at a computed byte
offset (derived from the token span of the "last import in group" statement, same grouping logic
_find_insert_position already computes, just resolved to a byte offset instead of a body-list
index), and a removal is a splice replacing a statement's full token byte-span with empty bytes.
Nothing in the catalogued 28 rows requires CST-specific concrete-syntax fidelity that the
ast-overlay + token-array model doesn't already carry — the contract's own design (NodeIndex
mapping every ast node to its exact token byte-span) was built precisely to cover this case.
The one place that looked like it might need "real" CST metadata —
_LSPToCSTTransformer's PositionProvider line/col-to-node matching — turns out to be strictly
easier in the new model (LSP TextEdits are already line/col ranges; the token stream's
byte-offset index resolves them directly, no tree-walk-and-match needed), which is why it's
delete rather than ast-walk.
6. What this audit did not cover
- The C tokenizer itself,
TokenStream,NodeIndex,EditBuffer,LosslessModule— out of scope per the task boundary (parallel builder track, files not touched or read). verification/review/checks_{safety,quality,correctness,structure}.py— read only far enough to confirmBannedConstructsre-hosts fromchecks_safety.pyspecifically (Finding 1); did not catalog those modules' own LibCST usage (if any) since they are outsiderefactor/codemod/and not named in the brief's file list. A quick check found nolibcstimport in the wholesrc/tree outsiderefactor/codemod/, soverification/review/checks_*.pyare confirmed to already be LibCST-free — but I did not deep-read their bodies.scripts/rename_engine.py,refactor/rename/*internals beyond confirming zerolibcstimports — the contract already treats these as reused/unchanged.- Test files outside
tests/refactor/codemod/(5 files identified importinglibcstdirectly:test_cig_builder_ast.py,test_parse_cache.py,test_review_passes.py,test_transactional_writeback.py,test_transform_passes.py— all named in §3's fan-out plan, but I did not read every test body, only representative fixtures to confirm thecst.parse_module(...)fixture pattern and the Finding 3 coverage gap).