refactor package

Reviewed against commit 8a2cb42ad on 2026-10-23.

Reference: generated package page orchestrata_ultimate.refactor (50 modules, 12,684 lines of code, of which the codemod engine is 41 modules and 11,382 lines); key modules agent, tools, codemod workspace. The package is in the "code intelligence" group of the orchestrata overview; the intent routing that sends REFACTOR work here is in orchestrator.

The story

Most of orchestrata's code-generation machinery is aimed at producing new code. But real projects spend more of their life being reshaped than being written, and reshaping an existing codebase safely is a different problem: the model must work from what is actually on disk, not from what it imagines is there. orchestrata_ultimate.refactor (SPEC-103) exists to solve exactly that. It is the tool-calling refactor capability — a small, self-contained agent that lets an LLM (DeepSeek by default) refactor a codebase in place through function calls against real, sandboxed tools, rather than by hallucinating whole files.

The heart of the package is a disciplined OBSERVE → MUTATE → VERIFY loop, driven from agent.py. A caller — typically the orchestrator, which routes REFACTOR intents here via orchestrator.intent_broker (SPEC-103 FR-009 / REQ-103-025), or the CLI at scripts/refactor.py — hands arefactor() a task description and a root directory. The agent sends a refactoring system prompt (FR-006: observe before mutating, make minimal surgical edits, never fabricate file contents, verify before finishing) plus the TOOL_SCHEMAS function specs to the model, then loops. Every tool call the model emits is executed through RefactorTools.dispatch, the string result is appended to the full assistant/tool message history so the model keeps continuity, and the run ends when the model answers without tool calls, exhausts its turn budget (max_iterations=25 by default), or errors. The outcome is packaged as a RefactorResult — files changed, iterations, the full tool-call trace, whether verification ran, and a completed | max_iterations | error status.

The tools themselves, in tools.py, are deliberately small and defensive. Each is confined to the run's root directory (path traversal is rejected in _resolve), and none is allowed to raise into the loop — failures come back as ERROR: ... strings the model can read and recover from. Observation tools (read_file, list_dir, grep, ast_outline) give the model genuine knowledge of the existing code; mutation tools (edit_file for exact-match replacement, write_file for whole files) apply the change; run_check closes the loop by running a whitelisted verification command. This mirrors the shape of a careful human refactoring session, encoded as a protocol the model cannot easily shortcut.

Its footprint in the rest of the system is intentionally light: agent.py calls out to the shared LLM client (llm.client.call_with_tools), logs through core.logging, and both modules carry @traces requirement decorators from core.traceability (the async entry point traces US-103-001, REQ-103-017/018/019). The package facade (__init__.py) re-exports RefactorTools, TOOL_SCHEMAS, refactor, arefactor, and RefactorResult, so callers depend on a stable surface while the internals stay free to change.

Key responsibilities

Class diagram

The package is deliberately flat — two principal classes, no inheritance. RefactorTools is the sandboxed hands (observe / mutate / verify); RefactorResult is the immutable-ish record the agent loop fills in as it dispatches tool calls. (Generated from the AST + source, render-verified.)

Diagram projects-orchestrata-ultimate-modules-refactor-1

The agent module is function-driven rather than class-driven: arefactor(task, root, model, max_iterations) is the async loop, and refactor(...) is the synchronous wrapper that runs it and swallows exceptions into a status="error" result. Both construct one RefactorTools per run and return a RefactorResult.

Module dependency diagram

Internal module imports within orchestrata_ultimate.refactor (generated from the AST, render-verified):

Diagram projects-orchestrata-ultimate-modules-refactor-2

Module inventory

