Files
gstack/test/size-ledger.test.ts
Garry Tanandcapy-ai[bot] 398618259d v1.91.286.0 feat(ship-queue): the ship size cap composes across a merge train; verify refuses a stale all-merged train (#3519) (#3522)
* feat(ship-queue): the ship size cap composes across a merge train; verify refuses a stale all-merged train (#3519 b, c)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes (runnable clear-time NEXT, stale exit only on a fetched main, open-slot freeze control, LF size-ledger fixture)

- verify's clear-time NEXT names gh pr view <n> --json state,headRefOid per unreadable slot instead of a pasteable #n@sha that views the current branch's PR
- QUEUE_TRAIN_STALE needs a fetched main (never under --no-fetch) and reports a freeze restored from the notes ref instead of saying the queue is not frozen
- freezeQueue docstring matches the unreadable field
- new test: a train with a slot still OPEN freezes even when another train verified main's sha
- test/size-ledger.test.ts fixture repo sets core.autocrlf false (Windows Free Tests shard 4 counted CRLF bytes)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: CHANGELOG [Unreleased] entry for the ship size ledger and QUEUE_TRAIN_STALE

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test: run scripts/size-ledger.ts (check on this tree, refusals write nothing)

The remote pregate refused 82f160c with PREGATE_NO_LANE: no test named scripts/size-ledger.ts. The new case runs the CLI itself.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test: spawn scripts/size-ledger.ts with bunIsolationArgs() so native Windows Bun starts it

The CLI test passed --config=/dev/null, which native Bun on Windows cannot open
(#3415); Windows Free Tests shard 6 failed at 4143373 with exit 1 and empty stdout.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* FILES: CHANGELOG.md VERSION agents-digest/gstack-AGENTS.md package.json
MERGE_CONDITION: sha=1b7c8b3af32a093924205f4894cba6c5d224630a
next: NEW_VERSION=1.91.286.0; commit and push only the stamp diff above (FILES), with no edit of your own: the train's verify accepts the CHANGELOG only as the base's plus this PR's entry re-headed; merge with the expected head 1b7c8b3af3 and recompute on a stale queue.
GSTACK_RESULT: skill=restamp status=complete run=/workspace/gstack/.gstack/tmp/restamp-journal.json

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-28 00:27:22 +00:00

227 lines
13 KiB
TypeScript

/**
* Size ledger (GSTA-132): ship's union cap composes across a merge train.
*
* The train-26 shape: two branches off one main both grow a carved skill. Under
* one-tree ratio pins, branch A re-pins on its own tree and branch B fits under
* main's rounding slack without a pin; each passes alone and the train's own
* merge of both (buildChain, the code train build runs) fails the parity
* check. Under the ledger each branch records its own delta and the same train
* passes, while a branch that grows beyond what it recorded still fails.
*/
import { afterAll, describe, expect, test } from 'bun:test';
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { bunIsolationArgs } from '../lib/bun-isolation';
import { buildChain } from '../lib/ship-queue-train';
import { DEFAULT_POLICY } from '../lib/ship-policy';
import { CARVE_GUARDS } from './helpers/carve-guards';
import { PARITY_INVARIANTS, checkSkillParity, readSkillForParity } from './helpers/parity-harness';
import type { SkillBaselineEntry } from './helpers/capture-parity-baseline';
import { entryFile, foldLedger, ledgerExpected, ledgerFailure, readSizeLedger, recordEntry } from './helpers/size-ledger';
const BASELINE = { skillMdBytes: 187_706 } as SkillBaselineEntry;
const MAIN_UNION = 328_937;
const LEDGER = 'test/size-ledger/demo';
const roots: string[] = [];
afterAll(() => { for (const r of roots) fs.rmSync(r, { recursive: true, force: true }); });
function git(cwd: string, ...args: string[]): string {
const r = spawnSync('git', args, { cwd, encoding: 'utf-8', timeout: 30_000, env: { ...process.env, GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' } });
if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`);
return r.stdout.trim();
}
const write = (root: string, rel: string, text: string) => { fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); fs.writeFileSync(path.join(root, rel), text); };
const append = (root: string, rel: string, bytes: number) => fs.appendFileSync(path.join(root, rel), 'y'.repeat(bytes - 1) + '\n');
const union = (root: string) => readSkillForParity(root, 'demo', true).unionBytes;
const pinFor = (root: string) => Math.ceil((union(root) / BASELINE.skillMdBytes) * 1e4) / 1e4;
const ratioCheck = (root: string) => checkSkillParity({ skill: 'demo', sectioned: true, maxSizeRatio: JSON.parse(fs.readFileSync(path.join(root, 'cap.json'), 'utf-8')).ratio }, BASELINE, BASELINE, root);
const ledgerCheck = (root: string) => checkSkillParity({ skill: 'demo', sectioned: true, sizeLedger: LEDGER }, BASELINE, BASELINE, root);
const ledger = (root: string) => { const l = readSizeLedger(root, LEDGER); if ('error' in l) throw new Error(l.error); return l; };
/** main: a carved skill at 328,937 union bytes, a ratio pin one in the last place above, and an empty ledger. */
function mainRepo(): { root: string; main: string } {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'size-ledger-'));
roots.push(root);
git(root, 'init', '-q', '-b', 'main');
git(root, 'config', 'core.autocrlf', 'false');
write(root, 'demo/sections/a.md', '# A\n');
write(root, 'demo/sections/b.md', '# B\n');
write(root, 'demo/SKILL.md', '## Preamble\n' + 'x'.repeat(MAIN_UNION - 8 - 13) + '\n');
expect(union(root)).toBe(MAIN_UNION);
write(root, 'cap.json', JSON.stringify({ ratio: pinFor(root) }) + '\n');
write(root, `${LEDGER}/base.json`, JSON.stringify({ union_bytes: MAIN_UNION, measured_at: 'fixture main' }) + '\n');
git(root, 'add', '-A');
git(root, 'commit', '-q', '-m', 'main');
return { root, main: git(root, 'rev-parse', 'HEAD') };
}
/** A branch off main that grows section `file` by `bytes`, then lets `mark` record the change its way. */
function branch(root: string, main: string, name: string, file: string, bytes: number, mark: (root: string) => void): string {
git(root, 'checkout', '-q', '-b', name, main);
append(root, `demo/sections/${file}`, bytes);
mark(root);
git(root, 'add', '-A');
git(root, 'commit', '-q', '-m', name);
return git(root, 'rev-parse', 'HEAD');
}
function train(root: string, main: string, heads: string[]): string {
const chain = buildChain(root, main, heads.map((head, i) => ({ pr: String(i + 1), head, ready_at: '', screen: 'none' as never })), DEFAULT_POLICY);
if (!('prefixes' in chain)) throw new Error(`train stopped at #${chain.stoppedAt}: ${chain.conflicts.join(', ')}`);
const tip = chain.prefixes.at(-1)!.commit;
git(root, 'checkout', '-q', '--detach', tip);
return tip;
}
function atHead(root: string, head: string) { git(root, 'checkout', '-q', '--detach', head); }
describe('train-26 shape under one-tree ratio pins (before)', () => {
test('each branch passes alone and the train merge of both fails the ratio cap', () => {
const { root, main } = mainRepo();
const a = branch(root, main, 'a', 'a.md', 55, r => write(r, 'cap.json', JSON.stringify({ ratio: pinFor(r) }) + '\n'));
const b = branch(root, main, 'b', 'b.md', 12, () => {});
atHead(root, a); expect(ratioCheck(root).passed).toBe(true);
atHead(root, b); expect(ratioCheck(root).passed).toBe(true);
train(root, main, [a, b]);
const res = ratioCheck(root);
expect(res.passed).toBe(false);
expect(res.failures.join('\n')).toMatch(/size ratio 1\.753 > maxSizeRatio 1\.7527/);
});
});
describe('the same train under the size ledger (after)', () => {
const recorded = (name: string, why: string) => (r: string) => { recordEntry(r, ledger(r), union(r), name, why); };
test('each branch records its own delta; both orders of the train pass', () => {
const { root, main } = mainRepo();
const a = branch(root, main, 'a', 'a.md', 55, recorded('a', 'grew a'));
const b = branch(root, main, 'b', 'b.md', 12, recorded('b', 'grew b'));
for (const head of [a, b]) { atHead(root, head); expect(ledgerCheck(root).failures).toEqual([]); }
for (const order of [[a, b], [b, a]]) {
train(root, main, order);
expect(ledgerCheck(root).failures).toEqual([]);
expect(ledger(root).entries.map(e => [e.file, e.bytes])).toEqual([['a.json', 55], ['b.json', 12]]);
}
});
test('a branch that grows without recording fails on its own tree and in the train', () => {
const { root, main } = mainRepo();
const a = branch(root, main, 'a', 'a.md', 55, recorded('a', 'grew a'));
const b = branch(root, main, 'b', 'b.md', 12, () => {});
atHead(root, b);
expect(ledgerCheck(root).failures.join('\n')).toContain('this tree is +12 bytes off: bun --no-env-file --config=/dev/null scripts/size-ledger.ts record demo');
train(root, main, [a, b]);
expect(ledgerCheck(root).failures.join('\n')).toContain('this tree is +12 bytes off');
});
test('a branch that records less than it grew fails the train by the difference', () => {
const { root, main } = mainRepo();
const a = branch(root, main, 'a', 'a.md', 55, recorded('a', 'grew a'));
const b = branch(root, main, 'b', 'b.md', 12, r => write(r, `${LEDGER}/b.json`, JSON.stringify({ bytes: 11, why: 'under-declared' }) + '\n'));
train(root, main, [a, b]);
expect(ledgerCheck(root).failures.join('\n')).toContain('this tree is +1 bytes off');
});
test('an undeclared shrink fails: it would leave headroom a later branch spends silently', () => {
const { root, main } = mainRepo();
atHead(root, main);
write(root, 'demo/sections/a.md', '#\n');
expect(ledgerCheck(root).failures.join('\n')).toContain('this tree is -2 bytes off');
recordEntry(root, ledger(root), union(root), 'trim', 'trimmed a');
expect(ledgerCheck(root).failures).toEqual([]);
});
test('two branches making the identical edit fail closed (git keeps one copy, both entries count)', () => {
const { root, main } = mainRepo();
const a = branch(root, main, 'a', 'a.md', 20, recorded('a', 'same line'));
const b = branch(root, main, 'b', 'a.md', 20, recorded('b', 'same line'));
train(root, main, [a, b]);
expect(ledgerCheck(root).failures.join('\n')).toContain('this tree is -20 bytes off');
});
});
describe('ledger records', () => {
test('record is idempotent, deletes a zero entry, and fold keeps the total', () => {
const { root, main } = mainRepo();
atHead(root, main);
append(root, 'demo/sections/a.md', 30);
expect(recordEntry(root, ledger(root), union(root), 'feat/x', 'grew')).toEqual({ file: 'feat-x.json', bytes: 30 });
expect(recordEntry(root, ledger(root), union(root), 'feat/x', 'grew')).toEqual({ file: 'feat-x.json', bytes: 30 });
write(root, `${LEDGER}/other.json`, JSON.stringify({ bytes: 5, why: 'other branch' }) + '\n');
append(root, 'demo/sections/b.md', 5);
expect(ledgerCheck(root).failures).toEqual([]);
const total = foldLedger(root, ledger(root), 'fixture fold');
expect(total).toBe(MAIN_UNION + 35);
expect(ledger(root)).toMatchObject({ base: { union_bytes: MAIN_UNION + 35 }, entries: [] });
expect(ledgerCheck(root).failures).toEqual([]);
write(root, 'demo/sections/a.md', '# A\n');
expect(recordEntry(root, ledger(root), union(root), 'feat/x', 'undo')).toEqual({ file: 'feat-x.json', bytes: -30 });
expect(recordEntry(root, ledger(root), union(root) + 30, 'feat/x', 'undo')).toEqual({ file: 'feat-x.json', bytes: 0 });
expect(fs.existsSync(path.join(root, LEDGER, 'feat-x.json'))).toBe(false);
});
test('malformed ledgers fail instead of skipping', () => {
const { root, main } = mainRepo();
atHead(root, main);
const bad = (rel: string, text: string, msg: RegExp) => {
write(root, rel, text);
expect(ledgerFailure('demo', union(root), readSizeLedger(root, LEDGER))).toMatch(msg);
fs.rmSync(path.join(root, rel));
};
bad(`${LEDGER}/x.json`, '{"bytes":1.5,"why":"a"}', /non-zero integer bytes and a why/);
bad(`${LEDGER}/x.json`, '{"bytes":3,"why":" "}', /non-zero integer bytes and a why/);
bad(`${LEDGER}/x.json`, '{"bytes":0,"why":"a"}', /non-zero integer bytes and a why/);
bad(`${LEDGER}/x.json`, 'not json', /unreadable/);
bad(`${LEDGER}/Upper.json`, '{"bytes":1,"why":"a"}', /entry names/);
fs.rmSync(path.join(root, LEDGER, 'base.json'));
expect(ledgerFailure('demo', union(root), readSizeLedger(root, LEDGER))).toMatch(/base\.json unreadable/);
});
test('entry names come from the branch', () => {
expect(entryFile('GSTA-132/Size Ledger')).toBe('gsta-132-size-ledger.json');
expect(() => entryFile('///')).toThrow(/no usable ledger entry name/);
expect(() => entryFile('base')).toThrow(/no usable ledger entry name/);
});
});
describe('ship is on the ledger', () => {
const repo = path.resolve(import.meta.dir, '..');
test('a guard carries sizeLedger or maxSizeRatio, never both', () => {
expect(Object.values(CARVE_GUARDS).filter(g => g.sizeLedger && g.maxSizeRatio !== undefined).map(g => g.skill)).toEqual([]);
expect(CARVE_GUARDS.ship!.sizeLedger).toBe('test/size-ledger/ship');
});
test('the parity invariant takes the ledger, not the 1.05 default', () => {
const inv = PARITY_INVARIANTS.find(i => i.skill === 'ship')!;
expect(inv.sizeLedger).toBe('test/size-ledger/ship');
expect(inv.maxSizeRatio).toBeUndefined();
expect(PARITY_INVARIANTS.find(i => i.skill === 'plan-eng-review')!.maxSizeRatio).toBe(CARVE_GUARDS['plan-eng-review']!.maxSizeRatio!);
});
test('the ship ledger accounts for this tree', () => {
const l = readSizeLedger(repo, 'test/size-ledger/ship');
expect(ledgerFailure('ship', readSkillForParity(repo, 'ship', true).unionBytes, l)).toBeNull();
expect('error' in l ? 0 : ledgerExpected(l)).toBeGreaterThan(0);
});
// Value: protects=the scripts/size-ledger.ts CLI contract lanes run (check exits 0 with SIZE_LEDGER: ok on a tree the ledger accounts for; record refuses on main or without --why, fold without --measured-at, a skill with no ledger; none of them writes); fails_when=a refusal is dropped, an exit code changes or a refused call writes an entry; why_new=the tests above call the helpers, never the script; seam=none; no_claim=the exit-1 mismatch path (needs a ship tree off the ledger)
test('scripts/size-ledger.ts checks this tree and refuses unsafe calls without writing', () => {
const run = (...args: string[]) => spawnSync(process.execPath, [...bunIsolationArgs(), path.join(repo, 'scripts/size-ledger.ts'), ...args], { cwd: repo, encoding: 'utf-8', timeout: 60_000 });
const dir = path.join(repo, 'test/size-ledger/ship');
const before = fs.readdirSync(dir).sort();
const check = run('check', 'ship');
expect([check.status, check.stdout.trim().split('\n').at(-1)]).toEqual([0, 'SIZE_LEDGER: ok']);
const refusals: Array<[string[], string]> = [
[['record', 'ship', '--why', 'probe', '--branch', 'main'], 'refusing to record on main'],
[['record', 'ship'], 'record needs --why'],
[['fold', 'ship'], 'fold needs --measured-at'],
[['check', 'plan-eng-review'], 'plan-eng-review has no sizeLedger'],
];
for (const [args, message] of refusals) {
const r = run(...args);
expect([r.status, r.stderr.includes(message)]).toEqual([2, true]);
}
expect(fs.readdirSync(dir).sort()).toEqual(before);
});
});