Skip to content

Pipeline Design inline

Seth Ford edited this page Mar 2, 2026 · 8 revisions

Now I have everything I need. Here is the ADR:


Design: Add a shipwright ping command that prints pong to stdout and exits 0

Context

Shipwright exposes 100+ subcommands through a central CLI router (scripts/sw). Each command is a standalone bash script (scripts/sw-<name>.sh) dispatched via a case statement using exec. A hello command already exists as the canonical reference implementation of this pattern.

The ping command is a minimal health-check primitive: shipwright ping → prints pong to stdout, exits 0. It has no external dependencies, no side effects, and no state. The only constraints are:

  1. Must conform to the established Standalone Script Pattern (identical structure to sw-hello.sh)
  2. Must be Bash 3.2 compatible (no associative arrays, no ${var,,}, no readarray)
  3. Must use set -euo pipefail + ERR trap
  4. Must carry VERSION="3.2.4" to stay consistent with the rest of the codebase
  5. Output must be exactly pong — no color prefix, no ANSI escape codes — because tests do an exact-string comparison

Decision

Standalone Script Pattern — create scripts/sw-ping.sh as a structural clone of scripts/sw-hello.sh with a single behavioral change: the no-argument case prints echo "pong" instead of echo "hello world".

The router entry (ping) case in scripts/sw) is inserted before hello) at line 605. The *)catch-all at line 608 is the unreachable-command guard; any new entry must be placed before it.

The test file (scripts/sw-ping-test.sh) is a structural clone of scripts/sw-hello-test.sh — inline assert_equals/assert_exit_code helpers, 6 test functions, PASS/FAIL counters, exits 1 on any failure.

Note on package.json insertion point: The plan states insertion between sw-pipeline-vitals-test.sh and sw-pm-test.sh. This is incorrect. Alphabetically, ping (p-i-n) sorts before pipeline (p-i-p) because n < p. The correct insertion is between sw-patrol-meta-test.sh and sw-pipeline-composer-test.sh.


Alternatives Considered

  1. Inline the ping handler in the router (scripts/sw) — Pros: zero new files, no exec overhead. Cons: violates Single Responsibility — the router is a dispatcher, not an implementer; breaks the test boundary (no independent bash scripts/sw-ping-test.sh path); against established architecture for all 100+ commands. Rejected.

  2. Add ping as a shared library function — Pros: reusable if multiple scripts need a health check. Cons: premature abstraction for a one-line output; introduces a dependency where none exists; over-engineered for the requirement. Rejected.


Implementation Plan

Files to create

File Lines Purpose
scripts/sw-ping.sh ~67 Command implementation — mirrors sw-hello.sh exactly, echo "pong" in no-arg case
scripts/sw-ping-test.sh ~108 6-test suite — mirrors sw-hello-test.sh exactly, asserts output pong

Files to modify

File Change
scripts/sw Insert ping) exec "$SCRIPT_DIR/sw-ping.sh" "$@" ;; before hello) at line 605
package.json Insert bash scripts/sw-ping-test.sh && between sw-patrol-meta-test.sh and sw-pipeline-composer-test.sh (alphabetical order)

Dependencies

None. No new packages, no new shared libraries.

Risk areas

