Migrating to Rad v0.12¶
Version 0.12 has nine breaking changes, one of them added in v0.12.1. Four can change what a script does without raising an error, so they are worth reading rather than waiting to be told.
Shell commands:
- Values you interpolate into a command are now quoted, so each arrives as a single argument instead of as shell syntax.
- Capturing into names of your own fills
(stdout, stderr, code), where it used to fill(code, stdout, stderr). $takes only the command. Anything written after it reads the command's result, which breaks three spellings that used to build the command instead.
The command line:
- A list arg collects every bare positional value, instead of filling a single slot.
- A constraint on a list or variadic arg is enforced, having been accepted and ignored until now.
- A
--prefixed token is no longer accepted as a flag's value. - A list arg named with no value after it is an error, where it used to parse as an empty list and satisfy a required arg with nothing. (v0.12.1)
Text: split() and replace() match literally, with regex=true to opt back in.
Prompts: with no terminal available, a script that can prompt now stops before executing anything, instead of running until it reaches the prompt.
Five of the nine ship a rad check diagnostic, so start by sweeping the scripts
you've run recently:
rad check --from-logs all
How to Migrate covers the rest of the upgrade workflow.
Three of those five report an error, and rad refuses to run the affected scripts
until you fix them: hand-written quotes around an interpolation, an impossible arg
constraint, and the three $ spellings that no longer build a command. You will not
miss those.
The other two are warnings, and a script that trips only those still runs, with changed behavior: a capture name bound to a different stream than it promises, and a pattern that reads like a regex but now matches literally. Read those two sections closely.
The four changes with no check diagnostic split differently. A rejected flag-like
value, a bare list flag, and a blocked prompt all fail loudly the moment they
happen. A list arg swallowing a value meant for a later arg does not fail at all,
and no check can see it - list args collect every positional value
tells
you how to spot it from your own --help output instead.
Breaking Change: Shell Commands Quote Interpolated Values¶
What Changed¶
A value interpolated into a shell command now reaches the program as exactly one argument, whatever characters it contains:
message = "it's fine"
$`git commit -m {message}`
// v0.11: git commit -m it's fine -> shell syntax error
// v0.12: git commit -m 'it'\''s fine'
The text you write is still shell. Pipes, redirects, &&, globs and $VAR in the
literal parts of the command all work exactly as before. Only the interpolations
changed.
Values made only of A-Z a-z 0-9 @ % + = : , . / _ - pass through unquoted, so
most commands produce byte-identical output to v0.11.
A list interpolates to one argument per element. A command that is a list, rather
than a string, skips the shell entirely and is executed directly. Rad quotes
interpolated values as POSIX sh whichever shell ends up running the command, so a
string command carrying interpolations is wrong under PowerShell, cmd.exe,
csh/tcsh and xonsh. The list form never reaches a shell, so it is the one to
use there.
Why¶
Hand-written quotes could never be correct for every value, and both spellings fail on ordinary data:
| You wrote | Value | v0.11 result |
|---|---|---|
-m "{msg}" |
it's |
works |
-m "{msg}" |
100% of $USERS |
silently becomes 100% of |
-m '{msg}' |
a b |
works |
-m '{msg}' |
it's o'clock |
silently becomes its oclock |
-m {msg} |
a b |
silently becomes two arguments |
There is no quoting a script can write in advance that survives every value. Rad knows the value at the moment it interpolates, so it does the quoting there.
This also closes an injection route. A value from an HTTP response, a filename, or
an LLM could previously carry ; rm -rf ~ into a command and have it run.
What To Do¶
1. Delete your quotes. RAD40023 flags interpolations wrapped in quotes the
script wrote itself.
message = "hello"
// Before
$`git commit -m "{message}"`
// After
$`git commit -m {message}`
2. Hoist arguments that mix literal text with a value. The quotes were holding the two halves together as one argument; a single interpolation does that alone:
version = "1.2.3"
// Before
$`git commit -m "Bump to {version}"`
// After
message = "Bump to {version}"
$`git commit -m {message}`
3. Turn joined lists into real lists. A list now expands to one argument per
element, each quoted separately, so the join(" ") workaround is obsolete - and it
was losing any element containing a space:
files = ["My Notes.txt", "b.txt"]
// Before - one argument, or several wrong ones
$`tar -czf out.tgz {files.join(" ")}`
// After - one argument per file
$`tar -czf out.tgz {files}`
An empty list contributes no arguments at all, which replaces the conditional flag-fragment trick:
args:
all bool
// Before
extra = all ? "--hidden --no-ignore" : ""
// After
extra = all ? ["--hidden", "--no-ignore"] : []
4. Convert commands you assemble piecewise into lists. Building a command as a
string and running it with $cmd still works and is still verbatim - the
interpolation already happened when the string was built, so there is nothing left
for Rad to protect. A list is safe and reads better:
args:
start str?
path = "clip.mp4"
// Before
cmd = `ffmpeg -i '{path}'`
if start:
cmd += " -ss {start}"
$cmd
// After
cmd = ["ffmpeg", "-i", path]
if start:
cmd += ["-ss", start]
$cmd
Two shapes that used to produce a mangled command now fail outright, both under
RAD20045: interpolating a value with no single-argument form (a map, or a null
that would have become the four-character word null), and interpolating a list
that isn't standing alone as its own argument, such as --file={files}, where
there is no obvious answer to which element takes the prefix.
What The Check Misses¶
RAD40023 reads command literals. It cannot see a command assembled into a string
elsewhere and run with $cmd, because by then the interpolation has already
happened and there is nothing left to flag:
path = "a b.txt"
cmd = `ls '{path}'` // no warning possible
$cmd
That form is unchanged in v0.12 - it was raw before and is raw now - but it is also where the old quoting bugs live. If you have scripts that build command strings, read them by hand and convert them to lists.
Breaking Change: Shell Captures Bind stdout First, Exit Code Last¶
What Changed¶
Shell commands produce stdout, stderr, and an exit code. When any variable in the
statement isn't named code, stdout, or stderr, Rad fills them all by
position - and that order has changed:
// v0.11: (code, stdout, stderr) v0.12: (stdout, stderr, code)
out = $`rad -v` // v0.11: 0 v0.12: "rad v0.12.0\n"
out, err = $`rad -v` // v0.11: 0, output v0.12: output, errors
out, err, c = $`rad -v` // v0.11: 0, out, err v0.12: out, err, 0
Named assignment is unchanged. If every variable in the statement is spelled
exactly code, stdout, or stderr, Rad assigns by name and always did:
code = $`cmd` // unchanged
stdout = $`cmd` // unchanged
code, stdout = $`cmd` // unchanged
stderr, stdout, code = $`cmd` // unchanged
Why¶
out = $cmd binding the exit code was the wrong answer to the most natural-looking
question. Every other language with a capture idiom - bash's $(...), Python's
check_output, Ruby and Perl's backticks - gives you the output. Rad gave you a
number.
The number wasn't even useful. Rad assigns the capture variables before it checks
the exit code, and an unhandled non-zero exit stops the script. So in out = $cmd
with no catch:, out is 0 on every line that can read it - it cannot be
anything else. The most natural capture form was reserved for the one value that
carried no information.
Moving the code last also makes the two assignment rules agree. stdout = $cmd
used to mean stdout by name but the exit code by position; now both readings give
you stdout.
What To Do¶
RAD40018 flags capture targets whose names promise a different stream than they
now receive.
1. For mixed naming, rename rather than reorder. The most common affected shape uses a reserved name alongside an ordinary one, which drops the whole statement to positional:
// Before - 'out' isn't reserved, so 'code' silently receives stdout
code, out = $`make test` catch:
print_err("failed: {out}")
// After - now all names are reserved, so assignment goes by name
code, stdout = $`make test` catch:
print_err("failed: {stdout}")
This is a rename, and it captures exactly the same streams as before. It's the
best fix for code, out = ... and code, out, err = ....
2. For discard-then-capture, drop a slot. _, x = $cmd was the old idiom for
"skip the exit code, take stdout". Stdout is now first, so the _ goes:
// Before
_, version = $`rad -v`
// After
version = $`rad -v`
Careful with the three-target form - the old middle slot is now the first:
// Before
_, output, _ = $`pwd`
// After
output = $`pwd`
3. Check your silencing. Discards follow the same order, so silencing needs one fewer slot than it used to:
// Before: three slots to swallow both streams, two to swallow stdout
_, _, _ = $`cmd`
_, _ = $`cmd`
// After: one fewer each
_, _ = $`cmd`
_ = $`cmd`
What The Check Misses¶
RAD40018 reads variable names, so a positional capture whose names give no
signal either way is invisible to it:
a, b = $`cmd` // no warning possible
It also stays deliberately quiet on names that are genuinely ambiguous. status
is the instructive one: it reads as "exit status", but capturing git status into
a variable called status is a perfectly good v0.12 capture of stdout, so flagging
it would fire on correct new code. If a script captures into names like these,
read it by hand.
Breaking Change: $ Takes The Command, Not The Whole Expression¶
What Changed¶
$ used to bind an entire expression, so everything written after a command
became part of building it. $ now takes only the command - a string, a list, a
variable, or a parenthesized expression - and everything after it reads the
command's result.
Three spellings meant something other than what they read like, and rad check
reported none of them:
x = $`echo hi`.upper() // ran ECHO HI
x = $`cmd` catch "fallback" // caught the command string; the handler never ran
x = $`cmd` ?? "fallback" // the same
The last two are the ones worth stopping on. They look exactly like working
error handling. What actually happened is that catch and ?? applied to the
string `cmd`, which never fails, so the command's own failure went
unhandled and ended the script:
⚡️ false
error[RAD20000]: Command exited with code 1
--> script.rad:1:1
|
1 | x = $`false` catch "fallback"
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Statement forms are untouched:
$`make build`
stdout, stderr, code = $`cmd`
$`make build` catch:
pass
Why¶
$ had to stop binding the whole expression before a command could be read where it
stands. That is what reading a command inline buys.
What To Do¶
| Old | New |
|---|---|
$cmds[1] |
$(cmds[1]) |
$parts.join(" ") |
$(parts.join(" ")) |
$`echo hi`.upper() |
$`echo hi`.stdout.upper() |
$`cmd` catch "x" |
$`cmd`.stdout catch "x" |
$`cmd` ?? "x" |
$`cmd`.stdout ?? "x" |
All of them are check-time errors (RAD40025, RAD40026), so nothing changes
meaning quietly. The computed-command rows are mechanical, and editors offer the
parenthesized form as a quick fix. $(...) has always parsed and run, so the fix
works on older versions too.
Breaking Change: List Args Collect Every Positional Value¶
What Changed¶
A list arg used to fill exactly one positional slot. Now it collects every bare value until the next flag:
args:
targets str[]
./script a.txt b.txt c.txt
# v0.11: error, "Too many positional arguments. Unused: [b.txt, c.txt]"
# v0.12: targets = ["a.txt", "b.txt", "c.txt"]
Through its own flag nothing changed - --targets a --targets b is still how you
repeat it, and --targets a b is still an error, because the flag takes one value
and b has nowhere left to go.
Why¶
./script a.txt b.txt c.txt is what everyone types first, and it was the one
thing that didn't work. The fix was to declare the arg variadic (*targets str)
instead, which differs by a sigil and was invisible from the error.
Now both spellings collect positionals, and they differ where it's meaningful:
after its own flag, a variadic is greedy and a list takes one value.
What To Do¶
An arg declared after a list arg can no longer be given positionally. This is the case that can move a value silently, and there is no diagnostic for it:
args:
files str[]
mode str = "fast"
./script a b
# v0.11: files = ["a"], mode = "b"
# v0.12: files = ["a", "b"], mode = "fast"
Where the later arg is required you'll get a missing-argument error, which is loud. Where it has a default, as above, the value just moves.
Run --help: if the usage line ends at your list arg, everything declared after
it is flag-only now. Either reorder the declarations so the list comes last, or
tell users to pass the later arg by flag.
Scripts using *variadic args are unaffected.
Breaking Change: Constraints On A List Arg Are Enforced¶
What Changed¶
enum, regex and range could be declared on a list or variadic arg. They
were accepted, shown nowhere, and never checked:
args:
scores int[]
scores range [0, 100]
./script --scores 5 --scores 500
# v0.11: scores = [5, 500]
# v0.12: error, "'scores' value 500 is > maximum 100"
On a list, a constraint describes each value. Every value has to pass, whether
it arrived through a repeated flag or as a bare positional. The constraint also
now appears in --help, where it showed a bare --scores ints before.
Declaring a constraint the arg's type cannot honor - enum on an int[],
range on a str[] - is now an error. That check catches the same mistake on
scalar args, which were equally silently ignored: age int with age enum [...]
never did anything either.
Why¶
A script that declares a constraint reads as though it validates its input. Whoever wrote it stops checking by hand, because the args block says the work is done. It wasn't: the constraint reached the argument parser and was dropped there, and the values flowed into the script unexamined - into shell commands, in the cases that prompted this.
Interactive mode had been checking these all along, so rad -i already rejected
what plain rad accepted. One of the two was wrong, and it wasn't the one doing
the checking.
What To Do¶
RAD40024 finds constraints that can never apply. Fix or delete those.
Then look for scripts that declare a constraint on a list and are passed values violating it. Those were already not doing what they said; they will now fail loudly.
What The Check Misses¶
RAD40024 cannot tell you that a constraint which does fit will now start
rejecting values it previously let through - that depends on what your users pass,
not on what the script says. If a script has a declared-but-ignored constraint that
its callers have been violating, the first sign will be the run that fails.
Breaking Change: Flag-Like Tokens Are Rejected As Values¶
What Changed¶
A flag used to take whatever token followed it. Now a --prefixed token is read
as a flag:
./script --name --verbose
# v0.11: name = "--verbose", and --verbose was never set
# v0.12: error
To pass a value that starts with -, use the = form (--name=--verbose) or
put it after --.
Why¶
A mistyped flag became data, silently, and scripts were left hand-rolling their own leading-dash checks to catch what the parser should have caught.
What To Do¶
Nothing, unless a script passes --prefixed values to its own subcommands or
tools. This one fails loudly the moment it happens.
Negative numbers still work for number args, so --count -5 is unaffected. The
carve-out is typed, though: for a str arg, --name -5 is now an error and
--name=-5 is how you write it. A bare - is still a value, so --output -
keeps working.
If your script hand-rolls a starts_with(value, "-") rejection, you can drop it.
Breaking Change: A Bare List Flag Is Rejected¶
Added in v0.12.1.
What Changed¶
A list arg named with nothing after it used to parse as an empty list, and the script ran:
./script --files
# up to v0.12.0: files = [], and the script ran
# v0.12.1: error
Naming the flag counted as supplying it, so this satisfied a required list arg
with no values at all. Omitting --files was correctly rejected; naming it and
stopping was not. Where the arg had a default, naming it bare did nothing
whatsoever - the default came through unchanged.
A list arg takes one value per occurrence, so repeat the flag for more than one:
./script --files a.txt --files b.txt
Variadic args are unaffected. Collecting nothing is their documented behavior, so
with *files str declared, ./script --files still leaves the list empty and the
script still runs.
Why¶
A required arg you can satisfy by naming it and supplying nothing is not
required. The parser knew this for every other kind of arg - --name with
nothing after it has always been an error for a str - but a list took the
missing value as an empty one.
No reading of the old behavior was useful. A bare flag could not even clear a default, so there was no way to use it to mean "explicitly empty"; the only case where it was observable at all was the required one, where it was wrong.
What To Do¶
Nothing, unless something invokes your script with a list flag last and no value after it - a shell alias, a CI step, a wrapper script. This fails loudly the moment it happens, so you will not be left guessing, but it fails at a point that used to succeed.
There is no rad check diagnostic for this one, and there cannot be: the mistake
is in how a script is invoked, not in what it contains.
Breaking Change: split() and replace() Match Literally¶
What Changed¶
Both functions used to treat their pattern as a regex. Now they look for the exact text you give them:
print(split("1.2.3", ".")) // v0.11: ["", "", "", "", "", ""] v0.12: ["1", "2", "3"]
print(replace("a.b", ".", "-")) // v0.11: "---" v0.12: "a-b"
Pass regex=true to get the old behavior:
print(split("a b c", "\s+", regex=true)) // -> ["a", "b", "c"]
print(replace("order 66", "\d+", "N", regex=true)) // -> "order N"
matches() is unchanged. It exists to test patterns, so it's still always a regex.
Two things changed alongside:
replace()'s replacement text is now literal too. In the default mode, $1
and $$ in the replacement are inserted as written. Previously they were always
interpreted, which meant relaying text through replace() could mangle it. Group
references still work under regex=true.
split() no longer guesses. It used to compile the separator as a regex and,
if that failed, silently fall back to a literal split. Whether a call was a regex
split or a literal one was decided at runtime by the shape of the input:
// v0.11
split("a.b", ".") // -> ["", "", "", ""] (a valid regex, so: regex)
split("a(b", "(") // -> ["a", "b"] (not a valid regex, so: literal)
Now the separator is literal unless you say otherwise, and under regex=true a
pattern that doesn't compile is an error.
Why¶
Two reasons, and the second is the one that matters.
The first is that literal is what most calls want. Across the Rad scripts we
surveyed, the large majority of split and replace calls passed a pattern with
no regex constructs in it. The default was serving the minority.
The second is how the two mistakes fail. Reaching for a literal when you needed a
regex gives you an obviously wrong answer that you notice immediately. Reaching for
a regex when you needed a literal corrupts quietly: a . matches every character,
a $ in text you relayed from somewhere else eats your replacement. Silent
corruption is the worse failure, and it belonged to the old default.
What To Do¶
RAD40016 flags patterns that read like regexes but have no regex=true:
warning[RAD40016]: '\s+' reads as a regex ('\s'), but split() matches it literally
--> parse.rad:4:22
|
3 |
4 | fields = split(line, "\s+")
| ^^^^^
5 |
|
= help: Pass regex=true, or ignore this if the text really is literal - otherwise it silently
matches nothing. See https://amterp.dev/rad/migrations/v0.12/
= info: rad docs RAD40016
1. Add regex=true where you meant a regex.
2. Drop the escaping where you didn't. Patterns that only existed to escape metacharacters get simpler:
// Before
print(split("1.2.3", "\."))
// After
print(split("1.2.3", "."))
3. Check your replace() replacements. If a replacement string contains a $
that you wanted taken literally, it now works without a workaround. If it contains
a $1 that you wanted expanded, the call needs regex=true.
What The Check Misses¶
RAD40016 only reads patterns that are written literally in your source. A pattern
held in a variable or built at runtime is invisible to it:
sep = get_env("FIELD_SEP")
parts = split("a b c", sep) // no warning possible
It also stays quiet on patterns whose metacharacters are common in ordinary text,
. and a lone | in particular, because warning on those would bury the real
hits. If you have a script that assembles patterns dynamically, read it by hand.
Group References In replace()¶
While making the replacement literal, the $-expansion under regex=true was
tightened up. Four long-standing bugs are fixed:
$10used to be corrupted by the pass that substituted$1. Group 10 now works.- A captured group whose own text contained something like
$2used to get substituted a second time. Each output character is now written once. - There was no way to write a literal
$, or to separate a group reference from a following digit.$$and${1}now do both. - Groups came back empty when the pattern used a zero-width assertion such as
\bor\B, whose match depends on the characters around it.replace("abc abc", "\Bb(c)", "[$1]", regex=true)produceda[$1] a[$1]and now producesa[c] a[c].
The full rules are in the replace reference.
Breaking Change: Scripts That Prompt Stop Before Running Without A Terminal¶
What Changed¶
A script calling input, confirm, pick, pick_kv, pick_from_resource,
multipick, or a confirm-gated shell command used to run until it reached the
prompt and then fail. Now rad checks first, and refuses to start:
print("fetching")
env = pick(["dev", "prod"])
rad deploy.rad < /dev/null
# v0.11: prints "fetching", then error[RAD20000]: pick requires an interactive terminal
# v0.12: error[RAD20046], listing every prompt. Nothing runs.
Two things soften this. Rad now looks for a terminal on /dev/tty as well as
stdin, so echo x | rad script.rad and the rad - --shell Bash embedding both
prompt normally where they previously could not. And on a terminal nothing
changes at all: the check only runs when neither is available, which means CI,
cron, and AI agent tool calls.
Why¶
A prompt reached halfway through a run has already done whatever the script did to get there - fetched, written, deleted. The caller then has to work out what completed before they can safely retry. Checking up front is the difference between a run you can repeat and a run you have to unpick.
It also makes --reply possible. Rad has to know
which prompts a script has before it can accept answers for them.
What To Do¶
Answer each prompt with --reply, keyed by the line it sits on. Rad prints your
own command back with a --reply per prompt, keys in place and a blank where each
answer goes. Fill in the blanks and run it.
A prompt on a branch this run won't take still counts. Rad reads the script, not the future, so it lists every prompt reachable from the command you invoked. Where you know a prompt won't be hit, say so rather than inventing a value:
rad deploy.rad --reply-na 20
Reaching it anyway fails cleanly (RAD20047) instead of acting on a guess.
A pick given a filter is the case most likely to bite. It narrows its
options first and never asks when exactly one survives, so in v0.11 this pattern
ran fine without a terminal:
args:
name str
servers = ["web-1", "web-2"]
server = pick(servers, name)
Rad cannot evaluate name before running, so it lists the prompt and blocks.
Add --reply-na <line> for the runs where you expect the filter to resolve it.
The listing marks these as filtered, so they are easy to spot.
--confirm-shell with no terminal is now refused outright. It gates every
shell command, including ones rad cannot see in advance, so it cannot be
satisfied with --reply. Drop the flag, or run where a terminal exists.
Scripts with no interactive calls are unaffected, as are all runs on a terminal.
Behavior Change: ?? and catch See Errors You Already Hold¶
This one isn't counted among the eight - the old behavior was never the documented one - but it can change a result.
What Changed¶
Both operators fired when a call failed, but ignored an error value sitting directly in front of them:
r = error("boom") ?? "fallback"
// v0.11: r is the error. v0.12: r is "fallback"
The same applies to an error that a catch: block bound earlier:
fn fail() -> str|error:
return error("boom")
stored = fail() catch:
pass
r = stored catch "default"
// v0.11: r is the error. v0.12: r is "default"
Why¶
The guide, the syntax reference, and the type checker all already specified that these operators fire on an error. Only the interpreter disagreed. Rather than document the exception, we removed it.
What To Do¶
Nothing, unless a script deliberately used ?? or catch to pass an error
through. A held error is otherwise unchanged - returning it from a function still
raises it there. See Holding an Error.
New: Reading A Command Inline¶
A command can now be read where it stands, without capturing it into a variable first:
branch = $`git branch --show-current`.stdout.trim() catch "unknown"
if not $`which docker`.ok:
exit(1)
.stdout and .stderr give you the output and fail if the command did. .code
and .ok describe the outcome and never fail, which is what makes them the way to
test a command. See
Reading One Result Inline.
New: len Bounds On List Args¶
len bounds how many values a list or variadic arg takes, using the interval
notation range already uses:
args:
pair str[]
*ports int
pair len [2,2] // exactly two
ports len [1,] // at least one
This is additive - no existing script is affected.
Error Messages¶
The codes v0.12 introduces, and where each one fires. Run rad docs RAD<code> for
the full explanation of any of them.
| Code | Severity | Fires at | Means |
|---|---|---|---|
RAD40016 |
warning | rad check |
A split() or replace() pattern reads as a regex, but now matches literally |
RAD40018 |
warning | rad check |
A capture target's name promises a different stream than its slot delivers |
RAD40023 |
error | rad check |
A shell interpolation is wrapped in quotes the script wrote itself |
RAD40024 |
error | rad check |
A constraint the arg's type cannot honor |
RAD40025 |
error | rad check |
A command used as a value with no accessor |
RAD40026 |
error | rad check |
Something other than an accessor follows a command |
RAD20045 |
error | run time | A value with no single-argument form was interpolated into a command |
RAD20046 |
error | start up | The script can prompt, and there is no terminal to prompt at |
RAD20047 |
error | run time | A prompt was reached without a usable answer |
RAD20048 |
error | run time | A shell command exited non-zero |
RAD20048 is not a breaking change. Shell failures used to report the catch-all
RAD20000, which meant rad docs had least to say about the runtime error scripts
hit most often.