Linux & Bash · Part II · Module 35 · Reusable Flow Tooling and Hand-Off
A Shared Function Library for a Project's Scripts
Four modules of this track produced twenty-nine shell function definitions, ten of them the same function. This chapter measures what those ten had already become, consolidates them into one sourced library with a written contract, and then breaks that library eleven ways to find out whether its test suite is worth anything.
Advanced27 min readLinuxPart IILibrariesTestingMaintenance
Part II · Module 35 · Chapter 35.1 · A Shared Function Library
Module 34 finished the flow's behaviour: it fails on what it should, bounds what could run forever, and returns a status naming the stage. What it did not address is that the flow is now a dozen scripts somebody has to maintain, and this module is about the difference between automation that survives its author and automation that does not.
The first measurement is of my own code.
1. Twenty-Nine Definitions, Ten of Them the Same Function
The four fixtures behind modules 31 to 34, counted:
digest defined 10 time(s): m32/regress m32/rerun m33/digestlib.sh m33/regress …
strip defined 7 time(s): m31/matrix m32/matrix m32/regress m33/matrix m33/regress …
die defined 3 time(s): m34/flow_loud m34/flowlib.sh m34/probe_m347.sh
step defined 3 time(s): m34/flow_loud m34/flowlib.sh m34/probe_m347.sh
norm defined 2 time(s): m33/classify m34/classify
note defined 1 time(s): m34/flowlib.sh
need defined 1 time(s): m34/flowlib.sh
want defined 1 time(s): m34/flowlib.sh
transient defined 1 time(s): m34/retry
---- 29 function definitions across the four fixturesdigest exists ten times. Not because anybody decided to duplicate it — because each script needed a digest, the function was five lines, and copying five lines is faster than deciding where a library lives.
2. What the Ten Copies Had Already Become
$ md5 -q dup/d*.txt | sort -u | wc -l
2 4 copies: d10 d3 d6 d7 (m33/digestlib.sh m33/runone m34/digestlib.sh m34/runone)
6 copies: d1 d2 d4 d5 d8 d9 (m32/regress m32/rerun m33/regress m33/rerun m34/regress m34/rerun)Two distinct implementations. And here is the honest part, because the obvious expectation is wrong:
=== are the two versions behaviourally identical? ===
d1 -> 66e7e66ea27fe3015a43f93dca688604 rc=0
d3 -> 66e7e66ea27fe3015a43f93dca688604 rc=0Identical output, identical status. The drift is cosmetic:
local variable name : d vs x
case formatting : 5 lines vs 1
the error message : no working md5 tool found for '$f'
no working md5 tool for '$f'3. One Library, With a Written Contract
# lib/flow.sh -- the functions every script in this flow was repeating.
#
# SOURCE this; never copy it. Measured in the fixtures that preceded it:
# `digest` existed in 10 copies and `strip` in 7, and the 10 digests had
# already drifted into two spellings whose error messages differ -- so a log
# search for one phrase found 6 of the 10 sites and for the other, 4.
#
# CONTRACT
# every function writes diagnostics to stderr and nothing to stdout except
# its documented result; every loop variable is `local`; no function exits
# except `die`; and no function is silent on failure.
#
# shellcheck is not installed on this machine, so t/flow_test.sh is the guard.
: "${FLOW_LIB_VERSION:=1}" # bumped when a contract changes, not a body
FLOW_NAME=${FLOW_NAME:-flow}
# --- diagnostics --------------------------------------------------------
# die MESSAGE [STATUS] -- stop, name the problem, say which stage. Never 1:
# 1 belongs to a failing test (34.1), so a flow problem must not look like one.
die() { printf '%s: %s\n' "$FLOW_NAME" "$1" >&2; exit "${2:-2}"; }
# step N TOTAL MESSAGE -- progress a human reads while it runs. n/total, so a
# reader knows how much is left; a silent stop is indistinguishable from a hang.
step() { printf '%s: %s/%s %s\n' "$FLOW_NAME" "$1" "$2" "$3" >&2; }
# note MESSAGE -- recorded, never fatal. Exists so `|| true` does not have to.
note() { printf '%s: note: %s\n' "$FLOW_NAME" "$1" >&2; }
# --- preconditions ------------------------------------------------------
# need TOOL... -- all of them, reported together, before any is used.
need() {
local miss="" t # `t` MUST be local: without it this clobbers
for t in "$@"; do # a caller's $t and the flow ran a test
command -v "$t" >/dev/null 2>&1 || miss="$miss $t" # called "vvp" (34.7)
done
[ -z "$miss" ] || die "not on PATH:$miss" 3
}
# want FILE... -- every input readable before any work starts.
want() { local f; for f in "$@"; do [ -r "$f" ] || die "cannot read '$f'" 3; done; }
# --- identity -----------------------------------------------------------
# digest FILE -- one md5, by ABSOLUTE tool path, or a refusal.
# md5 lives in /sbin here and md5sum does not exist, so PATH cannot be trusted:
# an unguarded fallback chain produced an EMPTY digest under cron, and two
# empty digests compared equal (32.8).
digest() {
local f=$1 t d=""
[ -r "$f" ] || { printf '%s: digest: cannot read %s\n' "$FLOW_NAME" "$f" >&2; return 2; }
for t in /sbin/md5 /usr/bin/md5 /usr/bin/md5sum /sbin/md5sum; do
[ -x "$t" ] || continue
case $t in
*md5sum) d=$("$t" "$f" | cut -d' ' -f1);;
*) d=$("$t" -q "$f");;
esac
[ -n "$d" ] && break
done
[ -n "$d" ] || { printf '%s: digest: no working md5 tool for %s\n' "$FLOW_NAME" "$f" >&2; return 1; }
printf '%s\n' "$d"
}
# --- lists --------------------------------------------------------------
# strip FILE -- the file's content with comments and blank lines removed.
# One definition, so a `# comment` means the same thing in a test list, a seed
# list and a filelist (31.2).
strip() {
local f=$1
[ -r "$f" ] || { printf '%s: strip: cannot read %s\n' "$FLOW_NAME" "$f" >&2; return 2; }
sed -e 's/[[:space:]]*#.*$//' -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' "$f" \
| /usr/bin/grep -v '^$'
}
# --- paths --------------------------------------------------------------
# abspath DIR -- create it if needed and print it absolutely. Called BEFORE
# anything cds: a relative path crossing a `cd` has broken a flow eight times
# in this track.
abspath() {
local d=$1
[ -n "$d" ] || { printf '%s: abspath: need a path\n' "$FLOW_NAME" >&2; return 2; }
mkdir -p "$d" 2>/dev/null || { printf '%s: abspath: cannot create %s\n' "$FLOW_NAME" "$d" >&2; return 1; }
( cd "$d" && pwd )
}Seventy-four lines, eight functions. Four things about it are load-bearing.
The contract is in the file
# CONTRACT
# every function writes diagnostics to stderr and nothing to stdout except
# its documented result; every loop variable is `local`; no function exits
# except `die`; and no function is silent on failure.A library is a promise to code you have not read. The four clauses above are the promises this library makes, and each exists because its violation was measured: diagnostics on stdout contaminate a captured result, a non-local loop variable clobbered a caller's $t in Chapter 34.7, a function that exits turns a recoverable check into a dead flow, and a silent failure is this whole track's subject.
Writing the contract down is what makes Section 4 possible, because a test suite is a contract expressed as assertions, and you cannot assert a promise nobody stated.
Every comment says what it cost
digest() {
local f=$1 t d=""
# md5 lives in /sbin here and md5sum does not exist, so PATH cannot be trusted:
# an unguarded fallback chain produced an EMPTY digest under cron, and two
# empty digests compared equal (32.8).Not what the code does — the code says that — but why it is shaped this way, with the measurement that forced it. A future maintainer looking at four absolute paths and wondering why command -v md5 is not good enough has the answer in place, and Chapter 35.6 is this habit at the scale of a whole flow.
FLOW_LIB_VERSION is bumped for contract changes, not bodies
: "${FLOW_LIB_VERSION:=1}" # bumped when a contract changes, not a bodyFixing digest's message is not a version change; making die return 1 is. A version that increments on every edit is a version nobody reads, and Chapter 35.8 is about the consequences of getting that wrong over a project's life.
And it is sourced, never executed
There is no #! line, which is deliberate: a file with a shebang invites being run, and running this one does nothing. lib/flow.sh is a .sh under lib/ and every user does
FLOW_NAME=regress; . "$root/lib/flow.sh"setting the name before sourcing, so FLOW_NAME=${FLOW_NAME:-flow} picks it up and every message says which flow produced it.
4. A Library Has One Owner, So It Can Have Tests
This is the actual argument for consolidation, and it is stronger than "duplication is bad".
Ten copies of digest cannot be tested. Not "are not" — cannot, because a test asserts something about a named thing, and there are ten things with the same name in ten files, each reachable only by sourcing the script that contains it, which also runs that script. One library in one place is a thing a test can import.
run() { ( FLOW_NAME=t; . "$root/lib/flow.sh"; eval "$1" ) 2>&1; }
rc() { ( FLOW_NAME=t; . "$root/lib/flow.sh"; eval "$1" ) >/dev/null 2>&1; echo $?; }Each case in its own subshell, because die exits and a test must not — the one place where a library function's contract ("no function exits except die") directly shapes the harness.
flow.sh contract tests
ok step writes nothing to stdout
ok step writes to stderr
ok note is prefixed
ok die defaults to 2
ok die honours a status
ok need passes for real tools
ok need reports all missing
ok need leaves $t alone
ok want passes on a real file
ok want refuses a missing file
ok digest is stable
ok digest is 32 hex chars
ok digest refuses a missing file
ok digest refuses with no md5 tool
ok digest prints nothing when it refuses
ok strip removes comments and blanks
ok strip refuses a missing file
ok abspath is absolute
ok abspath resolves a relative path
ok abspath resolution is real
ok abspath created the directory
ok abspath refuses an empty arg
22 passed, 0 failedNote need leaves $t alone — a test that exists only because Chapter 34.7 measured the bug it guards:
got=$( ( FLOW_NAME=t; . "$root/lib/flow.sh"; t=smoke; need sh ls >/dev/null 2>&1; echo "$t" ) )
check "need leaves \$t alone" "$got" "smoke"That assertion is the entire value of a library over copies. The bug existed once, was found once, was fixed once, and is now guarded once — against nine call sites.
5. Breaking It Eleven Ways
A suite that has only ever passed is not known to work, which is Chapter 21.8's discipline. So: eleven mutations, one per contract clause.
MUTATION caught? which test(s)
die returns 1 instead of 2 yes (1) die defaults to 2
step writes to stdout yes (2) step writes nothing to stdout; step writ…
need loses `local t` yes (1) need leaves $t alone
need dies on the first miss yes (1) need reports all missing
want returns 1 instead of 3 yes (1) want refuses a missing file
digest drops the readability check yes (1) digest refuses a missing file
digest drops the no-tool refusal yes (1) digest refuses with no md5 tool
strip stops removing comments yes (1) strip removes comments and blanks
strip stops removing blank lines yes (1) strip removes comments and blanks
abspath prints a relative path yes (2) abspath resolves a relative path; abspat…
abspath accepts an empty argument yes (1) abspath refuses an empty argEleven mutations, eleven caught. It did not start that way.
6. What Belongs in the Library, and What Does Not
The eight functions above share one property: they are about the flow's mechanics, not about the design. That is the dividing line, and it is more useful than "anything used twice".
| in the library | why |
|---|---|
diagnostics — die, step, note | every script needs them and they must agree |
preconditions — need, want | the same checks, the same statuses |
identity — digest | one definition, so a digest means one thing |
list handling — strip | so # means the same in every list |
path resolution — abspath | eight failures in this track came from not doing it |
| not in the library | why |
|---|---|
transient — which failures are retryable | tool-specific; it belongs with the tool wrapper |
norm — how to normalise a diagnostic | one tool's message format |
| the verdict rules | Chapter 32.3's seven classes are a policy, versioned separately |
| anything that reads the design | a library used by every script must not know the design |
The second table is the harder judgement. transient is used by one script today and will be used by three, and the temptation is to move it in. The reason not to is that it encodes a fact about a particular tool's messages, so a project with two simulators needs two of them — and a library function with a case on which tool is running has become the flow rather than a library for it.
Practice
- Count the function definitions across your own flow's scripts.
- Find the function defined most often, and extract every copy.
- Hash them and count the distinct implementations.
- Diff two of them and classify the difference as behavioural or cosmetic.
- If a diagnostic's wording differs, grep your logs for each version and count the call sites each finds.
- Write the four-clause contract for a library of your own.
- For each clause, name the measurement that forced it.
- Put the reason, not the behaviour, in every comment.
- Decide what a version number means, and what does not change it.
- Omit the shebang and explain why.
- Set the flow's name before sourcing and confirm the messages are prefixed.
- Write the test harness with each case in its own subshell, and say why.
- Write an assertion for a bug you have already had.
- Mutate each contract clause once and record which test catches it.
- Find a survivor, and decide whether it is a gap or a harness bug.
- Verify that each mutation changed the file.
- Find a test whose input already satisfies the property it checks.
- Fix it by changing the input rather than the assertion.
- Classify your own functions into library and not-library.
- Take the hardest case and argue both ways.
- Put
lib/besidebin/and source it by an absolute path. - Make the library's path configurable, then explain what becomes unverifiable.
Check Your Understanding
- How many function definitions were counted, and which function appeared most?
- Why does duplication happen, in the honest account?
- How many distinct implementations did the ten copies have?
- Were they behaviourally different? What was the evidence?
- What had actually diverged?
- What is the measured cost of the message difference?
- State the real argument against ten copies.
- How do copies usually diverge?
- Give the library's four contract clauses.
- Why does writing the contract down matter?
- What should a comment in a library say?
- What changes
FLOW_LIB_VERSIONand what does not? - Why no shebang?
- Why must
FLOW_NAMEbe set before sourcing? - State the real argument for consolidation.
- Why can ten copies not be tested?
- Why is each test case in its own subshell?
- What is the single most valuable assertion in the suite, and why?
- How many mutations, and how many were caught at first?
- What was gap 1, and what failure does it correspond to?
- What was gap 2, and why did the test pass despite the bug?
- State the general trap gap 2 illustrates, and name two earlier instances.
- What was the third survivor, and why?
- What one line removes that class of error?
- State the rule about mutation results.
- What is the dividing line for library membership?
- Why does
transientnot belong, even though it will be used three times? - Why
lib/besidebin/rather than inside it? - Why source by an absolute path, and what breaks with each wrong form?
- What is the one path in a flow that should stay hardcoded, and why?
7. Remember
- Twenty-nine function definitions across four fixtures,
digestten times — not by decision, but because copying five lines is cheaper than choosing where a library goes. - The ten copies were already two spellings, behaviourally identical and differing by a variable name, some formatting and one word of a diagnostic.
- That one word costs a log search 4 of 10 sites, or 6. Copies diverge cosmetically first; the bill arrives as a search that finds part of the answer.
- The argument is not "duplication is bad" — it is that one library has one owner and can therefore have tests. Ten copies cannot be tested at all.
- Write the contract in the file. Four clauses, each forced by a measured failure, and a test suite is that contract as assertions.
- Comments say why, with the measurement, not what.
- Bump the version for contract changes, never for bodies. No shebang; set
FLOW_NAMEbefore sourcing. need leaves $t aloneis the suite's most valuable line — one bug, found once, guarded against nine call sites.- Eleven mutations, eleven caught — after two real gaps and one false survivor.
- A test whose input already satisfies the property cannot fail.
abspathwas fed an absolute path. - A mutation that does not apply looks exactly like a missing test.
cmp -sthe file and skip it. - The library holds mechanics, never the design — and
transientstays out because it encodes one tool's vocabulary. lib/besidebin/, sourced by an absolute path, and the library's own location is the one path that must stay hardcoded.
8. What Is Next
The library removed the duplicated logic. Every script still contains its own paths, its own tool version and its own defaults. Chapter 35.2 moves those out — and measures what has to be true before one flow can serve two blocks without being edited.
Where this fits
Part of the Linux & Bash curriculum.
