mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-13 16:38:56 +02:00
spawnSync/execSync/Bun.spawnSync BLOCK the main thread, so bun's in-process per-test timeout can never fire while one waits — a hung child (stdin read, network probe, dead daemon) wedges the whole shard until the runner's external wall-clock SIGKILL. This exact class reached main: free-tests run 33262077256, test/gstack-memory-ingest.test.ts (normally 2.3s) held shard 2 at the 360s wall while its five siblings finished in ~65s. Mechanical sweep in two waves (12 + 4 fan-out agents, every edit verified against its call site): default timeout: 30_000 (matches the free runner's per-test budget), 120_000 for genuinely slow ops (installs, builds, playwright, provider CLIs), helper wrappers fixed ONCE where call sites route through them. Sites that only LOOK like calls (string fixtures, grep needles, comments) were skipped with reasons — the enforcement commit that follows marks them exempt. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
259 lines
9.6 KiB
TypeScript
259 lines
9.6 KiB
TypeScript
/**
|
|
* Parity baseline capture — cathedral parity-eval suite primitive.
|
|
*
|
|
* Snapshots the current state of every top-level SKILL.md: byte count, line
|
|
* count, estimated token count, frontmatter description length, eval
|
|
* coverage. The output JSON is the v1.44 baseline that v2 must beat on
|
|
* compression AND match (or exceed) on parity.
|
|
*
|
|
* The numbers quoted in the v2.0.0.0 CHANGELOG numbers table are read
|
|
* from a baseline JSON captured by this script. Never invent baseline
|
|
* numbers; ship them only if they came from a real captureBaseline() run.
|
|
*
|
|
* Usage:
|
|
* bun run scripts/capture-baseline.ts # write default path
|
|
* bun run scripts/capture-baseline.ts --out PATH # write custom path
|
|
* bun run scripts/capture-baseline.ts --tag v1.44.1 # tag the snapshot
|
|
*/
|
|
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import { execSync } from 'child_process';
|
|
|
|
export interface SkillBaselineEntry {
|
|
skill: string;
|
|
/**
|
|
* SKILL.md file bytes as captured. NOTE for rebaselines: the parity harness
|
|
* compares UNION bytes (skeleton + sections/*.md) against this field, so a
|
|
* committed baseline fixture must have carved skills' entries normalized to
|
|
* union size (skeleton + sum of sections/*.md) or every carved skill reads
|
|
* as 1.2-1.4x over on day one. The v1.57.7.0 and v1.64.1.0 fixtures are
|
|
* union-normalized.
|
|
*/
|
|
skillMdBytes: number;
|
|
skillMdLines: number;
|
|
estTokens: number; // ~4 chars/token heuristic
|
|
tmplBytes: number | null; // null when no .tmpl exists (vendored or non-Claude)
|
|
descriptionLen: number; // bytes in frontmatter description field
|
|
hasGateEval: boolean;
|
|
hasPeriodicEval: boolean;
|
|
}
|
|
|
|
export interface ParityBaseline {
|
|
tag: string;
|
|
capturedAt: string;
|
|
capturedFromCommit: string;
|
|
capturedFromBranch: string;
|
|
totalSkills: number;
|
|
totalCorpusBytes: number;
|
|
estTotalCatalogTokens: number; // sum of all description lengths / 4
|
|
topHeaviest: SkillBaselineEntry[]; // sorted desc by skillMdBytes
|
|
skills: Record<string, SkillBaselineEntry>;
|
|
}
|
|
|
|
export interface CaptureOptions {
|
|
repoRoot: string;
|
|
tag?: string;
|
|
/**
|
|
* Skills whose baseline bytes must be the UNION of skeleton + sections/*.md
|
|
* (mirroring parity-harness readSkillForParity, which is what the checker
|
|
* compares against). Omitting a carved skill here records skeleton-only
|
|
* bytes and the ratio check then reads ~2x on the next parity run — the
|
|
* exact capture-vs-check drift that broke the v1.64 rebase.
|
|
*/
|
|
sectionedSkills?: string[];
|
|
}
|
|
|
|
/** Extract the frontmatter description from a SKILL.md file. Empty string if none. */
|
|
export function extractDescription(content: string): string {
|
|
if (!content.startsWith('---\n')) return '';
|
|
const fmEnd = content.indexOf('\n---', 4);
|
|
if (fmEnd === -1) return '';
|
|
const frontmatter = content.slice(4, fmEnd);
|
|
const lines = frontmatter.split('\n');
|
|
let inDescription = false;
|
|
const descLines: string[] = [];
|
|
for (const line of lines) {
|
|
if (line.match(/^description:\s*\|?\s*$/)) {
|
|
inDescription = true;
|
|
continue;
|
|
}
|
|
if (line.match(/^description:\s+/)) {
|
|
descLines.push(line.replace(/^description:\s+/, ''));
|
|
inDescription = true;
|
|
continue;
|
|
}
|
|
if (inDescription) {
|
|
if (line.match(/^\w+:\s/)) break;
|
|
descLines.push(line.trim());
|
|
}
|
|
}
|
|
return descLines.join('\n').trim();
|
|
}
|
|
|
|
/** Estimate token count via 4 chars/token. Crude but matches existing budget-regression usage. */
|
|
function estimateTokens(bytes: number): number {
|
|
return Math.round(bytes / 4);
|
|
}
|
|
|
|
/** Find which top-level directories contain a SKILL.md (skills we capture). */
|
|
function discoverSkillDirs(repoRoot: string): string[] {
|
|
const entries = fs.readdirSync(repoRoot, { withFileTypes: true });
|
|
const dirs: string[] = [];
|
|
for (const e of entries) {
|
|
if (!e.isDirectory()) continue;
|
|
if (e.name.startsWith('.')) continue;
|
|
if (e.name === 'node_modules' || e.name === 'docs') continue;
|
|
const skillMd = path.join(repoRoot, e.name, 'SKILL.md');
|
|
if (fs.existsSync(skillMd)) dirs.push(e.name);
|
|
}
|
|
return dirs.sort();
|
|
}
|
|
|
|
/** Check whether a skill has E2E gate / periodic eval coverage by scanning test/. */
|
|
function discoverEvalCoverage(repoRoot: string, skills: string[]): {
|
|
gate: Set<string>;
|
|
periodic: Set<string>;
|
|
} {
|
|
const gate = new Set<string>();
|
|
const periodic = new Set<string>();
|
|
const testDir = path.join(repoRoot, 'test');
|
|
if (!fs.existsSync(testDir)) return { gate, periodic };
|
|
const testFiles = fs.readdirSync(testDir).filter(f => f.startsWith('skill-e2e-') && f.endsWith('.test.ts'));
|
|
// Try to map each test file to a skill by reading its contents for skill names.
|
|
for (const file of testFiles) {
|
|
const content = fs.readFileSync(path.join(testDir, file), 'utf-8');
|
|
for (const skill of skills) {
|
|
// Match the skill name as a word boundary, also try /skill-name slash form.
|
|
const re = new RegExp(`(/${skill}|['"\`]${skill}['"\`]|skill[s]?[/=:]\\s*['"\`]${skill}['"\`])`);
|
|
if (re.test(content)) {
|
|
// Crude tier inference: if file name contains "regression" / known-periodic markers, classify periodic.
|
|
if (file.includes('chain') || file.includes('multi') || file.includes('idempotency') || file.includes('finding-floor')) {
|
|
periodic.add(skill);
|
|
} else {
|
|
gate.add(skill);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return { gate, periodic };
|
|
}
|
|
|
|
function getGitInfo(repoRoot: string): { commit: string; branch: string } {
|
|
try {
|
|
const commit = execSync('git rev-parse --short HEAD', { cwd: repoRoot, encoding: 'utf-8', timeout: 30_000 }).trim();
|
|
const branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: repoRoot, encoding: 'utf-8', timeout: 30_000 }).trim();
|
|
return { commit, branch };
|
|
} catch {
|
|
return { commit: 'unknown', branch: 'unknown' };
|
|
}
|
|
}
|
|
|
|
export function captureBaseline(opts: CaptureOptions): ParityBaseline {
|
|
const { repoRoot, tag, sectionedSkills } = opts;
|
|
const skillDirs = discoverSkillDirs(repoRoot);
|
|
const evalCoverage = discoverEvalCoverage(repoRoot, skillDirs);
|
|
const skills: Record<string, SkillBaselineEntry> = {};
|
|
let totalCorpusBytes = 0;
|
|
let totalDescriptionBytes = 0;
|
|
for (const dir of skillDirs) {
|
|
const skillMdPath = path.join(repoRoot, dir, 'SKILL.md');
|
|
const tmplPath = path.join(repoRoot, dir, 'SKILL.md.tmpl');
|
|
const content = fs.readFileSync(skillMdPath, 'utf-8');
|
|
let bytes = Buffer.byteLength(content, 'utf-8');
|
|
// Union in the carved sections for sectioned skills — semantic twin of
|
|
// parity-harness readSkillForParity (which the checker uses). Kept inline
|
|
// because parity-harness imports this module as a value (cycle).
|
|
if (sectionedSkills?.includes(dir)) {
|
|
const sectionsDir = path.join(repoRoot, dir, 'sections');
|
|
if (fs.existsSync(sectionsDir)) {
|
|
for (const f of fs.readdirSync(sectionsDir).filter(f => f.endsWith('.md')).sort()) {
|
|
bytes += Buffer.byteLength(fs.readFileSync(path.join(sectionsDir, f), 'utf-8'), 'utf-8');
|
|
}
|
|
}
|
|
}
|
|
const lines = content.split('\n').length;
|
|
const description = extractDescription(content);
|
|
const descriptionLen = Buffer.byteLength(description, 'utf-8');
|
|
const tmplBytes = fs.existsSync(tmplPath)
|
|
? Buffer.byteLength(fs.readFileSync(tmplPath, 'utf-8'), 'utf-8')
|
|
: null;
|
|
const entry: SkillBaselineEntry = {
|
|
skill: dir,
|
|
skillMdBytes: bytes,
|
|
skillMdLines: lines,
|
|
estTokens: estimateTokens(bytes),
|
|
tmplBytes,
|
|
descriptionLen,
|
|
hasGateEval: evalCoverage.gate.has(dir),
|
|
hasPeriodicEval: evalCoverage.periodic.has(dir),
|
|
};
|
|
skills[dir] = entry;
|
|
totalCorpusBytes += bytes;
|
|
totalDescriptionBytes += descriptionLen;
|
|
}
|
|
const topHeaviest = Object.values(skills)
|
|
.slice()
|
|
.sort((a, b) => b.skillMdBytes - a.skillMdBytes)
|
|
.slice(0, 10);
|
|
const git = getGitInfo(repoRoot);
|
|
return {
|
|
tag: tag ?? 'untagged',
|
|
capturedAt: new Date().toISOString(),
|
|
capturedFromCommit: git.commit,
|
|
capturedFromBranch: git.branch,
|
|
totalSkills: skillDirs.length,
|
|
totalCorpusBytes,
|
|
estTotalCatalogTokens: estimateTokens(totalDescriptionBytes),
|
|
topHeaviest,
|
|
skills,
|
|
};
|
|
}
|
|
|
|
/** Diff two baselines; useful for v2 vs v1.44 deltas. */
|
|
export interface BaselineDiff {
|
|
totalCorpusDelta: number;
|
|
totalCorpusDeltaPct: number;
|
|
catalogTokensDelta: number;
|
|
catalogTokensDeltaPct: number;
|
|
perSkill: Array<{
|
|
skill: string;
|
|
beforeBytes: number;
|
|
afterBytes: number;
|
|
deltaBytes: number;
|
|
deltaPct: number;
|
|
}>;
|
|
}
|
|
|
|
export function diffBaselines(before: ParityBaseline, after: ParityBaseline): BaselineDiff {
|
|
const totalCorpusDelta = after.totalCorpusBytes - before.totalCorpusBytes;
|
|
const totalCorpusDeltaPct = before.totalCorpusBytes
|
|
? (totalCorpusDelta / before.totalCorpusBytes) * 100
|
|
: 0;
|
|
const catalogTokensDelta = after.estTotalCatalogTokens - before.estTotalCatalogTokens;
|
|
const catalogTokensDeltaPct = before.estTotalCatalogTokens
|
|
? (catalogTokensDelta / before.estTotalCatalogTokens) * 100
|
|
: 0;
|
|
const perSkill: BaselineDiff['perSkill'] = [];
|
|
const allSkills = new Set([...Object.keys(before.skills), ...Object.keys(after.skills)]);
|
|
for (const skill of allSkills) {
|
|
const b = before.skills[skill]?.skillMdBytes ?? 0;
|
|
const a = after.skills[skill]?.skillMdBytes ?? 0;
|
|
perSkill.push({
|
|
skill,
|
|
beforeBytes: b,
|
|
afterBytes: a,
|
|
deltaBytes: a - b,
|
|
deltaPct: b ? ((a - b) / b) * 100 : 0,
|
|
});
|
|
}
|
|
perSkill.sort((x, y) => Math.abs(y.deltaBytes) - Math.abs(x.deltaBytes));
|
|
return {
|
|
totalCorpusDelta,
|
|
totalCorpusDeltaPct,
|
|
catalogTokensDelta,
|
|
catalogTokensDeltaPct,
|
|
perSkill,
|
|
};
|
|
}
|