Files
gstack/test/skill-size-budget.test.ts
T
Garry Tan e722c5bf89 v1.57.0.0 feat: carve-guard system + carve cso/document-release/design-consultation (#1907)
* test: canonical CARVE_GUARDS registry; derive parity + size-budget from it

Single source of truth for the carved-skill set + per-skill invariants
(EQ1). parity-harness.ts sectioned entries and skill-size-budget.ts
SECTIONS_EXTRACTED now derive from it instead of hand-maintained lists.
Closes a pre-existing drift: plan-devex-review was in SECTIONS_EXTRACTED
but had no sectioned parity invariant; now generated. carve-guards.ts is
a pure leaf data module (import type only) to avoid an import cycle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: shared carve-guard check fns with injectable root

discoverCarvedSkills/checkOrdering/checkCompleteness take a root param so
the negative tests can point the real guards at a fixture dir.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: E2 data-driven carve static ordering guard (gate)

Per-PR backstop for every carved skill, one test() per skill, driven by
CARVE_GUARDS staticInvariants. Generalizes + retires the ceo-specific
ordering test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: E1 carve-guard completeness meta-guard (gate)

Asserts filesystem carved set == CARVE_GUARDS set both directions, so a
future carve without a registry entry fails CI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: ET1 guard-of-guards negative tests (gate)

Temp fixture broken 3 ways proves E1/E2 actually throw, via the injectable
root. Kills the silent-pass-guard failure class.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: T2 data-driven behavioral section-loading guard (periodic)

One file iterating CARVE_GUARDS, one test() per skill with GSTACK_CARVE_SKILL
cost-scoping (D-CODEX A). external carves (ship, plan-ceo) keep bespoke
tests; testNames aligned to their touchfile keys. Registered in touchfiles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: defer E3 real-session carve canary to TODOS

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: carve document-release into skeleton + on-demand section

Steps 2-9 (per-file audit, auto-updates, risky-change asks, CHANGELOG
voice polish, cross-doc consistency, TODOS cleanup, VERSION bump, commit +
PR body) move to sections/release-body.md, read on demand after the Step
1.5 coverage map. Skeleton 59,256 -> 45,797 B (-23%); union preserved.
Adds the CARVE_GUARDS entry (auto-extends parity + size-budget via EQ1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: carve design-consultation into skeleton + on-demand section

Phases 3-6 (complete proposal, drill-downs, design preview, writing
DESIGN.md) move to sections/proposal-and-preview.md, read on demand after
product context + research. Skeleton 80,719 -> 59,229 B (-27%); union
preserved. Adds the CARVE_GUARDS entry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: carve cso into skeleton + on-demand section (security-safe)

Scope-dependent audit Phases 2-11 move to sections/audit-phases.md. Mode
dispatch (## Arguments, ## Mode Resolution), always-run Phases 0/1, and the
Phase 12 false-positive-filtering exceptions stay ALWAYS-LOADED in the
skeleton. Skeleton 79,383 -> 65,117 B (-18%); union preserved.

Adds a cso CARVE_GUARDS entry with an earliest-use invariant (mustPrecedeStop):
mode dispatch must appear before any STOP-Read, so a directive that decides
which sections to read can't be stranded behind the STOP that reads them
(codex outside-voice #6). carve-guard-checks gains the mustPrecedeStop check.
parity moves cso monolith -> generated carved entry. cso-preserved.test.ts
strengthened: phrases checked against the union, plus an always-loaded
contract on the skeleton (dispatch + FP-filtering, codex #5).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: make redaction/taxonomy tests union-aware for cso + document-release carves

The cso carve moved Secrets Archaeology (prefixes, lib/redact-patterns.ts
pointer, git-history scan) into sections/audit-phases.md, and the
document-release carve moved the Step 9 PR-body redaction scan into
sections/release-body.md. Three content-presence tests asserted that content
in the skeleton SKILL.md/.md.tmpl; they now read the skeleton+sections union
(same fix as cso-preserved + parity).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: bump version and changelog (v1.57.0.0)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: address pre-landing review (codex) on the carve

- cso section: add a scope-gate header so '--owasp' (and other scoped modes)
  run only their selected phases, not every phase bundled in the section
  ('execute in full' no longer overrides Mode Resolution).
- carve-guard-checks: gateAfterStop now compares against the LAST STOP, not the
  first, so a gate stranded between two STOPs in a multi-STOP skeleton fails.
- TODOS: behavioral section-loading hermeticity (verifier matches global-install
  path, not the fixture) — pre-existing in auq-sdk-capture.ts, deferred.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 19:13:24 -07:00

236 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Per-skill SKILL.md size budget regression (v1.46.0.0 T5).
*
* Asserts that no skill's generated SKILL.md grew beyond the v1.47.0.0
* baseline. Catches preamble/resolver changes that bloat skills back to
* the pre-compression size. Free — pure file IO + JSON diff.
*
* Baseline rebased v1.44.1 → v1.47.0.0 in the AskUserQuestion split-rule
* PR after main merged GSTACK_PLAN_MODE + /spec, pushing the v1.44.1
* anchor past the 5% ratchet. Historical v1.44.1.json and v1.46.0.0.json
* are retained in test/fixtures/ for reference.
*
* Why a separate test from skill-budget-regression.test.ts: that one
* compares LIVE eval runs (tool calls, turns, cost); this one compares
* static SKILL.md sizes. Both gate-tier.
*
* The baseline lives at test/fixtures/parity-baseline-v1.47.0.0.json,
* captured by scripts/capture-baseline.ts before any Phase A work landed.
*
* Override:
* - GSTACK_SIZE_BUDGET_RATIO=<n> changes the per-skill regression ratio.
* Default 1.0 (no growth allowed). Set to 1.10 to permit 10% growth
* (e.g., during deliberate feature additions that the catalog trim
* doesn't offset).
* - GSTACK_SIZE_BUDGET_OVERRIDE_REASON="text" allows a regression to
* pass and logs the reason to ~/.gstack/analytics/spend-overrides.jsonl
* for audit. Use sparingly; the next baseline should bake in the new
* size.
*/
import { describe, test, expect } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
import { captureBaseline, type ParityBaseline } from './helpers/capture-parity-baseline';
import { logBudgetOverride } from './helpers/budget-override';
import { CARVED_SKILLS } from './helpers/carve-guards';
const REPO_ROOT = path.resolve(import.meta.dir, '..');
const BASELINE_PATH = path.join(REPO_ROOT, 'test', 'fixtures', 'parity-baseline-v1.47.0.0.json');
// Default per-skill ratio is 1.50 (50% growth tolerance). Adjusted v1.52.0.0
// (cathedral cap audit) from 1.05 → 1.50: a 5% ratio tripped on legitimate
// feature additions (e.g., plan-tune cathedral T13 grew SKILL.md ×1.24
// adding load-bearing Dream cycle + Audit unmarked + Recent auto-decisions
// surfaces). Real bloat is 2-3×; this catches that while not tripping on
// normal feature scope. The always-loaded catalog cost is enforced
// separately with a hard ceiling.
const DEFAULT_RATIO = 1.50;
const RATIO = Number(process.env.GSTACK_SIZE_BUDGET_RATIO) || DEFAULT_RATIO;
interface Regression {
skill: string;
beforeBytes: number;
afterBytes: number;
growth: number;
}
describe('SKILL.md size budget regression (gate, free)', () => {
test('parity-baseline-v1.47.0.0.json exists', () => {
expect(fs.existsSync(BASELINE_PATH)).toBe(true);
});
test('no skill exceeds v1.47.0.0 baseline size × ratio', () => {
const baseline: ParityBaseline = JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf-8'));
const current = captureBaseline({ repoRoot: REPO_ROOT });
const regressions: Regression[] = [];
for (const [skill, before] of Object.entries(baseline.skills)) {
const after = current.skills[skill];
if (!after) continue; // skill removed since v1.44 — not a regression
if (after.skillMdBytes <= before.skillMdBytes * RATIO) continue;
regressions.push({
skill,
beforeBytes: before.skillMdBytes,
afterBytes: after.skillMdBytes,
growth: after.skillMdBytes / before.skillMdBytes,
});
}
if (regressions.length === 0) return;
const overrideReason = process.env.GSTACK_SIZE_BUDGET_OVERRIDE_REASON?.trim();
if (overrideReason) {
logBudgetOverride({
scope: 'skill-size-budget',
reason: overrideReason,
details: { ratio: RATIO, regressions },
});
// eslint-disable-next-line no-console
console.warn(
`[skill-size-budget] OVERRIDE APPLIED (${overrideReason}) — ${regressions.length} regression(s) allowed:`,
);
for (const r of regressions) {
// eslint-disable-next-line no-console
console.warn(` ${r.skill}: ${r.beforeBytes}${r.afterBytes} bytes (×${r.growth.toFixed(2)})`);
}
return;
}
const msg = regressions.map(r =>
` ${r.skill}: ${r.beforeBytes}${r.afterBytes} bytes (×${r.growth.toFixed(2)})`,
).join('\n');
throw new Error(
`${regressions.length} skill(s) regressed past v1.47.0.0 baseline × ${RATIO}:\n${msg}\n` +
`Override: set GSTACK_SIZE_BUDGET_OVERRIDE_REASON="why this is OK" to allow and audit-log.`,
);
});
test('total corpus byte count does not regress past baseline × ratio', () => {
const baseline: ParityBaseline = JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf-8'));
const current = captureBaseline({ repoRoot: REPO_ROOT });
const ratio = current.totalCorpusBytes / baseline.totalCorpusBytes;
if (current.totalCorpusBytes <= baseline.totalCorpusBytes * RATIO) {
// eslint-disable-next-line no-console
console.log(
`[skill-size-budget] corpus OK: ${baseline.totalCorpusBytes}${current.totalCorpusBytes} bytes (×${ratio.toFixed(3)})`,
);
return;
}
const overrideReason = process.env.GSTACK_SIZE_BUDGET_OVERRIDE_REASON?.trim();
if (overrideReason) {
logBudgetOverride({
scope: 'skill-size-budget-corpus',
reason: overrideReason,
details: { ratio: RATIO, observed: ratio, before: baseline.totalCorpusBytes, after: current.totalCorpusBytes },
});
return;
}
throw new Error(
`Total corpus regressed past v1.47.0.0 baseline × ${RATIO}: ` +
`${baseline.totalCorpusBytes}${current.totalCorpusBytes} bytes (×${ratio.toFixed(3)}). ` +
`Override: set GSTACK_SIZE_BUDGET_OVERRIDE_REASON to allow.`,
);
});
/**
* Gap E (v1.46.0.0): per-skill min-size floor.
*
* The existing skill-coverage-floor enforces body ≥ 200 bytes, which is
* a tiny noise floor. A skill that was 100 KB at v1.47.0.0 and shrinks to
* 250 bytes passes that check despite losing 99.75% of content. The
* parity-suite content invariants cover this for 10 hand-picked skills
* (cso, ship, plan-ceo, etc.); the remaining 41 skills had no per-skill
* shrinkage floor.
*
* Floor: 80% of the v1.47.0.0 baseline. v1.46 actual shrinkage is <1% per
* skill, so this is a comfortable ceiling that still catches accidental
* mass deletion (e.g., a refactor that strips the body of a skill).
*
* v2.0.0.0 introduces the sections/ pattern for 5 heavyweights
* (ship, plan-ceo-review, office-hours, plan-eng-review,
* plan-design-review). Carved so far: ship (skeleton ~83 KB) and
* plan-ceo-review (skeleton ~81 KB, down from the 138 KB monolith). Those
* skeletons legitimately fall below the 80% body-strip floor, so each carved
* skill is added to SECTIONS_EXTRACTED; its union is guarded instead by the
* sectioned invariant in parity-harness.ts (minBytes on skeleton+sections).
* Add the remaining three here as they carve.
*/
test('no skill shrinks past 80% of v1.47.0.0 baseline (catches accidental body strip)', () => {
const baseline: ParityBaseline = JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf-8'));
const current = captureBaseline({ repoRoot: REPO_ROOT });
const MIN_RATIO = 0.80; // a skill at <80% of its v1.44 size signals mass-deletion
// Carved skills (v2 plan T9): the skeleton SKILL.md intentionally shrinks
// because prose moved into sections/*.md. The union size is guarded instead
// by the sectioned invariant in parity-harness.ts (minBytes on the
// skeleton+sections union), so exempt the skeleton from the body-strip floor.
// EQ1: derived from the canonical CARVE_GUARDS registry — no parallel list.
const SECTIONS_EXTRACTED = new Set<string>(CARVED_SKILLS);
const undershoots: Array<{
skill: string; beforeBytes: number; afterBytes: number; ratio: number;
}> = [];
for (const [skill, before] of Object.entries(baseline.skills)) {
if (SECTIONS_EXTRACTED.has(skill)) continue;
const after = current.skills[skill];
if (!after) continue; // skill removed since baseline — separate concern
const ratio = after.skillMdBytes / before.skillMdBytes;
if (ratio < MIN_RATIO) {
undershoots.push({
skill, beforeBytes: before.skillMdBytes, afterBytes: after.skillMdBytes, ratio,
});
}
}
if (undershoots.length === 0) return;
const overrideReason = process.env.GSTACK_SIZE_BUDGET_OVERRIDE_REASON?.trim();
if (overrideReason) {
logBudgetOverride({
scope: 'skill-size-budget-floor',
reason: overrideReason,
details: { min_ratio: MIN_RATIO, undershoots },
});
// eslint-disable-next-line no-console
console.warn(
`[skill-size-budget-floor] OVERRIDE APPLIED (${overrideReason}) — ${undershoots.length} undershoot(s) allowed`,
);
return;
}
const msg = undershoots.map(u =>
` ${u.skill}: ${u.beforeBytes}${u.afterBytes} bytes (×${u.ratio.toFixed(2)} — below ${MIN_RATIO} floor)`,
).join('\n');
throw new Error(
`${undershoots.length} skill(s) shrunk past v1.47.0.0 × ${MIN_RATIO} floor:\n${msg}\n` +
`This usually signals accidental body strip (e.g., a resolver returning empty, a ` +
`template losing a section). If the shrinkage is intentional (e.g., the skill moved ` +
`to the sections/ pattern), add it to SECTIONS_EXTRACTED in this test. Override: ` +
`GSTACK_SIZE_BUDGET_OVERRIDE_REASON="why" allows + audit-logs.`,
);
});
test('catalog token estimate stays compressed (v1.45 target ≤ 7000)', () => {
const current = captureBaseline({ repoRoot: REPO_ROOT });
const v145Target = 7000;
if (current.estTotalCatalogTokens <= v145Target) {
// eslint-disable-next-line no-console
console.log(`[skill-size-budget] catalog OK: ~${current.estTotalCatalogTokens} tokens (target ≤${v145Target})`);
return;
}
const overrideReason = process.env.GSTACK_SIZE_BUDGET_OVERRIDE_REASON?.trim();
if (overrideReason) {
logBudgetOverride({
scope: 'skill-size-budget-catalog',
reason: overrideReason,
details: { target: v145Target, observed: current.estTotalCatalogTokens },
});
return;
}
throw new Error(
`Catalog token estimate regressed past v1.45 target: ${current.estTotalCatalogTokens} tokens > ${v145Target}. ` +
`T4 catalog trim should keep this under control. Override: set GSTACK_SIZE_BUDGET_OVERRIDE_REASON to allow.`,
);
});
});