Merge origin/main (d9b0d600, v1.91.267.0) into absorb-b-pr-body-blast-radius-prose-lint (rule 19 grant, GSTA-36; CHANGELOG via mergeChangelogs, NOTICE.md additive)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-27 05:01:43 +00:00
commit 5619199800
114 files changed
+8354 -397

No files matched your search

+10 -1
View File
@@ -62,8 +62,17 @@ import { skillCensus } from './helpers/skill-census';
* New-skill ratchet: previous ceiling 1,251 + ceil(150 / 4) = 1,289
* token-equivalents (5,156 bytes), leaving 6 bytes. Existing descriptions
* are unchanged.
* ref design-motion addition on base 6c5b2d46 (2026-10-27), Garry
* 2026-10-25 'Do it' (GSTA-92 plan item 15)
* result pre-addition aggregate 5,150 bytes; design-motion adds 99 bytes
* (name + the trimmed lead "animate an approved design into a
* seamless MP4 and GIF loop"), yielding 5,249 bytes = 1,313
* token-equivalents including the root router alias.
* New-skill ratchet: previous ceiling 1,289 + ceil(99 / 4) = 1,314
* token-equivalents (5,256 bytes), leaving 7 bytes. Existing descriptions
* are unchanged.
*/
const CATALOG_BUDGET_TOKEN_EQUIVALENTS = 1_289;
const CATALOG_BUDGET_TOKEN_EQUIVALENTS = 1_314;
// Largest today: design-consultation at 229 bytes. A description that needs
// more than 260 bytes is a body paragraph, not a catalog entry.
+52
View File
@@ -0,0 +1,52 @@
/**
* Craft resolvers (scripts/resolvers/design-craft.ts) shared by /design-review's
* craft audit and /plan-design-review.
*
* Value: protects=DESIGN_CRAFT_RULE returns a craft paragraph with its bullets and fails generation on a missing or renamed lead; fails_when=the lookup returns '' or the first paragraph instead of throwing, or drops bullets; why_new=only the freshness golden exercises the resolver, and no test reaches its throw paths; seam=none; no_claim=agent use of the rendered rules (behavioral, no paid case)
* Value: protects=design-review and plan-design-review render the same five Persuade checks; fails_when=either surface drops or hand-forks the {{DESIGN_PERSUADE_CHECKS}} block; why_new=carve-guards checks one phrase in design-review only, nothing checks plan-design-review; seam=none; no_claim=that an agent applies the checks (eval)
*/
import { describe, expect, test } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
import { DESIGN_CRAFT_RULE, DESIGN_PERSUADE_CHECKS } from '../scripts/resolvers/design-craft';
import type { TemplateContext } from '../scripts/resolvers/types';
const ROOT = path.resolve(import.meta.dir, '..');
const read = (...p: string[]) => fs.readFileSync(path.join(ROOT, ...p), 'utf-8');
const ctx = { skillName: 'design-review', tmplPath: 'design-review/sections/craft-audit.md.tmpl' } as TemplateContext;
describe('craft resolvers', () => {
test('DESIGN_CRAFT_RULE renders the craft paragraph with its bullets; a missing or unknown lead fails generation', () => {
const motion = DESIGN_CRAFT_RULE(ctx, ['Motion']);
expect(motion).toStartWith('**Motion.**');
expect(motion).toContain("Never an unthrottled `window.addEventListener('scroll')`");
expect(motion).toContain('`min-height: 100dvh`, not `100vh`.');
expect(motion).not.toContain('\n\n');
expect(DESIGN_CRAFT_RULE(ctx, ['Claims come from the user'])).toStartWith('**Claims come from the user, never from you.**');
expect(read('design-html', 'sections', 'craft.md')).toContain(motion);
expect(() => DESIGN_CRAFT_RULE(ctx, ['No such lead'])).toThrow('No such lead');
expect(() => DESIGN_CRAFT_RULE(ctx, ['Claims'])).toThrow('0 paragraphs');
expect(() => DESIGN_CRAFT_RULE(ctx, ['Mot'])).toThrow('need exactly one');
expect(() => DESIGN_CRAFT_RULE(ctx, [])).toThrow('requires the bold lead');
expect(() => DESIGN_CRAFT_RULE(ctx)).toThrow('requires the bold lead');
});
test('design-review and plan-design-review carry the same five Persuade checks', () => {
const checks = DESIGN_PERSUADE_CHECKS(ctx);
expect(checks.split('\n').map(l => l.slice(0, 3))).toEqual(['1. ', '2. ', '3. ', '4. ', '5. ']);
expect(read('design-review', 'sections', 'craft-audit.md')).toContain(checks);
expect(read('plan-design-review', 'sections', 'review-sections.md')).toContain(checks);
});
test('the design-review craft audit settles each check outcome and exception kind, and keeps the saved page private', () => {
const audit = read('design-review', 'sections', 'craft-audit.md');
expect(audit).toContain('line is one category 9 finding under that id');
expect(audit).toContain('no `CRAFT_RESULT` line (framework source');
expect(audit).toContain('or a mount-node shell: say the check was unavailable for that page and judge the tells below by eye');
expect(audit).toContain('add `--accepted` with that screen\'s finalized.json');
expect(audit).toContain('a `CRAFT_ACCEPTED` line is a finding the user accepted, listed with its reason, not filed again, except `needs-proof` and `lorem`');
expect(audit).toContain('After a `cannot use exceptions` warning, file every line.');
expect(audit).toContain('Never mark or cut a claim as a rule or fix says: defer it and ask for the proof.');
expect(audit).toContain('never copy it to the report directory');
});
});
+67 -5
View File
@@ -2,10 +2,11 @@ import { describe, expect, test } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { acceptFindings, BUZZWORDS, checkCraft, formatCraft, parseCraftExceptions, visibleText } from '../lib/design-craft';
import { catalogEntry } from '../lib/design-catalog';
import { acceptFindings, BUZZWORDS, CRAFT_IDS, checkCraft, formatCraft, parseCraftExceptions, visibleText } from '../lib/design-craft';
import { catalogEntry, DESIGN_SLOP_CATALOG, detectorSlopEntries, judgmentTellEntries, renderCatalog, selectCatalog } from '../lib/design-catalog';
import { autoFixEntries, checklistSlopEntries } from '../scripts/resolvers/design-checklist';
import { selectTests } from './helpers/test-selection';
import { E2E_TOUCHFILES } from './helpers/touchfiles-data';
import { E2E_TOUCHFILES, LLM_JUDGE_TOUCHFILES } from './helpers/touchfiles-data';
const ROOT = path.resolve(import.meta.dir, '..');
const BIN = path.join(ROOT, 'bin', 'gstack-design-craft');
@@ -33,7 +34,7 @@ describe('design-craft check', () => {
const counts = Object.fromEntries(checkCraft(DIRTY).map(f => [f.id, f.count]));
expect(counts).toEqual({
'placeholder-name': 1, 'placeholder-brand': 1, lorem: 1, 'marketing-buzzword': 2, 'round-number': 3, 'needs-proof': 1, oops: 1,
'scroll-listener': 1, 'transition-all': 1, 'dead-link': 1, alert: 1, 'img-no-alt': 1, 'vh-height': 1,
'scroll-listener': 1, 'layout-transition': 1, 'dead-link': 1, alert: 1, 'img-no-alt': 1, 'vh-height': 1,
'missing-title': 1, 'missing-meta-description': 1, 'missing-favicon': 1,
'missing-skip-link': 1, 'missing-aria-current': 1,
});
@@ -46,6 +47,33 @@ describe('design-craft check', () => {
expect(formatCraft([])).toBe('CRAFT_RESULT: clean');
});
test('every id the check prints is a grep-detectable catalog entry, and the fix it prints is the catalog fix', () => {
expect(new Set(CRAFT_IDS).size).toBe(CRAFT_IDS.length);
for (const id of CRAFT_IDS) {
const entry = catalogEntry(id);
expect(entry, id).toBeDefined();
expect(entry!.detect, id).toContain('grep');
expect(entry!.fix?.length ?? 0, id).toBeGreaterThan(0);
}
const findings = checkCraft(DIRTY);
expect(findings.map(f => f.id)).toEqual([...CRAFT_IDS]);
for (const f of findings) expect(f.fix).toBe(catalogEntry(f.id)!.fix!);
});
// Value: protects=checker-only craft entries render into no skill prose or checklist, and each is a printed craft id; fails_when=a renderer reads DESIGN_SLOP_CATALOG, not RENDERED, or filters on fix; why_new=only goldens catch it, until regenerated; seam=none; no_claim=byte size of any one skill (size guard owns it)
test('checker-only catalog entries are craft ids that no rendered list carries; catalog entries with a fix still render', () => {
const checkerOnly = DESIGN_SLOP_CATALOG.filter(e => e.checkerOnly);
expect(checkerOnly.length).toBeGreaterThan(0);
const rendered = new Set([selectCatalog({}), detectorSlopEntries(), judgmentTellEntries(), checklistSlopEntries(), autoFixEntries()].flat().map(e => e.id));
const prose = renderCatalog({ kind: 'slop' });
for (const e of checkerOnly) {
expect(CRAFT_IDS, e.id).toContain(e.id);
expect(rendered.has(e.id), e.id).toBe(false);
expect(prose, e.id).not.toContain(e.prose);
}
for (const id of ['marketing-buzzword', 'layout-transition']) expect(rendered.has(id), id).toBe(true);
});
test('BUZZWORDS covers every word the catalog marketing-buzzword heuristic names', () => {
const heuristic = catalogEntry('marketing-buzzword')?.heuristic ?? '';
const words = heuristic.replace(/^.*?for /, '').replace(/\.$/, '').split(/,\s*/);
@@ -130,6 +158,8 @@ describe('accepted craft exceptions (finalized.json craft_exceptions)', () => {
expect(accepted).toEqual([{ id: 'placeholder-brand', count: 1, reason: 'Acme is the real customer' }, { id: 'scroll-listener', count: 1, reason: 'unused' }]);
expect(issues.find(f => f.id === 'round-number')?.fix).toEndWith('(2 accepted earlier: supplied figures)');
expect(issues).toHaveLength(findings.length - 2);
expect(acceptFindings([{ id: 'needs-proof', count: 1, fix: 'f' }], [{ id: 'needs-proof', count: 1, reason: 'awaiting logo' }])).toEqual({ issues: [], accepted: [{ id: 'needs-proof', count: 1, reason: 'awaiting logo' }] });
expect(acceptFindings([{ id: 'needs-proof', count: 2, fix: 'f' }], [{ id: 'needs-proof', count: 1, reason: 'awaiting logo' }])).toEqual({ issues: [{ id: 'needs-proof', count: 2, fix: 'f (1 accepted earlier: awaiting logo)' }], accepted: [] });
expect(formatCraft([], accepted)).toBe('CRAFT_ACCEPTED: placeholder-brand 1 Acme is the real customer\nCRAFT_ACCEPTED: scroll-listener 1 unused\nCRAFT_RESULT: clean (2 accepted)');
const carried = [{ id: 'round-number', count: 1, reason: 'first run' }, { id: 'round-number', count: 3, reason: 'supplied figures' }];
for (const list of [carried, [...carried].reverse()]) {
@@ -143,6 +173,9 @@ describe('accepted craft exceptions (finalized.json craft_exceptions)', () => {
const record = JSON.parse(block.replace(/<number of refinement iterations>|<n>/g, '1'));
expect(parseCraftExceptions(JSON.stringify(record))).toEqual([{ id: '<craft id>', count: 1, reason: '<why accepted>' }]);
expect(parseCraftExceptions('{"mode":"freeform"}')).toEqual([]);
const legacy = parseCraftExceptions('{"craft_exceptions":[{"id":"transition-all","count":1,"reason":"recorded before the rename"}]}');
expect(legacy).toEqual([{ id: 'layout-transition', count: 1, reason: 'recorded before the rename' }]);
expect(acceptFindings(checkCraft('<style>a { transition: all .2s }</style>'), legacy).accepted).toEqual([{ id: 'layout-transition', count: 1, reason: 'recorded before the rename' }]);
expect(parseCraftExceptions('{"craft_exceptions":[{"id":"oops","count":1,"reason":"quoted\\nfrom the user"}]}')).toEqual([{ id: 'oops', count: 1, reason: 'quoted from the user' }]);
for (const bad of ['[]', 'null', '{"craft_exceptions":{}}', '{"craft_exceptions":[{"id":"oops","count":0,"reason":"x"}]}', '{"craft_exceptions":[{"id":"oops","count":1,"reason":" "}]}', '{"craft_exceptions":[{"id":"oops","count":1.5,"reason":"x"}]}']) {
expect(() => parseCraftExceptions(bad)).toThrow();
@@ -175,6 +208,7 @@ describe('accepted craft exceptions (finalized.json craft_exceptions)', () => {
const bad = run(page, '--accepted', path.join(dir, name));
expect(bad).toMatchObject({ code: 2, out: run(page).out });
expect(bad.err).toContain('checked with none accepted');
expect(bad.err).toContain('cannot use exceptions');
}
fs.writeFileSync(page, CLEAN);
expect(run(page, '--accepted', path.join(dir, 'broken.json'))).toMatchObject({ code: 2, out: 'CRAFT_RESULT: clean\n' });
@@ -185,6 +219,27 @@ describe('accepted craft exceptions (finalized.json craft_exceptions)', () => {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('an exception the page no longer triggers prints nothing, and a record is read only with --accepted', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'design-craft-cells-'));
try {
const page = path.join(dir, 'finalized.html');
const record = path.join(dir, 'finalized.json');
fs.writeFileSync(page, CLEAN);
fs.writeFileSync(record, JSON.stringify({ craft_exceptions: [{ id: 'placeholder-brand', count: 1, reason: 'Acme is the customer' }] }));
expect(run(page, '--accepted', record)).toMatchObject({ code: 0, out: 'CRAFT_RESULT: clean\n' });
expect(run(page)).toMatchObject({ code: 0, out: 'CRAFT_RESULT: clean\n', err: '' });
expect(acceptFindings([], [{ id: 'oops', count: 1, reason: 'x' }])).toEqual({ issues: [], accepted: [] });
expect(acceptFindings([], [{ id: 'lorem', count: 1, reason: 'draft copy' }])).toEqual({ issues: [], accepted: [] });
fs.writeFileSync(page, CLEAN.replace('Halvorsen Freight', 'Acme and Nexus'));
expect(run(page)).toMatchObject({ code: 1, out: 'CRAFT_CHECK: placeholder-brand 2 use a supplied customer, or a fitting name in sample data\nCRAFT_RESULT: 1 issue(s)\n' });
fs.writeFileSync(page, CLEAN);
fs.writeFileSync(record, '{');
expect(run(page)).toMatchObject({ code: 0, out: 'CRAFT_RESULT: clean\n', err: '' });
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});
describe('design-shotgun to design-html handoff', () => {
@@ -206,11 +261,18 @@ describe('design-shotgun to design-html handoff', () => {
});
test('a change to the craft checker selects the design-html-slop-gate E2E that runs it', () => {
for (const file of ['lib/design-craft.ts', 'bin/gstack-design-craft']) {
for (const file of ['lib/design-craft.ts', 'lib/design-catalog.ts', 'bin/gstack-design-craft']) {
expect(selectTests([file], E2E_TOUCHFILES).selected).toContain('design-html-slop-gate');
}
});
// Value: protects=a change to the shared craft resolver reruns the paid cases whose prompts it renders; fails_when=scripts/resolvers/design-craft.ts drops from those touchfile lists; why_new=the row above covers lib/design-craft.ts and the bin only; seam=none; no_claim=that the selected cases pass
test('a change to the craft resolvers selects the design-review and plan-design-review cases that render them', () => {
const e2e = selectTests(['scripts/resolvers/design-craft.ts'], E2E_TOUCHFILES).selected;
for (const name of ['design-review-fix', 'design-review-detector-shim', 'design-review-detector-shim-dom', 'design-review-plugin-handoff', 'plan-design-review-plan-mode']) expect(e2e).toContain(name);
expect(selectTests(['scripts/resolvers/design-craft.ts'], LLM_JUDGE_TOUCHFILES).selected).toContain('plan-design-review/SKILL.md passes');
});
test('design-html persists accepted exceptions and passes them back with --accepted', () => {
const html = read('design-html', 'SKILL.md');
const craft = read('design-html', 'sections', 'craft.md');
+4 -4
View File
@@ -17,6 +17,7 @@ import { collectFiles, contextWaivers, lintStatic, MAX_FILE_BYTES } from '../lib
import type { LintContext } from '../lib/design-lint/context';
import { browserAvailable } from './helpers/browser-available';
import { RULES } from '../lib/design-lint/rules/ids';
import { CRAFT_IDS } from '../lib/design-craft';
import { FAMILIES } from '../lib/design-lint/rules';
import { findWaivers, reasonIsBoilerplate, resolveWaivers, applyWaivers, suspectedRules } from '../lib/design-lint/waivers';
@@ -398,11 +399,10 @@ describe('design-lint: third review cycle', () => {
});
test('every id gstack-design-craft can emit has a lint rule or belongs to the static tier', () => {
const src = fs.readFileSync(path.join(ROOT, 'lib', 'design-craft.ts'), 'utf8');
const ids = [...new Set([...src.matchAll(/id: '([a-z-]+)'/g)].map((m) => m[1]))];
const ids = [...CRAFT_IDS];
expect(ids.length).toBeGreaterThan(15);
expect(ids.filter((id) => !(id in RULES) && id !== 'transition-all')).toEqual([]);
expect(ids.filter((id) => id in RULES && RULES[id].gate !== 'craft')).toEqual(['vh-height']);
expect(ids.filter((id) => !(id in RULES))).toEqual([]);
expect(ids.filter((id) => RULES[id].gate !== 'craft')).toEqual(['layout-transition', 'vh-height']);
});
});
+3 -3
View File
@@ -1,5 +1,5 @@
/**
* C4 (#2777): ship, plan-ceo-review and office-hours are carved on every host, and no
* C4 (#2777): ship, plan-ceo-review, office-hours and design-review are carved on every host, and no
* generated SKILL.md exceeds 160,000 UTF-8 bytes on any host or model overlay
* setup can select. Carved sections on external hosts are pointed at relative
* to the installed skill directory: `$GSTACK_ROOT/<skill>/sections/...` does
@@ -65,10 +65,10 @@ describe('C4: external section pointers', () => {
});
});
describe('C4: ship, plan-ceo-review and office-hours are carved on every external host', () => {
describe('C4: ship, plan-ceo-review, office-hours and design-review are carved on every external host', () => {
for (const config of EXTERNAL) {
test(`${config.name}: every pointer in the carved skill resolves to a generated section file`, () => {
for (const skill of ['ship', 'plan-ceo-review', 'office-hours']) {
for (const skill of ['ship', 'plan-ceo-review', 'office-hours', 'design-review']) {
expect(usesLazySections(config.name, skill)).toBe(true);
const dir = skillDir(config, renders.all, skill);
const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, skill, 'sections', 'manifest.json'), 'utf8')) as { sections: Array<{ file: string }> };
+1
View File
@@ -17,6 +17,7 @@
"cso": 4924,
"design-consultation": 16188,
"design-html": 14380,
"design-motion": 10409,
"design-review": 35808,
"design-shotgun": 14901,
"deslop-shared-libs": 3411,
+40
View File
@@ -0,0 +1,40 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Ledgerline weekly summary</title>
<style>
html, body { margin: 0; background: #f4f1ea; }
.frame { position: relative; width: 640px; height: 400px; font: 500 15px/1.35 Georgia, serif; color: #1f1c17; }
.card { position: absolute; top: 48px; width: 272px; height: 304px; border-radius: 16px; background: #fffdf8;
box-shadow: 0 8px 20px rgba(31, 28, 23, 0.08); overflow: hidden; }
.revenue { left: 32px; }
.queue { left: 336px; }
h2 { margin: 22px 22px 4px; font-size: 15px; font-weight: 600; }
.value { margin: 0 22px; font-size: 30px; font-variant-numeric: tabular-nums; }
.bars { position: absolute; left: 22px; right: 22px; bottom: 26px; height: 120px; display: flex; align-items: flex-end; gap: 10px; }
.bars span { flex: 1; border-radius: 4px 4px 0 0; background: #3f6b5c; }
.rows { margin: 18px 22px 0; padding: 0; list-style: none; }
.rows li { display: flex; justify-content: space-between; padding: 11px 0; border-top: 1px solid #ece6da; }
.rows b { font-weight: 600; }
</style>
</head>
<body>
<main class="frame">
<section class="card revenue">
<h2>Collected this week</h2>
<p class="value">$48,120</p>
<div class="bars"><span style="height:42%"></span><span style="height:61%"></span><span style="height:55%"></span><span style="height:78%"></span><span style="height:70%"></span><span style="height:92%"></span></div>
</section>
<section class="card queue">
<h2>Invoices due</h2>
<ul class="rows">
<li><span>Harbor Dental</span><b>$2,340</b></li>
<li><span>Pinecrest Studio</span><b>$1,180</b></li>
<li><span>Okafor &amp; Lane</span><b>$4,905</b></li>
<li><span>Mill Street Bakery</span><b>$612</b></li>
</ul>
</section>
</main>
</body>
</html>
+3 -1
View File
@@ -159,7 +159,9 @@
{ "file": "lib/ship-queue-train-cli.ts", "movedFrom": "(new: merge-train argument parsing and output, plan GSTA-49 T1)" },
{ "file": "lib/gate-cost.ts", "movedFrom": "(new: paid gate cost by model and token class, per-case costs, selection replays and the merge-train model, GSTA-49 T0)" },
{ "file": "scripts/gate-cost-replay.ts", "movedFrom": "(new: CLI behind lib/gate-cost.ts: cached report artifacts, merged-PR replay and the train model table, GSTA-49 T0)" },
{ "file": "lib/gate-cost-history.ts", "movedFrom": "(new: red attribution, red-head screen replay, READY times and the READY-arrival train replay, GSTA-49 T0b)" }
{ "file": "lib/gate-cost-history.ts", "movedFrom": "(new: red attribution, red-head screen replay, READY times and the READY-arrival train replay, GSTA-49 T0b)" },
{ "file": "lib/pr-wait.ts", "movedFrom": "(new: gstack-pr-wait event logic, GSTA-115 item 1; design ported from tstack pr-wait)" },
{ "file": "lib/pr-wait-github.ts", "movedFrom": "(new: gstack-pr-wait gh reads, GSTA-115 item 1; design ported from tstack pr-wait)" }
],
"residualFiles": [
{ "file": "scripts/test-free-shards.ts", "maxLines": 2361, "reason": "free lane policy after the shard engine took spawn, sandbox, logs, seeds and flags" },
+23 -23
View File
@@ -4,90 +4,90 @@
"pins": {
"agy/gstack-autoplan": 130472,
"agy/gstack-design-consultation": 108152,
"agy/gstack-design-review": 143496,
"agy/gstack-design-review": 144104,
"agy/gstack-land-and-deploy": 117690,
"agy/gstack-plan-design-review": 139981,
"agy/gstack-plan-design-review": 141063,
"agy/gstack-plan-devex-review": 135728,
"agy/gstack-plan-eng-review": 152574,
"agy/gstack-review": 124400,
"claude/design-review": 136499,
"claude/design-review": 137077,
"codex/gstack-autoplan": 130596,
"codex/gstack-design-consultation": 109420,
"codex/gstack-design-review": 144996,
"codex/gstack-design-review": 145604,
"codex/gstack-land-and-deploy": 122240,
"codex/gstack-office-hours": 102276,
"codex/gstack-plan-design-review": 142434,
"codex/gstack-plan-design-review": 143516,
"codex/gstack-plan-devex-review": 138617,
"codex/gstack-plan-eng-review": 155120,
"codex/gstack-review": 125304,
"copilot/gstack-autoplan": 131605,
"copilot/gstack-codex": 100203,
"copilot/gstack-design-consultation": 109504,
"copilot/gstack-design-review": 144801,
"copilot/gstack-design-review": 145409,
"copilot/gstack-land-and-deploy": 118945,
"copilot/gstack-office-hours": 100432,
"copilot/gstack-plan-design-review": 140852,
"copilot/gstack-plan-design-review": 141934,
"copilot/gstack-plan-devex-review": 136880,
"copilot/gstack-plan-eng-review": 153299,
"copilot/gstack-review": 125671,
"cursor/gstack-autoplan": 129246,
"cursor/gstack-design-consultation": 107190,
"cursor/gstack-design-review": 142475,
"cursor/gstack-design-review": 143083,
"cursor/gstack-land-and-deploy": 116613,
"cursor/gstack-plan-design-review": 138936,
"cursor/gstack-plan-design-review": 140018,
"cursor/gstack-plan-devex-review": 134559,
"cursor/gstack-plan-eng-review": 151428,
"cursor/gstack-review": 142320,
"factory/gstack-autoplan": 131530,
"factory/gstack-codex": 100623,
"factory/gstack-design-consultation": 108638,
"factory/gstack-design-review": 144265,
"factory/gstack-design-review": 144873,
"factory/gstack-land-and-deploy": 120785,
"factory/gstack-office-hours": 101311,
"factory/gstack-plan-design-review": 141717,
"factory/gstack-plan-design-review": 142799,
"factory/gstack-plan-devex-review": 137928,
"factory/gstack-plan-eng-review": 154542,
"factory/gstack-review": 146099,
"gbrain/gstack-autoplan": 129783,
"gbrain/gstack-design-review": 132810,
"gbrain/gstack-design-review": 133418,
"gbrain/gstack-land-and-deploy": 117135,
"gbrain/gstack-plan-design-review": 127823,
"gbrain/gstack-plan-design-review": 128905,
"gbrain/gstack-plan-devex-review": 115097,
"gbrain/gstack-plan-eng-review": 134160,
"hermes/gstack-autoplan": 129873,
"hermes/gstack-design-review": 132917,
"hermes/gstack-design-review": 133525,
"hermes/gstack-land-and-deploy": 117237,
"hermes/gstack-plan-design-review": 127932,
"hermes/gstack-plan-design-review": 129014,
"hermes/gstack-plan-devex-review": 115183,
"hermes/gstack-plan-eng-review": 134245,
"kiro/gstack-autoplan": 129008,
"kiro/gstack-design-consultation": 107032,
"kiro/gstack-design-review": 142289,
"kiro/gstack-design-review": 142897,
"kiro/gstack-land-and-deploy": 116501,
"kiro/gstack-plan-design-review": 138776,
"kiro/gstack-plan-design-review": 139858,
"kiro/gstack-plan-devex-review": 134399,
"kiro/gstack-plan-eng-review": 151278,
"kiro/gstack-review": 142132,
"openclaw/gstack-autoplan": 129488,
"openclaw/gstack-design-review": 131615,
"openclaw/gstack-design-review": 132223,
"openclaw/gstack-land-and-deploy": 116725,
"openclaw/gstack-plan-design-review": 126674,
"openclaw/gstack-plan-design-review": 127756,
"openclaw/gstack-plan-devex-review": 113936,
"openclaw/gstack-plan-eng-review": 132557,
"opencode/gstack-autoplan": 131608,
"opencode/gstack-design-consultation": 109325,
"opencode/gstack-design-review": 144673,
"opencode/gstack-design-review": 145281,
"opencode/gstack-land-and-deploy": 118730,
"opencode/gstack-office-hours": 100266,
"opencode/gstack-plan-design-review": 140706,
"opencode/gstack-plan-design-review": 141788,
"opencode/gstack-plan-devex-review": 136780,
"opencode/gstack-plan-eng-review": 153180,
"opencode/gstack-review": 144555,
"slate/gstack-autoplan": 129127,
"slate/gstack-design-consultation": 107111,
"slate/gstack-design-review": 142382,
"slate/gstack-design-review": 142990,
"slate/gstack-land-and-deploy": 116557,
"slate/gstack-plan-design-review": 138856,
"slate/gstack-plan-design-review": 139938,
"slate/gstack-plan-devex-review": 134479,
"slate/gstack-plan-eng-review": 151353,
"slate/gstack-review": 142226
+241 -1
View File
@@ -7,7 +7,7 @@
* the real executable against stub gh/glab on POSIX.
*/
import { describe, expect, test } from "bun:test";
import { EXIT, runPost, type PostEnv } from "../lib/gstack-post";
import { EXIT, mergeBodySections, runPost, type PostEnv } from "../lib/gstack-post";
interface Call { cmd: string; args: string[]; input?: string }
@@ -226,6 +226,47 @@ describe("gstack-post owns the redaction scan", () => {
}
});
// Value: protects=an 11-digit GitHub Actions run id written after a run word (run, screen, gate, backstop, bisect, rerun, dispatched, failed, passed,
// with an optional ':' '#' '='), or chained after one with ",", "and", "&", "or" or "/" (a "(#1234)" between allowed), posts without a pii.phone.e164
// confirmation, while a generic word (id, job, attempt, workflow) exempts nothing; fails_when=real receipts ("failed 40269992077 | rerun passed
// 40287239206", "screen 40269992077 (#3434) and 40275443393 (#3453)", "gate 40323989742") need --confirm, "customer id 14155551234" or "job 40323989748"
// posts unconfirmed, or a bare 11-digit number, a 10-digit national number or an E.164 number stops reporting;
// why_new=the receipt-token test allowlists only "run <id>" and the /actions/runs/ URL; seam=none; no_claim=none beyond protects
test("run ids next to run words, and the ids chained after them, are not phone numbers; a bare id, a national number and an E.164 number still are", () => {
const clean = [
"failed 40269992077 | rerun passed 40287239206",
"screen 40269992077 (#3434) and 40275443393 (#3453)",
"gate 40323989742",
"gates: 40323989742, 40323989743 or 40323989744",
"backstop=40323989742 bisect #40323989745",
"dispatched 40323989742 / 40323989746 & 40323989747",
"runs 40323989742 and 40323989753 (#3454), 40323989754",
];
for (const body of clean) {
const f = fakeEnv({ files: { "/b": body + "\n" }, visibility: "public" });
expect([body, run(["pr-comment", "12", "--body-file", "/b"], f)]).toEqual([body, EXIT.posted]);
expect(f.posts()).toHaveLength(1);
}
const controls = [
"the id is 19368712345",
"customer id 14155551234",
"job 40323989748",
"workflow 40323989742; attempt 40323989749; ids 40323989751, 40323989752",
"40269992077 failed",
"gate 40323989742. Then 40323989743 on its own",
"gate 4155551234",
"call +14155551234 for the on-call",
"run +14155551234",
"see 40323989742, 40323989743",
];
for (const body of controls) {
const f = fakeEnv({ files: { "/b": body + "\n" }, visibility: "public" });
expect([body, run(["pr-comment", "12", "--body-file", "/b"], f)]).toEqual([body, EXIT.confirm]);
expect(f.stdout()).toContain("RULE: pii.phone.e164 LINE: 1 PART: body");
expect(f.posts()).toEqual([]);
}
});
test("the title is scanned too, and the pusher's own email is not a finding", () => {
const f = fakeEnv({ files: { "/t": "v1 fix for jane.roe@acme-corp.io", "/b": "body" } });
expect(run(["pr-create", "--base", "main", "--title-file", "/t", "--body-file", "/b"], f)).toBe(EXIT.confirm);
@@ -263,3 +304,202 @@ describe("gstack-post reports CLI failures and repairs known edit failures", ()
expect(f.posts()[0]!.args.slice(0, 3)).toEqual(["pr", "comment", "3"]);
});
});
describe("gstack-post pr-body merges the PR body by section", () => {
const RECEIPT = (head: string) => "```gstack-ship-receipt\n{\n \"schema_version\": 1,\n \"pr\": \"5\",\n \"head\": \"" + head + "\"\n}\n```\n";
const CURRENT = RECEIPT("aaaa") + "\n## What changed\n\n- the old summary\n\n## Why\n\nBecause.\n\n## Approvals\n\n- owner: do it\n";
const withBody = (current: string, files: Record<string, string>) => fakeEnv({
files,
respond: c => (c.cmd === "gh" && c.args[1] === "view" && c.args.includes("body") ? { status: 0, stdout: current + "\n" } : undefined),
});
const sent = (f: ReturnType<typeof fakeEnv>) => f.posts().find(c => c.args[1] === "edit")!;
// Value: protects=a pr-body post holding one section replaces only that section: same-titled sections (case-insensitively) are replaced in place, the
// receipt block is its own section, sections the new body lacks are kept, new sections are appended, and one stderr line names each group;
// fails_when=pr-body overwrites the whole body (a "## What changed"-only post wipes Why, Approvals and the receipt), the receipt is folded into the
// section before it, or the merge changes the kept sections' bytes; why_new=pr-body was a plain overwrite; seam=the fake run answers gh pr view --json body;
// no_claim=real gh (unit through runPost)
test("a first-section-only body keeps the other sections and the receipt; a same-titled section is replaced in place; new sections are appended", () => {
const f = withBody(CURRENT, { "/b": "## What Changed\n\n- the new summary\n" });
expect(run(["pr-body", "5", "--body-file", "/b"], f)).toBe(EXIT.posted);
expect(sent(f).input).toBe(RECEIPT("aaaa") + "\n## What Changed\n\n- the new summary\n\n## Why\n\nBecause.\n\n## Approvals\n\n- owner: do it\n");
expect(f.stderr()).toBe("gstack-post: pr-body merged by section: replaced What changed; kept ship receipt, Why, Approvals; added none\n");
expect(f.calls.find(c => c.cmd === "gh" && c.args[1] === "view")!.args).toEqual(["pr", "view", "5", "--json", "body", "--jq", ".body"]);
const receipt = withBody(CURRENT, { "/b": RECEIPT("bbbb") });
expect(run(["pr-body", "5", "--body-file", "/b"], receipt)).toBe(EXIT.posted);
expect(sent(receipt).input).toBe(CURRENT.replace(RECEIPT("aaaa"), RECEIPT("bbbb")));
expect(receipt.stderr()).toBe("gstack-post: pr-body merged by section: replaced ship receipt; kept What changed, Why, Approvals; added none\n");
const added = withBody(CURRENT, { "/b": "## Why\n\nStill because.\n\n## Tests\n\n- one\n" });
expect(run(["pr-body", "5", "--body-file", "/b"], added)).toBe(EXIT.posted);
expect(sent(added).input).toBe(RECEIPT("aaaa") + "\n## What changed\n\n- the old summary\n\n## Why\n\nStill because.\n\n## Approvals\n\n- owner: do it\n\n## Tests\n\n- one\n");
expect(added.stderr()).toBe("gstack-post: pr-body merged by section: replaced Why; kept ship receipt, What changed, Approvals; added Tests\n");
const fenced = withBody("## Notes\n\n```md\n## not a heading\n```\n\n## Why\n\nold\n", { "/b": "## Why\n\nnew\n" });
expect(run(["pr-body", "5", "--body-file", "/b"], fenced)).toBe(EXIT.posted);
expect(sent(fenced).input).toBe("## Notes\n\n```md\n## not a heading\n```\n\n## Why\n\nnew\n");
});
// Value: protects=a fence closes only on a run of its own character at least as long as the opener with nothing after it, so a ``` line inside a
// ```` block, a ```js line and a ~~~ line close nothing and the headings inside stay kept bytes, and a receipt fence that never closes is an unclosed
// fence rather than a receipt that swallows the sections after it; fails_when=a longer or info-string fence closes early and a heading inside the
// block is replaced (dropping the block's own closing fence and the sections after it), or an unclosed receipt fence loses ## Approvals;
// why_new=the fenced case above covers only a three-backtick block closed by its own fence; seam=fake run; no_claim=real gh
test("longer fences, info-string closers and an unclosed receipt fence never expose a heading inside them to the merge", () => {
const long = "## Notes\n\n````md\n```\n## Tests\nfake\n```\n````\n\n## Tests\n\nold\n";
const four = withBody(long, { "/b": "## Tests\n\nnew\n" });
expect(run(["pr-body", "5", "--body-file", "/b"], four)).toBe(EXIT.posted);
expect(sent(four).input).toBe("## Notes\n\n````md\n```\n## Tests\nfake\n```\n````\n\n## Tests\n\nnew\n");
expect(four.stderr()).toBe("gstack-post: pr-body merged by section: replaced Tests; kept Notes; added none\n");
const info = withBody("## Notes\n\n```sh\n```js\n## Tests\n~~~\n```\n\n## Tests\n\nold\n", { "/b": "## Tests\n\nnew\n" });
expect(run(["pr-body", "5", "--body-file", "/b"], info)).toBe(EXIT.posted);
expect(sent(info).input).toBe("## Notes\n\n```sh\n```js\n## Tests\n~~~\n```\n\n## Tests\n\nnew\n");
const unclosed = "```gstack-ship-receipt\nSHIP_RECEIPT: pr=5 head=aaaa\n\n## Summary\n\nold\n\n## Approvals\n\n- owner: do it\n";
const open = withBody(unclosed, { "/b": RECEIPT("bbbb") + "\n## Summary\n\nnew\n" });
expect(run(["pr-body", "5", "--body-file", "/b"], open)).toBe(EXIT.posted);
expect(sent(open).input).toBe(RECEIPT("bbbb") + "\n" + unclosed + "\n## Summary\n\nnew\n");
expect(open.stderr()).toBe("gstack-post: pr-body merged by section: replaced none; kept unclosed receipt fence; added ship receipt, Summary\n");
const prose = withBody(unclosed, { "/b": "Updated introduction\n\n## Summary\n\nnew\n" });
expect(run(["pr-body", "5", "--body-file", "/b"], prose)).toBe(EXIT.posted);
expect(sent(prose).input).toBe("Updated introduction\n\n" + unclosed + "\n## Summary\n\nnew\n");
const inside = withBody("Intro\n\n## Notes\n\n" + unclosed, { "/b": "New intro\n\n## Notes\n\nnew notes\n\n## Approvals\n\n- owner: still\n" });
expect(run(["pr-body", "5", "--body-file", "/b"], inside)).toBe(EXIT.posted);
expect(sent(inside).input).toBe("New intro\n\n## Notes\n\nnew notes\n\n" + unclosed + "\n## Approvals\n\n- owner: still\n");
expect(inside.stderr()).toBe("gstack-post: pr-body merged by section: replaced leading text, Notes; kept unclosed receipt fence; added Approvals\n");
const after = withBody(RECEIPT("aaaa") + "\n## Why\n\nold\n\n" + unclosed, { "/b": RECEIPT("bbbb") + "\n## Why\n\nnew\n" });
expect(run(["pr-body", "5", "--body-file", "/b"], after)).toBe(EXIT.posted);
expect(sent(after).input).toBe(RECEIPT("bbbb") + "\n## Why\n\nnew\n\n" + unclosed);
expect(after.stderr()).toBe("gstack-post: pr-body merged by section: replaced ship receipt, Why; kept unclosed receipt fence; added none\n");
});
// Value: protects=--replace keeps the plain overwrite for a caller that composed the whole body, a current body with no H2 headings is kept as the leading
// section, an empty current body posts the new body byte for byte, the scan runs on the merged bytes, and an unreadable current body is exit 3 with
// the --replace hint rather than an overwrite; fails_when=--replace merges, headingless text is dropped, the merge changes a lone new body, a secret in a
// kept section passes the scan, or a failed read overwrites; why_new=first pr-body merge tests; seam=fake run; no_claim=real gh
test("--replace overwrites; a body without H2 headings is kept as a leading section; the merged bytes are what is scanned; a failed read refuses", () => {
const replaced = withBody(CURRENT, { "/b": "## What changed\n\nonly this\n" });
expect(run(["pr-body", "5", "--replace", "--body-file", "/b"], replaced)).toBe(EXIT.posted);
expect(sent(replaced).input).toBe("## What changed\n\nonly this\n");
expect(replaced.stderr()).toBe("");
expect(replaced.calls.some(c => c.cmd === "gh" && c.args[1] === "view")).toBe(false);
const plain = withBody("Just a paragraph.\nAnother line.", { "/b": "## Tests\n\n- one\n" });
expect(run(["pr-body", "5", "--body-file", "/b"], plain)).toBe(EXIT.posted);
expect(sent(plain).input).toBe("Just a paragraph.\nAnother line.\n\n## Tests\n\n- one\n");
expect(plain.stderr()).toBe("gstack-post: pr-body merged by section: replaced none; kept leading text; added Tests\n");
const empty = withBody("", { "/b": "new body" });
expect(run(["pr-body", "6", "--body-file", "/b"], empty)).toBe(EXIT.posted);
expect(sent(empty).input).toBe("new body");
const secret = fakeEnv({ files: { "/b": "## What changed\n\nclean\n" }, respond: c => (c.cmd === "gh" && c.args[1] === "view" ? { status: 0, stdout: "## Keys\n\n" + AWS + "\n" } : undefined) });
expect(run(["pr-body", "5", "--body-file", "/b"], secret)).toBe(EXIT.refused);
expect(secret.stdout()).toContain("HIGH RULE: ");
expect(secret.posts().filter(c => c.args[1] === "edit")).toEqual([]);
const unreadable = fakeEnv({ files: { "/b": "## What changed\n\nx\n" }, respond: c => (c.cmd === "gh" && c.args[1] === "view" ? { status: 1, stderr: "GraphQL: Could not resolve to a PullRequest" } : undefined) });
expect(run(["pr-body", "5", "--body-file", "/b"], unreadable)).toBe(EXIT.cliFailed);
expect(unreadable.stderr()).toContain("cannot read PR 5's current body");
expect(unreadable.stderr()).toContain("--replace");
expect(unreadable.posts()).toEqual([]);
expect(run(["pr-comment", "5", "--replace", "--body-file", "/b"], fakeEnv({ files: { "/b": "x" } }))).toBe(EXIT.usage);
});
// Value: protects=on GitLab the merge reads the MR's description from `glab mr view <n> --output json`, posts the merged bytes through
// `glab mr update --description=`, treats an empty answer as an empty body (the file is posted unchanged), and a non-JSON answer is exit 3 with the
// --replace hint and nothing posted; fails_when=the GitLab merge reads the wrong field or command, overwrites the MR on a failed read, or refuses an MR
// whose description is empty; why_new=the merge tests above run against gh only; seam=fake run answers glab mr view; no_claim=real glab
test("on GitLab the current description comes from glab mr view JSON; an empty answer is an empty body; a non-JSON answer refuses", () => {
const remote = "https://gitlab.com/grp/sub/proj.git";
const view = (stdout: string) => (c: Call) => (c.cmd === "glab" && c.args[0] === "mr" && c.args[1] === "view" ? { status: 0, stdout } : undefined);
const merged = fakeEnv({ remote, files: { "/b": "## Why\n\nnew\n" }, respond: view(JSON.stringify({ iid: 8, description: "## What changed\n\n- old\n\n## Why\n\nold\n" }) + "\n") });
expect(run(["pr-body", "8", "--body-file", "/b"], merged)).toBe(EXIT.posted);
expect(merged.calls.find(c => c.cmd === "glab" && c.args[1] === "view")!.args).toEqual(["mr", "view", "8", "--output", "json"]);
expect(merged.posts()).toEqual([{ cmd: "glab", args: ["mr", "update", "8", "--description=## What changed\n\n- old\n\n## Why\n\nnew\n"], input: undefined }]);
expect(merged.stderr()).toBe("gstack-post: pr-body merged by section: replaced Why; kept What changed; added none\n");
const empty = fakeEnv({ remote, files: { "/b": "## Why\n\nnew\n" }, respond: view("") });
expect(run(["pr-body", "8", "--body-file", "/b"], empty)).toBe(EXIT.posted);
expect(empty.posts()[0]!.args).toEqual(["mr", "update", "8", "--description=## Why\n\nnew\n"]);
const nullDescription = fakeEnv({ remote, files: { "/b": "## Why\n\nnew\n" }, respond: view('{"iid":8,"description":null}\n') });
expect(run(["pr-body", "8", "--body-file", "/b"], nullDescription)).toBe(EXIT.posted);
expect(nullDescription.posts()[0]!.args).toEqual(["mr", "update", "8", "--description=## Why\n\nnew\n"]);
const garbled = fakeEnv({ remote, files: { "/b": "## Why\n\nnew\n" }, respond: view("<html>502</html>\n") });
expect(run(["pr-body", "8", "--body-file", "/b"], garbled)).toBe(EXIT.cliFailed);
expect(garbled.stderr()).toContain("cannot read PR 8's current body to merge into (glab returned no JSON)");
expect(garbled.stderr()).toContain("--replace");
expect(garbled.posts()).toEqual([]);
});
// Value: protects=section matching survives the bodies GitHub actually returns and the shapes /ship composes: a CRLF current body (web-edited) keeps
// its kept section's bytes and still replaces the same-titled section, a receipt new to a body without one goes first (where gstack-ship-receipt puts
// it), a current body with two same-titled sections replaces the first and keeps the second, and a current body with no trailing newline still gets a
// blank-line seam before an appended section; fails_when=a CRLF title no longer matches an LF one (the section is appended as a duplicate), a kept
// CRLF section is rewritten, a new receipt lands after the sections, the second duplicate is dropped, or an appended section is glued to the last line;
// why_new=the merge tests above use LF bodies with unique titles and a receipt that already exists; seam=none (mergeBodySections); no_claim=rendering
test("a CRLF current body, a receipt new to the body, duplicate titles and a body without a trailing newline merge by section", () => {
const crlfKept = "## What changed\r\n\r\n- old\r\n\r\n";
const crlf = mergeBodySections(crlfKept + "## Why\r\n\r\nBecause.\r\n", "## why\n\nnew\n");
expect([crlf.replaced, crlf.kept, crlf.added]).toEqual([["Why"], ["What changed"], []]);
expect(crlf.body.startsWith(crlfKept)).toBe(true);
expect(crlf.body.endsWith("\n## why\n\nnew\n")).toBe(true);
expect(crlf.body).not.toContain("Because.");
expect(crlf.body.match(/^ {0,3}## /gm)).toHaveLength(2);
const receipt = "```gstack-ship-receipt\n{\n \"pr\": \"5\"\n}\n```\n";
const fresh = mergeBodySections("## A\n\na\n", receipt + "## A\n\nb\n");
expect(fresh.body).toBe(receipt + "\n## A\n\nb\n");
expect([fresh.replaced, fresh.kept, fresh.added]).toEqual([["A"], [], ["ship receipt"]]);
const twice = mergeBodySections("## A\na\n## A\na2\n", "## A\nnew\n");
expect(twice.body).toBe("## A\nnew\n\n## A\na2\n");
expect([twice.replaced, twice.kept]).toEqual([["A"], ["A"]]);
const glued = mergeBodySections("## A\n\na", "## B\n\nb");
expect(glued.body).toBe("## A\n\na\n\n## B\n\nb");
expect(mergeBodySections("## A\n\na\n\n", "## B\n\nb\n").body).toBe("## A\n\na\n\n## B\n\nb\n");
});
// Value: protects=the body /ship composes (lib/ship-receipt.ts placeReceiptFirst: the receipt block, one blank line, then the sections) reposts
// idempotently: merged into a PR body of the same shape it yields the file's bytes, the receipt stays byte-first, and the stderr line reports no
// addition; fails_when=the blank line after the receipt is read as new leading text and prepended before the receipt (one more leading newline per
// repost, "added leading text" on every /ship re-READY), or a kept blank line is counted as added; why_new=the merge tests above post a receipt-only
// file or a file without a receipt, never the composed shape; seam=none (mergeBodySections); no_claim=the gh round trip
test("the composed receipt-then-sections body reposts byte for byte, receipt first, with nothing reported as added", () => {
const receipt = (head: string) => "```gstack-ship-receipt\n{\n \"head\": \"" + head + "\"\n}\n```\n";
const composed = (head: string, summary: string) => receipt(head) + "\n## Summary\n\n" + summary + "\n\n## Test plan\n\n- [x] bun test\n";
const first = mergeBodySections(composed("aaaa", "old"), composed("bbbb", "new"));
expect(first.body).toBe(composed("bbbb", "new"));
expect([first.replaced, first.kept, first.added]).toEqual([["ship receipt", "Summary", "Test plan"], [], []]);
const again = mergeBodySections(first.body, composed("bbbb", "new"));
expect(again.body).toBe(composed("bbbb", "new"));
expect(again.added).toEqual([]);
});
// Value: protects=leading prose in the file replaces the current body's leading prose instead of being prepended again, so a repost of a file that
// opens with text keeps one copy; fails_when=text sections are never matched and the file's leading text is treated as fresh on every post;
// why_new=the merge tests above post files that open with a heading or a receipt, never with prose; seam=none (mergeBodySections); no_claim=the gh round trip
test("leading prose in the file replaces the current leading prose and reposts once", () => {
const current = "Intro line\n\n## Summary\n\nold\n\n## Approvals\n\nx\n";
const once = mergeBodySections(current, "Intro line\n\n## Summary\n\nnew\n");
expect(once.body).toBe("Intro line\n\n## Summary\n\nnew\n\n## Approvals\n\nx\n");
expect([once.replaced, once.kept, once.added]).toEqual([["leading text", "Summary"], ["Approvals"], []]);
const twice = mergeBodySections(once.body, "Intro line\n\n## Summary\n\nnew\n");
expect(twice.body).toBe(once.body);
const fresh = mergeBodySections("## Summary\n\nold\n", "Intro line\n\n## Summary\n\nnew\n");
expect(fresh.body).toBe("Intro line\n\n## Summary\n\nnew\n");
expect(fresh.added).toEqual(["leading text"]);
const receipt = "```gstack-ship-receipt\n{\n \"head\": \"a\"\n}\n```\n";
const afterReceipt = mergeBodySections("Intro line\n\n## Summary\n\nold\n", receipt + "note\n\n## Summary\n\nnew\n");
expect(afterReceipt.body).toContain("Intro line\n");
expect(afterReceipt.body).toContain("note\n");
expect(afterReceipt.replaced).toEqual(["Summary"]);
});
});
+64
View File
@@ -292,6 +292,70 @@ describe('ready', () => {
const stalePregate = queue(repo, ['ready', '--pr', '1', '--pregate', pregate], ghQueueShim(repo, { prs: { '1': { headRefOid: moved, body: shippedBody('1', moved) } }, checks: { [moved]: green } }));
expect(stalePregate.status).toBe(EXIT.refused);
expect(stalePregate.out).toMatch(/^PREGATE: pass .* \(PREGATE_STALE\)$/m);
// A lane that cleared its pregate from a separate clone (gstack-pregate --remote cannot run in a worktree) holds the verdict under
// <state root>/pregate/by-tree/<tree>.json; ready reads that mirror for the current tree when its own record is stale or missing, and only a run
// that holds the tests stage (what `--stage tests --remote` writes: checks=[lanes]) against origin/main under a reviewed policy stands in: a
// --only timing run, another base or a head policy is named on the PREGATE line instead.
const stateRoot = path.join(repo.root, 'state-root');
const complete = { base: `origin/main@${repo.git('rev-parse', 'origin/main').slice(0, 12)}`, policy: 'origin/main@abc (no .gstack/pregate.json)', checks: [{ id: 'lanes', status: 'pass' }] };
const fromSuffix = `(from the state root's pregate/by-tree/${'TREE'}.json, recorded by another checkout)`;
const movedShim = ghQueueShim(repo, { prs: { '1': { headRefOid: moved, body: shippedBody('1', moved) } }, checks: { [moved]: green } });
const movedTree = runBin(repo, 'gstack-wtree', []).out.trim();
const mirror = path.join(stateRoot, 'pregate', 'by-tree', `${movedTree}.json`);
fs.mkdirSync(path.dirname(mirror), { recursive: true });
fs.writeFileSync(path.join(stateRoot, 'pregate', 'by-tree', `${'0'.repeat(movedTree.length)}.json`), JSON.stringify({ schema_version: 1, tree: '0'.repeat(movedTree.length), head: moved, verdict: 'pass', ...complete }));
const otherTree = queue(repo, ['ready', '--pr', '1', '--pregate', pregate], { ...movedShim, GSTACK_STATE_ROOT: stateRoot });
expect(otherTree.status).toBe(EXIT.refused);
expect(otherTree.out).toMatch(/^PREGATE: pass .* \(PREGATE_STALE\)$/m);
const missing = queue(repo, ['ready', '--pr', '1'], { ...movedShim, GSTACK_STATE_ROOT: stateRoot });
expect(missing.status).toBe(EXIT.refused);
expect(missing.out).toMatch(/^PREGATE: missing \(\.gstack\/tmp\/pregate\.json; run gstack-pregate run --allow-repo-commands\)$/m);
const suffix = fromSuffix.replace('TREE', movedTree.slice(0, 12));
const partial = { ...complete, checks: [{ id: 'timing', status: 'pass' }] };
for (const [bad, why] of [[partial, 'a run without the tests stage (lanes)'], [{ ...complete, base: 'origin/develop@0123456789ab' }, 'recorded against origin/develop@0123456789ab, not origin/main'], [{ ...complete, policy: 'head (unreviewed) .gstack/pregate.json' }, 'recorded under the unreviewed head policy']] as const) {
fs.writeFileSync(mirror, JSON.stringify({ schema_version: 1, tree: movedTree, head: moved, verdict: 'pass', ...bad }));
const ignored = queue(repo, ['ready', '--pr', '1'], { ...movedShim, GSTACK_STATE_ROOT: stateRoot });
expect(ignored.status).toBe(EXIT.refused);
expect(line(ignored.out, 'PREGATE:')).toBe(`PREGATE: missing (.gstack/tmp/pregate.json; run gstack-pregate run --allow-repo-commands); the state root's mirror for this tree cannot stand in: ${why}`);
}
fs.writeFileSync(mirror, JSON.stringify({ schema_version: 1, tree: movedTree, head: moved, verdict: 'pass', ...complete }));
const fromMirror = queue(repo, ['ready', '--pr', '1', '--pregate', pregate], { ...movedShim, GSTACK_STATE_ROOT: stateRoot });
expect([fromMirror.status, fromMirror.err]).toEqual([EXIT.ok, '']);
expect(line(fromMirror.out, 'PREGATE:')).toBe(`PREGATE: pass tree=${movedTree.slice(0, 12)} head=${moved.slice(0, 12)} checks=1 ${suffix}`);
const missingFromMirror = queue(repo, ['ready', '--pr', '1'], { ...movedShim, GSTACK_STATE_ROOT: stateRoot });
expect([missingFromMirror.status, missingFromMirror.err]).toEqual([EXIT.ok, '']);
expect(line(missingFromMirror.out, 'PREGATE:')).toContain(suffix);
const current = queue(repo, ['ready', '--pr', '1', '--pregate', pregateFor(repo, moved)], { ...movedShim, GSTACK_STATE_ROOT: stateRoot });
expect([current.status, current.err]).toEqual([EXIT.ok, '']);
expect(line(current.out, 'PREGATE:')).toBe(`PREGATE: pass tree=${movedTree.slice(0, 12)} head=${moved.slice(0, 12)} checks=1`);
// Value: protects=a same-tree record that only waits on a remote receipt (the small-machine run) is cleared by the clone's mirror, an
// unreadable record falls through to the mirror, and a record whose own checks failed or stayed incomplete is never overridden by a green mirror,
// including an incomplete check the environment caused (PREGATE_ENVIRONMENT, #3480: a secrets scanner that never finished is not a scanned diff);
// fails_when=ready reads the mirror only for another tree (the documented lane case refuses PREGATE_REMOTE_UNCLEARED) or lets a mirror hide a red
// or incomplete local run; why_new=the rows above cover only stale, missing and current records; seam=none; no_claim=the mirror's writer
const local = path.join(repo.root, 'pregate-local.json');
const obligation = { lane: 'pregate.json/remote_gate', platform: 'remote', workflow: 'pregate.json', job: 'remote_gate', tests: ['test/a.test.ts'], receipt: null };
fs.writeFileSync(local, JSON.stringify({ schema_version: 1, tree: movedTree, head: moved, verdict: 'requires-remote', checks: [{ id: 'lanes', status: 'requires-remote' }], requires_remote: [obligation] }));
const uncleared = queue(repo, ['ready', '--pr', '1', '--pregate', local], { ...movedShim, GSTACK_STATE_ROOT: stateRoot });
expect([uncleared.status, uncleared.err]).toEqual([EXIT.ok, '']);
expect(line(uncleared.out, 'PREGATE:')).toBe(`PREGATE: pass tree=${movedTree.slice(0, 12)} head=${moved.slice(0, 12)} checks=1 ${suffix}`);
fs.writeFileSync(local, '{ not json');
const unreadable = queue(repo, ['ready', '--pr', '1', '--pregate', local], { ...movedShim, GSTACK_STATE_ROOT: stateRoot });
expect([unreadable.status, unreadable.err]).toEqual([EXIT.ok, '']);
expect(line(unreadable.out, 'PREGATE:')).toContain(suffix);
fs.writeFileSync(local, JSON.stringify({ schema_version: 1, tree: movedTree, head: moved, verdict: 'fail', checks: [{ id: 'lanes', status: 'fail' }] }));
const redLocal = queue(repo, ['ready', '--pr', '1', '--pregate', local], { ...movedShim, GSTACK_STATE_ROOT: stateRoot });
expect(redLocal.status).toBe(EXIT.refused);
expect(line(redLocal.out, 'PREGATE:')).toBe(`PREGATE: fail tree=${movedTree.slice(0, 12)} head=${moved.slice(0, 12)} checks=1 — 1 check(s) failed: lanes (PREGATE_LANE_FAILED)`);
fs.writeFileSync(local, JSON.stringify({ schema_version: 1, tree: movedTree, head: moved, verdict: 'incomplete', checks: [{ id: 'lanes', status: 'incomplete' }] }));
const incompleteLocal = queue(repo, ['ready', '--pr', '1', '--pregate', local], { ...movedShim, GSTACK_STATE_ROOT: stateRoot });
expect(incompleteLocal.status).toBe(EXIT.refused);
expect(line(incompleteLocal.out, 'PREGATE:')).toMatch(/\(PREGATE_INCOMPLETE\)$/);
fs.writeFileSync(local, JSON.stringify({ schema_version: 1, tree: movedTree, head: moved, verdict: 'incomplete', checks: [{ id: 'secrets', status: 'incomplete', cause: 'environment', environment: { kind: 'missing-binary', evidence: 'gstack-redact: not on PATH', proof: 'launch' } }] }));
const environmentLocal = queue(repo, ['ready', '--pr', '1', '--pregate', local], { ...movedShim, GSTACK_STATE_ROOT: stateRoot });
expect(environmentLocal.status).toBe(EXIT.refused);
expect(line(environmentLocal.out, 'PREGATE:')).toMatch(/\(PREGATE_ENVIRONMENT\)$/);
expect(environmentLocal.out).not.toContain(suffix);
fs.appendFileSync(path.join(repo.work, 'pr-a.txt'), 'dirty\n');
expect(queue(repo, ['ready', '--pr', '1'], shim(moved, green)).err).toMatch(/uncommitted tracked changes: pr-a\.txt.*\(QUEUE_NOT_READY\)/);
}, 180_000);
+61
View File
@@ -732,6 +732,31 @@ do not launch the downstream skill or open a browser.`,
mustContain: ['retrospective', '45-minute gap', 'Ship of the week', 'Praise'],
},
// ── GSTA-92 Phase 2 (2026-10-25): design-review carved on every host ──
// One lazy section, the craft audit, read for a Persuade page or a redesign;
// CARVED_ON_EVERY_HOST in scripts/resolvers/sections.ts keeps it out of the
// external skeletons too (Garry 2026-10-25 'Do it' (GSTA-92), Big decision 2a).
'design-review': {
skill: 'design-review',
expectedSections: ['craft-audit.md'],
requiredReads: ['craft-audit.md'],
scenario:
'Walk /design-review in SIMULATION — do not run bash, launch a browser, or edit files. The target is the landing page of a developer-tools SaaS (a Persuade page): treat Setup and Phase 1 as done. Read each pointed section before doing its step, then list the Phases 1-6 checks you would run on that page, including any section-specific check, and stop before Phase 7.',
staticInvariants: {
mustStayInSkeleton: ['## Phases 1-6: Design Audit Baseline', '**9. AI Slop Detection**', '## Phase 7: Triage', '## Phase 8: Fix Loop'],
mustPrecedeStop: ['### Design Hard Rules'],
mustMoveToSection: ['### 4. Fix order and stack (Phase 8)'],
gateAfterStop: undefined,
},
// No section-loading paid case yet: a new periodic case would push the
// periodic census past its 38-slice cap (test/paid-retry-supervision.test.ts).
behavioral: 'none',
maxSkeletonBytes: 137_767, // measured 137767 (2026-10-26; 137773 on 2026-10-25 before the shorter craft-audit trigger): the Section index the external-host carve test requires, its craft-audit row, and the STOP pointer (GSTA-92; Garry 2026-10-25 'Do it' (GSTA-92)).
minUnionBytes: 143_300, // measured union 145037 (2026-10-26)
mustContain: ['AI Slop Detection', 'Fix Loop', 'gstack-design-craft', 'Proof beside each claim', 'holds session data'],
maxSizeRatio: 1.3704, // first parity guard for design-review: measured 145082 / 105,842 = 1.3708 (2026-10-25; 145077 after the /ship review fixes, 145068 = 1.3707 after the line-13 reword, 145049 = 1.3705 after the post-merge /ship review, 145037 = 1.3704 after its second pass, 2026-10-26), the skeleton's growth since v1.64.1.0 plus the craft audit (Garry 2026-10-25 'Do it' (GSTA-92)).
},
// ── Token-reduction Phase 4 wave 4 (v1.69.x branch): design doctrine carve ──
// (D3A: read-on-demand doctrine, requiredReads-guarded + loading eval)
'design-html': {
@@ -791,6 +816,42 @@ do not launch the downstream skill or open a browser.`,
mustContain: ["Don't make me think", "Users scan, they don't read", 'trunk test', '44px minimum'],
},
// ── GSTA-92 plan item 15 (Garry 2026-10-25 "Do it"): /design-motion ──
// Fidelity, motion and the optional Figma path are lazy; the doctrine, the
// capture/check/export steps and their bins stay always loaded. The paid
// design-motion-showcase case drives a fixture page through the whole flow
// and asserts the fidelity and motion reads.
'design-motion': {
skill: 'design-motion',
expectedSections: ['fidelity.md', 'motion.md', 'figma.md'],
requiredReads: ['fidelity.md', 'motion.md'],
scenario:
'Covered by the design-motion-showcase case in test/skill-e2e-design.test.ts: a finalized.html fixture is rebuilt into a page with the capture contract, captured, checked and exported.',
staticInvariants: {
mustStayInSkeleton: [
'## Motion doctrine',
'one authored motion moment per page',
'## Step 5: Capture',
'## Step 6: Motion checks (before any export)',
'gstack-motion-encode check',
'## Step 7: Export and report',
'## Important Rules',
],
mustMoveToSection: [
'### Loading choreography',
'### Stories: one per visual',
'### Page architecture and the capture contract',
'### Layer export',
],
gateAfterStop: undefined,
},
behavioral: 'external',
externalTest: 'test/skill-e2e-design.test.ts',
maxSkeletonBytes: 39_900, // new skill (GSTA-92 plan item 15, Garry 2026-10-25 "Do it"); measured skeleton 39,836 (2026-10-25)
minUnionBytes: 57_500, // measured union 58,666 (2026-10-25)
mustContain: ['window.__start()', 'window.__tick(dt)', 'whole milliseconds averaging 1000/60', 'Content never hides behind animation timing'],
},
// ── Multi-agent wave PR D3 (2026-10-10): investigate recipes carve ──
// Five lazy recipes (flake, polluter, race, eval-bisect, backlog) read when
// the Phase 2 pattern or the --backlog argument matches; Phases 1–5, the
+9
View File
@@ -73,6 +73,13 @@ export const IDENTIFIER_PLACEHOLDERS: Record<string, IdentifierGrammar> = {
'<slug>': { grammar: ID, what: 'slug' },
'<dimension>': { grammar: ID, what: 'design dimension slug' },
'<dim>': { grammar: ID, what: 'profile dimension name' },
// /design-motion (GSTA-92 plan item 15): capture and export arguments the skill's plan fixes.
'<frame-size>': { grammar: /^[0-9]+x[0-9]+$/, what: 'design frame size in CSS pixels, WxH' },
'<cycle-seconds>': { grammar: NUMBER, what: 'loop cycle length in seconds' },
'<app|cards>': { grammar: oneOf('app', 'cards'), what: 'motion layout: joined app surfaces or separate cards' },
'<hex-color>': { grammar: /^#(?:[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/, what: 'page background colour as hex', quoted: true },
'<figma-file-key>': { grammar: ID, what: 'Figma file key from the link' },
'<figma-node-id>': { grammar: /^[0-9]+[:-][0-9]+$/, what: 'Figma node id from the link' },
'<new_value>': { grammar: NUMBER, what: 'profile dimension value' },
'<Q1_VALUE>': { grammar: NUMBER, what: 'profile answer value' },
'<Q2_VALUE>': { grammar: NUMBER, what: 'profile answer value' },
@@ -140,7 +147,9 @@ export const IDENTIFIER_PLACEHOLDERS: Record<string, IdentifierGrammar> = {
'<plan-path>': { grammar: PATH, what: 'bound plan file path' },
'<sketch-dir>': { grammar: PATH, what: 'sketch directory printed by an earlier block' },
'<path-to-finalized.html>': { grammar: PATH, what: 'finalized HTML path' },
'<path-to-page.html>': { grammar: PATH, what: 'saved page HTML path' },
'<finalized.html>': { grammar: PATH, what: 'finalized HTML path' },
'<motion-dir>': { grammar: PATH, what: 'run directory printed by the /design-motion Step 0 MOTION_DIR line' },
'<approved-variant.png>': { grammar: PATH, what: 'approved variant image path' },
'<failing-test-file>': { grammar: PATH, what: 'test file path' },
'<source-file-under-test>': { grammar: PATH, what: 'source file path' },
+20 -3
View File
@@ -31,13 +31,22 @@ export interface Rule {
plan?: { planned: number } | { manifest: unknown } | null;
/** Marks every artifact of runs dispatched under this rule expired in the artifacts listing. */
expired?: boolean;
/** A screen run's gate binding (gate-binding-a1/gate-binding.json, scripts/gate-reuse.ts) and its report's collector-outcomes.json (report-verdict-a1). */
binding?: { verdict: 'GREEN' | 'RED'; reconcile_exit?: number; flaky?: string[] };
outcomes?: { red?: string[]; flakyLines?: string[] };
/** The run confirmed a verdict reuse (R2a/R2): paid-plan carries a valid gate-reuse.json naming this run and no binding is uploaded. */
reuse?: { run: number; flaky?: string[] };
check?: string | null;
/** The check-run name a run dispatched under this rule attaches to its head (a push or workflow_dispatch run's job), with the run's status and conclusion. */
ci?: string;
bisect?: { prefixes: string[]; patterns: Record<string, string>; costUsd?: number };
}
export interface ShimData {
repo: string; origin: string; nextId: number; prs: Record<string, unknown>; runs: Array<Record<string, unknown>>;
artifacts: Record<string, Record<string, unknown>>; checks: Record<string, Array<{ name: string; status: string; conclusion: string | null }>>;
ci: Record<string, unknown[]>; rules: Record<string, Rule>; artifact: string; log: Array<Record<string, unknown>>;
/** Commit statuses (the legacy status API) at a sha, beside the check runs in `ci`. */
statuses?: Record<string, Array<{ state: string; context: string }>>;
}
export interface TrainFixture { repo: FixtureRepo; state: string; dataFile: string; env: Record<string, string>; read: () => ShimData; write: (fn: (d: ShimData) => void) => void }
@@ -163,6 +172,13 @@ function shimSource(): string {
" if (rule.record && !inputs.prefix_shas) d.artifacts[id][d.artifact] = { schema: 1, kind: 'train-gate', nonce: rule.record.nonce || inputs.nonce, head_sha: head, tree: rev(head + '^{tree}'),",
" verdict: rule.record.verdict || 'GREEN', census: rule.record.census, panels: rule.record.panels };",
" if (rule.check && !inputs.prefix_shas) (d.checks[head] = d.checks[head] || []).push({ name: 'gstack/full-gate', status: 'completed', conclusion: rule.check });",
" if (rule.ci) (d.ci[head] = d.ci[head] || []).push({ name: rule.ci, status: run.status, conclusion: run.conclusion, id: id, completed_at: '2026-10-26T00:00:00Z' });",
" if (rule.binding) d.artifacts[id]['gate-binding-a1'] = { schema: 1, repository: d.repo, pr: Number(inputs.screen_pr) || 0, run_id: id, run_attempt: 1, head_sha: rev(head + '^2') || head, base_sha: rev(head + '^1') || head,",
" profile: 'pr', reconcile_exit: rule.binding.reconcile_exit === undefined ? (rule.binding.verdict === 'GREEN' ? 0 : 1) : rule.binding.reconcile_exit, verdict: rule.binding.verdict, manual_accepted: 0, selection_sha256: 'a'.repeat(64),",
" flaky_cases: rule.binding.flaky || [], purpose: 'screen', tested_sha: head, screen_policy: inputs.screen_policy || 'narrow' };",
" if (rule.reuse && d.artifacts[id]['paid-plan']) d.artifacts[id]['paid-plan'].__reuse = { schema: 1, kind: 'main-moved', pr: Number(inputs.screen_pr) || 0, reused_run_id: rule.reuse.run, reused_run_attempt: 1, gated_head: 'b'.repeat(40), current_head: 'c'.repeat(40),",
" gated_base: 'd'.repeat(40), base_sha: 'e'.repeat(40), selection_sha256: 'a'.repeat(64), why: 'same own diff on a moved main', gated_merge_base: 'f'.repeat(40), merge_base: '1'.repeat(40), own_diff_sha256: 'b'.repeat(64), reused_screen_policy: 'narrow', reused_flaky: rule.reuse.flaky || [] };",
" if (rule.outcomes) d.artifacts[id]['report-verdict-a1'] = { version: 2, panels: (rule.outcomes.red || []).map(c => ({ case: c, kind: 'judge', status: 'FAIL', failsLane: true, redClass: 'VERDICT' })), flakyLines: rule.outcomes.flakyLines || [] };",
" if (rule.bisect && inputs.prefix_shas) for (const sha of inputs.prefix_shas.split(',')) {",
" const at = rule.bisect.prefixes.indexOf(sha);",
" d.artifacts[id][d.artifact + '-bisect-' + sha.slice(0, 12)] = { schema: 1, kind: 'train-bisect-prefix', nonce: inputs.nonce, prefix_sha: sha, checked_out_sha: sha,",
@@ -178,8 +194,9 @@ function shimSource(): string {
"if (a[0] === 'run' && a[1] === 'view') { const r = d.runs.find(x => String(x.databaseId) === a[2]); if (!r) fail('run not found'); out(r); }",
"if (a[0] === 'run' && a[1] === 'download') {",
" const art = (d.artifacts[a[2]] || {})[opt('-n')]; if (!art) fail('no artifact matches');",
" fs.mkdirSync(opt('-D'), { recursive: true }); fs.writeFileSync(path.join(opt('-D'), opt('-n') === 'paid-plan' ? 'manifest.json' : 'record.json'), JSON.stringify(art));",
" if (opt('-n') === 'paid-plan') fs.writeFileSync(path.join(opt('-D'), 'gate-reuse.json'), '{\"reuse\":\"confirmed\"}'); save(); process.exit(0);",
" const fileFor = n => n === 'paid-plan' ? 'manifest.json' : n.startsWith('gate-binding-') ? 'gate-binding.json' : n.startsWith('report-verdict-') ? 'paid-report/collector-outcomes.json' : 'record.json';",
" fs.mkdirSync(path.dirname(path.join(opt('-D'), fileFor(opt('-n')))), { recursive: true }); fs.writeFileSync(path.join(opt('-D'), fileFor(opt('-n'))), JSON.stringify(art));",
" if (opt('-n') === 'paid-plan') fs.writeFileSync(path.join(opt('-D'), 'gate-reuse.json'), art.__reuse ? JSON.stringify(art.__reuse) : '{\"reuse\":\"confirmed\"}'); save(); process.exit(0);",
"}",
"if (a[0] === 'api') {",
" const arts = /actions\\/runs\\/(\\d+)\\/artifacts(?:\\?name=([^&]+))?/.exec(a[1] || '');",
@@ -187,7 +204,7 @@ function shimSource(): string {
" const named = /commits\\/([0-9a-f]+)\\/check-runs\\?check_name=([^&]+)/.exec(a[1] || '');",
" if (named) { const list = (d.checks[named[1]] || []).filter(c => c.name === decodeURIComponent(named[2])); out({ total_count: list.length, check_runs: list }); }",
" const paged = /commits\\/([0-9a-f]+)\\/(check-runs|status)\\?.*page=(\\d+)/.exec(a[1] || '');",
" if (paged) { const list = paged[2] === 'check-runs' ? (d.ci[paged[1]] || []) : []; const key = paged[2] === 'check-runs' ? 'check_runs' : 'statuses';",
" if (paged) { const list = paged[2] === 'check-runs' ? (d.ci[paged[1]] || []) : ((d.statuses || {})[paged[1]] || []); const key = paged[2] === 'check-runs' ? 'check_runs' : 'statuses';",
" out(paged[3] === '1' ? { total_count: list.length, state: 'success', [key]: list } : { total_count: list.length, [key]: [] }); }",
"}",
"fail('gh shim: unsupported ' + a.join(' '));",
+13 -8
View File
@@ -263,7 +263,7 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
"scripts/resolvers/preamble/generate-preamble-bash.ts",
'test/fixtures/pty-companion-cli.ts', 'test/helpers/plan-seed-submission.ts', 'test/fixtures/plan-seed-cli.ts', 'test/helpers/owned-claude-transcript.ts', 'lib/fs-atomic.ts', 'test/helpers/plan-skill-questions.ts', 'test/fixtures/eng-auq-validation-error.json', 'test/fixtures/bash-directory-permission.json', 'test/fixtures/design-tasks-bash-permission.json', 'test/fixtures/read-permission.json', 'test/fixtures/ceo-split-e5-numbered-description-491.json', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/skill-census.ts', 'test/helpers/plan-mode-evidence.ts', 'lib/redact-engine.ts', 'lib/redact-patterns.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'bin/gstack-context-recovery'],
// SDK plan-edit case in test/skill-e2e-design.test.ts (claude -p edits plan.md).
'plan-design-review-plan-mode': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'plan-design-review/**', 'scripts/gen-skill-docs.ts', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/design.ts',
'plan-design-review-plan-mode': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'plan-design-review/**', 'scripts/gen-skill-docs.ts', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/design.ts', 'scripts/resolvers/design-craft.ts',
'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble/generate-preamble-bash.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble/generate-completion-status.ts',
'lib/eval-model.ts', 'test/skill-e2e-design.test.ts',
'test/fixtures/fake-impeccable.ts', 'test/fixtures/review-eval-design-slop.html', 'test/fixtures/review-eval-design-slop.css', 'test/helpers/aside-available.ts', 'test/helpers/fake-impeccable.ts', 'test/helpers/llm-judge.ts', 'test/helpers/office-hours-attempt.ts', 'test/helpers/pty/**'],
@@ -383,7 +383,7 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
"scripts/resolvers/preamble/generate-preamble-bash.ts", "scripts/resolvers/preamble/generate-completion-status.ts",
'test/fixtures/pty-companion-cli.ts', 'lib/fs-atomic.ts', 'test/helpers/ceo-finding-fixture.ts', 'test/helpers/plan-review-cases.ts', 'test/helpers/plan-review-board-feedback.ts', 'test/fixtures/design-board-questions.json', 'test/fixtures/design-outside-voices-question.json', 'design/src/daemon-state.ts', 'design/src/daemon.ts', 'design/test/daemon-tests-fixtures.ts', 'design/src/daemon-client.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/plan-skill-questions.ts', 'test/fixtures/eng-auq-validation-error.json', 'test/fixtures/bash-directory-permission.json', 'test/fixtures/design-tasks-bash-permission.json', 'test/fixtures/read-permission.json', 'test/fixtures/ceo-split-e5-numbered-description-491.json', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/skill-census.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'bin/gstack-paths', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh', 'scripts/resolvers/design.ts', 'scripts/resolvers/outside-voice.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts'],
'test/fixtures/pty-companion-cli.ts', 'lib/fs-atomic.ts', 'test/helpers/ceo-finding-fixture.ts', 'test/helpers/plan-review-cases.ts', 'test/helpers/plan-review-board-feedback.ts', 'test/fixtures/design-board-questions.json', 'test/fixtures/design-outside-voices-question.json', 'design/src/daemon-state.ts', 'design/src/daemon.ts', 'design/test/daemon-tests-fixtures.ts', 'design/src/daemon-client.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/plan-skill-questions.ts', 'test/fixtures/eng-auq-validation-error.json', 'test/fixtures/bash-directory-permission.json', 'test/fixtures/design-tasks-bash-permission.json', 'test/fixtures/read-permission.json', 'test/fixtures/ceo-split-e5-numbered-description-491.json', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/skill-census.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'bin/gstack-paths', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh', 'scripts/resolvers/design.ts', 'scripts/resolvers/design-craft.ts', 'scripts/resolvers/outside-voice.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts'],
'tpa-present': [ 'scripts/resolvers/third-party-actions.ts', 'ship/SKILL.md.tmpl', 'ship/sections/apple-release.md.tmpl', 'scripts/gen-skill-docs.ts', 'test/helpers/session-runner.ts', 'test/skill-e2e-third-party-actions.test.ts', 'test/helpers/third-party-actions.ts',
'lib/eval-model.ts'
],
@@ -848,15 +848,16 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
"scripts/resolvers/preamble/generate-preamble-bash.ts", "scripts/resolvers/preamble/generate-completion-status.ts",
'scripts/resolvers/preamble/generate-ask-user-format.ts', 'test/fixtures/fake-impeccable.ts', 'test/fixtures/review-eval-design-slop.html', 'test/fixtures/review-eval-design-slop.css', 'test/helpers/aside-available.ts', 'test/helpers/fake-impeccable.ts', 'test/helpers/llm-judge.ts', 'test/helpers/office-hours-attempt.ts'],
'design-review-fix': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'design-review/**', 'scripts/resolvers/aside.ts', 'scripts/resolvers/design.ts', 'scripts/resolvers/outside-voice.ts', 'lib/design-catalog.ts', 'browse/src/**', 'scripts/gen-skill-docs.ts', 'test/skill-e2e-design.test.ts',
'design-review-fix': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'design-review/**', 'scripts/resolvers/aside.ts', 'scripts/resolvers/design.ts', 'scripts/resolvers/design-craft.ts', 'scripts/resolvers/outside-voice.ts', 'lib/design-catalog.ts', 'browse/src/**', 'scripts/gen-skill-docs.ts', 'test/skill-e2e-design.test.ts',
'scripts/resolvers/testing.ts', 'test/fixtures/fake-impeccable.ts', 'test/fixtures/review-eval-design-slop.html', 'test/fixtures/review-eval-design-slop.css', 'test/helpers/aside-available.ts', 'test/helpers/fake-impeccable.ts', 'test/helpers/llm-judge.ts', 'test/helpers/office-hours-attempt.ts'],
// Design detector (user-installed impeccable engine) through the fake engine shim: source mode on a diff and DOM mode on a served page.
'design-review-detector-shim': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'design-review/**', 'scripts/resolvers/design.ts', 'scripts/resolvers/outside-voice.ts', 'lib/design-catalog.ts', 'lib/design-detect-contract.ts', 'lib/dom-dump-script.ts', 'lib/dom-dump.js', 'bin/gstack-design-detect.ts', 'test/helpers/fake-impeccable.ts', 'test/fixtures/fake-impeccable.ts', 'test/fixtures/impeccable-detect-sample.json', 'test/fixtures/review-eval-design-slop.*', 'test/skill-e2e-design.test.ts',
'design-review-detector-shim': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'design-review/**', 'scripts/resolvers/design.ts', 'scripts/resolvers/design-craft.ts', 'scripts/resolvers/outside-voice.ts', 'lib/design-catalog.ts', 'lib/design-detect-contract.ts', 'lib/dom-dump-script.ts', 'lib/dom-dump.js', 'bin/gstack-design-detect.ts', 'test/helpers/fake-impeccable.ts', 'test/fixtures/fake-impeccable.ts', 'test/fixtures/impeccable-detect-sample.json', 'test/fixtures/review-eval-design-slop.*', 'test/skill-e2e-design.test.ts',
'scripts/resolvers/testing.ts', 'test/helpers/aside-available.ts', 'test/helpers/llm-judge.ts', 'test/helpers/office-hours-attempt.ts'],
'design-review-detector-shim-dom': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'design-review/**', 'scripts/resolvers/design.ts', 'scripts/resolvers/outside-voice.ts', 'lib/design-detect-contract.ts', 'lib/dom-dump-script.ts', 'lib/dom-dump.js', 'bin/gstack-design-detect.ts', 'browse/src/**', 'test/helpers/fake-impeccable.ts', 'test/fixtures/fake-impeccable.ts', 'test/fixtures/impeccable-detect-sample.json', 'test/fixtures/review-eval-design-slop.*', 'test/skill-e2e-design.test.ts',
'design-review-detector-shim-dom': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'design-review/**', 'scripts/resolvers/design.ts', 'scripts/resolvers/design-craft.ts', 'scripts/resolvers/outside-voice.ts', 'lib/design-detect-contract.ts', 'lib/dom-dump-script.ts', 'lib/dom-dump.js', 'bin/gstack-design-detect.ts', 'browse/src/**', 'test/helpers/fake-impeccable.ts', 'test/fixtures/fake-impeccable.ts', 'test/fixtures/impeccable-detect-sample.json', 'test/fixtures/review-eval-design-slop.*', 'test/skill-e2e-design.test.ts',
'scripts/resolvers/testing.ts', 'test/helpers/aside-available.ts', 'test/helpers/llm-judge.ts', 'test/helpers/office-hours-attempt.ts'],
'design-review-plugin-handoff': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'design-review/**', 'scripts/resolvers/design.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/testing.ts', 'lib/design-catalog.ts', 'lib/design-detect-contract.ts', 'bin/gstack-design-detect.ts', 'test/helpers/hermetic-env.ts', 'test/helpers/fake-impeccable.ts', 'test/fixtures/fake-impeccable.ts', 'test/fixtures/impeccable-detect-sample.json', 'test/fixtures/review-eval-design-slop.html', 'test/fixtures/review-eval-design-slop.css', 'test/skill-e2e-design.test.ts', 'test/helpers/aside-available.ts', 'test/helpers/llm-judge.ts', 'test/helpers/office-hours-attempt.ts'],
'design-html-slop-gate': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'design-html/**', 'scripts/resolvers/design.ts', 'scripts/resolvers/outside-voice.ts', 'lib/design-detect-contract.ts', 'bin/gstack-design-detect.ts', 'lib/design-craft.ts', 'bin/gstack-design-craft', 'test/helpers/fake-impeccable.ts', 'test/fixtures/fake-impeccable.ts', 'test/fixtures/impeccable-detect-sample.json', 'test/skill-e2e-design.test.ts', 'test/fixtures/review-eval-design-slop.html', 'test/fixtures/review-eval-design-slop.css', 'test/helpers/aside-available.ts', 'test/helpers/llm-judge.ts', 'test/helpers/office-hours-attempt.ts'],
'design-review-plugin-handoff': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'design-review/**', 'scripts/resolvers/design.ts', 'scripts/resolvers/design-craft.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/testing.ts', 'lib/design-catalog.ts', 'lib/design-detect-contract.ts', 'bin/gstack-design-detect.ts', 'test/helpers/hermetic-env.ts', 'test/helpers/fake-impeccable.ts', 'test/fixtures/fake-impeccable.ts', 'test/fixtures/impeccable-detect-sample.json', 'test/fixtures/review-eval-design-slop.html', 'test/fixtures/review-eval-design-slop.css', 'test/skill-e2e-design.test.ts', 'test/helpers/aside-available.ts', 'test/helpers/llm-judge.ts', 'test/helpers/office-hours-attempt.ts'],
'design-html-slop-gate': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'design-html/**', 'scripts/resolvers/design.ts', 'scripts/resolvers/outside-voice.ts', 'lib/design-detect-contract.ts', 'bin/gstack-design-detect.ts', 'lib/design-craft.ts', 'lib/design-catalog.ts', 'bin/gstack-design-craft', 'test/helpers/fake-impeccable.ts', 'test/fixtures/fake-impeccable.ts', 'test/fixtures/impeccable-detect-sample.json', 'test/skill-e2e-design.test.ts', 'test/fixtures/review-eval-design-slop.html', 'test/fixtures/review-eval-design-slop.css', 'test/helpers/aside-available.ts', 'test/helpers/llm-judge.ts', 'test/helpers/office-hours-attempt.ts'],
'design-motion-showcase': ['design-motion/**', 'bin/gstack-motion-capture.ts', 'bin/gstack-motion-encode', 'lib/motion-capture.ts', 'lib/motion-encode.ts', 'test/fixtures/design-motion-finalized.html', 'bin/gstack-browser-ensure', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'test/skill-e2e-design.test.ts', 'test/helpers/fake-impeccable.ts', 'test/fixtures/fake-impeccable.ts', 'test/fixtures/review-eval-design-slop.html', 'test/fixtures/review-eval-design-slop.css', 'test/helpers/aside-available.ts', 'test/helpers/llm-judge.ts', 'test/helpers/office-hours-attempt.ts'],
// /diagram (diagram-render bundle consumers). Triplet = deterministic
// functional (gate); authoring quality = LLM-judged benchmark (periodic).
@@ -1448,6 +1449,7 @@ export const E2E_TIERS: Record<string, 'gate' | 'periodic' | 'marathon'> = {
'design-review-detector-shim-dom': 'gate', // same shim, DOM mode through the browse binary's dump; self-skips when the binary is absent
'design-review-plugin-handoff': 'gate',
'design-html-slop-gate': 'periodic', // one-pass gate behavior is a judgment call on a fake engine's fixed output
'design-motion-showcase': 'marathon', // a full capture-check-export flow (3-6 min a trial) that needs ffmpeg and Chromium; the periodic census is at its 38-slice cap
// /diagram — triplet is deterministic functional (gstack-render falls back
// to the browse daemon, so CI runs it); judge is a quality benchmark
@@ -1573,7 +1575,7 @@ export const LLM_JUDGE_TOUCHFILES: Record<string, string[]> = {
"scripts/resolvers/preamble/generate-preamble-bash.ts", "scripts/resolvers/preamble/generate-completion-status.ts",
'plan-design-review/sections/**', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/design.ts', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'test/fixtures/eval-baselines.json', 'test/helpers/cookie-workflow-judge-input.ts', 'test/helpers/cookie-workflow-manual-review.ts', 'test/helpers/llm-judge.ts'],
'plan-design-review/sections/**', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/design.ts', 'scripts/resolvers/design-craft.ts', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'test/fixtures/eval-baselines.json', 'test/helpers/cookie-workflow-judge-input.ts', 'test/helpers/cookie-workflow-manual-review.ts', 'test/helpers/llm-judge.ts'],
// Design skills
'design-review/SKILL.md fix loop': ['design-review/SKILL.md', 'design-review/SKILL.md.tmpl', 'test/skill-llm-eval.test.ts', 'test/helpers/workflow-judge-input.ts', 'test/helpers/workflow-judge-cache.ts', 'scripts/eval-input-cache.ts', 'test/helpers/workflow-excerpt.ts', 'test/fixtures/eval-baselines.json', 'test/helpers/cookie-workflow-judge-input.ts', 'test/helpers/cookie-workflow-manual-review.ts', 'test/helpers/llm-judge.ts'],
@@ -1856,6 +1858,7 @@ export const E2E_KINDS: Record<string, 'rule' | 'behavior' | 'judge'> = {
'design-review-detector-shim-dom': 'rule',
'design-review-plugin-handoff': 'rule',
'design-html-slop-gate': 'behavior',
'design-motion-showcase': 'behavior',
'diagram-triplet': 'rule',
'diagram-authoring-quality': 'rule',
'gstack-upgrade-happy-path': 'rule',
@@ -1980,6 +1983,8 @@ export const BEHAVIOR_WHY: Record<string, string> = {
"Avoiding neutral posture and naming a because-reason is live formatting of one question.",
'design-html-slop-gate':
"How many scan passes the one-pass slop gate takes on a fake engine's fixed output is a judgment call.",
'design-motion-showcase':
"The stories and the number of check-fix rounds vary per run; reading the sections, running check before export and a clean result stay the contract.",
'scrape-match-path':
"The /scrape fallback no longer prescribes the browser-skills match flow, so taking it is prompt compliance.",
'dx-error-contract-review':
+7 -4
View File
@@ -155,14 +155,17 @@ describe('template wiring', () => {
const main = source('SKILL.md.tmpl');
const step = (title: string, next: string) => main.slice(main.indexOf(title), main.indexOf(next));
test('Step 2 and the Step 3 CI wait call the helper, not a --required-only query', () => {
test('Step 2 calls the gate helper and Step 3 is one gstack-pr-wait call, not a polling loop or a --required-only query', () => {
const step2 = step('## Step 2: Pre-merge checks', '## Step 3: Wait for CI');
const step3 = step('## Step 3: Wait for CI', '## Step 3.4');
expect(step2).toContain('bin/gstack-ci-gate --repo "$REPO" --pr "$PR_NUMBER" --expect-head "$PR_HEAD"');
expect(step3).toMatch(/bin\/gstack-ci-gate [^\n]*--expect-head "\$PR_HEAD" --wait \d+/);
expect(step3).toContain('bin/gstack-pr-wait "$PR_NUMBER" --repo "$REPO" --until green');
expect(step3.match(/```bash/g)).toHaveLength(1);
expect(`${step2}${step3}`).not.toMatch(/gh pr checks[^\n]*--required/);
expect(step3).not.toContain('--watch');
expect(step3).toContain('still-pending checks');
expect(step3).not.toMatch(/--watch|--wait \d+|4-minute|sleep/);
for (const code of ['Exit 0', 'PR_WAIT_BLOCKED', 'PR_WAIT_REFUSED']) expect(step3).toContain(code);
expect(step3).toMatch(/restart at\s+Step 1/);
expect(main).not.toContain('4-minute');
expect(step2).toContain('never on the exit code');
});
+127
View File
@@ -0,0 +1,127 @@
import { describe, expect, test } from 'bun:test';
import * as path from 'path';
import {
captureScale, clockMs, maskCss, parseCaptureArgs, planCapture, stepMs, withMode, type CaptureOptions,
} from '../lib/motion-capture';
const ROOT = path.resolve(import.meta.dir, '..');
const BIN = path.join(ROOT, 'bin', 'gstack-motion-capture.ts');
describe('capture timing', () => {
test('steps are whole milliseconds averaging 1000/60', () => {
expect(Array.from({ length: 6 }, (_, i) => stepMs(i))).toEqual([17, 16, 17, 17, 16, 17]);
const steps = Array.from({ length: 720 }, (_, i) => stepMs(i));
expect(steps.every(s => s === 16 || s === 17)).toBe(true);
expect(steps.slice(0, 60).reduce((a, b) => a + b, 0)).toBe(1000);
expect(steps.reduce((a, b) => a + b, 0)).toBe(12_000);
});
test('the page clock is 0 before the start and sums the steps from PRE on', () => {
expect(clockMs(0, 15)).toBe(0);
expect(clockMs(14, 15)).toBe(0);
expect(clockMs(15, 15)).toBe(stepMs(15));
let sum = 0;
for (let i = 15; i < 200; i++) {
sum += stepMs(i);
expect(clockMs(i, 15)).toBe(sum);
}
});
test('the seam frame lands exactly one cycle after the window start for any PRE and cycle', () => {
for (const cycle of [4, 6, 12, 12.5, 15]) {
for (let pre = 2; pre <= 30; pre++) {
const plan = planCapture({ cycleSeconds: cycle, pre });
expect(clockMs(plan.windowStart, pre)).toBe(0);
expect(clockMs(plan.windowStart + plan.windowFrames, pre)).toBe(cycle * 1000);
expect(plan.frames).toBeGreaterThan(plan.windowStart + plan.windowFrames);
}
}
});
test('a default 12 s plan records PRE, one cycle from PRE - 1, and 10 frames past it', () => {
expect(planCapture({ cycleSeconds: 12 })).toEqual({ pre: 15, windowStart: 14, windowFrames: 720, tail: 10, frames: 744 });
});
test('the scale puts the long side on 1920x1200', () => {
expect(captureScale(1440, 990)).toBeCloseTo(1200 / 990, 10);
expect(Math.floor(1440 * captureScale(1440, 990))).toBe(1745);
expect(captureScale(1920, 1080)).toBe(1);
expect(captureScale(600, 400)).toBe(3);
// Value: protects=a portrait frame scales its long side to 1920 and short side within 1200, like a landscape one;
// fails_when=the scale again treats width as the long side and under-scales phone frames; why_new=every case above is landscape;
// seam=none; no_claim=scale math only; the capture's screenshot size is covered by test/motion-pipeline.test.ts
expect(captureScale(390, 844)).toBeCloseTo(1920 / 844, 10);
expect(captureScale(1080, 1920)).toBe(1);
});
});
describe('capture arguments', () => {
test('a capture and a still parse', () => {
expect(parseCaptureArgs(['page.html', '--size', '1440x990', '--cycle', '12', '--out', 'cap', '--mask', '.card'])).toEqual({
target: 'page.html', width: 1440, height: 990, out: 'cap', still: undefined, cycle: 12, scale: undefined, pre: 15, tail: 10, mask: '.card',
} satisfies CaptureOptions);
const still = parseCaptureArgs(['https://example.com/a', '--size', '640x400', '--still', 'ref.png', '--scale', '2']) as CaptureOptions;
expect([still.still, still.scale, still.out]).toEqual(['ref.png', 2, undefined]);
});
test('each bad invocation names its problem', () => {
const err = (argv: string[]) => (parseCaptureArgs(argv) as { error: string }).error;
expect(err(['--size', '10x10'])).toContain('name the page');
expect(err(['p.html', '--cycle', '12', '--out', 'c'])).toContain('--size');
expect(err(['p.html', '--size', '640x400', '--out', 'c'])).toContain('--cycle');
expect(err(['p.html', '--size', '640x400', '--cycle', '4'])).toContain('--out');
expect(err(['p.html', '--size', '640x400', '--cycle', '4', '--out', 'c', '--still', 's.png'])).toContain('not both');
expect(err(['p.html', '--size', '640x400', '--cycle', '4', '--out', 'c', '--pre', '1'])).toContain('--pre');
expect(err(['p.html', '--size', '640x400', '--cycle', 'abc', '--out', 'c'])).toContain('positive number');
expect(err(['p.html', '--size', '640x400', '--still', 's.jpg'])).toContain('.png');
expect(err(['p.html', '--size', '640x400', '--still', 's.png', '--mask', '.card'])).toContain('--mask');
expect(err(['p.html', '--size', '640x400', '--cycle', '4', '--out', 'c', '--scale', '2'])).toContain('--scale');
expect(err(['p.html', '--size', '640x400', '--fps', '30'])).toContain("unknown option '--fps'");
expect(err(['p.html', 'q.html', '--size', '640x400'])).toContain("unexpected argument 'q.html'");
expect(err(['p.html', '--size'])).toContain('needs a value');
expect(err(['p.html', '--size', '640x400', '--cycle', '4', '--out', 'c', '--mask', '.card} body {'])).toContain('--mask takes a CSS selector');
// Value: protects=a degenerate frame, a zero cycle and a fractional tail are refused at parse time with exit-2 wording;
// fails_when=the size floor, the >0 check or the tail integer check is dropped; why_new=no row hit these three guards; seam=none;
// no_claim=parser only; Chromium's behaviour on such a frame is not exercised
expect(err(['p.html', '--size', '10x10', '--still', 's.png'])).toContain('--size 10x10 is too small');
expect(err(['p.html', '--size', '640x400', '--cycle', '0', '--out', 'c'])).toContain("--cycle must be a positive number, got '0'");
expect(err(['p.html', '--size', '640x400', '--cycle', '4', '--out', 'c', '--tail', '2.5'])).toContain('--tail must be a whole number');
// Value: protects=a cycle that is not a multiple of 0.05 s is refused with a nearest cycle the same guard accepts;
// fails_when=the guard is dropped or loosened to whole frames, where the seam frame's clock depends on PRE; why_new=new guard;
// seam=none; no_claim=parser and clock math only
expect(err(['p.html', '--size', '640x400', '--cycle', '6.01', '--out', 'c'])).toContain('--cycle must be a multiple of 0.05 s');
expect(err(['p.html', '--size', '640x400', '--cycle', '6.01', '--out', 'c'])).toContain('nearest: 6)');
expect(err(['p.html', '--size', '640x400', '--cycle', '0.5167', '--out', 'c'])).toContain('nearest: 0.5)');
expect(err(['p.html', '--size', '640x400', '--cycle', '0.01', '--out', 'c'])).toContain('nearest: 0.05)');
expect(parseCaptureArgs(['p.html', '--size', '640x400', '--cycle', '0.55', '--out', 'c'])).not.toHaveProperty('error');
expect(new Set([14, 15, 16, 17].map(pre => clockMs(pre - 1 + 31, pre)))).toEqual(new Set([516, 517]));
for (const pre of [14, 15, 16, 17]) expect(clockMs(pre - 1 + 33, pre)).toBe(550);
});
test('modes keep the existing query and fragment', () => {
expect(withMode('file:///tmp/page.html', 'capture')).toBe('file:///tmp/page.html?capture');
expect(withMode('https://x.dev/y?a=1#top', 'still')).toBe('https://x.dev/y?a=1&still#top');
});
test('the mask paints only the matched containers white', () => {
const css = maskCss('.card, .panel');
expect(css).toContain('* { visibility: hidden !important;');
expect(css).toContain('.card, .panel { visibility: visible !important; background: #fff !important;');
expect(css).toContain('box-shadow: none !important');
});
});
describe('gstack-motion-capture CLI', () => {
test('a usage error exits 2 before any browser starts', () => {
const r = Bun.spawnSync([BIN, 'page.html', '--size', '640x400'], { stdout: 'pipe', stderr: 'pipe', timeout: 30_000 });
expect(r.exitCode).toBe(2);
expect(r.stderr.toString()).toContain('--out <dir> is required');
expect(r.stderr.toString()).toContain('Usage:');
});
test('a missing page file exits 2', () => {
const r = Bun.spawnSync([BIN, path.join(ROOT, 'no-such-page.html'), '--size', '640x400', '--still', '/tmp/x.png'], { stdout: 'pipe', stderr: 'pipe', timeout: 30_000 });
expect(r.exitCode).toBe(2);
expect(r.stderr.toString()).toContain('does not exist');
});
});
+270
View File
@@ -0,0 +1,270 @@
import { describe, expect, test } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { clockMs, planCapture } from '../lib/motion-capture';
import {
compareArgs, contactSheetArgs, cropError, judgeCheck, loopGifArgs, manifestError, maskCoverageArgs, mp4Args, padError, parseMaskCoverage, parsePsnrStats, planChecks, psnrArgs,
resolveFfmpeg, smallGifArgs, FFMPEG_MISSING, type CaptureManifest, type CheckJob, type CheckOptions,
} from '../lib/motion-encode';
const ROOT = path.resolve(import.meta.dir, '..');
const BIN = path.join(ROOT, 'bin', 'gstack-motion-encode');
const QUIET = ['-hide_banner', '-loglevel', 'error', '-nostdin', '-y'];
function manifest(cycle = 12, mask = true): CaptureManifest {
const plan = planCapture({ cycleSeconds: cycle });
return {
schema: 1, target: 'page.html', width: 1440, height: 990, scale: 1200 / 990, fps: 60, cycle,
pre: plan.pre, windowStart: plan.windowStart, windowFrames: plan.windowFrames, frames: plan.frames,
clockMs: Array.from({ length: plan.frames }, (_, i) => clockMs(i, plan.pre)),
loopTime: [], still: 'still.png', ...(mask ? { mask: 'mask.png' } : {}),
};
}
const OPTS: CheckOptions = { dissolveAt: 11, emptyFrom: 11.6, introEnd: 2.4, layout: 'cards', maskGrow: 2 };
describe('export recipes', () => {
const m = manifest();
test('MP4 for X: H.264 yuv420p, 60 fps, CRF 15, preset slow, faststart, even crop, one cycle from the window start', () => {
expect(mp4Args(m, '/c', '/c/n-x.mp4')).toEqual([...QUIET,
'-start_number', '14', '-framerate', '60', '-i', '/c/frames/%05d.png', '-frames:v', '720',
'-vf', 'crop=trunc(iw/2)*2:trunc(ih/2)*2', '-c:v', 'libx264', '-preset', 'slow', '-crf', '15',
'-pix_fmt', 'yuv420p', '-movflags', '+faststart', '/c/n-x.mp4']);
});
test('loop GIF: trim to the cycle, 20 fps, 960 wide lanczos, 192-colour diff palette, bayer 4, loop forever', () => {
const args = loopGifArgs(m, '/c', '/c/n-loop.gif');
expect(args.slice(args.indexOf('-filter_complex') + 1)).toEqual([
'trim=end_frame=720,fps=20,scale=960:-2:flags=lanczos,split[a][b];[a]palettegen=max_colors=192:stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=4:diff_mode=rectangle',
'-loop', '0', '/c/n-loop.gif']);
});
test('800x500 GIF: optional content crop, fit, pad with the page background', () => {
const args = smallGifArgs(m, '/c', '/c/n.gif', { crop: '1600:1000:72:100', pad: '#f4f1ea', fps: 12 });
const filter = args[args.indexOf('-filter_complex') + 1]!;
expect(filter.startsWith('trim=end_frame=720,fps=12,crop=1600:1000:72:100,scale=800:500:force_original_aspect_ratio=decrease:flags=lanczos,pad=800:500:(ow-iw)/2:(oh-ih)/2:color=0xf4f1ea,split[a][b];')).toBe(true);
expect(smallGifArgs(m, '/c', '/c/n.gif').join(' ')).toContain('fps=20,scale=800:500');
});
test('a crop must be 1.6:1', () => {
expect(cropError('1600:1000:0:0')).toBeNull();
expect(cropError('801:500:0:0')).toBeNull();
expect(cropError('1000:1000:0:0')).toContain('width 1600');
expect(cropError('1600x1000')).toContain('w:h:x:y');
});
// Value: protects=values that reach the ffmpeg filter graph (--pad, compare's --crop) are plain colours and w:h:x:y;
// fails_when=a value with ',' or ';' reaches the graph and adds filters or inputs; why_new=pad and compare's crop were unvalidated;
// seam=none; no_claim=validators only; the CLI refusal rows are in the exit-2 test below
test('filter-graph values are validated: a hex pad and a w:h:x:y crop', () => {
expect(padError('#f4f1ea')).toBeNull();
expect(padError('f4f1eaff')).toBeNull();
expect(padError('#fff')).toContain('hex colour');
expect(padError('#ffffff,hue=s=0')).toContain('hex colour');
expect(cropError('1000:1000:0:0', { ratio: false })).toBeNull();
expect(cropError('100:100:0:0,hue=s=0', { ratio: false })).toContain('w:h:x:y');
});
test('the contact sheet tiles twelve frames from the first to the last of the window', () => {
const args = contactSheetArgs(m, '/c', '/c/sheet.png');
const vf = args[args.indexOf('-vf') + 1]!;
expect(vf).toContain(String.raw`eq(n\,0)+`);
expect(vf).toContain(String.raw`eq(n\,719)'`);
expect(vf).toContain('tile=4x3');
});
});
describe('check planning', () => {
test('seam, empty beat, rest and overflow compare the right frames', () => {
const m = manifest();
const jobs = planChecks(m, '/c', OPTS) as CheckJob[];
expect(jobs.map(j => j.id)).toEqual(['seam', 'empty-beat', 'rest', 'overflow']);
const [seam, empty, rest, overflow] = jobs;
expect(seam!.what).toBe('frame 14 vs 734');
expect(seam!.args).toContain('/c/frames/00014.png');
expect(seam!.args).toContain('/c/frames/00734.png');
// Empty beat: clock in [11.6 s, 12 s) inside the window, compared with the pre-start frame.
const emptyFrames = Array.from({ length: m.frames }, (_, i) => i)
.filter(i => i > 14 && i < 734 && m.clockMs[i]! >= 11_600);
expect(empty!.expected).toBe(emptyFrames.length);
expect(empty!.what).toBe(`frames ${emptyFrames[0]}-733 vs 14`);
expect(empty!.args.slice(empty!.args.indexOf('-start_number'), empty!.args.indexOf('-start_number') + 2)).toEqual(['-start_number', String(emptyFrames[0])]);
// Rest: the last frame whose clock is still before the dissolve.
const restFrame = Math.max(...Array.from({ length: 734 }, (_, i) => i).filter(i => i > 14 && m.clockMs[i]! < 11_000));
expect(rest!.what).toBe(`frame ${restFrame} vs still.png`);
expect(m.clockMs[restFrame + 1]).toBeGreaterThanOrEqual(11_000);
// Cards overflow: story frames (intro landed .. dissolve) against the still, through the mask.
expect(overflow!.what).toContain('story frames');
expect(overflow!.args.join(' ')).toContain('/c/mask.png');
expect(overflow!.args.join(' ')).toContain('/c/still.png');
});
test('an app frame compares every window frame with the empty first frame', () => {
const jobs = planChecks(manifest(), '/c', { ...OPTS, layout: 'app' }) as CheckJob[];
const overflow = jobs.find(j => j.id === 'overflow')!;
expect(overflow.expected).toBe(720);
expect(overflow.what).toBe('every window frame vs 14, outside the mask');
});
test('no mask, no overflow job; bad phases and a short tail are refused', () => {
expect((planChecks(manifest(12, false), '/c', OPTS) as CheckJob[]).map(j => j.id)).toEqual(['seam', 'empty-beat', 'rest']);
expect((planChecks(manifest(), '/c', { ...OPTS, dissolveAt: 12.5 }) as { error: string }).error).toContain('intro-end < dissolve-at');
const short = { ...manifest(), frames: 734 };
expect((planChecks(short, '/c', OPTS) as { error: string }).error).toContain('--tail');
});
test('the mask is grown by one dilation per pixel and blacks out the containers on both sides', () => {
const args = psnrArgs({ first: '/c/frames/%05d.png', start: 20, count: 5, reference: '/c/still.png', mask: '/c/mask.png', maskGrow: 3 });
const graph = args[args.indexOf('-filter_complex') + 1]!;
expect(graph).toContain('format=gray,dilation,dilation,dilation,format=gbrp,split[m1][m2]');
expect(graph).toContain("[f][m1]blend=all_expr='if(gt(B,127),0,A)'[fa]");
expect(graph).toContain('[fa][ra]psnr=stats_file=-');
expect(graph).toContain('trim=end_frame=5');
});
test('compare crops and blurs both sides the same way', () => {
const args = compareArgs('a.png', 'b.png', { blur: 1.2, crop: '100:100:10:10' });
expect(args[args.indexOf('-filter_complex') + 1]).toBe('[0:v]format=gbrp,crop=100:100:10:10,gblur=sigma=1.2[a];[1:v]format=gbrp,crop=100:100:10:10,gblur=sigma=1.2[b];[a][b]psnr=stats_file=-');
});
});
describe('PSNR parsing and verdicts', () => {
const STATS = [
'n:1 mse_avg:0.00 mse_r:0.00 mse_g:0.00 mse_b:0.00 psnr_avg:inf psnr_r:inf psnr_g:inf psnr_b:inf',
'n:2 mse_avg:1.53 mse_r:1.40 mse_g:1.60 mse_b:1.59 psnr_avg:46.28 psnr_r:46.67 psnr_g:46.09 psnr_b:46.12',
'garbage line',
'n:3 mse_avg:0.00 mse_r:0.00 mse_g:0.00 mse_b:0.00 psnr_avg:inf psnr_r:inf psnr_g:inf psnr_b:inf',
].join('\n');
const job: CheckJob = { id: 'empty-beat', what: 'frames 711-733 vs 14', args: [], expected: 3 };
test('inf becomes Infinity and junk lines are skipped', () => {
expect(parsePsnrStats(STATS)).toEqual([{ n: 1, psnr: Infinity }, { n: 2, psnr: 46.28 }, { n: 3, psnr: Infinity }]);
expect(parsePsnrStats('')).toEqual([]);
});
test('identical frames pass; one differing frame fails with its worst value', () => {
const allInf = parsePsnrStats(STATS.replace('psnr_avg:46.28', 'psnr_avg:inf'));
expect(judgeCheck(job, allInf)).toEqual({ id: 'empty-beat', pass: true, line: 'MOTION_CHECK: empty-beat pass (frames 711-733 vs 14)' });
const v = judgeCheck(job, parsePsnrStats(STATS));
expect(v.pass).toBe(false);
expect(v.line).toBe('MOTION_CHECK: empty-beat fail 1 frame(s) differ, worst 46.28 dB at compared frame 2 (frames 711-733 vs 14)');
expect(judgeCheck(job, parsePsnrStats(STATS), 45).pass).toBe(true);
});
test('fewer compared frames than planned is a failure, never a pass', () => {
const v = judgeCheck({ ...job, expected: 23 }, parsePsnrStats(STATS.replace('psnr_avg:46.28', 'psnr_avg:inf')));
expect(v.pass).toBe(false);
expect(v.line).toContain('compared 3 of 23 frames');
expect(judgeCheck({ ...job, expected: 0 }, []).pass).toBe(false);
});
// Value: protects=phase overrides that leave no story frame refuse the check instead of passing overflow on zero frames;
// fails_when=the empty-span guard is dropped and overflow reports pass for 'story frames undefined-undefined'; why_new=new guard; seam=none;
// no_claim=planning only
test('a cards overflow with no story frames is a planning error', () => {
const r = planChecks(manifest(), '/c', { ...OPTS, introEnd: 10.99, dissolveAt: 11 });
expect(r).toEqual({ error: 'no window frame lies between intro-end and dissolve-at, so overflow has no story frames to check' });
});
// Value: protects=a capture.json this tool did not write cannot put text into ffmpeg filter graphs or paths outside the capture dir;
// fails_when=a field check is dropped and a string windowFrames or a ../ still reaches the encoder; why_new=new validator; seam=none;
// no_claim=validator only; the bin calls it before building any job
test('a capture manifest is checked before its fields reach ffmpeg', () => {
expect(manifestError(manifest())).toBeNull();
expect(manifestError(manifest(12, false))).toBeNull();
expect(manifestError({ ...manifest(), schema: 2 })).toContain('schema 1');
expect(manifestError({ ...manifest(), windowFrames: '60,metadata=mode=print' })).toContain('windowFrames must be a non-negative integer');
expect(manifestError({ ...manifest(), cycle: 0 })).toContain('cycle must be a positive number');
expect(manifestError({ ...manifest(), clockMs: [0] })).toContain('one number per frame');
expect(manifestError({ ...manifest(), still: '../still.png' })).toContain('still must be a .png file name');
expect(manifestError({ ...manifest(), mask: 'mask.png,hue=s=0' })).toContain('mask must be a .png file name');
expect(manifestError(null)).toContain('schema 1');
expect(manifestError({ ...manifest(), fps: 0 })).toContain('at least 1');
expect(manifestError({ ...manifest(), windowFrames: 1_000_000_000 })).toContain('runs past its frames');
});
test('mask coverage reads signalstats YAVG as a fraction of white', () => {
const args = maskCoverageArgs('/c/mask.png', 2);
expect(args[args.indexOf('-vf') + 1]).toBe('format=gray,dilation,dilation,signalstats,metadata=print:key=lavfi.signalstats.YAVG:file=-');
expect(parseMaskCoverage('frame:0 pts:0 pts_time:0\nlavfi.signalstats.YAVG=127.5\n')).toBe(0.5);
expect(parseMaskCoverage('')).toBeNull();
});
});
describe('ffmpeg is optional and named when missing', () => {
test('resolution prefers GSTACK_FFMPEG, refuses a missing override, else uses PATH', () => {
const which = (b: string) => (b === 'ffmpeg' ? '/usr/bin/ffmpeg' : null);
expect(resolveFfmpeg({ GSTACK_FFMPEG: '/opt/ff' }, which, p => p === '/opt/ff')).toBe('/opt/ff');
expect(resolveFfmpeg({ GSTACK_FFMPEG: '/opt/ff' }, which, () => false)).toBeNull();
expect(resolveFfmpeg({}, which, () => true)).toBe('/usr/bin/ffmpeg');
expect(resolveFfmpeg({}, () => null, () => true)).toBeNull();
});
test('the CLI prints FFMPEG_MISSING with install hints and exits 3', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'motion-encode-'));
try {
const r = Bun.spawnSync([BIN, 'check', dir], { stdout: 'pipe', stderr: 'pipe', timeout: 30_000, env: { ...process.env, GSTACK_FFMPEG: path.join(dir, 'no-ffmpeg') } });
expect(r.exitCode).toBe(3);
expect(r.stdout.toString().trim()).toBe(FFMPEG_MISSING);
expect(r.stderr.toString()).toContain(`GSTACK_FFMPEG=${path.join(dir, 'no-ffmpeg')} does not exist`);
expect(FFMPEG_MISSING).toContain('brew install ffmpeg');
expect(FFMPEG_MISSING).toContain('apt-get install -y ffmpeg');
expect(FFMPEG_MISSING).toContain('winget install');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('a usage error exits 2 without touching ffmpeg', () => {
const r = Bun.spawnSync([BIN, 'encode'], { stdout: 'pipe', stderr: 'pipe', timeout: 30_000, env: { ...process.env, GSTACK_FFMPEG: '/nonexistent/ffmpeg' } });
expect(r.exitCode).toBe(2);
expect(r.stderr.toString()).toContain('Usage: gstack-motion-encode check');
});
// Value: protects=an unreadable capture, a bad --layout, impossible phases, a missing --name and a non-1.6:1 crop exit 2 before any ffmpeg run;
// fails_when=a guard is dropped and ffmpeg runs on bad input or the CLI exits 0/1; why_new=only the no-subcommand usage path was tested; seam=none;
// no_claim=a stand-in file satisfies GSTACK_FFMPEG resolution and is never executed, so real ffmpeg is not exercised
test('bad captures and bad options exit 2 once ffmpeg resolves, before it runs', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'motion-encode-usage-'));
try {
const fake = path.join(dir, 'fake-ffmpeg');
fs.writeFileSync(fake, '');
const cli = (...args: string[]) => {
const r = Bun.spawnSync([BIN, ...args], { stdout: 'pipe', stderr: 'pipe', timeout: 30_000, env: { ...process.env, GSTACK_FFMPEG: fake } });
return { code: r.exitCode, err: r.stderr.toString() };
};
const empty = path.join(dir, 'empty');
fs.mkdirSync(empty);
const missing = cli('check', empty);
expect(missing.code).toBe(2);
expect(missing.err).toContain('capture.json');
expect(missing.err).toContain('capture first with gstack-motion-capture.ts');
const cap = path.join(dir, 'cap');
fs.mkdirSync(cap);
fs.writeFileSync(path.join(cap, 'capture.json'), JSON.stringify(manifest()));
expect(cli('check', cap, '--layout', 'grid')).toEqual({ code: 2, err: '--layout must be app or cards\n' });
const phases = cli('check', cap, '--dissolve-at', '13');
expect(phases.code).toBe(2);
expect(phases.err).toContain('intro-end < dissolve-at <= empty-from < cycle');
const name = cli('export', cap, '--name', 'Bad Name');
expect(name.code).toBe(2);
expect(name.err).toContain('--name <kebab-case-name> is required');
const crop = cli('export', cap, '--name', 'ok', '--crop', '1000:1000:0:0');
expect(crop.code).toBe(2);
expect(crop.err).toContain('is not 1.6:1');
// Value: protects=unknown flags, unknown formats, a non-hex pad, a malformed compare crop and missing frames exit 2 before ffmpeg;
// fails_when=a typo'd threshold or format is ignored, or export encodes a truncated capture and prints EXPORT_OK;
// why_new=these guards are new; seam=none; no_claim=the stand-in ffmpeg is never executed
expect(cli('check', cap, '--dissolveat', '10').err).toContain("unknown option '--dissolveat' for check");
expect(cli('check', cap, '--dissolveat', '10').code).toBe(2);
expect(cli('export', cap, '--name', 'ok', '--formats', 'mp4,gif')).toMatchObject({ code: 2, err: expect.stringContaining("got 'gif'") });
expect(cli('export', cap, '--name', 'ok', '--pad', '#ffffff,hue=s=0')).toMatchObject({ code: 2, err: expect.stringContaining('--pad must be a hex colour') });
expect(cli('compare', 'a.png', 'b.png', '--crop', '1:1:0:0,hue=s=0')).toMatchObject({ code: 2, err: expect.stringContaining('--crop must be w:h:x:y') });
expect(cli('export', cap, '--name', 'ok')).toMatchObject({ code: 2, err: expect.stringContaining('the capture is incomplete') });
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});
+276
View File
@@ -0,0 +1,276 @@
/**
* /design-motion capture -> check -> export, end to end on real Chromium and
* real ffmpeg (bin/gstack-motion-capture.ts feeding bin/gstack-motion-encode).
* The unit tests pin the argument lists and the PSNR parser on synthetic
* lines; this file proves the two bins agree on the capture directory
* (frame names from the window start, capture.json, still.png and mask.png at
* the frame scale) and that the checks judge real ffmpeg output both ways.
* The fixture page is a 0.5 s loop at 640x100 (captured 1920x300), so a run is
* 54 frames. Self-skips when the Playwright Chromium bundle, ffmpeg or ffprobe
* is missing.
*/
import { describe, expect, test } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { chromium } from 'playwright';
import { clockMs, stepMs } from '../lib/motion-capture';
import { resolveFfmpeg, type CaptureManifest } from '../lib/motion-encode';
const ROOT = path.resolve(import.meta.dir, '..');
const CAPTURE = path.join(ROOT, 'bin', 'gstack-motion-capture.ts');
const ENCODE = path.join(ROOT, 'bin', 'gstack-motion-encode');
const CHROMIUM = process.env.GSTACK_CHROMIUM_PATH || (() => { try { return chromium.executablePath(); } catch { return ''; } })();
const FFMPEG = resolveFfmpeg(process.env, bin => Bun.which(bin), p => fs.existsSync(p));
const FFPROBE = Bun.which('ffprobe');
const READY = Boolean(CHROMIUM) && fs.existsSync(CHROMIUM) && Boolean(FFMPEG) && Boolean(FFPROBE) && process.platform !== 'win32';
const PHASES = ['--intro-end', '0.1', '--dissolve-at', '0.3', '--empty-from', '0.4', '--layout', 'cards'];
function page(o: { loops: boolean; contract?: boolean }): string {
const contract = o.contract === false ? '' : `
window.__lt = 0;
window.__start = () => { window.__lt = 0; paint(0); };
window.__tick = dt => { window.__lt = ${o.loops ? '(window.__lt + dt) % 0.5' : 'window.__lt + dt'}; paint(window.__lt); };`;
return `<!doctype html><html><head><style>
html, body { margin: 0; background: #f4f1ea; }
.card { position: absolute; left: 40px; top: 20px; width: 400px; height: 60px; background: #fff; border-radius: 8px; overflow: hidden; }
.bar { position: absolute; left: 0; top: 20px; width: 80px; height: 20px; background: #2a6f4f; }
</style></head><body><section class="card"><div class="bar"></div></section><script>
const card = document.querySelector('.card');
const bar = document.querySelector('.bar');
function paint(lt) {
card.style.visibility = lt >= 0.05 && ${o.loops ? 'lt < 0.3' : 'true'} ? 'visible' : 'hidden';
bar.style.transform = 'translateX(' + Math.round(Math.min(1, Math.max(0, (lt - 0.05) / 0.1)) * 300) + 'px)';
}
if (location.search.slice(1).split('&').includes('still')) paint(0.25);
else { paint(0);${contract} }
</script></body></html>
`;
}
function run(argv: string[]) {
const r = Bun.spawnSync(argv, { stdout: 'pipe', stderr: 'pipe', timeout: 150_000, env: process.env });
return { code: r.exitCode, out: r.stdout.toString(), err: r.stderr.toString() };
}
function capture(dir: string, html: string, o: { mask?: string | false } = {}) {
const file = path.join(dir, 'page.html');
fs.writeFileSync(file, html);
return run([CAPTURE, file, '--size', '640x100', '--cycle', '0.5', '--out', path.join(dir, 'cap'), ...(o.mask === false ? [] : ['--mask', o.mask ?? '.card'])]);
}
describe.skipIf(!READY)('design-motion pipeline on real Chromium and ffmpeg', () => {
// Value: protects=a looping page captures 54 frames whose manifest, still and mask the encoder accepts, checks clean and exports a one-cycle MP4;
// fails_when=frame naming, start frame, tick units, still/mask scale or the export window drift between the two bins, a failed re-export
// truncates the last good file, or a GIF fps that drifts against the cycle is accepted; why_new=only the paid marathon case runs both bins;
// seam=none; no_claim=a 0.5 s synthetic loop stands in for an agent-built page; GIF quality and X upload acceptance are not checked
test('a looping page captures, checks clean and exports one cycle', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'motion-pipeline-'));
try {
const cap = path.join(dir, 'cap');
const c = capture(dir, page({ loops: true }));
expect(c.code, c.err).toBe(0);
expect(c.out.trim().split('\n').pop()).toBe(`CAPTURE_OK frames=54 window=14+30 scale=3.0000 dir=${cap}`);
expect(fs.readdirSync(path.join(cap, 'frames')).sort()).toEqual(Array.from({ length: 54 }, (_, i) => `${String(i).padStart(5, '0')}.png`));
expect(fs.existsSync(path.join(cap, 'still.png')) && fs.existsSync(path.join(cap, 'mask.png'))).toBe(true);
const m = JSON.parse(fs.readFileSync(path.join(cap, 'capture.json'), 'utf-8')) as CaptureManifest;
expect([m.scale, m.windowStart, m.windowFrames, m.frames, m.mask]).toEqual([3, 14, 30, 54, 'mask.png']);
expect(m.loopTime.length).toBe(54);
expect(m.loopTime.slice(0, 15).every(t => t === 0)).toBe(true);
expect(m.loopTime[15]).toBeCloseTo(stepMs(15) / 1000, 9);
for (let i = 15; i < 44; i++) expect(m.loopTime[i]!).toBeCloseTo(clockMs(i, 15) / 1000, 6);
const check = run([ENCODE, 'check', cap, ...PHASES]);
expect(check.code, check.out + check.err).toBe(0);
for (const id of ['seam', 'empty-beat', 'rest', 'overflow']) expect(check.out).toContain(`MOTION_CHECK: ${id} pass`);
expect(check.out).toContain(`CONTACT_SHEET ${path.join(cap, 'contact-sheet.png')}`);
expect(check.out.trim().split('\n').pop()).toBe('MOTION_RESULT: clean');
const exp = run([ENCODE, 'export', cap, '--name', 'pipe', '--formats', 'mp4,800', '--pad', '#f4f1ea']);
expect(exp.code, exp.out + exp.err).toBe(0);
const gifSize = fs.statSync(path.join(cap, 'pipe-800x500.gif')).size;
const broken = run([ENCODE, 'export', cap, '--name', 'pipe', '--formats', '800', '--crop', '4000:2500:0:0']);
expect(broken.code).toBe(1);
expect(broken.out).toContain('EXPORT_FAILED pipe-800x500.gif');
expect(fs.statSync(path.join(cap, 'pipe-800x500.gif')).size).toBe(gifSize);
expect(fs.readdirSync(cap).filter(f => f.startsWith('.partial-'))).toEqual([]);
const drift = run([ENCODE, 'export', cap, '--name', 'pipe', '--formats', 'loop', '--gif-fps', '7']);
expect(drift.code).toBe(2);
expect(drift.err).toContain('must be a whole fps from 1 to 50');
expect(run([ENCODE, 'export', cap, '--name', 'pipe', '--formats', '800', '--small-fps', '60']).code).toBe(2);
expect(exp.out).toContain(`EXPORT_OK ${path.join(cap, 'pipe-x.mp4')}`);
expect(exp.out).toContain(`EXPORT_OK ${path.join(cap, 'pipe-800x500.gif')}`);
const probe = run([FFPROBE!, '-v', 'error', '-select_streams', 'v:0', '-count_frames', '-show_entries', 'stream=width,height,nb_read_frames', '-of', 'csv=p=0', path.join(cap, 'pipe-x.mp4')]);
expect(probe.out.trim()).toBe('1920,300,30');
const same = run([ENCODE, 'compare', path.join(cap, 'still.png'), path.join(cap, 'still.png')]);
expect(same.code).toBe(0);
expect(same.out).toStartWith('PSNR inf dB');
const sizes = run([ENCODE, 'compare', path.join(cap, 'still.png'), path.join(cap, 'contact-sheet.png')]);
expect(sizes.code).toBe(1);
expect(sizes.err).toContain('COMPARE_FAILED');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}, 240_000);
// Value: protects=a page that never returns to its empty state fails the seam and empty-beat checks on real ffmpeg output and exits 1;
// fails_when=finite psnr_avg lines stop parsing or a failed job stops counting toward MOTION_RESULT; why_new=unit verdicts use hand-written stats lines; seam=none;
// no_claim=only the seam/empty-beat failure class; a rest or overflow failure is not reproduced here
test('a page that does not loop fails the seam and the empty beat', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'motion-pipeline-'));
try {
const c = capture(dir, page({ loops: false }));
expect(c.code, c.err).toBe(0);
const check = run([ENCODE, 'check', path.join(dir, 'cap'), ...PHASES]);
expect(check.code).toBe(1);
expect(check.out).toMatch(/MOTION_CHECK: seam fail 1 frame\(s\) differ, worst \d+\.\d\d dB at compared frame \d+ \(frame 14 vs 44\)/);
expect(check.out).toMatch(/MOTION_CHECK: empty-beat fail \d+ frame\(s\) differ/);
expect(check.out).toContain('MOTION_CHECK: rest pass');
expect(check.out).toContain('MOTION_CHECK: overflow pass');
expect(check.out.trim().split('\n').pop()).toBe('MOTION_RESULT: 2 failed');
const relaxed = run([ENCODE, 'check', path.join(dir, 'cap'), ...PHASES, '--min-db', '0']);
expect(relaxed.code, relaxed.out + relaxed.err).toBe(0);
expect(relaxed.out).toMatch(/^MOTION_SETTINGS: .*--min-db 0; defaults otherwise$/m);
const blank = run([ENCODE, 'check', path.join(dir, 'cap'), ...PHASES, '--min-db', '']);
expect(blank.code).toBe(2);
expect(blank.err).toContain("--min-db must be a non-negative number, got ''");
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}, 240_000);
// Value: protects=a page without window.__start/__tick stops with MOTION_CONTRACT_MISSING and exit 1 before writing capture.json;
// fails_when=the contract probe is dropped and frames of an unanimated page are recorded as a capture; why_new=no test loads a page in the capture bin; seam=none;
// no_claim=only the missing-contract case; a contract that throws mid-capture is not exercised
test('a page without the capture contract is refused', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'motion-pipeline-'));
try {
const c = capture(dir, page({ loops: true, contract: false }));
expect(c.code).toBe(1);
expect(c.err).toContain('MOTION_CONTRACT_MISSING');
expect(fs.existsSync(path.join(dir, 'cap', 'capture.json'))).toBe(false);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}, 120_000);
// Value: protects=a capture reuses an --out only when its own marker is there: a foreign still.png is refused and kept, the retry after a
// refused page succeeds, and a later failed capture clears the previous manifest; the no-mask run ends 'clean (overflow not checked)'
// and an unbounded --mask-grow is refused; fails_when=the marker test, the cleanup, the no-mask result or the grow bound is dropped;
// why_new=review cycles found a refused retry and a deleted foreign dir; seam=none; no_claim=a crash mid-frame-loop is not simulated
test('an --out is reused only when this tool wrote it', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'motion-pipeline-'));
try {
const cap = path.join(dir, 'cap');
fs.mkdirSync(cap);
fs.writeFileSync(path.join(cap, 'still.png'), 'not ours');
const foreign = capture(dir, page({ loops: true, contract: false }));
expect(foreign.code).toBe(2);
expect(foreign.err).toContain('already holds still.png but no .gstack-motion-capture marker');
expect(fs.readFileSync(path.join(cap, 'still.png'), 'utf-8')).toBe('not ours');
fs.rmSync(path.join(cap, 'still.png'));
const c = capture(dir, page({ loops: true, contract: false }));
expect(c.code).toBe(1);
expect(c.err).toContain('MOTION_CONTRACT_MISSING');
const retry = capture(dir, page({ loops: true }), { mask: false });
expect(retry.code, retry.err).toBe(0);
expect(retry.out).toContain('CAPTURE_OK frames=54');
const check = run([ENCODE, 'check', cap, ...PHASES]);
expect(check.code, check.out + check.err).toBe(0);
expect(check.out).toContain('MOTION_CHECK: overflow skipped');
expect(check.out.trim().split('\n').pop()).toBe('MOTION_RESULT: clean (overflow not checked)');
const grow = run([ENCODE, 'check', cap, ...PHASES, '--mask-grow', '33']);
expect(grow.code).toBe(2);
expect(grow.err).toContain('--mask-grow must be a whole number of pixels from 0 to 32');
expect(capture(dir, page({ loops: true, contract: false })).code).toBe(1);
expect(fs.existsSync(path.join(cap, 'capture.json')) || fs.existsSync(path.join(cap, 'still.png'))).toBe(false);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}, 240_000);
// Value: protects=a mask that paints the whole frame fails overflow in a cards layout and is reported as not checked only for an app
// frame, instead of blacking out every pixel and passing; a --mask that matches nothing stops the capture before any frame;
// fails_when=the coverage guard, its app-only skip or the empty-mask check is dropped; why_new=adversarial review reproduced each;
// seam=none; no_claim=only a full-frame mask; the 90% line and a grow-driven failure are not swept
test('a mask that covers the frame fails cards and is not checked for an app frame; an empty mask stops the capture', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'motion-pipeline-'));
try {
expect(capture(dir, page({ loops: true }), { mask: 'html' }).code).toBe(0);
const cards = run([ENCODE, 'check', path.join(dir, 'cap'), ...PHASES]);
expect(cards.code).toBe(1);
expect(cards.out).toMatch(/MOTION_CHECK: overflow fail the mask covers \d+% of the frame; in a cards layout mark only the card containers/);
const app = run([ENCODE, 'check', path.join(dir, 'cap'), ...PHASES, '--layout', 'app']);
expect(app.code, app.out + app.err).toBe(0);
expect(app.out).toMatch(/MOTION_CHECK: overflow skipped \(the app frame's surfaces cover \d+% of the frame/);
expect(app.out.trim().split('\n').pop()).toBe('MOTION_RESULT: clean (overflow not checked)');
const empty = capture(dir, page({ loops: true }), { mask: '[data-nope]' });
expect(empty.code).toBe(1);
expect(empty.err).toContain("MOTION_MASK_EMPTY: --mask '[data-nope]' matches no element");
expect(fs.readdirSync(path.join(dir, 'cap', 'frames'))).toEqual([]);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}, 240_000);
// Value: protects=a CSS transition that a story starts with a class toggle is seeked from the frame it appears, not from the last
// __mark, so it eases instead of popping; fails_when=new animations are seeded at __mark again; why_new=adversarial probe showed a
// story transition exported ~100 ms ahead while every check passed; seam=none; no_claim=one linear opacity transition
test('a story transition started by a class toggle eases from the frame it starts', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'motion-pipeline-'));
try {
const c = capture(dir, `<!doctype html><html><head><style>html, body { margin: 0; background: #000; }
#s { position: absolute; inset: 0; background: #fff; opacity: 0; transition: opacity 0.2s linear; } #s.on { opacity: 1; }</style></head>
<body><div id="s"></div><script>const s = document.getElementById('s'); window.__lt = 0;
window.__start = () => { window.__lt = 0; window.__mark = performance.now(); };
window.__tick = dt => { window.__lt += dt; if (window.__lt >= 0.1) s.classList.add('on'); };
if (location.search.includes('still')) s.classList.add('on');</script></body></html>`, { mask: false });
expect(c.code, c.err).toBe(0);
const probe = run([FFMPEG!, '-hide_banner', '-loglevel', 'error', '-i', path.join(dir, 'cap', 'frames', '00026.png'), '-vf', 'format=gray,signalstats,metadata=print:key=lavfi.signalstats.YAVG:file=-', '-f', 'null', '-']);
const yavg = Number(/YAVG=([\d.]+)/.exec(probe.out)?.[1]);
expect(yavg).toBeGreaterThan(90);
expect(yavg).toBeLessThan(170);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}, 120_000);
// Value: protects=a page whose __tick never returns stops with MOTION_CAPTURE_FAILED instead of hanging the capture forever;
// fails_when=the per-frame 10 s bound is dropped; why_new=adversarial probe hung at 100% renderer CPU; seam=none;
// no_claim=only a spinning __tick; a hung screenshot is not bounded
test('a __tick that never returns fails the capture', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'motion-pipeline-'));
try {
const c = capture(dir, '<!doctype html><body><script>window.__lt = 0; window.__start = () => {}; window.__tick = dt => { window.__lt += dt; if (window.__lt > 0.05) for (;;) {} };</script></body>', { mask: false });
expect(c.code).toBe(1);
expect(c.err).toMatch(/MOTION_CAPTURE_FAILED: frame \d+ took longer than 10 s/);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}, 120_000);
// Value: protects=an offscreen loading=lazy image no longer hangs the still render (img.decode() never settles on it), and --still writes
// one PNG at --scale (the Step 3 fidelity reference);
// fails_when=the eager switch and the 30 s bound are both dropped and --still waits forever; why_new=adversarial probe hung at 90 s; seam=none;
// no_claim=one lazy image on a local page; a slow network image is bounded only by the 30 s timer
test('a page with an offscreen lazy image renders its still', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'motion-pipeline-'));
try {
fs.writeFileSync(path.join(dir, 'dot.png'), Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', 'base64'));
const file = path.join(dir, 'lazy.html');
fs.writeFileSync(file, '<!doctype html><body style="margin:0"><div style="height:5000px"></div><img loading="lazy" src="dot.png" width="4" height="4"></body>');
const out = path.join(dir, 'still.png');
const still = run([CAPTURE, file, '--size', '640x100', '--still', out, '--scale', '2']);
expect(still.code, still.err).toBe(0);
expect(still.out.trim()).toBe(`STILL_OK ${out}`);
expect(still.err).not.toContain('still loading');
const png = fs.readFileSync(out);
expect([png.readUInt32BE(16), png.readUInt32BE(20)]).toEqual([1280, 200]);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}, 200_000);
});
+665
View File
@@ -0,0 +1,665 @@
/**
* gstack-pr-wait (lib/pr-wait.ts, lib/pr-wait-github.ts): one stdout line per
* real event, state under the gstack state root, required checks from rulesets
* so an unstarted one is pending, two identical polls before green, the
* base-moved-and-touches-this-PR event, BEHIND and DIRTY, the per-PR lock, the
* 6-hour baseline staleness and the configurable bot lists. Every GitHub read
* goes through a fake gh runner, as test/gstack-ci-gate.test.ts does.
*/
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { spawn, spawnSync } from 'node:child_process';
import type { GhResult } from '../lib/ci-gate';
import { DEFAULT_ADVISORY_BOTS, DEFAULT_GATING_BOTS, PR_WAIT_EXIT, Watcher, botLists, parsePrTarget, parsePrWaitArgs, prWaitMain, stateDir, type PrWaitEnv, type PrWaitOptions } from '../lib/pr-wait';
import { GhError, runGh } from '../lib/pr-wait-github';
import { RESULT_CODES } from '../lib/result-codes';
const ROOT = join(import.meta.dir, '..');
const HEAD = 'a'.repeat(40);
const BASE_A = 'b'.repeat(40);
const BASE_B = 'c'.repeat(40);
const REPO = 'owner/project';
const ok = (stdout: string): GhResult => ({ status: 0, stdout, stderr: '' });
const err = (stderr: string, status = 1): GhResult => ({ status, stdout: '', stderr: `${stderr}\n` });
const NOT_FOUND = err('gh: Not Found (HTTP 404)');
interface Author { login: string; bot?: boolean }
const actor = ({ login, bot }: Author) => ({ login, __typename: bot ? 'Bot' : 'User' });
const comment = (id: string, author: Author, body: string) => ({ id, url: `https://github.com/${REPO}/pull/42#issuecomment-${id}`, body, author: actor(author) });
const review = (id: string, author: Author, state: string, body = '', commit: string | null = HEAD) =>
({ id, url: `https://github.com/${REPO}/pull/42#pullrequestreview-${id}`, state, body, commit: commit ? { oid: commit } : null, author: actor(author) });
const thread = (path: string, line: number | null, resolved: boolean, ...comments: ReturnType<typeof comment>[]) => ({ isResolved: resolved, path, line, comments: { nodes: comments } });
interface PrShape {
state?: string; head?: string; base?: string; baseOid?: string | null; mergeState?: string;
comments?: ReturnType<typeof comment>[]; reviews?: ReturnType<typeof review>[]; threads?: ReturnType<typeof thread>[];
}
const prJson = (shape: PrShape = {}) => JSON.stringify({
data: {
viewer: { login: 'me' },
repository: {
pullRequest: {
url: `https://github.com/${REPO}/pull/42`, state: shape.state ?? 'OPEN', headRefOid: shape.head ?? HEAD,
baseRefName: shape.base ?? 'main', mergeStateStatus: shape.mergeState ?? 'CLEAN', author: { login: 'author' },
baseRef: shape.baseOid === null ? null : { target: { oid: shape.baseOid ?? BASE_A } },
comments: { nodes: shape.comments ?? [] }, reviews: { nodes: shape.reviews ?? [] }, reviewThreads: { nodes: shape.threads ?? [] },
},
},
},
});
const checksJson = (...rows: Array<[string, string] | [string, string, string]>) =>
JSON.stringify(rows.map(([name, bucket, description]) => ({ name, bucket, link: `https://ci.example/${name}`, description: description ?? '' })));
/** Scripted replies per gh command shape; a list is consumed one poll at a time, the last reply repeating. */
interface Script {
pr?: string | string[];
checks?: GhResult | GhResult[];
rulesets?: GhResult;
protection?: GhResult;
prFiles?: GhResult;
compare?: Record<string, GhResult>;
behind?: GhResult;
}
function fakeGh(script: Script) {
// Function declarations, not generic arrows: pregate's import scanner reads tests as TSX, where `<T>(x) =>` is a tag.
function queue<T>(value: T | T[] | undefined): T[] { return value === undefined ? [] : Array.isArray(value) ? [...value] : [value]; }
function next<T>(q: T[]): T | undefined { return q.length > 1 ? q.shift() : q[0]; }
const prs = queue(script.pr).map(ok);
const checks = queue(script.checks);
const calls: string[][] = [];
const gh = (args: string[]): GhResult => {
calls.push(args);
const [a, b] = args;
if (a === 'api' && b === 'graphql') return next(prs) ?? ok(prJson());
if (a === 'pr' && b === 'checks') return next(checks) ?? ok(checksJson());
if (a === 'pr' && b === 'view') return ok('{"number":42}');
if (a === 'pr' && b === 'diff') return script.prFiles ?? ok('');
if (a === 'repo' && b === 'view') return ok(`${REPO}\n`);
if (a === 'api' && b.includes('/rules/branches/')) return script.rulesets ?? NOT_FOUND;
if (a === 'api' && b.includes('/protection/')) return script.protection ?? NOT_FOUND;
if (a === 'api' && b.includes('/compare/')) {
if (args.includes('.behind_by')) return script.behind ?? ok('0\n');
return script.compare?.[b.split('/compare/')[1]] ?? ok('');
}
throw new Error(`fake gh: unexpected ${args.join(' ')}`);
};
return Object.assign(gh, { calls });
}
let dir: string;
let stdout: string[];
let stderr: string[];
let clock: number;
let alive: Set<number>;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'pr-wait-'));
stdout = [];
stderr = [];
clock = 1_700_000_000_000;
alive = new Set([4242]);
});
afterEach(() => rmSync(dir, { recursive: true, force: true }));
function env(gh: PrWaitEnv['gh'], pid = 4242, vars: Record<string, string> = {}): PrWaitEnv {
return {
gh, vars: { HOME: dir, GSTACK_STATE_ROOT: join(dir, 'state'), ...vars }, pid,
now: () => clock, sleep: () => {}, out: t => stdout.push(t), err: t => stderr.push(t), pidAlive: p => alive.has(p),
};
}
const watcher = (script: Script, until: PrWaitOptions['until'] = 'ready', vars: Record<string, string> = {}) => {
const e = env(fakeGh(script), 4242, vars);
return new Watcher(e, REPO, 42, until, 20, botLists(e.vars));
};
/** Run polls until one returns a code or the budget is spent; the first poll is the baseline. */
function pollUntilDone(w: Watcher, budget = 6): number | null {
for (let i = 0; i < budget; i++) {
const code = w.poll();
if (code !== null) return code;
w.save();
w.baseline = false;
}
return null;
}
const lines = () => stdout.join('').split('\n').filter(Boolean);
const RULESETS = ok('free-tests\nwindows-free-tests\n');
describe('state lives under the gstack state root', () => {
test('state file and lock are <root>/pr-wait/<owner>__<name>__<pr>, never ~/.local/state', () => {
const w = watcher({});
expect(w.stateFile).toBe(join(dir, 'state', 'pr-wait', 'owner__project__42.json'));
expect(w.lockFile).toBe(join(dir, 'state', 'pr-wait', 'owner__project__42.lock'));
expect(stateDir({ HOME: dir })).toBe(join(dir, '.gstack', 'pr-wait'));
pollUntilDone(w, 1);
expect(existsSync(w.stateFile)).toBe(true);
expect(existsSync(join(dir, '.local'))).toBe(false);
});
test('a state file older than six hours is a fresh baseline: nothing from it is replayed and nothing new is reported on that poll', () => {
const file = join(dir, 'state', 'pr-wait', 'owner__project__42.json');
mkdirSync(join(dir, 'state', 'pr-wait'), { recursive: true });
writeFileSync(file, JSON.stringify({ reported: [], summaries: {}, base_oid: BASE_A, merge_state: 'CLEAN' }));
const old = new Date(clock - 7 * 3600 * 1000);
utimesSync(file, old, old);
const w = watcher({ pr: prJson({ comments: [comment('c1', { login: 'reviewer' }, 'looks wrong')] }) });
expect(w.baseline).toBe(true);
expect(w.poll()).toBeNull();
expect(lines()).toEqual([]);
const fresh = watcher({ pr: prJson({ comments: [comment('c1', { login: 'reviewer' }, 'looks wrong')] }) });
expect(fresh.baseline).toBe(true);
});
test('a state file within six hours carries the baseline across a restart, so only what arrived meanwhile prints', () => {
const first = watcher({ pr: prJson({ comments: [comment('c1', { login: 'reviewer' }, 'first')] }) });
pollUntilDone(first, 1);
expect(lines()).toEqual([]);
clock += 60 * 60 * 1000;
const restarted = watcher({ pr: prJson({ comments: [comment('c1', { login: 'reviewer' }, 'first'), comment('c2', { login: 'reviewer' }, 'second thought')] }) });
expect(restarted.baseline).toBe(false);
restarted.poll();
expect(lines()).toEqual([expect.stringMatching(/^#42 comment from reviewer: second thought https:/)]);
});
});
describe('events: one stdout line each, progress on stderr', () => {
test('the baseline poll records what exists and prints nothing; the next poll prints only new comments, reviews and thread replies, never the author, the gh user, or edits', () => {
const before = prJson({ comments: [comment('c1', { login: 'reviewer' }, 'old')] });
const after = prJson({
comments: [comment('c1', { login: 'reviewer' }, 'old, edited'), comment('c2', { login: 'author' }, 'mine'), comment('c3', { login: 'me' }, 'also mine'),
comment('c4', { login: 'github-actions', bot: true }, 'deploy preview ready'), comment('c5', { login: 'reviewer' }, '<b>Please</b> &amp; fix &lt;this&gt;')],
reviews: [review('r1', { login: 'reviewer' }, 'CHANGES_REQUESTED', 'nope'), review('r2', { login: 'lurker' }, 'COMMENTED', '')],
threads: [thread('lib/x.ts', 12, false, comment('t1', { login: 'reviewer' }, 'rename this'), comment('t2', { login: 'author' }, 'done'))],
});
const w = watcher({ pr: [before, after] }, 'any-change');
expect(w.run()).toBe(PR_WAIT_EXIT.met);
expect(lines()).toEqual([
'#42 comment from reviewer: Please & fix <this> https://github.com/owner/project/pull/42#issuecomment-c5',
'#42 review from reviewer (changes requested): nope https://github.com/owner/project/pull/42#pullrequestreview-r1',
'#42 new thread from reviewer on lib/x.ts:12: rename this https://github.com/owner/project/pull/42#issuecomment-t1',
]);
expect(stderr.join('')).toContain('watching owner/project#42 until any-change; required checks: none set; every check on the head gates');
expect(stderr.join('')).toContain('baseline recorded for owner/project#42');
});
test('merged ends with exit 0; closed without merging is PR_WAIT_BLOCKED', () => {
expect(pollUntilDone(watcher({ pr: prJson({ state: 'MERGED' }) }))).toBe(0);
expect(lines()).toEqual(['#42 merged: https://github.com/owner/project/pull/42']);
stdout = [];
expect(pollUntilDone(watcher({ pr: prJson({ state: 'CLOSED' }) }))).toBe(1);
expect(lines()).toEqual(['#42 closed without merging: https://github.com/owner/project/pull/42', expect.stringContaining('stopped: action needed (PR_WAIT_BLOCKED; https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#pr-wait-blocked)')]);
});
});
describe('required checks and the green condition', () => {
test('a required check named by the ruleset that has not started is pending, not green by omission', () => {
const w = watcher({ rulesets: RULESETS, checks: ok(checksJson(['free-tests', 'pass'])) }, 'green');
expect(pollUntilDone(w, 4)).toBeNull();
expect(lines()).toEqual([]);
expect(stderr.join('')).toContain('required checks: free-tests, windows-free-tests');
});
test('a required check that failed ends the watch at once, naming it with its link, even on the baseline poll', () => {
const w = watcher({ rulesets: RULESETS, checks: ok(checksJson(['free-tests', 'fail'], ['windows-free-tests', 'pending'])) }, 'green');
expect(w.poll()).toBe(PR_WAIT_EXIT.blocked);
expect(lines()).toEqual(['#42 required check failed: free-tests https://ci.example/free-tests', expect.stringContaining('(PR_WAIT_BLOCKED;')]);
});
test('an unknown bucket counts as failed, as gstack-ci-gate counts it', () => {
const w = watcher({ rulesets: RULESETS, checks: ok(checksJson(['free-tests', 'stale'], ['windows-free-tests', 'pass'])) }, 'green');
expect(w.poll()).toBe(PR_WAIT_EXIT.blocked);
expect(lines()[0]).toBe('#42 required check failed: free-tests https://ci.example/free-tests');
});
test('green needs two identical polls: a check set seen once is not stable', () => {
const w = watcher({ rulesets: RULESETS, checks: [ok(checksJson(['free-tests', 'pass'], ['windows-free-tests', 'pass'])), ok(checksJson(['free-tests', 'pass'], ['windows-free-tests', 'pass'], ['late-check', 'pass']))], behind: ok('2\n') }, 'green');
expect(w.poll()).toBeNull();
w.baseline = false;
expect(w.poll()).toBeNull();
expect(w.poll()).toBe(PR_WAIT_EXIT.met);
expect(lines()).toEqual(['#42 green on aaaaaaa: 3 pass; required 2/2 green; no greptile or macroscope yet; 0 unresolved threads; merge state CLEAN; 2 commits behind main']);
});
test('every check on the head gates, advisory bots\' own included, and a gating bot\'s review on the head makes it ready', () => {
const pr = prJson({ reviews: [review('g1', { login: 'greptile-apps', bot: true }, 'COMMENTED', 'Confidence score: 4/5')] });
const held = watcher({ pr, checks: ok(checksJson(['build', 'pass'], ['greptile-review', 'pass'], ['coderabbit', 'pending', 'Review in progress'])) });
expect(pollUntilDone(held, 4)).toBeNull();
expect(lines()).toEqual([]);
const w = watcher({ pr, checks: ok(checksJson(['build', 'pass'], ['greptile-review', 'pass'], ['coderabbit', 'pass', 'Reviewed'])) });
expect(pollUntilDone(w)).toBe(PR_WAIT_EXIT.met);
expect(lines()).toEqual(['#42 coderabbit finished on aaaaaaa (advisory, not waited on)', '#42 ready on aaaaaaa: 3 pass; greptile done, coderabbit Reviewed (advisory); 0 unresolved threads; merge state CLEAN; 0 commits behind main']);
});
test('ready is blocked by unresolved threads and by BEHIND, in that order', () => {
const base = { checks: ok(checksJson(['build', 'pass'])), rulesets: NOT_FOUND };
const reviewed = review('g1', { login: 'greptile-apps', bot: true }, 'COMMENTED', '');
expect(pollUntilDone(watcher({ ...base, pr: prJson({ reviews: [reviewed], threads: [thread('a.ts', 1, false, comment('t1', { login: 'reviewer' }, 'hm'))] }) }))).toBe(1);
expect(lines()[0]).toBe('#42 blocked by 1 unresolved threads on aaaaaaa: https://github.com/owner/project/pull/42#issuecomment-t1');
stdout = [];
expect(pollUntilDone(watcher({ ...base, pr: prJson({ reviews: [reviewed], mergeState: 'BEHIND' }) }))).toBe(1);
expect(lines()).toContain('#42 merge state is BEHIND on aaaaaaa: update the branch from main before merging');
});
});
describe('base branch and merge state', () => {
const compareKey = `${BASE_A}...${BASE_B}`;
test('the base moved and its new commits touch files in this PR → one event naming the overlap', () => {
const w = watcher({
pr: [prJson({ baseOid: BASE_A }), prJson({ baseOid: BASE_B })],
prFiles: ok('lib/pr-wait.ts\nREADME.md\n'),
compare: { [compareKey]: ok('lib/pr-wait.ts\nCHANGELOG.md\n') },
}, 'any-change');
expect(pollUntilDone(w)).toBe(PR_WAIT_EXIT.met);
expect(lines()).toEqual(['#42 main moved to ccccccc and touches files in this PR: lib/pr-wait.ts']);
});
test('the base moved without touching this PR → silence, and the new base oid is remembered', () => {
const w = watcher({
pr: [prJson({ baseOid: BASE_A }), prJson({ baseOid: BASE_B })],
prFiles: ok('lib/pr-wait.ts\n'),
compare: { [compareKey]: ok('CHANGELOG.md\n') },
}, 'any-change');
expect(pollUntilDone(w, 3)).toBeNull();
expect(lines()).toEqual([]);
expect(JSON.parse(readFileSync(w.stateFile, 'utf8')).base_oid).toBe(BASE_B);
});
test('a restarted watcher reports a base move that happened while no watcher ran', () => {
const first = watcher({ pr: prJson({ baseOid: BASE_A }) });
first.poll();
first.save();
const restarted = watcher({ pr: prJson({ baseOid: BASE_B }), prFiles: ok('x\n'), compare: { [compareKey]: ok('x\n') } }, 'any-change');
expect(restarted.baseline).toBe(false);
expect(restarted.poll()).toBe(PR_WAIT_EXIT.met);
expect(lines()).toEqual(['#42 main moved to ccccccc and touches files in this PR: x']);
});
test('BEHIND is an event once per head, again after a push that stays BEHIND; DIRTY ends the watch as blocked', () => {
const D = 'd'.repeat(40);
const behind = watcher({ pr: [prJson({ mergeState: 'CLEAN' }), prJson({ mergeState: 'BEHIND' }), prJson({ mergeState: 'BEHIND' }), prJson({ mergeState: 'UNKNOWN', head: D }), prJson({ mergeState: 'BEHIND', head: D })], checks: ok(checksJson(['build', 'pending'])) }, 'green');
expect(pollUntilDone(behind, 5)).toBeNull();
expect(lines()).toEqual([
'#42 merge state is BEHIND on aaaaaaa: update the branch from main before merging',
'#42 merge state is BEHIND on ddddddd: update the branch from main before merging',
]);
stdout = [];
const dirty = watcher({ pr: prJson({ mergeState: 'DIRTY' }) }, 'green');
expect(dirty.poll()).toBe(PR_WAIT_EXIT.blocked);
expect(lines()[0]).toBe('#42 merge state is DIRTY on aaaaaaa: the branch conflicts with main, rebase it');
});
});
describe('review bots are configuration', () => {
const withConfig = (yaml: string) => {
mkdirSync(join(dir, 'state'), { recursive: true });
writeFileSync(join(dir, 'state', 'config.yaml'), yaml);
};
test('defaults are tstack\'s: greptile and macroscope gate, coderabbit is advisory', () => {
expect(botLists({ HOME: dir, GSTACK_STATE_ROOT: join(dir, 'state') })).toEqual({ gating: ['greptile', 'macroscope'], advisory: ['coderabbit'] });
});
test('pr_wait_gating_bots decides which bot\'s review makes the PR ready', () => {
withConfig('pr_wait_gating_bots: macroscope\n');
const pr = prJson({ reviews: [review('g1', { login: 'greptile-apps', bot: true }, 'COMMENTED', '')] });
const w = watcher({ pr, checks: [ok(checksJson(['build', 'pending'])), ok(checksJson(['build', 'pass']))] });
expect(w.bots).toEqual({ gating: ['macroscope'], advisory: ['coderabbit'] });
expect(pollUntilDone(w, 4)).toBeNull();
expect(lines()).toEqual([expect.stringMatching(/^#42 checks finished on aaaaaaa: 1 pass; no macroscope yet; 0 unresolved threads; merge state CLEAN$/)]);
});
test('none empties a list', () => {
withConfig('pr_wait_advisory_bots: none\n');
expect(botLists({ HOME: dir, GSTACK_STATE_ROOT: join(dir, 'state') })).toEqual({ gating: ['greptile', 'macroscope'], advisory: [] });
});
test('pr_wait_advisory_bots: its review is reported but never waited on, while its check gates like any other', () => {
withConfig('pr_wait_gating_bots: greptile\npr_wait_advisory_bots: coderabbit, Sourcery\n');
const pr = prJson({ reviews: [review('g1', { login: 'greptile-apps', bot: true }, 'COMMENTED', '')] });
const w = watcher({ pr, checks: ok(checksJson(['build', 'pass'], ['sourcery-ai', 'pass', 'Reviewed'])) });
expect(w.bots.advisory).toEqual(['coderabbit', 'sourcery']);
expect(pollUntilDone(w)).toBe(PR_WAIT_EXIT.met);
expect(lines()[0]).toContain('ready on aaaaaaa: 2 pass; greptile done, sourcery Reviewed (advisory)');
});
});
describe('the per-PR lock', () => {
test('a live holder refuses the second watcher with PR_WAIT_REFUSED; a dead holder is taken over and released on exit', () => {
const first = watcher({});
expect(first.lock()).toBeNull();
const second = new Watcher(env(fakeGh({}), 4343), REPO, 42, 'ready', 20, botLists({}));
expect(second.lock()).toEqual({ held: '4242' });
alive.clear();
expect(second.lock()).toBeNull();
expect(readFileSync(second.lockFile, 'utf8')).toBe('4343');
first.unlock();
expect(existsSync(second.lockFile)).toBe(true);
second.unlock();
expect(existsSync(second.lockFile)).toBe(false);
});
test('prWaitMain refuses a duplicate watcher before any poll', () => {
const w = watcher({});
expect(w.lock()).toBeNull();
const gh = fakeGh({});
const code = prWaitMain(['42', '--repo', REPO], env(gh, 9999));
expect(code).toBe(PR_WAIT_EXIT.refused);
expect(lines()).toEqual([expect.stringMatching(/^gstack-pr-wait: the watcher did not start, or a local error stopped it before a verdict — pid 4242 already watches owner\/project#42.*\(PR_WAIT_REFUSED; https:\/\/github\.com\/garrytan\/gstack\/blob\/main\/docs\/troubleshooting\.md#pr-wait-refused\)$/)]);
expect(gh.calls.some(c => c[1] === 'graphql')).toBe(false);
});
});
describe('arguments and the gh layer', () => {
test('targets: number, #number, URL (repo from the URL), --repo override; bad input is refused', () => {
expect(parsePrTarget('42')).toEqual({ repo: undefined, pr: 42 });
expect(parsePrTarget('#42', REPO)).toEqual({ repo: REPO, pr: 42 });
expect(parsePrTarget('https://github.com/garrytan/gstack/pull/3481/files')).toEqual({ repo: 'garrytan/gstack', pr: 3481 });
expect(parsePrTarget('')).toMatchObject({ error: expect.stringContaining('missing PR number') });
expect(parsePrTarget('abc')).toMatchObject({ error: "'abc' is not a PR number or PR URL" });
expect(parsePrTarget('0')).toMatchObject({ error: "'0' is not a valid PR number" });
expect(parsePrTarget('42', 'nope')).toMatchObject({ error: "'nope' is not owner/name" });
expect(parsePrWaitArgs(['42', '--until', 'green', '--interval', '2'])).toEqual({ target: '42', until: 'green', interval: 5, timeout: 60 });
expect(parsePrWaitArgs(['42', '--timeout', '0'])).toMatchObject({ timeout: 0 });
expect(parsePrWaitArgs(['42', '--timeout', 'soon'])).toMatchObject({ error: expect.stringContaining('--timeout needs') });
expect(parsePrWaitArgs(['--until', 'soon'])).toMatchObject({ error: expect.stringContaining('--until needs') });
expect(parsePrWaitArgs(['--bogus'])).toMatchObject({ error: 'unknown argument --bogus' });
expect(parsePrWaitArgs(['--help'])).toEqual({ help: true });
});
test('main: --help exits 0, bad input exits 2 with the code and anchor, an unreadable PR exits 2', () => {
expect(prWaitMain(['--help'], env(fakeGh({})))).toBe(0);
expect(stdout.join('')).toContain('--until');
stdout = [];
expect(prWaitMain([], env(fakeGh({})))).toBe(2);
expect(lines()[0]).toMatch(/missing PR number.*\(PR_WAIT_REFUSED; .*#pr-wait-refused\)$/);
stdout = [];
const gone = (args: string[]): GhResult => (args[1] === 'view' ? err('GraphQL: Could not resolve to a PullRequest with the number of 42. (HTTP 404)') : ok(''));
expect(prWaitMain(['42', '--repo', REPO], env(gone))).toBe(2);
expect(lines()[0]).toContain('cannot read owner/project#42: GraphQL: Could not resolve');
expect(RESULT_CODES.PR_WAIT_REFUSED.anchor).toBe('pr-wait-refused');
expect(RESULT_CODES.PR_WAIT_BLOCKED.anchor).toBe('pr-wait-blocked');
});
test('a transient gh failure is retried with backoff and never ends the watch; a 403 on classic protection is logged, not fatal', () => {
let graphqlCalls = 0;
const base = fakeGh({ checks: ok(checksJson(['build', 'pass'])), protection: err('gh: Resource not accessible by integration (HTTP 403)') });
const flaky = (args: string[]): GhResult => {
if (args[1] === 'graphql' && graphqlCalls++ === 0) return err('gh: Something went wrong (HTTP 502)');
return base(args);
};
const e = env(flaky);
const w = new Watcher(e, REPO, 42, 'green', 20, botLists(e.vars));
expect(w.poll()).toBeNull();
expect(graphqlCalls).toBe(2);
expect(stderr.join('')).toContain('gh api graphql: gh: Something went wrong (HTTP 502); retry in 2s');
expect(stderr.join('')).toContain('could not read required checks from repos/owner/project/branches/main/protection/required_status_checks: gh: Resource not accessible by integration (HTTP 403)');
});
test('the bin runs on the isolated bun shebang and prints the usage', () => {
const result = spawnSync(join(ROOT, 'bin', 'gstack-pr-wait'), ['--help'], { encoding: 'utf8', cwd: ROOT, timeout: 30_000 });
expect(result.status).toBe(0);
expect(result.stdout).toContain('gstack-config set pr_wait_gating_bots greptile,macroscope');
expect(readFileSync(join(ROOT, 'bin', 'gstack-pr-wait'), 'utf8').startsWith('#!/usr/bin/env -S bun --no-env-file --config=/dev/null\n')).toBe(true);
});
});
describe('pass-1 review fixes: gates agree, bounded waits, bot identity, local faults', () => {
function withConfig(yaml: string) {
mkdirSync(join(dir, 'state'), { recursive: true });
writeFileSync(join(dir, 'state', 'config.yaml'), yaml);
}
const GREPTILE = review('g1', { login: 'greptile-apps', bot: true }, 'COMMENTED', '');
test('green gates on every check on the head, as gstack-ci-gate does: a pending non-required check holds it and a red one blocks it', () => {
const pending = watcher({ rulesets: RULESETS, checks: ok(checksJson(['free-tests', 'pass'], ['windows-free-tests', 'pass'], ['quality-gate', 'pending'])) }, 'green');
expect(pollUntilDone(pending, 4)).toBeNull();
expect(lines()).toEqual([]);
const red = watcher({ rulesets: RULESETS, checks: ok(checksJson(['free-tests', 'pass'], ['windows-free-tests', 'pass'], ['quality-gate', 'fail'])) }, 'green');
expect(red.poll()).toBe(PR_WAIT_EXIT.blocked);
expect(lines()).toEqual(['#42 check failed: quality-gate https://ci.example/quality-gate', expect.stringContaining('(PR_WAIT_BLOCKED;')]);
});
test('a failed bot check is a failed check, gating or advisory, as gstack-ci-gate counts it; never a completed review', () => {
const gating = watcher({ pr: prJson({ reviews: [] }), checks: ok(checksJson(['build', 'pass'], ['greptile-review', 'fail'])) });
expect(pollUntilDone(gating)).toBe(PR_WAIT_EXIT.blocked);
expect(lines()[0]).toBe('#42 check failed: greptile-review https://ci.example/greptile-review');
stdout = [];
const advisory = watcher({ pr: prJson({ reviews: [GREPTILE] }), checks: ok(checksJson(['build', 'pass'], ['coderabbit', 'fail'])) });
expect(pollUntilDone(advisory)).toBe(PR_WAIT_EXIT.blocked);
expect(lines()).toEqual([
'#42 check failed: coderabbit https://ci.example/coderabbit',
'#42 greptile finished on aaaaaaa',
'#42 coderabbit finished on aaaaaaa: failed: coderabbit (advisory, not waited on)',
expect.stringContaining('(PR_WAIT_BLOCKED;'),
]);
});
test('BEHIND is keyed on the head alone: a base that keeps moving does not repeat the line, even across restarts', () => {
const first = watcher({ pr: [prJson({ mergeState: 'BEHIND', baseOid: BASE_A }), prJson({ mergeState: 'BEHIND', baseOid: BASE_B })], checks: ok(checksJson(['build', 'pending'])), prFiles: ok('x\n'), compare: { [`${BASE_A}...${BASE_B}`]: ok('') } }, 'any-change');
expect(pollUntilDone(first, 3)).toBeNull();
expect(lines()).toEqual([]);
const restarted = watcher({ pr: prJson({ mergeState: 'BEHIND', baseOid: 'e'.repeat(40) }), checks: ok(checksJson(['build', 'pending'])), prFiles: ok('x\n'), compare: {} }, 'any-change');
expect(restarted.baseline).toBe(false);
expect(pollUntilDone(restarted, 3)).toBeNull();
expect(lines()).toEqual([]);
});
test('a verdict keeps its exit code when the state file cannot be written; the save error goes to stderr', () => {
const w = watcher({ pr: prJson({ state: 'MERGED' }) }, 'green');
rmSync(join(dir, 'state'), { recursive: true, force: true });
writeFileSync(join(dir, 'state'), '');
expect(w.poll()).toBe(PR_WAIT_EXIT.met);
expect(lines()).toEqual(['#42 merged: https://github.com/owner/project/pull/42']);
expect(stderr.join('')).toContain('could not save state');
});
test('a bot owns logins that start with its name, so a person called notgreptile is a reviewer, not the gating bot', () => {
const human = review('h1', { login: 'notgreptile' }, 'COMMENTED', 'looks fine');
const w = watcher({ pr: [prJson({}), prJson({ reviews: [human] })], checks: ok(checksJson(['build', 'pass'])) });
expect(pollUntilDone(w, 4)).toBeNull();
expect(lines()).toEqual(['#42 review from notgreptile (commented): looks fine https://github.com/owner/project/pull/42#pullrequestreview-h1']);
});
test('ready with no gating bot configured is green plus the thread and merge-state checks', () => {
withConfig('pr_wait_gating_bots: none\n');
const w = watcher({ checks: ok(checksJson(['build', 'pass'])) });
expect(w.bots.gating).toEqual([]);
expect(pollUntilDone(w, 4)).toBe(PR_WAIT_EXIT.met);
expect(lines()[0]).toContain('ready on aaaaaaa: 1 pass; no review bot yet');
});
test('ready waits while the merge state is UNKNOWN and is met once GitHub knows it', () => {
const w = watcher({ pr: [prJson({ mergeState: 'UNKNOWN', reviews: [GREPTILE] }), prJson({ mergeState: 'UNKNOWN', reviews: [GREPTILE] }), prJson({ mergeState: 'UNKNOWN', reviews: [GREPTILE] }), prJson({ mergeState: 'CLEAN', reviews: [GREPTILE] })], checks: ok(checksJson(['build', 'pass'])) });
expect(w.poll()).toBeNull();
w.baseline = false;
expect(w.poll()).toBeNull();
expect(w.poll()).toBeNull();
expect(lines()).toEqual([]);
expect(w.poll()).toBe(PR_WAIT_EXIT.met);
expect(lines()[0]).toContain('merge state CLEAN');
});
test('--timeout ends a watch that met nothing as PR_WAIT_BLOCKED, naming what is still pending; 0 waits without limit', () => {
function timed(timeout: number) {
const e = env(fakeGh({ checks: ok(checksJson(['build', 'pending'], ['docs', 'pass'])) }));
e.sleep = ms => { clock += ms; };
return new Watcher(e, REPO, 42, 'green', 20, botLists(e.vars), timeout);
}
expect(timed(1).run()).toBe(PR_WAIT_EXIT.blocked);
expect(lines()).toEqual(['#42 gstack-pr-wait stopped: no green after 1 minutes; still pending: build', expect.stringContaining('(PR_WAIT_BLOCKED;')]);
stdout = [];
const forever = timed(0);
let polls = 0;
const poll = forever.poll.bind(forever);
forever.poll = () => { polls += 1; return polls < 50 ? poll() : PR_WAIT_EXIT.met; };
expect(forever.run()).toBe(PR_WAIT_EXIT.met);
expect(polls).toBe(50);
});
test('run() ends as blocked after ten transient failures, and at once on a non-transient gh error', () => {
const down = (args: string[]): GhResult => (args[1] === 'graphql' ? err('gh: Something went wrong (HTTP 502)') : ok(''));
const e = env(down);
expect(new Watcher(e, REPO, 42, 'green', 20, botLists(e.vars)).run()).toBe(PR_WAIT_EXIT.blocked);
expect(stderr.join('').match(/poll failed \(\d+\/10\)/g)).toHaveLength(10);
expect(lines()[0]).toBe('#42 gstack-pr-wait stopped: GitHub unreachable for 10 polls: gh: Something went wrong (HTTP 502)');
stdout = [];
const gone = (args: string[]): GhResult => (args[1] === 'graphql' ? err('gh: Not Found (HTTP 404)') : ok(''));
const e2 = env(gone);
expect(new Watcher(e2, REPO, 42, 'green', 20, botLists(e2.vars)).run()).toBe(PR_WAIT_EXIT.blocked);
expect(lines()[0]).toBe('#42 gstack-pr-wait stopped: gh: Not Found (HTTP 404)');
});
test('a gh spawn timeout is retried like any transient failure; a missing gh is fatal', () => {
const results: GhResult[] = [{ status: null, stdout: '', stderr: '', error: 'gh failed to run (ETIMEDOUT)' }, ok('fine')];
const slept: number[] = [];
const ghEnv = { gh: () => results.shift()!, sleep: (ms: number) => slept.push(ms), log: () => {} };
expect(runGh(ghEnv, ['api', 'x']).stdout).toBe('fine');
expect(slept).toEqual([2000]);
const missing = { gh: (): GhResult => ({ status: null, stdout: '', stderr: '', error: 'gh is not installed or not on PATH' }), sleep: () => {}, log: () => {} };
expect(() => runGh(missing, ['api', 'x'])).toThrow(GhError);
});
test('a local fault is a refusal (exit 2 with PR_WAIT_REFUSED), so exit 1 always carries a PR_WAIT_BLOCKED line', () => {
writeFileSync(join(dir, 'not-a-dir'), '');
const e = env(fakeGh({}), 4242, { GSTACK_STATE_ROOT: join(dir, 'not-a-dir') });
expect(prWaitMain(['42', '--repo', REPO], e)).toBe(PR_WAIT_EXIT.refused);
expect(lines()[0]).toMatch(/a local error stopped the watch before a verdict: .*(ENOTDIR|EEXIST|not a directory).*\(PR_WAIT_REFUSED;/);
});
test('control characters in GitHub text never start a second event line', () => {
const w = watcher({ checks: ok(checksJson(['build\n#42 forged event', 'fail'])) }, 'green');
expect(w.poll()).toBe(PR_WAIT_EXIT.blocked);
expect(lines()[0]).toBe('#42 check failed: build #42 forged event https://ci.example/build #42 forged event');
expect(lines()).toHaveLength(2);
});
test('gstack-config validates the bot lists at set time and its defaults match the lib constants', () => {
const run = (...args: string[]) => spawnSync(join(ROOT, 'bin', 'gstack-config'), args, { encoding: 'utf8', cwd: ROOT, timeout: 30_000, env: { ...process.env, GSTACK_STATE_ROOT: join(dir, 'state'), HOME: dir } });
for (const bad of ['Greptile', 'a, b', 'a,', '']) {
const r = run('set', 'pr_wait_gating_bots', bad);
expect(r.status).toBe(1);
expect(r.stderr).toContain('Existing value left unchanged');
}
expect(run('get', 'pr_wait_gating_bots').stdout.trim()).toBe(DEFAULT_GATING_BOTS.join(','));
expect(run('get', 'pr_wait_advisory_bots').stdout.trim()).toBe(DEFAULT_ADVISORY_BOTS.join(','));
expect(run('set', 'pr_wait_advisory_bots', 'none').status).toBe(0);
expect(run('set', 'pr_wait_gating_bots', 'greptile,macroscope.v2').status).toBe(0);
expect(run('get', 'pr_wait_gating_bots').stdout.trim()).toBe('greptile,macroscope.v2');
expect(botLists({ HOME: dir, GSTACK_STATE_ROOT: join(dir, 'state') })).toEqual({ gating: ['greptile', 'macroscope.v2'], advisory: [] });
});
test('the bin dies on SIGTERM at once (no JS handler swallows it) and the next watcher takes over its lock', async () => {
const fakeBin = join(dir, 'fakebin');
mkdirSync(fakeBin);
writeFileSync(join(fakeBin, 'gh'), `#!/bin/sh
case "$1 $2" in
'pr view') echo '{"number":42}' ;;
'api graphql') echo '${prJson({})}' ;;
'pr checks') echo '${checksJson(['build', 'pending'])}'; exit 8 ;;
*) echo 'gh: Not Found (HTTP 404)' >&2; exit 1 ;;
esac`, { mode: 0o755 });
const stateRoot = join(dir, 'state');
const child = spawn(join(ROOT, 'bin', 'gstack-pr-wait'), ['42', '--repo', REPO, '--interval', '5'], { cwd: ROOT, env: { ...process.env, PATH: `${fakeBin}:${process.env.PATH}`, GSTACK_STATE_ROOT: stateRoot, HOME: dir } });
const lockFile = join(stateRoot, 'pr-wait', 'owner__project__42.lock');
const started = Date.now();
while (!existsSync(lockFile) && Date.now() - started < 15_000) await Bun.sleep(100);
expect(existsSync(lockFile)).toBe(true);
child.kill('SIGTERM');
const ended = await Promise.race([
new Promise<string>(resolve => child.on('exit', (code, signal) => resolve(`exit ${code} ${signal}`))),
Bun.sleep(5_000).then(() => 'alive'),
]);
expect(ended).toBe('exit null SIGTERM');
expect(readFileSync(lockFile, 'utf8')).toBe(String(child.pid));
const next = watcher({});
expect(next.lock()).toBeNull();
expect(readFileSync(lockFile, 'utf8')).toBe('4242');
}, 30_000);
});
describe('state matrix cells (gstack-state-matrix: --until × bucket, merge state × --until, lock, baseline × event)', () => {
const GREPTILE = review('g1', { login: 'greptile-apps', bot: true }, 'COMMENTED', '');
test('ready × fail: exit 1 with the failed-check line and PR_WAIT_BLOCKED', () => {
const w = watcher({ rulesets: RULESETS, checks: ok(checksJson(['free-tests', 'fail'], ['windows-free-tests', 'pass'])) });
expect(w.poll()).toBe(PR_WAIT_EXIT.blocked);
expect(lines()).toEqual(['#42 required check failed: free-tests https://ci.example/free-tests', expect.stringContaining('(PR_WAIT_BLOCKED;')]);
});
test('any-change × pass: silent while nothing is new; one "checks finished" line once the set turns stable, then exit 0', () => {
const w = watcher({ checks: [ok(checksJson(['build', 'pending'])), ok(checksJson(['build', 'pass']))] }, 'any-change');
expect(w.poll()).toBeNull();
w.baseline = false;
expect(w.poll()).toBeNull();
expect(lines()).toEqual([]);
expect(w.poll()).toBe(PR_WAIT_EXIT.met);
expect(lines()).toEqual([expect.stringMatching(/^#42 checks finished on aaaaaaa: 1 pass; no greptile or macroscope yet; 0 unresolved threads; merge state CLEAN$/)]);
});
test('any-change × pending: silent, keeps polling', () => {
expect(pollUntilDone(watcher({ checks: ok(checksJson(['build', 'pending'])) }, 'any-change'), 4)).toBeNull();
expect(lines()).toEqual([]);
});
test('any-change × fail: the failed check is an event and the watch ends with exit 0, not blocked', () => {
const w = watcher({ checks: [ok(checksJson(['build', 'pending'])), ok(checksJson(['build', 'fail']))] }, 'any-change');
expect(pollUntilDone(w)).toBe(PR_WAIT_EXIT.met);
expect(lines()).toEqual(['#42 check failed: build https://ci.example/build']);
});
test('BEHIND × any-change and DIRTY × any-change: one merge-state line each, exit 0, no PR_WAIT_BLOCKED', () => {
const behind = watcher({ pr: [prJson({ mergeState: 'CLEAN' }), prJson({ mergeState: 'BEHIND' })], checks: ok(checksJson(['build', 'pending'])) }, 'any-change');
expect(pollUntilDone(behind)).toBe(PR_WAIT_EXIT.met);
expect(lines()).toEqual(['#42 merge state is BEHIND on aaaaaaa: update the branch from main before merging']);
stdout = [];
const dirty = watcher({ pr: [prJson({ mergeState: 'CLEAN' }), prJson({ mergeState: 'DIRTY' })], checks: ok(checksJson(['build', 'pending'])) }, 'any-change');
expect(pollUntilDone(dirty)).toBe(PR_WAIT_EXIT.met);
expect(lines()).toEqual(['#42 merge state is DIRTY on aaaaaaa: the branch conflicts with main, rebase it']);
});
test('DIRTY × ready: exit 1 with the DIRTY line and PR_WAIT_BLOCKED', () => {
const w = watcher({ pr: prJson({ mergeState: 'DIRTY', reviews: [GREPTILE] }), checks: ok(checksJson(['build', 'pass'])) });
expect(w.poll()).toBe(PR_WAIT_EXIT.blocked);
expect(lines()).toEqual(['#42 merge state is DIRTY on aaaaaaa: the branch conflicts with main, rebase it', expect.stringContaining('(PR_WAIT_BLOCKED;')]);
});
test('UNKNOWN × green, UNKNOWN × any-change: no merge-state event; green is decided by the checks alone', () => {
const green = watcher({ pr: prJson({ mergeState: 'UNKNOWN' }), checks: ok(checksJson(['build', 'pass'])) }, 'green');
expect(pollUntilDone(green)).toBe(PR_WAIT_EXIT.met);
expect(lines()).toEqual([expect.stringMatching(/^#42 green on aaaaaaa: 1 pass; no greptile or macroscope yet; 0 unresolved threads; merge state UNKNOWN; 0 commits behind main$/)]);
expect(JSON.parse(readFileSync(green.stateFile, 'utf8')).merge_state).toBeNull();
stdout = [];
expect(pollUntilDone(watcher({ pr: prJson({ mergeState: 'UNKNOWN' }), checks: ok(checksJson(['build', 'pending'])) }, 'any-change'), 4)).toBeNull();
expect(lines()).toEqual([]);
});
test('lock: free × unlock is a no-op; dead-pid × unlock leaves the stale file for the next lock() to take over', () => {
const w = watcher({});
expect(() => w.unlock()).not.toThrow();
expect(existsSync(w.lockFile)).toBe(false);
mkdirSync(join(w.lockFile, '..'), { recursive: true });
writeFileSync(w.lockFile, '999999');
w.unlock();
expect(readFileSync(w.lockFile, 'utf8')).toBe('999999');
expect(w.lock()).toBeNull();
expect(readFileSync(w.lockFile, 'utf8')).toBe('4242');
});
test('restarted × required-check-failed and restarted × merged: the verdict line is printed again by the new process', () => {
const first = watcher({ rulesets: RULESETS, checks: ok(checksJson(['free-tests', 'fail'], ['windows-free-tests', 'pass'])) }, 'green');
expect(first.poll()).toBe(PR_WAIT_EXIT.blocked);
stdout = [];
const again = watcher({ rulesets: RULESETS, checks: ok(checksJson(['free-tests', 'fail'], ['windows-free-tests', 'pass'])) }, 'green');
expect(again.baseline).toBe(false);
expect(again.poll()).toBe(PR_WAIT_EXIT.blocked);
expect(lines()[0]).toBe('#42 required check failed: free-tests https://ci.example/free-tests');
stdout = [];
const merged = watcher({ pr: prJson({ state: 'MERGED' }) }, 'green');
expect(merged.baseline).toBe(false);
expect(merged.poll()).toBe(PR_WAIT_EXIT.met);
expect(lines()).toEqual(['#42 merged: https://github.com/owner/project/pull/42']);
});
});
+31
View File
@@ -90,4 +90,35 @@ describe('pregate invalidation', () => {
expect(bad.status).toBe(EXIT.fail);
expect(bad.err).toContain('(ARTIFACT_SCHEMA)');
}, 30_000);
// Value: protects=every record gstack-pregate writes (run and clear) is mirrored byte for byte to <state root>/pregate/by-tree/<tree>.json, the path
// gstack-ship-queue ready reads for a checkout that did not run the pregate, and a state root that cannot be written still leaves the record and
// the exit code untouched; fails_when=the mirror is missing, lands under another path or name, holds different bytes than the record, is not
// refreshed by clear, or an unwritable state root fails the run; why_new=the ship-queue test writes the mirror by hand and covers only the reader;
// seam=none; no_claim=a second checkout reading it (test/gstack-ship-queue.test.ts covers ready)
test('run and clear mirror the record to <state root>/pregate/by-tree/<tree>.json; an unwritable state root never fails the record', () => {
const repo = fixture();
commit(repo, { 'test/windows-paths.test.js': "const test = require('node:test');\ntest('windows paths', () => { /* touched */ });\n" });
expect(run(repo, []).status).toBe(EXIT.ok);
const record = fs.readFileSync(outFile(repo), 'utf8');
const tree = (JSON.parse(record) as { tree: string }).tree;
expect(tree).toMatch(/^[0-9a-f]{12,128}$/);
const mirror = path.join(repo.home, 'pregate', 'by-tree', `${tree}.json`);
expect(fs.readdirSync(path.join(repo.home, 'pregate', 'by-tree'))).toEqual([`${tree}.json`]);
expect(fs.readFileSync(mirror, 'utf8')).toBe(record);
expect(runBin(repo, 'gstack-pregate', ['clear', '--remote', 'windows.yml/windows-shard', '--run-url', 'https://github.com/o/r/actions/runs/42']).status).toBe(EXIT.ok);
const cleared = fs.readFileSync(outFile(repo), 'utf8');
expect(cleared).not.toBe(record);
expect(fs.readFileSync(mirror, 'utf8')).toBe(cleared);
expect(JSON.parse(fs.readFileSync(mirror, 'utf8')).requires_remote[0].receipt).toBe('https://github.com/o/r/actions/runs/42');
const blocked = path.join(repo.root, 'not-a-dir');
fs.writeFileSync(blocked, 'a file where the state root should be\n');
const unwritable = runBin(repo, 'gstack-pregate', ['--base', 'main', '--allow-repo-commands'], { GSTACK_STATE_ROOT: path.join(blocked, 'root') });
expect(unwritable.status, unwritable.out + unwritable.err).toBe(EXIT.ok);
expect(unwritable.err).not.toMatch(/ENOTDIR|EACCES|by-tree/);
expect(JSON.parse(fs.readFileSync(outFile(repo), 'utf8')).tree).toBe(tree);
expect(fs.existsSync(path.join(blocked, 'root'))).toBe(false);
expect(fs.readFileSync(mirror, 'utf8')).toBe(cleared);
}, 120_000);
});
+2 -2
View File
@@ -161,8 +161,8 @@ describe('QA-only cross-host lazy rendering', () => {
expect(fs.readFileSync(path.join(rendered, dir, 'SKILL.md'), 'utf8')).not.toContain(body.split('\n').slice(2).join('\n').trim());
}
const outside = generated.artifacts.filter(artifact => artifact.host === host.name && artifact.kind === 'section'
&& !/^(?:qa|qa-only|ship|plan-ceo-review|office-hours)\//.test(artifact.relativePath)
&& !/\/gstack-(?:qa(?:-only)?|ship|plan-ceo-review|office-hours)\//.test(artifact.relativePath));
&& !/^(?:qa|qa-only|ship|plan-ceo-review|office-hours|design-review)\//.test(artifact.relativePath)
&& !/\/gstack-(?:qa(?:-only)?|ship|plan-ceo-review|office-hours|design-review)\//.test(artifact.relativePath));
expect(outside.length > 0).toBe(host.name === 'claude');
// C4: ship is carved on every host; external pointers are relative to the installed skill.
const ctx = context(host.name, 'ship');
+171 -3
View File
@@ -14,7 +14,7 @@ import * as path from 'node:path';
import { bunIsolationArgs } from '../lib/bun-isolation';
import {
BACKSTOP_CHECK, NOTES_CAP, NOTES_REF, REQUIRED_CHECK, changelogEntries, compareVersions, cutRelease, describePlan, gateVerdict, gitIn,
planRelease, releaseNotes, type Gh,
planRelease, readTrainNotes, releaseNotes, type Gh, type Git,
} from '../scripts/release-cut';
const bases: string[] = [];
@@ -154,13 +154,13 @@ describe('release-cut: planning', () => {
const queued = checks([{ check_runs: [...GREEN, { name: 'slices-report', status: 'queued', conclusion: null }] }]);
expect(gateVerdict(queued, 'o/r', 'a'.repeat(40))).toEqual({ green: false, why: 'check runs still going: slices-report', pending: true });
const paged = checks([{ check_runs: [{ name: 'typecheck', status: 'completed', conclusion: 'success' }, { name: BACKSTOP_CHECK, status: 'completed', conclusion: 'success' }] }, { check_runs: [{ name: REQUIRED_CHECK, status: 'completed', conclusion: 'success' }] }]);
expect(gateVerdict(paged, 'o/r', 'a'.repeat(40))).toEqual({ green: true });
expect(gateVerdict(paged, 'o/r', 'a'.repeat(40))).toEqual({ green: true, via: BACKSTOP_CHECK });
const pagedRed = checks([{ check_runs: GREEN }, { check_runs: [{ name: 'windows-free-tests', status: 'completed', conclusion: 'timed_out' }] }]);
expect(gateVerdict(pagedRed, 'o/r', 'a'.repeat(40))).toEqual({ green: false, why: 'red check runs: windows-free-tests', pending: false });
const cancelled = checks([{ check_runs: [...GREEN, { name: 'quality', status: 'completed', conclusion: 'cancelled' }, { name: 'docs', status: 'completed', conclusion: 'skipped' }] }]);
expect(gateVerdict(cancelled, 'o/r', 'a'.repeat(40))).toEqual({ green: false, why: 'red check runs: quality', pending: false });
const skipped = checks([{ check_runs: [...GREEN, { name: 'docs', status: 'completed', conclusion: 'skipped' }] }]);
expect(gateVerdict(skipped, 'o/r', 'a'.repeat(40))).toEqual({ green: true });
expect(gateVerdict(skipped, 'o/r', 'a'.repeat(40))).toEqual({ green: true, via: BACKSTOP_CHECK });
});
test('an active freeze in refs/notes/gstack-trains refuses the cut; a cleared one does not', () => {
@@ -175,6 +175,174 @@ describe('release-cut: planning', () => {
git(f.seed, 'push', '-q', '-f', 'origin', NOTES_REF);
expect(planRelease({ repo: f.repo, git: f.git, gh: f.gh }).ok).toBe(true);
});
// Value: protects=one read of the trains notes ref feeds both gates: a fetch failure other than a missing ref refuses the plan with its message, a
// missing ref is no freeze and no evidence, a blob that is not JSON, is JSON that is not an object (null, an array, a string, a number), carries a
// malformed merged[] or a non-string nonce, or is a train without a nonce is skipped while a freeze and a train in the same
// ref are both read (the freeze still wins over the evidence); fails_when=a fetch error is read as "no notes" and a frozen or ungated main is
// released, an unparsable note aborts the read, or evidence from a later note is lost once a freeze is seen; why_new=the freeze test covers one
// readable freeze record only and the evidence tests never mix record kinds or break the fetch; seam=none; no_claim=a real origin outage
test('readTrainNotes: a fetch error refuses, a missing ref holds nothing, junk notes are skipped, and a freeze beside train evidence still refuses', () => {
const f = fixture();
const head = f.shas['1.0.3']!;
const failing: Git = (args) => (args[0] === 'fetch' ? { ok: false, out: 'fatal: unable to access the remote: Could not resolve host' } : f.git(args));
const broken = planRelease({ repo: f.repo, git: failing, gh: f.gh });
expect(broken).toEqual({ ok: false, code: 'REFUSED', message: 'cannot fetch ' + NOTES_REF + ': fatal: unable to access the remote: Could not resolve host' });
const absent = readTrainNotes(f.git);
expect([absent.error, absent.freeze, absent.evidence.bySha.size, absent.evidence.byTree.size]).toEqual([null, null, 0, 0]);
const add = (record: string, on: string) => git(f.seed, 'notes', '--ref=' + NOTES_REF, 'add', '-f', '-m', record, on);
add('not json {', f.shas['1.0.0']!);
for (const junk of ['null', '[]', '"train"', '7', JSON.stringify({ kind: 'train', nonce: 'bad1', state: 'green', merged: 'x' }), JSON.stringify({ kind: 'train', nonce: 'bad2', state: 'green', merged: [null, 'y', { sha: 3, tree: ['t'] }, { sha: '', tree: '' }] }), JSON.stringify({ kind: 'train', nonce: 9, state: 'green', merged: [{ sha: f.shas['1.0.0'] }] })]) {
add(junk, f.shas['1.0.0']!);
git(f.seed, 'push', '-q', '-f', 'origin', NOTES_REF);
const read = readTrainNotes(f.git);
expect([junk, read.error, read.freeze, read.evidence.bySha.size, read.evidence.byTree.size]).toEqual([junk, null, null, 0, 0]);
}
add(JSON.stringify({ schema: 1, kind: 'train', state: 'merged', merged: [{ sha: f.shas['1.0.1'], tree: 't1' }] }), f.shas['1.0.1']!);
add(JSON.stringify({ schema: 1, kind: 'train', nonce: 'ok1', state: 'green', merged: [{ pr: '2' }, { sha: f.shas['1.0.2'] }, { tree: 'tree-only' }] }), f.shas['1.0.2']!);
add(JSON.stringify({ schema: 1, kind: 'freeze', train: 'ok1', reason: 'backstop red', cleared_at: null }), head);
git(f.seed, 'push', '-q', '-f', 'origin', NOTES_REF);
const mixed = readTrainNotes(f.git);
expect(mixed.error).toBeNull();
expect(mixed.freeze).toBe('queue frozen since train ok1: backstop red');
expect([...mixed.evidence.bySha]).toEqual([[f.shas['1.0.2']!, 'ok1']]);
expect([...mixed.evidence.byTree]).toEqual([['tree-only', 'ok1']]);
f.state.checks[f.shas['1.0.2']!] = backstop('completed', 'neutral');
const frozen = planRelease({ repo: f.repo, ref: f.shas['1.0.2'], git: f.git, gh: f.gh });
expect(frozen).toEqual({ ok: false, code: 'REFUSED', message: 'queue frozen since train ok1: backstop red' });
add(JSON.stringify({ schema: 1, kind: 'freeze', train: 'ok1', reason: 'backstop red', cleared_at: 'y' }), head);
git(f.seed, 'push', '-q', '-f', 'origin', NOTES_REF);
const thawed = planRelease({ repo: f.repo, ref: f.shas['1.0.2'], git: f.git, gh: f.gh });
expect(thawed.ok, JSON.stringify(thawed)).toBe(true);
if (thawed.ok) expect(thawed.plan.via).toBe('train ok1');
});
});
describe('release-cut: train evidence gates a commit whose backstop never settled', () => {
const train = (nonce: string, state: string, merged: Array<{ sha: string; tree: string }>) =>
({ schema: 1, kind: 'train', nonce, state, merged: merged.map(m => ({ pr: '1', at: 'x', ...m })), updated_at: 'x' });
const note = (f: ReturnType<typeof fixture>, record: unknown, on: string, ref = NOTES_REF) => {
git(f.seed, 'notes', '--ref=' + ref, 'add', '-f', '-m', JSON.stringify(record), on);
git(f.seed, 'push', '-q', '-f', 'origin', ref);
};
const treeOf = (f: ReturnType<typeof fixture>, sha: string) => git(f.seed, 'rev-parse', sha + '^{tree}');
// Value: protects=a main commit whose main-backstop check run is neutral (superseded) or absent is released when a green, merging or merged train record's
// merged[] names its sha or its tree, and the CANDIDATE line says which train gated it; fails_when=the gate still needs a successful backstop run on
// every commit (the weekly cut returns NO_GREEN_COMMIT for a week of train-merged commits), evidence is matched on sha only, or the plan hides what gated
// the commit; why_new=the planning tests know only the backstop check run as evidence; seam=none; no_claim=real refs/notes/gstack-trains content (a
// fixture record shaped like lib/ship-queue-train-record.ts TrainRecord)
test('a neutral backstop plus a train record naming the sha, or only the tree, is a candidate gated by that train', () => {
const f = fixture();
const head = f.shas['1.0.3']!;
f.state.checks[head] = backstop('completed', 'neutral');
note(f, train('t1', 'merged', [{ sha: head, tree: treeOf(f, head) }]), head);
const bySha = planRelease({ repo: f.repo, git: f.git, gh: f.gh });
expect(bySha.ok, JSON.stringify(bySha)).toBe(true);
if (!bySha.ok) return;
expect(bySha.plan.sha).toBe(head);
expect(bySha.plan.via).toBe('train t1');
expect(describePlan(bySha.plan, true)[0]).toBe('CANDIDATE: ' + head + ' (' + bySha.plan.date + ') gated by train t1');
const byTree = fixture();
const head2 = byTree.shas['1.0.3']!;
delete byTree.state.checks[head2];
byTree.state.checks[head2] = GREEN.filter(c => c.name !== BACKSTOP_CHECK);
note(byTree, train('t2', 'green', [{ sha: 'f'.repeat(40), tree: treeOf(byTree, head2) }]), head2);
const r = planRelease({ repo: byTree.repo, git: byTree.git, gh: byTree.gh });
expect(r.ok, JSON.stringify(r)).toBe(true);
if (r.ok) { expect(r.plan.sha).toBe(head2); expect(r.plan.via).toBe('train t2'); }
const plain = planRelease({ repo: f.repo, ref: f.shas['1.0.2'], git: f.git, gh: f.gh });
expect(plain.ok).toBe(true);
if (plain.ok) { expect(plain.plan.via).toBe(BACKSTOP_CHECK); expect(describePlan(plain.plan, false)[0]).toBe('CANDIDATE: ' + f.shas['1.0.2'] + ' (' + plain.plan.date + ') [hotfix: --ref] gated by ' + BACKSTOP_CHECK); }
});
// Value: protects=train evidence never relaxes the rest of the gate: a covered commit still needs a successful required check run (also when
// --required-check and --backstop-check name the same check, where evidence substitutes for nothing), no red check run (a red backstop included),
// nothing still running, and only a green, merging or merged record counts; fails_when=a covered commit with a failed free-tests run, a red windows lane
// or a red backstop is released, a running check is not waited for, a red or superseded train record is read as evidence, or equal check names let a
// covered commit with zero check runs through; why_new=the first train test covers the accepting path only; seam=none; no_claim=none beyond protects
test('a covered commit is still refused by a red check run, a red backstop, a missing or failed required run, a running run, or a red/superseded record', () => {
const f = fixture();
const head = f.shas['1.0.3']!;
const tree = treeOf(f, head);
note(f, train('t1', 'merging', [{ sha: head, tree }]), head);
const gate = (checks: CheckRun[]) => { f.state.checks[head] = checks; const v = planRelease({ repo: f.repo, ref: head, git: f.git, gh: f.gh }); return v.ok ? 'green' : v.message; };
expect(gate([...backstop('completed', 'neutral'), { name: 'windows-free-tests', status: 'completed', conclusion: 'failure' }])).toBe('--ref ' + head.slice(0, 12) + ' is not green: red check runs: windows-free-tests');
expect(gate(backstop('completed', 'failure'))).toBe('--ref ' + head.slice(0, 12) + ' is not green: red check runs: ' + BACKSTOP_CHECK);
expect(gate([{ name: BACKSTOP_CHECK, status: 'completed', conclusion: 'neutral' }])).toBe('--ref ' + head.slice(0, 12) + ' is not green: no ' + REQUIRED_CHECK + ' check run');
expect(gate([{ name: REQUIRED_CHECK, status: 'completed', conclusion: 'failure' }, { name: BACKSTOP_CHECK, status: 'completed', conclusion: 'neutral' }])).toBe('--ref ' + head.slice(0, 12) + ' is not green: ' + REQUIRED_CHECK + ' concluded failure');
expect(gate(backstop('in_progress', null))).toBe('--ref ' + head.slice(0, 12) + ' is not green: ' + BACKSTOP_CHECK + ' is still running');
expect(gate([...backstop('completed', 'neutral'), { name: 'slices-report', status: 'queued', conclusion: null }])).toBe('--ref ' + head.slice(0, 12) + ' is not green: check runs still going: slices-report');
expect(gate(backstop('completed', 'neutral'))).toBe('green');
f.state.checks[head] = [];
const same = planRelease({ repo: f.repo, ref: head, git: f.git, gh: f.gh, requiredCheck: 'ci', backstopCheck: 'ci' });
expect(same.ok ? 'green' : same.message).toBe('--ref ' + head.slice(0, 12) + ' is not green: no ci check run');
f.state.checks[head] = [{ name: 'ci', status: 'completed', conclusion: 'success' }];
const sameGreen = planRelease({ repo: f.repo, ref: head, git: f.git, gh: f.gh, requiredCheck: 'ci', backstopCheck: 'ci' });
expect(sameGreen.ok ? sameGreen.plan.via : sameGreen.message).toBe('ci');
for (const state of ['red', 'superseded', 'incomplete', 'free-green']) {
note(f, train('t1', state, [{ sha: head, tree }]), head);
expect([state, gate(backstop('completed', 'neutral'))]).toEqual([state, '--ref ' + head.slice(0, 12) + ' is not green: ' + BACKSTOP_CHECK + ' concluded neutral']);
}
for (const sha of Object.values(f.shas)) f.state.checks[sha] = backstop('completed', 'neutral');
const none = planRelease({ repo: f.repo, git: f.git, gh: f.gh });
expect(none.ok).toBe(false);
if (none.ok) return;
expect(none.code).toBe('NO_GREEN_COMMIT');
expect(none.message).toBe('no green commit among 4 on origin/main; see the skipped list');
expect(none.skipped).toEqual(Object.values(f.shas).reverse().map(s => s.slice(0, 12) + ': ' + BACKSTOP_CHECK + ' concluded neutral'));
note(f, train('other', 'merged', [{ sha: f.shas['1.0.1']!, tree: treeOf(f, f.shas['1.0.1']!) }]), head);
const older = planRelease({ repo: f.repo, git: f.git, gh: f.gh });
expect(older.ok, JSON.stringify(older)).toBe(true);
if (!older.ok) return;
expect(older.plan.sha).toBe(f.shas['1.0.1']);
expect(older.plan.via).toBe('train other');
expect(older.plan.skipped).toEqual([head.slice(0, 12) + ': ' + BACKSTOP_CHECK + ' concluded neutral', f.shas['1.0.2']!.slice(0, 12) + ': ' + BACKSTOP_CHECK + ' concluded neutral']);
});
// Value: protects=--required-check, --backstop-check and --notes-ref rename what the gate reads so another repository can run the script, and the
// defaults stay free-tests, main-backstop and refs/notes/gstack-trains for weekly-release.yml; fails_when=a renamed check is still looked up under the
// default name, the train notes are read from the default ref only, or a flag is misread by the CLI; why_new=every other test runs under the defaults;
// seam=none; no_claim=none beyond protects
test('--required-check, --backstop-check and --notes-ref rename what the gate reads, through planRelease and the CLI', () => {
const f = fixture();
const head = f.shas['1.0.3']!;
const renamed: CheckRun[] = [{ name: 'ci', status: 'completed', conclusion: 'success' }, { name: 'nightly-backstop', status: 'completed', conclusion: 'success' }];
for (const sha of Object.values(f.shas)) f.state.checks[sha] = renamed;
const defaults = planRelease({ repo: f.repo, git: f.git, gh: f.gh });
expect(defaults.ok).toBe(false);
if (!defaults.ok) expect(defaults.skipped![0]).toBe(head.slice(0, 12) + ': no ' + REQUIRED_CHECK + ' check run');
const flagged = planRelease({ repo: f.repo, git: f.git, gh: f.gh, requiredCheck: 'ci', backstopCheck: 'nightly-backstop' });
expect(flagged.ok, JSON.stringify(flagged)).toBe(true);
if (flagged.ok) { expect(flagged.plan.sha).toBe(head); expect(flagged.plan.via).toBe('nightly-backstop'); }
f.state.checks[head] = [{ name: 'ci', status: 'completed', conclusion: 'success' }, { name: 'nightly-backstop', status: 'completed', conclusion: 'neutral' }];
note(f, train('t9', 'merged', [{ sha: head, tree: treeOf(f, head) }]), head, 'refs/notes/other-trains');
const defaultRef = planRelease({ repo: f.repo, ref: head, git: f.git, gh: f.gh, requiredCheck: 'ci', backstopCheck: 'nightly-backstop' });
expect(defaultRef.ok).toBe(false);
if (!defaultRef.ok) expect(defaultRef.message).toBe('--ref ' + head.slice(0, 12) + ' is not green: nightly-backstop concluded neutral');
const otherRef = planRelease({ repo: f.repo, ref: head, git: f.git, gh: f.gh, requiredCheck: 'ci', backstopCheck: 'nightly-backstop', notesRef: 'refs/notes/other-trains' });
expect(otherRef.ok, JSON.stringify(otherRef)).toBe(true);
if (otherRef.ok) expect(otherRef.plan.via).toBe('train t9');
if (process.platform === 'win32') return;
const shim = path.join(f.base, 'shim');
fs.mkdirSync(shim);
fs.writeFileSync(path.join(shim, 'gh'), '#!/usr/bin/env bash\ncase "$*" in\n *check-runs*) echo \'' + JSON.stringify([{ check_runs: f.state.checks[head] }]) + '\' ;;\n *) exit 1 ;;\nesac\n', { mode: 0o755 });
const run = (...args: string[]) => spawnSync(process.execPath, [...bunIsolationArgs(), 'run', path.join(path.resolve(import.meta.dir, '..'), 'scripts', 'release-cut.ts'), ...args],
{ cwd: f.work, encoding: 'utf8', timeout: 60_000, env: { ...process.env, PATH: shim + path.delimiter + process.env.PATH } });
const cli = run('--repo', f.repo, '--dry-run', '--ref', head, '--required-check', 'ci', '--backstop-check', 'nightly-backstop', '--notes-ref', 'refs/notes/other-trains');
expect(cli.status, cli.stdout + cli.stderr).toBe(0);
expect(cli.stdout.split('\n')[0]).toBe('CANDIDATE: ' + head + ' (' + git(f.seed, 'show', '-s', '--format=%cs', head) + ') [hotfix: --ref] gated by train t9');
const unnamed = run('--repo', f.repo, '--dry-run', '--ref', head, '--required-check', 'ci', '--backstop-check', 'nightly-backstop');
expect(unnamed.status).toBe(1);
expect(unnamed.stdout.split('\n')[0]).toBe('REFUSED: --ref ' + head.slice(0, 12) + ' is not green: nightly-backstop concluded neutral');
});
});
describe('release-cut: cutting, hotfixes and the forward-only stable', () => {
+244
View File
@@ -0,0 +1,244 @@
/**
* Restamp mention scanning and rewriting (gbrain #7640, gstack 1.91.246.0): a
* PR that stamped X lost its slot, merged the base, and the base was X by
* then, so the branch's VERSION equalled the base's (no branch stamp) and its
* quoted `since: 'X'` rows never moved: the mention scanner could not pass
* the opening quote and, with no branch stamp, nothing else named X. Pins
* the quoted forms (single, double, unquoted unchanged, quote style kept) in
* both the scanner and the rewrite table, the equal-version rule (the base
* version moves only on lines the branch added), and the negative control
* (a base-side quoted row carrying that version stays).
*/
import { afterEach, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { EXIT } from '../lib/headless-artifacts';
import { rewriteStamps, tentativeMentions } from '../lib/restamp';
import { BASE_POLICY, cleanup, makeFixtureRepo, makePr, mergeMain, mergeToMain, readFile, restamp, type FixtureRepo } from './helpers/restamp-fixture';
const repos: FixtureRepo[] = [];
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
const stageDetail = (out: string, name: string) => new RegExp(`^stage ${name}: (\\w+)(?: \\((.*)\\))?$`, 'm').exec(out);
describe('quoted stamp forms (pure)', () => {
test('rewriteStamps: since: with a single or double quote moves and keeps its quote; the unquoted form is unchanged', () => {
expect(rewriteStamps("{ since: '1.2.3.0', id: 'a' }", '1.2.3.0', '1.2.4.0')).toEqual({ text: "{ since: '1.2.4.0', id: 'a' }", count: 1 });
expect(rewriteStamps('{ since: "1.2.3.0", id: "a" }', '1.2.3.0', '1.2.4.0')).toEqual({ text: '{ since: "1.2.4.0", id: "a" }', count: 1 });
expect(rewriteStamps("since:'1.2.3.0'", '1.2.3.0', '1.2.4.0')).toEqual({ text: "since:'1.2.4.0'", count: 1 });
expect(rewriteStamps('since: 1.2.3.0', '1.2.3.0', '1.2.4.0')).toEqual({ text: 'since: 1.2.4.0', count: 1 });
// A quote is an exact boundary too: a longer version behind it is not X.
expect(rewriteStamps("since: '1.2.3.01'", '1.2.3.0', '1.2.4.0').count).toBe(0);
expect(rewriteStamps("since: '1.2.3.0.1'", '1.2.3.0', '1.2.4.0').count).toBe(0);
// An opened quote must close right after X: a quoted prerelease or a mismatched pair is not the form
// (the tree receipt and gate reuse share this table, so a looser match would call a real change a stamp).
expect(rewriteStamps("since: '1.2.3.0-beta'", '1.2.3.0', '1.2.4.0').count).toBe(0);
expect(rewriteStamps('since: "1.2.3.0-beta"', '1.2.3.0', '1.2.4.0').count).toBe(0);
expect(rewriteStamps("since: '1.2.3.0\"", '1.2.3.0', '1.2.4.0').count).toBe(0);
expect(rewriteStamps('since: 1.2.3.0-beta', '1.2.3.0', '1.2.4.0').count).toBe(1);
});
test('rewriteStamps: (was X) accepts a matching quote pair and keeps it; a mismatched pair is not the form; a quoted vX already matched', () => {
expect(rewriteStamps("(was '1.2.3.0')", '1.2.3.0', '1.2.4.0')).toEqual({ text: "(was '1.2.4.0')", count: 1 });
expect(rewriteStamps('(was "1.2.3.0")', '1.2.3.0', '1.2.4.0')).toEqual({ text: '(was "1.2.4.0")', count: 1 });
expect(rewriteStamps('(was 1.2.3.0)', '1.2.3.0', '1.2.4.0')).toEqual({ text: '(was 1.2.4.0)', count: 1 });
expect(rewriteStamps("(was '1.2.3.0\")", '1.2.3.0', '1.2.4.0').count).toBe(0);
expect(rewriteStamps("'v1.2.3.0'", '1.2.3.0', '1.2.4.0')).toEqual({ text: "'v1.2.4.0'", count: 1 });
// A bare pin equal to X is still not a stamp form, quoted or not.
expect(rewriteStamps("pinned: '1.2.3.0'", '1.2.3.0', '1.2.4.0').count).toBe(0);
});
test('rewriteStamps: a policy template that overlaps the built-in quoted form counts the mention once', () => {
const r = rewriteStamps(" { since: '1.2.3.0', id: 'x', text: 't' },", '1.2.3.0', '1.2.4.0', ["since: '{version}'"]);
expect(r).toEqual({ text: " { since: '1.2.4.0', id: 'x', text: 't' },", count: 1 });
});
test('tentativeMentions: quoted since: rows and template forms on branch-added lines are candidates like the unquoted ones', () => {
const added = new Map([['src/notice.ts', new Set([
" { since: '1.2.4.0', id: 'single', text: 't' },",
' { since: "1.2.5.0", id: "double", text: "t" },',
' since: 1.2.6.0',
'<!-- x-tree-stamp: 1.2.7.0 -->',
" pinned: '1.2.8.0'",
])]]);
expect(tentativeMentions(added, '1.2.3.0', '1.2.9.0').sort()).toEqual(['1.2.4.0', '1.2.5.0', '1.2.6.0']);
expect(tentativeMentions(added, '1.2.3.0', '1.2.9.0', ['<!-- x-tree-stamp: {version} -->']).sort()).toEqual(['1.2.4.0', '1.2.5.0', '1.2.6.0', '1.2.7.0']);
// The fork-version floor and the allocated version still apply to quoted mentions.
expect(tentativeMentions(added, '1.2.5.0', '1.2.6.0').sort()).toEqual([]);
// A quoted prerelease, a mismatched pair and an unclosed (was are not candidates; the bare prerelease still is.
const odd = new Map([['x', new Set(["since: '1.2.4.0-rc'", 'since: "1.2.5.0\'', "(was '1.2.6.0'x", '(was 1.2.7.0-rc)', 'since: 1.2.8.0-rc'])]]);
expect(tentativeMentions(odd, '1.2.3.0', '1.2.9.0').sort()).toEqual(['1.2.8.0']);
});
});
/** A gbrain-shaped fixture: a quoted-row source file in stamp_files, with and without the since: template. */
const NOTICE = 'src/notice.ts';
const row = (v: string, id: string) => ` { since: '${v}', id: '${id}', text: '${id}' },`;
const notice = (rows: string[]) => ['export const ROWS = [', ...rows, '];', ''].join('\n');
function policy(templates: boolean) {
return { ...BASE_POLICY, release_tool: null, stamp_files: ['VERSION', 'package.json', 'CHANGELOG.md', NOTICE], ...(templates ? { stamp_patterns: ["since: '{version}'"] } : {}) };
}
function write(repo: FixtureRepo, rel: string, text: string): void {
fs.mkdirSync(path.dirname(path.join(repo.work, rel)), { recursive: true });
fs.writeFileSync(path.join(repo.work, rel), text);
}
/** The repo's own stamp on the branch, the way gbrain's release:restamp leaves it: VERSION and the rows at X. */
function stampBranch(repo: FixtureRepo, v: string, rows: string[]): void {
write(repo, 'VERSION', v + '\n');
write(repo, NOTICE, notice(rows));
repo.git('commit', '-qam', `chore: bump version (v${v})`);
}
/**
* The reporter's state: the PR stamped 1.2.4.0 (VERSION and its row), another PR took the slot and the base
* is 1.2.4.0 with its own quoted 1.2.4.0 row, the PR merged the base. VERSION equals the base's on both sides.
*/
function lostSlot(repo: FixtureRepo): void {
write(repo, NOTICE, notice([row('1.2.3.0', 'base_row')]));
repo.git('add', '-A'); repo.git('commit', '-qm', 'notice rows'); repo.git('push', '-q', 'origin', 'main');
makePr(repo, 'pr-mine');
stampBranch(repo, '1.2.4.0', [row('1.2.3.0', 'base_row'), '', '', '', row('1.2.4.0', 'mine')]);
// The other PR takes the slot through the real stamp: VERSION 1.2.4.0, its CHANGELOG entry headed, its own 1.2.4.0 row.
makePr(repo, 'pr-other');
write(repo, NOTICE, notice([row('1.2.4.0', 'other'), row('1.2.3.0', 'base_row')]));
repo.git('commit', '-qam', 'other row');
expect(restamp(repo, ['--next']).status).toBe(EXIT.ok);
repo.git('commit', '-qam', 'stamp other');
mergeToMain(repo, 'pr-other');
repo.git('checkout', '-q', 'pr-mine');
mergeMain(repo);
expect(readFile(repo, 'VERSION')).toBe('1.2.4.0\n');
expect(repo.git('show', 'origin/main:VERSION')).toBe('1.2.4.0');
}
describe('a PR whose VERSION already equals the base (gbrain #7640)', () => {
test.each([['with the since: template', true], ['built-in quoted form alone', false]])('%s: the PR\'s quoted row moves, the base\'s own quoted rows stay, and the stage says why', (_, templates) => {
const repo = makeFixtureRepo({ prefix: 'restamp-mentions-', policy: policy(templates) }); repos.push(repo);
lostSlot(repo);
const dry = restamp(repo, ['--next', '--dry-run']);
expect(dry.status).toBe(EXIT.ok);
expect(dry.out).toMatch(/^- \{ since: '1\.2\.4\.0', id: 'mine', text: 'mine' \},$/m);
expect(dry.out).toMatch(/^\+ \{ since: '1\.2\.5\.0', id: 'mine', text: 'mine' \},$/m);
expect(repo.git('status', '--porcelain')).toBe('');
const r = restamp(repo, ['--next']);
expect(r.status).toBe(EXIT.ok);
expect(r.out).toContain('RESTAMP: 1.2.4.0 -> 1.2.5.0 (stamped;');
const sp = stageDetail(r.out, 'stamp_paths')!;
expect(sp[1]).toBe('done');
expect(sp[2]).toBe(`1 mention(s) in 1 file(s) (1.2.4.0 -> 1.2.5.0); VERSION equals base 1.2.4.0, so 1.2.4.0 moved only on lines this branch added and stayed on the base's own lines (${NOTICE}: 1 moved, 1 kept)`);
expect(readFile(repo, NOTICE)).toBe(notice([row('1.2.4.0', 'other'), row('1.2.3.0', 'base_row'), '', '', '', row('1.2.5.0', 'mine')]));
expect(readFile(repo, 'VERSION')).toBe('1.2.5.0\n');
// The base's rows are byte-identical to origin/main's: the 1.2.4.0 row is the base's release and never moved.
expect(repo.git('show', `origin/main:${NOTICE}`) + '\n').toBe(notice([row('1.2.4.0', 'other'), row('1.2.3.0', 'base_row')]));
const journal = JSON.parse(readFile(repo, '.gstack/tmp/restamp-journal.json'));
expect(journal.inputs).toMatchObject({ old_version: '1.2.4.0', new_version: '1.2.5.0' });
expect(journal.stages.find((s: any) => s.name === 'stamp_paths').detail).toContain('1 moved, 1 kept');
});
test('negative control: with no branch-added mention of the base version nothing moves, every base-side quoted row stays, and the stage says so', () => {
const repo = makeFixtureRepo({ prefix: 'restamp-mentions-neg-', policy: policy(true) }); repos.push(repo);
write(repo, NOTICE, notice([row('1.2.3.0', 'base_row')]));
repo.git('add', '-A'); repo.git('commit', '-qm', 'notice rows'); repo.git('push', '-q', 'origin', 'main');
makePr(repo, 'pr-mine');
// The PR adds a row of its own in another form entirely, plus an unrelated line in the stamp file.
write(repo, NOTICE, notice([row('1.2.3.0', 'base_row'), " { since: 'TBD', id: 'mine', text: 'mine' },"]));
repo.git('commit', '-qam', 'my row');
const r = restamp(repo, ['--next']);
expect(r.status).toBe(EXIT.ok);
const sp = stageDetail(r.out, 'stamp_paths')!;
expect(sp[1]).toBe('skipped');
expect(sp[2]).toBe('no tentative mention newer than the fork version 1.2.3.0 and none of base 1.2.3.0 on a line this branch added; VERSION equals base 1.2.3.0, so 1.2.3.0 moved only on lines this branch added and stayed on the base\'s own lines (src/notice.ts: 0 moved, 1 kept)');
expect(readFile(repo, NOTICE)).toBe(notice([row('1.2.3.0', 'base_row'), " { since: 'TBD', id: 'mine', text: 'mine' },"]));
expect(r.out).not.toMatch(/^[-+] \{ since: '1\.2\.3\.0'/m);
});
test('a branch forked at the base version (fork equals base, nothing stamped) still moves its own base-version row and keeps the base\'s', () => {
const repo = makeFixtureRepo({ prefix: 'restamp-mentions-fork-', policy: policy(true) }); repos.push(repo);
write(repo, NOTICE, notice([row('1.2.3.0', 'base_row')]));
repo.git('add', '-A'); repo.git('commit', '-qm', 'notice rows'); repo.git('push', '-q', 'origin', 'main');
makePr(repo, 'pr-mine');
write(repo, NOTICE, notice([row('1.2.3.0', 'base_row'), row('1.2.3.0', 'mine')]));
write(repo, 'docs/pr-mine.md', 'Requires v1.2.3.0 or later.\n');
repo.git('add', '-A'); repo.git('commit', '-qm', 'my row');
const r = restamp(repo, ['--next']);
expect(r.status).toBe(EXIT.ok);
expect(stageDetail(r.out, 'stamp_paths')![2]).toBe('2 mention(s) in 2 file(s) (1.2.3.0 -> 1.2.4.0); VERSION equals base 1.2.3.0, so 1.2.3.0 moved only on lines this branch added and stayed on the base\'s own lines (docs/pr-mine.md: 1 moved, 0 kept; src/notice.ts: 1 moved, 1 kept)');
expect(readFile(repo, NOTICE)).toBe(notice([row('1.2.3.0', 'base_row'), row('1.2.4.0', 'mine')]));
expect(readFile(repo, 'docs/pr-mine.md')).toBe('Requires v1.2.4.0 or later.\n');
expect(readFile(repo, 'docs/notes.md')).toBe('Previous release v1.2.3.0.\nleft-pad pinned at 1.2.3 (was 1.2.2)\n');
});
test('provenance is by position: a branch-added line whose text equals a base line moves alone, and the base copy stays', () => {
const repo = makeFixtureRepo({ prefix: 'restamp-mentions-dup-', policy: policy(true) }); repos.push(repo);
write(repo, NOTICE, notice([row('1.2.3.0', 'dup')]));
repo.git('add', '-A'); repo.git('commit', '-qm', 'notice rows'); repo.git('push', '-q', 'origin', 'main');
makePr(repo, 'pr-mine');
write(repo, NOTICE, notice([row('1.2.3.0', 'dup'), '', '', '', row('1.2.3.0', 'dup')]));
repo.git('commit', '-qam', 'my row, same text as the base row');
const r = restamp(repo, ['--next']);
expect(r.status).toBe(EXIT.ok);
expect(stageDetail(r.out, 'stamp_paths')![2]).toBe(`1 mention(s) in 1 file(s) (1.2.3.0 -> 1.2.4.0); VERSION equals base 1.2.3.0, so 1.2.3.0 moved only on lines this branch added and stayed on the base's own lines (${NOTICE}: 1 moved, 1 kept)`);
expect(readFile(repo, NOTICE)).toBe(notice([row('1.2.3.0', 'dup'), '', '', '', row('1.2.4.0', 'dup')]));
});
test('diff.interHunkContext joining two hunks around a base row still places each branch row by position', () => {
const repo = makeFixtureRepo({ prefix: 'restamp-mentions-ihc-', policy: policy(true) }); repos.push(repo);
write(repo, NOTICE, notice([' { since: "0.0.0.0", id: "first_placeholder" },', row('1.2.3.0', 'base_row'), ' { since: "0.0.0.0", id: "second_placeholder" },']));
repo.git('add', '-A'); repo.git('commit', '-qm', 'notice rows'); repo.git('push', '-q', 'origin', 'main');
repo.git('config', 'diff.interHunkContext', '1');
makePr(repo, 'pr-mine');
write(repo, NOTICE, notice([row('1.2.3.0', 'first'), row('1.2.3.0', 'base_row'), row('1.2.3.0', 'second')]));
repo.git('commit', '-qam', 'two rows around the base row');
// The joined hunk carries the base row as context between the two added lines.
expect(repo.git('diff', '--unified=0', 'origin/main', '--', NOTICE)).toMatch(/\n\+ \{ since: '1\.2\.3\.0', id: 'first'.*\n \{ since: '1\.2\.3\.0', id: 'base_row'.*\n-.*\n\+ \{ since: '1\.2\.3\.0', id: 'second'/);
const r = restamp(repo, ['--next']);
expect(r.status).toBe(EXIT.ok);
expect(stageDetail(r.out, 'stamp_paths')![2]).toContain(`${NOTICE}: 2 moved, 1 kept`);
expect(readFile(repo, NOTICE)).toBe(notice([row('1.2.4.0', 'first'), row('1.2.3.0', 'base_row'), row('1.2.4.0', 'second')]));
});
test('a CRLF checkout (core.autocrlf, eol=crlf) whose diff is LF-normalized still finds the branch-added row', () => {
const repo = makeFixtureRepo({ prefix: 'restamp-mentions-crlf-', policy: policy(true) }); repos.push(repo);
write(repo, '.gitattributes', `${NOTICE} text eol=crlf\n`);
write(repo, NOTICE, notice([row('1.2.3.0', 'base_row')]));
repo.git('config', 'core.autocrlf', 'true');
repo.git('add', '-A'); repo.git('commit', '-qm', 'notice rows'); repo.git('push', '-q', 'origin', 'main');
makePr(repo, 'pr-mine');
write(repo, NOTICE, notice([row('1.2.3.0', 'base_row'), row('1.2.3.0', 'mine')]).replace(/\n/g, '\r\n'));
repo.git('commit', '-qam', 'my row');
expect(repo.git('diff', '--unified=0', 'origin/main', '--', NOTICE)).toContain("+ { since: '1.2.3.0', id: 'mine'");
const r = restamp(repo, ['--next']);
expect(r.status).toBe(EXIT.ok);
expect(stageDetail(r.out, 'stamp_paths')![2]).toContain(`${NOTICE}: 1 moved, 1 kept`);
expect(readFile(repo, NOTICE)).toBe(notice([row('1.2.3.0', 'base_row'), row('1.2.4.0', 'mine')]).replace(/\n/g, '\r\n'));
});
test.skipIf(process.platform === 'win32' || process.getuid?.() === 0)('a stamp interrupted after VERSION was written retries as a stamp, not a noop: the journal restores the equal-base provenance', () => {
const repo = makeFixtureRepo({ prefix: 'restamp-mentions-retry-', policy: policy(true) }); repos.push(repo);
lostSlot(repo);
const dir = path.join(repo.work, path.dirname(NOTICE));
fs.chmodSync(dir, 0o555);
try {
const first = restamp(repo, ['--next']);
expect(first.status).not.toBe(EXIT.ok);
expect(first.err).toMatch(/EACCES|permission denied/i);
} finally { fs.chmodSync(dir, 0o755); }
expect(readFile(repo, 'VERSION')).toBe('1.2.5.0\n');
expect(readFile(repo, NOTICE)).toContain(row('1.2.4.0', 'mine'));
const again = restamp(repo, ['--next']);
expect(again.status).toBe(EXIT.ok);
expect(again.out).toContain('RESTAMP: 1.2.5.0 -> 1.2.5.0 (stamped;');
expect(stageDetail(again.out, 'stamp_paths')![2]).toContain(`${NOTICE}: 1 moved, 1 kept`);
expect(readFile(repo, NOTICE)).toBe(notice([row('1.2.4.0', 'other'), row('1.2.3.0', 'base_row'), '', '', '', row('1.2.5.0', 'mine')]));
expect(JSON.parse(readFile(repo, '.gstack/tmp/restamp-journal.json')).status).toBe('complete');
});
test('--was still rewrites every line, the base\'s rows included: it is not the fix for this state', () => {
const repo = makeFixtureRepo({ prefix: 'restamp-mentions-was-', policy: policy(true) }); repos.push(repo);
lostSlot(repo);
const r = restamp(repo, ['--next', '--was', '1.2.4.0']);
expect(r.status).toBe(EXIT.ok);
expect(stageDetail(r.out, 'stamp_paths')![2]).toBe('2 mention(s) in 1 file(s) (1.2.4.0 -> 1.2.5.0)');
expect(readFile(repo, NOTICE)).toBe(notice([row('1.2.5.0', 'other'), row('1.2.3.0', 'base_row'), '', '', '', row('1.2.5.0', 'mine')]));
});
});
+20
View File
@@ -26,6 +26,26 @@ describe('issue intent (pure)', () => {
expect(mentionsIn('v1.2.3 (#301) release notes').map(x => x.issue)).toEqual([301]);
});
// Value: protects=a parenthesised named reference "(name #N, #M)", "(name PR #N)" or "(name issue #N)" is a cross-repo reference to that name, never a
// same-repo Refs line or a promotion candidate, while "(see #12)", "(closing #12)", "(cf. #12)", "(v1.2 #3)", "(#301)" and "Fixes #12" keep their
// meaning; fails_when=the bare-#N rule reads the numbers as this repository's issues (Refs lines and fix-#N prompts for issues that do not exist here), or
// a closure, reference, filler or version word in the parentheses is taken for a repository name (the issue then vanishes from Refs and from the fix-#N
// offer as a phantom cross-repo reference); why_new=the intent tests cover owner/repo#N as the only cross form; seam=none; no_claim=none beyond protects
test('a parenthesised named reference is a cross-repo reference, never a same-repo Refs or promotion candidate', () => {
const named = mentionsIn('ship-queue: screen verdict from the run (gbrain #7625, #7606)\n\nPorts the fix from (gbrain PR #7630) and (acme/tools issue #4).\n');
expect(named.map(x => [x.repo ?? '', x.issue, x.intent])).toEqual([['gbrain', 7625, 'refs'], ['gbrain', 7606, 'refs'], ['gbrain', 7630, 'refs'], ['acme/tools', 4, 'refs']]);
const links = resolveLinks(named, [], () => true);
expect(links).toEqual({ fixes: [], refs: [], flakes: [], cross: ['acme/tools#4', 'gbrain#7606', 'gbrain#7625', 'gbrain#7630'], unreadable: [] });
expect(renderIssueLines(links)).toEqual(['Refs acme/tools#4', 'Refs gbrain#7606', 'Refs gbrain#7625', 'Refs gbrain#7630']);
expect(promotionCandidates(named, links)).toEqual([]);
for (const word of ['see', 'fixes', 'refs', 'related', 'part of', 'follow-up', 'closes', 'PR', 'issue', 'closing', 'addresses', 'fixing', 'implements', 'cf.', 'e.g.', 'i.e.', 'bug', 'upstream PR', 'v1.2', 'v1.2.3-rc1', 'supersedes', 'duplicate of']) {
expect([word, mentionsIn(`retry (${word} #12)`).map(x => [x.repo ?? '', x.issue, x.intent])]).toEqual([word, [['', 12, 'refs']]]);
}
expect(mentionsIn('v1.2.3 (#301) release notes').map(x => [x.repo ?? '', x.issue])).toEqual([['', 301]]);
expect(mentionsIn('Fixes #12').map(x => [x.repo ?? '', x.issue, x.intent])).toEqual([['', 12, 'fixes']]);
expect(mentionsIn('same line (gbrain #7625) and #13 here').map(x => [x.repo ?? '', x.issue])).toEqual([['gbrain', 7625], ['', 13]]);
});
test('resolution: explicit --issue is closure, unreadable closure becomes Refs, a flake issue is never a plain Ref', () => {
const links = resolveLinks(mentionsIn('Fixes #14\nFixes #15\nsee #40 and #16'), [16], n => n !== 15, [{ issue: 40, run: '7' }]);
expect(links).toEqual({ fixes: [14, 16], refs: [15], flakes: [{ issue: 40, run: '7' }], cross: [], unreadable: [15] });
+474
View File
@@ -0,0 +1,474 @@
/**
* Operational gaps seen on live trains, through the real
* bin/gstack-ship-queue against the train fixture: the screen verdict is read
* from the run (ready refuses a red head, verify --screen prints it, train
* build refuses the pin), a lagging GitHub merge ref gets a scratch merge
* (#3465), `train prune --stale` settles trains whose PRs merged elsewhere,
* and `train watch` resumes from the record after an interruption.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { EXIT } from '../lib/headless-artifacts';
import { cleanup, runBin } from './helpers/restamp-fixture';
import {
GREEN_GATE, TRAIN_POLICY, dispatches, line, nonceOf, openPr, queue, readyReceipt, repoKey, trainJson, trainFixture, type Rule, type TrainFixture,
} from './helpers/ship-queue-train-fixture';
const fixtures: TrainFixture[] = [];
const make = (policy: Record<string, unknown> = TRAIN_POLICY) => { const f = trainFixture(policy); fixtures.push(f); return f; };
afterEach(() => { while (fixtures.length) cleanup(fixtures.pop()!.repo); });
const next = (out: string) => line(out, 'NEXT:');
const result = (out: string) => line(out, 'GSTACK_RESULT:');
function readyFixture() {
const fx = make();
const head = openPr(fx, '1');
fx.write(d => { d.ci[head] = [{ status: 'completed', conclusion: 'success' }]; });
const tree = runBin(fx.repo, 'gstack-wtree', []).out.trim();
const pregate = path.join(fx.repo.root, 'pregate.json');
fs.writeFileSync(pregate, JSON.stringify({ schema_version: 1, tree, head, verdict: 'pass', checks: [{ id: 'unit', status: 'pass' }] }));
return { fx, head, pregate };
}
const receiptOf = (fx: TrainFixture, pr: string, head: string) => JSON.parse(fs.readFileSync(path.join(fx.state, 'ship-queue', 'ready', repoKey(fx), `${pr}-${head}.json`), 'utf8'));
const RED_SCREEN: Rule = { status: 'completed', conclusion: 'failure', plan: { planned: 3 }, binding: { verdict: 'RED', flaky: ['c-flaky'] }, outcomes: { red: ['judge-voice-tone'], flakyLines: ['c-flaky rule FLAKY: failed in slice 2, passed on its one rerun'] } };
const GREEN_SCREEN: Rule = { status: 'completed', conclusion: 'success', plan: { planned: 3 }, binding: { verdict: 'GREEN', flaky: ['c-flaky'] }, outcomes: { flakyLines: ['c-flaky rule FLAKY: failed in slice 2, passed on its one rerun'] } };
describe('the screen verdict comes from the run', () => {
// Value: protects=a RED screen run (runs 40269992077 and 40275443393 on 2026-10-26 were VERDICT RED while the lane reported green) can never be READY'd, followed or seated;
// fails_when=ready or train build trust the lane's choice without reading the run's gate binding; why_new=no test read a screen's verdict; seam=the gh shim's gate-binding and report-verdict artifacts; no_claim=evals.yml's real upload step
test('a RED screen refuses ready under either choice and is never re-dispatched; verify --screen prints the red and flaky cases; train build refuses the pin', () => {
const { fx, head, pregate } = readyFixture();
fx.write(d => { d.rules['evals.yml'] = RED_SCREEN; });
const first = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--screen']);
expect([first.status, line(first.out, 'SCREEN_DISPATCH:')]).toEqual([EXIT.ok, expect.stringMatching(/^SCREEN_DISPATCH: evals\.yml run 100 /)]);
const again = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--screen']);
expect(again.status).toBe(EXIT.refused);
expect(line(again.out, 'SCREEN_VERDICT:')).toBe('SCREEN_VERDICT: #1 run 100 RED red=judge-voice-tone flaky=c-flaky (verdict RED, reconcile exit 1, run concluded failure, flaky 1)');
expect(line(again.out, 'FLAKY:')).toBe('FLAKY: c-flaky rule FLAKY: failed in slice 2, passed on its one rerun');
expect(again.err).toMatch(/the screen recorded for [0-9a-f]{12} \(run 100\) is RED on judge-voice-tone; this head is not READY under any screen choice; fix: .*\(QUEUE_SCREEN_RED\)/);
expect(next(again.out)).toBe('NEXT: repair, push a new head, run /ship, then gstack-ship-queue ready --pr 1 --screen');
expect(receiptOf(fx, '1', head).screen.verdict).toMatchObject({ outcome: 'RED', run: 100, red: ['judge-voice-tone'], flaky: ['c-flaky'] });
const noScreen = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--no-screen']);
expect([noScreen.status, noScreen.err]).toEqual([EXIT.refused, expect.stringContaining('(QUEUE_SCREEN_RED)')]);
expect(dispatches(fx, 'evals.yml').length).toBe(1);
const verify = queue(fx, ['verify', '--pr', '1', '--screen']);
expect(verify.status).toBe(EXIT.fail);
expect(line(verify.out, 'SCREEN_VERDICT:')).toBe('SCREEN_VERDICT: #1 run 100 RED red=judge-voice-tone flaky=c-flaky (verdict RED, reconcile exit 1, run concluded failure, flaky 1)');
expect(verify.out.split('\n')[1]).toMatch(/^screen: screen policy=narrow nonce=pr\.screen\.narrow-\S+ run=100 verdict=RED$/);
expect(result(verify.out)).toMatch(/status=refused run=.*1-/);
const build = queue(fx, ['train', 'build', '--prs', `1@${head}`, '--dry-run']);
expect(build.status).toBe(EXIT.refused);
expect(build.err).toMatch(/#1's screen run 100 is RED on judge-voice-tone .*a red head is not seated.*\(QUEUE_SCREEN_RED\)/);
expect(next(build.out)).toBe('NEXT: none');
expect(fs.existsSync(path.join(fx.state, 'ship-queue', 'trains'))).toBe(false);
}, 120_000);
test('a GREEN screen with flaky cases is followed, recorded and printed by verify --screen and train build; a run that bound no verdict is re-dispatched', () => {
const { fx, head, pregate } = readyFixture();
fx.write(d => { d.rules['evals.yml'] = GREEN_SCREEN; });
expect(queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--screen']).status).toBe(EXIT.ok);
const again = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--screen']);
expect(again.status).toBe(EXIT.ok);
expect(line(again.out, 'SCREEN_VERDICT:')).toBe('SCREEN_VERDICT: #1 run 100 GREEN flaky=c-flaky (verdict GREEN, reconcile exit 0, run concluded success, flaky 1)');
expect(line(again.out, 'SCREEN_DISPATCH:')).toBe('SCREEN_DISPATCH: following run 100 (same merge SHA, base and policy; no duplicate)');
expect(again.out.split('\n')[1]).toMatch(/^screen: screen policy=narrow nonce=\S+ run=100 verdict=GREEN$/);
expect(receiptOf(fx, '1', head).screen.verdict).toMatchObject({ outcome: 'GREEN', run: 100, red: [], flaky: ['c-flaky'] });
const verify = queue(fx, ['verify', '--pr', '1', '--screen']);
expect([verify.status, verify.err]).toEqual([EXIT.ok, '']);
expect(line(verify.out, 'FLAKY:')).toBe('FLAKY: c-flaky rule FLAKY: failed in slice 2, passed on its one rerun');
expect(next(verify.out)).toBe(`NEXT: gstack-ship-queue train build --prs 1@${head},...`);
const build = queue(fx, ['train', 'build', '--prs', `1@${head}`, '--dry-run']);
expect([build.status, line(build.out, 'SCREEN:'), line(build.out, 'ORDER:')]).toEqual([EXIT.ok, 'SCREEN: #1 run 100 GREEN (flaky: c-flaky)', 'ORDER: 1:#1(screened)']);
fx.write(d => { delete d.artifacts['100']['gate-binding-a1']; });
const unbound = queue(fx, ['verify', '--pr', '1', '--screen']);
expect(unbound.status).toBe(EXIT.refused);
expect(unbound.err).toMatch(/has no verdict to read: run 100 concluded success and bound no verdict \(no gate-binding artifact.*\(QUEUE_PROOF_MISSING\)/);
expect(next(unbound.out)).toBe('NEXT: gstack-ship-queue ready --pr 1 --screen (dispatches a fresh screen)');
const redispatch = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--screen']);
expect(redispatch.status).toBe(EXIT.ok);
expect(line(redispatch.out, 'SCREEN_DISPATCH:')).toMatch(/^SCREEN_DISPATCH: evals\.yml run 101 .*\(not following run 100 concluded success and bound no verdict .*: a screen that bound no verdict is not a screen\)$/);
fx.write(d => { d.rules['evals.yml'] = { status: 'completed', conclusion: 'success', plan: { planned: 3 }, reuse: { run: 77, flaky: ['c-reused'] } }; });
const reusedDispatch = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--broad', '--screen']);
expect(reusedDispatch.status).toBe(EXIT.ok);
const reused = queue(fx, ['verify', '--pr', '1', '--screen']);
expect([reused.status, line(reused.out, 'SCREEN_VERDICT:')]).toEqual([EXIT.ok, 'SCREEN_VERDICT: #1 run 102 GREEN flaky=c-reused (reused the GREEN verdict of run 77 (main-moved): same own diff on a moved main, flaky 1)']);
const noRun = queue(fx, ['verify', '--pr', '2', '--screen']);
expect(noRun.err).toMatch(/cannot read PR #2's head through gh.*\(QUEUE_PROOF_MISSING\)/);
const unscreened = openPr(fx, '3');
readyReceipt(fx, '3', unscreened);
const none = queue(fx, ['verify', '--pr', '3', '--screen']);
expect([none.status, line(none.out, 'SCREEN_VERDICT:')]).toEqual([EXIT.ok, 'SCREEN_VERDICT: #3 none (READY --no-screen)']);
}, 120_000);
// Value: protects=a screen that concluded success with no slice (18 of 41 PR screens on 2026-10-26, superseded by a push before the plan) is NOT-RUN at verdict, not a screen and not a stale ref;
// fails_when=verify --screen calls it GREEN, HOLLOW or QUEUE_SCREEN_STALE_REF, or ready follows it; why_new=the hollow outcome reused the stale-ref code; seam=the gh shim's run with no plan artifact; no_claim=why evals.yml planned nothing
// Value: protects=train build --dry-run (with or without --stack-tests) writes nothing: the screen verdict it reads is printed, not recorded in the READY receipt or pushed to refs/notes/gstack-trains, as its DRY-RUN line says;
// fails_when=admitSlots persists the read verdict under --dry-run (the notes ref moves and the receipt gains a verdict before any real build); why_new=the other dry-run tests only check the trains dir; seam=none; no_claim=a verdict that was already recorded by ready or verify
test('train build --dry-run prints the screen verdict it read without recording it in the receipt or pushing the notes ref', () => {
const { fx, head, pregate } = readyFixture();
fx.write(d => { d.rules['evals.yml'] = GREEN_SCREEN; });
expect(queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--screen']).status).toBe(EXIT.ok);
expect(receiptOf(fx, '1', head).screen.verdict).toBeUndefined();
const notes = fx.repo.git('ls-remote', 'origin', 'refs/notes/gstack-trains');
const build = queue(fx, ['train', 'build', '--prs', `1@${head}`, '--dry-run']);
expect([build.status, line(build.out, 'SCREEN:'), line(build.out, 'DRY-RUN:')]).toEqual([EXIT.ok, 'SCREEN: #1 run 100 GREEN (flaky: c-flaky)', 'DRY-RUN: nothing written, pushed or dispatched']);
expect(receiptOf(fx, '1', head).screen.verdict).toBeUndefined();
expect(fx.repo.git('ls-remote', 'origin', 'refs/notes/gstack-trains')).toBe(notes);
const verify = queue(fx, ['verify', '--pr', '1', '--screen']);
expect([verify.status, receiptOf(fx, '1', head).screen.verdict?.outcome]).toEqual([EXIT.ok, 'GREEN']);
expect(fx.repo.git('ls-remote', 'origin', 'refs/notes/gstack-trains')).not.toBe(notes);
}, 120_000);
test('a screen run that concluded success with no slice is NOT-RUN: verify --screen refuses with QUEUE_SCREEN_NOT_RUN, the receipt records NOT_RUN, and ready --screen dispatches afresh', () => {
const { fx, head, pregate } = readyFixture();
fx.write(d => { d.rules['evals.yml'] = { status: 'completed', conclusion: 'success', plan: null }; });
expect(queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--screen']).status).toBe(EXIT.ok);
const verify = queue(fx, ['verify', '--pr', '1', '--screen']);
expect(verify.status).toBe(EXIT.refused);
expect(line(verify.out, 'SCREEN_VERDICT:')).toBe('SCREEN_VERDICT: #1 run 100 NOT-RUN (run 100 planned no cases (no paid-plan artifact; concluded success))');
expect(verify.err).toMatch(/the screen of #1 \(run 100\) concluded success with no slice: run 100 planned no cases .*; it is not a screen, and this head is not screened; fix: .*\(QUEUE_SCREEN_NOT_RUN\)/);
expect(next(verify.out)).toBe('NEXT: gstack-ship-queue ready --pr 1 --screen (dispatches a fresh screen)');
expect(receiptOf(fx, '1', head).screen.verdict).toMatchObject({ outcome: 'NOT_RUN', run: 100 });
const again = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--screen']);
expect([again.status, dispatches(fx, 'evals.yml').length]).toEqual([EXIT.ok, 2]);
expect(line(again.out, 'SCREEN_VERDICT:')).toBe('SCREEN_VERDICT: #1 run 100 NOT-RUN (run 100 planned no cases (no paid-plan artifact; concluded success))');
expect(line(again.out, 'SCREEN_DISPATCH:')).toMatch(/not following run 100 planned no cases .*: a screen that planned nothing is not a screen\)$/);
}, 120_000);
});
describe('#3465: the screen is planned on a scratch merge when GitHub\'s merge ref lags', () => {
// Value: protects=a CHANGELOG-only conflict (every main stamp leaves one) no longer blocks --screen: the screen tests the head merged onto current main by the train's rules;
// fails_when=ready still refuses on the lagging ref, pushes nothing, or the scratch merge's parents are not (base, head); why_new=the stale-ref test only asserted the refusal; seam=none; no_claim=evals.yml's checkout of the pushed branch
test('a lagging or missing merge ref gets a scratch merge pushed as screen/<pr>-<head12>; a real conflict is QUEUE_SCREEN_STALE_REF; prune --stale removes the branch once the PR merged', () => {
const { fx, head, pregate } = readyFixture();
const { repo } = fx;
fx.write(d => { d.rules['evals.yml'] = GREEN_SCREEN; });
repo.git('checkout', '-q', '-B', 'mainsim', 'origin/main');
const cl = fs.readFileSync(path.join(repo.work, 'CHANGELOG.md'), 'utf8');
fs.writeFileSync(path.join(repo.work, 'CHANGELOG.md'), cl.replace('# Changelog\n', '# Changelog\n\n## [1.2.4.0] - 2026-10-18\n\n- another PR, stamped\n'));
fs.writeFileSync(path.join(repo.work, 'VERSION'), '1.2.4.0\n');
repo.git('commit', '-qam', 'v1.2.4.0 stamp');
repo.git('push', '-q', 'origin', 'HEAD:refs/heads/main');
repo.git('checkout', '-q', 'pr-1');
const dry = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--screen', '--dry-run']);
expect([dry.status, dry.err]).toEqual([EXIT.ok, '']);
const main = repo.git('rev-parse', 'origin/main');
expect(line(dry.out, 'SCREEN_MERGE:')).toMatch(new RegExp(`^SCREEN_MERGE: scratch merge [0-9a-f]{12} of #1@${head.slice(0, 12)} onto main@${main.slice(0, 12)} \\(refs/pull/1/merge [0-9a-f]{12} merges onto [0-9a-f]{12}, not main@${main.slice(0, 12)}\\); would push screen/1-${head.slice(0, 12)} \\(dry run\\)$`));
expect(repo.git('ls-remote', 'origin', 'refs/heads/screen/*')).toBe('');
const real = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--screen']);
expect([real.status, real.err]).toEqual([EXIT.ok, '']);
const branch = `screen/1-${head.slice(0, 12)}`;
expect(line(real.out, 'SCREEN_MERGE:')).toContain(`; pushed ${branch}`);
const scratch = repo.git('ls-remote', 'origin', `refs/heads/${branch}`).split(/\s/)[0]!;
expect(scratch).toMatch(/^[0-9a-f]{40}$/);
repo.git('fetch', '-q', 'origin', `refs/heads/${branch}`);
expect([repo.git('rev-parse', `${scratch}^1`), repo.git('rev-parse', `${scratch}^2`)]).toEqual([repo.git('rev-parse', 'origin/main'), head]);
expect(repo.git('show', `${scratch}:VERSION`)).toBe('1.2.4.0');
expect(repo.git('show', `${scratch}:CHANGELOG.md`)).toContain('- #1 change');
expect(repo.git('show', `${scratch}:CHANGELOG.md`)).toContain('- another PR, stamped');
const d = dispatches(fx, 'evals.yml');
expect(d.length).toBe(1);
expect((d[0]!.inputs as any)).toMatchObject({ screen_merge_sha: scratch, expected_sha: scratch, screen_pr: '1' });
expect(receiptOf(fx, '1', head).screen).toMatchObject({ choice: 'screen', merge_sha: scratch, branch });
repo.git('push', '-q', 'origin', ':refs/pull/1/merge');
const missing = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--screen']);
expect([missing.status, line(missing.out, 'SCREEN_DISPATCH:')]).toEqual([EXIT.ok, 'SCREEN_DISPATCH: following run 100 (same merge SHA, base and policy; no duplicate)']);
expect(line(missing.out, 'SCREEN_MERGE:')).toContain('(origin has no refs/pull/1/merge)');
const kept = queue(fx, ['train', 'prune', '--stale', '--dry-run']);
expect(line(kept.out, 'KEEP:')).toBe(`KEEP: ${branch} (#1 OPEN)`);
fx.write(data => { (data.prs['1'] as any).state = 'MERGED'; });
const pruned = queue(fx, ['train', 'prune', '--stale']);
expect(line(pruned.out, 'DELETED:')).toBe(`DELETED: ${branch} (#1 MERGED)`);
expect(repo.git('ls-remote', 'origin', 'refs/heads/screen/*')).toBe('');
fx.write(data => { (data.prs['1'] as any).state = 'OPEN'; });
repo.git('checkout', '-q', '-B', 'mainsim2', 'origin/main');
fs.writeFileSync(path.join(repo.work, 'pr-1.txt'), 'main added the same file\n');
repo.git('add', 'pr-1.txt');
repo.git('commit', '-qm', 'main adds pr-1.txt');
repo.git('push', '-q', 'origin', 'HEAD:refs/heads/main');
repo.git('checkout', '-q', 'pr-1');
const conflict = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--screen']);
expect(conflict.status).toBe(EXIT.refused);
expect(conflict.err).toMatch(/#1 conflicts with main@[0-9a-f]{12} beyond the CHANGELOG and the stamp files \(pr-1\.txt\); GitHub's merge ref lags too \(origin has no refs\/pull\/1\/merge\).*\(QUEUE_SCREEN_STALE_REF\)/);
expect(next(conflict.out)).toBe('NEXT: resolve the conflict with main, push, run /ship, then gstack-ship-queue ready --pr 1 --screen');
expect(dispatches(fx, 'evals.yml').length).toBe(1);
}, 120_000);
});
describe('train prune --stale and train watch', () => {
function greenTrain(fx: TrainFixture, gate: Rule = GREEN_GATE) {
const h1 = openPr(fx, '1');
const h2 = openPr(fx, '2');
readyReceipt(fx, '1', h1);
readyReceipt(fx, '2', h2);
fx.write(d => { d.rules['evals.yml'] = gate; });
const r = queue(fx, ['train', 'build', '--prs', `1@${h1},2@${h2}`, '--free-timeout', '1']);
return { h1, h2, r, nonce: nonceOf(r.out) };
}
// Value: protects=a merging or green train whose PRs all merged elsewhere (gate.train-23af94b3c5e9 and -6755ab829acf on 2026-10-26) can be pruned without hand-editing refs/notes/gstack-trains;
// fails_when=prune skips merging/green trains, prunes one with an OPEN PR, or prunes without --stale; why_new=prune only knew terminal states; seam=the gh shim's PR states; no_claim=GitHub's real PR state
test('--stale settles a merging or green train whose every PR is MERGED or CLOSED on GitHub, logging each PR; an OPEN PR keeps it; without --stale nothing changes', () => {
const fx = make();
const { nonce } = greenTrain(fx);
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
expect(trainJson(fx, nonce).state).toBe('green');
const plain = queue(fx, ['train', 'prune']);
expect(line(plain.out, 'KEEP:')).toBe(`KEEP: train/${nonce} (green)`);
const open = queue(fx, ['train', 'prune', '--stale', '--dry-run']);
expect(line(open.out, 'KEEP:')).toBe(`KEEP: train/${nonce} (green; #1 OPEN, #2 OPEN still need the train)`);
fx.write(d => { (d.prs['1'] as any).state = 'MERGED'; });
const half = queue(fx, ['train', 'prune', '--stale', '--dry-run']);
expect(line(half.out, 'KEEP:')).toBe(`KEEP: train/${nonce} (green; #2 OPEN still needs the train)`);
fx.write(d => { (d.prs['2'] as any).state = 'CLOSED'; });
const dry = queue(fx, ['train', 'prune', '--stale', '--dry-run']);
expect(line(dry.out, 'STALE:')).toBe(`STALE: ${nonce} (green): #1 MERGED, #2 CLOSED; would mark stale`);
expect(line(dry.out, 'WOULD-DELETE:')).toBe(`WOULD-DELETE: train/${nonce} (stale)`);
expect(trainJson(fx, nonce).state).toBe('green');
const pruned = queue(fx, ['train', 'prune', '--stale']);
expect([pruned.status, line(pruned.out, 'STALE:'), line(pruned.out, 'DELETED:')]).toEqual([EXIT.ok, `STALE: ${nonce} (green): #1 MERGED, #2 CLOSED`, `DELETED: train/${nonce} (stale)`]);
const t = trainJson(fx, nonce);
expect(t.state).toBe('stale');
expect(t.effects.map((e: any) => e.what)).toContain('marked stale by train prune --stale: #1 MERGED, #2 CLOSED');
expect(fx.repo.git('ls-remote', 'origin', `refs/heads/train/${nonce}`)).toBe('');
const status = queue(fx, ['train', 'status']);
expect(status.out).toContain('TRAIN: none live');
}, 120_000);
// Value: protects=a stale train record or READY receipt leaves refs/notes/gstack-trains through one command under an exact lease, with the local files, instead of a hand-run git notes remove on every machine;
// fails_when=forget removes the wrong notes, deletes locally when the push failed, or forgets a merging train; why_new=no CLI removed notes (merge-train skill, known gaps); seam=none; no_claim=a concurrent writer's race (the lease is exercised only as a fast-forward)
test('train forget removes a settled train (attempts included) or a READY receipt from the notes ref and the state root, leaves other records alone, keeps everything (the local notes ref too) when the lease is rejected, and refuses a train still building, running or merging', () => {
const fx = make();
const { nonce, h1 } = greenTrain(fx);
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
const trainDir = path.join(fx.state, 'ship-queue', 'trains', nonce);
expect(fs.existsSync(path.join(trainDir, 'attempts', 'a1.json'))).toBe(true);
const dry = queue(fx, ['train', 'forget', '--train', nonce, '--dry-run']);
expect([dry.status, line(dry.out, 'FORGET:')]).toEqual([EXIT.ok, `FORGET: train ${nonce} (green; 1 attempt(s))`]);
expect(dry.out).toContain(` note gstack-trains:train:${nonce}`);
expect(dry.out).toContain(` note gstack-trains:attempt:${nonce}:a1`);
expect(fs.existsSync(trainDir)).toBe(true);
const notesBefore = fx.repo.git('ls-remote', 'origin', 'refs/notes/gstack-trains').split(/\s/)[0]!;
const forgot = queue(fx, ['train', 'forget', '--train', nonce]);
expect([forgot.status, forgot.err]).toEqual([EXIT.ok, '']);
expect(line(forgot.out, 'FORGOTTEN:')).toMatch(new RegExp(`^FORGOTTEN: 2 note\\(s\\) removed; notes pushed to origin refs/notes/gstack-trains \\(lease ${notesBefore.slice(0, 12)}\\); 1 local path\\(s\\) deleted$`));
expect(fs.existsSync(trainDir)).toBe(false);
const clean = path.join(fx.repo.root, 'clean-state');
const status = queue(fx, ['train', 'status'], { GSTACK_STATE_ROOT: clean });
expect(status.out).toContain('TRAIN: none live');
expect(fs.existsSync(path.join(clean, 'ship-queue', 'trains', nonce))).toBe(false);
expect(fs.existsSync(path.join(clean, 'ship-queue', 'ready', repoKey(fx), `1-${h1}.json`))).toBe(false);
const again = queue(fx, ['train', 'forget', '--train', nonce]);
expect(again.err).toMatch(new RegExp(`no train record ${nonce} here or in refs/notes/gstack-trains.*\\(QUEUE_PROOF_MISSING\\)`));
const h3 = openPr(fx, '3');
fx.write(d => { d.ci[h3] = [{ status: 'completed', conclusion: 'success' }]; });
const pregate = path.join(fx.repo.root, 'pregate3.json');
fs.writeFileSync(pregate, JSON.stringify({ schema_version: 1, tree: runBin(fx.repo, 'gstack-wtree', []).out.trim(), head: h3, verdict: 'pass', checks: [{ id: 'unit', status: 'pass' }] }));
expect(queue(fx, ['ready', '--pr', '3', '--pregate', pregate, '--no-screen']).status).toBe(EXIT.ok);
const h4 = openPr(fx, '4');
fx.write(d => { d.ci[h4] = [{ status: 'completed', conclusion: 'success' }]; });
fs.writeFileSync(pregate, JSON.stringify({ schema_version: 1, tree: runBin(fx.repo, 'gstack-wtree', []).out.trim(), head: h4, verdict: 'pass', checks: [{ id: 'unit', status: 'pass' }] }));
expect(queue(fx, ['ready', '--pr', '4', '--pregate', pregate, '--no-screen']).status).toBe(EXIT.ok);
const receipt = queue(fx, ['train', 'forget', '--receipt', `3@${h3}`]);
expect([receipt.status, line(receipt.out, 'FORGET:')]).toEqual([EXIT.ok, `FORGET: READY receipt #3 at ${h3.slice(0, 12)} (screen no-screen)`]);
expect(fs.existsSync(path.join(fx.state, 'ship-queue', 'ready', repoKey(fx), `3-${h3}.json`))).toBe(false);
const rebuilt = queue(fx, ['train', 'status'], { GSTACK_STATE_ROOT: path.join(fx.repo.root, 'clean-state-2') });
expect(rebuilt.status).toBe(EXIT.ok);
expect(fs.existsSync(path.join(fx.repo.root, 'clean-state-2', 'ship-queue', 'ready', repoKey(fx), `4-${h4}.json`))).toBe(true);
expect(fs.existsSync(path.join(fx.repo.root, 'clean-state-2', 'ship-queue', 'ready', repoKey(fx), `3-${h3}.json`))).toBe(false);
expect(queue(fx, ['train', 'forget', '--receipt', '3@abc']).err).toMatch(/--receipt takes <pr>@<full head sha>.*\(QUEUE_PROOF_MISSING\)/);
expect(queue(fx, ['train', 'forget']).status).toBe(EXIT.usage);
const { nonce: second } = (() => { readyReceipt(fx, '3', h3); readyReceipt(fx, '4', h4); const r = queue(fx, ['train', 'build', '--prs', `3@${h3},4@${h4}`, '--free-timeout', '1']); return { nonce: nonceOf(r.out) }; })();
expect(queue(fx, ['train', 'verdict', '--train', second]).status).toBe(EXIT.ok);
const hook = path.join(fx.repo.origin, 'hooks', 'pre-receive');
fs.mkdirSync(path.dirname(hook), { recursive: true });
fs.writeFileSync(hook, '#!/bin/sh\nwhile read o n r; do case "$r" in refs/notes/*) echo "notes frozen" >&2; exit 1;; esac; done\nexit 0\n', { mode: 0o755 });
const localNotes = fx.repo.git('rev-parse', 'refs/notes/gstack-trains');
const rejected = queue(fx, ['train', 'forget', '--train', second]);
expect(rejected.status).toBe(EXIT.refused);
expect(rejected.err).toMatch(/the notes ref was not updated \(git push refs\/notes\/gstack-trains rejected: .*notes frozen.*; the local notes ref was restored\); nothing deleted locally.*\(QUEUE_PROOF_MISSING\)/);
expect(fs.existsSync(path.join(fx.state, 'ship-queue', 'trains', second, 'train.json'))).toBe(true);
expect(fx.repo.git('rev-parse', 'refs/notes/gstack-trains')).toBe(localNotes);
expect(fx.repo.git('notes', '--ref=refs/notes/gstack-trains', 'list').split('\n').length).toBeGreaterThan(0);
fs.rmSync(hook);
const trainFile = path.join(fx.state, 'ship-queue', 'trains', second, 'train.json');
for (const state of ['dispatch-unknown', 'merging']) {
const t = JSON.parse(fs.readFileSync(trainFile, 'utf8'));
t.state = state;
fs.writeFileSync(trainFile, JSON.stringify(t));
const live = queue(fx, ['train', 'forget', '--train', second]);
expect(live.status).toBe(EXIT.refused);
expect(live.err).toMatch(new RegExp(`is ${state}; forget settles nothing that is still building, running or merging .*\\(QUEUE_PROOF_MISSING\\)`));
expect(fs.existsSync(trainFile)).toBe(true);
}
}, 120_000);
// Value: protects=a restarted controller re-arms with one command: watch continues from the record (free tests, gate dispatch, the pending run, the verdict) and prints NEXT;
// fails_when=watch dispatches twice, blocks past --timeout, silently replaces a malformed --free-timeout with the default, or does not reach the verdict after a restart; why_new=no resumable follow existed (hand-run gh run watch chains died with the machine); seam=GSTACK_SHIP_QUEUE_POLL_MS; no_claim=real GitHub run timing
test('watch resumes from free-pending through the gate dispatch to the verdict, times out with its own NEXT, and never dispatches twice', () => {
const fx = make();
fx.write(d => { d.rules['free-tests.yml'] = { status: 'in_progress', conclusion: null }; });
const { nonce, r } = greenTrain(fx, { ...GREEN_GATE, status: 'in_progress', conclusion: null });
expect(r.status).toBe(EXIT.refused);
expect(trainJson(fx, nonce).state).toBe('free-pending');
const waiting = queue(fx, ['train', 'watch', '--train', nonce, '--timeout', '1']);
expect([waiting.status, result(waiting.out)]).toEqual([EXIT.refused, expect.stringContaining('status=incomplete')]);
expect(waiting.out).toContain(`WATCH: train ${nonce} state=free-pending (resumable; rerun this command after an interruption)`);
expect(next(waiting.out)).toBe(`NEXT: gstack-ship-queue train watch --train ${nonce} --timeout 1`);
expect(dispatches(fx, 'evals.yml')).toEqual([]);
fx.write(d => { for (const run of d.runs) if (run.workflow === 'free-tests.yml') Object.assign(run, { status: 'completed', conclusion: 'success' }); });
const gate = queue(fx, ['train', 'watch', '--train', nonce, '--timeout', '1']);
expect(gate.status).toBe(EXIT.refused);
expect(gate.out).toContain(`WATCH: train build --train ${nonce}: free tests green, gate dispatched`);
expect(gate.err).toMatch(/is still in_progress at the watch timeout.*\(QUEUE_TRAIN_PENDING\)/);
expect(next(gate.out)).toBe(`NEXT: gstack-ship-queue train watch --train ${nonce} --timeout 1`);
expect(dispatches(fx, 'evals.yml').length).toBe(1);
expect(trainJson(fx, nonce).state).toBe('gate-pending');
const again = queue(fx, ['train', 'watch', '--train', nonce, '--timeout', '1']);
expect([again.status, dispatches(fx, 'evals.yml').length]).toEqual([EXIT.refused, 1]);
fx.write(d => { for (const run of d.runs) if (run.workflow === 'evals.yml') Object.assign(run, { status: 'completed', conclusion: 'success' }); });
const done = queue(fx, ['train', 'watch', '--train', nonce, '--timeout', '5']);
expect([done.status, done.err]).toEqual([EXIT.ok, '']);
expect(done.out).toContain('WATCH: gate run completed; reading the verdict');
expect(line(done.out, 'VERDICT:')).toMatch(new RegExp(`^VERDICT: GREEN train ${nonce} run 10[0-9] `));
expect(next(done.out)).toBe(`NEXT: gstack-ship-queue verify --pr 1 --train ${nonce}`);
const idle = queue(fx, ['train', 'watch', '--train', nonce]);
expect([idle.status, line(idle.out, 'WATCH: nothing')]).toEqual([EXIT.ok, 'WATCH: nothing to wait for in state green']);
expect(next(idle.out)).toBe(`NEXT: gstack-ship-queue verify --pr 1 --train ${nonce}`);
expect(queue(fx, ['train', 'watch']).status).toBe(EXIT.usage);
expect(queue(fx, ['train', 'watch', '--train', nonce, '--free-timeout', 'abc']).status).toBe(EXIT.usage);
expect(queue(fx, ['train', 'watch', '--train', 'no-such-train']).err).toMatch(/no train record no-such-train.*\(QUEUE_PROOF_MISSING\)/);
}, 120_000);
});
describe('verify --post-merge', () => {
// Value: protects=after a train merges, the coordinator watches main's push workflows at the merged sha until they settle instead of a background gh chain a restart kills (GBrain practice 8);
// fails_when=a pending check is called settled, a red check is called green, a pending or failed commit status (the legacy status API, which ciAtHead counts) is reported as red with no name or as green, or the command needs process state; why_new=no post-merge watch existed; seam=the gh shim's check runs and commit statuses at a sha; no_claim=which workflows main runs
test('waits for every check run at the sha to complete, prints each, is green only when none failed, red with the failed names, pending or red on a commit status, and resumable after a timeout', () => {
const fx = make();
const sha = fx.repo.git('rev-parse', 'HEAD');
fx.write(d => { d.ci[sha] = [{ name: 'free-tests', status: 'completed', conclusion: 'success' }, { name: 'windows-free-tests', status: 'in_progress', conclusion: null }, { name: 'main-backstop', status: 'completed', conclusion: 'neutral' }]; });
const pending = queue(fx, ['verify', '--post-merge', sha, '--timeout', '1']);
expect(pending.status).toBe(EXIT.refused);
expect(line(pending.out, 'POST_MERGE:')).toBe(`POST_MERGE: ${sha.slice(0, 12)} pending (passed=1 failed=0 pending=1 skipped=1)`);
expect(pending.out).toContain('CHECK: windows-free-tests in_progress');
expect(pending.err).toMatch(new RegExp(`main's push workflows at ${sha.slice(0, 12)} have not settled .*windows-free-tests.*\\(QUEUE_TRAIN_PENDING\\)`));
expect(next(pending.out)).toBe(`NEXT: gstack-ship-queue verify --post-merge ${sha} --timeout 1`);
fx.write(d => { d.ci[sha]![1] = { name: 'windows-free-tests', status: 'completed', conclusion: 'failure' }; });
const red = queue(fx, ['verify', '--post-merge', sha, '--timeout', '1']);
expect(red.status).toBe(EXIT.fail);
expect(line(red.out, 'POST_MERGE:')).toBe(`POST_MERGE: ${sha.slice(0, 12)} red (passed=1 failed=1 pending=0 skipped=1)`);
expect(red.out).toContain('CHECK: windows-free-tests failure');
expect(red.err).toMatch(new RegExp(`main's push workflows at ${sha.slice(0, 12)} are red: windows-free-tests.*\\(QUEUE_POST_MERGE_RED\\)`));
expect(next(red.out)).toBe('NEXT: read the red run, repair on a new PR or roll back; gstack-ship-queue train status names the freeze if the backstop filed one');
fx.write(d => { d.ci[sha]![1] = { name: 'windows-free-tests', status: 'completed', conclusion: 'success' }; });
const green = queue(fx, ['verify', '--post-merge', sha, '--timeout', '1']);
expect([green.status, green.err]).toEqual([EXIT.ok, '']);
expect(line(green.out, 'POST_MERGE:')).toBe(`POST_MERGE: ${sha.slice(0, 12)} green (passed=2 failed=0 pending=0 skipped=1)`);
expect(green.out.split('\n').filter(l => l.startsWith('CHECK: ')).sort()).toEqual(['CHECK: free-tests success', 'CHECK: main-backstop neutral', 'CHECK: windows-free-tests success']);
expect(next(green.out)).toBe('NEXT: none');
fx.write(d => { d.statuses = { [sha]: [{ state: 'pending', context: 'ci/vendor' }] }; });
const statusPending = queue(fx, ['verify', '--post-merge', sha, '--timeout', '1']);
expect([statusPending.status, line(statusPending.out, 'POST_MERGE:')]).toEqual([EXIT.refused, `POST_MERGE: ${sha.slice(0, 12)} pending (passed=2 failed=0 pending=1 skipped=1)`]);
expect(statusPending.err).toMatch(/have not settled at the timeout: a commit status still running.*\(QUEUE_TRAIN_PENDING\)/);
fx.write(d => { d.statuses = { [sha]: [{ state: 'failure', context: 'ci/vendor' }] }; });
const statusRed = queue(fx, ['verify', '--post-merge', sha, '--timeout', '1']);
expect([statusRed.status, line(statusRed.out, 'POST_MERGE:')]).toEqual([EXIT.fail, `POST_MERGE: ${sha.slice(0, 12)} red (passed=2 failed=1 pending=0 skipped=1)`]);
expect(statusRed.err).toMatch(/are red: a commit status \(no check run failed\).*\(QUEUE_POST_MERGE_RED\)/);
fx.write(d => { d.statuses = {}; d.ci[sha] = []; });
const none = queue(fx, ['verify', '--post-merge', sha, '--timeout', '1']);
expect([none.status, line(none.out, 'POST_MERGE:')]).toEqual([EXIT.refused, `POST_MERGE: ${sha.slice(0, 12)} none (passed=0 failed=0 pending=0 skipped=0)`]);
expect(none.err).toMatch(new RegExp(`no check run has attached to ${sha.slice(0, 12)} yet.*\\(QUEUE_TRAIN_PENDING\\)`));
expect(queue(fx, ['verify', '--post-merge', 'not-a-sha']).status).toBe(EXIT.usage);
}, 120_000);
});
describe('ready --dispatch-ci', () => {
// Value: protects=a PR that conflicts with the base (a CHANGELOG-only conflict after every stamp) gets its free workflows dispatched on the branch, waited for, and its requires-remote pregate lanes cleared with the run URLs in one command, replacing the four-step hand procedure;
// fails_when=the dispatch happens on a fork head, a red run clears a lane, a lane whose workflow was not dispatched is cleared, ready still says ci: none, or the CI_DISPATCH lines push the screen: line in front of READY (identifiers first); why_new=the ci-hint was advisory prose; seam=the gh shim's workflow run, run list and check runs; no_claim=GitHub's real dispatch latency
test('dispatches the named workflows on a conflicting PR, waits for their runs, clears the matching pregate lanes with the run URLs and then READYs; a fork head and a red run are refused', () => {
const { fx, head, pregate } = readyFixture();
fx.write(d => {
(d.prs['1'] as any).mergeable = 'CONFLICTING';
d.ci[head] = [];
d.rules['free-tests.yml'] = { ci: 'free-tests', headSha: head };
d.rules['windows-free-tests.yml'] = { ci: 'windows-free-tests', headSha: head };
});
const tree = JSON.parse(fs.readFileSync(pregate, 'utf8')).tree;
fs.writeFileSync(pregate, JSON.stringify({ schema_version: 1, tree, head, verdict: 'requires-remote', checks: [{ id: 'unit', status: 'pass' }], requires_remote: [
{ lane: 'free-tests.yml/free-tests', platform: 'linux', workflow: 'free-tests.yml', job: 'free-tests', tests: ['test/a.test.ts'], receipt: null },
{ lane: 'windows-free-tests.yml/windows-free-tests', platform: 'windows', workflow: 'windows-free-tests.yml', job: 'windows-free-tests', tests: ['test/a.test.ts'], receipt: null },
{ lane: 'macos-free-tests.yml/macos', platform: 'macos', workflow: 'macos-free-tests.yml', job: 'macos', tests: ['test/b.test.ts'], receipt: null }] }));
const plain = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--no-screen']);
expect([plain.status, line(plain.out, 'ci:')]).toEqual([EXIT.refused, `ci: none at ${head.slice(0, 12)} (passed=0 failed=0 pending=0 skipped=0)`]);
const r = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--no-screen', '--dispatch-ci', 'free-tests.yml,windows-free-tests.yml', '--timeout', '5']);
expect(r.out.split('\n').filter(l => l.startsWith('CI_DISPATCH:'))).toEqual([
'CI_DISPATCH: free-tests.yml --ref pr-1 run 100 https://example.invalid/runs/100 success',
'CI_DISPATCH: windows-free-tests.yml --ref pr-1 run 101 https://example.invalid/runs/101 success']);
expect(r.out.split('\n').filter(l => l.startsWith('PREGATE_CLEARED:'))).toEqual([
'PREGATE_CLEARED: free-tests.yml/free-tests https://example.invalid/runs/100',
'PREGATE_CLEARED: windows-free-tests.yml/windows-free-tests https://example.invalid/runs/101']);
expect(line(r.out, 'ci:')).toBe(`ci: green at ${head.slice(0, 12)} (passed=2 failed=0 pending=0 skipped=0)`);
expect(line(r.out, 'PREGATE:')).toMatch(/^PREGATE: requires-remote .*/);
expect(r.err).toMatch(/1 requires-remote obligation\(s\) without a receipt: macos-free-tests\.yml\/macos.*\(QUEUE_NOT_READY\)/);
const saved = JSON.parse(fs.readFileSync(pregate, 'utf8'));
expect(saved.requires_remote.map((l: any) => l.receipt)).toEqual(['https://example.invalid/runs/100', 'https://example.invalid/runs/101', null]);
expect(dispatches(fx).map(l => l.workflow)).toEqual(['free-tests.yml', 'windows-free-tests.yml']);
const again = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--no-screen', '--dispatch-ci', 'free-tests.yml', '--timeout', '5']);
expect(dispatches(fx).length).toBe(2);
expect(line(again.out, 'CI_DISPATCH:')).toBe('CI_DISPATCH: free-tests.yml not dispatched (run 100 already concluded success at this head)');
const cleared = JSON.parse(fs.readFileSync(pregate, 'utf8'));
cleared.requires_remote[2].receipt = 'https://example.invalid/runs/7';
fs.writeFileSync(pregate, JSON.stringify(cleared));
const ok = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--no-screen', '--dispatch-ci', 'free-tests.yml', '--timeout', '5']);
expect([ok.status, ok.err]).toEqual([EXIT.ok, '']);
const okLines = ok.out.split('\n');
expect(okLines[0]).toBe('CI_DISPATCH: free-tests.yml not dispatched (run 100 already concluded success at this head)');
expect(okLines[okLines.findIndex(l => l.startsWith('READY #1 ')) + 1]).toBe('screen: no-screen');
fx.write(d => { d.rules['free-tests.yml'] = { ci: 'free-tests', conclusion: 'failure', headSha: head }; d.ci[head] = []; d.runs.length = 0; });
const red = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--no-screen', '--dispatch-ci', 'free-tests.yml', '--timeout', '5']);
expect([red.status, line(red.out, 'CI_DISPATCH:')]).toEqual([EXIT.refused, 'CI_DISPATCH: free-tests.yml --ref pr-1 run 102 https://example.invalid/runs/102 failure']);
expect(red.err).toMatch(new RegExp(`free-tests\\.yml run 102 concluded failure at ${head.slice(0, 12)}; a red run clears no lane.*\\(QUEUE_NOT_READY\\)`));
expect(JSON.parse(fs.readFileSync(pregate, 'utf8')).requires_remote[0].receipt).toBe('https://example.invalid/runs/100');
fx.write(d => { (d.prs['1'] as any).isCrossRepository = true; });
const fork = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--no-screen', '--dispatch-ci', 'free-tests.yml']);
expect([fork.status, fork.err]).toEqual([EXIT.refused, expect.stringMatching(/#1's head is on a fork.*not dispatched.*\(QUEUE_TRAIN_UNTRUSTED\)/)]);
expect(queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--no-screen', '--dispatch-ci', '../evil.yml']).status).toBe(EXIT.usage);
}, 120_000);
});
describe('train build --dry-run --stack-tests', () => {
// Value: protects=a PR whose new tests pass on its own branch but fail once stacked behind another PR's change is caught before the train spends a gate (#3460 on #3451, 2026-10-26);
// fails_when=the new test files run against the PR head instead of the integrated prefix, a failing prefix still dry-runs green, or a PR without new tests (a helper under test/ included: bun exits 1 when a positional filter matches no test file) is misreported; why_new=no pre-dispatch stack check existed; seam=bun test in a scratch worktree; no_claim=the repo's full suite on the prefix
test('runs each PR\'s new test files on its integrated prefix T<k>: a pass and a helper-only PR are reported, a failure refuses the dry run and names the prefix, and the ordinary dry run skips it', () => {
const fx = make();
const h1 = openPr(fx, '1', { file: 'test/pr-1_test.ts', text: "import { expect, test } from 'bun:test';\ntest('pr-1', () => { expect(1).toBe(1); });\n" });
const h2 = openPr(fx, '2', { file: 'test/pr-2.test.ts', text: "import { existsSync } from 'node:fs';\nimport { expect, test } from 'bun:test';\ntest('pr-2 wants no pr-1 test beside it', () => { expect(existsSync('test/pr-1_test.ts')).toBe(false); });\n" });
const h3 = openPr(fx, '3', { file: 'test/helpers/pr-3-helper.ts', text: 'export const three = 3;\n' });
readyReceipt(fx, '1', h1);
readyReceipt(fx, '2', h2);
readyReceipt(fx, '3', h3);
const plain = queue(fx, ['train', 'build', '--prs', `1@${h1},2@${h2},3@${h3}`, '--dry-run']);
expect([plain.status, plain.out.includes('STACK_TESTS:')]).toEqual([EXIT.ok, false]);
const r = queue(fx, ['train', 'build', '--prs', `1@${h1},2@${h2},3@${h3}`, '--dry-run', '--stack-tests']);
expect(r.status).toBe(EXIT.fail);
expect(r.out.split('\n').filter(l => l.startsWith('STACK_TESTS:'))).toEqual([
'STACK_TESTS: #1 T1 1 new test file(s) pass (test/pr-1_test.ts)',
expect.stringMatching(/^STACK_TESTS: #2 T2 1 new test file\(s\) FAIL \(test\/pr-2\.test\.ts\): .*\(fail\) pr-2 wants no pr-1 test beside it.*1 fail$/),
'STACK_TESTS: #3 T3 no new test files']);
expect(r.err).toMatch(/#2's new tests fail on the integrated prefix T2 \(test\/pr-2\.test\.ts\); they pass on the PR head alone only if nothing before it in the train changes what they read.*\(QUEUE_TRAIN_STACK_RED\)/);
expect(next(r.out)).toBe(`NEXT: fix #2's tests against T2, or reorder with --order, then gstack-ship-queue train build --prs 1@${h1},2@${h2},3@${h3} --dry-run --stack-tests`);
expect(line(r.out, 'DRY-RUN:')).toBe('DRY-RUN: nothing written, pushed or dispatched');
const reordered = queue(fx, ['train', 'build', '--prs', `1@${h1},2@${h2},3@${h3}`, '--order', '2,1,3', '--dry-run', '--stack-tests']);
expect(reordered.status).toBe(EXIT.ok);
expect(reordered.out.split('\n').filter(l => l.startsWith('STACK_TESTS:'))).toEqual([
'STACK_TESTS: #2 T1 1 new test file(s) pass (test/pr-2.test.ts)',
'STACK_TESTS: #1 T2 1 new test file(s) pass (test/pr-1_test.ts)',
'STACK_TESTS: #3 T3 no new test files']);
expect(fx.repo.git('worktree', 'list', '--porcelain')).not.toContain('gstack-stack-tests-');
expect(queue(fx, ['train', 'build', '--prs', `1@${h1}`, '--stack-tests']).status).toBe(EXIT.usage);
}, 180_000);
});
+21 -20
View File
@@ -196,9 +196,9 @@ describe('ready under a train policy: the screen choice', () => {
expect(queue(fx, ['ready', '--pr', '1', '--broad']).status).toBe(EXIT.usage);
}, 120_000);
// Value: protects=--screen never dispatches against a merge ref GitHub has not recomputed for the PR head (GSTA-95 on #3431: a stale ref made evals.yml plan 0 cases and report success);
// fails_when=the merge ref's second parent is not compared with the PR head, or a missing merge ref keeps the old QUEUE_NOT_READY code; why_new=the --screen test above only sees a fresh merge ref; seam=none; no_claim=GitHub's real recompute timing (a bare origin stands in)
test('--screen refuses QUEUE_SCREEN_STALE_REF when refs/pull/<n>/merge lags the PR head or is missing, dispatching nothing; a recomputed merge ref admits it', () => {
// Value: protects=--screen never dispatches against a merge ref GitHub has not recomputed for the PR head (GSTA-95 on #3431: a stale ref made evals.yml plan 0 cases and report success): a lagging, missing or unfetchable ref is replaced by a scratch merge of the head onto current main (#3465), and an unreachable remote is still refused;
// fails_when=the merge ref's parents are not compared with the PR head and current main, or the scratch merge is skipped; why_new=the --screen test above only sees a fresh merge ref; seam=none; no_claim=GitHub's real recompute timing (a bare origin stands in)
test('--screen plans the screen on a scratch merge when refs/pull/<n>/merge lags the PR head, is missing or cannot be fetched; an unreachable remote is POLICY_SOURCE_UNAVAILABLE; a recomputed merge ref is used as is', () => {
const { fx, head, pregate } = readyFixture();
const { repo } = fx;
fs.appendFileSync(path.join(repo.work, 'pr-1.txt'), 'pushed after the merge ref was computed\n');
@@ -208,41 +208,41 @@ describe('ready under a train policy: the screen choice', () => {
fx.write(d => { (d.prs['1'] as any).headRefOid = moved; (d.prs['1'] as any).body = shippedBody('1', moved); d.ci[moved] = [{ status: 'completed', conclusion: 'success' }]; });
const pregate2 = path.join(repo.root, 'pregate2.json');
fs.writeFileSync(pregate2, JSON.stringify({ schema_version: 1, tree: runBin(repo, 'gstack-wtree', []).out.trim(), head: moved, verdict: 'pass', checks: [{ id: 'unit', status: 'pass' }] }));
const stale = queue(fx, ['ready', '--pr', '1', '--pregate', pregate2, '--screen']);
expect(stale.status).toBe(EXIT.refused);
expect(stale.err).toMatch(new RegExp(`refs/pull/1/merge \\([0-9a-f]{12}\\) merges ${head.slice(0, 12)}, not the PR head ${moved.slice(0, 12)}.*\\(QUEUE_SCREEN_STALE_REF\\)`));
expect(line(stale.out, 'NEXT:')).toBe('NEXT: resolve the conflict with main (or wait for GitHub to recompute refs/pull/1/merge), push, then gstack-ship-queue ready --pr 1 --screen; a screen dispatched on a stale merge ref plans no cases');
const stale = queue(fx, ['ready', '--pr', '1', '--pregate', pregate2, '--screen', '--dry-run']);
expect([stale.status, stale.err]).toEqual([EXIT.ok, '']);
expect(line(stale.out, 'SCREEN_MERGE:')).toMatch(new RegExp(`^SCREEN_MERGE: scratch merge [0-9a-f]{12} of #1@${moved.slice(0, 12)} onto main@[0-9a-f]{12} \\(refs/pull/1/merge [0-9a-f]{12} merges ${head.slice(0, 12)}, not ${moved.slice(0, 12)}\\); would push screen/1-${moved.slice(0, 12)} \\(dry run\\)$`));
repo.git('push', '-q', 'origin', ':refs/pull/1/merge');
const missing = queue(fx, ['ready', '--pr', '1', '--pregate', pregate2, '--screen']);
expect(missing.status).toBe(EXIT.refused);
expect(missing.err).toMatch(/has no refs\/pull\/1\/merge.*\(QUEUE_SCREEN_STALE_REF\)/);
const missing = queue(fx, ['ready', '--pr', '1', '--pregate', pregate2, '--screen', '--dry-run']);
expect([missing.status, missing.err]).toEqual([EXIT.ok, '']);
expect(line(missing.out, 'SCREEN_MERGE:')).toContain('(origin has no refs/pull/1/merge); would push');
const tree = repo.git('rev-parse', `${moved}^{tree}`);
const main = repo.git('rev-parse', 'origin/main');
const badMerge = spawnSync('git', ['-C', repo.origin, 'hash-object', '-t', 'commit', '-w', '--stdin', '--literally'], { input: `tree ${tree}\nparent ${main}\nparent ${moved}\nauthor A <a@example.com> 1700000000 +0000\ncommitter B 1700000000 +0000\n\nmerge without a committer email\n`, encoding: 'utf8', timeout: 30_000 }).stdout.trim();
spawnSync('git', ['-C', repo.origin, 'update-ref', 'refs/pull/1/merge', badMerge], { timeout: 30_000 });
repo.git('config', 'fetch.fsckObjects', 'true');
const unfetchable = queue(fx, ['ready', '--pr', '1', '--pregate', pregate2, '--screen']);
expect(unfetchable.status).toBe(EXIT.refused);
expect(unfetchable.err).toMatch(/could not fetch refs\/pull\/1\/merge from origin: .*\(QUEUE_SCREEN_STALE_REF\)/);
expect(line(unfetchable.out, 'NEXT:')).toBe('NEXT: check the remote, then gstack-ship-queue ready --pr 1 --screen');
const unfetchable = queue(fx, ['ready', '--pr', '1', '--pregate', pregate2, '--screen', '--dry-run']);
expect([unfetchable.status, unfetchable.err]).toEqual([EXIT.ok, '']);
expect(line(unfetchable.out, 'SCREEN_MERGE:')).toMatch(/\(refs\/pull\/1\/merge [0-9a-f]{12} could not be fetched\); would push/);
repo.git('config', '--unset', 'fetch.fsckObjects');
const originUrl = repo.git('remote', 'get-url', 'origin');
repo.git('remote', 'set-url', 'origin', path.join(repo.root, 'no-such-origin.git'));
const unlisted = queue(fx, ['ready', '--pr', '1', '--pregate', pregate2, '--screen']);
repo.git('remote', 'set-url', 'origin', originUrl);
expect(unlisted.status).toBe(EXIT.refused);
expect(unlisted.err).toMatch(/could not list origin's refs: .*\(QUEUE_SCREEN_STALE_REF\)/);
expect(unlisted.err).toMatch(/git fetch origin main failed: .*\(POLICY_SOURCE_UNAVAILABLE\)/);
expect(unlisted.err.split('\n').filter(Boolean).length).toBe(1);
expect(dispatches(fx)).toEqual([]);
expect(fs.existsSync(path.join(fx.state, 'ship-queue', 'ready'))).toBe(false);
repo.git('push', '-q', '-f', 'origin', `${mergeRefCommit(fx, moved)}:refs/pull/1/merge`);
const fresh = queue(fx, ['ready', '--pr', '1', '--pregate', pregate2, '--screen', '--dry-run']);
expect([fresh.status, fresh.err]).toEqual([EXIT.ok, '']);
expect(line(fresh.out, 'SCREEN_MERGE:')).toMatch(new RegExp(`^SCREEN_MERGE: GitHub's refs/pull/1/merge [0-9a-f]{12} merges the head ${moved.slice(0, 12)} onto main@[0-9a-f]{12}$`));
expect(line(fresh.out, 'SCREEN_DISPATCH:')).toContain('(dry run: not dispatched)');
expect(repo.git('ls-remote', 'origin', 'refs/heads/screen/*')).toBe('');
}, 120_000);
// Value: protects=a finished screen that planned 0 cases never counts as a screen: ready dispatches a fresh one instead of adopting it and train build seats the slot unscreened with a SCREEN: line;
// fails_when=the census is not read (a hollow run is followed, or ORDER says screened); why_new=no test reads a screen run's record; seam=none; no_claim=evals.yml's real artifact (the shim's record stands in)
// fails_when=the census is not read (a not-run screen is followed, or ORDER says screened); why_new=no test reads a screen run's record; seam=none; no_claim=evals.yml's real artifact (the shim's record stands in)
test('a screen run that planned 0 cases is not followed by ready and is counted as not-run by train build', () => {
const { fx, head, pregate } = readyFixture();
fx.write(d => { d.rules['evals.yml'] = { status: 'completed', conclusion: 'success', plan: null }; });
@@ -260,7 +260,7 @@ describe('ready under a train policy: the screen choice', () => {
fx.write(d => { d.rules['evals.yml'] = { status: 'completed', conclusion: 'success', plan: { planned: 0 } }; });
const emptyPlan = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--screen']);
expect(line(emptyPlan.out, 'SCREEN_DISPATCH:')).toMatch(/^SCREEN_DISPATCH: evals\.yml run 102 .*not following run 101 planned no cases/);
fx.write(d => { d.rules['evals.yml'] = { status: 'completed', conclusion: 'success', plan: { planned: 3 } }; });
fx.write(d => { d.rules['evals.yml'] = { status: 'completed', conclusion: 'success', plan: { planned: 3 }, binding: { verdict: 'GREEN' } }; });
const live = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--screen']);
expect(live.status).toBe(EXIT.ok);
expect(line(live.out, 'SCREEN_DISPATCH:')).toMatch(/^SCREEN_DISPATCH: evals\.yml run 103 .*not following run 102 planned no cases \(an empty plan manifest; concluded success\)/);
@@ -269,10 +269,11 @@ describe('ready under a train policy: the screen choice', () => {
expect(line(followed.out, 'SCREEN_DISPATCH:')).toBe('SCREEN_DISPATCH: following run 103 (same merge SHA, base and policy; no duplicate)');
expect(dispatches(fx, 'evals.yml').length).toBe(4);
const liveBuild = queue(fx, ['train', 'build', '--prs', `1@${head}`, '--dry-run']);
expect([liveBuild.status, line(liveBuild.out, 'ORDER:'), line(liveBuild.out, 'SCREEN:')]).toEqual([EXIT.ok, 'ORDER: 1:#1(screened)', '']);
fx.write(d => { d.rules['evals.yml'] = { status: 'in_progress', conclusion: null }; });
expect([liveBuild.status, line(liveBuild.out, 'ORDER:'), line(liveBuild.out, 'SCREEN:')]).toEqual([EXIT.ok, 'ORDER: 1:#1(screened)', 'SCREEN: #1 run 103 GREEN']);
fx.write(d => { for (const run of d.runs) if ((run as any).databaseId === 103) Object.assign(run, { status: 'in_progress', conclusion: null }); });
const runningBuild = queue(fx, ['train', 'build', '--prs', `1@${head}`, '--dry-run']);
expect([runningBuild.status, line(runningBuild.out, 'ORDER:'), line(runningBuild.out, 'SCREEN:')]).toEqual([EXIT.ok, 'ORDER: 1:#1(screened)', '']);
expect([runningBuild.status, line(runningBuild.out, 'ORDER:'), line(runningBuild.out, 'SCREEN:')]).toEqual([EXIT.ok, 'ORDER: 1:#1(screened)', 'SCREEN: #1 run 103 still running; counted as screened']);
fx.write(d => { for (const run of d.runs) if ((run as any).databaseId === 103) Object.assign(run, { status: 'completed', conclusion: 'success' }); });
fx.write(d => { d.rules['evals.yml'] = { status: 'completed', conclusion: 'success' }; d.artifacts['103']['paid-plan'] = { reuse: 'confirmed' }; });
const unreadable = queue(fx, ['train', 'build', '--prs', `1@${head}`, '--dry-run']);
expect([unreadable.status, line(unreadable.out, 'ORDER:'), line(unreadable.out, 'SCREEN:')]).toEqual([EXIT.ok, 'ORDER: 1:#1(screened)', "SCREEN: #1 run 103's paid-plan manifest.json is unreadable; counted as screened, unverified"]);
+84
View File
@@ -1342,3 +1342,87 @@ Write ${workDir}/gate-output.md listing what you fixed and every remaining findi
expect(out).toContain('accepted');
}, CAPTURE_MS);
});
describeIfSelected('Design motion showcase E2E', ['design-motion-showcase'], () => {
let workDir: string;
let gstackHome: string;
beforeAll(() => {
workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-e2e-motion-'));
gstackHome = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-e2e-motion-home-'));
const run = (cmd: string, args: string[]) => spawnSync(cmd, args, { cwd: workDir, stdio: 'pipe', timeout: 5000 });
run('git', ['init', '-b', 'main']);
run('git', ['config', 'user.email', 'test@test.com']);
run('git', ['config', 'user.name', 'Test']);
fs.copyFileSync(path.join(ROOT, 'test', 'fixtures', 'design-motion-finalized.html'), path.join(workDir, 'finalized.html'));
run('git', ['add', '.']);
run('git', ['commit', '-m', 'finalized page']);
const full = fs.readFileSync(path.join(ROOT, 'design-motion', 'SKILL.md'), 'utf-8');
const text = full.slice(full.indexOf('# /design-motion:'))
.replaceAll('$HOME/.claude/skills/gstack', ROOT).replaceAll('~/.claude/skills/gstack', ROOT);
fs.writeFileSync(path.join(workDir, 'design-motion.md'), text);
});
afterAll(() => {
for (const d of [workDir, gstackHome]) { try { fs.rmSync(d, { recursive: true, force: true }); } catch {} }
});
testConcurrentIfSelected('design-motion-showcase', async () => {
const result = await runSkillTest({
prompt: `Read design-motion.md: the /design-motion skill. Run it on finalized.html in this directory, the user's /design-html page, as the source.
Settled answers, so do not use AskUserQuestion: showcase context; separate cards (the two .card sections); stories: the bars breathe and settle on the design heights, a soft highlight steps down the invoice rows; no camera; a 6 second cycle instead of 12 (scale the phases to it); name "ledgerline"; the frame is 640x400.
Read each section the skill points to before its step. Build page.html with the capture contract, verify the still, capture with a mask on the cards, run the motion checks, fix and recheck until they pass, then export. Do not open a browser window and do not install anything.
Finally write ${workDir}/motion-report.md with every MOTION_CHECK line, the MOTION_RESULT line, and each EXPORT_OK line exactly as printed.`,
workingDirectory: workDir,
maxTurns: 60,
timeout: CAPTURE_LONG_MS,
testName: 'design-motion-showcase',
runId,
env: { GSTACK_HOME: gstackHome },
});
logCost('/design-motion showcase', result);
recordE2E(evalCollector, '/design-motion showcase', 'Design motion showcase E2E', result);
expect(result.exitReason).toBe('success');
const sectionRead = (name: string) => {
if (result.toolCalls.some(c => c.tool === 'Read' && String(c.input?.file_path ?? '').endsWith(`/design-motion/sections/${name}`))) return true;
const printed = result.toolCalls
.filter(c => c.tool === 'Bash' && String(c.input?.command ?? '').includes(`design-motion/sections/${name}`))
.map(c => String(c.output ?? '')).join('\n');
const lines = fs.readFileSync(path.join(ROOT, 'design-motion', 'sections', name), 'utf-8').split('\n').map(l => l.trimEnd()).filter(Boolean);
return lines.every(line => printed.includes(line));
};
expect(sectionRead('fidelity.md')).toBe(true);
expect(sectionRead('motion.md')).toBe(true);
const commands = result.toolCalls.filter(c => c.tool === 'Bash').map(c => String(c.input?.command ?? '')).join('\n');
const after = (re: RegExp, from: number) => { const i = commands.slice(from).search(re); return i < 0 ? -1 : from + i; };
const capture = after(/gstack-motion-capture\.ts\b[^\n]*--out\b/, 0);
expect(capture).toBeGreaterThanOrEqual(0);
const check = after(/gstack-motion-encode['"]?\s+check\b/, capture);
expect(check).toBeGreaterThan(capture);
expect(after(/gstack-motion-encode['"]?\s+export\b/, check)).toBeGreaterThan(check);
const designs = path.join(gstackHome, 'projects');
const motionDirs = fs.existsSync(designs)
? fs.readdirSync(designs).flatMap(slug => {
const d = path.join(designs, slug, 'designs');
return fs.existsSync(d) ? fs.readdirSync(d).filter(n => n.includes('-motion-')).map(n => path.join(d, n)) : [];
})
: [];
expect(motionDirs.length).toBe(1);
const page = fs.readFileSync(path.join(motionDirs[0]!, 'page.html'), 'utf-8');
expect(page).toContain('__start');
expect(page).toContain('__tick');
const manifest = JSON.parse(fs.readFileSync(path.join(motionDirs[0]!, 'cap', 'capture.json'), 'utf-8'));
expect(manifest.windowFrames).toBe(360);
expect(manifest.mask).toBe('mask.png');
const report = fs.readFileSync(path.join(workDir, 'motion-report.md'), 'utf-8');
for (const id of ['seam', 'empty-beat', 'rest', 'overflow']) expect(report).toContain(`MOTION_CHECK: ${id} pass`);
expect(report).toContain('MOTION_RESULT: clean');
for (const file of ['ledgerline-x.mp4', 'ledgerline-loop.gif', 'ledgerline-800x500.gif']) {
expect(report).toContain(file);
expect(fs.statSync(path.join(motionDirs[0]!, 'cap', file)).size).toBeGreaterThan(10_000);
}
}, CAPTURE_LONG_MS);
});
+3 -1
View File
@@ -45,7 +45,9 @@ describe('stamp templates (pure)', () => {
expect(rewriteStamps('"version": "1.2.3.0",', '1.2.3.0', '1.2.4.0', ['"version": "{version}"']).count).toBe(1);
expect(rewriteStamps('<!-- x-plugin-tree-stamp: 1.2.3.0 -->', '1.2.3.0', '1.2.4.0', ['<!-- x-plugin-tree-stamp: {version} -->']).text).toBe('<!-- x-plugin-tree-stamp: 1.2.4.0 -->');
expect(rewriteStamps("since: '1.2.3.01'", '1.2.3.0', '1.2.4.0', ["since: '{version}'"]).count).toBe(0);
expect(rewriteStamps("since: '1.2.3.0'", '1.2.3.0', '1.2.4.0').count).toBe(0);
// A quoted since: row is a built-in form too (gbrain #7640): the template and the built-in count it once.
expect(rewriteStamps("since: '1.2.3.0'", '1.2.3.0', '1.2.4.0')).toEqual({ text: "since: '1.2.4.0'", count: 1 });
expect(rewriteStamps("since: '1.2.3.0'", '1.2.3.0', '1.2.4.0', ["since: '{version}'"]).count).toBe(1);
});
test('policy: stamp_patterns are bounded literal templates with exactly one {version}; stamp_files are contained globs', () => {