v0.41.6.0 feat(ci): CI test speedup — 23min → ~9min via matrix 4→6 + weight-aware sharding + auto SHA cache + parallel verify (#1444)

* feat(ci): scripts/run-verify-parallel.sh — parallel verify dispatcher

Fans out the 21 pre-test grep guards via & + wait, captures per-check
exit codes in a tempdir, aggregates failures with named check + log
tail to stderr on miss. Wallclock 27s sequential → 13s parallel
locally (2x). Bigger CI win is shard 1 deload (workflow restructure
in a later commit).

Pinned by test/scripts/run-verify-parallel.test.ts (6 cases: CLI
contract + synthetic dispatcher failure-surfacing).

* feat(ci): weight-aware LPT bin-packer + auto SHA cache hash

scripts/sharding.ts (NEW) — pure TypeScript LPT bin-packer. Sort
weights desc, assign each file to the shard with current minimum total.
Worst-case makespan within 4/3 of optimal, O(n log n). Missing weights
fall back to corpus median (not 0). New test file → ships immediately
without regenerating weights. Pinned by test/scripts/sharding.test.ts
(23 cases).

scripts/mine-shard-weights.ts (NEW) — scrapes per-file timing from
gh run view --log via timestamp delta between ##[group]test/foo.test.ts:
headers within a shard. Three input modes: --run <ID>, --from-file
<PATH>, stdin. Stable JSON output (sorted keys). Initial weights mined
from run 26398061007. Pinned by test/scripts/mine-shard-weights.test.ts
(15 cases).

scripts/ci-cache-hash.sh (NEW) — deterministic 16-char sha256 over
git ls-files -s minus deny-list (CHANGELOG/TODOS/README/LICENSE/
docs/**/*.md). CLAUDE.md, AGENTS.md, skills/**/* deliberately
INCLUDED (8+ test files read them; deny-listing would create
false-pass holes). ~40ms on 1891 files. Pinned by
test/scripts/ci-cache-hash.test.ts (24 cases: 8 CRITICAL false-pass
guards + 7 SAFE deny-list invariants + 9 edge cases).

scripts/test-weights.json (NEW) — 712 weights. Total 3306s observed
runtime; median 30ms; max 6 min outlier.

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

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-05-25 13:15:11 -07:00
committed by GitHub
co-authored by Claude Opus 4.7
parent e084457c2d
commit 3a2605e9a0
16 changed files with 2977 additions and 109 deletions
+422
View File
@@ -0,0 +1,422 @@
/**
* ci-cache-hash.test.ts — adversarial coverage of the auto-cache hash.
*
* The cache key controls false-pass risk. If a test-affecting file is
* accidentally deny-listed (or a file change fails to invalidate the
* hash), broken code ships under a green CI check. This suite is the
* primary safety net.
*
* Strategy: each test spins up a synthetic git repo, copies the
* ci-cache-hash.sh script in, populates a small file set covering the
* deny-list edges, runs the script, modifies one file, runs again,
* asserts hash behavior.
*
* Critical invariants (the false-pass guards):
* - CLAUDE.md edit → DIFFERENT hash
* - AGENTS.md edit → DIFFERENT hash
* - skills/foo/SKILL.md edit → DIFFERENT hash
* - src/core/db.ts edit → DIFFERENT hash
* - test/foo.test.ts edit → DIFFERENT hash
*
* Safe deny-list invariants:
* - CHANGELOG.md edit → same hash
* - README.md edit → same hash
* - docs/guide.md edit → same hash
* - TODOS.md edit → same hash
* - LICENSE edit → same hash
*/
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { execFileSync, spawnSync } from "node:child_process";
import {
cpSync,
mkdirSync,
mkdtempSync,
rmSync,
symlinkSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
const REPO_ROOT = resolve(import.meta.dir, "..", "..");
const SCRIPT_SRC = resolve(REPO_ROOT, "scripts/ci-cache-hash.sh");
interface Sandbox {
dir: string;
scriptPath: string;
}
function makeSandbox(files: Record<string, string>): Sandbox {
const dir = mkdtempSync(join(tmpdir(), "ci-cache-hash-"));
// Init git repo
execFileSync("git", ["init", "--quiet"], { cwd: dir });
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: dir });
execFileSync("git", ["config", "user.name", "Test"], { cwd: dir });
// Copy script under scripts/ so cd "$(dirname "$0")/.." lands at sandbox root
mkdirSync(join(dir, "scripts"), { recursive: true });
cpSync(SCRIPT_SRC, join(dir, "scripts/ci-cache-hash.sh"));
// Write each fixture file under its declared path
for (const [path, content] of Object.entries(files)) {
const abs = join(dir, path);
mkdirSync(resolve(abs, ".."), { recursive: true });
writeFileSync(abs, content);
}
// Stage + commit so git ls-files returns them
execFileSync("git", ["add", "-A"], { cwd: dir });
execFileSync("git", ["commit", "--quiet", "-m", "fixture"], { cwd: dir });
return { dir, scriptPath: join(dir, "scripts/ci-cache-hash.sh") };
}
function hash(s: Sandbox): string {
const r = spawnSync("bash", [s.scriptPath], {
cwd: s.dir,
encoding: "utf8",
});
if (r.status !== 0) {
throw new Error(`ci-cache-hash exit ${r.status}: ${r.stderr}`);
}
return r.stdout.trim();
}
function modify(s: Sandbox, path: string, content: string) {
writeFileSync(join(s.dir, path), content);
// The script reads `git ls-files -s` which reflects the INDEX, not
// the working tree. Stage + commit so the change is visible to the
// hash. This matches what CI sees on a checked-out PR (committed
// tree, not working tree).
execFileSync("git", ["add", path], { cwd: s.dir });
execFileSync("git", ["commit", "--quiet", "-m", `modify ${path}`], {
cwd: s.dir,
});
}
// ──────────────────────────────────────────────────────────────────────
// Baseline fixtures used across most cases.
// ──────────────────────────────────────────────────────────────────────
const BASELINE_FILES: Record<string, string> = {
"CHANGELOG.md": "# Changelog\n\n## [1.0.0]\n",
"TODOS.md": "- [ ] thing\n",
"README.md": "# Project\n",
"LICENSE": "MIT\n",
"CLAUDE.md": "# CLAUDE.md\n\nproject instructions\n",
"AGENTS.md": "# AGENTS.md\n\nopenclaw entry\n",
"package.json": '{"name":"test","version":"0.0.0"}\n',
"bun.lock": "lockfile-v1\n",
"tsconfig.json": '{"compilerOptions":{"strict":true}}\n',
"src/core/db.ts": "export const db = 1;\n",
"src/cli.ts": "console.log('hi');\n",
"test/foo.test.ts": "import {test} from 'bun:test';\ntest('x', () => {});\n",
"scripts/check-x.sh": "#!/bin/bash\necho ok\n",
".github/workflows/test.yml": "name: Test\n",
"skills/foo/SKILL.md": "---\nname: foo\n---\nFoo skill\n",
"docs/guide.md": "# Guide\n",
"docs/sub/notes.md": "Notes\n",
"docs/raw.txt": "raw text\n",
};
describe("ci-cache-hash.sh — determinism", () => {
let sb: Sandbox;
beforeAll(() => {
sb = makeSandbox(BASELINE_FILES);
});
afterAll(() => {
if (sb) rmSync(sb.dir, { recursive: true, force: true });
});
it("same tree → same hash (run twice)", () => {
const h1 = hash(sb);
const h2 = hash(sb);
expect(h1).toBe(h2);
expect(h1).toMatch(/^[0-9a-f]{16}$/);
});
it("hash is 16-char lowercase hex", () => {
const h = hash(sb);
expect(h).toMatch(/^[0-9a-f]{16}$/);
});
});
describe("ci-cache-hash.sh — CRITICAL false-pass guards (must invalidate)", () => {
// Each test gets its own sandbox so modifications don't leak across cases.
function withSandbox(test: (sb: Sandbox) => void) {
const sb = makeSandbox(BASELINE_FILES);
try {
test(sb);
} finally {
rmSync(sb.dir, { recursive: true, force: true });
}
}
it("CLAUDE.md edit MUST change hash (8+ test files reference it)", () => {
withSandbox((sb) => {
const before = hash(sb);
modify(sb, "CLAUDE.md", "# CLAUDE.md\n\nNEW INSTRUCTIONS\n");
const after = hash(sb);
expect(after).not.toBe(before);
});
});
it("AGENTS.md edit MUST change hash (resolver tests read it)", () => {
withSandbox((sb) => {
const before = hash(sb);
modify(sb, "AGENTS.md", "# AGENTS.md\n\nNEW DISPATCH\n");
const after = hash(sb);
expect(after).not.toBe(before);
});
});
it("skills/foo/SKILL.md edit MUST change hash (skill conformance tests)", () => {
withSandbox((sb) => {
const before = hash(sb);
modify(sb, "skills/foo/SKILL.md", "---\nname: foo\n---\nNEW SKILL BODY\n");
const after = hash(sb);
expect(after).not.toBe(before);
});
});
it("src/core/db.ts edit MUST change hash", () => {
withSandbox((sb) => {
const before = hash(sb);
modify(sb, "src/core/db.ts", "export const db = 2;\n");
const after = hash(sb);
expect(after).not.toBe(before);
});
});
it("test/foo.test.ts edit MUST change hash", () => {
withSandbox((sb) => {
const before = hash(sb);
modify(
sb,
"test/foo.test.ts",
"import {test} from 'bun:test';\ntest('y', () => {});\n",
);
const after = hash(sb);
expect(after).not.toBe(before);
});
});
it("package.json edit MUST change hash", () => {
withSandbox((sb) => {
const before = hash(sb);
modify(sb, "package.json", '{"name":"test","version":"0.0.1"}\n');
const after = hash(sb);
expect(after).not.toBe(before);
});
});
it("bun.lock edit MUST change hash (dependency drift catches false-pass)", () => {
withSandbox((sb) => {
const before = hash(sb);
modify(sb, "bun.lock", "lockfile-v2\n");
const after = hash(sb);
expect(after).not.toBe(before);
});
});
it(".github/workflows/test.yml edit MUST change hash (CI shape change is test-affecting)", () => {
withSandbox((sb) => {
const before = hash(sb);
modify(sb, ".github/workflows/test.yml", "name: Test\n# changed\n");
const after = hash(sb);
expect(after).not.toBe(before);
});
});
});
describe("ci-cache-hash.sh — SAFE deny-list invariants (must NOT invalidate)", () => {
function withSandbox(test: (sb: Sandbox) => void) {
const sb = makeSandbox(BASELINE_FILES);
try {
test(sb);
} finally {
rmSync(sb.dir, { recursive: true, force: true });
}
}
it("CHANGELOG.md edit produces SAME hash (deny-listed)", () => {
withSandbox((sb) => {
const before = hash(sb);
modify(sb, "CHANGELOG.md", "# Changelog\n\n## [1.1.0]\nnew release\n");
const after = hash(sb);
expect(after).toBe(before);
});
});
it("README.md edit produces SAME hash (deny-listed)", () => {
withSandbox((sb) => {
const before = hash(sb);
modify(sb, "README.md", "# Project\n\nupdated tagline\n");
const after = hash(sb);
expect(after).toBe(before);
});
});
it("TODOS.md edit produces SAME hash (deny-listed)", () => {
withSandbox((sb) => {
const before = hash(sb);
modify(sb, "TODOS.md", "- [x] thing\n");
const after = hash(sb);
expect(after).toBe(before);
});
});
it("LICENSE edit produces SAME hash (deny-listed)", () => {
withSandbox((sb) => {
const before = hash(sb);
modify(sb, "LICENSE", "Apache-2.0\n");
const after = hash(sb);
expect(after).toBe(before);
});
});
it("docs/guide.md edit produces SAME hash (deny-listed via docs/**/*.md)", () => {
withSandbox((sb) => {
const before = hash(sb);
modify(sb, "docs/guide.md", "# Guide v2\n");
const after = hash(sb);
expect(after).toBe(before);
});
});
it("docs/sub/notes.md edit produces SAME hash (nested docs match)", () => {
withSandbox((sb) => {
const before = hash(sb);
modify(sb, "docs/sub/notes.md", "Updated notes\n");
const after = hash(sb);
expect(after).toBe(before);
});
});
it("docs/raw.txt edit produces SAME hash (deny-listed via docs/**/*.txt)", () => {
withSandbox((sb) => {
const before = hash(sb);
modify(sb, "docs/raw.txt", "new raw text\n");
const after = hash(sb);
expect(after).toBe(before);
});
});
});
describe("ci-cache-hash.sh — edge cases", () => {
function withSandbox(
files: Record<string, string>,
test: (sb: Sandbox) => void,
) {
const sb = makeSandbox(files);
try {
test(sb);
} finally {
rmSync(sb.dir, { recursive: true, force: true });
}
}
it("untracked file does NOT affect hash (git ls-files only)", () => {
withSandbox(BASELINE_FILES, (sb) => {
const before = hash(sb);
// Write a new file but don't `git add` it.
writeFileSync(join(sb.dir, "src/untracked.ts"), "const x = 1;\n");
const after = hash(sb);
expect(after).toBe(before);
});
});
it("rename detection: same content, new path → DIFFERENT hash", () => {
withSandbox(BASELINE_FILES, (sb) => {
const before = hash(sb);
execFileSync("git", ["mv", "src/cli.ts", "src/main.ts"], { cwd: sb.dir });
const after = hash(sb);
// Same content, new path. Our hash includes the path, so it changes.
// This is correct: a rename can absolutely affect test outcomes
// (imports change, file resolution changes, etc.).
expect(after).not.toBe(before);
});
});
it("new file under unknown path (e.g. prompts/x.md) DOES affect hash (deny-list default)", () => {
withSandbox(BASELINE_FILES, (sb) => {
const before = hash(sb);
// Add a NEW file in a path that isn't deny-listed and didn't exist
// before. Default behavior: include in hash. Closes the "missed file
// type" false-pass class (e.g. someone adds `prompts/*.md` that
// tests read).
mkdirSync(join(sb.dir, "prompts"), { recursive: true });
writeFileSync(join(sb.dir, "prompts/extract-takes.md"), "system prompt\n");
execFileSync("git", ["add", "prompts/extract-takes.md"], { cwd: sb.dir });
execFileSync("git", ["commit", "--quiet", "-m", "add prompts"], {
cwd: sb.dir,
});
const after = hash(sb);
expect(after).not.toBe(before);
});
});
it("symlink target change affects hash (git hash-object follows symlink contents)", () => {
// Note: git stores symlinks as the link target string, so changing
// the LINK TARGET changes the git blob sha. Our hash will catch
// that. We can't easily test "change of target's content" without
// creating an out-of-tree file, so this case pins the link-target
// path change.
withSandbox(BASELINE_FILES, (sb) => {
// Create a symlink and commit.
symlinkSync("./db.ts", join(sb.dir, "src/core/db-link.ts"));
execFileSync("git", ["add", "src/core/db-link.ts"], { cwd: sb.dir });
execFileSync("git", ["commit", "--quiet", "-m", "add link"], {
cwd: sb.dir,
});
const before = hash(sb);
// Point the symlink elsewhere — and commit so it lands in the
// index that `git ls-files -s` reads.
rmSync(join(sb.dir, "src/core/db-link.ts"));
symlinkSync("./cli.ts", join(sb.dir, "src/core/db-link.ts"));
execFileSync("git", ["add", "src/core/db-link.ts"], { cwd: sb.dir });
execFileSync("git", ["commit", "--quiet", "-m", "repoint link"], {
cwd: sb.dir,
});
const after = hash(sb);
expect(after).not.toBe(before);
});
});
it("locale-stable: LC_ALL=de_DE.UTF-8 produces the same hash as default", () => {
withSandbox(BASELINE_FILES, (sb) => {
const defaultHash = spawnSync("bash", [sb.scriptPath], {
cwd: sb.dir,
encoding: "utf8",
}).stdout.trim();
const altLocale = spawnSync("bash", [sb.scriptPath], {
cwd: sb.dir,
encoding: "utf8",
env: { ...process.env, LC_ALL: "de_DE.UTF-8", LANG: "de_DE.UTF-8" },
}).stdout.trim();
expect(altLocale).toBe(defaultHash);
});
});
});
describe("ci-cache-hash.sh — usage errors", () => {
it("--bogus arg exits 2", () => {
const r = spawnSync("bash", [SCRIPT_SRC, "--bogus"], {
cwd: REPO_ROOT,
encoding: "utf8",
});
expect(r.status).toBe(2);
expect(r.stderr).toContain("usage:");
});
it("--verbose flag works (writes diagnostics to stderr, hash to stdout)", () => {
const r = spawnSync("bash", [SCRIPT_SRC, "--verbose"], {
cwd: REPO_ROOT,
encoding: "utf8",
});
expect(r.status).toBe(0);
expect(r.stdout.trim()).toMatch(/^[0-9a-f]{16}$/);
expect(r.stderr).toContain("files in hash");
});
});
+158
View File
@@ -0,0 +1,158 @@
// mine-shard-weights.test.ts — pure-function tests for the log parser
// and weight extraction. The full pipeline (gh run view → write JSON)
// is integration-tested by actually running it once during T4.
import { describe, expect, it } from "bun:test";
import {
computeWeights,
parseLog,
serializeWeights,
} from "../../scripts/mine-shard-weights.ts";
const SAMPLE = `test (1)\tUNKNOWN STEP\t2026-05-25T11:26:40.000000Z ##[group]test/alpha.test.ts:
test (1)\tUNKNOWN STEP\t2026-05-25T11:26:41.000000Z (pass) some test [1.00ms]
test (1)\tUNKNOWN STEP\t2026-05-25T11:26:43.500000Z ##[group]test/beta.test.ts:
test (1)\tUNKNOWN STEP\t2026-05-25T11:26:45.000000Z (pass) another test
test (1)\tUNKNOWN STEP\t2026-05-25T11:26:48.000000Z ##[group]test/gamma.test.ts:
test (1)\tUNKNOWN STEP\t2026-05-25T11:26:50.000000Z ##[group]test/delta.test.ts:
test (2)\tUNKNOWN STEP\t2026-05-25T11:26:42.000000Z ##[group]test/epsilon.test.ts:
test (2)\tUNKNOWN STEP\t2026-05-25T11:26:55.000000Z ##[group]test/zeta.test.ts:
`;
describe("parseLog", () => {
it("extracts file-start events with timestamps and jobs", () => {
const events = parseLog(SAMPLE);
expect(events.length).toBe(6);
expect(events[0]).toMatchObject({
job: "test (1)",
file: "test/alpha.test.ts",
});
expect(events[5]).toMatchObject({
job: "test (2)",
file: "test/zeta.test.ts",
});
});
it("preserves stream order", () => {
const events = parseLog(SAMPLE);
const files = events.map((e) => e.file);
expect(files).toEqual([
"test/alpha.test.ts",
"test/beta.test.ts",
"test/gamma.test.ts",
"test/delta.test.ts",
"test/epsilon.test.ts",
"test/zeta.test.ts",
]);
});
it("ignores non-group lines", () => {
// The sample has (pass) lines mixed in. They should not appear in the
// event list; parseLog only emits ##[group]test/X.test.ts: matches.
const events = parseLog(SAMPLE);
expect(events.every((e) => e.file.startsWith("test/"))).toBe(true);
expect(events.every((e) => e.file.endsWith(".test.ts"))).toBe(true);
});
it("handles empty input", () => {
expect(parseLog("")).toEqual([]);
});
it("ignores malformed timestamps", () => {
const bad = "test (1)\tUNKNOWN\tBAD-TS ##[group]test/x.test.ts:\n";
expect(parseLog(bad)).toEqual([]);
});
it("rejects non-test-file groups (e.g. setup-bun action groups)", () => {
const noise =
"test (1)\tUNKNOWN\t2026-05-25T11:26:40.000Z ##[group]Run actions/checkout\n";
expect(parseLog(noise)).toEqual([]);
});
});
describe("computeWeights", () => {
it("computes delta between consecutive file headers within a job", () => {
const events = parseLog(SAMPLE);
const weights = computeWeights(events);
// job test (1): alpha → 3500ms (40s → 43.5s), beta → 4500ms (43.5s → 48s),
// gamma → 2000ms (48s → 50s), delta dropped (no successor).
expect(weights.get("test/alpha.test.ts")).toBe(3500);
expect(weights.get("test/beta.test.ts")).toBe(4500);
expect(weights.get("test/gamma.test.ts")).toBe(2000);
expect(weights.get("test/delta.test.ts")).toBeUndefined();
// job test (2): epsilon → 13000ms (42s → 55s), zeta dropped.
expect(weights.get("test/epsilon.test.ts")).toBe(13000);
expect(weights.get("test/zeta.test.ts")).toBeUndefined();
});
it("does not cross job boundaries", () => {
// If we naively diffed across jobs we'd get bogus values when
// alpha (test 1) was followed by epsilon (test 2) by clock skew.
const events = parseLog(SAMPLE);
const weights = computeWeights(events);
// alpha → beta within same job: 3500ms. Not 2000ms (which would be
// alpha's stamp to epsilon's stamp across jobs).
expect(weights.get("test/alpha.test.ts")).toBe(3500);
});
it("takes the max when a file appears with multiple deltas", () => {
const events = [
{ job: "test (1)", timestampMs: 1000, file: "test/x.test.ts" },
{ job: "test (1)", timestampMs: 2000, file: "test/y.test.ts" },
{ job: "test (2)", timestampMs: 3000, file: "test/x.test.ts" },
{ job: "test (2)", timestampMs: 8000, file: "test/y.test.ts" },
];
const w = computeWeights(events);
// x: 1000→2000 (1000ms in job 1), 3000→8000 (5000ms in job 2). Max = 5000.
expect(w.get("test/x.test.ts")).toBe(5000);
});
it("skips out-of-order events defensively (negative delta)", () => {
const events = [
{ job: "test (1)", timestampMs: 5000, file: "test/x.test.ts" },
{ job: "test (1)", timestampMs: 3000, file: "test/y.test.ts" },
];
const w = computeWeights(events);
// x → negative delta → skipped. y has no successor → not recorded.
expect(w.size).toBe(0);
});
it("empty input → empty map", () => {
expect(computeWeights([]).size).toBe(0);
});
});
describe("serializeWeights", () => {
it("sorts keys alphabetically for stable diffs", () => {
const w = new Map([
["test/z.test.ts", 100],
["test/a.test.ts", 200],
["test/m.test.ts", 50],
]);
const json = serializeWeights(w);
const lines = json.split("\n").filter((l) => l.includes('"test/'));
expect(lines[0]).toContain("a.test.ts");
expect(lines[1]).toContain("m.test.ts");
expect(lines[2]).toContain("z.test.ts");
});
it("ends with a trailing newline (POSIX-friendly diff)", () => {
const w = new Map([["test/x.test.ts", 1]]);
expect(serializeWeights(w).endsWith("\n")).toBe(true);
});
it("empty map → empty object JSON", () => {
expect(serializeWeights(new Map())).toBe("{}\n");
});
it("round-trips: parse our own output", () => {
const w = new Map([
["test/alpha.test.ts", 100],
["test/beta.test.ts", 250],
]);
const json = serializeWeights(w);
const parsed = JSON.parse(json);
expect(parsed["test/alpha.test.ts"]).toBe(100);
expect(parsed["test/beta.test.ts"]).toBe(250);
});
});
+175
View File
@@ -0,0 +1,175 @@
// run-verify-parallel.test.ts — pin the dispatcher's contract.
//
// We can't easily fake the 20-check list inside the script (it's a static
// array), but we CAN verify:
// 1. --dry-list emits one line per check
// 2. Unknown args exit 2
// 3. The fast-path measurement claim — running it on a clean tree
// completes faster than the sequential `&&`-chain would have
// 4. A made-up failing check propagates exit 1 with the named check
// in the failure report (covered by overriding the CHECKS array
// via a sibling temp script)
//
// (3) is a soft regression guard, not a hard timing assertion (CI runners
// vary). (4) is the load-bearing test: failure surfaces with name + log.
import { describe, expect, it } from "bun:test";
import { spawnSync } from "node:child_process";
import { writeFileSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const SCRIPT = "scripts/run-verify-parallel.sh";
describe("run-verify-parallel.sh — CLI contract", () => {
it("--dry-list emits one line per check, exit 0", () => {
const r = spawnSync("bash", [SCRIPT, "--dry-list"], { encoding: "utf8" });
expect(r.status).toBe(0);
const lines = r.stdout.trim().split("\n").filter(Boolean);
// The exact count is allowed to grow; assert > 10 and that every line
// looks like a script name (no whitespace, no quotes).
expect(lines.length).toBeGreaterThan(10);
for (const l of lines) {
expect(l).toMatch(/^[a-z][a-z0-9:_-]+$/);
}
});
it("includes the load-bearing privacy + jsonb + typecheck checks", () => {
const r = spawnSync("bash", [SCRIPT, "--dry-list"], { encoding: "utf8" });
expect(r.status).toBe(0);
const set = new Set(r.stdout.trim().split("\n"));
expect(set.has("check:privacy")).toBe(true);
expect(set.has("check:jsonb")).toBe(true);
expect(set.has("typecheck")).toBe(true);
expect(set.has("check:operations-filter-bypass")).toBe(true);
});
it("unknown arg exits 2 with usage error", () => {
const r = spawnSync("bash", [SCRIPT, "--bogus"], { encoding: "utf8" });
expect(r.status).toBe(2);
expect(r.stderr).toContain("unknown arg");
expect(r.stderr).toContain("usage:");
});
});
describe("run-verify-parallel.sh — failure surfacing (synthetic dispatcher)", () => {
// We can't inject a fake check into the real script without touching the
// CHECKS array. Instead, we write a SMALLER synthetic dispatcher that
// shares the same shape (background jobs, exit-file aggregation, log
// tail on failure) and assert the surfacing contract on it. This proves
// the design, leaving the real script tested via CLI contract above.
//
// Each "check" is written as its own executable file so bash array
// expansion doesn't try to word-split shell strings (the natural
// failure mode of expressing checks as bash command strings is that
// spaces and quotes don't survive `"${CHECKS[@]}"`).
function writeSynth(checkBodies: { name: string; body: string }[]): string {
const dir = mkdtempSync(join(tmpdir(), "verify-synth-"));
const checkPaths: string[] = [];
for (const c of checkBodies) {
const p = `${dir}/${c.name}.sh`;
writeFileSync(p, `#!/usr/bin/env bash\n${c.body}\n`, { mode: 0o755 });
checkPaths.push(p);
}
const dispatcher = `${dir}/dispatch.sh`;
// CHECKS is an array of absolute paths to executable scripts.
const arrLit = checkPaths.map((p) => `"${p}"`).join(" ");
writeFileSync(
dispatcher,
`#!/usr/bin/env bash
set -uo pipefail
LOG_DIR=$(mktemp -d /tmp/verify-synth-log-XXXXXX)
trap 'rm -rf "$LOG_DIR"' EXIT
CHECKS=(${arrLit})
PIDS=()
NAMES=()
for c in "\${CHECKS[@]}"; do
base=$(basename "$c" .sh)
NAMES+=("$base")
(
"$c" > "$LOG_DIR/$base.log" 2>&1
echo $? > "$LOG_DIR/$base.exit"
) &
PIDS+=($!)
done
for p in "\${PIDS[@]}"; do wait "$p" 2>/dev/null || true; done
FAIL=0
FAILED_NAMES=""
for i in "\${!NAMES[@]}"; do
n="\${NAMES[$i]}"
rc=$(cat "$LOG_DIR/$n.exit")
if [ "$rc" != "0" ]; then
FAIL=$((FAIL+1))
FAILED_NAMES="$FAILED_NAMES $n"
echo "--- $n (rc=$rc) ---" >&2
tail -10 "$LOG_DIR/$n.log" >&2
fi
done
if [ "$FAIL" -gt 0 ]; then
echo "Failed:$FAILED_NAMES" >&2
exit 1
fi
exit 0
`,
{ mode: 0o755 },
);
return dispatcher;
}
function cleanup(dispatcher: string) {
rmSync(dispatcher.replace(/\/dispatch\.sh$/, ""), { recursive: true, force: true });
}
it("all-pass: exit 0, no failure block", () => {
const d = writeSynth([
{ name: "alpha", body: "exit 0" },
{ name: "beta", body: "exit 0" },
{ name: "gamma", body: "exit 0" },
]);
try {
const r = spawnSync("bash", [d], { encoding: "utf8" });
expect(r.status).toBe(0);
expect(r.stderr).not.toContain("Failed:");
} finally {
cleanup(d);
}
});
it("one-fails: exit 1, failure block names the check + shows log tail", () => {
const d = writeSynth([
{ name: "alpha", body: "exit 0" },
{
name: "beta",
body: 'echo "synthetic failure detail line" >&2\nexit 7',
},
{ name: "gamma", body: "exit 0" },
]);
try {
const r = spawnSync("bash", [d], { encoding: "utf8" });
expect(r.status).toBe(1);
expect(r.stderr).toContain("Failed: beta");
expect(r.stderr).toContain("synthetic failure detail line");
expect(r.stderr).toMatch(/--- beta \(rc=7\) ---/);
} finally {
cleanup(d);
}
});
it("two-fail: both names appear in the Failed: list", () => {
const d = writeSynth([
{ name: "alpha", body: "exit 0" },
{ name: "beta", body: "echo err-A >&2\nexit 1" },
{ name: "gamma", body: "echo err-B >&2\nexit 2" },
]);
try {
const r = spawnSync("bash", [d], { encoding: "utf8" });
expect(r.status).toBe(1);
expect(r.stderr).toContain("err-A");
expect(r.stderr).toContain("err-B");
expect(r.stderr).toMatch(/Failed:\s+beta\s+gamma/);
} finally {
cleanup(d);
}
});
});
+266
View File
@@ -0,0 +1,266 @@
// sharding.test.ts — pure unit tests for the LPT bin-packer.
//
// Covers: happy path, fallback semantics, full coverage, determinism,
// balance ratio, N=1 trivial, weight-equal-to-zero handling, malformed
// weights, missing weights file fail-soft.
import { describe, expect, it } from "bun:test";
import {
computeMedian,
imbalanceRatio,
loadWeights,
partition,
WeightsLoadError,
type WeightMap,
} from "../../scripts/sharding.ts";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
function tempJson(content: string): string {
const dir = mkdtempSync(join(tmpdir(), "weights-test-"));
const path = join(dir, "weights.json");
writeFileSync(path, content, "utf8");
return path;
}
describe("computeMedian", () => {
it("returns 0 on empty input", () => {
expect(computeMedian([])).toBe(0);
});
it("single value is itself", () => {
expect(computeMedian([42])).toBe(42);
});
it("odd count: middle value", () => {
expect(computeMedian([1, 5, 3])).toBe(3);
});
it("even count: mean of middle two", () => {
expect(computeMedian([1, 2, 3, 4])).toBe(2.5);
});
});
describe("partition — happy path", () => {
it("balances 4 known-weight files across 2 shards", () => {
const weights: WeightMap = new Map([
["a.test.ts", 100],
["b.test.ts", 100],
["c.test.ts", 50],
["d.test.ts", 50],
]);
const out = partition(
["a.test.ts", "b.test.ts", "c.test.ts", "d.test.ts"],
weights,
2,
);
expect(out.length).toBe(2);
// LPT assigns 100→s0, 100→s1, 50→s0 (now 150), 50→s1 (now 150).
// Wait: 100→s0 (s0=100), 100→s1 (s1=100), tie → s0 gets 50 (s0=150),
// then s1 gets 50 (s1=150). Balanced.
const totals = out.map((s) =>
s.reduce((acc, f) => acc + (weights.get(f) ?? 0), 0),
);
expect(totals[0]).toBe(150);
expect(totals[1]).toBe(150);
});
it("LPT prefers heavy-first assignment", () => {
// 3 files: 100, 10, 10. 2 shards. Naive assignment (alpha) would put
// 100 + 10 = 110 in s0 and 10 in s1 (imbalance 11x). LPT puts 100 in
// s0, then 10 + 10 in s1 (totals 100 + 20 = imbalance 5x).
const weights: WeightMap = new Map([
["big.test.ts", 100],
["small1.test.ts", 10],
["small2.test.ts", 10],
]);
const out = partition(
["big.test.ts", "small1.test.ts", "small2.test.ts"],
weights,
2,
);
expect(out[0]).toEqual(["big.test.ts"]);
expect(out[1]?.sort()).toEqual(["small1.test.ts", "small2.test.ts"]);
});
});
describe("partition — fallback semantics", () => {
it("missing weights default to corpus median", () => {
// weights = {a:100, b:50}, median = 75. Files c + d are unknown → 75 each.
const weights: WeightMap = new Map([
["a", 100],
["b", 50],
]);
const out = partition(["a", "b", "c", "d"], weights, 2);
// Effective weights: a=100, b=50, c=75, d=75. LPT: 100→s0, 75→s1 (c),
// 75→s1 (d, ties broken alpha)... actually: 100→s0=100, 75→s1=75,
// 75→s1 vs s0 → s1=150, 50→s0=150. Balanced 150/150.
const totalsEffective = out.map((s) =>
s.reduce((acc, f) => acc + (weights.get(f) ?? 75), 0),
);
expect(totalsEffective[0]).toBe(totalsEffective[1]);
});
it("explicit fallback override beats median", () => {
const weights: WeightMap = new Map([["a", 100]]);
const out = partition(["a", "unknown"], weights, 2, { fallbackWeight: 500 });
// a=100 to s0, unknown=500 to s1 (heavier goes first actually — sort
// desc puts unknown=500 first). Wait, LPT sorts desc, so unknown
// (500) goes to s0 first, then a (100) goes to s1.
expect(out[0]).toEqual(["unknown"]);
expect(out[1]).toEqual(["a"]);
});
it("zero-weight files are valid (just always go to current min shard)", () => {
const weights: WeightMap = new Map([
["zero1", 0],
["zero2", 0],
["big", 100],
]);
const out = partition(["zero1", "zero2", "big"], weights, 2);
// 100→s0=100, 0→s1=0, 0→s1=0. Both zeroes go to s1.
expect(out[0]).toEqual(["big"]);
expect(out[1]?.sort()).toEqual(["zero1", "zero2"]);
});
});
describe("partition — invariants", () => {
it("every input file lands in exactly one shard (full coverage)", () => {
const files = Array.from({ length: 25 }, (_, i) => `f${i}.test.ts`);
const weights: WeightMap = new Map(
files.map((f, i) => [f, (i % 5) * 10 + 5]),
);
const out = partition(files, weights, 4);
const seen: string[] = [];
for (const shard of out) seen.push(...shard);
expect(seen.sort()).toEqual([...files].sort());
expect(new Set(seen).size).toBe(files.length);
});
it("deterministic — same input always produces same output", () => {
const files = ["d.test.ts", "a.test.ts", "c.test.ts", "b.test.ts"];
const weights: WeightMap = new Map([
["a.test.ts", 30],
["b.test.ts", 30],
["c.test.ts", 30],
["d.test.ts", 30],
]);
const r1 = partition(files, weights, 2);
const r2 = partition([...files].reverse(), weights, 2);
const r3 = partition(files, weights, 2);
// Same input → identical output
expect(r3).toEqual(r1);
// Input order shuffled but contents identical → identical output
// (ties broken by path asc so order is canonical regardless of input)
expect(r2).toEqual(r1);
});
it("N=1 trivial: everything in one shard", () => {
const out = partition(["x.test.ts", "y.test.ts"], new Map(), 1);
expect(out.length).toBe(1);
expect(out[0]?.sort()).toEqual(["x.test.ts", "y.test.ts"]);
});
it("empty file list returns N empty shards", () => {
const out = partition([], new Map(), 3);
expect(out).toEqual([[], [], []]);
});
it("invalid shard count throws RangeError", () => {
expect(() => partition(["a"], new Map(), 0)).toThrow(RangeError);
expect(() => partition(["a"], new Map(), -1)).toThrow(RangeError);
expect(() => partition(["a"], new Map(), 1.5)).toThrow(RangeError);
});
it("invalid fallbackWeight throws RangeError", () => {
expect(() =>
partition(["a"], new Map(), 2, { fallbackWeight: -1 }),
).toThrow(RangeError);
expect(() =>
partition(["a"], new Map(), 2, { fallbackWeight: NaN }),
).toThrow(RangeError);
});
});
describe("partition — balance quality", () => {
it("synthetic skewed corpus produces imbalance ratio ≤ 1.5", () => {
// 100 files, weights drawn from a Zipf-ish distribution (a few heavy,
// many light). This is the shape that broke FNV-1a hash sharding.
const files = Array.from({ length: 100 }, (_, i) => `f${i}.test.ts`);
const weights: WeightMap = new Map();
for (let i = 0; i < files.length; i++) {
// Heavy tail: file 0 = 1000ms, file 1 = 500, file 2 = 333, ...
// Average about 50ms; max about 1000ms.
const w = Math.max(10, Math.floor(1000 / (i + 1)));
weights.set(files[i]!, w);
}
const out = partition(files, weights, 6);
const ratio = imbalanceRatio(out, weights, 0);
expect(ratio).toBeLessThanOrEqual(1.5);
});
it("imbalance ratio of 1.0 when weights are perfectly divisible", () => {
const files = ["a", "b", "c", "d"];
const weights: WeightMap = new Map([
["a", 50],
["b", 50],
["c", 50],
["d", 50],
]);
const out = partition(files, weights, 2);
expect(imbalanceRatio(out, weights, 0)).toBe(1.0);
});
});
describe("loadWeights", () => {
it("missing file → empty map (fail-soft)", () => {
const w = loadWeights("/tmp/definitely-does-not-exist-weights.json");
expect(w.size).toBe(0);
});
it("malformed JSON → throws WeightsLoadError", () => {
const p = tempJson("not json {");
try {
expect(() => loadWeights(p)).toThrow(WeightsLoadError);
} finally {
rmSync(p.replace(/\/weights\.json$/, ""), { recursive: true, force: true });
}
});
it("array JSON → throws (wrong shape)", () => {
const p = tempJson("[1,2,3]");
try {
expect(() => loadWeights(p)).toThrow(/expected top-level object/);
} finally {
rmSync(p.replace(/\/weights\.json$/, ""), { recursive: true, force: true });
}
});
it("negative weight → throws (semantic invalid)", () => {
const p = tempJson('{"foo.test.ts": -5}');
try {
expect(() => loadWeights(p)).toThrow(/non-negative finite/);
} finally {
rmSync(p.replace(/\/weights\.json$/, ""), { recursive: true, force: true });
}
});
it("non-number weight → throws (semantic invalid)", () => {
const p = tempJson('{"foo.test.ts": "100ms"}');
try {
expect(() => loadWeights(p)).toThrow(/non-negative finite/);
} finally {
rmSync(p.replace(/\/weights\.json$/, ""), { recursive: true, force: true });
}
});
it("valid weights JSON round-trips", () => {
const p = tempJson('{"a.test.ts": 100, "b.test.ts": 250}');
try {
const w = loadWeights(p);
expect(w.size).toBe(2);
expect(w.get("a.test.ts")).toBe(100);
expect(w.get("b.test.ts")).toBe(250);
} finally {
rmSync(p.replace(/\/weights\.json$/, ""), { recursive: true, force: true });
}
});
});
+113 -27
View File
@@ -1,18 +1,30 @@
/**
* Regression test: scripts/test-shard.sh exclusion symmetry.
* Regression test: scripts/test-shard.sh exclusion + balance contract.
*
* Pins the contract that CI's hash-bucketed shard script EXCLUDES
* *.serial.test.ts and test/e2e/ from every shard. Serial files share
* file-wide state (top-level mock.module, module singletons) that leaks
* across files in the same `bun test` shard process. Before v0.31.4.1
* they were hashed into the same buckets as parallel files, which broke
* the quarantine — `eval-takes-quality-runner.serial.test.ts` stubbed
* `gateway.ts` and broke every `gateway.embedMultimodal` test in
* `voyage-multimodal.test.ts` on shard 2.
* Pins two invariants of the CI matrix shard script:
*
* Without this guard, a future refactor that drops the `-not -name
* '*.serial.test.ts'` clause from test-shard.sh would silently undo the
* fix and re-introduce the contention flake.
* 1. EXCLUDE *.serial.test.ts and test/e2e/ from every shard. Serial
* files share file-wide state (top-level mock.module, module
* singletons) that leaks across files in the same `bun test` shard
* process. Before v0.31.4.1 they were hashed into the same buckets
* as parallel files, which broke the quarantine —
* `eval-takes-quality-runner.serial.test.ts` stubbed `gateway.ts`
* and broke every `gateway.embedMultimodal` test in
* `voyage-multimodal.test.ts` on shard 2.
*
* 2. INCLUDE *.slow.test.ts. CI is the only default place slow files
* run; the local fast loop excludes them via run-unit-shard.sh. See
* CLAUDE.md "CI vs local: intentionally divergent file sets".
*
* 3. LPT balance: file counts across shards are within 5% of each other
* under the default (no-weights / cold-start) configuration. Once
* test-weights.json is populated, weighted balance also lands the
* imbalance ratio ≤ 1.5.
*
* Without (1), the v0.31.4.1 mock.module leak comes back. Without (2),
* slow tests stop running in CI (silent regression — they don't fail,
* they just never execute). Without (3), shard 3 will drift back to
* 26-min p100 wallclock.
*/
import { describe, it, expect, beforeAll } from 'bun:test';
@@ -22,30 +34,27 @@ import { resolve } from 'path';
const REPO_ROOT = resolve(import.meta.dir, '..', '..');
const SHARD_SH = resolve(REPO_ROOT, 'scripts/test-shard.sh');
// Pure-bash FNV-1a per shard takes ~4s on a M-series Mac because the script
// computes a hash for every test file. Compute all 4 shards once in beforeAll
// and reuse across cases so the suite finishes in one shell-out per shard.
const shardCache: Record<number, string[]> = {};
// LPT partition via sharding.ts is ~30ms per shard (much faster than the
// pure-bash FNV-1a it replaced). Cache results so repeated assertions
// don't shell out per case.
const shardCache: Record<string, string[]> = {};
function dryRunList(shard: number, total: number): string[] {
if (shardCache[shard]) return shardCache[shard];
const key = `${shard}/${total}`;
if (shardCache[key]) return shardCache[key];
const out = execFileSync('bash', [SHARD_SH, '--dry-run-list', String(shard), String(total)], {
cwd: REPO_ROOT,
encoding: 'utf-8',
});
shardCache[shard] = out.split('\n').map(s => s.trim()).filter(Boolean);
return shardCache[shard];
shardCache[key] = out.split('\n').map(s => s.trim()).filter(Boolean);
return shardCache[key];
}
describe('test-shard.sh exclusion symmetry', () => {
describe('test-shard.sh exclusion contract', () => {
beforeAll(() => {
for (const shard of [1, 2, 3, 4]) dryRunList(shard, 4);
// v0.40.10 flake-hardening: bump beforeAll budget 60s → 180s. Each
// FNV-1a dry-run shells out and computes a hash for every test file
// (~4s solo). Under slow-shard concurrency (longmemeval E2E at ~50s
// hogging CPU), the 4 sequential shell-outs slip past 60s and time
// out even though they'd complete fine solo.
}, 180_000);
}, 60_000);
it('includes plain *.test.ts files in at least one shard', () => {
const allFiles = [1, 2, 3, 4].flatMap(s => dryRunList(s, 4));
expect(allFiles.length).toBeGreaterThan(0);
@@ -68,7 +77,17 @@ describe('test-shard.sh exclusion symmetry', () => {
}
});
it('partitions plain files across shards without overlap', () => {
it('INCLUDES *.slow.test.ts files (CI matrix is where slow files run)', () => {
const allFiles = [1, 2, 3, 4].flatMap(s => dryRunList(s, 4));
const slowFiles = allFiles.filter(f => /\.slow\.test\.ts$/.test(f));
// The repo currently has 3 slow files — pin >= 1 so the test stays
// green if the count changes but fails loud if slow files vanish
// entirely (regression: someone re-added -not -name '*.slow.test.ts'
// to the find clause).
expect(slowFiles.length).toBeGreaterThan(0);
});
it('partitions every file across shards without overlap', () => {
const seen = new Map<string, number>();
for (const shard of [1, 2, 3, 4]) {
for (const f of dryRunList(shard, 4)) {
@@ -81,3 +100,70 @@ describe('test-shard.sh exclusion symmetry', () => {
expect(seen.size).toBeGreaterThan(0);
});
});
describe('test-shard.sh — LPT balance contract', () => {
// LPT balances WALLCLOCK (sum of weights), not file count. When real
// weights are loaded from scripts/test-weights.json, shards with
// heavier files have FEWER files — that's the optimization working.
// We assert wallclock balance via the imbalance ratio (≤1.5).
// Read weights from the committed JSON. If it doesn't exist yet (early
// in the wave) or is empty, we degrade to cold-start round-robin and
// assert file-count balance instead.
let weightsMap = new Map<string, number>();
let weightsLoaded = false;
beforeAll(() => {
try {
const fs = require('fs');
const path = require('path');
const p = path.resolve(REPO_ROOT, 'scripts/test-weights.json');
if (fs.existsSync(p)) {
const raw = JSON.parse(fs.readFileSync(p, 'utf8'));
for (const [k, v] of Object.entries(raw)) {
if (typeof v === 'number' && Number.isFinite(v)) weightsMap.set(k, v);
}
weightsLoaded = weightsMap.size > 0;
}
} catch {
// fall through to cold-start mode
}
});
function totalsFor(shards: string[][]): number[] {
// Use 30ms as the cold-start fallback (matches mine-shard-weights
// median observation). When weights are loaded, missing files get
// the corpus median anyway via sharding.ts.
const fallback = weightsLoaded
? Array.from(weightsMap.values()).sort((a, b) => a - b)[
Math.floor(weightsMap.size / 2)
] ?? 30
: 1;
return shards.map((s) =>
s.reduce((acc, f) => acc + (weightsMap.get(f) ?? fallback), 0),
);
}
it('4-shard wallclock imbalance ratio ≤ 1.5', () => {
const shards = [1, 2, 3, 4].map(s => dryRunList(s, 4));
for (const s of shards) expect(s.length).toBeGreaterThan(0);
const totals = totalsFor(shards);
const ratio = Math.max(...totals) / Math.min(...totals);
expect(ratio).toBeLessThanOrEqual(1.5);
});
it('6-shard wallclock imbalance ratio ≤ 1.5', () => {
const shards = [1, 2, 3, 4, 5, 6].map(s => dryRunList(s, 6));
for (const s of shards) expect(s.length).toBeGreaterThan(0);
const totals = totalsFor(shards);
const ratio = Math.max(...totals) / Math.min(...totals);
expect(ratio).toBeLessThanOrEqual(1.5);
});
it('6-shard partition is deterministic across runs', () => {
// Clear cache + re-run for one shard, compare to the cached result.
const cached = [...dryRunList(1, 6)];
delete shardCache['1/6'];
const fresh = dryRunList(1, 6);
expect(fresh).toEqual(cached);
});
});