mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-07 01:15:47 +01:00
* feat(retro): factory scorecard bin (lead time, gate cost, red-train and flaky rates) and the recommended-harness adoption doc Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(scorecard): register the recorded gh fixture with its free consumer Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(scorecard): cover --gate-event any, an empty window, a failed PR read and a bad --now Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes Review pass 1 (core checklist, testing, maintainability, security, performance, simplification, state-machines, red team) over the whole diff: - the flaky line and cost-per-merge depend on every input they read, so a failed free-run or PR read prints unavailable (...) on its half instead of 0 - a PR verdict-comment cost backs one gate run per PR; a READY comment after the merge is ignored - one unreadable artifact leaves its run without cost or flake data instead of dropping the gate section - --repo defaults to the origin owner/name and is required without one (no built-in repository) - --max-artifact-runs, --pass-cap and --pass-marker are validated; --csv creates its directory - one artifact listing per gate run; PR comments are paged; issues use the exact window instant - every run appends its rows to trend.csv in --out-dir - new row: review passes per PR (median, p90, PRs at the cap) from the ship receipt or a comment marker - /retro: report format gains the Factory scorecard slot; the Step 7.5 fence quotes its placeholder - tests for each; <window-days> joins the placeholder allowlist Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: review pass 2 (delta): usage text, pass-marker capture group, GitHub-host origin parsing, idempotent trend.csv - usage() strips the header comment decoration again; --help text asserted - passCount takes the first all-digit capture group; --pass-marker without a capture group exits 2 - --repo falls back to the origin remote only on github.com or GH_HOST; a local-path origin exits 2 - trend.csv keeps one row set per repo and window (a rerun replaces its rows) and always carries its header - /retro Step 7.5 points at SCORECARD_TREND for the weeks; the doc cites the per-PR baseline CSV Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(host-conformance): delete a named section in the C3 broken-link case The case removed the first .md readdirSync returned under gstack-qa/sections. qa-patterns.md and system-functional.md are referenced only in table form, which setup --status does not count as section links, so on an ext4 directory order that listed one of them first the deleted file broke nothing and --status exited 0: deterministic red on remote-gate VMs, green on Actions. Delete scope.md, which every host's rendered SKILL.md reads. Closes #3479 Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * v1.91.252.0 stamp 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>
365 lines
27 KiB
TypeScript
365 lines
27 KiB
TypeScript
/**
|
|
* factory-scorecard (free): the window's merged PRs, lead times, gate runs with
|
|
* known cost, red-train rate, flaky events, issues and releases, computed from
|
|
* recorded gh responses (test/fixtures/factory-scorecard/recorded.json); an
|
|
* unreadable input marks its lines unavailable and the rest still print; the
|
|
* bin writes the table, the CSV and the JSON without touching the network.
|
|
*/
|
|
import { afterEach, describe, expect, test } from 'bun:test';
|
|
import { spawnSync } from 'node:child_process';
|
|
import * as fs from 'node:fs';
|
|
import * as os from 'node:os';
|
|
import * as path from 'node:path';
|
|
import {
|
|
BENCHMARKS, DEFAULTS, classifyGate, collectScorecard, fixtureSource, flakeRecords, fmtHours, gateOutcome, passCount, percentile, readComments, renderCsv,
|
|
renderMarkdown, scorecardRows, verdictCost, type FixtureFile, type ScorecardOptions,
|
|
} from '../lib/factory-scorecard';
|
|
|
|
const ROOT = path.resolve(import.meta.dir, '..');
|
|
const FIXTURE = path.join(ROOT, 'test', 'fixtures', 'factory-scorecard', 'recorded.json');
|
|
const BIN = path.join(ROOT, 'bin', 'gstack-factory-scorecard');
|
|
const NOW = Date.parse('2026-10-26T12:00:00Z');
|
|
|
|
const tmp: string[] = [];
|
|
afterEach(() => { for (const dir of tmp.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); });
|
|
const scratch = () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-scorecard-test-')); tmp.push(dir); return dir; };
|
|
|
|
const fixture = (): FixtureFile => JSON.parse(fs.readFileSync(FIXTURE, 'utf8'));
|
|
const options = (over: Partial<ScorecardOptions> = {}): ScorecardOptions => ({
|
|
repo: 'acme/widgets', base: DEFAULTS.base, now: NOW, sinceDays: DEFAULTS.sinceDays, crew: null, gateWorkflow: DEFAULTS.gateWorkflow,
|
|
gateEvent: DEFAULTS.gateEvent, freeWorkflow: DEFAULTS.freeWorkflow, trainPrefix: DEFAULTS.trainPrefix,
|
|
readyMarker: new RegExp(DEFAULTS.readyMarker, 'm'), passMarker: new RegExp(DEFAULTS.passMarker, 'm'), passCap: DEFAULTS.passCap,
|
|
maxArtifactRuns: DEFAULTS.maxArtifactRuns, ...over,
|
|
});
|
|
const byId = (rows: ReturnType<typeof scorecardRows>) => Object.fromEntries(rows.map(r => [r.id, r.value]));
|
|
|
|
describe('factory-scorecard: recorded window', () => {
|
|
test('merged PRs, lead times, gates, cost, trains, flakes, issues and releases from the recorded responses', () => {
|
|
const source = fixtureSource(fixture(), scratch());
|
|
const card = collectScorecard(source, options());
|
|
expect(card.unavailable).toEqual([]);
|
|
expect(card.prs.map(p => p.number)).toEqual([101, 103, 102]);
|
|
expect(card.prs.map(p => p.crew)).toEqual([true, true, false]);
|
|
expect(card.prs.find(p => p.number === 101)!.readyAt).toBe('2026-10-24T16:00:00Z');
|
|
expect(card.prs.find(p => p.number === 101)!.commentCostUsd).toBe(12.5);
|
|
expect(card.prs.find(p => p.number === 102)!.readyAt).toBeNull();
|
|
expect(card.prs.map(p => p.passes)).toEqual([2, 3, null]);
|
|
expect(card.gates.map(g => [g.id, g.kind, g.outcome, g.costUsd, g.costSource, g.flakes])).toEqual([
|
|
[9004, 'backstop', 'cancelled', null, null, 0],
|
|
[9003, 'screen', 'green', 12.5, 'comment', 0],
|
|
[9002, 'train', 'red', null, null, 0],
|
|
[9001, 'train', 'green', 30.5, 'artifact', 2],
|
|
]);
|
|
expect(card.gates.find(g => g.id === 9001)!.flakeRerunUsd).toBe(1.1);
|
|
expect(card.freeRuns.map(r => [r.id, r.retries])).toEqual([[8003, 0], [8002, 0], [8001, 2]]);
|
|
expect([card.issuesOpened, card.issuesClosed]).toEqual([5, 7]);
|
|
expect(card.releases).toEqual([{ tag: 'v1.0.0', publishedAt: '2026-10-23T07:00:00Z' }]);
|
|
|
|
const values = byId(scorecardRows(card));
|
|
expect(values.merged).toBe('3 (crew 2, outside 1)');
|
|
expect(values.lead_time).toBe('median 8.0h, p90 5.0d');
|
|
expect(values.ready_to_merge).toBe('median 1.0h, p90 2.0h (2 of 3 PRs with a READY comment)');
|
|
expect(values.review_passes).toBe('median 2, p90 3 (2 of 3 PRs report a pass count; 1 at the cap of 3)');
|
|
expect(values.gates).toBe('2 / 1 / 1; known cost $43.00 on 2 of 4 runs');
|
|
expect(values.cost_per_merge).toBe('$14.33 (2 runs without a known cost)');
|
|
expect(values.red_train).toBe('50% (1 red of 2 trains; 0 red backstop, 0 red screens)');
|
|
expect(values.flaky).toBe('2 paid case(s) passed on their one rerun (rerun spend $1.10); 2 free-suite retries in 1 of 3 runs');
|
|
expect(values.issues).toBe('5 / 7');
|
|
expect(values.releases).toBe('1 (v1.0.0)');
|
|
});
|
|
|
|
test('a crew login list overrides the author_association rule', () => {
|
|
const card = collectScorecard(fixtureSource(fixture(), scratch()), options({ crew: ['Alice'] }));
|
|
expect(byId(scorecardRows(card)).merged).toBe('3 (crew 1, outside 2)');
|
|
});
|
|
|
|
test('a run older than the window, a non-dispatch event and a draft release are left out; the page stops once older than the window', () => {
|
|
const source = fixtureSource(fixture(), scratch());
|
|
const card = collectScorecard(source, options());
|
|
expect(card.gates.map(g => g.id)).not.toContain(9000);
|
|
expect(card.gates.map(g => g.id)).not.toContain(9005);
|
|
expect(card.releases.map(r => r.tag)).not.toContain('v1.1.0');
|
|
expect(source.calls.filter(c => c.includes('/pulls?'))).toHaveLength(1);
|
|
});
|
|
|
|
test('a full page inside the window reads the next page; a full page whose oldest update predates the window does not', () => {
|
|
// Value: protects=the PR paging loop continues past page 1 only while the page is full and still inside the window; fails_when=the break condition stops after one page or ignores the oldest update; why_new=the recorded fixture never fills a page, so the continue branch was untested; seam=none; no_claim=none beyond protects
|
|
const pullsKey = (page: number) => `repos/acme/widgets/pulls?state=closed&base=main&sort=updated&direction=desc&per_page=100&page=${page}`;
|
|
const page = (first: number, updatedAt: string) => Array.from({ length: 100 }, (_, i) => ({ number: first + i, title: `pr ${first + i}`, user: { login: 'alice' }, author_association: 'MEMBER',
|
|
created_at: '2026-10-24T00:00:00Z', merged_at: '2026-10-25T00:00:00Z', updated_at: updatedAt }));
|
|
const withPages = (oldest: string) => {
|
|
const fx = fixture();
|
|
fx.responses[pullsKey(1)] = page(1000, oldest);
|
|
fx.responses[pullsKey(2)] = page(2000, oldest).slice(0, 5);
|
|
for (let n = 1000; n < 1100; n++) fx.responses[`repos/acme/widgets/issues/${n}/comments?per_page=100&page=1`] = [];
|
|
for (let n = 2000; n < 2005; n++) fx.responses[`repos/acme/widgets/issues/${n}/comments?per_page=100&page=1`] = [];
|
|
return fx;
|
|
};
|
|
const inWindow = fixtureSource(withPages('2026-10-25T00:00:00Z'), scratch());
|
|
expect(collectScorecard(inWindow, options()).prs).toHaveLength(105);
|
|
expect(inWindow.calls.filter(c => c.includes('/pulls?'))).toHaveLength(2);
|
|
const stale = fixtureSource(withPages('2026-10-01T00:00:00Z'), scratch());
|
|
expect(collectScorecard(stale, options()).prs).toHaveLength(100);
|
|
expect(stale.calls.filter(c => c.includes('/pulls?'))).toHaveLength(1);
|
|
});
|
|
|
|
test('a PR comment cost backs one gate run per PR; the gate-run read failing marks only its lines unavailable', () => {
|
|
// Value: protects=two artifact-less gate runs on one PR do not both copy the PR's comment cost into known cost; fails_when=the once-per-PR guard is removed; why_new=review found the fallback keyed by PR number only; seam=none; no_claim=none beyond protects
|
|
const fx = fixture();
|
|
const runsKey = Object.keys(fx.responses).find(k => k.includes('/actions/workflows/evals.yml/runs?') && k.includes('event=workflow_dispatch'))!;
|
|
const body = fx.responses[runsKey] as { workflow_runs: any[] };
|
|
const screen = body.workflow_runs.find(r => r.id === 9003)!;
|
|
body.workflow_runs.push({ ...screen, id: 9006, created_at: '2026-10-25T13:00:00Z' });
|
|
const card = collectScorecard(fixtureSource(fx, scratch()), options());
|
|
expect(card.gates.filter(g => g.id === 9003 || g.id === 9006).map(g => [g.pr, g.costSource])).toEqual([[101, 'comment'], [101, null]]);
|
|
expect(byId(scorecardRows(card)).gates).toBe('3 / 1 / 1; known cost $43.00 on 2 of 5 runs');
|
|
const broken = fixture();
|
|
delete broken.responses[runsKey];
|
|
const noGates = collectScorecard(fixtureSource(broken, scratch()), options());
|
|
expect(noGates.unavailable).toHaveLength(1);
|
|
expect(noGates.unavailable[0]).toStartWith('gate runs: ');
|
|
const values = byId(scorecardRows(noGates));
|
|
expect(values.gates).toStartWith('unavailable (');
|
|
expect(values.red_train).toStartWith('unavailable (');
|
|
expect(values.merged).toBe('3 (crew 2, outside 1)');
|
|
});
|
|
|
|
test('--max-artifact-runs caps artifact reads: later runs count without cost or flake data', () => {
|
|
const card = collectScorecard(fixtureSource(fixture(), scratch()), options({ maxArtifactRuns: 2 }));
|
|
expect(card.artifactRunsRead).toBe(2);
|
|
expect(card.gates.find(g => g.id === 9001)!.costUsd).toBeNull();
|
|
expect(card.gates.find(g => g.id === 9001)!.flakes).toBe(0);
|
|
expect(byId(scorecardRows(card)).gates).toBe('2 / 1 / 1; known cost $12.50 on 1 of 4 runs');
|
|
});
|
|
|
|
test('an unreadable input marks only its lines unavailable, with the reason, and the rest still print', () => {
|
|
const fx = fixture();
|
|
for (const key of Object.keys(fx.responses)) if (key.startsWith('search/issues')) delete fx.responses[key];
|
|
const card = collectScorecard(fixtureSource(fx, scratch()), options());
|
|
expect(card.unavailable).toHaveLength(1);
|
|
expect(card.unavailable[0]).toStartWith('issues: ');
|
|
const values = byId(scorecardRows(card));
|
|
expect(values.issues).toStartWith('unavailable (');
|
|
expect(values.merged).toBe('3 (crew 2, outside 1)');
|
|
expect(renderMarkdown(card, scorecardRows(card))).toContain('Unavailable inputs: issues: ');
|
|
});
|
|
|
|
test('markdown and CSV carry every row with its benchmark and counting rule', () => {
|
|
const card = collectScorecard(fixtureSource(fixture(), scratch()), options());
|
|
const rows = scorecardRows(card);
|
|
expect(rows.map(r => r.id)).toEqual(Object.keys(BENCHMARKS));
|
|
const md = renderMarkdown(card, rows);
|
|
expect(md).toStartWith('## Factory scorecard: acme/widgets (2026-10-19 to 2026-10-26, 7 days)');
|
|
expect(md).toContain('| Metric | This window | One of the best factories would show | Counting rule |');
|
|
expect(md).toContain('| Red-train rate | 50% (1 red of 2 trains; 0 red backstop, 0 red screens) | under 5% of trains red');
|
|
const csv = renderCsv(card, rows).split('\n');
|
|
expect(csv[0]).toBe('repo,window_start,window_end,metric_id,metric,value,benchmark,rule');
|
|
expect(csv).toHaveLength(rows.length + 2);
|
|
expect(csv[1]).toStartWith('"acme/widgets","2026-10-19T12:00:00.000Z","2026-10-26T12:00:00.000Z","merged","Merged PRs","3 (crew 2, outside 1)"');
|
|
});
|
|
|
|
test('--gate-event any keeps non-dispatch runs and drops the event filter from the query', () => {
|
|
// Value: protects=gateEvent null counts every completed gate run and omits event= from the runs query; fails_when=the null branch filters or the query keeps event=; why_new=only the default dispatch path was covered; seam=none; no_claim=live GitHub query semantics
|
|
const fx = fixture();
|
|
const key = Object.keys(fx.responses).find(k => k.includes('/evals.yml/runs?'))!;
|
|
fx.responses[key.replace('&event=workflow_dispatch', '')] = fx.responses[key];
|
|
const source = fixtureSource(fx, scratch());
|
|
const card = collectScorecard(source, options({ gateEvent: null }));
|
|
expect(source.calls.find(c => c.includes('/evals.yml/runs?'))).not.toContain('event=');
|
|
expect(card.gates.map(g => g.id)).toEqual([9005, 9004, 9003, 9002, 9001]);
|
|
expect(card.gates.find(g => g.id === 9005)!.kind).toBe('other');
|
|
});
|
|
|
|
test('an empty window prints n/a on every ratio instead of NaN, and short durations read as minutes', () => {
|
|
// Value: protects=zero merges and zero trains render n/a and sub-hour lead times render as minutes; fails_when=a 0/0 guard is removed or fmtHours loses a branch; why_new=every other case has merges and hour-scale durations; seam=none; no_claim=none beyond protects
|
|
const fx = fixture();
|
|
for (const key of Object.keys(fx.responses)) {
|
|
if (key.includes('/pulls?') || key.includes('/releases?')) fx.responses[key] = [];
|
|
else if (key.includes('/runs?')) fx.responses[key] = { total_count: 0, workflow_runs: [] };
|
|
else if (key.startsWith('search/issues')) fx.responses[key] = { total_count: 0, items: [] };
|
|
}
|
|
const card = collectScorecard(fixtureSource(fx, scratch()), options());
|
|
const values = byId(scorecardRows(card));
|
|
expect(card.unavailable).toEqual([]);
|
|
expect(values.merged).toBe('0 (crew 0, outside 0)');
|
|
expect(values.lead_time).toBe('median n/a, p90 n/a');
|
|
expect(values.cost_per_merge).toBe('n/a (no merges)');
|
|
expect(values.red_train).toBe('n/a (0 red of 0 trains; 0 red backstop, 0 red screens)');
|
|
expect(values.releases).toBe('0');
|
|
expect(renderMarkdown(card, scorecardRows(card))).not.toContain('NaN');
|
|
expect(fmtHours(0.5)).toBe('30m');
|
|
expect(fmtHours(null)).toBe('n/a');
|
|
});
|
|
|
|
test('a failed merged-PR read leaves gate rows standing with comment costs unknown', () => {
|
|
// Value: protects=each input is guarded on its own so a PR-list failure still prints gates, free retries, issues and releases; fails_when=the guards are merged into one try or the gate read depends on the PR read succeeding; why_new=only the issues guard was exercised; seam=none; no_claim=none beyond protects
|
|
const fx = fixture();
|
|
for (const key of Object.keys(fx.responses)) if (key.includes('/pulls?')) delete fx.responses[key];
|
|
const card = collectScorecard(fixtureSource(fx, scratch()), options());
|
|
expect(card.unavailable).toHaveLength(1);
|
|
expect(card.unavailable[0]).toStartWith('merged PRs: ');
|
|
const values = byId(scorecardRows(card));
|
|
expect(values.merged).toStartWith('unavailable (');
|
|
expect(values.lead_time).toStartWith('unavailable (');
|
|
expect(values.gates).toBe('2 / 1 / 1; known cost $30.50 on 1 of 4 runs (comment costs unavailable: merged PRs unreadable)');
|
|
expect(values.cost_per_merge).toStartWith('unavailable (');
|
|
expect(values.review_passes).toStartWith('unavailable (');
|
|
expect(card.gates.find(g => g.id === 9003)!.costSource).toBeNull();
|
|
expect(values.issues).toBe('5 / 7');
|
|
});
|
|
|
|
test('a failed free-run read marks only the free half of the flaky line; an unreadable artifact leaves its run without cost or flake data', () => {
|
|
// Value: protects=the flaky line never prints a clean 0 for a read that failed, and one bad artifact does not drop the whole gate section; fails_when=the flaky row is keyed to one guard again or the per-run try/catch is removed; why_new=red-team review found both; seam=none; no_claim=none beyond protects
|
|
const fx = fixture();
|
|
for (const key of Object.keys(fx.responses)) if (key.includes('/actions/workflows/free-tests.yml/')) delete fx.responses[key];
|
|
const values = byId(scorecardRows(collectScorecard(fixtureSource(fx, scratch()), options())));
|
|
expect(values.flaky).toMatch(/^2 paid case\(s\) passed on their one rerun \(rerun spend \$1\.10\); unavailable \(/);
|
|
expect(values.gates).toBe('2 / 1 / 1; known cost $43.00 on 2 of 4 runs');
|
|
const inner = fixtureSource(fixture(), scratch());
|
|
const flaky = { getJson: (p: string) => inner.getJson(p), artifactDirs: (id: number, m: (n: string) => boolean) => { if (id === 9001 || id === 8001) throw new Error('unzip failed'); return inner.artifactDirs(id, m); } };
|
|
const card = collectScorecard(flaky, options());
|
|
expect(card.unavailable).toEqual([]);
|
|
expect(card.artifactErrors).toBe(1);
|
|
expect(card.gates.find(g => g.id === 9001)!.costUsd).toBeNull();
|
|
expect(card.freeRuns.find(r => r.id === 8001)!.retries).toBeNull();
|
|
const v = byId(scorecardRows(card));
|
|
expect(v.gates).toBe('2 / 1 / 1; known cost $12.50 on 1 of 4 runs');
|
|
expect(v.flaky).toBe('0 paid case(s) passed on their one rerun (rerun spend $0.00); artifacts unreadable on 1 run(s); 0 free-suite retries in 0 of 3 runs (1 unreadable)');
|
|
});
|
|
});
|
|
|
|
describe('factory-scorecard: pieces', () => {
|
|
test('gate kind by train prefix, backstop and screen titles; outcome by conclusion', () => {
|
|
expect(classifyGate({ branch: 'train/gate.train-x', title: 'E2E Evals [eval-bg gate.train-x]' }, 'train/')).toBe('train');
|
|
expect(classifyGate({ branch: 'main', title: 'E2E Evals [eval-bg gate.backstop-abc]' }, 'train/')).toBe('backstop');
|
|
expect(classifyGate({ branch: 'main', title: 'E2E Evals [eval-bg pr.screen.narrow-x] screen #12' }, 'train/')).toBe('screen');
|
|
expect(classifyGate({ branch: 'feature/x', title: 'E2E Evals [eval-bg pr.gate-x]' }, 'train/')).toBe('other');
|
|
expect(classifyGate({ branch: 'queue/gate-1', title: 'gate' }, 'queue/')).toBe('train');
|
|
expect(['success', 'failure', 'timed_out', 'cancelled', 'skipped', null].map(gateOutcome)).toEqual(['green', 'red', 'red', 'cancelled', 'cancelled', 'red']);
|
|
});
|
|
|
|
test('READY marker: the first matching comment wins and the newest cost line is kept', () => {
|
|
const source = fixtureSource(fixture(), scratch());
|
|
const markers = (readyMarker: RegExp, mergedAt?: string) => ({ readyMarker, passMarker: new RegExp(DEFAULTS.passMarker, 'm'), mergedAt });
|
|
expect(readComments(source, 'acme/widgets', 101, markers(/READY #\d+/m))).toEqual({ readyAt: '2026-10-24T16:00:00Z', costUsd: 12.5, passes: null });
|
|
expect(readComments(source, 'acme/widgets', 101, markers(/never matches/m))).toEqual({ readyAt: null, costUsd: 12.5, passes: null });
|
|
expect(readComments(source, 'acme/widgets', 103, markers(new RegExp(DEFAULTS.readyMarker, 'm')))).toEqual({ readyAt: '2026-10-25T03:00:00Z', costUsd: null, passes: 3 });
|
|
// Value: protects=a READY-style comment posted after the merge (a controller recap) does not become the READY time; fails_when=the mergedAt bound is dropped; why_new=review found negative READY-to-merge durations possible; seam=none; no_claim=none beyond protects
|
|
expect(readComments(source, 'acme/widgets', 102, markers(/READY #\d+/m, '2026-10-25T12:00:00Z')).readyAt).toBeNull();
|
|
expect(readComments(source, 'acme/widgets', 102, markers(/READY #\d+/m)).readyAt).toBe('2026-10-26T01:00:00Z');
|
|
expect(passCount('"passes": 4 then passes=1 then REVIEW_PASSES: 2', new RegExp(DEFAULTS.passMarker, 'm'))).toBe(4);
|
|
expect(passCount('reviews: 3 and passes=2', /(passes|reviews)[=: ]+(\d+)/m)).toBe(3);
|
|
expect(passCount('no count here', new RegExp(DEFAULTS.passMarker, 'm'))).toBeNull();
|
|
});
|
|
|
|
test('verdict cost reads collector-outcomes.json and ignores an unreadable one; flake records skip torn lines and unknown rerun cost', () => {
|
|
const dir = scratch();
|
|
fs.mkdirSync(path.join(dir, 'a', 'paid-report'), { recursive: true });
|
|
fs.writeFileSync(path.join(dir, 'a', 'paid-report', 'collector-outcomes.json'), '{not json');
|
|
expect(verdictCost([path.join(dir, 'a')])).toBeNull();
|
|
fs.writeFileSync(path.join(dir, 'a', 'paid-report', 'collector-outcomes.json'), JSON.stringify({ verdict: { costUsd: 23.814 } }));
|
|
expect(verdictCost([path.join(dir, 'a')])).toBe(23.81);
|
|
fs.writeFileSync(path.join(dir, 'a', 'flake-records.jsonl'), [
|
|
JSON.stringify({ kind: 'gate-flake', case: 'x', rerun_cost_usd: 0.5 }), 'torn {', JSON.stringify({ kind: 'gate-flake', case: 'y', rerun_cost_usd: null }),
|
|
JSON.stringify({ kind: 'other', case: 'z', rerun_cost_usd: 9 }), ''].join('\n'));
|
|
expect(flakeRecords([path.join(dir, 'a')])).toEqual({ flakes: 2, rerunUsd: 0.5 });
|
|
expect(flakeRecords([path.join(dir, 'missing')])).toEqual({ flakes: 0, rerunUsd: 0 });
|
|
});
|
|
|
|
test('percentile is nearest-rank: p50 and p90 of small samples, null when empty', () => {
|
|
expect(percentile([], 0.5)).toBeNull();
|
|
expect(percentile([4, 8, 120], 0.5)).toBe(8);
|
|
expect(percentile([4, 8, 120], 0.9)).toBe(120);
|
|
expect(percentile([2, 1], 0.5)).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('factory-scorecard: the bin', () => {
|
|
const run = (args: string[], cwd: string) => spawnSync(BIN, args, { cwd, encoding: 'utf8', timeout: 60_000, env: { ...process.env, HOME: cwd } });
|
|
|
|
test('--fixtures replays the recorded window and writes the table, the CSV and the JSON', () => {
|
|
const cwd = scratch();
|
|
const r = run(['--repo', 'acme/widgets', '--fixtures', FIXTURE, '--now', '2026-10-26T12:00:00Z', '--json'], cwd);
|
|
expect(r.status, r.stderr).toBe(0);
|
|
expect(r.stdout).toContain('| Merged PRs | 3 (crew 2, outside 1) |');
|
|
expect(r.stdout).toContain('| Known gate cost per merged PR | $14.33 (2 runs without a known cost) |');
|
|
expect(r.stdout).toContain(`SCORECARD_CSV: ${path.join('.context', 'factory-scorecard', 'scorecard.csv')}`);
|
|
const csv = fs.readFileSync(path.join(cwd, '.context', 'factory-scorecard', 'scorecard.csv'), 'utf8');
|
|
expect(csv.split('\n')[0]).toBe('repo,window_start,window_end,metric_id,metric,value,benchmark,rule');
|
|
expect(csv).toContain('"red_train","Red-train rate","50% (1 red of 2 trains; 0 red backstop, 0 red screens)"');
|
|
expect(fs.existsSync(path.join(cwd, '.context', 'factory-scorecard', 'scorecard.md'))).toBe(true);
|
|
const json = JSON.parse(fs.readFileSync(path.join(cwd, '.context', 'factory-scorecard', 'scorecard.json'), 'utf8'));
|
|
expect(json.card.prs).toHaveLength(3);
|
|
expect(json.rows).toHaveLength(Object.keys(BENCHMARKS).length);
|
|
});
|
|
|
|
test('--crew, --out-dir, --csv and a custom --ready-marker are honoured', () => {
|
|
const cwd = scratch();
|
|
const out = path.join(cwd, 'out');
|
|
const r = run(['--repo', 'acme/widgets', '--fixtures', FIXTURE, '--now', '2026-10-26T12:00:00Z', '--crew', 'alice,bo', '--out-dir', out,
|
|
'--csv', path.join(cwd, 'card.csv'), '--ready-marker', 'ship receipt follows'], cwd);
|
|
expect(r.status, r.stderr).toBe(0);
|
|
expect(r.stdout).toContain('| Merged PRs | 3 (crew 2, outside 1) |');
|
|
expect(r.stdout).toContain('(1 of 3 PRs with a READY comment)');
|
|
expect(fs.existsSync(path.join(cwd, 'card.csv'))).toBe(true);
|
|
expect(fs.existsSync(path.join(out, 'scorecard.md'))).toBe(true);
|
|
// Value: protects=--csv into a directory that does not exist yet is created instead of crashing after every read; fails_when=the mkdirSync before the CSV write is removed; why_new=review found only --out-dir was created; seam=none; no_claim=none beyond protects
|
|
const nested = run(['--repo', 'acme/widgets', '--fixtures', FIXTURE, '--now', '2026-10-26T12:00:00Z', '--csv', path.join(cwd, 'trends', 'w43', 'card.csv')], cwd);
|
|
expect(nested.status, nested.stderr).toBe(0);
|
|
expect(fs.existsSync(path.join(cwd, 'trends', 'w43', 'card.csv'))).toBe(true);
|
|
// Value: protects=every run appends its rows to trend.csv with one header, so weeks line up without renaming files; fails_when=the append is removed or the header repeats; why_new=review found scorecard.csv overwritten every run; seam=none; no_claim=none beyond protects
|
|
const trend = fs.readFileSync(path.join(cwd, '.context', 'factory-scorecard', 'trend.csv'), 'utf8').split('\n').filter(Boolean);
|
|
expect(trend.filter(l => l.startsWith('repo,')).length).toBe(1);
|
|
expect(trend.length).toBe(1 + Object.keys(BENCHMARKS).length);
|
|
const again = run(['--repo', 'acme/widgets', '--fixtures', FIXTURE, '--now', '2026-10-27T12:00:00Z'], cwd);
|
|
expect(again.status, again.stderr).toBe(0);
|
|
const trendLines = () => fs.readFileSync(path.join(cwd, '.context', 'factory-scorecard', 'trend.csv'), 'utf8').split('\n').filter(Boolean).length;
|
|
expect(trendLines()).toBe(1 + 2 * Object.keys(BENCHMARKS).length);
|
|
expect(run(['--repo', 'acme/widgets', '--fixtures', FIXTURE, '--now', '2026-10-27T12:00:00Z'], cwd).status).toBe(0);
|
|
expect(trendLines()).toBe(1 + 2 * Object.keys(BENCHMARKS).length);
|
|
expect(nested.stdout).toContain(`SCORECARD_TREND: ${path.join('.context', 'factory-scorecard', 'trend.csv')}`);
|
|
});
|
|
|
|
test('bad usage exits 2 with the usage text; --help exits 0', () => {
|
|
const cwd = scratch();
|
|
expect(run(['--since-days', 'soon'], cwd).status).toBe(2);
|
|
// Value: protects=an unparsable --now exits 2 before any read; fails_when=the --now validation branch is removed; why_new=the other flag validations were covered and this one was not; seam=none; no_claim=none beyond protects
|
|
expect(run(['--now', 'yesterday-ish', '--fixtures', FIXTURE], cwd).status).toBe(2);
|
|
expect(run(['--ready-marker', '(', '--fixtures', FIXTURE], cwd).status).toBe(2);
|
|
// Value: protects=a non-numeric or negative --max-artifact-runs exits 2 instead of silently reading no artifact; fails_when=the validation is removed and Number('lots') flows into Math.min; why_new=review found this flag unvalidated; seam=none; no_claim=none beyond protects
|
|
const lots = run(['--max-artifact-runs', 'lots', '--fixtures', FIXTURE], cwd);
|
|
expect(lots.status).toBe(2);
|
|
expect(lots.stderr).toContain('--max-artifact-runs must be a non-negative integer');
|
|
expect(run(['--max-artifact-runs', '-1', '--fixtures', FIXTURE], cwd).status).toBe(2);
|
|
expect(run(['--pass-cap', '0', '--fixtures', FIXTURE], cwd).status).toBe(2);
|
|
expect(run(['--pass-marker', '[', '--fixtures', FIXTURE], cwd).status).toBe(2);
|
|
// Value: protects=without --repo and without a GitHub origin the bin refuses instead of guessing a repository; fails_when=a hard-coded fallback repository returns; why_new=red-team review found the shared helper's fallback would score the wrong repository; seam=none; no_claim=none beyond protects
|
|
const noRepo = run(['--fixtures', FIXTURE], cwd);
|
|
expect(noRepo.status).toBe(2);
|
|
expect(noRepo.stderr).toContain('--repo owner/name is required');
|
|
const local = scratch();
|
|
spawnSync('git', ['init', '-q', local]);
|
|
spawnSync('git', ['-C', local, 'remote', 'add', 'origin', '/srv/git/repo.git']);
|
|
expect(run(['--fixtures', FIXTURE], local).stderr).toContain('--repo owner/name is required');
|
|
spawnSync('git', ['-C', local, 'remote', 'set-url', 'origin', 'git@ghe.example.test:acme/widgets.git']);
|
|
const ghe = spawnSync(BIN, ['--fixtures', FIXTURE, '--now', '2026-10-26T12:00:00Z'], { cwd: local, encoding: 'utf8', timeout: 60_000, env: { ...process.env, HOME: local, GH_HOST: 'ghe.example.test' } });
|
|
expect(ghe.status, ghe.stderr).toBe(0);
|
|
expect(ghe.stdout).toContain('| Merged PRs | 3 (crew 2, outside 1) |');
|
|
expect(run(['--pass-marker', 'passes', '--fixtures', FIXTURE], cwd).stderr).toContain('--pass-marker needs a capture group');
|
|
const stray = run(['stray'], cwd);
|
|
expect(stray.status).toBe(2);
|
|
expect(stray.stderr).toContain('unexpected argument: stray');
|
|
const help = run(['--help'], cwd);
|
|
expect(help.status).toBe(0);
|
|
expect(help.stdout).toContain('gstack-factory-scorecard [--repo owner/name]');
|
|
expect(help.stdout).not.toContain(' * ');
|
|
});
|
|
|
|
test('the retro template runs the bin from the runtime root and reads its table', () => {
|
|
const tmpl = fs.readFileSync(path.join(ROOT, 'retro', 'SKILL.md.tmpl'), 'utf8');
|
|
expect(tmpl).toContain('"{{RUNTIME_ROOT}}/bin/gstack-factory-scorecard"');
|
|
expect(tmpl).toContain('### Step 7.5: Factory scorecard');
|
|
expect(fs.readFileSync(path.join(ROOT, 'retro', 'SKILL.md'), 'utf8')).toContain('$HOME/.claude/skills/gstack/bin/gstack-factory-scorecard');
|
|
});
|
|
});
|