feat(config-audit): TOK flags skill description > 500 chars (v5 M2) [skip-docs]

- New Pattern F in TOK: low-severity finding when SKILL.md description > 500 chars
- Scoped to discovery.files (project-local) — activeConfig.skills walk would
  pull in user/plugin skills out of project scope
- New fixtures: skill-bloated (594-char desc) + skill-tight (46-char baseline)

574 → 576 tests, all green.
This commit is contained in:
Kjell Tore Guttormsen 2026-05-01 06:58:42 +02:00
commit 9a44df22ac
4 changed files with 71 additions and 1 deletions

View file

@ -26,7 +26,7 @@ import { stat } from 'node:fs/promises';
import { readTextFile } from './lib/file-discovery.mjs';
import { finding, scannerResult } from './lib/output.mjs';
import { SEVERITY } from './lib/severity.mjs';
import { findImports, parseJson } from './lib/yaml-parser.mjs';
import { findImports, parseJson, parseFrontmatter } from './lib/yaml-parser.mjs';
import { estimateTokens, readActiveConfig } from './lib/active-config-reader.mjs';
const SCANNER = 'TOK';
@ -48,6 +48,10 @@ const MAX_IMPORT_DEPTH = 2;
// any tool description loads. Heuristic for "context budget under pressure".
const CASCADE_TOKEN_THRESHOLD = 10_000;
// v5 M2: SKILL.md `description` loads on every turn even when the body does
// not. Anything past this hints the description is doing the body's job.
const SKILL_DESCRIPTION_THRESHOLD = 500;
const HOTSPOTS_MAX = 10;
// v5 F7: shared evidence note appended to every TOK pattern finding.
@ -334,6 +338,40 @@ export async function scan(targetPath, discovery) {
}
}
// ── Pattern F: SKILL.md description > 500 chars (v5 M2) ──
// Scoped to discovery.files (project-local skill-md). The plan mentioned
// walking activeConfig.skills, but that pulls in user's ~/.claude/skills
// and installed plugin skills which are out-of-scope for a project audit
// and add noise the user can't act on. Project-local discovery is what
// /config-audit on a path is actually asking about.
for (const f of discovery.files) {
if (f.type !== 'skill-md') continue;
const content = await readTextFile(f.absPath);
if (!content) continue;
filesScanned++;
const fm = parseFrontmatter(content)?.frontmatter || null;
const desc = (fm && typeof fm.description === 'string') ? fm.description : '';
if (desc.length <= SKILL_DESCRIPTION_THRESHOLD) continue;
const skillName = (fm && fm.name) || f.absPath.split('/').slice(-2, -1)[0] || f.absPath;
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.low,
title: 'Bloated skill description (loads on every turn)',
description:
`Skill "${skillName}" has a description of ${desc.length} characters ` +
`(>${SKILL_DESCRIPTION_THRESHOLD}). The description block loads on every turn ` +
'even when the skill body does not — long descriptions inflate per-turn cost.',
file: f.absPath,
evidence:
`description_chars=${desc.length}; threshold=${SKILL_DESCRIPTION_THRESHOLD}; ` +
`skill="${skillName}" — ${CALIBRATION_NOTE}`,
recommendation:
'Tighten the description to a single sentence (≤500 chars) covering trigger phrases ' +
'only. Move detailed usage / examples into the SKILL.md body.',
category: 'token-efficiency',
}));
}
// ── Pattern E: CLAUDE.md cascade > CASCADE_TOKEN_THRESHOLD (v5 M4) ──
if (activeConfig?.claudeMd?.estimatedTokens > CASCADE_TOKEN_THRESHOLD) {
const cascadeTokens = activeConfig.claudeMd.estimatedTokens;