Files
gbrain/scripts/select-e2e.ts
T
90e22c22e2 v0.23.1 feat: local CI gate + 4-tier wall-time optimization (~13x faster) (#528)
* feat: diff-aware E2E test selector

Adds scripts/select-e2e.ts: reads git diff vs origin/master, classifies
the change set (EMPTY/DOC_ONLY/SRC), and emits the relevant E2E test files
on stdout. Fail-closed by design: any unmapped src/ change runs all E2E.

- scripts/e2e-test-map.ts: hand-tuned path-glob -> test files map
- scripts/select-e2e.ts: pure-function selector with three explicit cases
- scripts/run-e2e.sh: accepts optional file list from argv + --dry-run-list
- test/select-e2e.test.ts: 24 cases including 3 codex regression guards
  (skills/, untracked files, unmapped src/)

* feat: local CI gate via docker compose

Adds bun run ci:local — runs every check GH Actions runs (gitleaks +
unit + 29 E2E files) inside a Docker container that bind-mounts the
repo. Pure bind-mount + named volumes (gbrain-ci-node-modules,
gbrain-ci-bun-cache, gbrain-ci-pg-data) for fast warm restarts.

- docker-compose.ci.yml: pgvector/pgvector:pg16 + oven/bun:1
- scripts/ci-local.sh: orchestrator with --diff, --no-pull, --clean
- gitleaks runs on host (scoped to working dir + branch commits)
- DATABASE_URL unset for unit phase (matches GH Actions split)
- git installed in container at startup (oven/bun:1 omits it)
- Postgres host port via GBRAIN_CI_PG_PORT env (default 5434)

Stronger than PR CI: runs all 29 E2E files vs CI's 2-file Tier 1.

* chore: bump version and changelog (v0.23.1)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: document local CI gate for v0.23.1

CLAUDE.md gains key-files entries for docker-compose.ci.yml,
scripts/ci-local.sh, scripts/select-e2e.ts + e2e-test-map.ts, and the
scripts/run-e2e.sh argv tweak. Pre-ship requirements section now lists
the Docker-based local gate as Path A alongside the manual lifecycle.

CONTRIBUTING.md tests section adds the bun run ci:local / ci:local:diff /
ci:select-e2e block with prerequisites (Docker engine + gitleaks) and the
GBRAIN_CI_PG_PORT override.

AGENTS.md "Before shipping" promotes ci:local as the easiest path and
keeps the manual lifecycle as a fallback.

README.md Contributing section points to ci:local for the full gate.

CHANGELOG.md untouched — v0.23.1 entry already finalized.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat: SHARD=N/M env support in scripts/run-e2e.sh

Filters the E2E file list to every M-th file starting at index N (1-indexed).
Sequential execution within a shard preserves the TRUNCATE CASCADE no-race
property documented at the top of the file. Empty-shard handling under
`set -u` uses ${arr[@]:-} fallback.

Standalone change; not yet wired up in ci-local.sh.

* feat: 4-way parallel E2E shards in ci:local

Replaces the single postgres service with 4 (postgres-1..4) on host ports
5434-5437. scripts/ci-local.sh fans 4 workers via xargs -P4 inside the
runner container; each pinned to its own DATABASE_URL via SHARD=N/4.

Wall-time on a 16-core host: ~6 min sequential -> ~1.5-2 min sharded.
Total full-gate wall-time goes from ~25 min to ~3-5 min warm.

Also handles git-worktree (Conductor) layouts: when /app/.git is a file
instead of a directory, parse the gitdir + commondir and bind-mount the
shared host gitdir at its absolute path. Without this, in-container
`git ls-files` (used by scripts/check-trailing-newline.sh and friends)
exits 128 with "not a git repository". Also runs
`git config --global --add safe.directory '*'` inside the container so
the root-uid container can read host-uid gitdir without "dubious
ownership" rejection.

CHANGELOG entry updated to cover the speedup.

- docker-compose.ci.yml: 4 pgvector services + per-shard named volumes
- scripts/ci-local.sh: parallel xargs orchestration + worktree mount fix
- CHANGELOG.md v0.23.1: 4-way sharded wall-time, 36 E2E files, --no-shard flag

* chore: regenerate llms-full.txt for v0.23.1 doc updates

Required by test/build-llms.test.ts case 4 — committed llms-full.txt
must match `bun run build:llms` output. The CHANGELOG + CLAUDE.md
updates in this branch shifted bytes; regen catches up.

* feat: scripts/run-unit-shard.sh + slow-test convention

Tier 1 + Tier 4 plumbing:
- scripts/run-unit-shard.sh: SHARD=N/M filter for unit files (excludes
  test/e2e/*). Excludes *.slow.test.ts (Tier 4 convention) so the fast
  shard fan-out skips known-slow files; CI's `bun run test` still includes
  them via default discovery.
- scripts/run-slow-tests.sh: companion that runs ONLY *.slow.test.ts.
  Wired as `bun run test:slow`.
- scripts/profile-tests.sh: portable awk parser that extracts the top-N
  slowest tests from any captured `bun test` output. Wired as
  `bun run test:profile`. Use it to pick demotion candidates.

* feat: PGLite snapshot fixture for ~4.5x faster cold init (Tier 3)

scripts/build-pglite-snapshot.ts boots a fresh PGLite, runs the full
initSchema() (forward bootstrap + 30 migrations), and dumps the post-init
state to test/fixtures/pglite-snapshot.tar plus a SHA-256 schema hash
sidecar (.version). Both gitignored — built on demand via
`bun run build:pglite-snapshot`.

PGLiteEngine.connect() reads GBRAIN_PGLITE_SNAPSHOT env: validates the
sidecar hash against the in-process MIGRATIONS hash, loads via PGLite's
loadDataDir blob, sets _snapshotLoaded so initSchema() short-circuits.
Measured per-file cold init drops from 828ms → 181ms.

Bootstrap-correctness tests (bootstrap.test.ts,
schema-bootstrap-coverage.test.ts) explicitly delete the env at file
top so they keep exercising the cold path they verify.

* feat: --classify-only + heartbeat tolerance fix (Tiers 2 + flake fix)

- scripts/select-e2e.ts: --classify-only flag emits EMPTY|DOC_ONLY|SRC.
  Used by ci-local.sh's --diff fast-path to skip the heavy gate when
  only docs changed.
- test/progress.test.ts: startHeartbeat tolerance widened to 1-20 over
  200ms (was 2-6 over 85ms). Under 4-way parallel shard load on a
  contended host, setTimeout's effective quantum balloons and the tight
  bound flakes. The test still verifies "fires multiple times, stops
  cleanly" — exact count was never load-bearing.

* feat: 4-way unit + E2E sharding in ci-local.sh + CHANGELOG (Tiers 1-4)

ci-local.sh ties the four tiers together:
- Tier 2: pre-flight diff classification on host. DOC_ONLY exits in ~5s
  (gitleaks only, no postgres, no container).
- Tier 1: guards + typecheck run ONCE before fan-out. xargs -P4 then
  spawns 4 shards inside the runner container, each running unit phase
  (env -u DATABASE_URL bash run-unit-shard.sh) followed by E2E phase
  (DATABASE_URL=postgres-N bash run-e2e.sh) — both sharded N/4. Per-shard
  logs in /tmp/shard-logs/shard-N.log; printed in shard order at the end.
- Tier 3: snapshot fixture built once at runner startup if missing,
  GBRAIN_PGLITE_SNAPSHOT exported so all shards inherit.
- Tier 4: run-unit-shard.sh excludes *.slow.test.ts; run-slow-tests.sh
  + test:slow npm script handle the demoted set.
- --no-shard preserves the legacy single-process flow for debug.

package.json: build:pglite-snapshot, test:slow, test:profile scripts.

Measured wall-time on 16-core host: 100s warm (down from ~22 min cold
single-process). 4 shards × ~640-1024 unit tests each, plus 9 E2E
files each. PGLite snapshot saves 4.5× per cold init (828ms → 181ms).

CHANGELOG.md updated with measured numbers + four-tier breakdown.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 20:47:32 -07:00

246 lines
7.6 KiB
TypeScript

#!/usr/bin/env bun
// scripts/select-e2e.ts
//
// Fail-closed diff-based E2E test selector. Reads the working-tree diff vs
// origin/master plus untracked files, classifies the change set as
// EMPTY / DOC_ONLY / SRC, and emits the relevant E2E test files on stdout.
//
// CONTRACT (fail-closed):
// - When in doubt, run all E2E. The map narrows from "all"; it never widens
// from "none". An unmapped src/ change emits ALL test/e2e/*.test.ts.
// - Doc-only diffs emit nothing (the only case where stdout is empty).
// - Empty diff emits ALL (clean branch shouldn't run nothing).
//
// Selection algorithm:
// 1. Read changed files from three git sources, union them:
// - git diff --name-only origin/master...HEAD (committed)
// - git diff --name-only HEAD (unstaged + staged)
// - git ls-files --others --exclude-standard (untracked, NOT .gitignore'd)
// 2. EMPTY -> emit ALL test/e2e/*.test.ts
// DOC_ONLY (every path matches doc allowlist) -> emit nothing
// SRC (at least one path is outside doc allowlist):
// a. Any escape-hatch path matched -> emit ALL
// b. Else union map matches; include directly-modified test/e2e/*.test.ts
// c. If still empty -> FAIL-CLOSED -> emit ALL
//
// On git command failure: print error to stderr and exit 2 so callers see the
// failure (xargs -r will run nothing AND the human sees the error).
//
// Usage:
// bun run scripts/select-e2e.ts
// bun run scripts/select-e2e.ts | xargs -r bash scripts/run-e2e.sh
import { spawnSync } from "node:child_process";
import { readdirSync, existsSync } from "node:fs";
import { join } from "node:path";
import { E2E_TEST_MAP } from "./e2e-test-map.ts";
// Doc allowlist (inclusive). A path counts as doc-only ONLY if it matches one
// of these patterns. Unrecognized paths fall through to SRC, never silently
// doc-only. skills/ is intentionally NOT here — skills are product input.
const DOC_ROOT_FILES = new Set([
"README.md",
"CLAUDE.md",
"AGENTS.md",
"CHANGELOG.md",
"TODOS.md",
"LICENSE",
"VERSION",
]);
function isDocPath(p: string): boolean {
if (DOC_ROOT_FILES.has(p)) return true;
// Any *.md at repo root.
if (!p.includes("/") && p.endsWith(".md")) return true;
// Anything under docs/.
if (p.startsWith("docs/")) return true;
return false;
}
// Escape-hatch triggers. Any match -> emit ALL.
const ESCAPE_HATCH_FILES = new Set([
"src/schema.sql",
"src/core/migrate.ts",
"src/core/db.ts",
"src/core/engine-factory.ts",
"src/core/operations.ts",
"package.json",
"bun.lock",
"Dockerfile.ci",
"docker-compose.ci.yml",
"scripts/ci-local.sh",
"scripts/run-e2e.sh",
"scripts/select-e2e.ts",
"scripts/e2e-test-map.ts",
"test/e2e/helpers.ts",
]);
const ESCAPE_HATCH_PREFIXES = [
"src/commands/migrations/",
"test/e2e/fixtures/",
"skills/",
".github/workflows/",
];
function isEscapeHatch(p: string): boolean {
if (ESCAPE_HATCH_FILES.has(p)) return true;
for (const prefix of ESCAPE_HATCH_PREFIXES) {
if (p.startsWith(prefix)) return true;
}
return false;
}
// Minimal glob matcher: supports ** (any segments) and * (one segment, no /).
// Throws on unsupported syntax so map mistakes surface loudly.
export function matchGlob(glob: string, path: string): boolean {
if (glob.includes("?") || glob.includes("[") || glob.includes("{")) {
throw new Error(
`select-e2e: unsupported glob syntax in "${glob}" (only ** and * are supported)`
);
}
// Build a regex: ** -> .*, * -> [^/]*, escape other regex meta-chars.
let regex = "";
let i = 0;
while (i < glob.length) {
const c = glob[i];
if (c === "*" && glob[i + 1] === "*") {
regex += ".*";
i += 2;
} else if (c === "*") {
regex += "[^/]*";
i += 1;
} else if (/[.+^${}()|\\]/.test(c)) {
regex += "\\" + c;
i += 1;
} else {
regex += c;
i += 1;
}
}
return new RegExp("^" + regex + "$").test(path);
}
function listAllE2ETests(repoRoot: string): string[] {
const dir = join(repoRoot, "test/e2e");
if (!existsSync(dir)) return [];
return readdirSync(dir)
.filter((f) => f.endsWith(".test.ts"))
.map((f) => `test/e2e/${f}`)
.sort();
}
// Pure function — exposed for unit tests. Decides what to emit given the
// inputs, without touching git or filesystem (callers pass arrays in).
export interface SelectInputs {
changedFiles: string[]; // union of three git sources
allE2ETests: string[]; // glob result of test/e2e/*.test.ts
map: Record<string, string[]>; // E2E_TEST_MAP
}
export type Classification = "EMPTY" | "DOC_ONLY" | "SRC";
export function classify(changedFiles: string[]): Classification {
if (changedFiles.length === 0) return "EMPTY";
for (const f of changedFiles) {
if (!isDocPath(f)) return "SRC";
}
return "DOC_ONLY";
}
export function selectTests(inputs: SelectInputs): string[] {
const { changedFiles, allE2ETests, map } = inputs;
const cls = classify(changedFiles);
const allSorted = allE2ETests.slice().sort();
if (cls === "EMPTY") return allSorted;
if (cls === "DOC_ONLY") return [];
// SRC case.
// 3a. Any escape-hatch -> ALL.
for (const f of changedFiles) {
if (isEscapeHatch(f)) return allSorted;
}
// 3b. Union map matches; include directly-modified test files.
const result = new Set<string>();
for (const f of changedFiles) {
if (isDocPath(f)) continue;
// Direct test file modification: include it.
if (f.startsWith("test/e2e/") && f.endsWith(".test.ts")) {
result.add(f);
continue;
}
for (const [glob, tests] of Object.entries(map)) {
if (matchGlob(glob, f)) {
for (const t of tests) result.add(t);
}
}
}
// 3c. Fail-closed: if no map entry matched any src/ path AND no test files
// were directly modified, run everything.
if (result.size === 0) return allSorted;
// Sort for determinism (helps tests + readability).
return Array.from(result).sort();
}
function runGit(args: string[], cwd: string): string {
const result = spawnSync("git", args, { cwd, encoding: "utf8" });
if (result.status !== 0) {
const stderr = (result.stderr || "").trim();
process.stderr.write(
`select-e2e: git ${args.join(" ")} failed: ${stderr}\n`
);
process.exit(2);
}
return result.stdout || "";
}
function readChangedFiles(repoRoot: string): string[] {
const sources = [
runGit(["diff", "--name-only", "origin/master...HEAD"], repoRoot),
runGit(["diff", "--name-only", "HEAD"], repoRoot),
runGit(["ls-files", "--others", "--exclude-standard"], repoRoot),
];
const set = new Set<string>();
for (const out of sources) {
for (const line of out.split("\n")) {
const trimmed = line.trim();
if (trimmed.length > 0) set.add(trimmed);
}
}
return Array.from(set).sort();
}
// Entrypoint. Skipped under test (Bun.main check).
if (import.meta.main) {
const repoRoot = spawnSync("git", ["rev-parse", "--show-toplevel"], {
encoding: "utf8",
}).stdout?.trim();
if (!repoRoot) {
process.stderr.write("select-e2e: not a git repository\n");
process.exit(2);
}
const changedFiles = readChangedFiles(repoRoot);
// --classify-only: print EMPTY|DOC_ONLY|SRC + exit. Used by ci-local.sh's
// Tier 2 fast-path so doc-only diffs skip the unit phase entirely.
if (process.argv.includes("--classify-only")) {
process.stdout.write(classify(changedFiles) + "\n");
process.exit(0);
}
const allE2ETests = listAllE2ETests(repoRoot);
const tests = selectTests({
changedFiles,
allE2ETests,
map: E2E_TEST_MAP,
});
process.stdout.write(tests.join(" "));
if (tests.length > 0) process.stdout.write("\n");
}