Module Purpose
refactor/__init__.py Package facade — re-exports RefactorTools, TOOL_SCHEMAS, refactor, arefactor, RefactorResult.
refactor/agent.py Tool-using refactor agent (SPEC-103 FR-005/006/007): the OBSERVE → MUTATE → VERIFY tool-use loop, the REFACTOR_SYSTEM_PROMPT, the RefactorResult data model, and the arefactor / refactor entry points.
refactor/tools.py Sandboxed, callable tools for the refactor agent (RefactorTools) plus their OpenAI/DeepSeek-compatible function schemas (TOOL_SCHEMAS) and the _schema builder.
refactor/codemod/ The SPEC-110 in-memory code-intelligence and codemod engine (41 modules, 11,382 lines of code; added after the first version of this page, which described a two-module package). See the layout below and the SPEC-110 sections that follow.
refactor/ctok/ ctok-parser (6 modules, 829 lines of code): the free-threading-safe C tokenizer and the token-array lossless CST layer the codemod engine parses with.

Package layout (8a2cb42ad)

The package is no longer "two principal modules": it has 50 modules and 12,684 lines of code, and the original tool-calling agent (agent.py, tools.py, __init__.py, 473 lines) is now the smaller part. Everything on this page above the SPEC-110 sections describes that original agent and is still accurate for it.

Subpackage or module Contents
codemod/ core workspace (CodemodWorkspace, the entry point), _cig, _cig_builder_ast (the sole code-intelligence-graph backend), _csr_index, _types, passes (built-in transform passes), txn (atomic commit and rollback), report, watcher (inotify incremental reload), _parse_cache, _fast_fallback.
codemod/ daemon server (Unix-socket JSON-RPC 2.0), client, daemon (lifecycle), _daemon_entry.
codemod/ review review, review_catalogue, review_checks, review_context, review_engine — the in-memory review engine of packet S3.
codemod/analysis/ Eleven read-only analysis passes and helpers: blast_radius, breaking_change, complexity and metrics, coupling, abstraction, dead_code, test_coverage, encode, protocol.
codemod/observability/ Seven modules: events, metrics, tracing, debug_panel, error_detector, graph_writer.
codemod/persistence/ repository, the graph persistence adapter.
ctok/ tokenizer, token_stream, node_index, lossless_module, edit_buffer.

Reference pages: codemod, ctok, workspace, agent, tools.

agent.py — the tool-use loop

agent.py drives the whole show. Its async entry point, arefactor(task, root, model="deepseek-v4-pro", max_iterations=25), opens a conversation with the model carrying the REFACTOR_SYSTEM_PROMPT and the tool schemas, then repeatedly hands the model's requested tool calls to a per-run RefactorTools instance and feeds the string results back into the message history. Because the full assistant/tool history is preserved across turns (including any reasoning_content that thinking models require echoed back), the model builds a genuine picture of the codebase before it touches anything — which is precisely what the system prompt demands of it. A synchronous refactor(...) wrapper exists for callers without an event loop; it calls asyncio.run(arefactor(...)) and converts any exception into a RefactorResult with status="error" so the entry point never raises.

Every run terminates into a RefactorResult dataclass recording the task, root, files_changed, iterations, tool_calls (the full dispatch trace), whether the run was verified, the model's final_message, and a status of completed, max_iterations, or error. The loop marks a path as changed only when an edit_file / write_file call returns OK, and sets verified from whether a run_check came back with exit=0. The entry point is traced to SPEC-103 US-103-001 (REQ-103-017/018/019), so the requirement matrix knows this capability is implemented here.

tools.py — the sandboxed toolset

Where agent.py supplies the discipline, tools.py supplies the hands. RefactorTools is constructed around a root directory (rejecting a non-directory root outright) and refuses to look outside it: every path argument passes through _resolve, which rejects traversal attempts. The observation methods let the model see the code as it really is — read_file and list_dir for raw content and layout (with a 100 KB read cap and __pycache__/dotfile filtering), grep for literal substring search across .py files (capped at 200 hits), and ast_outline for an AST-derived structural summary of a Python file (classes and functions with signatures and line numbers), which lets the model orient in a large file without reading all of it. Mutation is split between edit_file, an exact-match old-string/new-string replacement that fails loudly when the anchor text isn't found or isn't unique, and write_file for whole-file writes. run_check executes a whitelisted verification command (ruff, py_compile, pytest, mypy) with a 120-second timeout so the model can prove its edit didn't break anything before declaring victory.

