Files
gstack/test/context-budget-ratchet.test.ts
T
Garry TanandClaude Fable 5 d80d3c3ed7 fix(test): review findings — Windows path normalization, full totals rebuild, ratchet coverage
Pre-landing review (5 specialists) found one critical: the ratchet test runs
in the curated Windows lane, where path.relative yields backslash skill names
that miss the test/ filter and mismatch every POSIX fixture key. Names are now
normalized once in buildRatchetBill (toPosixName) and the fixture filter is
tightened to test/fixtures/. All eight Bill.totals fields are rebuilt from the
filtered list (no fixture-polluted perInvocation/totalMd numbers for future
consumers). New coverage: Windows-separator normalization pins, a
captureContextBudget round-trip against tree-a (headroom math exact), a
stripFields regression pin (interactive/benefits-from absent from renders,
hooks/gbrain preserved), and the ceilings test no longer double-reports
stale-fixture entries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 02:38:47 +00:00

114 lines
5.2 KiB
TypeScript

/**
* Context-budget ratchet — CI-enforced ceilings on the two token ledgers
* nothing else guards (plan OV8):
*
* ALWAYS-ON — full frontmatter bytes every session's skill scanner loads
* (catalog-budget.test.ts caps name+description only; this
* catches growth in the OTHER frontmatter keys).
* EAGER — per-invocation SKILL.md + forced-read references, per skill
* (skill-size-budget floors catch shrink; parity-suite catches
* growth RATIOS vs an old baseline; this pins absolute token
* ceilings that ratchet DOWN as reduction phases land).
*
* Fails when a skill's eager tokens exceed its fixture ceiling, when the
* always-on aggregate exceeds its ceiling, or when a skill exists with no
* ceiling at all (new skills must be consciously budgeted).
*
* RATCHET PROTOCOL (on failure):
* 1. If the growth is a real feature: re-run
* bun test/helpers/capture-context-budget.ts
* and commit the refreshed fixture in the SAME commit as the feature,
* so the growth is a visible, conscious decision in the diff.
* 2. If the growth is accidental (resolver bloat, duplicated block,
* copy-paste): fix the bloat instead.
* 3. After a token-reduction phase lands: re-run the capture so ceilings
* ratchet down and the win is locked against regression.
*/
import { describe, test, expect } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
import { checkBudget } from '../lib/context-bill';
import {
buildRatchetBill,
captureContextBudget,
isFixtureSkill,
toPosixName,
BUDGET_FIXTURE_PATH,
ALWAYS_ON_HEADROOM,
EAGER_HEADROOM,
type ContextBudget,
} from './helpers/capture-context-budget';
const RATCHET_PROTOCOL =
'Ratchet protocol: legitimate feature growth -> re-run `bun test/helpers/capture-context-budget.ts` ' +
'and commit the refreshed fixture in the same commit; accidental bloat -> fix the bloat; ' +
'after a reduction lands -> re-run the capture so the ceilings ratchet down.';
const budget: ContextBudget = JSON.parse(fs.readFileSync(BUDGET_FIXTURE_PATH, 'utf-8'));
const bill = buildRatchetBill();
describe('context-budget ratchet', () => {
test('always-on + eager ledgers stay under the fixture ceilings', () => {
// actual === null means "fixture names a skill missing from the tree" —
// the dedicated stale-fixture test below owns that case with a clearer
// message; filtering here keeps one failure from producing two reports.
const violations = checkBudget(bill, {
alwaysOnTotal: budget.alwaysOnTotal,
eagerPerInvocation: budget.eagerPerInvocation,
}).filter((v) => v.actual !== null);
const detail = violations
.map((v) => ` ${v.ceiling}: ${v.actual} tok > limit ${v.limit}\n ${v.files.join('\n ')}`)
.join('\n');
expect(
violations.length,
`Context-budget ceilings exceeded:\n${detail}\n${RATCHET_PROTOCOL}`,
).toBe(0);
});
test('every skill in the tree has an eager ceiling (new skills are consciously budgeted)', () => {
const missing = bill.skills
.map((s) => s.name)
.filter((name) => !(name in budget.eagerPerInvocation));
expect(
missing,
`Skills without a context-budget ceiling: ${missing.join(', ')}.\n` +
`Add them by re-running the capture. ${RATCHET_PROTOCOL}`,
).toEqual([]);
});
test('fixture has no ceilings for skills that no longer exist', () => {
const live = new Set(bill.skills.map((s) => s.name));
const stale = Object.keys(budget.eagerPerInvocation).filter((name) => !live.has(name));
expect(
stale,
`Fixture carries ceilings for removed skills: ${stale.join(', ')}. Re-run the capture.`,
).toEqual([]);
});
// Windows lane: skill names arrive backslash-separated from path.relative;
// the normalization must make the filter and the POSIX fixture keys agree.
test('name normalization handles Windows separators', () => {
expect(toPosixName(['test', 'fixtures', 'context-bill', 'tree-a', 'alpha'].join(path.sep))).toBe(
'test/fixtures/context-bill/tree-a/alpha',
);
expect(isFixtureSkill(['test', 'fixtures', 'x'].join(path.sep))).toBe(true);
expect(isFixtureSkill('test/fixtures/context-bill/tree-a/alpha')).toBe(true);
expect(isFixtureSkill('openclaw/skills/gstack-openclaw-retro')).toBe(false);
expect(bill.skills.every((s) => !s.name.includes('\\'))).toBe(true);
});
// Round-trip: a fresh capture must pass its own ratchet, and the headroom
// math must be exactly ceil(actual x headroom) — the recovery protocol is
// "re-run the capture", so a corrupt write side poisons every future fixture.
test('captureContextBudget round-trips against its own bill', () => {
const TREE_A = path.join(import.meta.dir, 'fixtures', 'context-bill', 'tree-a');
const capture = captureContextBudget(TREE_A);
const treeBill = buildRatchetBill(TREE_A);
expect(checkBudget(treeBill, capture)).toEqual([]);
for (const s of treeBill.skills) {
expect(capture.eagerPerInvocation[s.name]).toBe(Math.ceil(s.eagerTokens * EAGER_HEADROOM));
}
expect(capture.alwaysOnTotal).toBe(Math.ceil(treeBill.totals.alwaysOnTokens * ALWAYS_ON_HEADROOM));
});
});