Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 3 additions & 37 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 8 additions & 0 deletions .husky/pre-push
Original file line number Diff line number Diff line change
@@ -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
70 changes: 70 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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:<version>`
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.
23 changes: 20 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<version>`
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
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
54 changes: 54 additions & 0 deletions scripts/agent-lint-report.mjs
Original file line number Diff line number Diff line change
@@ -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 <report.json>
*
* 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 <report.json>');
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);
}
141 changes: 141 additions & 0 deletions scripts/preflight.sh
Original file line number Diff line number Diff line change
@@ -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'
16 changes: 14 additions & 2 deletions test/shell-init.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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(),
};
Expand Down