Two design choices make the loop robust. First, tools never raise into the agent: any failure is returned as an ERROR: ... string, so a bad tool call becomes information the model can act on rather than a crashed run. Second, dispatch(name, arguments) is the single funnel through which the agent executes model-requested calls — it rejects unknown or private names (only the seven schema-declared tools are callable), so the agent loop stays ignorant of individual tool signatures. The module also builds TOOL_SCHEMAS — the OpenAI/DeepSeek-compatible function-spec list sent to the model — via the _schema(name, desc, props, required) helper, so the schemas and their implementations live side by side.

init.py — the facade

The package facade keeps the public surface to exactly what neighbours need: RefactorTools and TOOL_SCHEMAS for anyone composing their own loop, and refactor, arefactor, and RefactorResult for anyone who just wants a refactoring done. The orchestrator's REFACTOR-intent routing and the scripts/refactor.py CLI both enter through these re-exports, which means the internal module layout can shift without touching callers.

SPEC-110 codemod engine — packet S1 (observability wiring)

CodemodWorkspace now owns one SyncObservable[CodemodEvent] (ws.observable, ws.subscribe) and emits the 13 CodemodEventType kinds REQ-110-025 names from load(), run() and commit(), each operation stamping one correlation_id on its events. observers= on all three calls overrides the empty default set for that call only (private per-operation bus); workspace-level subscriptions are explicit and additive, and an observer on both buses receives each event once. commit() writes through txn.commit_changes, so atomic write, concurrent-edit guard and rollback (REQ-110-014..016) apply, and a failed write emits ROLLBACK_TRIGGERED, ERROR_DETECTED, COMMIT_ABORTED in that order before re-raising. Six review rounds (2026-10-06) hardened the write path before the packet shipped. Round 1: ChangeSet.merge keeps the earliest (load-time) original_tree per path so a multi-pass run that edits one file commits. Rounds 2–5, all in txn.py: every write goes through _replace_file (mkstemp, os.write loop for short writes, fchown(fd, -1, gid), fchmod after the write so setuid/setgid survive, fsync, rename), so a 0755 script stays 0755 and the target's group is kept; fchown failing with EPERM or EINVAL (a group the writer is not in, or an unmapped group in a user namespace, where the file reads as gid 65534) logs a warning naming the path and both gids and the commit continues, while any other OSError fails it; the owning uid is not preserved (impossible without root). _write_atomic stats the target first and refuses a vanished target with ConcurrentEditError(..., vanished=True) ("no longer exists"); the stat-to-rename window stays open without renameat2. Rollback restores only the files this commit wrote, byte-exact with the snapshot's mode and group, through the same tempfile-and-rename path (a 0444 target is restored and stays 0444); per-file restore failures are attached to the original exception with add_note and listed in the ROLLBACK_TRIGGERED payload as rollback_failures ("path: ExcType: message" strings); a raising on_event sink is logged and never masks the original error. D10 (operational, plan §2): the default observer set stays empty; scripts/codemod.py run --observe attaches the five REQ-110-026 observers (default_observers() is public) for run and commit and closes them in finally. Known limits recorded in the checklist and design §8: REQ-110-006 (_update_index incremental index) is not implemented; review events are one aggregate pair per commit rather than per review pass; core.patterns.observer deduplicates subscribers by equality while _Operation deduplicates by identity. Tests: tests/refactor/codemod/test_transactional_writeback.py (24, including an unshare -r user-namespace commit, a thread deleting the second target during the first write, RLIMIT_FSIZE-induced rollback) and tests/scripts/test_codemod_cli.py (--observe proven with sys.setprofile). Docs: docs/codemod-observability.md; design: plans/codemod-observability-wiring-design.md §8; review log: plans/spec-110-completion-plan.md "S1 review log".

SPEC-110 codemod engine — packet S2 (analysis passes)

