Skip to content

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:

  • $10 used to be corrupted by the pass that substituted $1. Group 10 now works.
  • A captured group whose own text contained something like $2 used 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 \b or \B, whose match depends on the characters around it. replace("abc abc", "\Bb(c)", "[$1]", regex=true) produced a[$1] a[$1] and now produces a[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.