Extends the #1074 scheme to tests/agent-size-budget.test.cjs, which used the same assertTightCeiling tier ratchet but was still line-based (never rebased in #717). Completes the migration — the last part of the #1074 epic. - Rebase agent sizing from lines to LF-normalized bytes (#717/#683). - Delete the 'SIZE: tier anti-creep' describe (3 assertTightCeiling tests); add a per-agent baseline (tests/agent-size-baseline.json) as the primary anti-creep, and byte hard caps (XL 56 KiB / LARGE 48 KiB / DEFAULT 24 KiB), each above its tier high-water with real headroom. No separate new-file cap: a net-new agent is DEFAULT-tier, already bounded by the DEFAULT cap. - Keep the agent-classification tests verbatim. - scripts/workflow-size.cjs: add generic measureMdFiles(dir, predicate) (workflows + agents share one byte-measurement path); measureWorkflows now delegates to it. - scripts/update-size-baseline.cjs: one 'npm run size:baseline' now regenerates BOTH the workflow and agent baselines (gsd-* filter for agents). Rebased onto next after PR 2/3 (#1096) merged: replicate the scripts/lib/workflow-size.cjs -> scripts/workflow-size.cjs move (PR 1/3) across the generator and the agent test's require; regenerate the agent baseline against current agents (a uniform +170 B preamble drift on all 33 since authoring). Addresses the #1097 review (trek-e): - BLOCKER (acceptance criterion 5): document the agent contract in CONTEXT.md. Adds RULESET.AGENT_SIZE_BUDGET (caps 57344/49152/24576, per-agent baseline, dual size:baseline, shared measureMdFiles seam) and disambiguates it from the separate DEFECT.AGENT-FILE-SIZE-CAP-BREACH 45K-CHAR guard (two units, two purposes). - Docs: now that #1096's docs/TESTING-SUITES.md "Workflow size budget" section is in next, fold in the agent coverage here (renamed to "Workflow & agent size budget"): agent caps + per-agent baseline + the how-to + reference rows, and the disambiguation from the 45K-char guard. - Minor (negative proof): add a boundary-fixture test exercising the hard-cap comparison at cap-1/cap/cap+1 through the real lfByteCount path, so a future threshold/operator edit can't silently neuter a cap. - Nit: align the tier test name wording ("stays within") with the <= operator. Negative proof on a real tracked agent (gsd-planner): baseline catches +10 B; XL hard cap catches 57,516 > 57,344 with the baseline current. Closes #1095 (PR 3/3 child); landing this completes the #1074 epic.
187 lines
7.5 KiB
JavaScript
187 lines
7.5 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 (measured in BYTES — see #717).
|
|
*
|
|
* Agent definitions in `agents/gsd-*.md` are loaded verbatim into the agent's
|
|
* context on every subagent dispatch. Unbounded growth is paid on every call
|
|
* across every workflow.
|
|
*
|
|
* ## Enforcement model (issue #1074)
|
|
*
|
|
* Mirrors tests/workflow-size-budget.test.cjs — two complementary guards, no
|
|
* tier-max ceiling:
|
|
*
|
|
* 1. Per-agent baseline (the anti-creep): every agent is pinned to its exact
|
|
* byte size in `tests/agent-size-baseline.json`. Any growth fails with the
|
|
* file and delta; `npm run size:baseline` records a deliberate change as a
|
|
* reviewable one-line diff. This replaced the tier-max tighten-only ratchet
|
|
* (which only bound the single largest agent per tier).
|
|
*
|
|
* 2. Tier hard caps (the outer bound): XL/LARGE/DEFAULT absolute red lines
|
|
* with real headroom, never raised in normal work. Crossing one means
|
|
* extracting shared boilerplate to `gsd-core/references/`, not a +N bump.
|
|
* A net-new agent is DEFAULT-tier, so the DEFAULT cap already bounds it —
|
|
* no separate new-file cap is needed (DEFAULT is already small).
|
|
*
|
|
* Tiers:
|
|
* - XL : top-level orchestrators that own end-to-end rubrics
|
|
* - LARGE : multi-phase operators with branching workflows
|
|
* - DEFAULT : focused single-purpose agents
|
|
*
|
|
* See:
|
|
* - https://github.com/open-gsd/gsd-core/issues/1074 (per-file baseline + hard caps)
|
|
* - https://github.com/open-gsd/gsd-core/issues/717 (bytes, not lines)
|
|
* - https://github.com/open-gsd/gsd-core/issues/683 (LF-normalized byte count)
|
|
*/
|
|
|
|
const { test, describe } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('fs');
|
|
const os = require('node:os');
|
|
const path = require('path');
|
|
const { assertFileBaseline } = require('../scripts/lib/allowlist-ratchet.cjs');
|
|
const { lfByteCount, measureMdFiles } = require('../scripts/workflow-size.cjs');
|
|
const { cleanup } = require('./helpers.cjs');
|
|
|
|
const AGENTS_DIR = path.join(__dirname, '..', 'agents');
|
|
const BASELINE_PATH = path.join(__dirname, 'agent-size-baseline.json');
|
|
const isGsdAgent = (f) => f.startsWith('gsd-');
|
|
|
|
// Tier HARD CAPS (#1074, bytes) — absolute red lines, not high-water-hugging
|
|
// ceilings. Day-to-day creep is caught per-agent by the baseline guard below;
|
|
// these sit above each tier's current high-water with real headroom:
|
|
// XL 56 KiB — high-water gsd-debugger 51,043 → ~6.3 KB headroom
|
|
// LARGE 48 KiB — high-water gsd-executor 42,342 → ~6.8 KB headroom
|
|
// DEFAULT 24 KiB — high-water gsd-ui-researcher 19,095 → ~5.5 KB headroom
|
|
const XL_CAP = 57344; // 56 KiB
|
|
const LARGE_CAP = 49152; // 48 KiB
|
|
const DEFAULT_CAP = 24576; // 24 KiB
|
|
|
|
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 => isGsdAgent(f) && f.endsWith('.md'))
|
|
.map(f => f.replace('.md', ''));
|
|
|
|
function capFor(agent) {
|
|
if (XL_AGENTS.has(agent)) return { tier: 'XL', cap: XL_CAP };
|
|
if (LARGE_AGENTS.has(agent)) return { tier: 'LARGE', cap: LARGE_CAP };
|
|
return { tier: 'DEFAULT', cap: DEFAULT_CAP };
|
|
}
|
|
|
|
describe('SIZE: agent tier hard caps (issue #1074)', () => {
|
|
// Absolute outer bound per tier. A cap is NOT raised when an agent approaches
|
|
// it — crossing it means extract shared boilerplate to gsd-core/references/.
|
|
for (const agent of ALL_AGENTS) {
|
|
const { tier, cap } = capFor(agent);
|
|
test(`${agent} (${tier}) stays within the ${tier} hard cap (${cap} bytes)`, () => {
|
|
const bytes = lfByteCount(path.join(AGENTS_DIR, agent + '.md'));
|
|
assert.ok(
|
|
bytes <= cap,
|
|
`${agent}.md is ${bytes} bytes — exceeds the ${tier} hard cap of ${cap}. ` +
|
|
`This cap is a red line, NOT a budget to raise: extract shared boilerplate ` +
|
|
`to gsd-core/references/ and load it lazily.`
|
|
);
|
|
});
|
|
}
|
|
});
|
|
|
|
describe('SIZE: agent hard-cap boundary fixtures (#1074 — negative proof)', () => {
|
|
// The per-tier loop above only iterates the real, fully-compliant agent
|
|
// corpus, so its `bytes <= cap` failure branch never executes. Exercise that
|
|
// exact comparison on synthetic files measured at cap-1 / cap / cap+1 (the
|
|
// limit boundary — RULESET.TESTS.boundary-coverage.fixtures) through the SAME
|
|
// lfByteCount path the guard uses, so a future threshold or operator edit
|
|
// cannot silently neuter a cap (RULESET.TESTS.regression-must-fail-first).
|
|
test('cap comparison fires at the limit boundary for every tier', () => {
|
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-size-'));
|
|
try {
|
|
// ASCII 'a' is 1 byte/char and has no CRLF, so lfByteCount == length.
|
|
const measureAt = (n) => {
|
|
const p = path.join(tmp, `fixture-${n}.md`);
|
|
fs.writeFileSync(p, 'a'.repeat(n));
|
|
return lfByteCount(p);
|
|
};
|
|
for (const cap of [DEFAULT_CAP, LARGE_CAP, XL_CAP]) {
|
|
assert.equal(measureAt(cap - 1) <= cap, true, `${cap - 1} must be within cap ${cap}`);
|
|
assert.equal(measureAt(cap) <= cap, true, `${cap} (exactly at cap) must be within cap ${cap}`);
|
|
assert.equal(measureAt(cap + 1) <= cap, false, `${cap + 1} must exceed cap ${cap}`);
|
|
}
|
|
} finally {
|
|
cleanup(tmp);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('SIZE: per-agent baseline (issue #1074)', () => {
|
|
// Per-agent exact-size ratchet — the primary anti-creep guard. Guards EVERY
|
|
// agent by name against tests/agent-size-baseline.json. Growth fails with the
|
|
// file and delta; shrinkage fails as a stale snapshot. Fix: `npm run
|
|
// size:baseline` plus a PR justification for genuine growth (or extraction).
|
|
test('every agent matches its committed baseline', () => {
|
|
const baseline = JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf-8'));
|
|
const current = measureMdFiles(AGENTS_DIR, isGsdAgent);
|
|
assertFileBaseline({
|
|
label: 'agent-size',
|
|
current,
|
|
baseline,
|
|
fail: assert.fail,
|
|
updateHint:
|
|
'Run `npm run size:baseline` to update tests/agent-size-baseline.json, ' +
|
|
'then justify any growth in your PR (or extract shared boilerplate to gsd-core/references/).',
|
|
});
|
|
});
|
|
});
|
|
|
|
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`
|
|
);
|
|
}
|
|
});
|
|
});
|