mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-04 10:26:52 +02:00
feat(evals): --case/--trials local diagnosis and panels in local sharded runs
bun run scripts/test-paid-shards.ts --case <id> [--trials N] runs N independent trials of one case through the CI panel runner (trial shards, TRIAL_ENV identity, name-pattern isolation) and prints its panelVerdict(); N defaults to the case's policy panel and CI never reads it. The local sharded path (test:gate:sharded, test:periodic:sharded) now plans the same trial shards and exclusions as CI and exits on execution completeness plus panel verdicts.
This commit is contained in:
1 parent
3bae8e33da
commit
32772f535d
2 files changed
+131
-11
No files matched your search
+106
-11
@@ -714,14 +714,15 @@ export function partitionShardsByDiffSelection(
|
|||||||
|
|
||||||
export function planPaidShards(
|
export function planPaidShards(
|
||||||
files: string[],
|
files: string[],
|
||||||
options: { maxFilesPerShard?: number } = {},
|
options: { maxFilesPerShard?: number; ownShard?: ReadonlySet<string> } = {},
|
||||||
): string[][] {
|
): string[][] {
|
||||||
const size = Math.max(1, options.maxFilesPerShard ?? DEFAULT_MAX_FILES_PER_SHARD);
|
const size = Math.max(1, options.maxFilesPerShard ?? DEFAULT_MAX_FILES_PER_SHARD);
|
||||||
const unique = [...new Set(files.map(normalizeRelativePath))].sort();
|
const unique = [...new Set(files.map(normalizeRelativePath))].sort();
|
||||||
const shards: string[][] = [];
|
const shards: string[][] = [];
|
||||||
let pending: string[] = [];
|
let pending: string[] = [];
|
||||||
for (const file of unique) {
|
for (const file of unique) {
|
||||||
if (isOverlayTestFile(file) || shardCaseId(file) !== null || FILE_RETRY_BUDGETS.some(budget => budget.file === shardFile(file))) {
|
if (isOverlayTestFile(file) || shardCaseId(file) !== null || FILE_RETRY_BUDGETS.some(budget => budget.file === shardFile(file))
|
||||||
|
|| options.ownShard?.has(file)) {
|
||||||
if (pending.length) shards.push(pending);
|
if (pending.length) shards.push(pending);
|
||||||
pending = [];
|
pending = [];
|
||||||
shards.push([file]);
|
shards.push([file]);
|
||||||
@@ -2241,6 +2242,55 @@ export function collectorOutcomeCounts(results: Array<{ tests?: Array<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ─── Local diagnosis: one case through the CI panel runner (A9) ────────────
|
||||||
|
|
||||||
|
/** The one paid file that statically registers `id`, else a thrown reason. */
|
||||||
|
export function caseFile(id: string, rootDir = ROOT, discovered = collectPaidTestFiles(rootDir)): string {
|
||||||
|
const owners = discovered.filter(file => {
|
||||||
|
const { registered, known } = fileCaseRegistration(file, fs.readFileSync(path.join(rootDir, file), 'utf8'));
|
||||||
|
return known && registered.includes(id);
|
||||||
|
});
|
||||||
|
if (owners.length !== 1) throw new Error(`--case ${id}: ${owners.length ? `registered by ${owners.join(', ')}` : 'no paid file statically registers it'}; it needs exactly one`);
|
||||||
|
return owners[0]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run `trials` independent trials of one case exactly as CI runs a panel
|
||||||
|
* (trial shards, TRIAL_ENV identity, name-pattern isolation), then print its
|
||||||
|
* panelVerdict(). `trials` defaults to the case's policy panel; CI never
|
||||||
|
* reads it. k keeps the kind's meaning (all trials for rule, the policy
|
||||||
|
* majority for behavior, capped at n).
|
||||||
|
*/
|
||||||
|
export async function runCaseDiagnosis(id: string, options: {
|
||||||
|
trials?: number; jobs?: number; withinShardConcurrency?: number; timeoutMs?: number; rootDir?: string; env?: NodeJS.ProcessEnv;
|
||||||
|
evalDirBase?: string; commandFor?: RunShardsOptions['commandFor']; log?: (line: string) => void; file?: string;
|
||||||
|
} = {}): Promise<PanelVerdict> {
|
||||||
|
const rootDir = options.rootDir ?? ROOT;
|
||||||
|
const log = options.log ?? ((line: string) => console.log(line));
|
||||||
|
const file = options.file ?? caseFile(id, rootDir);
|
||||||
|
const policy = caseTrialPlan(id);
|
||||||
|
const n = options.trials ?? policy.panel.n;
|
||||||
|
const plan: CaseTrialPlan = { ...policy, panel: { n, k: policy.panel.k === policy.panel.n ? n : Math.min(policy.panel.k, n) } };
|
||||||
|
const keys = Array.from({ length: n }, (_, i) => trialShardKey(file, id, i + 1));
|
||||||
|
const tier = E2E_TIERS[id] as PaidTier;
|
||||||
|
log(`[test:paid] --case ${id}: ${n} trial(s) of ${file} (kind ${plan.kind}, PASS at ${plan.panel.k}/${n}${plan.quarantined ? ', quarantined' : ''}), tier=${tier}`);
|
||||||
|
const summary = await runPaidShards(keys.map(key => [key]), {
|
||||||
|
jobs: Math.min(options.jobs ?? DEFAULT_JOBS, n), withinShardConcurrency: options.withinShardConcurrency, timeoutMs: options.timeoutMs,
|
||||||
|
rootDir, log, commandFor: options.commandFor, evalDirBase: options.evalDirBase,
|
||||||
|
trials: Object.fromEntries(keys.map(key => [key, plan])),
|
||||||
|
env: { ...(options.env ?? process.env), EVALS: '1', EVALS_TIER: tier, EVALS_PREFLIGHT_OK: '1', EVALS_ALL: '1',
|
||||||
|
...paidSelectionEnv('full', { e2e: [id], judges: [] }, `--case ${id}`) },
|
||||||
|
});
|
||||||
|
const trials = summary.outcomes.flatMap(outcome => outcome.trial && outcome.trial.outcome !== null ? [{
|
||||||
|
trial: outcome.trial.trial, outcome: outcome.trial.outcome, ...(outcome.trial.failure_class ? { failure_class: outcome.trial.failure_class } : {}),
|
||||||
|
...(outcome.trial.exit_reason ? { exit_reason: outcome.trial.exit_reason } : {}), ...(outcome.trial.error ? { error: outcome.trial.error } : {}),
|
||||||
|
...(outcome.trial.timeout_at_turn !== undefined ? { timeout_at_turn: outcome.trial.timeout_at_turn } : {}) }] : []);
|
||||||
|
const verdict = panelVerdict({ case: id, kind: plan.kind, panel: plan.panel, trials, quarantined: plan.quarantined });
|
||||||
|
log(formatPanelLine({ ...verdict, file, slices: {} }, tier));
|
||||||
|
for (const outcome of summary.outcomes.filter(o => o.trial?.outcome === null)) log(` t${outcome.trial!.trial}: no trial record (${outcome.trial!.harness})`);
|
||||||
|
return verdict;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Report: verdicts, history records and the human readout ───────────────
|
// ─── Report: verdicts, history records and the human readout ───────────────
|
||||||
|
|
||||||
/** One Bun JUnit testcase (`--reporter=junit`). */
|
/** One Bun JUnit testcase (`--reporter=junit`). */
|
||||||
@@ -2716,6 +2766,9 @@ type CliOptions = {
|
|||||||
writeDurations: boolean;
|
writeDurations: boolean;
|
||||||
/** Planner: the workflow matrix cap, for the capacity preflight's wave count. */
|
/** Planner: the workflow matrix cap, for the capacity preflight's wave count. */
|
||||||
maxParallel: number | null;
|
maxParallel: number | null;
|
||||||
|
/** Local diagnosis: run one case through the panel runner CI uses (never read by CI). */
|
||||||
|
caseId: string | null;
|
||||||
|
trials: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function parsePositiveInt(value: string | undefined, flag: string): number {
|
function parsePositiveInt(value: string | undefined, flag: string): number {
|
||||||
@@ -2771,6 +2824,8 @@ export function parseCliOptions(argv: string[], env: NodeJS.ProcessEnv = process
|
|||||||
reportDir: null,
|
reportDir: null,
|
||||||
writeDurations: false,
|
writeDurations: false,
|
||||||
maxParallel: null,
|
maxParallel: null,
|
||||||
|
caseId: null,
|
||||||
|
trials: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
for (let index = 0; index < argv.length; index += 1) {
|
for (let index = 0; index < argv.length; index += 1) {
|
||||||
@@ -2809,9 +2864,19 @@ export function parseCliOptions(argv: string[], env: NodeJS.ProcessEnv = process
|
|||||||
}
|
}
|
||||||
if (arg === '--write-durations') { options.writeDurations = true; continue; }
|
if (arg === '--write-durations') { options.writeDurations = true; continue; }
|
||||||
if (arg === '--max-parallel') { options.maxParallel = parsePositiveInt(argv[index += 1], '--max-parallel'); continue; }
|
if (arg === '--max-parallel') { options.maxParallel = parsePositiveInt(argv[index += 1], '--max-parallel'); continue; }
|
||||||
|
if (arg === '--case') {
|
||||||
|
const value = argv[index += 1];
|
||||||
|
if (!value || !Object.hasOwn(E2E_TIERS, value)) throw new Error(`--case needs a live E2E case id. Received: ${value}`);
|
||||||
|
options.caseId = value; continue;
|
||||||
|
}
|
||||||
|
if (arg === '--trials') { options.trials = parsePositiveInt(argv[index += 1], '--trials'); continue; }
|
||||||
throw new Error(`Unknown argument: ${arg}`);
|
throw new Error(`Unknown argument: ${arg}`);
|
||||||
}
|
}
|
||||||
if (options.writeDurations && !options.reportDir) throw new Error('--write-durations requires --report');
|
if (options.writeDurations && !options.reportDir) throw new Error('--write-durations requires --report');
|
||||||
|
if (options.trials !== null && options.caseId === null) throw new Error('--trials requires --case');
|
||||||
|
if (options.caseId !== null && (options.emitPlanPath || options.planPath || options.reportDir || options.sliceIndex !== null)) {
|
||||||
|
throw new Error('--case is local diagnosis; it cannot combine with --emit-plan, --plan/--slice or --report');
|
||||||
|
}
|
||||||
if (options.sliceBudgetMs !== null && argv.includes('--slices')) throw new Error('Plan with exactly one of --slices or --slice-budget');
|
if (options.sliceBudgetMs !== null && argv.includes('--slices')) throw new Error('Plan with exactly one of --slices or --slice-budget');
|
||||||
if (options.sliceBudgetMs !== null && !options.jobsExplicit) throw new Error('--slice-budget needs explicit --jobs (or EVALS_JOBS): the plan packs and supervises for that worker count');
|
if (options.sliceBudgetMs !== null && !options.jobsExplicit) throw new Error('--slice-budget needs explicit --jobs (or EVALS_JOBS): the plan packs and supervises for that worker count');
|
||||||
if (options.skipJudges && (!options.emitPlanPath || options.tier !== 'gate')) throw new Error('--skip-judges applies only to an emitted gate census plan');
|
if (options.skipJudges && (!options.emitPlanPath || options.tier !== 'gate')) throw new Error('--skip-judges applies only to an emitted gate census plan');
|
||||||
@@ -2859,6 +2924,14 @@ async function main(): Promise<number> {
|
|||||||
// a slice whose artifact never landed is a FAILURE, not an absence.
|
// a slice whose artifact never landed is a FAILURE, not an absence.
|
||||||
if (options.reportDir) return runPaidReport(options.reportDir, { writeDurations: options.writeDurations });
|
if (options.reportDir) return runPaidReport(options.reportDir, { writeDurations: options.writeDurations });
|
||||||
|
|
||||||
|
if (options.caseId) {
|
||||||
|
preflightAnthropicApi(process.env);
|
||||||
|
const verdict = await runCaseDiagnosis(options.caseId, { trials: options.trials ?? undefined, jobs: options.jobs,
|
||||||
|
withinShardConcurrency: options.withinShardConcurrency, timeoutMs: timeoutOverride,
|
||||||
|
evalDirBase: process.env.GSTACK_EVAL_DIR || getProjectEvalDir() });
|
||||||
|
return verdict.status === 'PASS' ? 0 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
const discovered = collectPaidTestFiles();
|
const discovered = collectPaidTestFiles();
|
||||||
if (discovered.length === 0) throw new Error('No paid test files were discovered.');
|
if (discovered.length === 0) throw new Error('No paid test files were discovered.');
|
||||||
|
|
||||||
@@ -2986,18 +3059,22 @@ async function main(): Promise<number> {
|
|||||||
const caseKeys = partitionCaseExclusions(expandCaseShards(tierSelection.selected, options.tier));
|
const caseKeys = partitionCaseExclusions(expandCaseShards(tierSelection.selected, options.tier));
|
||||||
const selected = tierSelection.selected;
|
const selected = tierSelection.selected;
|
||||||
const excluded = [...tierSelection.excluded, ...caseKeys.excluded];
|
const excluded = [...tierSelection.excluded, ...caseKeys.excluded];
|
||||||
const shards = planPaidShards(caseKeys.runnable, { maxFilesPerShard: options.maxFilesPerShard });
|
// Same panels as CI: isolated cases run as trial shards, their file shard excludes them.
|
||||||
|
const expansion = expandTrialShards(caseKeys.runnable, options.tier);
|
||||||
|
const shards = planPaidShards(expansion.keys, { maxFilesPerShard: options.maxFilesPerShard,
|
||||||
|
ownShard: new Set(Object.keys(expansion.excludeCases)) });
|
||||||
|
|
||||||
// Parent-side diff selection (D9): skip whole shards whose mapped tests are
|
// Parent-side diff selection (D9): skip whole shards whose mapped tests are
|
||||||
// all unselected. Fail-open everywhere — the child's self-skip stays
|
// all unselected. Fail-open everywhere — the child's self-skip stays
|
||||||
// authoritative for anything the mapper can't attribute.
|
// authoritative for anything the mapper can't attribute.
|
||||||
const cases = computePaidCaseSelection({ profile: options.profile });
|
const cases = computePaidCaseSelection({ profile: options.profile });
|
||||||
const fast = cases.coverage?.mode === 'pr';
|
const fast = cases.coverage?.mode === 'pr';
|
||||||
const profileShards = fast ? shards.filter(files => files.some(file => prProfileFileSelected(file, cases.selection))) : shards;
|
const excludeOf = (file: string) => expansion.excludeCases[normalizeRelativePath(file)] ?? [];
|
||||||
|
const profileShards = fast ? shards.filter(files => files.some(file => prProfileFileSelected(file, cases.selection, excludeOf(file)))) : shards;
|
||||||
const { runnable, skipped } = partitionShardsByDiffSelection(profileShards,
|
const { runnable, skipped } = partitionShardsByDiffSelection(profileShards,
|
||||||
cases.selection.e2e === null ? null : new Set(cases.selection.e2e));
|
cases.selection.e2e === null ? null : new Set(cases.selection.e2e), { excludeCases: expansion.excludeCases });
|
||||||
if (fast) for (const files of shards) {
|
if (fast) for (const files of shards) {
|
||||||
if (!files.some(file => prProfileFileSelected(file, cases.selection))) skipped.push({ files, reason: 'Outside the fast PR profile; retained in broad coverage' });
|
if (!files.some(file => prProfileFileSelected(file, cases.selection, excludeOf(file)))) skipped.push({ files, reason: 'Outside the fast PR profile; retained in broad coverage' });
|
||||||
}
|
}
|
||||||
const selectedCount = cases.selection.e2e?.length ?? Object.keys(E2E_TOUCHFILES).length;
|
const selectedCount = cases.selection.e2e?.length ?? Object.keys(E2E_TOUCHFILES).length;
|
||||||
console.log(
|
console.log(
|
||||||
@@ -3038,9 +3115,11 @@ async function main(): Promise<number> {
|
|||||||
timeoutMs: options.timeoutExplicit ? options.timeoutMs : undefined,
|
timeoutMs: options.timeoutExplicit ? options.timeoutMs : undefined,
|
||||||
jobs: options.jobs,
|
jobs: options.jobs,
|
||||||
withinShardConcurrency: options.withinShardConcurrency,
|
withinShardConcurrency: options.withinShardConcurrency,
|
||||||
|
trials: expansion.trials,
|
||||||
|
casePatterns: Object.fromEntries(Object.entries(expansion.excludeCases).map(([file, ids]) => [file, excludedCasesNamePattern(ids)])),
|
||||||
...(fast ? {
|
...(fast ? {
|
||||||
expectedCases: Object.fromEntries(runnable.flat().map(file => [file, expectedPrCaseCount(file, cases.selection)])),
|
expectedCases: Object.fromEntries(runnable.flat().map(file => [file, expectedPrCaseCount(file, cases.selection, excludeOf(file))])),
|
||||||
casePatterns: Object.fromEntries(runnable.flat().map(file => [file, prProfileTestNamePattern(file, cases.selection)])),
|
casePatterns: Object.fromEntries(runnable.flat().map(file => [file, prProfileTestNamePattern(file, cases.selection, excludeOf(file))])),
|
||||||
} : {}),
|
} : {}),
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
@@ -3065,13 +3144,29 @@ async function main(): Promise<number> {
|
|||||||
executedTests: null,
|
executedTests: null,
|
||||||
skippedTests: null,
|
skippedTests: null,
|
||||||
}));
|
}));
|
||||||
const guardedOutcomes = applyHollowShardGuard(runSummary.outcomes, {
|
const guardedOutcomes = guardTrialRecords(applyHollowShardGuard(runSummary.outcomes, {
|
||||||
evalsAll: process.env.EVALS_ALL === '1',
|
evalsAll: process.env.EVALS_ALL === '1',
|
||||||
requireExecuted: fast,
|
requireExecuted: fast,
|
||||||
});
|
}));
|
||||||
const summary = summarize([...guardedOutcomes, ...skippedOutcomes]);
|
const summary = summarize([...guardedOutcomes, ...skippedOutcomes]);
|
||||||
for (const line of formatSummary(summary)) console.log(line);
|
for (const line of formatSummary(summary)) console.log(line);
|
||||||
return summaryExitCode(summary);
|
// The same verdict rule as the CI report: one panelVerdict() per isolated case.
|
||||||
|
const panels = new Map<string, ShardTrialRecord[]>();
|
||||||
|
for (const outcome of guardedOutcomes) {
|
||||||
|
const panel = outcome.trial ? trialPanelKey(outcome.files[0]!) : null;
|
||||||
|
if (panel) panels.set(panel, [...(panels.get(panel) ?? []), outcome.trial!]);
|
||||||
|
}
|
||||||
|
let panelRed = false;
|
||||||
|
for (const [key, records] of panels) {
|
||||||
|
const plan = expansion.trials[`${key}~t1`]!;
|
||||||
|
const verdict = panelVerdict({ case: shardCaseId(key)!, kind: plan.kind, panel: plan.panel, quarantined: plan.quarantined,
|
||||||
|
trials: records.filter(r => r.outcome !== null).map(r => ({ trial: r.trial, outcome: r.outcome!,
|
||||||
|
...(r.failure_class ? { failure_class: r.failure_class } : {}), ...(r.exit_reason ? { exit_reason: r.exit_reason } : {}),
|
||||||
|
...(r.error ? { error: r.error } : {}) })) });
|
||||||
|
if (verdict.status !== 'PASS' || verdict.split) console.log(` ${formatPanelLine({ ...verdict, file: shardFile(key), slices: {} }, options.tier)}`);
|
||||||
|
panelRed ||= verdict.failsLane;
|
||||||
|
}
|
||||||
|
return sliceExitCode([...guardedOutcomes, ...skippedOutcomes]) || (panelRed ? 1 : 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (import.meta.main) {
|
if (import.meta.main) {
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ import {
|
|||||||
caseIdForTestName,
|
caseIdForTestName,
|
||||||
shardTrial,
|
shardTrial,
|
||||||
excludedCasesNamePattern,
|
excludedCasesNamePattern,
|
||||||
|
runCaseDiagnosis,
|
||||||
|
caseFile,
|
||||||
|
parseCliOptions,
|
||||||
type CaseTrialPlan,
|
type CaseTrialPlan,
|
||||||
type ShardOutcome,
|
type ShardOutcome,
|
||||||
} from '../scripts/test-paid-shards';
|
} from '../scripts/test-paid-shards';
|
||||||
@@ -674,4 +677,26 @@ console.log("Ran 1 tests across 1 files. [1ms]"); process.exit(${fail ? 1 : 0});
|
|||||||
expect(caseIdForTestName(CASE_TEST_NAMES['plan-review-report']!)).toBe('plan-review-report');
|
expect(caseIdForTestName(CASE_TEST_NAMES['plan-review-report']!)).toBe('plan-review-report');
|
||||||
expect(caseIdForTestName('plain helper')).toBeNull();
|
expect(caseIdForTestName('plain helper')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('--case/--trials: local diagnosis flags are validated and never combine with CI modes', () => {
|
||||||
|
expect(parseCliOptions(['--case', 'review-sql-injection', '--trials', '5'], {})).toMatchObject({ caseId: 'review-sql-injection', trials: 5 });
|
||||||
|
expect(() => parseCliOptions(['--trials', '3'], {})).toThrow('--trials requires --case');
|
||||||
|
expect(() => parseCliOptions(['--case', 'no-such-case'], {})).toThrow('live E2E case id');
|
||||||
|
expect(() => parseCliOptions(['--case', 'review-sql-injection', '--report', '/tmp/r'], {})).toThrow('local diagnosis');
|
||||||
|
expect(caseFile('review-sql-injection')).toBe('test/skill-e2e-review.test.ts');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('--case runs the CI panel runner and prints its panelVerdict', async () => {
|
||||||
|
const evalDirBase = fs.mkdtempSync(path.join(os.tmpdir(), 'case-diagnosis-'));
|
||||||
|
const lines: string[] = [];
|
||||||
|
try {
|
||||||
|
const verdict = await runCaseDiagnosis('review-sql-injection', { trials: 3, evalDirBase, log: line => lines.push(line), jobs: 3,
|
||||||
|
commandFor: files => ({ command: process.execPath, args: ['-e',
|
||||||
|
`console.log("Ran 1 tests across 1 files. [1ms]"); process.exit(${files[0]!.endsWith('~t3') ? 1 : 0});`] }) });
|
||||||
|
// A rule case keeps its meaning locally: every trial must pass.
|
||||||
|
expect(verdict).toMatchObject({ case: 'review-sql-injection', kind: 'rule', panel: { n: 3, k: 3 }, passed: 2, status: 'FAIL' });
|
||||||
|
expect(lines.join('\n')).toContain('--case review-sql-injection: 3 trial(s) of test/skill-e2e-review.test.ts');
|
||||||
|
expect(lines.join('\n')).toContain('FAIL 2/3 (✓✓✗)');
|
||||||
|
} finally { fs.rmSync(evalDirBase, { recursive: true, force: true }); }
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user