notes · · 6 min

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
  1. Discovery is textual, editing is not
  2. Two renames that want the same characters
  3. What it does not do
  4. What it costs

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.

The field name order_id at the top, splitting into the three spellings this repository actually contains: order_id in the proto, the SQL and Rust, OrderID in Go, orderId in TypeScript. Below, the two routes out — identifiers go to the analyzer of their own sub-project (gopls, the TypeScript server, rust-analyzer), while what no analyzer owns (schema files, json tags, SQL strings) is edited as text. Two notes close the figure: the text phase re-scans what the analyzers produced rather than the first scan's positions, and each changed code project receives analyzer validation before writing
The scan says where to ask. The analyzers say what to write. Permitted schema and string-literal edits are re-found in the result of the first phase, not at the positions of the original scan, because 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 .proto field 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
Citation
Alexander Panasenko (2026-10-01). One field, three spellings. https://prod.codes/blog/one-field-three-spellings/