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/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 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' 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(), };