Risk Affected location Mitigation
Router entry placed after *) scripts/sw line 608 Insert at line 605 (before hello)), verified with grep -n 'hello|^\s*\*)' scripts/sw
pong output has trailing newline stripped test assert_equals "pong" "$output" echo "pong" adds one \n; command substitution $() strips it — both sides are stripped equally, assertion holds
ERR trap fires during intentional exit 1 test_ping_invalid_option Use `
VERSION drift sw-ping.sh header Hardcode VERSION="3.2.4" — same value as sw-hello.sh:8 and package.json
chmod not applied scripts/sw-ping.sh chmod +x immediately after creation, before any test run

Component Diagram

  ┌─────────────────────────────────────┐
  │           scripts/sw                │
  │         (CLI Router)                │
  │                                     │
  │  case "$cmd" in                     │
  │    ping)  exec sw-ping.sh  "$@" ;;  │◄── NEW (line 605, before hello)
  │    hello) exec sw-hello.sh "$@" ;;  │
  │    *)     error "Unknown cmd" ;;    │
  │  esac                               │
  └──────────────┬──────────────────────┘
                 │ exec (replaces process)
                 ▼
  ┌─────────────────────────────────────┐
  │         scripts/sw-ping.sh          │
  │         (Command Handler)           │
  │                                     │
  │  main()                             │
  │    case "${1:-}" in                 │
  │      --help|-h)  show_help; exit 0  │
  │      --version)  echo $VERSION;     │
  │                  exit 0             │
  │      "")         echo "pong";       │◄── CORE BEHAVIOR
  │                  exit 0             │
  │      *)          error; exit 1      │
  │    esac                             │
  └─────────────────────────────────────┘
                 ▲
                 │ bash invocation
  ┌─────────────────────────────────────┐
  │       scripts/sw-ping-test.sh       │
  │         (Test Harness)              │
  │                                     │
  │  assert_equals "pong" "$output"     │
  │  assert_exit_code 0 $?              │
  │  [[ output =~ "USAGE" ]]            │
  │  [[ output =~ semver ]]             │
  │  || local exit_code=$?  (exit 1)    │
  └─────────────────────────────────────┘

Interface Contracts

# sw-ping.sh — public interface (all args optional)
#
# Input:  $1 — optional flag: --help | -h | --version | -v | (empty)
# Output: stdout — "pong" (no-arg), help text (--help/-h), semver (--version/-v)
# Stderr: error message on unknown option
# Exit:   0 — success (pong, help, version)
#         1 — unknown option

sw_ping() {
  # ""        → echo "pong" to stdout, exit 0
  # --help/-h → echo help text to stdout, exit 0
  # --version → echo "$VERSION" to stdout, exit 0
  # *         → echo error to stderr, echo help, exit 1
}

# Router dispatch (scripts/sw)
# Precondition: $cmd == "ping"
# Postcondition: process replaced by sw-ping.sh via exec (no return)
ping) exec "$SCRIPT_DIR/sw-ping.sh" "$@" ;;

Data Flow

User invokes: shipwright ping
      │
      ▼
scripts/sw main()
  → parses $1 as $cmd
  → case "ping" → exec sw-ping.sh (stdin/stdout/stderr inherited)
      │
      ▼
sw-ping.sh main()
  → case "${1:-}" — no arg: ""
  → echo "pong"           ← single call, no color prefix
  → exit 0
      │
      ▼
stdout: "pong\n"
exit code: 0

For the test path:

bash scripts/sw-ping-test.sh
  → output=$("$SCRIPT_DIR/sw-ping.sh")    ← subshell, stdout captured
  → assert_equals "pong" "$output"         ← $() strips trailing newline
  → PASS++

Error Boundaries

Component Errors it handles Propagation
sw-ping.sh Unknown CLI flag → error() to stderr + exit 1 Caller receives exit code 1; no trap interference
sw-ping.sh ERR trap Unexpected bash errors (e.g., broken pipe) Prints ERROR: $BASH_SOURCE:$LINENO to stderr; exits non-zero
scripts/sw router ping command not found on PATH Would fall to *) — prevented by correct case placement
sw-ping-test.sh negative test Intentional exit 1 triggers ERR trap in test Mitigated by `
package.json test runner Any suite exits non-zero && chain halts; later suites do not run

Validation Criteria

  • bash scripts/sw-ping.sh prints exactly pong (no color codes, no prefix characters)
  • bash scripts/sw-ping.sh exits with code 0
  • bash scripts/sw-ping.sh --help contains the string USAGE
  • bash scripts/sw-ping.sh -h contains the string USAGE
  • bash scripts/sw-ping.sh --version matches ^[0-9]+\.[0-9]+\.[0-9]+
  • bash scripts/sw-ping.sh --invalid exits with code 1
  • bash scripts/sw-ping-test.sh reports PASS: 6 FAIL: 0
  • bash scripts/sw ping prints pong (router integration verified)
  • npm test passes with sw-ping-test.sh included and no regressions in existing suites
  • sw-ping.sh is executable (ls -l scripts/sw-ping.sh | grep ^-rwx)
  • scripts/sw ping) case appears before hello) and before *)

Clone this wiki locally