* test(#1074): add additive per-file workflow size baseline guard (PR 1/3) Introduces a committed per-file size baseline scheme alongside (not replacing) the existing tier anti-creep tests. Green by construction — the baseline records current sizes, so both schemes pass side by side during migration. - scripts/lib/allowlist-ratchet.cjs: add assertFileBaseline (third pure helper, same injected-fail style) — per-file growth/shrink/add/remove diff vs baseline. - scripts/workflow-size.cjs: single source of truth for LF-normalized byte counting (#683) + workflow enumeration, shared by the guard and the generator so they can never measure differently. Lives in scripts/ root (NOT scripts/lib/) because it is dev/CI-only tooling — scripts/lib/ is bundled into the installed runtime, scripts/ root is not, so this keeps it out of the shipped payload. - scripts/update-size-baseline.cjs + npm run size:baseline: regenerate the snapshot (sorted keys, trailing newline, idempotent). - tests/workflow-size-baseline.json: generated snapshot (88 workflows). - tests/workflow-size-budget.test.cjs: import the shared counter (drops the duplicated local byteCount) and add the per-file baseline describe block. - Tests for the helper, the shared module, and the generator (incl. round-trip and fault-injection cases). Refs #1074. Part 1 of 3; PR 2 swaps enforcement, PR 3 covers the agent test. * test(#1074): regenerate workflow baseline after Update-branch merge with next The 'Update branch' merge (652a916b) pulled in next's update.md change (#1090) without regenerating the snapshot, leaving the per-file baseline stale by one file. Re-ran `npm run size:baseline` so the committed baseline matches the merged workflow files. Refs #1074. --------- Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
446 lines
16 KiB
JavaScript
446 lines
16 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* Tests for scripts/lib/allowlist-ratchet.cjs
|
|
*
|
|
* Covers assertWithinAllowlist and assertTightCeiling.
|
|
* Uses a non-throwing fake `fail` that records messages into an array so we can
|
|
* assert on call count and message content without early-exit on first failure.
|
|
*/
|
|
|
|
const { test, describe } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
|
|
const {
|
|
assertWithinAllowlist,
|
|
assertTightCeiling,
|
|
assertFileBaseline,
|
|
} = require('../scripts/lib/allowlist-ratchet.cjs');
|
|
|
|
// ─── Fake fail helper ────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Returns a { fail, calls } pair. `fail` records its message without throwing,
|
|
* so tests can observe every violation rather than stopping at the first.
|
|
*/
|
|
function makeFail() {
|
|
const calls = [];
|
|
return {
|
|
calls,
|
|
fail(msg) {
|
|
calls.push(msg);
|
|
},
|
|
};
|
|
}
|
|
|
|
// ─── assertWithinAllowlist ───────────────────────────────────────────────────
|
|
|
|
describe('assertWithinAllowlist', () => {
|
|
test('clean case: current subset of known, no stale entries — fail never called', () => {
|
|
const { fail, calls } = makeFail();
|
|
const result = assertWithinAllowlist({
|
|
label: 'test-guard',
|
|
current: ['a.ts', 'b.ts'],
|
|
known: ['a.ts', 'b.ts'],
|
|
fail,
|
|
});
|
|
assert.strictEqual(calls.length, 0, 'fail should not be called');
|
|
assert.deepStrictEqual(result.novel, []);
|
|
assert.deepStrictEqual(result.stale, []);
|
|
});
|
|
|
|
test('novel detected: id in current but not in known — fail called with that id', () => {
|
|
const { fail, calls } = makeFail();
|
|
const result = assertWithinAllowlist({
|
|
label: 'novel-guard',
|
|
current: ['a.ts', 'b.ts', 'c.ts'],
|
|
known: ['a.ts', 'b.ts'],
|
|
fail,
|
|
});
|
|
assert.strictEqual(calls.length, 1, 'fail should be called once for novel');
|
|
assert.ok(calls[0].includes('c.ts'), 'message should mention the novel id');
|
|
assert.ok(
|
|
calls[0].includes('fix at the source'),
|
|
'message should include fix-at-source guidance'
|
|
);
|
|
assert.deepStrictEqual(result.novel, ['c.ts']);
|
|
assert.deepStrictEqual(result.stale, []);
|
|
});
|
|
|
|
test('stale detected: id in known but not in current — fail called with that id', () => {
|
|
const { fail, calls } = makeFail();
|
|
const result = assertWithinAllowlist({
|
|
label: 'stale-guard',
|
|
current: ['a.ts'],
|
|
known: ['a.ts', 'b.ts'],
|
|
fail,
|
|
});
|
|
assert.strictEqual(calls.length, 1, 'fail should be called once for stale');
|
|
assert.ok(calls[0].includes('b.ts'), 'message should mention the stale id');
|
|
assert.ok(
|
|
calls[0].includes('ratchets toward zero'),
|
|
'message should include ratchet-toward-zero language'
|
|
);
|
|
assert.deepStrictEqual(result.novel, []);
|
|
assert.deepStrictEqual(result.stale, ['b.ts']);
|
|
});
|
|
|
|
test('stale message includes pruneHint when provided', () => {
|
|
const { fail, calls } = makeFail();
|
|
assertWithinAllowlist({
|
|
label: 'prune-guard',
|
|
current: ['a.ts'],
|
|
known: ['a.ts', 'b.ts'],
|
|
fail,
|
|
pruneHint: 'edit scripts/my-allowlist.json',
|
|
});
|
|
assert.ok(
|
|
calls[0].includes('edit scripts/my-allowlist.json'),
|
|
'message should include the pruneHint'
|
|
);
|
|
});
|
|
|
|
test('both novel and stale at once — fail called twice', () => {
|
|
const { fail, calls } = makeFail();
|
|
const result = assertWithinAllowlist({
|
|
label: 'both-guard',
|
|
current: ['a.ts', 'c.ts'], // c.ts is new, b.ts is fixed
|
|
known: ['a.ts', 'b.ts'],
|
|
fail,
|
|
});
|
|
assert.strictEqual(calls.length, 2, 'fail should be called once for novel and once for stale');
|
|
const allMessages = calls.join('\n');
|
|
assert.ok(allMessages.includes('c.ts'), 'should mention novel id c.ts');
|
|
assert.ok(allMessages.includes('b.ts'), 'should mention stale id b.ts');
|
|
assert.deepStrictEqual(result.novel, ['c.ts']);
|
|
assert.deepStrictEqual(result.stale, ['b.ts']);
|
|
});
|
|
|
|
test('empty inputs — fail never called', () => {
|
|
const { fail, calls } = makeFail();
|
|
const result = assertWithinAllowlist({
|
|
label: 'empty-guard',
|
|
current: [],
|
|
known: [],
|
|
fail,
|
|
});
|
|
assert.strictEqual(calls.length, 0);
|
|
assert.deepStrictEqual(result.novel, []);
|
|
assert.deepStrictEqual(result.stale, []);
|
|
});
|
|
|
|
test('order-independence: Sets and arrays produce the same result', () => {
|
|
const callsArr = makeFail();
|
|
const callsSet = makeFail();
|
|
|
|
const resultArr = assertWithinAllowlist({
|
|
label: 'order-array',
|
|
current: ['z.ts', 'a.ts', 'm.ts'],
|
|
known: ['a.ts', 'm.ts'],
|
|
fail: callsArr.fail,
|
|
});
|
|
|
|
const resultSet = assertWithinAllowlist({
|
|
label: 'order-set',
|
|
current: new Set(['z.ts', 'a.ts', 'm.ts']),
|
|
known: new Set(['a.ts', 'm.ts']),
|
|
fail: callsSet.fail,
|
|
});
|
|
|
|
assert.deepStrictEqual(resultArr.novel, resultSet.novel, 'novel should be identical regardless of input type');
|
|
assert.deepStrictEqual(resultArr.stale, resultSet.stale, 'stale should be identical regardless of input type');
|
|
assert.deepStrictEqual(resultArr.novel, ['z.ts'], 'novel should be sorted');
|
|
});
|
|
|
|
test('returned novel and stale arrays are sorted', () => {
|
|
const { fail } = makeFail();
|
|
const result = assertWithinAllowlist({
|
|
label: 'sort-guard',
|
|
current: ['z.ts', 'a.ts', 'm.ts', 'new.ts'],
|
|
known: ['z.ts', 'a.ts', 'm.ts', 'old.ts'],
|
|
fail,
|
|
});
|
|
assert.deepStrictEqual(result.novel, ['new.ts']);
|
|
assert.deepStrictEqual(result.stale, ['old.ts']);
|
|
});
|
|
|
|
test('current empty, known non-empty — all known are stale', () => {
|
|
const { fail, calls } = makeFail();
|
|
const result = assertWithinAllowlist({
|
|
label: 'all-stale',
|
|
current: [],
|
|
known: ['a.ts', 'b.ts'],
|
|
fail,
|
|
});
|
|
assert.strictEqual(calls.length, 1);
|
|
assert.deepStrictEqual(result.stale, ['a.ts', 'b.ts']);
|
|
assert.deepStrictEqual(result.novel, []);
|
|
});
|
|
|
|
test('known empty, current non-empty — all current are novel', () => {
|
|
const { fail, calls } = makeFail();
|
|
const result = assertWithinAllowlist({
|
|
label: 'all-novel',
|
|
current: ['a.ts', 'b.ts'],
|
|
known: [],
|
|
fail,
|
|
});
|
|
assert.strictEqual(calls.length, 1);
|
|
assert.deepStrictEqual(result.novel, ['a.ts', 'b.ts']);
|
|
assert.deepStrictEqual(result.stale, []);
|
|
});
|
|
});
|
|
|
|
// ─── assertTightCeiling ──────────────────────────────────────────────────────
|
|
|
|
describe('assertTightCeiling', () => {
|
|
test('actualMax under ceiling within grace — ok, fail never called', () => {
|
|
const { fail, calls } = makeFail();
|
|
const result = assertTightCeiling({
|
|
label: 'size-guard',
|
|
actualMax: 90,
|
|
ceiling: 100,
|
|
grace: 15,
|
|
fail,
|
|
});
|
|
assert.strictEqual(calls.length, 0, 'fail should not be called');
|
|
assert.strictEqual(result.ok, true);
|
|
assert.strictEqual(result.slack, 10);
|
|
});
|
|
|
|
test('actualMax over ceiling — fail called with regression message', () => {
|
|
const { fail, calls } = makeFail();
|
|
const result = assertTightCeiling({
|
|
label: 'size-guard',
|
|
actualMax: 110,
|
|
ceiling: 100,
|
|
grace: 5,
|
|
fail,
|
|
});
|
|
assert.strictEqual(calls.length, 1, 'fail should be called once');
|
|
assert.ok(calls[0].includes('Regression'), 'message should say Regression');
|
|
assert.ok(calls[0].includes('110'), 'message should include actualMax');
|
|
assert.ok(calls[0].includes('100'), 'message should include ceiling');
|
|
assert.strictEqual(result.ok, false);
|
|
assert.strictEqual(result.slack, -10);
|
|
});
|
|
|
|
test('ceiling too loose (slack > grace) — fail called with tighten message', () => {
|
|
const { fail, calls } = makeFail();
|
|
const result = assertTightCeiling({
|
|
label: 'loose-guard',
|
|
actualMax: 50,
|
|
ceiling: 100,
|
|
grace: 10,
|
|
fail,
|
|
});
|
|
assert.strictEqual(calls.length, 1, 'fail should be called once');
|
|
assert.ok(
|
|
calls[0].toLowerCase().includes('tighten') || calls[0].includes('too far'),
|
|
'message should mention tightening'
|
|
);
|
|
assert.ok(calls[0].includes('Budgets may only decrease'), 'message should include budget policy');
|
|
assert.strictEqual(result.ok, false);
|
|
assert.strictEqual(result.slack, 50);
|
|
});
|
|
|
|
test('boundary: slack === grace — ok (exactly at the grace limit)', () => {
|
|
const { fail, calls } = makeFail();
|
|
const result = assertTightCeiling({
|
|
label: 'boundary-guard',
|
|
actualMax: 90,
|
|
ceiling: 100,
|
|
grace: 10,
|
|
fail,
|
|
});
|
|
assert.strictEqual(calls.length, 0, 'fail should not be called at exact grace boundary');
|
|
assert.strictEqual(result.ok, true);
|
|
assert.strictEqual(result.slack, 10);
|
|
});
|
|
|
|
test('actualMax equals ceiling — ok, slack is zero', () => {
|
|
const { fail, calls } = makeFail();
|
|
const result = assertTightCeiling({
|
|
label: 'exact-guard',
|
|
actualMax: 100,
|
|
ceiling: 100,
|
|
grace: 0,
|
|
fail,
|
|
});
|
|
assert.strictEqual(calls.length, 0);
|
|
assert.strictEqual(result.ok, true);
|
|
assert.strictEqual(result.slack, 0);
|
|
});
|
|
|
|
test('grace 0: any slack triggers fail', () => {
|
|
const { fail, calls } = makeFail();
|
|
assertTightCeiling({
|
|
label: 'tight-guard',
|
|
actualMax: 99,
|
|
ceiling: 100,
|
|
grace: 0,
|
|
fail,
|
|
});
|
|
assert.strictEqual(calls.length, 1, 'any slack above 0 should fail when grace is 0');
|
|
});
|
|
|
|
test('label appears in failure messages', () => {
|
|
const { fail, calls } = makeFail();
|
|
assertTightCeiling({
|
|
label: 'my-special-guard',
|
|
actualMax: 200,
|
|
ceiling: 100,
|
|
grace: 5,
|
|
fail,
|
|
});
|
|
assert.ok(calls[0].includes('my-special-guard'), 'label should appear in message');
|
|
});
|
|
});
|
|
|
|
// ─── assertFileBaseline ──────────────────────────────────────────────────────
|
|
|
|
describe('assertFileBaseline', () => {
|
|
test('exact match: current equals baseline — fail never called', () => {
|
|
const { fail, calls } = makeFail();
|
|
const result = assertFileBaseline({
|
|
label: 'workflow-size',
|
|
current: { 'a.md': 100, 'b.md': 200 },
|
|
baseline: { 'a.md': 100, 'b.md': 200 },
|
|
fail,
|
|
});
|
|
assert.strictEqual(calls.length, 0, 'fail should not be called on an exact match');
|
|
assert.deepStrictEqual(result.grown, []);
|
|
assert.deepStrictEqual(result.shrunk, []);
|
|
assert.deepStrictEqual(result.added, []);
|
|
assert.deepStrictEqual(result.removed, []);
|
|
});
|
|
|
|
test('growth: a file larger than baseline — fail called, delta reported', () => {
|
|
const { fail, calls } = makeFail();
|
|
const result = assertFileBaseline({
|
|
label: 'workflow-size',
|
|
current: { 'a.md': 154, 'b.md': 200 },
|
|
baseline: { 'a.md': 100, 'b.md': 200 },
|
|
fail,
|
|
});
|
|
assert.strictEqual(calls.length, 1, 'fail should be called once for growth');
|
|
assert.ok(calls[0].includes('grew') || calls[0].includes('grow'), 'message should describe growth');
|
|
assert.ok(calls[0].includes('a.md'), 'message should name the grown file');
|
|
assert.ok(calls[0].includes('100') && calls[0].includes('154'), 'message should show from → to');
|
|
assert.ok(calls[0].includes('54'), 'message should show the +delta');
|
|
assert.deepStrictEqual(result.grown.map((g) => g.name), ['a.md']);
|
|
assert.strictEqual(result.grown[0].delta, 54);
|
|
});
|
|
|
|
test('shrink: a file smaller than baseline — fail called as stale (auto-tighten)', () => {
|
|
const { fail, calls } = makeFail();
|
|
const result = assertFileBaseline({
|
|
label: 'workflow-size',
|
|
current: { 'a.md': 80, 'b.md': 200 },
|
|
baseline: { 'a.md': 100, 'b.md': 200 },
|
|
fail,
|
|
});
|
|
assert.strictEqual(calls.length, 1, 'fail should be called once for a stale (shrunk) baseline');
|
|
assert.ok(/stale|smaller|shrank|shrunk/i.test(calls[0]), 'message should flag a stale/shrunk baseline');
|
|
assert.ok(calls[0].includes('a.md'), 'message should name the shrunk file');
|
|
assert.deepStrictEqual(result.shrunk.map((s) => s.name), ['a.md']);
|
|
assert.strictEqual(result.shrunk[0].delta, 20);
|
|
});
|
|
|
|
test('added: a file absent from baseline — fail called', () => {
|
|
const { fail, calls } = makeFail();
|
|
const result = assertFileBaseline({
|
|
label: 'workflow-size',
|
|
current: { 'a.md': 100, 'new.md': 50 },
|
|
baseline: { 'a.md': 100 },
|
|
fail,
|
|
});
|
|
assert.strictEqual(calls.length, 1, 'fail should be called once for an unbaselined new file');
|
|
assert.ok(/not in the baseline|new|missing/i.test(calls[0]), 'message should flag the unbaselined file');
|
|
assert.ok(calls[0].includes('new.md'), 'message should name the new file');
|
|
assert.deepStrictEqual(result.added, ['new.md']);
|
|
});
|
|
|
|
test('removed: a baseline entry with no current file — fail called', () => {
|
|
const { fail, calls } = makeFail();
|
|
const result = assertFileBaseline({
|
|
label: 'workflow-size',
|
|
current: { 'a.md': 100 },
|
|
baseline: { 'a.md': 100, 'gone.md': 70 },
|
|
fail,
|
|
});
|
|
assert.strictEqual(calls.length, 1, 'fail should be called once for an orphaned baseline entry');
|
|
assert.ok(/no longer exist|removed|orphan/i.test(calls[0]), 'message should flag the orphaned entry');
|
|
assert.ok(calls[0].includes('gone.md'), 'message should name the orphaned entry');
|
|
assert.deepStrictEqual(result.removed, ['gone.md']);
|
|
});
|
|
|
|
test('multiple categories at once — one fail per non-empty category', () => {
|
|
const { fail, calls } = makeFail();
|
|
const result = assertFileBaseline({
|
|
label: 'workflow-size',
|
|
current: { 'grow.md': 150, 'shrink.md': 50, 'new.md': 10 },
|
|
baseline: { 'grow.md': 100, 'shrink.md': 100, 'gone.md': 30 },
|
|
fail,
|
|
});
|
|
// grown(1) + shrunk(1) + added(1) + removed(1) = 4 categories
|
|
assert.strictEqual(calls.length, 4, 'one fail per non-empty category');
|
|
assert.deepStrictEqual(result.grown.map((g) => g.name), ['grow.md']);
|
|
assert.deepStrictEqual(result.shrunk.map((s) => s.name), ['shrink.md']);
|
|
assert.deepStrictEqual(result.added, ['new.md']);
|
|
assert.deepStrictEqual(result.removed, ['gone.md']);
|
|
});
|
|
|
|
test('updateHint appears in every failure message', () => {
|
|
const { fail, calls } = makeFail();
|
|
assertFileBaseline({
|
|
label: 'workflow-size',
|
|
current: { 'grow.md': 150, 'new.md': 10 },
|
|
baseline: { 'grow.md': 100, 'gone.md': 30 },
|
|
fail,
|
|
updateHint: 'Run `npm run size:baseline`',
|
|
});
|
|
assert.ok(calls.length >= 2, 'multiple categories should each fail');
|
|
for (const msg of calls) {
|
|
assert.ok(msg.includes('Run `npm run size:baseline`'), 'every message should carry the updateHint');
|
|
}
|
|
});
|
|
|
|
test('empty inputs — fail never called', () => {
|
|
const { fail, calls } = makeFail();
|
|
const result = assertFileBaseline({
|
|
label: 'workflow-size',
|
|
current: {},
|
|
baseline: {},
|
|
fail,
|
|
});
|
|
assert.strictEqual(calls.length, 0);
|
|
assert.deepStrictEqual(result.grown, []);
|
|
assert.deepStrictEqual(result.shrunk, []);
|
|
assert.deepStrictEqual(result.added, []);
|
|
assert.deepStrictEqual(result.removed, []);
|
|
});
|
|
|
|
test('returned category lists are sorted by name', () => {
|
|
const { fail } = makeFail();
|
|
const result = assertFileBaseline({
|
|
label: 'workflow-size',
|
|
current: { 'z.md': 10, 'a.md': 10, 'm.md': 10 },
|
|
baseline: {},
|
|
fail,
|
|
});
|
|
assert.deepStrictEqual(result.added, ['a.md', 'm.md', 'z.md'], 'added should be sorted');
|
|
});
|
|
|
|
test('label appears in failure messages', () => {
|
|
const { fail, calls } = makeFail();
|
|
assertFileBaseline({
|
|
label: 'my-size-guard',
|
|
current: { 'a.md': 200 },
|
|
baseline: { 'a.md': 100 },
|
|
fail,
|
|
});
|
|
assert.ok(calls[0].includes('my-size-guard'), 'label should appear in message');
|
|
});
|
|
});
|