The windows-test-parity ratchet greps test source for fs.rmSync-without-
maxRetries (and six other Windows-portability anti-patterns), failing when an
integer offender COUNT exceeds a frozen baseline (rmSync: 95). A count ratchet
is a Goodhart metric: fixing one offender and adding another keeps the count
constant, so a new defect slips through green. Replace it — and every other
count ratchet in the repo — with a layered, masking-proof design.
Behavioral seam test
- tests/helpers-cleanup.test.cjs proves helpers.cleanup() carries the Windows
EBUSY retry budget. cleanup() delegates retries to Node's fs.rmSync via
maxRetries (it owns no loop), so the test asserts the option contract
(recursive/force/maxRetries>0/retryDelay>0) + real-FS removal + the cwd-guard,
rather than a loop that does not exist. The EBUSY risk is now tested ONCE at
the helper, not approximated textually at every call site.
Write-time ESLint rule (AST-accurate, replaces the grep)
- eslint-rules/no-raw-rmsync-in-tests.cjs (error in tests/**/*.test.cjs) bans
raw fs.rmSync, steering to cleanup(). Catches member, computed (fs['rmSync']),
destructured and aliased forms; escape hatch is inline
`// eslint-disable-next-line local/no-raw-rmsync-in-tests -- <reason>` only.
- Migrated 336 raw fs.rmSync teardown calls across ~116 test files to cleanup().
~18 genuinely load-bearing sites (mid-test SUT/fault-injection removals,
error-swallowing or name-colliding local teardown helpers) keep the raw call
with an inline eslint-disable + reason.
Shared anti-ratchet primitive
- scripts/lib/allowlist-ratchet.cjs:
- assertWithinAllowlist: fails on NOVEL ids (new offender introduced) AND on
STALE ids (a known offender was fixed but not pruned) — identity, not count,
and a ratchet DOWN toward zero.
- assertTightCeiling: a size/length budget whose ceiling must stay within a
grace band of the high-water mark, so budgets may only tighten, never creep.
Ratchets converted onto the primitive
- windows-test-parity-guard.test.cjs: rmSync rule deleted (now ESLint-enforced);
the remaining six patterns moved from integer baselines to named-set
allowlists with ratchet-down.
- scripts/lint-test-file-count.{cjs,allowlist.json}: per-module integer counts →
named filename sets (closes the swap-a-file-keep-the-count blind spot); a
module dropping under cap now FAILS to force pruning its allowlist entry.
- enh-2790 skill-count `<= 63` → named skill allowlist (ratchets toward ~58).
Size budgets hardened (tighten-only)
- agent-size / workflow-size / feat-3039 help-tiered: ceilings lowered to the
current high-water mark and an assertTightCeiling anti-creep check added per
tier. Fixed external-contract limits (description ≤100 chars, agent ≤100 KB)
are intentionally left as-is — they are not grandfathered creeping budgets.
No user-facing behavior change (tests + tooling only); no USER_FACING_PREFIXES
touched, so no changeset fragment is required.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
161 lines
5.9 KiB
JavaScript
161 lines
5.9 KiB
JavaScript
// allow-test-rule: source-text-is-the-product
|
||
// Workflow .md / agent .md / command .md / reference .md files — their text
|
||
// IS what the runtime loads. Testing text content tests the deployed contract.
|
||
// Per CONTRIBUTING.md exception matrix.
|
||
|
||
/**
|
||
* Agent size budget.
|
||
*
|
||
* Agent definitions in `agents/gsd-*.md` are loaded verbatim into Claude's
|
||
* context on every subagent dispatch. Unbounded growth is paid on every call
|
||
* across every workflow.
|
||
*
|
||
* Budgets are tiered to reflect the intent of each agent class:
|
||
* - XL : top-level orchestrators that own end-to-end rubrics
|
||
* - LARGE : multi-phase operators with branching workflows
|
||
* - DEFAULT : focused single-purpose agents
|
||
*
|
||
* Raising a budget is a deliberate choice — adjust the constant, write a
|
||
* rationale in the PR, and make sure the bloat is not duplicated content
|
||
* that belongs in `get-shit-done/references/`.
|
||
*
|
||
* Tighten-only invariant (issue #597): ceilings track the tier high-water mark
|
||
* within GRACE lines. Budgets may only decrease, never silently creep upward.
|
||
* The assertTightCeiling() call below enforces this automatically.
|
||
*
|
||
* See: https://github.com/open-gsd/gsd-core/issues/2361
|
||
*/
|
||
|
||
const { test, describe } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const { assertTightCeiling } = require('../scripts/lib/allowlist-ratchet.cjs');
|
||
|
||
const AGENTS_DIR = path.join(__dirname, '..', 'agents');
|
||
|
||
// Ceilings tightened to actualMax + GRACE per the ratchet-down rule (#597).
|
||
// XL ceiling lowered from 1600 → 1512 (actualMax=1452, gsd-debugger).
|
||
const XL_BUDGET = 1512;
|
||
// LARGE ceiling kept at 1000 (actualMax=978, slack=22 ≤ GRACE=60).
|
||
const LARGE_BUDGET = 1000;
|
||
// DEFAULT ceiling kept at 500 (actualMax=495, slack=5 ≤ GRACE=60).
|
||
const DEFAULT_BUDGET = 500;
|
||
|
||
// Grace band: maximum allowed slack (ceiling − actualMax) before a ceiling is
|
||
// considered too loose. 60 lines gives one reasonable screen of breathing room
|
||
// without permitting gross inflation.
|
||
const GRACE = 60;
|
||
|
||
const XL_AGENTS = new Set([
|
||
'gsd-debugger',
|
||
'gsd-planner',
|
||
]);
|
||
|
||
const LARGE_AGENTS = new Set([
|
||
'gsd-phase-researcher',
|
||
'gsd-verifier',
|
||
'gsd-doc-writer',
|
||
'gsd-plan-checker',
|
||
'gsd-executor',
|
||
'gsd-code-fixer',
|
||
'gsd-codebase-mapper',
|
||
'gsd-project-researcher',
|
||
'gsd-roadmapper',
|
||
]);
|
||
|
||
const ALL_AGENTS = fs.readdirSync(AGENTS_DIR)
|
||
.filter(f => f.startsWith('gsd-') && f.endsWith('.md'))
|
||
.map(f => f.replace('.md', ''));
|
||
|
||
function budgetFor(agent) {
|
||
if (XL_AGENTS.has(agent)) return { tier: 'XL', limit: XL_BUDGET };
|
||
if (LARGE_AGENTS.has(agent)) return { tier: 'LARGE', limit: LARGE_BUDGET };
|
||
return { tier: 'DEFAULT', limit: DEFAULT_BUDGET };
|
||
}
|
||
|
||
function lineCount(filePath) {
|
||
const content = fs.readFileSync(filePath, 'utf-8');
|
||
if (content.length === 0) return 0;
|
||
const trailingNewline = content.endsWith('\n') ? 1 : 0;
|
||
return content.split('\n').length - trailingNewline;
|
||
}
|
||
|
||
describe('SIZE: agent line-count budget', () => {
|
||
for (const agent of ALL_AGENTS) {
|
||
const { tier, limit } = budgetFor(agent);
|
||
test(`${agent} (${tier}) stays under ${limit} lines`, () => {
|
||
const filePath = path.join(AGENTS_DIR, agent + '.md');
|
||
const lines = lineCount(filePath);
|
||
assert.ok(
|
||
lines <= limit,
|
||
`${agent}.md has ${lines} lines — exceeds ${tier} budget of ${limit}. ` +
|
||
`Extract shared boilerplate to get-shit-done/references/ or raise the budget ` +
|
||
`in tests/agent-size-budget.test.cjs with a rationale.`
|
||
);
|
||
});
|
||
}
|
||
});
|
||
|
||
describe('SIZE: tier anti-creep (tighten-only ceilings, issue #597)', () => {
|
||
// For each tier, compute the high-water mark across all files in that tier
|
||
// and assert the ceiling stays tight. Prevents budgets from silently drifting
|
||
// upward: ceiling − actualMax must not exceed GRACE.
|
||
test('XL tier: ceiling tracks high-water mark within GRACE', () => {
|
||
const values = ALL_AGENTS
|
||
.filter(a => XL_AGENTS.has(a))
|
||
.map(a => lineCount(path.join(AGENTS_DIR, a + '.md')));
|
||
const actualMax = Math.max(...values);
|
||
assertTightCeiling({ label: 'XL', actualMax, ceiling: XL_BUDGET, grace: GRACE, fail: assert.fail });
|
||
});
|
||
|
||
test('LARGE tier: ceiling tracks high-water mark within GRACE', () => {
|
||
const values = ALL_AGENTS
|
||
.filter(a => LARGE_AGENTS.has(a))
|
||
.map(a => lineCount(path.join(AGENTS_DIR, a + '.md')));
|
||
const actualMax = Math.max(...values);
|
||
assertTightCeiling({ label: 'LARGE', actualMax, ceiling: LARGE_BUDGET, grace: GRACE, fail: assert.fail });
|
||
});
|
||
|
||
test('DEFAULT tier: ceiling tracks high-water mark within GRACE', () => {
|
||
const values = ALL_AGENTS
|
||
.filter(a => !XL_AGENTS.has(a) && !LARGE_AGENTS.has(a))
|
||
.map(a => lineCount(path.join(AGENTS_DIR, a + '.md')));
|
||
const actualMax = Math.max(...values);
|
||
assertTightCeiling({ label: 'DEFAULT', actualMax, ceiling: DEFAULT_BUDGET, grace: GRACE, fail: assert.fail });
|
||
});
|
||
});
|
||
|
||
describe('SIZE: every agent is classified', () => {
|
||
test('every agent falls in exactly one tier', () => {
|
||
for (const agent of ALL_AGENTS) {
|
||
const inXL = XL_AGENTS.has(agent);
|
||
const inLarge = LARGE_AGENTS.has(agent);
|
||
assert.ok(
|
||
!(inXL && inLarge),
|
||
`${agent} is in both XL_AGENTS and LARGE_AGENTS — pick one`
|
||
);
|
||
}
|
||
});
|
||
|
||
test('every named XL agent exists', () => {
|
||
for (const agent of XL_AGENTS) {
|
||
const filePath = path.join(AGENTS_DIR, agent + '.md');
|
||
assert.ok(
|
||
fs.existsSync(filePath),
|
||
`XL_AGENTS references ${agent}.md which does not exist — clean up the set`
|
||
);
|
||
}
|
||
});
|
||
|
||
test('every named LARGE agent exists', () => {
|
||
for (const agent of LARGE_AGENTS) {
|
||
const filePath = path.join(AGENTS_DIR, agent + '.md');
|
||
assert.ok(
|
||
fs.existsSync(filePath),
|
||
`LARGE_AGENTS references ${agent}.md which does not exist — clean up the set`
|
||
);
|
||
}
|
||
});
|
||
});
|