mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-09 18:28:15 +01:00
* feat(eval-plan): results --check refuses a results table without its no-gstack rows Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(model-panel): a <model>#no-gstack arm per model, identity-keyed cells, one campaign, results.md (GST-468) Each panel model also runs as <model>#no-gstack: the same fixture, ticket, tools, limits, route and reservation, with no skill folder and no routing line. The arm identity keys the ledger attempt, test name, row and summary; candidate folders are named after the fixture project and the seed commit has a neutral author. summarize reads one campaign (run id and PREREG.md sha256) and refuses a duplicate cell; suggestionLine reads the skill arms only, so the preregistered particle-1 line is unchanged. renderResults writes results.md, which passes gstack-eval-plan results --check against the new test/fixtures/model-panel/PREREG.md (supersedes GSTA-173 for later runs). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(arm-benchmark): no-gstack arm on a pinned model, 3 paired trials, both-order paired judge, spend ledger (GST-469) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(evals): no-gstack arm for the safety-rule and router evals (GST-470) GSTACK_SAFETY_ARM and GSTACK_ROUTER_ARM take a third value, none: the same fixture, tools and user request with no skill excerpt, rule text, step file or installed skill. Under none every behavioral check is recorded as an observation (none-arm-observations.jsonl) and never fails the case or writes contract-violations.jsonl; harness preconditions still assert. The four safety prompt builders (ship-evidence, codex-boundary, codex-consult, design-risk) and the router fixtures move into helpers; the rule, removed, current and before prompts stay byte-identical, proven against reference copies of main's code. pair-agent and ios-demo refuse none with the reason (NO_NONE_ARM). GSTACK_ROUTER_ARM now throws on an unknown value instead of silently running current. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * chore: changelog fragment for the no-gstack arms in existing evals Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(arm-benchmark): arm-pair paired-judge calibration run (bar met 6/6, $7.22) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(paid-shards): keep loop-named panel and arm-benchmark cases selectable by --case The lanes moved each cell's testName into helpers, so the static detector no longer saw a computed registration and B4 failed for all eight cases. Same runtime test names; the paid runs are unaffected. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(tests): keep FREE_FIXTURES and the placeholder allowlist current for the no-gstack arms scripts/judge-calibration.ts is now an arm-benchmark paid touchfile, so it can no longer be a free consumer of test/fixtures/judge-calibration/** (CI free-suite 8). The eval-plan Step 5 fence names <prereg-path> and <results-path>; allowlist both as PATH placeholders (CI free-suite 19). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(eval-plan): cover a complete table after a partial one, --json output and the results usage error Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(eval-plan): keep the Step 7 coverage test as reviewed Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * chore: name the changelog fragment after the branch slug Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: arm-benchmark project folder names and the arm-pair calibration corpus Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: keep the judge-calibration fixture README as reviewed Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(skills): describe eval-plan results --check Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
103 lines
5.8 KiB
TypeScript
103 lines
5.8 KiB
TypeScript
/**
|
|
* GST-470: the router failure-routing eval's arms. GSTACK_ROUTER_ARM is
|
|
* current (the default), before or none, and anything else throws. The none arm
|
|
* builds a repository with no installed skills and a prompt that is the user's
|
|
* request alone. The current and before arms' prompts and repositories are byte
|
|
* for byte what main built (the reference copies below, from
|
|
* test/skill-e2e-failure-routing.test.ts at 0cd2b4a).
|
|
*/
|
|
import { afterAll, describe, expect, test } from 'bun:test';
|
|
import * as fs from 'node:fs';
|
|
import * as os from 'node:os';
|
|
import * as path from 'node:path';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { FIXTURES, makeRepo, plainMetrics, routerArm, routerPrompt, type Fixture } from './helpers/failure-routing-fixture';
|
|
import { extractSkillHead, sliceBetween } from './helpers/skill-fixture';
|
|
|
|
const ROOT = path.resolve(import.meta.dir, '..');
|
|
const BASE = 'HEAD';
|
|
const dirs: string[] = [];
|
|
afterAll(() => { for (const dir of dirs) fs.rmSync(dir, { recursive: true, force: true }); });
|
|
|
|
function mainRouterSection(arm: 'before' | 'current'): string {
|
|
const skill = arm === 'before'
|
|
? spawnSync('git', ['show', `${BASE}:SKILL.md`], { cwd: ROOT, encoding: 'utf8', timeout: 10_000 }).stdout
|
|
: fs.readFileSync(path.join(ROOT, 'SKILL.md'), 'utf8');
|
|
return sliceBetween(skill, '## Route first', 'Best-effort, record') + sliceBetween(skill, '**Routing rules', 'If the user opts out');
|
|
}
|
|
|
|
function mainPrompt(fixture: Fixture, arm: 'before' | 'current'): string {
|
|
return `The user invoked the gstack router skill (/gstack) in this repository. The router's instructions:\n\n${mainRouterSection(arm)}\n\nThe user's request: "${fixture.request}"`;
|
|
}
|
|
|
|
function mainMakeRepo(fixture: Fixture): string {
|
|
const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'failure-routing-'));
|
|
fs.writeFileSync(path.join(repo, 'package.json'), JSON.stringify({ name: 'fixture', type: 'module', scripts: { test: 'bun test' } }, null, 2));
|
|
for (const [file, body] of Object.entries(fixture.files)) {
|
|
fs.mkdirSync(path.dirname(path.join(repo, file)), { recursive: true });
|
|
fs.writeFileSync(path.join(repo, file), body);
|
|
}
|
|
for (const skill of ['investigate', 'qa', 'review', 'ship']) {
|
|
fs.mkdirSync(path.join(repo, '.claude', 'skills', skill), { recursive: true });
|
|
fs.writeFileSync(path.join(repo, '.claude', 'skills', skill, 'SKILL.md'), extractSkillHead(path.join(ROOT, skill, 'SKILL.md')));
|
|
}
|
|
const git = (...args: string[]) => spawnSync('git', ['-c', 'user.email=test@example.invalid', '-c', 'user.name=Test', ...args], { cwd: repo, timeout: 10_000 });
|
|
git('init', '-q', '-b', 'main');
|
|
git('add', '.');
|
|
git('commit', '-qm', 'initial');
|
|
return repo;
|
|
}
|
|
|
|
/** Every file under dir except .git, path → contents, plus the committed file list. */
|
|
function tree(dir: string): { files: Record<string, string>; committed: string } {
|
|
dirs.push(dir);
|
|
const files: Record<string, string> = {};
|
|
for (const rel of fs.readdirSync(dir, { recursive: true, encoding: 'utf8' }).sort()) {
|
|
if (rel === '.git' || rel.startsWith('.git' + path.sep)) continue;
|
|
const abs = path.join(dir, rel);
|
|
if (fs.statSync(abs).isFile()) files[rel] = fs.readFileSync(abs, 'utf8');
|
|
}
|
|
const committed = spawnSync('git', ['ls-files'], { cwd: dir, encoding: 'utf8', timeout: 10_000 }).stdout;
|
|
return { files, committed };
|
|
}
|
|
|
|
describe('router arms (GST-470)', () => {
|
|
test('GSTACK_ROUTER_ARM: unset is current; before, current and none parse; anything else throws', () => {
|
|
expect(routerArm({})).toBe('current');
|
|
for (const arm of ['before', 'current', 'none'] as const) expect(routerArm({ GSTACK_ROUTER_ARM: arm })).toBe(arm);
|
|
for (const bad of ['nonee', 'Current', 'after', '']) {
|
|
expect(() => routerArm({ GSTACK_ROUTER_ARM: bad })).toThrow('must be before, current or none, got ' + bad);
|
|
}
|
|
});
|
|
|
|
test('none: the repository has no installed skills and the prompt is the request alone', () => {
|
|
for (const fixture of FIXTURES) {
|
|
const repo = tree(makeRepo(fixture, 'none'));
|
|
const reference = tree(mainMakeRepo(fixture));
|
|
expect(Object.keys(repo.files).some(f => f.startsWith('.claude'))).toBe(false);
|
|
const withoutSkills = Object.fromEntries(Object.entries(reference.files).filter(([f]) => !f.startsWith('.claude')));
|
|
expect(repo.files).toEqual(withoutSkills);
|
|
const prompt = routerPrompt(fixture, 'none');
|
|
expect(prompt).toBe(fixture.request);
|
|
for (const word of ['## Route first', '**Routing rules', 'gstack', 'router', 'skill', '/investigate']) expect(prompt).not.toContain(word);
|
|
}
|
|
});
|
|
|
|
test('current and before: prompts and repositories are byte-identical to main', () => {
|
|
for (const fixture of FIXTURES) {
|
|
for (const arm of ['current', 'before'] as const) expect(routerPrompt(fixture, arm, BASE)).toBe(mainPrompt(fixture, arm));
|
|
for (const arm of ['current', 'before'] as const) expect(tree(makeRepo(fixture, arm))).toEqual(tree(mainMakeRepo(fixture)));
|
|
}
|
|
});
|
|
|
|
test('plain metrics count read-only calls before the first edit', () => {
|
|
const call = (tool: string, input: Record<string, unknown> = {}) => ({ tool, input, output: '' });
|
|
expect(plainMetrics([])).toMatchObject({ editedBeforeRead: false, readsBeforeEdit: 0 });
|
|
expect(plainMetrics([call('Read'), call('Grep'), call('Edit'), call('Read')])).toMatchObject({ editedBeforeRead: false, readsBeforeEdit: 2 });
|
|
expect(plainMetrics([call('Edit'), call('Read')])).toMatchObject({ editedBeforeRead: true, readsBeforeEdit: 0 });
|
|
expect(plainMetrics([call('Bash', { command: "sed -i 's/ /-/' src/slug.js" }), call('Read')])).toMatchObject({ editedBeforeRead: true, readsBeforeEdit: 0 });
|
|
expect(plainMetrics([call('Bash', { command: 'bun test' }), call('Skill', { skill: 'investigate' }), call('Write')]))
|
|
.toMatchObject({ editedBeforeRead: false, readsBeforeEdit: 1, sequence: 'Bash → Skill(investigate) → Write' });
|
|
});
|
|
});
|