From 6ffafe753656a70b3591e0c2fe40c3ae142b3581 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sat, 19 Sep 2026 23:57:06 +0200 Subject: [PATCH 1/4] 4d - every gate leg names itself, its image and its seconds --- AGENTS.md | 7 +++++++ ci.sh | 38 +++++++++++++++++++++----------------- docker-runner.sh | 14 ++++++++++++++ publish.sh | 29 +++++++++++++++++++---------- 4 files changed, 61 insertions(+), 27 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c132b13..6decc9a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -238,6 +238,13 @@ The leg re-checks the conversions and nothing else — each fixture's emitted ma document, its error code — leaving the corpus's pairing, uniqueness, source positions and byte-level equality to the Node suite that owns them. +Every leg announces its name and its image before it runs and its elapsed time after, `publish.sh` +alongside `ci.sh`, so a long run reads as progress rather than as a hang — which is what a silent +one cost the `0.1.0` release. A leg added later owes the same marker, and a leg that buffers its +output to read something out of it streams and keeps the copy in a file: `tee /dev/stderr` reopens +the stream, so under the `> log 2>&1` a reader runs locally the two offsets advance independently +and punch NUL holes through each other's lines (4d). + The floors live in the `test` script, so `npm test` and the gate are one path: 100% of lines and functions, and a branch floor that only ever moves upward. It sits below 100 because the guards `noUncheckedIndexedAccess` and ADF's optional keys force — `?? []`, `?? {}`, `?.`, an index diff --git a/ci.sh b/ci.sh index dd3d712..012f0ba 100755 --- a/ci.sh +++ b/ci.sh @@ -3,28 +3,32 @@ set -euo pipefail cd "$(dirname "$0")" source ./docker-runner.sh -in_image "$node_image" npm ci -in_image "$node_image" npm run typecheck +test_log=$(mktemp) -if ! test_output=$(in_image "$node_image" npm test 2>&1); then - printf '%s\n' "$test_output" - exit 1 -fi -printf '%s\n' "$test_output" -if printf '%s' "$test_output" | grep -q 'ℹ tests 0'; then +# Streamed, and copied so the zero-test guard reads the count without trading the output for it. +node_tests() { + in_image "$node_image" npm test 2>&1 | tee "$test_log" +} + +leg "install ($node_image)" in_image "$node_image" npm ci +leg "typecheck ($node_image)" in_image "$node_image" npm run typecheck + +leg "tests ($node_image)" node_tests +if grep -q 'ℹ tests 0' "$test_log"; then echo 'the gate ran zero tests — failing instead of a vacuous green' exit 1 fi +rm -f "$test_log" -in_image "$deno_image" deno test --allow-env=PROPERTY_RUNS --allow-read --no-check src/ -in_image "$bun_image" bun test src/ +leg "tests ($deno_image)" in_image "$deno_image" deno test --allow-env=PROPERTY_RUNS --allow-read --no-check src/ +leg "tests ($bun_image)" in_image "$bun_image" bun test src/ -in_image "$node_image" npm run build -in_image "$node_image" sh -c 'set -e +leg "build ($node_image)" in_image "$node_image" npm run build +leg "pack and install the tarball ($node_image)" in_image "$node_image" sh -c 'set -e rm -rf package-tests/node_modules - npm pack --pack-destination /tmp >/dev/null - npm install --no-audit --no-fund --no-package-lock --no-save --offline --prefix package-tests /tmp/*.tgz >/dev/null' -in_image "$node_image" npx tsc -p package-tests -in_image "$floor_image" node package-tests/node-floor.js + npm pack --pack-destination /tmp + npm install --no-audit --no-fund --no-package-lock --no-save --offline --prefix package-tests /tmp/*.tgz' +leg "typecheck the consumer ($node_image)" in_image "$node_image" npx tsc -p package-tests +leg "round-trip on the engines floor ($floor_image)" in_image "$floor_image" node package-tests/node-floor.js -with_firefox in_image "$node_image" node browser-tests/run.js +leg "browser ($firefox_image)" with_firefox in_image "$node_image" node browser-tests/run.js diff --git a/docker-runner.sh b/docker-runner.sh index 8602e71..4fa0ad7 100644 --- a/docker-runner.sh +++ b/docker-runner.sh @@ -10,6 +10,20 @@ in_image() { docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp ${PROPERTY_RUNS+-e PROPERTY_RUNS} ${in_image_network:+--network "$in_image_network"} -v "$PWD:/app" -w /app --entrypoint "$entrypoint" "$image" "$@" } +# Markers go to stderr so a leg reporting a value stays capturable, and the locals carry the +# function's own name because bash scopes them dynamically into whatever the leg runs. +leg() { + local leg_name=$1 leg_elapsed leg_started leg_status=0 + shift + printf '\n\033[1;34m==> %s\033[0m\n' "$leg_name" >&2 + # EPOCHREALTIME carries the locale's radix character, so keep the digits and read microseconds. + leg_started=${EPOCHREALTIME//[^0-9]/} + "$@" || leg_status=$? + leg_elapsed=$((${EPOCHREALTIME//[^0-9]/} - leg_started)) + printf '\033[1;34m<== %s: %d.%ds\033[0m\n' "$leg_name" "$((leg_elapsed / 1000000))" "$((leg_elapsed % 1000000 / 100000))" >&2 + return $leg_status +} + with_firefox() { local container in_image_network status=0 container=$(docker run -d --rm "$firefox_image") diff --git a/publish.sh b/publish.sh index d0f5999..e4c480c 100755 --- a/publish.sh +++ b/publish.sh @@ -7,31 +7,40 @@ read_field() { in_image "$node_image" npm pkg get "$1" | tr -d '"\r' } +read_package_fields() { + private=$(read_field private) + name=$(read_field name) + version=$(read_field version) +} + published_version() { in_image "$node_image" npm view "$1@$2" version 2>/dev/null || true } -private=$(read_field private) +push_tag() { + git tag "v$version" + git push origin "v$version" +} + +leg "read package.json ($node_image)" read_package_fields if [ "$private" = 'true' ]; then echo 'package.json is private — the maintainer removes that in the bump that first publishes' exit 0 fi -name=$(read_field name) -version=$(read_field version) -published=$(published_version "$name" "$version") -tagged=$(git ls-remote --tags origin "v$version") +published=$(leg "ask npmjs for $name@$version" published_version "$name" "$version") +tagged=$(leg "ask origin for v$version" git ls-remote --tags origin "v$version") # Both steps observe their own end state, so a partial run converges on the next push to main. if [ -z "$published" ]; then : "${NPM_TOKEN:?the publish needs NPM_TOKEN}" - in_image "$node_image" npm ci - in_image "$node_image" npm run build - docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp -e NPM_TOKEN -v "$PWD:/app" -w /app --entrypoint sh "$node_image" -c \ + leg "install ($node_image)" in_image "$node_image" npm ci + leg "build ($node_image)" in_image "$node_image" npm run build + leg "publish $name@$version ($node_image)" \ + docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp -e NPM_TOKEN -v "$PWD:/app" -w /app --entrypoint sh "$node_image" -c \ 'printf "//registry.npmjs.org/:_authToken=%s\n" "$NPM_TOKEN" > "$HOME/.npmrc" && npm publish --access public' fi if [ -z "$tagged" ]; then - git tag "v$version" - git push origin "v$version" + leg "tag v$version" push_tag fi -- 2.52.0 From be25a7a3f5c9bc7563e240b06c3eaf454db7e201 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sat, 19 Sep 2026 23:57:06 +0200 Subject: [PATCH 2/4] Tick 4d, moving its text to todo-history.md --- todo-history.md | 28 ++++++++++++++++++++++++++++ todo.md | 15 +-------------- 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/todo-history.md b/todo-history.md index d2bf5d0..60d7d15 100644 --- a/todo-history.md +++ b/todo-history.md @@ -546,6 +546,34 @@ The done `todo.md` items in full, as they were written. `todo.md` keeps a one-li lines an indented code block held (200k of them at 200 kB), and `emitRun` joining a mark run's segments (200k nodes under one mark). Both fixed here with the same loop and a test each, and §11 gained the rule so the spelling cannot walk back in. +- [x] **4d — What the gate says while it runs (`0.2.0`).** `ci.sh` runs nine legs and announces + none of them, so five minutes of a Gitea run read as silence and a hang cannot be told from + a slow pull — the maintainer hit exactly this on the `0.1.0` release. Three causes, each its + own fix. The legs need markers: `plainpages`' `ci.sh` prints a `step()` header per leg and + this one prints nothing, so name the leg and the image before each. The longest leg is the + quietest: `test_output=$(… npm test 2>&1)` buffers the whole Node run to replay it after, + because the zero-test guard greps the count — stream it and grep a copy (`tee`), rather than + trading the output for the guard. And two legs are silenced outright, `npm pack` and the + tarball install, whose `>/dev/null` predates the offline install that made them quick and + quiet. `publish.sh` owes the same: today it says nothing between reading `private` and the + registry answering, which is where its `npm ci` and rebuild sit — the seconds §9 accepts + rather than promoting the gate's `dist`, and unmeasured until the log shows them. Per-leg + timing is what turns "slow or hung" from a guess into a reading; the browser leg's own + 5.4–7.9s against a 17s warm gate is the number that made it obviously cheap. + **Measured** (2026-09-19): ten legs, not the nine counted above, each naming its image, on a + 27.9 s warm gate — install 1.5 s, typecheck 1.1 s, Node tests 4.6 s, Deno 6.3 s, Bun 3.9 s, + build 1.0 s, pack and install 1.6 s, consumer typecheck 1.0 s, engines floor 0.5 s, browser + 5.7 s. The browser leg lands in the 5.4–7.9 s the item quotes, and the markers cost nothing + measurable: 28.9 s before against 27.9 s after. `publish.sh` reads its fields in 2.5 s and the + registry in 1.4 s; its `npm ci` and rebuild are the gate's own 1.5 s and 1.0 s, so the seconds + §9 accepts for rebuilding rather than promoting the gate's `dist` are about 2.5. + Three things the writing turned up. The markers print to stderr, so a leg whose value is read — + `publish.sh` asking npmjs — stays capturable. `leg`'s locals carry its own name because bash + scopes them into whatever the leg runs: unprefixed, `name` was swallowed by the leg reading + `package.json`. And `leg` returns its command's status the way `with_firefox` already did, + because the bare call swallowed a non-zero one wherever `set -e` is suspended, which also gets + the elapsed time printed for the leg that failed. + - [x] **5a — Rename to `@larvit/adf-codec` (`0.1.0`).** Before the first publish, the name being the published identity: `package.json` `name` and `repository`, the Gitea repo and its remote, the README title, §6's published-as line, the checkout directory. diff --git a/todo.md b/todo.md index 98361fb..a1283a5 100644 --- a/todo.md +++ b/todo.md @@ -69,20 +69,7 @@ bundle size and the tagline. - [x] **4.4 — The real payloads.** - [x] **4b — The block walk's retry (`0.2.0`).** - [x] **4c — The scanning rule's remaining sites (`0.2.0`).** -- [ ] **4d — What the gate says while it runs (`0.2.0`).** `ci.sh` runs nine legs and announces - none of them, so five minutes of a Gitea run read as silence and a hang cannot be told from - a slow pull — the maintainer hit exactly this on the `0.1.0` release. Three causes, each its - own fix. The legs need markers: `plainpages`' `ci.sh` prints a `step()` header per leg and - this one prints nothing, so name the leg and the image before each. The longest leg is the - quietest: `test_output=$(… npm test 2>&1)` buffers the whole Node run to replay it after, - because the zero-test guard greps the count — stream it and grep a copy (`tee`), rather than - trading the output for the guard. And two legs are silenced outright, `npm pack` and the - tarball install, whose `>/dev/null` predates the offline install that made them quick and - quiet. `publish.sh` owes the same: today it says nothing between reading `private` and the - registry answering, which is where its `npm ci` and rebuild sit — the seconds §9 accepts - rather than promoting the gate's `dist`, and unmeasured until the log shows them. Per-leg - timing is what turns "slow or hung" from a guess into a reading; the browser leg's own - 5.4–7.9s against a 17s warm gate is the number that made it obviously cheap. +- [x] **4d — What the gate says while it runs (`0.2.0`).** - [x] **5 — Ship `0.1.0`.** - [ ] **5e — The publish token's deadline.** `0.1.0` published only once the npm token carried **Bypass 2FA**: the account requiring no 2FA on writes was not enough, and npm -- 2.52.0 From 1fb712c76dc58e42ad13314d227b1bbe18b920d7 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sun, 20 Sep 2026 00:07:06 +0200 Subject: [PATCH 3/4] A legged function chains with && : the || that captures the status suspends set -e --- AGENTS.md | 13 +++++++------ ci.sh | 8 +++----- docker-runner.sh | 7 ++++--- publish.sh | 25 ++++++++++++------------- todo-history.md | 34 +++++++++++++++++++++------------- 5 files changed, 47 insertions(+), 40 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6decc9a..e24f42a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -238,12 +238,13 @@ The leg re-checks the conversions and nothing else — each fixture's emitted ma document, its error code — leaving the corpus's pairing, uniqueness, source positions and byte-level equality to the Node suite that owns them. -Every leg announces its name and its image before it runs and its elapsed time after, `publish.sh` -alongside `ci.sh`, so a long run reads as progress rather than as a hang — which is what a silent -one cost the `0.1.0` release. A leg added later owes the same marker, and a leg that buffers its -output to read something out of it streams and keeps the copy in a file: `tee /dev/stderr` reopens -the stream, so under the `> log 2>&1` a reader runs locally the two offsets advance independently -and punch NUL holes through each other's lines (4d). +Every leg announces its name, and the image where it runs in one, before it runs and its elapsed +time after, `publish.sh` alongside `ci.sh`, so a long run reads as progress rather than as a hang. +A leg added later owes the same marker, and a function a leg runs chains its statements with `&&`, +because the `||` that captures the leg's status suspends `set -e` for everything it calls. A leg +whose output is both streamed and grepped keeps the copy in a `mktemp` file: `tee /dev/stderr` +reopens fd 2, and under `./ci.sh > log 2>&1` the two offsets punch NUL holes through each other's +lines (4d). The floors live in the `test` script, so `npm test` and the gate are one path: 100% of lines and functions, and a branch floor that only ever moves upward. It sits below 100 because the guards diff --git a/ci.sh b/ci.sh index 012f0ba..2c99726 100755 --- a/ci.sh +++ b/ci.sh @@ -3,16 +3,14 @@ set -euo pipefail cd "$(dirname "$0")" source ./docker-runner.sh -test_log=$(mktemp) +leg "install ($node_image)" in_image "$node_image" npm ci +leg "typecheck ($node_image)" in_image "$node_image" npm run typecheck -# Streamed, and copied so the zero-test guard reads the count without trading the output for it. +test_log=$(mktemp) node_tests() { in_image "$node_image" npm test 2>&1 | tee "$test_log" } -leg "install ($node_image)" in_image "$node_image" npm ci -leg "typecheck ($node_image)" in_image "$node_image" npm run typecheck - leg "tests ($node_image)" node_tests if grep -q 'ℹ tests 0' "$test_log"; then echo 'the gate ran zero tests — failing instead of a vacuous green' diff --git a/docker-runner.sh b/docker-runner.sh index 4fa0ad7..b07496d 100644 --- a/docker-runner.sh +++ b/docker-runner.sh @@ -1,3 +1,5 @@ +: "${EPOCHREALTIME:?the gate times its legs with EPOCHREALTIME — bash 5 or newer}" + bun_image=oven/bun:1.4.0-alpine deno_image=denoland/deno:2.9.6 firefox_image=selenium/standalone-firefox:153.0.4 @@ -10,8 +12,7 @@ in_image() { docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp ${PROPERTY_RUNS+-e PROPERTY_RUNS} ${in_image_network:+--network "$in_image_network"} -v "$PWD:/app" -w /app --entrypoint "$entrypoint" "$image" "$@" } -# Markers go to stderr so a leg reporting a value stays capturable, and the locals carry the -# function's own name because bash scopes them dynamically into whatever the leg runs. +# Markers on stderr so a captured leg's value stays clean; leg_* because bash scopes local into the leg's own call. leg() { local leg_name=$1 leg_elapsed leg_started leg_status=0 shift @@ -26,7 +27,7 @@ leg() { with_firefox() { local container in_image_network status=0 - container=$(docker run -d --rm "$firefox_image") + container=$(docker run -d --rm "$firefox_image") || return $? # The id is baked in: the trap fires after this function's locals are gone. trap "docker rm -f $container >/dev/null 2>&1" EXIT trap 'exit 130' INT diff --git a/publish.sh b/publish.sh index e4c480c..9f4ec89 100755 --- a/publish.sh +++ b/publish.sh @@ -3,23 +3,22 @@ set -euo pipefail cd "$(dirname "$0")" source ./docker-runner.sh -read_field() { - in_image "$node_image" npm pkg get "$1" | tr -d '"\r' -} - -read_package_fields() { - private=$(read_field private) - name=$(read_field name) - version=$(read_field version) -} - published_version() { in_image "$node_image" npm view "$1@$2" version 2>/dev/null || true } push_tag() { - git tag "v$version" - git push origin "v$version" + git tag "v$version" && git push origin "v$version" +} + +read_field() { + in_image "$node_image" npm pkg get "$1" | tr -d '"\r' +} + +read_package_fields() { + private=$(read_field private) && + name=$(read_field name) && + version=$(read_field version) } leg "read package.json ($node_image)" read_package_fields @@ -28,7 +27,7 @@ if [ "$private" = 'true' ]; then exit 0 fi -published=$(leg "ask npmjs for $name@$version" published_version "$name" "$version") +published=$(leg "ask npmjs for $name@$version ($node_image)" published_version "$name" "$version") tagged=$(leg "ask origin for v$version" git ls-remote --tags origin "v$version") # Both steps observe their own end state, so a partial run converges on the next push to main. diff --git a/todo-history.md b/todo-history.md index 60d7d15..9b857e8 100644 --- a/todo-history.md +++ b/todo-history.md @@ -560,19 +560,27 @@ The done `todo.md` items in full, as they were written. `todo.md` keeps a one-li rather than promoting the gate's `dist`, and unmeasured until the log shows them. Per-leg timing is what turns "slow or hung" from a guess into a reading; the browser leg's own 5.4–7.9s against a 17s warm gate is the number that made it obviously cheap. - **Measured** (2026-09-19): ten legs, not the nine counted above, each naming its image, on a - 27.9 s warm gate — install 1.5 s, typecheck 1.1 s, Node tests 4.6 s, Deno 6.3 s, Bun 3.9 s, - build 1.0 s, pack and install 1.6 s, consumer typecheck 1.0 s, engines floor 0.5 s, browser - 5.7 s. The browser leg lands in the 5.4–7.9 s the item quotes, and the markers cost nothing - measurable: 28.9 s before against 27.9 s after. `publish.sh` reads its fields in 2.5 s and the - registry in 1.4 s; its `npm ci` and rebuild are the gate's own 1.5 s and 1.0 s, so the seconds - §9 accepts for rebuilding rather than promoting the gate's `dist` are about 2.5. - Three things the writing turned up. The markers print to stderr, so a leg whose value is read — - `publish.sh` asking npmjs — stays capturable. `leg`'s locals carry its own name because bash - scopes them into whatever the leg runs: unprefixed, `name` was swallowed by the leg reading - `package.json`. And `leg` returns its command's status the way `with_firefox` already did, - because the bare call swallowed a non-zero one wherever `set -e` is suspended, which also gets - the elapsed time printed for the leg that failed. + **Measured** (2026-09-20): ten legs, not the nine counted above, each naming the image it runs + in where it runs in one, on a 28.4 s warm gate — install 1.5 s, typecheck 1.2 s, Node tests + 4.9 s, Deno 6.0 s, Bun 4.5 s, build 1.0 s, pack and install 1.6 s, consumer typecheck 1.0 s, + engines floor 0.4 s, browser 5.6 s. The browser leg lands in the 5.4–7.9 s the item quotes, + and the markers cost nothing measurable: 28.9 s before against 28.4 s after. `publish.sh` + reads its fields in 2.6 s and the registry in 1.4 s; its `npm ci` and rebuild are the gate's + own 1.5 s and 1.0 s, so the seconds §9 accepts for rebuilding rather than promoting the gate's + `dist` are about 2.5. + Four things the writing turned up, three of them bash scoping a rule differently than it + reads. The markers print to stderr, so a leg whose value is read — `publish.sh` asking npmjs — + stays capturable. `leg`'s locals carry its own name because bash scopes them into whatever the + leg runs: unprefixed, `name` was swallowed by the leg reading `package.json`. `leg` returns + its command's status the way `with_firefox` already did, because the bare call dropped a + non-zero one wherever `set -e` is suspended, which also gets the elapsed time printed for the + leg that failed. And the `||` that captures that status suspends `set -e` for everything the + leg calls, so a function a leg runs chains its statements with `&&` or every statement but the + last runs unchecked: `read_package_fields` read on past a failed read, and `push_tag` pushed a + tag the tag step had refused to write, both of which aborted before this chunk (the + stability-reviewer, 2026-09-20). §10 carries the rule so the next leg cannot reintroduce it, + and `EPOCHREALTIME` is guarded at `source` so an older bash names itself rather than dying as + an unbound variable on the first leg. - [x] **5a — Rename to `@larvit/adf-codec` (`0.1.0`).** Before the first publish, the name being the published identity: `package.json` `name` and `repository`, the Gitea repo and its -- 2.52.0 From 78b2924fe1d68aeeea6248bb9f65e4fca7804954 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sun, 20 Sep 2026 00:12:05 +0200 Subject: [PATCH 4/4] The leg rule covers the host legs and reaches past the function a leg calls --- AGENTS.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e24f42a..e1fb8e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -238,13 +238,13 @@ The leg re-checks the conversions and nothing else — each fixture's emitted ma document, its error code — leaving the corpus's pairing, uniqueness, source positions and byte-level equality to the Node suite that owns them. -Every leg announces its name, and the image where it runs in one, before it runs and its elapsed -time after, `publish.sh` alongside `ci.sh`, so a long run reads as progress rather than as a hang. -A leg added later owes the same marker, and a function a leg runs chains its statements with `&&`, -because the `||` that captures the leg's status suspends `set -e` for everything it calls. A leg -whose output is both streamed and grepped keeps the copy in a `mktemp` file: `tee /dev/stderr` -reopens fd 2, and under `./ci.sh > log 2>&1` the two offsets punch NUL holes through each other's -lines (4d). +Every leg announces its name and, where a container is in play, the image, before it runs and its +elapsed time after, `publish.sh` alongside `ci.sh`, so a long run reads as progress rather than as +a hang. A leg added later owes the same marker, and a function a leg reaches chains its statements +with `&&`, because the `||` that captures the leg's status suspends `set -e` for everything it +calls. A leg whose output is both streamed and grepped keeps the copy in a `mktemp` +file: `tee /dev/stderr` reopens fd 2, and under `./ci.sh > log 2>&1` the two offsets punch NUL +holes through each other's lines (4d). The floors live in the `test` script, so `npm test` and the gate are one path: 100% of lines and functions, and a branch floor that only ever moves upward. It sits below 100 because the guards -- 2.52.0