The recipe language
A refactoring, written down
A recipe says what to find, what to do to it, and what must be true afterwards. Review it, run it again next month, and watch it fail loudly when the tree has moved.
Every command in this tool acts on one target. That suits a person at a terminal and fails the work people really have: "retire this flag, delete what it was guarding, and tidy the imports that leaves behind" is three commands in order, each depending on the last. Write it as a shell loop and the refusals scroll past, the ordering stays implicit, and no reviewer can read what you did. A recipe makes the plan the artifact, and the diff comes out of it.
Generated Every report and every diff below
records what fr recipe did, captured by
cargo test --test site_data. That test fails when the
page and the tool disagree.
The shape of one
schema 1
recipe name {
description "why this exists"
requires symbol "USE_LEGACY_AUTH"
operation where predicate… modifier…
expect changed > 0 files
}
requires is the guard. A recipe run against the wrong tree stops
there instead of part way through. For a settled rename, requires any
symbol "before" "after" accepts either name and rejects a tree with neither.
Layout means nothing here, and no statement takes a terminator. A
statement ends when the parser meets a token that can only begin a new one. Step
keywords are reserved and no predicate shares a name with one, an invariant a test
enforces rather than hopes for. So a where clause runs across as many
lines as it needs with no punctuation.
It is not a programming language. No loops, no arithmetic, no
conditionals. The moment a recipe needs those it should be a program calling the
CLI. And it does not extend what the tool can do: if a step could not be typed as
an fr command, it is not a step.
The operations
| Step | Takes | Selector |
|---|---|---|
rename to "x" | a name | required |
delete | nothing | required |
move to "path" | a destination | required |
imports | nothing | required |
inline variable / inline call | which | required |
signature "add:1:x: int:0" | the change | required |
rewrite guard-clause | the transformation | required, and it chooses files |
translate to python | the target language | required, and it chooses files |
extract function at "…" as "…" | a range and a name | rejected: nothing selects a range |
remove-flag "F" = false | the flag and its value | rejected: it acts on the whole workspace |
restructure go 'a($X)' => 'b($X)' | a shape and a template | rejected: the pattern is the selector |
“Rejected” means an error naming the operation and why, never a silent ignore. A
grammar that reads operation , [selector] , {modifier} cannot say that
rewrite needs a name or that remove-flag has no use for a
where clause, so a signature table checks it immediately after the
parse.
The predicates
| Predicate | Matches |
|---|---|
name="x" / name~"pre_*" | exactly, or by glob |
kind=function | function, method, class, key, selector, … |
lang=python | one of the eighteen |
file~"*_test.go" / in="src/adapters/" | by path |
annotated-with="test" | #[test], @Test, @pytest.mark |
exported / !exported | is or is not |
unused | nothing reaches it, from fr unused |
duplicated | part of a copy-paste class, from fr duplicates |
changed | this recipe already touched it |
calls="x" / called-by="x" | an edge in the call graph, from fr callers / fr callees |
implements="Sink" | a concrete answer to an abstraction, from fr implementations |
matches='log($X)' | a structural shape, from fr restructure. Needs lang= beside it |
Each of the last five reuses an existing analysis, and each runs only when a
predicate asks for it. Building a call graph over a large repository for a
selector that says name="x" is work nobody asked for.
matches= needs a lang= beside it, because the same text
parses into a different tree in every language, and no language-free answer says
where a shape occurs.
A selector that matches nothing stops the recipe. Doing nothing
quietly is the failure this most wants to avoid, because it looks just like
success. Write allow-empty when a step is genuinely conditional.
Refusals, transactions, expectations
on-refusal defaults to stop: abandon the run and write
nothing. report applies the rest and exits non-zero;
allow applies the rest and exits zero, because a person decided these
refusals were acceptable. No ignore exists, by design: every refusal
reaches the report, and the only question is what it costs.
A recipe file runs as one transaction. Either every recipe's edits land or none do, and each step and recipe sees the workspace as the previous one left it, which takes a re-index between them. It dry runs by default, as everything here does.
Five languages, five things it teaches
Writing one language as another
A translation makes a different promise from a refactoring. The signature is the contract, and anything with no counterpart travels into the output verbatim, and counted.