diff --git a/.claude/self/README.md b/.claude/self/README.md index 6330d20..d07e4b3 100644 --- a/.claude/self/README.md +++ b/.claude/self/README.md @@ -11,10 +11,17 @@ the tooling reads it only when explicitly pointed at it. ## Files - **`gates.json`** — the real adapter for THIS repo: modules (`docs`, `harness`→`.claude`, `examples`, `ci`) and node/bash-only gate commands, so they run with no extra linters installed. -- **`checks.sh`** — implements `build` / `lint` / `test`: +- **`checks.sh`** — implements the static checks: - `build` — every JSON config parses and each adapter (`gates.json` + `self/gates.json`) is well-shaped. - `lint` — `bash -n` every shell script + `node --check` every workflow. - - `test` — `build` + `lint` smoke (validates the harness end-to-end on itself). + - `test` — `build` + `lint` smoke (validates the harness statically on itself). +- **`smoke-fanout.sh`** + **`smoke/*.patch`** — Phase 2 (issue #64): a deterministic end-to-end smoke of + the fan-out scaffold against `examples/fixture-target` — stages a consumer-shaped temp repo (fixture + adapter + the real `gate.sh`), plays a *recorded implementer* (worktree → canned diff → module-boundary + check → gates → merge), and proves the failure path (a broken diff fails the gate non-zero). No agents, + no tokens, no network: it validates the scaffold **on the fixture, not on itself** (no bootstrap + regress). Wired into the self `test` gate, so it runs in the `self / test` CI job on every PR; + `test_affected` stays static-only (Stop-hook fast path). ## Running gates against the self-adapter `gate.sh` honors a `GATES_FILE` env override (defaults to `.claude/gates.json`): diff --git a/.claude/self/gates.json b/.claude/self/gates.json index eade57e..c1af9d0 100644 --- a/.claude/self/gates.json +++ b/.claude/self/gates.json @@ -11,12 +11,12 @@ ], "gates": { - "_note": "Implemented in .claude/self/checks.sh — node + bash only, no external linters, so they pass in a bare environment.", + "_note": "Static checks in .claude/self/checks.sh; end-to-end fan-out smoke in .claude/self/smoke-fanout.sh (issue #64) — node + bash + git only, so they pass in a bare environment. test_affected stays static-only: it's the Stop-hook fast path and shouldn't pay the smoke's git churn on every stop.", "install": "", "build": "bash .claude/self/checks.sh build", "lint": "bash .claude/self/checks.sh lint", "typecheck": "", - "test": "bash .claude/self/checks.sh test", + "test": "bash .claude/self/checks.sh test && bash .claude/self/smoke-fanout.sh", "test_affected": "bash .claude/self/checks.sh test", "coverage": "", "coverage_threshold": 0, diff --git a/.claude/self/smoke-fanout.sh b/.claude/self/smoke-fanout.sh new file mode 100644 index 0000000..71f828c --- /dev/null +++ b/.claude/self/smoke-fanout.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# smoke-fanout.sh — self-host Phase 2 (issue #64; spun off #11). +# +# Deterministic end-to-end smoke of the fan-out SCAFFOLD, run against the +# checked-in fixture target (examples/fixture-target) — never against the +# harness itself, so there is no bootstrap regress. No agents, no tokens, no +# network: a "recorded implementer" plays the worker role with a canned diff, +# which is what makes this CI-safe and deterministic (option (a) from the #11 +# plan; live runs stay on-demand). +# +# What it proves, in order: +# 1. a consumer-shaped repo (fixture gates.json as .claude/gates.json + the +# REAL gate.sh) can be staged and committed from scratch; +# 2. an isolated worktree + branch hosts the implementer's canned diff; +# 3. the diff respects the module boundary declared in the adapter; +# 4. the fixture's build/lint/test gates pass through the real gate.sh in the +# worktree (and an unconfigured gate exits 0 — the skip path); +# 5. the branch fast-forwards into main and the change is present; +# 6. FAILURE PATH: a broken canned diff makes the build gate exit non-zero +# (gate.sh propagates failure — what the loop's Stop hooks rely on). +# +# Sandbox-safe: everything happens under $TMPDIR (verified: git init/worktree/ +# commit/merge all work there under the strict sandbox). +set -uo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +fixture="$root/examples/fixture-target" +patches="$root/.claude/self/smoke" + +fail() { echo "smoke: FAIL — $*" >&2; exit 1; } +step() { echo "smoke: $*"; } + +command -v git >/dev/null || fail "git not found" +command -v node >/dev/null || fail "node not found" +[ -d "$fixture" ] || fail "fixture missing: $fixture" + +base="$(mktemp -d "${TMPDIR:-/tmp}/smoke-fanout.XXXXXX")" || fail "mktemp failed" +trap 'rm -rf "$base"' EXIT +repo="$base/target" + +# Identity/signing via -c flags only: the temp repo must not depend on (or +# touch) any host git config — and under the sandbox it couldn't anyway. +G() { git -C "$repo" -c user.name=smoke -c user.email=smoke@local -c commit.gpgsign=false "$@"; } + +# --- 1. Stage the fixture as a consumer-shaped repo -------------------------- +mkdir -p "$repo/.claude/scripts" +cp -R "$fixture/src" "$fixture/test" "$repo/" || fail "copy fixture sources" +cp "$fixture/gates.json" "$repo/.claude/gates.json" || fail "copy adapter" +cp "$root/.claude/scripts/gate.sh" "$repo/.claude/scripts/gate.sh" || fail "copy gate.sh" +G init -q -b main . || fail "git init" +G add -- .claude src test +G commit -qm "fixture: initial state" || fail "initial commit" +step "staged consumer-shaped fixture repo" + +# --- 2. 'Recorded implementer': isolated worktree + canned diff -------------- +wt="$base/wt-task" +G worktree add -q "$wt" -b feat/smoke-task || fail "worktree add" +git -C "$wt" apply "$patches/implementer.patch" || fail "apply implementer.patch" +step "worktree feat/smoke-task created; canned diff applied" + +# --- 3. Module-boundary check (the orchestrator's hard rule) ----------------- +mod="$(node -e "process.stdout.write(require('$repo/.claude/gates.json').modules[0].path)")" \ + || fail "read module path from adapter" +changed="$(git -C "$wt" apply --numstat "$patches/implementer.patch" | cut -f3)" +[ -n "$changed" ] || fail "could not list changed paths" +while IFS= read -r p; do + case "$p" in + "$mod"/*|"$mod") ;; + *) fail "canned diff escapes module '$mod': $p" ;; + esac +done <<< "$changed" +# shellcheck disable=SC2086 -- $changed is newline-split file list, added by name +git -C "$wt" add -- $changed +git -C "$wt" -c user.name=smoke -c user.email=smoke@local -c commit.gpgsign=false \ + commit -qm "feat: canned implementer change" || fail "worktree commit" +step "module boundary respected ($mod); change committed on branch" + +# --- 4. Gates through the REAL gate.sh, inside the worktree ------------------ +# env -u GATES_FILE: when this smoke itself runs under the self adapter (CI sets +# GATES_FILE=.claude/self/gates.json), the inner gate.sh would inherit it, fail +# to find that path inside the FIXTURE repo, and skip every gate — passing +# vacuously even on broken code. The fixture is a consumer repo: it must read +# its own default .claude/gates.json. (Caught by the failure-path check below.) +in_gate() { env -u GATES_FILE bash "$1/.claude/scripts/gate.sh" "$2"; } +step "toolchain: node $(node -v), $(git --version)" +for g in build lint test; do + # Capture output and surface it on failure — a silent gate failure on CI is + # undebuggable from the job log (learned the hard way on PR #65). + if ! out="$(in_gate "$wt" "$g" 2>&1)"; then + printf '%s\n' "$out" | tail -40 >&2 + fail "gate '$g' failed in worktree (output above)" + fi +done +step "gates build/lint/test passed in worktree" +if ! out="$(in_gate "$wt" typecheck 2>&1)"; then + printf '%s\n' "$out" | tail -40 >&2 + fail "unconfigured gate did not skip cleanly (output above)" +fi +step "unconfigured gate skipped with exit 0" + +# --- 5. Merge and verify ------------------------------------------------------ +G merge -q --ff-only feat/smoke-task || fail "fast-forward merge" +[ -f "$repo/src/farewell.js" ] || fail "merged change missing on main" +G worktree remove "$wt" || fail "worktree remove" +step "branch merged; change present on main; worktree cleaned" + +# --- 6. Failure path: broken diff must fail the gate -------------------------- +wt2="$base/wt-broken" +G worktree add -q "$wt2" -b feat/smoke-broken || fail "worktree add (broken)" +git -C "$wt2" apply "$patches/broken.patch" || fail "apply broken.patch" +if in_gate "$wt2" build >/dev/null 2>&1; then + fail "broken diff did NOT fail the build gate — failure propagation is broken" +fi +step "failure path verified: broken diff fails the build gate non-zero" +G worktree remove --force "$wt2" || fail "worktree remove (broken)" + +echo "smoke: PASS — fan-out scaffold validated end-to-end on the fixture" diff --git a/.claude/self/smoke/broken.patch b/.claude/self/smoke/broken.patch new file mode 100644 index 0000000..206e952 --- /dev/null +++ b/.claude/self/smoke/broken.patch @@ -0,0 +1,8 @@ +diff --git a/src/broken.js b/src/broken.js +new file mode 100644 +--- /dev/null ++++ b/src/broken.js +@@ -0,0 +1,3 @@ ++'use strict' ++// deliberate syntax error (unclosed paren+brace): the build gate MUST fail on this ++function broken( { diff --git a/.claude/self/smoke/implementer.patch b/.claude/self/smoke/implementer.patch new file mode 100644 index 0000000..1f1cb11 --- /dev/null +++ b/.claude/self/smoke/implementer.patch @@ -0,0 +1,16 @@ +diff --git a/src/farewell.js b/src/farewell.js +new file mode 100644 +--- /dev/null ++++ b/src/farewell.js +@@ -0,0 +1,11 @@ ++'use strict' ++ ++/** Canned "recorded implementer" change (smoke-fanout.sh) — stays inside src/. */ ++function farewell(name) { ++ if (typeof name !== 'string' || name.length === 0) { ++ throw new TypeError('name must be a non-empty string') ++ } ++ return `Goodbye, ${name}!` ++} ++ ++module.exports = { farewell } diff --git a/examples/README.md b/examples/README.md index 35daed2..eda6eeb 100644 --- a/examples/README.md +++ b/examples/README.md @@ -11,3 +11,7 @@ Each subdirectory is one stack; the `gates.json` there is what you'd drop into y > These are **references, not runnable projects** — they show the adapter shape and the decisions a mixed > stack forces, not a buildable tree. Adapt the paths and commands to your repo, then verify each gate runs > (`bash .claude/scripts/gate.sh build`, etc.). + +Also here, but **not** a worked example: [`fixture-target/`](fixture-target/) — the tiny *runnable* fixture +repo the self-host smoke harness (`.claude/self/smoke-fanout.sh`, issue #64) drives end-to-end on every PR. +See its README; don't copy it as an adapter starting point. diff --git a/examples/fixture-target/README.md b/examples/fixture-target/README.md new file mode 100644 index 0000000..c8abfbd --- /dev/null +++ b/examples/fixture-target/README.md @@ -0,0 +1,21 @@ +# fixture-target — smoke-test fixture for the fan-out scaffold + +A deliberately tiny, consumer-shaped target repo used by the self-host smoke harness +(`.claude/self/smoke-fanout.sh`, issue #64 — Phase 2 of #11). It is **not** a worked example to copy +(see `ts-solidity-foundry/` for that); it exists so every PR to this repo can validate the +deterministic fan-out scaffolding end-to-end **on a fixture, not on the harness itself** (no +bootstrap regress) and **without live agents** (no tokens, no nondeterminism — CI-safe). + +What the smoke harness does with it: + +1. Stages `src/`, `test/`, and `gates.json` (as `.claude/gates.json`) plus the **real** + `gate.sh` into a temp git repo — the same layout a consumer repo has. +2. Plays a *recorded implementer*: isolated worktree → applies a canned diff + (`.claude/self/smoke/implementer.patch`) → asserts the diff stays inside the `fixture-core` + module boundary (`src/`). +3. Runs the fixture's `build`/`lint`/`test` gates through `gate.sh` in the worktree (plus an + empty gate to prove the skip path), merges the branch, and asserts the change landed on `main`. +4. Proves the failure path: a broken canned diff (`smoke/broken.patch`) must make the build gate + exit non-zero. + +Keep it minimal: one module (`src/`), node-only gates, no dependencies. diff --git a/examples/fixture-target/gates.json b/examples/fixture-target/gates.json new file mode 100644 index 0000000..fca8a70 --- /dev/null +++ b/examples/fixture-target/gates.json @@ -0,0 +1,26 @@ +{ + "_README": "Adapter for the SMOKE-TEST FIXTURE TARGET (issue #64, Phase 2 of #11). A minimal consumer-shaped repo the smoke harness (.claude/self/smoke-fanout.sh) stages into a temp git repo and drives end-to-end: worktree -> canned diff -> these gates via the real gate.sh -> merge. Gates are node-only so they run in a bare environment.", + + "project": { "name": "fixture-target", "language": "js", "packageManager": "none" }, + + "modules": [ + { "name": "fixture-core", "path": "src", "description": "The fixture's only module. Smoke 'implementer' diffs must stay inside this path — the harness asserts it." } + ], + + "gates": { + "install": "", + "build": "node --check src/*.js", + "lint": "node --check src/*.js test/*.js", + "typecheck": "", + "test": "node --test test/*.test.js", + "test_affected": "node --test test/*.test.js", + "coverage": "", + "coverage_threshold": 0, + "e2e": "", + "security": "" + }, + + "worktree": { "setup": "", "teardown": "" }, + + "merge": { "policy": "pr-per-agent", "baseBranch": "main" } +} diff --git a/examples/fixture-target/src/greet.js b/examples/fixture-target/src/greet.js new file mode 100644 index 0000000..b30ccda --- /dev/null +++ b/examples/fixture-target/src/greet.js @@ -0,0 +1,11 @@ +'use strict' + +/** The fixture's one real function — enough surface for gates to bite on. */ +function greet(name) { + if (typeof name !== 'string' || name.length === 0) { + throw new TypeError('name must be a non-empty string') + } + return `Hello, ${name}!` +} + +module.exports = { greet } diff --git a/examples/fixture-target/test/greet.test.js b/examples/fixture-target/test/greet.test.js new file mode 100644 index 0000000..054a66a --- /dev/null +++ b/examples/fixture-target/test/greet.test.js @@ -0,0 +1,13 @@ +'use strict' + +const { test } = require('node:test') +const assert = require('node:assert/strict') +const { greet } = require('../src/greet.js') + +test('greet greets by name', () => { + assert.equal(greet('Ada'), 'Hello, Ada!') +}) + +test('greet rejects an empty name', () => { + assert.throws(() => greet(''), TypeError) +})