ADR-031: The codemod driver¶
Status¶
Accepted; amended by ADR-050, ADR-052 and ADR-053.
Decision¶
D1. The order is stated here, and it is two orders¶
Within a file the rules run in registry.rules(pack) order, the pack's declaration order, and that
order is a dependency: rename_import reserves the names, configure_to_client spells Client(...)
against the alias it bound, generative_model_calls reads both, and its chat half reads its model
half's output. A pack that inverts it refuses the file with alias_collision rather than writing a
wrong one, so rules declare no dependencies. Across tables: every Rule over every parsed file,
then F-2's survey of the plans they leave, then every ManifestRule, because the last reads what
the first decided (D12 adds a step for a pack that is its own target). That is one pack's run; several packs are one such run each, in id order, joined
by codemod.chained (ADR-052 D4).
D2. F-1 is one function with two callers, and the driver brings it rows¶
impact.planner.atomicity(findings) is public and serves the planner and the driver. It
takes no extra causes, because caused_by is exactly the bails the other findings carry (ADR-012),
so the driver first puts each fix-time code on its row: a rule's withheld Edit goes to the row that
rule claims on that line (never ambiguous, since ADR-030 D2 lets no row have two claimants), and an
unclaimed row gets D3's code. A rule's refusal is folded in only over rows the scan graded
eligible, so flag_only's edits never demote an unsupported row or drop its neighbours'
caused_by.
D3. usage_unmapped: the code for a row every rule declined¶
An eligible row no rule claims is withheld as usage_unmapped: a legacy free function referenced
and not called, or a methods entry with no rewrites. It is the row's own code, so the rows that
were ready name it in caused_by, and its Edit carries no rule_id because no rule produced it.
One code for every shape, because the reader does the same thing: this pack does not migrate this
usage.
D4. The output gate is parse.gates again, and nothing new¶
The driver asks parse.gates of the bytes it produced (libcst parses them, compile() accepts
them) and withholds the file on every row as output_does_not_parse when it fails. The gate is
asked only of a file the run would write and whose bytes changed. The round-trip gate has no output
counterpart: the output is libcst's own serialisation, so nothing can make it fire. ReadRefusal
gains no member for it, because a refused rewrite is an Edit, not an unreadable file.
D5. Neither new code gets a rung on the merged ladder, for two reasons¶
impact.planner.LADDER orders scan-time codes on one row. usage_unmapped is a fix-time code, like
section 4's call-rewrite bails. output_does_not_parse is asked only of a file where nothing else
fired, so a rung would never have anything to displace; section 6's rung-2 row still says where it
sits. D10's and D11's codes stay out of LADDER for the same reasons (codemod.BAILS).
D7. A run is bytes in memory, and a Run is what a caller may refuse¶
codemod.run writes nothing: it is a pure function of the scan, the bytes and the pack. So the
oracle grades whole repositories without a write, and obelize fix's default dry run is the apply's
code path minus the write. Outcome.after is the input for every file the run does not write,
including one F-1 refused after its rules produced good bytes and one whose parse does not reproduce
its own bytes (ADR-017's round-trip gate, asked again), so writing after unconditionally is right.
D8. The driver takes one mapping of bytes, and refuses two ways of getting it wrong¶
sources maps each scanned path to its bytes, manifests included, partitioned with
manifests.is_manifest, the walker's own predicate. A scanned file absent from it is left alone,
which is how a caller migrates part of a repository. CodemodError refuses a file with an
eligible row and no bytes, which F-2 would report migrated while nothing was written, and a path
no scan looked at, which has no plan.
D9. The corpus is four repositories, and the two measurements are not in it¶
tests/fixtures/transforms/codemod/ holds one small repository per shape of a run, because a run's
unit is a repository; clear/ and blocked/ scan identically and write two different manifests.
The acceptance measurements are outside it: one driver call reproduces tests/fixtures/scan/basic/'s
two hand-written keys byte for byte, and examples/gemini-legacy-app/, with its own .obelize.yml,
is withheld whole: two of its modules run on summarizer/config.py's configure (D11), so a run
writes nothing and the manifest keeps its legacy pin.
D10. A second output gate: every name the output reads is bound¶
Bytes that pass D4's gate are asked, through libcst's scope analysis, which names they read that
nothing binds, builtins aside. A name unbound in the output and not in the input was introduced by a
rule, and the file is withheld as output_names_unresolved on every row; comparing with the input
lets a module that already reads such a name (a star import's) migrate. Hand-written keys meet no
gate, so tests/oracle/test_written_names_resolve.py puts every complete: true key, and the
codemod oracle everything it writes, through ruff's F821, F811, F823 and TC004.
D11. A configure another module runs on is not deleted¶
A pack with no client has none, and the pass is skipped. configure sets a process-wide default. If, after every file has been through the rules, any
module with no configure of its own stays on the legacy SDK (a row that imports it is withheld),
every file whose configure the run would rewrite is left as it was: configure_consumed_elsewhere
on the configure, F-1 naming it on every other row. It is asked of the run's plans, because a rule
can leave on legacy a module the scan graded eligible (tests/fixtures/scan/COVERAGE.md gap 21).
One reliant module holds them all, since which configure runs first is decided at run time; a
mock.patch target is not reliance.
D12. A pack that is its own target runs all or nothing¶
When the pack's new distribution is its legacy one (manifests.coupled) and any row it graded, in a
file or in a manifest, is withheld after D1's survey (_unfinished), _together leaves every file as
it was: each applied row becomes needs_review with repo_not_fully_migrated, and each plan is
graded again so F-1 holds. It runs after D11's pass and before the manifest rules. A file written for
the new API fails on the old pin and a file left behind fails on the new one, so no part is worth
writing alone (ADR-053 D6).
D13. An exception from one file's rules withholds that file and nothing else¶
codemod.run catches any Exception a file's rules raise (a stack overrun keeps D4's
output_does_not_parse). The file is kept as it was and every row the scan graded eligible is
withheld as internal_error, a file gate like D4's, so F-1 names it in caused_by on the rest and
D12's pack holds the repository. Run.limitations carries one internal_error row per file naming
the exception's class and never its message (ADR-017 D9), and run.json
merges them with the scan's. Unlike D4's code it is on LADDER, at rung 2 beside
input_does_not_parse, because a scan raises it too.
Consequences¶
- Finding 7: a file the scan already refused produces findings and no
Edit, because every rule skips rows that are noteligible; its report is its findings (ADR-034 D10). - D10 cannot see a name bound to the wrong thing or bound only on some paths.
- A run keeps every produced byte in memory until the caller decides; no repository too large for that has been measured.