Skip to content
Merged
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
11 changes: 9 additions & 2 deletions .claude/self/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`):
Expand Down
4 changes: 2 additions & 2 deletions .claude/self/gates.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
117 changes: 117 additions & 0 deletions .claude/self/smoke-fanout.sh
Original file line number Diff line number Diff line change
@@ -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"
8 changes: 8 additions & 0 deletions .claude/self/smoke/broken.patch
Original file line number Diff line number Diff line change
@@ -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( {
16 changes: 16 additions & 0 deletions .claude/self/smoke/implementer.patch
Original file line number Diff line number Diff line change
@@ -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 }
4 changes: 4 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
21 changes: 21 additions & 0 deletions examples/fixture-target/README.md
Original file line number Diff line number Diff line change
@@ -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.
26 changes: 26 additions & 0 deletions examples/fixture-target/gates.json
Original file line number Diff line number Diff line change
@@ -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" }
}
11 changes: 11 additions & 0 deletions examples/fixture-target/src/greet.js
Original file line number Diff line number Diff line change
@@ -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 }
13 changes: 13 additions & 0 deletions examples/fixture-target/test/greet.test.js
Original file line number Diff line number Diff line change
@@ -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)
})
Loading