Bash style¶
Chalk is written for bash 5.3 on the host and bash 5.2 inside the sandbox. This guide says how Chalk uses what those versions offer, so that every feature has one idiom and the code reads the same everywhere. The reasoning and the verification behind each rule are in the bash 5.3 spec.
Rules marked (lint) are checked by scripts/lint-conventions.sh, which
make lint runs on bin/chalk and lib/. ShellCheck checks every shell
file. Rules without the mark are checked in review, and apply to
scripts/ and tests/ as well. Lint messages cite the rule number, such
as (C1).
Where each version applies (C8)¶
| Code | Bash | Why |
|---|---|---|
lib/core/guard.sh |
3.2 | Runs before Chalk knows which bash it is under, then re-executes under 5.3. |
share/sandbox/scripts/*.sh |
5.2 | Runs inside the sandbox container. Debian 12, the default image, ships 5.2. |
| Everything else | 5.3 | Host code: bin/chalk, lib/, scripts/, tests/. |
The first line of guard.sh is # bash 3.2 syntax, and the first line of
each sandbox script is # bash 5.2 syntax (lint). CI parses the sandbox scripts with a real bash 5.2
(make lint-sandbox) and runs the guard under macOS's bash 3.2.
Layers¶
bin/chalk sources lib/core/guard.sh first, on its own, because it must
run under any bash. Then it loads three layers, in this order:
- Shell runtime (
lib/core/): log, runtime (options, traps), jobs, system (host profile, locks). - Chalk domain (
lib/): repo, forge, state, config, db, memory, sandbox, fingerprint, agent. - Commands (
lib/, thecmd_*functions): run, fleet, lifecycle, dashboard, setup, share.
A file uses what is loaded before it, never after. Function names start
with their module's name, such as repo_, db_ or jobs_. bin/chalk loads each
layer with source -p DIR NAME.sh, which looks only in DIR. (source -p
does not accept a name containing /, hence one loop per directory.)
C1. How a function returns a result¶
| Kind | Returns by | Called as | For |
|---|---|---|---|
| Value | setting REPLY, printing nothing |
x="${\| repo_name; }" |
Values computed in bash or cached |
| Fill | writing a caller-named variable | agent_usage "$file" usage |
Several values at once |
| Stream | printing to stdout | piped, redirected, or $( ) |
Text for files, jq, psql, git |
- A value function's header comment has the form
# name ARGS -> REPLY: what it returns. - A value function never returns non-zero. "No value" is an empty
REPLY, which the caller tests. In bash 5.3 withinherit_errexit, a${| … }whose function fails ends the shell even inside&&,||orif, and fires the ERR trap;$( )in the same place does not.dieis still fine: it ends Chalk on purpose. - The lint catches an explicit
return 1orreturn "$x"(lint). It cannot catch a last command that fails, so end a value function with an assignment toREPLYor with:. - Value functions run in the calling shell, so they can cache.
git_common_dirasks git once per working directory; with$( )the cache would vanish with the subshell.
A value function, from lib/repo.sh:
Called as name="${| repo_name; }". Do not write it like this:
# Wrong: when $dir is empty, [[ ]] fails, the function returns 1, and
# ${| ticket_dir; } ends Chalk.
ticket_dir() {
[[ -n $dir ]] && REPLY="$dir/specs"
}
Write the same thing so it always succeeds:
C2. ${ … } and ${| … }¶
They run in the current shell, not a subshell. So:
- No
cdinside one (lint). It changes Chalk's own directory. Use$( )or a( … )subshell. - No
< fileinside one (lint).${ <file; }returns nothing. Useread -r var < filefor a line or$(<file)for a whole file. - Use them for Chalk's own functions. For external commands,
$( )is just as good: the command forks either way.
branch="${| current_branch; }" # right: runs in this shell, can use caches
head="$(git rev-parse HEAD)" # right: an external command
root="${ cd "$dir" && pwd; }" # wrong: moves Chalk itself into $dir
C3. Namerefs¶
- A nameref local starts with two underscores (lint):
local -n __usage=$2. Callers never name a variable with__. Without this, a caller passing a variable named like the nameref gets a circular reference and its value lands in the wrong place. - When filling an associative array through a nameref, quote the keys:
__usage=(["input"]="$x"). ShellCheck cannot see the nameref's type and otherwise reads bare keys as arithmetic.
C4. Collections¶
- A list is an indexed array, never a space-separated string.
- A table is an associative array. Module-level tables use
declare -gA: a module may be sourced from inside a function (the unit tests do), where a plaindeclare -Awould create a local. - Test membership with
[[ -v table[$key] ]]. Validate keys that come from outside first, asload_configdoes. - Expand as
"${a[@]}". Empty arrays are safe underset -u; the${a[@]+"${a[@]}"}workaround is not needed. - Read lines from a command with
mapfile -t a < <(command).
C5. Strings, time and files¶
| Use | Not |
|---|---|
${v@U}, ${v@L} |
tr '[:lower:]' '[:upper:]' |
${v//[^a-zA-Z0-9_.-]/-} |
tr -c 'a-zA-Z0-9_.-' '-' |
An extglob pattern, e.g. ${v/$loopback/…} with loopback='//@(localhost\|127.0.0.1)' |
sed on a single value |
$EPOCHSECONDS |
$(date +%s) |
read -r v < file |
$(cat file) |
${v@Q} for a value the user may paste into a shell |
bare $v |
C6. Errors and signals¶
lib/core/runtime.sh sets set -Eeuo pipefail and
shopt -s inherit_errexit extglob nullglob globskipdots for every module.
- An unexpected failure prints the command and the call stack, then errexit ends Chalk. A function that returns non-zero on purpose is not reported.
dieis the only way to end Chalk on purpose.- INT, TERM and HUP exit with
128 + $BASH_TRAPSIG; EXIT traps still run. - With
nullglob, a loop over a glob that matches nothing runs zero times. Do not add[ -e "$x" ] || continueguards.
C7. Background work¶
Only through lib/core/jobs.sh:
jobs_init "$RUN_IO/startup"
jobs_spawn database db_up
jobs_spawn image sandbox_ensure_image
local -A started
jobs_wait started || … # started[database], started[image]: exit statuses
Each job logs to its own file, replayed when the job ends: stdout on
success, stderr on failure. die inside a job ends only that job.
Tests¶
tests/unit/*_test.sheach test one module. They sourcetests/unit/testlib.shand load modules withload core/log repo ….tests/e2e.shruns the whole workflow against the fakes intests/fakes/.- How to run them, and what a change needs, is in CONTRIBUTING.md.