config-audit/tests/lib/skill-listing-budget.test.mjs
Kjell Tore Guttormsen dfe9049b55 feat(feature-gap): recommend disableBundledSkills under skill-listing pressure
Chunk 2 of the disableBundledSkills GAP feature. Adds a conditional GAP check
that prescribes the `disableBundledSkills` lever — but only when the active
skill listing is measurably over budget (SKL's CA-SKL-002 overflow signal) and
the lever is un-pulled. It stays an opportunity, not noise.

Bundled skills (/code-review, /batch, /debug, /loop, /claude-api, …) live in the
CC binary, not on disk, so their exact cost is unmeasurable here — the finding
says so plainly, and frames the lever as zero-cost budget reclaim that leaves
the user's own skills untouched. CC 2.1.169+.

- Pure, exported bundledSkillsLeverFinding({leverPulled, aggregate}) → finding|null
  (severity low, category token-efficiency, CA-GAP-NNN), wired into scan() via the
  shared measureActiveSkillListing().
- Lever resolved via new isBundledSkillsDisabled(): env var + settings cascade
  read directly, because discovery does NOT tag ~/.claude/settings.json (its
  relPath lacks ".claude" when walked from the .claude root) — the dominant
  user-scope location for this global preference would otherwise be missed.
- GAP scan() now reads HOME → existing GAP tests retrofitted to withHermeticHome
  per the hermetic rule. Snapshots unchanged, contamination grep clean.
- 16 new tests (9 GAP, 7 lib). Suite 887 -> 903. README/CLAUDE.md document the
  cross-scanner remediation; test counts synced. self-audit: PASS, configGrade
  A 96, pluginGrade A 100, readme gate passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ter3E2JSi1Khgmuf2kady8
2026-06-18 21:38:19 +02:00

181 lines
6.4 KiB
JavaScript
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.

import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { join } from 'node:path';
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import {
assessSkillListingBudget,
envFlag,
isBundledSkillsDisabled,
DESCRIPTION_CAP,
AGGREGATE_BUDGET_TOKENS,
CONTEXT_WINDOW_ANCHOR,
LARGE_CONTEXT_WINDOW,
LARGE_CONTEXT_BUDGET_TOKENS,
withCommas,
BUDGET_CALIBRATION_NOTE,
} from '../../scanners/lib/skill-listing-budget.mjs';
// This lib is the single source of truth for the skill-listing budget that both
// the SKL scanner (diagnoses overflow) and the GAP scanner (recommends the
// disableBundledSkills lever) consume. The constants and aggregate math here
// must match what SKL shipped in v5.2.0 — these tests pin that contract.
describe('skill-listing-budget — constants', () => {
it('DESCRIPTION_CAP is the verified 1536-char listing cap (CC 2.1.105)', () => {
assert.equal(DESCRIPTION_CAP, 1536);
});
it('AGGREGATE_BUDGET_TOKENS is 2% of the conservative 200k anchor', () => {
assert.equal(CONTEXT_WINDOW_ANCHOR, 200_000);
assert.equal(AGGREGATE_BUDGET_TOKENS, 4000);
});
it('LARGE_CONTEXT_BUDGET_TOKENS is 2% of the 1M window', () => {
assert.equal(LARGE_CONTEXT_WINDOW, 1_000_000);
assert.equal(LARGE_CONTEXT_BUDGET_TOKENS, 20000);
});
});
describe('withCommas', () => {
it('inserts thousands separators', () => {
assert.equal(withCommas(1_000_000), '1,000,000');
assert.equal(withCommas(4000), '4,000');
assert.equal(withCommas(999), '999');
assert.equal(withCommas(0), '0');
});
});
describe('BUDGET_CALIBRATION_NOTE', () => {
it('discloses the 1M-context scaling and flags this as an estimate, not telemetry', () => {
assert.match(BUDGET_CALIBRATION_NOTE, /1,000,000/);
assert.match(BUDGET_CALIBRATION_NOTE, /20,000/);
assert.match(BUDGET_CALIBRATION_NOTE, /estimate/i);
});
});
describe('assessSkillListingBudget — aggregate math', () => {
it('sums each description length capped at the 1536 cap', () => {
// 1000 + 1000 + min(5000,1536) = 3536
const r = assessSkillListingBudget([1000, 1000, 5000]);
assert.equal(r.aggregateChars, 3536);
});
it('scanned counts every entry, including empty descriptions', () => {
const r = assessSkillListingBudget([0, 0, 100]);
assert.equal(r.scanned, 3);
assert.equal(r.aggregateChars, 100);
});
it('aggregateTokens mirrors estimateTokens markdown (ceil chars/4)', () => {
const r = assessSkillListingBudget([16]); // 16 chars -> 4 tokens
assert.equal(r.aggregateTokens, 4);
});
it('is NOT over budget exactly at the budget (16×1000 chars -> 4000 tok)', () => {
const r = assessSkillListingBudget(Array(16).fill(1000));
assert.equal(r.aggregateChars, 16000);
assert.equal(r.aggregateTokens, 4000);
assert.equal(r.overBudget, false);
assert.equal(r.overBy, 0);
});
it('is over budget one skill past the budget (17×1000 chars -> 4250 tok)', () => {
const r = assessSkillListingBudget(Array(17).fill(1000));
assert.equal(r.aggregateTokens, 4250);
assert.equal(r.overBudget, true);
assert.equal(r.overBy, 250);
});
it('exposes budgetTokens and handles the empty case', () => {
const r = assessSkillListingBudget([]);
assert.equal(r.budgetTokens, AGGREGATE_BUDGET_TOKENS);
assert.equal(r.scanned, 0);
assert.equal(r.aggregateChars, 0);
assert.equal(r.aggregateTokens, 0);
assert.equal(r.overBudget, false);
assert.equal(r.overBy, 0);
});
it('treats negative or non-finite lengths as zero contribution', () => {
const r = assessSkillListingBudget([-5, NaN, 100]);
assert.equal(r.aggregateChars, 100);
assert.equal(r.scanned, 3);
});
});
describe('envFlag', () => {
it('treats 1/true/yes/on as set', () => {
for (const v of ['1', 'true', 'TRUE', 'yes', 'on', ' 1 ']) {
assert.equal(envFlag(v), true, `expected ${JSON.stringify(v)} → true`);
}
});
it('treats null/empty/0/false/no/off as un-set', () => {
for (const v of [undefined, null, '', '0', 'false', 'no', 'off', ' ']) {
assert.equal(envFlag(v), false, `expected ${JSON.stringify(v)} → false`);
}
});
});
describe('isBundledSkillsDisabled — lever cascade', () => {
async function withHome(fn) {
const home = await mkdtemp(join(tmpdir(), 'config-audit-lever-home-'));
const originalHome = process.env.HOME;
const originalEnv = process.env.CLAUDE_CODE_DISABLE_BUNDLED_SKILLS;
process.env.HOME = home;
delete process.env.CLAUDE_CODE_DISABLE_BUNDLED_SKILLS;
try {
return await fn(home);
} finally {
process.env.HOME = originalHome;
if (originalEnv === undefined) delete process.env.CLAUDE_CODE_DISABLE_BUNDLED_SKILLS;
else process.env.CLAUDE_CODE_DISABLE_BUNDLED_SKILLS = originalEnv;
await rm(home, { recursive: true, force: true });
}
}
async function writeSettings(dir, obj) {
await mkdir(dir, { recursive: true });
await writeFile(join(dir, 'settings.json'), JSON.stringify(obj));
}
it('is false on a clean HOME with no env and no settings', async () => {
await withHome(async () => {
assert.equal(await isBundledSkillsDisabled(), false);
});
});
it('is true when the env var is set', async () => {
await withHome(async () => {
process.env.CLAUDE_CODE_DISABLE_BUNDLED_SKILLS = '1';
assert.equal(await isBundledSkillsDisabled(), true);
});
});
it('is true when user ~/.claude/settings.json sets it (the location discovery misses)', async () => {
await withHome(async (home) => {
await writeSettings(join(home, '.claude'), { disableBundledSkills: true });
assert.equal(await isBundledSkillsDisabled(), true);
});
});
it('is true when project .claude/settings.json sets it', async () => {
await withHome(async () => {
const project = await mkdtemp(join(tmpdir(), 'config-audit-lever-proj-'));
try {
await writeSettings(join(project, '.claude'), { disableBundledSkills: true });
assert.equal(await isBundledSkillsDisabled(project), true);
} finally {
await rm(project, { recursive: true, force: true });
}
});
});
it('is false when the setting is present but not strictly true', async () => {
await withHome(async (home) => {
await writeSettings(join(home, '.claude'), { disableBundledSkills: false });
assert.equal(await isBundledSkillsDisabled(), false);
});
});
});