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
This commit is contained in:
Kjell Tore Guttormsen 2026-06-18 21:38:19 +02:00
commit dfe9049b55
6 changed files with 338 additions and 12 deletions

View file

@ -1,7 +1,12 @@
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,
@ -98,3 +103,79 @@ describe('assessSkillListingBudget — aggregate math', () => {
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);
});
});
});

View file

@ -1,10 +1,13 @@
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { resolve } from 'node:path';
import { resolve, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { resetCounter } from '../../scanners/lib/output.mjs';
import { scan, opportunitySummary } from '../../scanners/feature-gap-scanner.mjs';
import { scan, opportunitySummary, bundledSkillsLeverFinding } from '../../scanners/feature-gap-scanner.mjs';
import { discoverConfigFiles } from '../../scanners/lib/file-discovery.mjs';
import { withHermeticHome } from '../helpers/hermetic-home.mjs';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const FIXTURES = resolve(__dirname, '../fixtures');
@ -20,7 +23,10 @@ describe('GAP scanner — healthy project', () => {
beforeEach(async () => {
resetCounter();
const discovery = await fixtureDiscovery('healthy-project');
result = await scan(resolve(FIXTURES, 'healthy-project'), discovery);
// Hermetic HOME: scan() now enumerates active skills via process.env.HOME
// (the disableBundledSkills lever check). An empty HOME keeps these fixture
// tests environment-independent — no skills, no budget pressure, no lever finding.
result = await withHermeticHome(() => scan(resolve(FIXTURES, 'healthy-project'), discovery));
});
it('returns status ok', () => {
@ -91,7 +97,7 @@ describe('GAP scanner — minimal project', () => {
beforeEach(async () => {
resetCounter();
const discovery = await fixtureDiscovery('minimal-project');
result = await scan(resolve(FIXTURES, 'minimal-project'), discovery);
result = await withHermeticHome(() => scan(resolve(FIXTURES, 'minimal-project'), discovery));
});
it('returns status ok', () => {
@ -125,7 +131,7 @@ describe('GAP scanner — minimal project', () => {
it('has more findings than healthy project', async () => {
resetCounter();
const discovery = await fixtureDiscovery('healthy-project');
const healthyResult = await scan(resolve(FIXTURES, 'healthy-project'), discovery);
const healthyResult = await withHermeticHome(() => scan(resolve(FIXTURES, 'healthy-project'), discovery));
assert.ok(result.findings.length > healthyResult.findings.length);
});
});
@ -135,7 +141,7 @@ describe('GAP scanner — empty project', () => {
beforeEach(async () => {
resetCounter();
const discovery = await fixtureDiscovery('empty-project');
result = await scan(resolve(FIXTURES, 'empty-project'), discovery);
result = await withHermeticHome(() => scan(resolve(FIXTURES, 'empty-project'), discovery));
});
it('returns status ok (never skips)', () => {
@ -206,3 +212,131 @@ describe('opportunitySummary', () => {
assert.equal(result.explore.length, 2);
});
});
// ── disableBundledSkills lever (CA-GAP) — remediation companion to SKL CA-SKL-002 ──
describe('bundledSkillsLeverFinding — pure decision', () => {
const overBudget = { scanned: 17, aggregateChars: 17000, aggregateTokens: 4250, budgetTokens: 4000, overBudget: true, overBy: 250 };
const underBudget = { scanned: 3, aggregateChars: 3000, aggregateTokens: 750, budgetTokens: 4000, overBudget: false, overBy: 0 };
it('returns null when the lever is already pulled, even over budget', () => {
assert.equal(bundledSkillsLeverFinding({ leverPulled: true, aggregate: overBudget }), null);
});
it('returns null when the listing is under budget', () => {
assert.equal(bundledSkillsLeverFinding({ leverPulled: false, aggregate: underBudget }), null);
});
it('returns a finding when the lever is un-pulled AND the listing is over budget', () => {
resetCounter();
const f = bundledSkillsLeverFinding({ leverPulled: false, aggregate: overBudget });
assert.ok(f, 'expected a finding');
assert.match(f.id, /^CA-GAP-\d{3}$/);
assert.equal(f.scanner, 'GAP');
assert.equal(f.severity, 'low');
assert.equal(f.category, 'token-efficiency');
assert.match(f.recommendation, /disableBundledSkills/);
assert.match(`${f.description} ${f.recommendation}`, /2\.1\.169/);
});
it('handles a null/garbage aggregate without throwing', () => {
assert.equal(bundledSkillsLeverFinding({ leverPulled: false, aggregate: null }), null);
});
});
describe('GAP scanner — disableBundledSkills lever wiring (HOME-scoped)', () => {
async function buildHome(count, descLen, settings) {
const home = await mkdtemp(join(tmpdir(), 'config-audit-gap-home-'));
for (let i = 0; i < count; i++) {
const dir = join(home, '.claude', 'skills', `s${i}`);
await mkdir(dir, { recursive: true });
await writeFile(join(dir, 'SKILL.md'), `---\nname: s${i}\ndescription: ${'a'.repeat(descLen)}\n---\nBody.\n`);
}
if (settings) {
await mkdir(join(home, '.claude'), { recursive: true });
await writeFile(join(home, '.claude', 'settings.json'), JSON.stringify(settings));
}
return home;
}
/** Run the GAP scanner with HOME pointed at `home`; an empty throwaway project. */
async function runGapWithHome(home) {
const project = await mkdtemp(join(tmpdir(), 'config-audit-gap-proj-'));
const original = process.env.HOME;
process.env.HOME = home;
try {
resetCounter();
const discovery = await discoverConfigFiles(project, { includeGlobal: true });
const result = await scan(project, discovery);
return result;
} finally {
process.env.HOME = original;
await rm(project, { recursive: true, force: true });
}
}
const hasLever = (result) =>
result.findings.some(f => f.scanner === 'GAP' && /disableBundledSkills/.test(f.recommendation || ''));
it('fires when the listing is over budget and the lever is un-pulled', async () => {
const home = await buildHome(17, 1000); // 17000 chars -> 4250 tok > 4000 budget
try {
const result = await runGapWithHome(home);
const f = result.findings.find(x => x.scanner === 'GAP' && /disableBundledSkills/.test(x.recommendation || ''));
assert.ok(f, `expected a disableBundledSkills finding; got: ${result.findings.map(x => x.title).join(' | ')}`);
assert.equal(f.severity, 'low');
assert.equal(f.category, 'token-efficiency');
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('does NOT fire when the listing is under budget', async () => {
const home = await buildHome(3, 1000); // 3000 chars -> 750 tok < 4000
try {
const result = await runGapWithHome(home);
assert.equal(hasLever(result), false);
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('does NOT fire when disableBundledSkills is already true in settings', async () => {
const home = await buildHome(17, 1000, { disableBundledSkills: true });
try {
const result = await runGapWithHome(home);
assert.equal(hasLever(result), false,
'lever already pulled in settings — should not recommend it again');
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('does NOT fire when the CLAUDE_CODE_DISABLE_BUNDLED_SKILLS env var is set', async () => {
const home = await buildHome(17, 1000);
const originalEnv = process.env.CLAUDE_CODE_DISABLE_BUNDLED_SKILLS;
process.env.CLAUDE_CODE_DISABLE_BUNDLED_SKILLS = '1';
try {
const result = await runGapWithHome(home);
assert.equal(hasLever(result), false, 'env lever should suppress the recommendation');
} finally {
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 });
}
});
it('treats CLAUDE_CODE_DISABLE_BUNDLED_SKILLS=0 as un-pulled (still fires)', async () => {
const home = await buildHome(17, 1000);
const originalEnv = process.env.CLAUDE_CODE_DISABLE_BUNDLED_SKILLS;
process.env.CLAUDE_CODE_DISABLE_BUNDLED_SKILLS = '0';
try {
const result = await runGapWithHome(home);
assert.equal(hasLever(result), true, '"0" means off — the lever is NOT pulled, so the finding should fire');
} finally {
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 });
}
});
});