Script Your Code Migrations
Let’s start with a simple problem: there’s no way of scaling the CSS animation-delay property for a group of animations, so if I want to see what an animation would like if it were twice as fast, I’d need to edit every single animation-delay line in my CSS file.1
.component-1 {
animation-delay: 1s;
}
.component-2 {
animation-delay: 2s;
}
/* ...many more lines */
There’s certainly a vim one-liner for this, but it’s also pretty straightforward to script an edit to a CSS file in whatever programming language you like:
import sys
with open(sys.argv[1], "r") as infile, open(sys.argv[1], "w") as outfile:
for line in infile.read().splitlines():
if "animation-delay" in line:
first, rest = line.replace("s;", "").split(":")
line = f"{first}: {round(float(rest) / 3, 2)}s;"
outfile.write(line)
outfile.write("\n")
Most of this code is copy-pastable boilerplate that you can extract into a wrapper, and the three lines to rewrite animation-delay-containing lines is pretty straightforward. This is not a foolproof transformation, but it was good enough to change my CSS file in the way I wanted.
What if we needed to scope the changes to only a particular part of the CSS file? For example, say we only wanted to change the animation-delay timings for only certain classes?
search_match = re.search(r'(.*)\s*\{', line)
if search_match:
curr_selector = search_match.group(1)
if curr_selector.includes(".my_class") and "animation-delay" in line:
...
In one sense, this is a terrible parser. It’s incredibly fragile. But it has one crucial redeeming quality: it was quick to create.
A script like this that rewrites code files doesn’t need to handle every possible case, only the ones that are actually present in the codebase. And it doesn’t even need to handle every possible case in the codebase to be valuable! If it can automate the migration of 9/10 files, that can still save enough effort to make the migration as a whole worthwhile.
Let’s talk about a real problem. Giving every query that your application runs a unique name that will show up in your slow query logs can be incredibly useful, but if you haven’t been naming queries this entire time, you’ll need to backfill those names. If you have thousands of queries across your codebase, that’s going to be a prohibitive amount of work!
Unless you script it.
A few years ago, another engineer and I spent an enjoyable hour pairing on a script to add query names to every single query that our application ran. In terms of impact, the janky little NodeJS script we wrote was probably one of the most important things we accomplished at the company.
The code that we needed to update looked like this:
async function findWidgetsByGroupId (groupId: Id) {
return new WidgetQueryBuilder()
.whereId("groupId", groupId)
.findMultiple();
}
And we wanted it to look like this:
async function findWidgetsByGroupId (groupId: Id) {
return new WidgetQueryBuilder({ name: "findWidgetsByGroupId" })
.whereId("groupId", groupId)
.findMultiple();
}
The exact same patterns that we chatted about to handle updating a single CSS file can do this migration across an entire codebase:
import sys
import re
import os
files = sys.argv[1:] # uv run ./this_script.py $(rg QueryBuilder | grep .ts)
for file in files:
with open(file, "r") as infile, open(file, "w") as outfile:
file_label = os.path.basename(file).split(".")[0]
curr_function = "unset: if you see this in output, my terrible parser didn't handle something properly"
for line in infile.read().splitlines():
if "function" in line:
# if you're doing lots of regexp-like things in python,
# this [parse](https://pypi.org/project/parse/) library can be a nice alternative to regular expressions
curr_function = re.search("function ([^ ()]*)").group(1) or curr_function
if "QueryBuilder()" in line:
line.replace("QueryBuilder()", f"QueryBuilder(name: '{file_label}.{curr_function}')")
outfile.write(line)
outfile.write("\n")
Make sure to back up your code before running any migration like this! The first time you run any of these things, you’re almost certainly going to miss some cases and need to git reset everything.
The real script handled more things that I’m not covering here: nested functions, QueryBuilder<TypeOverride>, handling const fnName = async () => function syntax, pre-existing constructor argument, and probably one or two other things I’m forgetting. It was ephemeral code that successfully added 2,113 usable names to queries and made our whole system much more observable.
Does this still matter when coding agents exist?
Yes.
I think many teams are excited about using AI agents to automate large code migrations, and I think it’s a great use case for agents. These changes are often rote, easy to validate, and require limited context. If an AI agent can make a change like this, why should anyone care what scripting the change looks like?
If you’re roughly aware of when a script like this is easy to write, you’ll be able to give appropriate prompts to make the migration happen. If you tell an agent to do it manually, it will! (Or at least it will try to.) But the more lines of code an agent needs to write, the more chances it has to make a mistake. And the diffs for changes like this can be massive, so it’s far easier to review the logic for a quick script and tests for a large file migration than the 1,000s of lines of a diff.
For relatively simple migrations, the approach discussed here works well! As things get more complex, you might want to instead reach for a tool that has a better understanding of code. For TypeScript projects, I’ve used ts-morph a few times for large migrations, and it’s worked great.
These sorts of scripted code modifications, either with something AST-aware or not, are now easier to create with AI agents, which makes this technique even more valuable. Code is cheaper to modify than it used to be, so it’s worth keeping in mind when you want to improve a pattern in your codebase that once might have been prohibitively expensive to change.
-
CSS Tweaks for this site that make me happy has CSS notes on this animation.
↩︎