REQ-110-031/032: seven read-only analysis passes live in refactor/codemod/analysis/, one submodule each, every result a frozen dataclass. BlastRadiusAnalyzer (CodeIntelligenceGraph.blast_radius), BreakingChangeDetector (signature diff over a ChangeSet, parameters, return annotation and async-ness, then the graph's breaking_changes; consumers are the importing modules and consumer_line is the importing statement's line), ComplexityAnalyzer (cyclomatic, nesting and cognitive complexity from ComplexityMetrics over the forest's ast overlay; the graph only resolves the target module), TestCoverageMapper (TESTED_BY edges read in both directions plus the test_<name> heuristic, heuristic_only listing the by-name-only hits), AbstractionAnalyzer, CouplingAnalyzer and DeadCodeDetector (module-granularity reachability over dependencies; granularity == "module", unreachable_symbols empty until the graph grows a reachability query). _types.py gained FunctionComplexity, ComplexityReport, TestCoverageMap, DeadCodeReport. tests/refactor/codemod/analysis/test_read_only.py runs all seven and compares CIG node and edge counts and every forest module's bytes before and after. scripts/codemod.py analyze gained the --query choices breaking_changes, complexity, test_coverage, dead_code, all-passes, plus --json (one object keyed by analyzer name) and --base <git ref> (the pre-change text comes from git show <base>:./<path>). The analyzers add no graph methods; the six owed graph items are recorded as GAP-1..6 in docs/codemod-analysis.md and the S2 backlog of plans/spec-110-completion-plan.md.

Three review rounds (2026-10-06) changed the CIG builder underneath the passes. Round 1 (NO-GO): the import resolver bound by file order, so a fixture ordering hid that import x from a/b.py never reached x.py; _cig_builder_ast.py now resolves IMPORTS in a second pass over every registered module through _ImportResolver (_roots_for, _relative_roots), sink_import is a replayable op, Import nodes carry line, level, is_relative, resolved_module, one IMPORTS edge joins a module pair however many statements import it, and CodemodWorkspace.load() sorts its file list so node ids are deterministic across machines. Round 2 (GO except MAJOR-1): test_memory_guard sat over its 3,758 MB band after the resolver work; three reductions with no behaviour change took about 116 MB back (n=7 means 3,761.9 MB before, 3,646.0 MB after; the reviewer's round-3 samples 3,631 / 3,633 / 3,621 MB): _ForestSources, a lazy Mapping over the forest, replaces the eager decoded-sources dict so the tree's text is never copied a second time; the parallel build frees each module's op log as it is replayed; and one (source, target) tuple per edge serves both the CSR input and the per-type list in place of a CIGEdge object per edge plus a rebuilt tuple list. Round 2 also fixed four MINORs: a false-confidence overshoot test, stdlib names never binding to a same-named repo file, from A import B preferring an earlier root's package over a later root's submodule, and @traces on the new tests. Round 3 (GO against 3e4fe1e6d): the resolver fix changes nothing in this repo (identical 7,927-edge IMPORTS set over 3,226 modules before and after), 179 + 785 tests pass with 0 failed and 0 skipped. Carried to the S2 backlog, items (9) to (12): the resolver's layout heuristic for a package at the repository root (MINOR-A/B), INHERITS = 0 and OVERRIDES = 0 over the whole tree, the loader reading gitignored build/ artefacts, and a REQ-110-031 retag on a loader-ordering test. Docs: docs/codemod-analysis.md; contract decision D26 in plans/contracts/spec-110-codemod-engine.contract.json; review log: plans/spec-110-completion-plan.md S2 packet record.

SPEC-110 codemod engine — packet S3 (in-memory review engine)

REQ-110-033/034/035: CodemodWorkspace.commit() runs an InMemoryReviewEngine over the mutated forest before any byte is written, in three new modules under refactor/codemod/. review_catalogue.py holds the FR-012 catalogue as data: 40 CheckDescriptor rows in 7 CheckCategory groups, resolved by CheckRegistry to 30 distinct implementations (shared groups report under their owner's id through IssueAdapter.to_findings(rule=...)), each row naming its phase and substrate. review_checks.py holds the implementations: ReusedCheck wraps the existing verification/review/checks_* functions (with a keyword-only scope of all, source or test carrying the engine's source/test split, _source_lines blanking string and comment tokens so a literal never feeds a regex, and the local-module check dropping imports that resolve to a sibling module or fall outside the workspace's include_roots), plus CyclomaticComplexityCheck (maximum 10), TrailingWhitespaceCheck, TabIndentationCheck, FinalNewlineCheck, NamingConventionCheck, BannedLibraryCheck (the .claude/banned-libraries.txt registry), BroadRaiseCheck, PrintOutsideMainCheck, MutableCallDefaultCheck, SwallowedExceptionCheck, ModuleScopedFixtureCheck and GlobalMutableStateCheck. review_engine.py holds ReviewContext, ReviewPhaseRunner and the engine: phase 1 fans out one task per file on a ThreadPoolExecutor, phase 2 runs after it with the code-intelligence graph, phase 3 (Prolog) is opt-in; _shared reads pyproject.toml (via tomllib and importlib.metadata.packages_distributions()) and the banned registry once per review, and _context scopes project_modules to the importer's own root so scripts/, tests/ and claude/ siblings resolve locally without src/ leaking into them. ADR-0073 (Accepted 2026-10-07) amends ERR-110-002: the constructor default is baseline='delta', so a commit aborts only on findings the change introduced; _delta keys each finding on (path, rule, line-free message, stripped line text) with multiplicity, review_detailed() returns ReviewOutcome(introduced, legacy), the legacy set is reported in CommitReport.legacy_findings, as FINDING_EMITTED events marked legacy: True and in REVIEW_COMPLETED.legacy_count, and scripts/codemod.py run --baseline absolute is the opt-in clean-slate gate (report JSON carries baseline and legacy_findings). Measured on this repository at 7397f2c35: absolute finds 235 errors in 93 files and 12,786 warnings before any codemod runs (154 SF_EVAL_EXEC, 29 SF_FLUSHDB, 22 TI_MODULE_FIXTURES, 16 SF_ENVIRON_SUBSCRIPT, most of them real literal credentials and flushdb calls in shared test fixtures); delta introduces 0.

Seven review rounds (2026-10-06 to 2026-10-07) shaped the engine. Round 1 (NO-GO, blocker): absolute as the default aborted any --apply touching one of 134 files that already carried an error (419 errors, most false positives with named causes); fixed the four false-positive causes (string literals fed to regexes, the dependency reader's scope, _is_test_path, stand-in path masking) and minted ADR-0073 with the measured numbers (419/134 to 247/100 to 235/93). Round 2: the delta compared line numbers, so an inserted line re-flagged every finding below it; _line_free strips the line from the message. Round 3/3b: inferred include roots and lexical path containment replaced by workspace._include_root (resolve-based, symlink-safe, . stores no restriction) and _outside_include_roots; _top_level treats scripts as its own root. Round 4: importer-scoped locality (_context), the sibling-drop rule made observable and tested, symlink-safe include containment. Round 5 (ADR-0073 execution, 7db0f25e1): delta default, --baseline, ERR-110-002 and checklist rows amended, plan D6 superseded. Round 6 (ac8188445): B1, the key gained the stripped line text so an in-place edit of a line already carrying a content-free finding (placeholder to real secret) aborts; B2, legacy findings were being dropped and are now reported through review_detailed, legacy_findings, the legacy: True payload flag and legacy_count; M1/M2, the module docstring and REQ-110-035 row name delta as the default and a CLI test proves the default path aborts on an introduced error with the file unchanged. Round 7 (against 7397f2c35): B1, B2, M1 and M2 confirmed closed by probe (in-place edit on line 1 and on an unterminated last line, CRLF files, whitespace-only and indent-only edits all behave as designed; stream.source is the mutated bytes on the mutated side and the original bytes on the other); cost unchanged (scripts dry run 28.6 s wall, 610 MB, 8.0 s reported, 178 KB JSON; GraphWriterObserver writes no node per finding); one blocker, an S1 test pinning the exact REVIEW_COMPLETED payload that now carries legacy_count (fixed in c6946173a, which also records the key and the legacy flag in the spec's events table); and the C5 diagnosis of the tools re-fire corrected into C6: _sweep_docstrings_and_comments rewrites every simple string constant by design, against REQ-110-010 (12 of 23 changed lines in src/orchestrata_ultimate/tools are not docstrings and change behaviour, among them an HTTP header name, an env key and two enum values), so docstring-sweep --apply on the real tree is unsafe until S4/S5 fixes it. Carried to the S3 backlog in plans/spec-110-completion-plan.md: the phase-2 CIG gap, no diff preview on an aborted dry run, a narrow --include without src reporting the project's own packages as third-party, the DocstringSweepPass span defect that rewrites a non-docstring string when old matches inside it (C5, owned by the passes module, high priority), the claude/tools layout, a sibling package's submodule (C4), import os as x not flagged, legacy findings absent from the CLI's human summary (C-a), an edit on a continuation line of a multi-line statement staying legacy (C-b), the RPC commit not serialising legacy_findings (C-c), and the one masking shape left in the delta key (a legacy line removed and the same text re-added elsewhere in the same file; duplicating a legacy line is caught). Tests: tests/refactor/codemod/test_review_catalogue.py, test_review_checks.py, test_review_engine.py, test_review_engine_commit_wiring.py (TST-110-021..026), tests/scripts/test_codemod_cli.py, tests/refactor/codemod/test_workspace_load.py (include-root containment); full regression at c6946173a, every module of tests/refactor/codemod/ plus the CLI tests in eight groups: 1,069 passed, 0 failed, 0 skipped, and tests/verification/review/ 161 passed. Coverage (round-7 reviewer, branch on): review_engine.py 100%, review_checks.py 99%, review_catalogue.py 95%, report.py 99%, scripts/codemod.py 97%, workspace.py 98% with the load tests included. Docs: docs/codemod-review-engine.md; ADR: docs/adr/0073-...delta-baseline.md; contract decisions D27 and D28 in plans/contracts/spec-110-codemod-engine.contract.json; review log and fix-round briefs: plans/spec-110-completion-plan.md S3 packet record and plans/briefs/spec-110-s3-fix3-builder.md. Shipped 2026-10-07: master fast-forwarded 40 commits to 4fadfaac1 (ship attempt 2; attempt 1 was refused because the contract JSON had no declared test edge, now declared in plans/affected-tests-manifest.json), the three ship targets deployed and verified; the prod promote ran to verify and was rolled back on the P6/PH1 identity-verifier items and the ADR-0072 consumer question, none of them S3's.

SPEC-110 codemod engine — packet S4 (the CLI uses the daemon it can start)

REQ-110-140, contract D29 (2026-10-07). Before S4 the CLI could --auto-start the deployed daemon (P4, ADR-0067) and then never spoke to it: every run and analyze loaded the workspace locally, the daemon's commit RPC committed an empty ChangeSet, and a pass run through the daemon left its live forest mutated for every later client. S4 made four changes. (1) scripts/codemod.py decides the mode once: --no-daemon is library mode and silent; otherwise it connects to <repo>/.codemod_daemon/socket, and when no daemon answers it prints exactly one stderr line (codemod: no daemon serving <repo> … library mode, parsing on every invocation …) and parses locally; load-bench and report never connect. Daemon mode is used only when the daemon's loaded roots match the requested --include (else one notice and library mode for that run), because a daemon serving src, scripts, tests would otherwise write outside the roots the user asked for. (2) The server holds pending runs by reference: run_pass opens or extends a run (run_id), records each touched file's pre-run tree once, and holds the write lock until commit or the connection closes; commit looks the ChangeSet up by run_id, runs the review engine with the requested baseline, returns the full CommitReport payload, and restores the forest after a dry run, a refused review or a failed commit. A pending run has a lease (120 s idle) so an idle client cannot wedge every other writer; a blocked writer gives up with a named error; the inotify reload now swaps the workspace through replace_workspace, under the same lock (REQ-110-137). resolve_nodes lets the CLI print the same qualified names in both modes. (3) DocstringSweepPass sweeps docstrings and comments only (REQ-110-010's own words; the S3 reviewer had measured 12 of 23 changed lines in tools/ being code strings), both passes keep the string prefix (b, r, u) and refuse a replacement that would end the literal's own quotes or put a raw newline into a single-quoted literal. (4) Proof: library mode is the reference and daemon mode must print the same bytes — 13 analyze cases, run dry and --apply, stacked passes on one file, against a ("src",) daemon and an all-roots daemon; forest unchanged after a daemon dry run; disconnect and lease expiry restore it. Five analyze queries (breaking_changes, complexity, test_coverage, dead_code, all-passes) have no RPC method and parse locally with a notice (S4 backlog). Review: round 1 NO-GO on the --include blocker and the unlocked reload swap, both closed with tests; the docstring sweep passed ten edge-case probes. Three more fix rounds followed the confirmation review (GO with six minors): a reload loaded before a commit is dropped by a commit_generation guard instead of installing a pre-commit tree; lease expiry is re-checked under the pending lock (20 same-instant commit/run pairs, mutual exclusion held 20 of 20); the quote guard finally validates the candidate literal with ast.literal_eval, so invalid escapes and NUL leave the file unchanged; --auto-start and CodemodDaemon.start() both report a launcher that dies before serving at once (daemon <instance> failed before serving (<state>)), start() settling one second before it calls a unit started, because the first systemctl show after start still carries a dead launcher's MainPID. Record: plans/spec-110-completion-plan.md § S4; docs/codemod-daemon.md.

SPEC-110 codemod engine — packet S5 (the index follows the forest)

REQ-110-006, contract D30 (2026-10-07). CodemodWorkspace.dependencies() and dependents() read the code-intelligence graph's IMPORTS edges through a CSR index built once at load, so until S5 a pass that added or removed an import left the index describing the tree as it was loaded (the S1 reviewer measured it: after AddImportPass rewrote a file the index did not change). S5 gives the graph one mutator, CodeIntelligenceGraph.replace_imports(edges), which swaps the named modules' IMPORTS edges inside the builder's insertion-ordered edge list (new pairs take the place of the first dropped one, so a no-op or a restore reproduces the load-time lists element for element, topological_order included — the first build regrouped edges by type and the reviewer measured 9,911 reordered rows), rebuilds the CSR from that list and resets the betweenness and pagerank caches under a lock with a generation counter, because a centrality() started before a replace must not store its result afterwards (measured: 871 stale entries without the guard, GIL off). CodemodWorkspace._update_index(paths) re-resolves the imports of exactly the named modules, at every depth and in source order, through the builder's _ImportResolver and issues one replace_imports; run() calls it after each pass's trees land, so a second pass sees the first pass's imports, and restore() calls it after putting trees back, so every daemon restore path (dry run, refused review, failed commit, disconnect) restores the index together with the forest. Import nodes, symbol nodes and CALLS/INHERITS edges are not re-derived; those stay a reload's job (D30 R1). The same packet closed three hygiene items: REQ-110-011 (a pass has no disk I/O surface) gained its citing test, which parses passes.py and refuses open, write/rename/unlink/mkdir attribute calls and shutil/tempfile/io imports; the transactional write-back skips fchown when the temp file already carries the target gid, so a filesystem that rejects chown no longer fails a commit whose group is already right; and the REQ-110-050/085 audit closed with the measurement that no live citation of either id exists. The SPEC-110 checklist reads 49 COMPLETE / 2 IN PROGRESS / 0 NOT IMPLEMENTED. Record: plans/spec-110-completion-plan.md § S5; docs/codemod-daemon.md "The index follows the forest".

Class map (generated)

Generated from the live source AST by scripts/gen-module-class-diagrams.py — do not hand-edit. Abstract classes have a green border; members listed are those defined on the class itself (inherited members excluded). Solid empty-triangle arrows = in-package inheritance; dashed arrows into dashed boxes = external/stdlib/cross-package bases; filled diamond = composition (constructed in __init__); open diamond = aggregation (annotated/injected reference); diamond edge labels name the owning attribute.

refactor class map