mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* 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>
170 lines
6.7 KiB
TypeScript
170 lines
6.7 KiB
TypeScript
/**
|
|
* Regression test: scripts/test-shard.sh exclusion + balance contract.
|
|
*
|
|
* Pins two invariants of the CI matrix shard script:
|
|
*
|
|
* 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';
|
|
import { execFileSync } from 'child_process';
|
|
import { resolve } from 'path';
|
|
|
|
const REPO_ROOT = resolve(import.meta.dir, '..', '..');
|
|
const SHARD_SH = resolve(REPO_ROOT, 'scripts/test-shard.sh');
|
|
|
|
// 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[] {
|
|
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[key] = out.split('\n').map(s => s.trim()).filter(Boolean);
|
|
return shardCache[key];
|
|
}
|
|
|
|
describe('test-shard.sh — exclusion contract', () => {
|
|
beforeAll(() => {
|
|
for (const shard of [1, 2, 3, 4]) dryRunList(shard, 4);
|
|
}, 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);
|
|
expect(allFiles.some(f => /\.test\.ts$/.test(f) && !/\.serial\.test\.ts$/.test(f))).toBe(true);
|
|
});
|
|
|
|
it('excludes every *.serial.test.ts file from every shard', () => {
|
|
for (const shard of [1, 2, 3, 4]) {
|
|
const files = dryRunList(shard, 4);
|
|
const leaks = files.filter(f => /\.serial\.test\.ts$/.test(f));
|
|
expect(leaks, `shard ${shard} contains serial files`).toEqual([]);
|
|
}
|
|
});
|
|
|
|
it('excludes the test/e2e/ subtree from every shard', () => {
|
|
for (const shard of [1, 2, 3, 4]) {
|
|
const files = dryRunList(shard, 4);
|
|
const leaks = files.filter(f => f.startsWith('test/e2e/'));
|
|
expect(leaks, `shard ${shard} contains e2e files`).toEqual([]);
|
|
}
|
|
});
|
|
|
|
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)) {
|
|
if (seen.has(f)) {
|
|
throw new Error(`file ${f} appears in shard ${seen.get(f)} AND shard ${shard}`);
|
|
}
|
|
seen.set(f, shard);
|
|
}
|
|
}
|
|
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);
|
|
});
|
|
});
|