Skip to content
VLSI Mentor

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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  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 fixtures

digest 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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ md5 -q dup/d*.txt | sort -u | wc -l
       2
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
=== are the two versions behaviourally identical? ===
  d1 -> 66e7e66ea27fe3015a43f93dca688604  rc=0
  d3 -> 66e7e66ea27fe3015a43f93dca688604  rc=0

Identical output, identical status. The drift is cosmetic:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
# 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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
# 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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
: "${FLOW_LIB_VERSION:=1}"       # bumped when a contract changes, not a body

Fixing 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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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 failed

Note need leaves $t alone — a test that exists only because Chapter 34.7 measured the bug it guards:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  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 arg

Eleven 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 librarywhy
diagnostics — die, step, noteevery script needs them and they must agree
preconditions — need, wantthe same checks, the same statuses
identity — digestone definition, so a digest means one thing
list handling — stripso # means the same in every list
path resolution — abspatheight failures in this track came from not doing it
not in the librarywhy
transient — which failures are retryabletool-specific; it belongs with the tool wrapper
norm — how to normalise a diagnosticone tool's message format
the verdict rulesChapter 32.3's seven classes are a policy, versioned separately
anything that reads the designa 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

  1. Count the function definitions across your own flow's scripts.
  2. Find the function defined most often, and extract every copy.
  3. Hash them and count the distinct implementations.
  4. Diff two of them and classify the difference as behavioural or cosmetic.
  5. If a diagnostic's wording differs, grep your logs for each version and count the call sites each finds.
  6. Write the four-clause contract for a library of your own.
  7. For each clause, name the measurement that forced it.
  8. Put the reason, not the behaviour, in every comment.
  9. Decide what a version number means, and what does not change it.
  10. Omit the shebang and explain why.
  11. Set the flow's name before sourcing and confirm the messages are prefixed.
  12. Write the test harness with each case in its own subshell, and say why.
  13. Write an assertion for a bug you have already had.
  14. Mutate each contract clause once and record which test catches it.
  15. Find a survivor, and decide whether it is a gap or a harness bug.
  16. Verify that each mutation changed the file.
  17. Find a test whose input already satisfies the property it checks.
  18. Fix it by changing the input rather than the assertion.
  19. Classify your own functions into library and not-library.
  20. Take the hardest case and argue both ways.
  21. Put lib/ beside bin/ and source it by an absolute path.
  22. Make the library's path configurable, then explain what becomes unverifiable.

Check Your Understanding

  1. How many function definitions were counted, and which function appeared most?
  2. Why does duplication happen, in the honest account?
  3. How many distinct implementations did the ten copies have?
  4. Were they behaviourally different? What was the evidence?
  5. What had actually diverged?
  6. What is the measured cost of the message difference?
  7. State the real argument against ten copies.
  8. How do copies usually diverge?
  9. Give the library's four contract clauses.
  10. Why does writing the contract down matter?
  11. What should a comment in a library say?
  12. What changes FLOW_LIB_VERSION and what does not?
  13. Why no shebang?
  14. Why must FLOW_NAME be set before sourcing?
  15. State the real argument for consolidation.
  16. Why can ten copies not be tested?
  17. Why is each test case in its own subshell?
  18. What is the single most valuable assertion in the suite, and why?
  19. How many mutations, and how many were caught at first?
  20. What was gap 1, and what failure does it correspond to?
  21. What was gap 2, and why did the test pass despite the bug?
  22. State the general trap gap 2 illustrates, and name two earlier instances.
  23. What was the third survivor, and why?
  24. What one line removes that class of error?
  25. State the rule about mutation results.
  26. What is the dividing line for library membership?
  27. Why does transient not belong, even though it will be used three times?
  28. Why lib/ beside bin/ rather than inside it?
  29. Why source by an absolute path, and what breaks with each wrong form?
  30. What is the one path in a flow that should stay hardcoded, and why?

7. Remember

  • Twenty-nine function definitions across four fixtures, digest ten 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_NAME before sourcing.
  • need leaves $t alone is 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. abspath was fed an absolute path.
  • A mutation that does not apply looks exactly like a missing test. cmp -s the file and skip it.
  • The library holds mechanics, never the design — and transient stays out because it encodes one tool's vocabulary.
  • lib/ beside bin/, 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.