One field, three spellings
Cross-language schema rename needs semantic edits for identifiers and separate text edits for wire names. A polyglot fixture shows how prod-code plans the change, validates it, and reports what remains.

On this page · 4 sections
Rename a field in a schema and count the places it has to change. Not the files — the spellings, and the places each one lives:
| where | how it is written |
|---|---|
schema/order.proto, db/schema.sql |
order_id |
| Rust | order_id, and order_id again inside a SQL string |
| Go | OrderID, with json:"order_id" beside it and order_id in a query |
| TypeScript | orderId |
Three spellings across four homes, and each language is right about its own: Go capitalises initialisms whole, TypeScript camel-cases, and the wire format keeps the schema’s snake case — which is why the Go field carries a tag spelling it one way two inches from where the field spells it another.
Now rename it, and notice that the two tools you have both fail, differently.
A semantic rename is correct in one language and blind everywhere else. It is the good tool — the analyzer resolves the symbol and follows its uses within the project:
$ prod-code rename fixtures/polyglot-order/backend/order.go 5 2 TradeID # the CLI renames by position
# Output omitted; this is a positional Go rename, not a schema rename.
Two files. The proto, the SQL, the json: tag, the Rust struct and the TypeScript interface are
untouched, because this Go rename has no schema mapping to the TypeScript property. The relationship
is external to the Go symbol graph.
A textual replace reaches everything and is wrong in each. order_id is not an identifier
in the Go source at all, so replacing that one spelling misses both Go and TypeScript — and replacing all of
them edits identifiers with no analyzer watching, which is how a rename lands in a comment, in an
unrelated symbol that shares a name, or in half of a shorthand.
Discovery is textual, editing is not
code_schema_rename does both and keeps them apart, and that separation is the whole design.
The source references here pin prod-code v0.3.19,
including its two-phase implementation.
All the spellings are generated from the field’s words — snake, camel, Pascal, Go’s initialism form, SCREAMING, kebab — and found by a whole-word scan. That scan decides where to ask. It never decides what an identifier becomes.
Then discovered code identifiers outside strings and comments are submitted to the analyzer of their
own sub-project: the Go file by gopls, the TypeScript file by the TypeScript server, the Rust
file by rust-analyzer, in one repository with no manifest at its root. The text phase handles proto and SQL files,
structural OpenAPI and GraphQL names, and names inside code string literals like a json: tag
or an SQL query. Other text, including README prose, is reported as evidence. Text-edit positions
are re-found in what the first phase produced
rather than reused from the first scan, because by then they have moved.
Here it is on the shipped fixtures/polyglot-order fixture:
a proto, a SQL schema, a Rust crate, a Go module, a TypeScript package, and the README
that describes them. Markdown appears in the report as evidence left for review:
$ prod-code schema-rename order_id --to trade_id --path fixtures/polyglot-order
# Output omitted: per-language counts, diff, remaining evidence and analyzer diagnostics.
For schema rename, the CLI defaults to a report;
--apply opts into writing. Both preview and apply run analyzer validation. Apply refuses
reported errors unless --force is used.
An illustrative, abridged diff against the fixture makes the two edit routes visible:
--- a/fixtures/polyglot-order/backend/order.go
- OrderID string `json:"order_id"`
+ TradeID string `json:"trade_id"`
--- a/fixtures/polyglot-order/backend/handler.go
- return fmt.Sprintf("%s %s @ %.2f", o.OrderID, o.Symbol, o.Price)
+ return fmt.Sprintf("%s %s @ %.2f", o.TradeID, o.Symbol, o.Price)
-const selectOrder = "SELECT order_id, symbol, price FROM orders WHERE order_id = $1"
+const selectOrder = "SELECT trade_id, symbol, price FROM orders WHERE trade_id = $1"
--- a/fixtures/polyglot-order/schema/order.proto
- string order_id = 1;
+ string trade_id = 1;
…
Two identifiers, a struct tag, an SQL string and a schema field, each by the rule of its own language.
handler.go is the point of the first phase, and the difference is not which files were seen.
The scan reads supported file kinds under the chosen path, so it finds o.OrderID there too — as a string of seven
characters that looks like the one in order.go. It cannot decide whether two occurrences are the same
field or unrelated structs sharing a name. The scan can ask about both symbols; schema ownership
still needs review. gopls follows the selected field, and it is gopls that
rewrites both, which is why the identifier work is not done textually even though the discovery
is.
Two renames that want the same characters
The TypeScript half illustrates overlap handling. An earlier fixture report contained this message (positions and counts are historical):
fixtures/polyglot-order/frontend/src/order.ts:8:27 — another rename already changes these
characters; apply this run and run it again to finish
…
Renaming the interface property rewrites { orderId, symbol, price } into
{ tradeId: orderId, … } — the shorthand has to be expanded, because the property is renamed
and the local variable is not. Meanwhile makeOrder has a parameter also called orderId, which deserves its own rename.
Both answers were computed against the same original file, and applying both would produce text neither of them meant.
In that report, the parameter and the shorthand it feeds were left for a second run. The implementation refuses to merge overlapping answers; a repeat run computes edits against the newly expanded shorthand. Review each report before applying:
$ prod-code schema-rename order_id --to trade_id --path fixtures/polyglot-order --apply
# Output omitted.
$ prod-code schema-rename order_id --to trade_id --path fixtures/polyglot-order --apply
# Output omitted; inspect any remaining identifiers.
$ prod-code schema-rename order_id --to trade_id --path fixtures/polyglot-order
# README mentions can still appear as evidence; they are not rewritten.
Analyzer validation is not a compiler build. To check all three consumer projects on the node after applying, use a command that stops on the first failure (output omitted here):
$ prod-code exec -- bash -lc 'set -e
cd fixtures/polyglot-order/core
cargo check --quiet
cd ../backend
go build ./...
cd ../frontend
npx -p typescript tsc --noEmit -p .'
The per-project analyzer check runs before writing, too: the changed files are grouped by the project they belong to and each group is judged by its own analyzer, because one language’s engine cannot say anything useful about another’s.
The two phases also handle different edit shapes. Our in-process Rust adapter applies rust-analyzer’s edits to a copy of the file, then returns its whole new text through the gateway’s WorkspaceEdit conversion. That is prod-code’s adapter behavior, not a general claim about rust-analyzer’s LSP server. Forwarded gopls and TypeScript rename answers use ranged edits. The schema planner tells those shapes apart, applies the semantic result first, then re-scans it for text edits. Mixing text positions from the original file with a whole-file replacement would apply them to the wrong version.
What it does not do
- It is not a schema compiler. Renaming a
.protofield preserves its field number in this example, but changes generated names and may change its JSON name. This tool does not regenerate bindings or prove protocol, database or consumer compatibility. - Unquoted mentions in ordinary code comments are reported, under “left alone”: ordinary code comments are excluded from semantic discovery. Markdown and unsupported text are also left as evidence. Quote heuristics can misclassify comment boundaries. Proto and SQL files receive textual edits; their comments and database migrations are not understood.
- One field at a time. Renaming three fields is three runs.
- The string-literal detection is a quote counter, not a parser — good enough to decide whether an occurrence may be edited as text, and every decision it makes is in the diff you read first. A refused semantic rename leaves the identifier for review; a missing engine does not authorize textual identifier edits.
What it costs
The earlier fixture timing is not reproduced here, so we make no latency claim from it. The source shows the work: a bounded scan, semantic rename requests, and analyzer validation for each changed project. Warmth, project size and analyzer behavior all affect the duration. The scan is not the only cost, and a clean analyzer report does not replace the compiler runs.
Use --path to narrow discovery to the subtree the schema reaches. An analyzer may still
update uses outside that subtree; the summary reports files beyond the scan.
Read those paths and the leftover evidence before accepting the change.
Our rule: scope discovery, inspect every edit and leftover, then run each consumer’s build before accepting a schema rename.
Cite this article
Alexander Panasenko (2026-10-01). One field, three spellings. https://prod.codes/blog/one-field-three-spellings/