From 5c459c6a3386b25282ebca4619c2059a2c18805b Mon Sep 17 00:00:00 2001 From: Aaron Ware Date: Sat, 5 Sep 2026 10:01:01 -0400 Subject: [PATCH 1/3] test(NO-TASK): Filter the bash 5 job-control warning, not only the bash 3 wording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `bash -i` with no controlling terminal complains about job control on stderr, and the wording depends on the bash version. macOS ships bash 3.2, which prints only "no job control in this shell" — the string this filter matched. Every CI runner has bash 5, which prints "cannot set terminal process group (N): Inappropriate ioctl for device" first, so the filter let a line through and two `assert.equal(stderr, '')` checks failed on Linux and nowhere else. Match the family rather than one member of it, so the test measures the snippet instead of the platform. Co-Authored-By: Claude Opus 5 (1M context) --- test/shell-init.test.js | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/test/shell-init.test.js b/test/shell-init.test.js index 6348b61..47d7fdd 100644 --- a/test/shell-init.test.js +++ b/test/shell-init.test.js @@ -116,6 +116,12 @@ function stubBin(root) { return { bin, marker }; } +/** + * Bash's own complaints about starting interactively with no controlling + * terminal. Every message here is emitted before the snippet is sourced. + */ +const BASH_JOB_CONTROL_NOISE = /no job control in this shell|cannot set terminal process group/; + /** Source a snippet in an interactive bash and return what each stream saw. */ function sourceInBash(snippet, root, env = {}, { interactive = true } = {}) { const file = path.join(root, 'snippet.sh'); @@ -130,10 +136,16 @@ function sourceInBash(snippet, root, env = {}, { interactive = true } = {}) { code: result.status ?? 0, stdout: result.stdout ?? '', // `bash -i` without a controlling terminal announces its lack of job - // control on stderr. That is bash talking, not the snippet. + // control on stderr, and the wording is version- and platform-specific: + // bash 3.2 (macOS) says only "no job control in this shell", while bash 5 + // (Linux, so every CI runner) prefixes that with "cannot set terminal + // process group (N): Inappropriate ioctl for device". That is bash + // talking, not the snippet, so drop the whole family rather than one line + // of it — a filter that matches only the local wording is a test that + // passes on a laptop and fails in CI. stderr: (result.stderr ?? '') .split('\n') - .filter((line) => !line.includes('no job control')) + .filter((line) => !BASH_JOB_CONTROL_NOISE.test(line)) .join('\n') .trim(), }; From eebd3ab233e12438bc3f0c596ea9959d37d33726 Mon Sep 17 00:00:00 2001 From: Aaron Ware Date: Sat, 5 Sep 2026 10:01:10 -0400 Subject: [PATCH 2/3] ci(NO-TASK): Preflight the CI matrix on Linux before pushing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A green `npm test` on a workstation is evidence about macOS, not about ubuntu-latest, and this CLI shells out to bash, git and the filesystem — where the two platforms differ in behaviour and in wording. The bash 5 job-control regression in the previous commit passed locally and failed in CI for exactly that reason. `npm run preflight` runs the local gates, and now runs on git push via a husky pre-push hook. `npm run preflight:linux` stages the working tree into a node container per matrix version, removes ignored files so the container sees a clean checkout plus uncommitted edits, and runs install, typecheck, build, test and the agent-readiness floor. The matrix, the linter version and the score floor are read out of ci.yml rather than restated, and the scorer that was inlined in the workflow now lives in scripts/agent-lint-report.mjs so both callers compute the score the same way. A preflight that could disagree with the gate it previews would be worse than no preflight at all. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 40 +--------- .husky/pre-push | 8 ++ package.json | 2 + scripts/agent-lint-report.mjs | 54 +++++++++++++ scripts/preflight.sh | 141 ++++++++++++++++++++++++++++++++++ 5 files changed, 208 insertions(+), 37 deletions(-) create mode 100755 .husky/pre-push create mode 100644 scripts/agent-lint-report.mjs create mode 100755 scripts/preflight.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 918974e..fcefe85 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -110,40 +110,6 @@ jobs: run: | set -euo pipefail /tmp/cli-agent-lint check ./dist/cli.js -o json > /tmp/report.json 2>/dev/null || true - node --input-type=module -e ' - import { readFileSync } from "node:fs"; - import { appendFileSync } from "node:fs"; - - const report = JSON.parse(readFileSync("/tmp/report.json", "utf8")); - const score = report.score.percentage; - const grade = report.score.grade; - const min = Number(process.env.CLI_AGENT_LINT_MIN_SCORE); - const { pass, warn, fail, skip, total } = report.summary; - - const attention = report.checks - .filter((c) => c.status !== "pass" && c.status !== "skip") - .map((c) => `| ${c.id} | ${c.status} | ${c.name.trim()} |`) - .join("\n"); - - const summary = [ - `### Agent-readiness: ${score.toFixed(1)}% (grade ${grade})`, - "", - `${pass} pass · ${warn} warn · ${fail} fail · ${skip} skip — of ${total}`, - `Floor: ${min}%`, - "", - attention ? "| Check | Status | Name |\n| --- | --- | --- |\n" + attention : "Nothing needs attention.", - ].join("\n"); - - console.log(summary); - if (process.env.GITHUB_STEP_SUMMARY) { - appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary + "\n"); - } - - if (score + 1e-9 < min) { - console.error( - `\nAgent-readiness regressed: ${score.toFixed(1)}% is below the ${min}% floor.\n` + - "Fix the regression, or raise the floor deliberately if this is an accepted trade." - ); - process.exit(1); - } - ' + # The same scorer `scripts/preflight.sh` runs, so a local preflight + # cannot disagree with this gate. + node scripts/agent-lint-report.mjs /tmp/report.json diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100755 index 0000000..93687b5 --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,8 @@ +# The local gates, before a push turns into a red PR. ~10 seconds. +# +# This proves the change on *this* machine. CI runs Linux, so anything that +# shells out to bash, git or the filesystem still wants `npm run preflight:linux` +# — see CLAUDE.md. +# +# Skip deliberately with `git push --no-verify`, or `HUSKY=0 git push`. +npm run --silent preflight diff --git a/package.json b/package.json index 062c1a4..2e2d225 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,8 @@ "typecheck": "tsc --noEmit", "pretest": "npm run build", "test": "node --test", + "preflight": "bash scripts/preflight.sh", + "preflight:linux": "bash scripts/preflight.sh --linux", "prepack": "npm run build", "prepare": "husky || true", "commitlint": "commitlint --edit" diff --git a/scripts/agent-lint-report.mjs b/scripts/agent-lint-report.mjs new file mode 100644 index 0000000..bca524b --- /dev/null +++ b/scripts/agent-lint-report.mjs @@ -0,0 +1,54 @@ +/** + * Turn a cli-agent-lint JSON report into a summary, and fail if the score sits + * below the recorded floor. + * + * Shared deliberately: `.github/workflows/ci.yml` and `scripts/preflight.sh` + * both run this, so a preflight cannot disagree with the gate it is previewing. + * + * node scripts/agent-lint-report.mjs + * + * Reads CLI_AGENT_LINT_MIN_SCORE for the floor, and appends to + * GITHUB_STEP_SUMMARY when it is running inside Actions. + */ +import { appendFileSync, readFileSync } from 'node:fs'; + +const reportPath = process.argv[2]; + +if (!reportPath) { + console.error('usage: node scripts/agent-lint-report.mjs '); + process.exit(2); +} + +const report = JSON.parse(readFileSync(reportPath, 'utf8')); +const score = report.score.percentage; +const grade = report.score.grade; +const min = Number(process.env.CLI_AGENT_LINT_MIN_SCORE); +const { pass, warn, fail, skip, total } = report.summary; + +const attention = report.checks + .filter((c) => c.status !== 'pass' && c.status !== 'skip') + .map((c) => `| ${c.id} | ${c.status} | ${c.name.trim()} |`) + .join('\n'); + +const summary = [ + `### Agent-readiness: ${score.toFixed(1)}% (grade ${grade})`, + '', + `${pass} pass · ${warn} warn · ${fail} fail · ${skip} skip — of ${total}`, + `Floor: ${min}%`, + '', + attention ? '| Check | Status | Name |\n| --- | --- | --- |\n' + attention : 'Nothing needs attention.', +].join('\n'); + +console.log(summary); + +if (process.env.GITHUB_STEP_SUMMARY) { + appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary + '\n'); +} + +if (score + 1e-9 < min) { + console.error( + `\nAgent-readiness regressed: ${score.toFixed(1)}% is below the ${min}% floor.\n` + + 'Fix the regression, or raise the floor deliberately if this is an accepted trade.' + ); + process.exit(1); +} diff --git a/scripts/preflight.sh b/scripts/preflight.sh new file mode 100755 index 0000000..45523d8 --- /dev/null +++ b/scripts/preflight.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +# +# Run what CI runs, before CI runs it. +# +# scripts/preflight.sh the local gates: typecheck, build, test +# scripts/preflight.sh --linux the same gates on Linux, on every Node in +# the CI matrix, plus the agent-readiness floor +# scripts/preflight.sh --linux --node 24 one version of the matrix +# scripts/preflight.sh --linux --no-agent-lint gates only, no network +# +# Why --linux exists: this repo shells out — to bash, to git, to the filesystem — +# and those differ between a macOS workstation and an ubuntu-latest runner. bash +# 3.2 and bash 5 word the same warning differently; BSD and GNU coreutils take +# different flags; the macOS filesystem is case-insensitive. A green local run is +# evidence about macOS, not about CI. --linux is the evidence about CI. +# +# The container gets a *clean checkout plus your uncommitted edits*: ignored +# files are removed inside it, so an untracked file on your workstation cannot +# make a gate pass that would fail on a fresh clone. +set -euo pipefail + +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WORKFLOW="$REPO/.github/workflows/ci.yml" + +LINUX=0 +AGENT_LINT=1 +NODE_VERSIONS=() + +while [ $# -gt 0 ]; do + case "$1" in + --linux) LINUX=1 ;; + --node) NODE_VERSIONS=("$2"); shift ;; + --no-agent-lint) AGENT_LINT=0 ;; + -h|--help) sed -n '2,20p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "preflight: unknown option '$1'" >&2; exit 2 ;; + esac + shift +done + +step() { printf '\n\033[1m▸ %s\033[0m\n' "$1"; } +fail() { printf '\n\033[31m✖ %s\033[0m\n' "$1" >&2; exit 1; } + +# --- The local gates --------------------------------------------------------- + +step 'Local: typecheck' +npm run --silent typecheck + +step 'Local: build' +npm run --silent build + +step 'Local: test' +npm test + +if [ "$LINUX" -eq 0 ]; then + printf '\n\033[32m✔ Local gates pass.\033[0m\n' + printf 'These ran on %s. CI runs on Linux — if this change touches shell, git,\n' "$(uname -s)" + printf 'paths or process behaviour, confirm it there too: npm run preflight:linux\n' + exit 0 +fi + +# --- The CI matrix, on Linux ------------------------------------------------- + +command -v docker >/dev/null 2>&1 || fail 'docker is not installed; --linux needs it' +docker info >/dev/null 2>&1 || fail 'docker is installed but not running' + +# Read the matrix out of the workflow rather than restating it, so the preflight +# cannot drift away from the thing it is previewing. +if [ "${#NODE_VERSIONS[@]}" -eq 0 ]; then + matrix_line="$(grep -E "^ *node: \[" "$WORKFLOW" || true)" + # shellcheck disable=SC2207 + NODE_VERSIONS=($(printf '%s' "$matrix_line" | grep -oE "'[0-9.]+'" | tr -d "'")) + [ "${#NODE_VERSIONS[@]}" -gt 0 ] || fail "could not read the node matrix from $WORKFLOW" +fi + +LINT_VERSION="$(grep -E "^ *CLI_AGENT_LINT_VERSION:" "$WORKFLOW" | grep -oE "'[^']+'" | tr -d "'")" +LINT_FLOOR="$(grep -E "^ *CLI_AGENT_LINT_MIN_SCORE:" "$WORKFLOW" | grep -oE "'[^']+'" | tr -d "'")" +# bash 3.2 ships on macOS and has no negative array indexing. +LAST_NODE="${NODE_VERSIONS[$(( ${#NODE_VERSIONS[@]} - 1 ))]}" + +printf '\nMatrix from %s: node %s\n' ".github/workflows/ci.yml" "${NODE_VERSIONS[*]}" + +# npm's cache and the linter binary survive between runs, so only the first +# preflight of the day pays for them. +docker volume create linchpin-preflight-npm >/dev/null +docker volume create linchpin-preflight-tools >/dev/null + +# Staged into /work rather than run from the mount: the mount is read-only, and +# a build must not write Linux artifacts into the host's dist/ or node_modules/. +STAGE=' + mkdir -p /work + tar -C /src --exclude=./node_modules --exclude=./dist -cf - . 2>/dev/null \ + | tar -C /work --no-same-owner -xf - + cd /work + git clean -Xdfq +' + +for node_version in "${NODE_VERSIONS[@]}"; do + step "Linux / node ${node_version}: install, typecheck, build, test" + docker run --rm \ + -v "$REPO:/src:ro" \ + -v linchpin-preflight-npm:/root/.npm \ + -e HUSKY=0 \ + -e CI=1 \ + "node:${node_version}" \ + bash -euo pipefail -c "${STAGE}"' + npm ci --no-audit --no-fund --silent + npm run typecheck + npm run build + npm test + ' || fail "Linux / node ${node_version} failed — this is what CI will report" +done + +if [ "$AGENT_LINT" -eq 1 ]; then + step "Linux / node ${LAST_NODE}: agent-readiness (floor ${LINT_FLOOR}%)" + docker run --rm \ + -v "$REPO:/src:ro" \ + -v linchpin-preflight-npm:/root/.npm \ + -v linchpin-preflight-tools:/tools \ + -e HUSKY=0 \ + -e CI=1 \ + -e CLI_AGENT_LINT_VERSION="$LINT_VERSION" \ + -e CLI_AGENT_LINT_MIN_SCORE="$LINT_FLOOR" \ + "node:${LAST_NODE}" \ + bash -euo pipefail -c "${STAGE}"' + npm ci --no-audit --no-fund --silent + npm run build + + bin="/tools/cli-agent-lint-${CLI_AGENT_LINT_VERSION}" + if [ ! -x "$bin" ]; then + url="https://github.com/Camil-H/cli-agent-lint/releases/download/v${CLI_AGENT_LINT_VERSION}/cli-agent-lint_${CLI_AGENT_LINT_VERSION}_linux_amd64.tar.gz" + curl -fsSL -o /tmp/cli-agent-lint.tar.gz "$url" + tar -xzf /tmp/cli-agent-lint.tar.gz -C /tmp + install -m 0755 /tmp/cli-agent-lint "$bin" + fi + + "$bin" check ./dist/cli.js -o json > /tmp/report.json 2>/dev/null || true + node scripts/agent-lint-report.mjs /tmp/report.json + ' || fail 'agent-readiness failed — this is what CI will report' +fi + +printf '\n\033[32m✔ Linux matrix passes. This is the evidence CI will produce.\033[0m\n' From abdf01d7fdf9ca76917507b06bc87807c138c831 Mon Sep 17 00:00:00 2001 From: Aaron Ware Date: Sat, 5 Sep 2026 10:01:16 -0400 Subject: [PATCH 3/3] docs(NO-TASK): Document the preflight and the platform gap it closes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLAUDE.md is new: it says what to run before pushing, which changes make preflight:linux non-optional — spawning a shell, shelling out, filesystem semantics, TTY and process behaviour — and how to write a test that survives the crossing. Never assert on the exact text a system tool emits, prefer a positive match over an empty-string equality, and filter noise by family rather than by the one phrasing this laptop happens to print. The bash 5 failure is cited as the worked example, since a rule with a scar attached is easier to remember. The README Development section gains the same two commands and the reason the local run is not the evidence CI produces. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 70 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 23 +++++++++++++++--- 2 files changed, 90 insertions(+), 3 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..cc4df25 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,70 @@ +# Working in this repo + +TypeScript + ESM, built with tsdown into `dist/`. Tests are `node --test` against the +**built** output (`pretest` builds first), so a source change is only tested once it has +been built. Un-ported CommonJS lives in `legacy/`. + +## Before you push: preflight + +```bash +npm run preflight # typecheck, build, test — on this machine (~10s) +npm run preflight:linux # the same gates on Linux, on every Node in the CI matrix, + # plus the agent-readiness floor (needs Docker, ~2min) +``` + +`npm run preflight` also runs automatically on `git push` (husky `pre-push`). + +**`npm run preflight` passing is not evidence that CI will pass.** It ran on macOS; CI runs +on `ubuntu-latest`. `preflight:linux` stages the working tree into a `node:` +container, removes ignored files so it is a clean checkout plus your uncommitted edits, and +runs exactly what `.github/workflows/ci.yml` runs. It reads the Node matrix and the +agent-readiness floor out of the workflow file, and scores agent-readiness with the same +`scripts/agent-lint-report.mjs` CI uses, so the preflight cannot drift away from the gate it +is previewing. + +## When preflight:linux is not optional + +Run it before pushing whenever the change touches anything whose behaviour is supplied by +the operating system rather than by this codebase: + +- **Spawning a shell** — `bash`, `sh`, `zsh`, `fish`, or any snippet this CLI emits for one. +- **Shelling out at all** — `git`, `npm`, coreutils. Flags and messages differ between BSD + and GNU. +- **Filesystem semantics** — case sensitivity, symlinks, permissions, `os.tmpdir()`. +- **Process and TTY behaviour** — job control, signals, detached children, `isatty`. + +macOS ships **bash 3.2**; every CI runner has **bash 5**. They differ in features *and* in +wording. This is not hypothetical — it is how [#71][pr71] went red: a test filtered bash's +job-control complaint by matching `no job control`, which is the entire message bash 3.2 +prints. bash 5 prints `cannot set terminal process group (N): Inappropriate ioctl for +device` first, so the filter let it through and an `assert.equal(stderr, '')` failed on +Linux only. + +[pr71]: https://github.com/linchpin/cli/pull/71 + +## Writing tests that survive the crossing + +- **Never assert on the exact text a system tool emits.** Its wording is version- and + platform-specific. Assert on the shape you care about, and filter the noise by its + *family* — every phrasing you know of, not the one your laptop happens to print. +- **Assert positively where you can.** `assert.match(stderr, /Update available/)` is stable; + `assert.equal(stderr, '')` fails on any unrelated chatter the platform decides to add. +- **Nothing may reach the network.** The update checker runs against a local registry stub + and the shared fixture sets `LINCHPIN_NO_UPDATE_NOTIFIER`. +- **Nothing may depend on an untracked file.** `preflight:linux` deletes ignored files + inside the container, which is what a fresh clone looks like. (This is also why the + agent-readiness score reads ~1.7 points high locally: SD-5 passes off a gitignored + `.claude/` that CI never sees. Record the number CI reports.) + +## Commits and releases + +Conventional Commits, enforced by commitlint on `commit-msg`. The scope carries the task key +or `NO-TASK`: + +```text +feat(LINCHPIN-4850): add release automation +fix(NO-TASK): filter bash 5's job-control warning too +``` + +release-please owns `version` in `package.json`, `CHANGELOG.md` and +`.release-please-manifest.json`. Never edit those by hand. diff --git a/README.md b/README.md index 9baf8f0..c93b2fd 100644 --- a/README.md +++ b/README.md @@ -510,11 +510,28 @@ the safety net. More on why this shapes the whole design: ```bash npm install -npm run typecheck # tsc --noEmit -npm run build # tsdown -> dist/ -npm test # builds first, then node --test +npm run typecheck # tsc --noEmit +npm run build # tsdown -> dist/ +npm test # builds first, then node --test + +npm run preflight # all three, in order — also runs on git push (husky pre-push) +npm run preflight:linux # the CI matrix on Linux, in Docker, before CI runs it ``` +`npm run preflight` proves the change on *your* machine. CI runs `ubuntu-latest`, and this +CLI shells out — to bash, to git, to the filesystem — so the two are not the same evidence. +macOS ships bash 3.2 and every runner has bash 5; BSD and GNU coreutils take different +flags; the macOS filesystem is case-insensitive. + +`npm run preflight:linux` closes that gap. It stages the working tree into a `node:` +container, deletes ignored files so the container sees a clean checkout plus your uncommitted +edits, and runs install, typecheck, build, test on **every Node in the CI matrix**, then the +agent-readiness floor. The matrix, the linter version and the floor are read out of +`.github/workflows/ci.yml`, and the score is computed by the same +`scripts/agent-lint-report.mjs` the workflow calls — so a preflight cannot quietly disagree +with the gate it is previewing. Run it before pushing anything that touches shell, git, +process or filesystem behaviour. See `CLAUDE.md`. + TypeScript and ESM, built with [tsdown](https://tsdown.dev). Every runtime dependency lives in `devDependencies` and is bundled into `dist/`, so the published package installs with **zero transitive dependencies**. Un-ported CommonJS still lives in `legacy/`, which carries its own