feat(config-audit): /config-audit manifest command (v5 N2) [skip-docs]
New scanners/manifest.mjs CLI + commands/manifest.md slash command. Reads activeConfig and produces a flat, ranked list of every token source (CLAUDE.md cascade entries, plugins, skills, MCP servers, hooks) sorted DESC by estimated_tokens. CLAUDE.md per-file tokens are derived by distributing claudeMd.estimatedTokens across the cascade proportional to bytes. Tests cover both real-config (plugin root) and fixture (rich-repo with patched HOME containing 2 plugins + 3 skills + .mcp.json) paths, plus error handling (nonexistent path → exit 3, --output-file). Builds on readActiveConfig from M1 (v5 alpha.2). [skip-docs] reason: v5 plan fences off README/CLAUDE.md badge updates to Session 5; Forgejo pre-commit-docs-gate hook requires this tag on feat commits without doc changes. Tests: 593 → 604 (+11). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
b2407a09b3
commit
0420b8cc4a
3 changed files with 436 additions and 0 deletions
157
plugins/config-audit/scanners/manifest.mjs
Normal file
157
plugins/config-audit/scanners/manifest.mjs
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Manifest scanner CLI (v5 N2) — produce a ranked list of every token source
|
||||
* loaded for a given repo path. Built on top of readActiveConfig so the source
|
||||
* inventory is identical to whats-active; this CLI flattens and ranks them.
|
||||
*
|
||||
* Output JSON shape:
|
||||
* {
|
||||
* meta: { repoPath, generatedAt, durationMs },
|
||||
* sources: [
|
||||
* { kind: 'claude-md'|'plugin'|'skill'|'mcp-server'|'hook',
|
||||
* name: string, source: string, estimated_tokens: number },
|
||||
* ...
|
||||
* ],
|
||||
* total: <sum of sources.estimated_tokens>
|
||||
* }
|
||||
*
|
||||
* Usage:
|
||||
* node manifest.mjs [path] [--json] [--output-file <path>]
|
||||
*
|
||||
* Exit codes: 0=ok, 3=unrecoverable error.
|
||||
* Zero external dependencies.
|
||||
*/
|
||||
|
||||
import { resolve } from 'node:path';
|
||||
import { writeFile, stat } from 'node:fs/promises';
|
||||
import { readActiveConfig } from './lib/active-config-reader.mjs';
|
||||
|
||||
/**
|
||||
* Flatten an activeConfig snapshot into a single ranked array of sources.
|
||||
*/
|
||||
export function buildManifest(activeConfig) {
|
||||
const sources = [];
|
||||
|
||||
for (const f of activeConfig.claudeMd?.files || []) {
|
||||
const tokens = estimateClaudeMdEntryTokens(f, activeConfig);
|
||||
sources.push({
|
||||
kind: 'claude-md',
|
||||
name: f.path,
|
||||
source: f.scope,
|
||||
estimated_tokens: tokens,
|
||||
});
|
||||
}
|
||||
|
||||
for (const p of activeConfig.plugins || []) {
|
||||
sources.push({
|
||||
kind: 'plugin',
|
||||
name: p.name,
|
||||
source: p.path,
|
||||
estimated_tokens: p.estimatedTokens || 0,
|
||||
});
|
||||
}
|
||||
|
||||
for (const s of activeConfig.skills || []) {
|
||||
sources.push({
|
||||
kind: 'skill',
|
||||
name: s.name,
|
||||
source: s.pluginName ? `plugin:${s.pluginName}` : s.source || 'user',
|
||||
estimated_tokens: s.estimatedTokens || 0,
|
||||
});
|
||||
}
|
||||
|
||||
for (const m of activeConfig.mcpServers || []) {
|
||||
if (m && m.enabled === false) continue;
|
||||
sources.push({
|
||||
kind: 'mcp-server',
|
||||
name: m.name,
|
||||
source: m.source || 'unknown',
|
||||
estimated_tokens: m.estimatedTokens || 0,
|
||||
});
|
||||
}
|
||||
|
||||
for (const h of activeConfig.hooks || []) {
|
||||
sources.push({
|
||||
kind: 'hook',
|
||||
name: `${h.event}${h.matcher ? `:${h.matcher}` : ''}`,
|
||||
source: h.source || h.sourcePath || 'unknown',
|
||||
estimated_tokens: h.estimatedTokens || 0,
|
||||
});
|
||||
}
|
||||
|
||||
sources.sort((a, b) => b.estimated_tokens - a.estimated_tokens);
|
||||
const total = sources.reduce((s, x) => s + (x.estimated_tokens || 0), 0);
|
||||
return { sources, total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Distribute the cascade-level estimated tokens across the individual files
|
||||
* proportional to their byte size. claudeMd.estimatedTokens is computed for
|
||||
* the cascade as a whole, but for ranking we want per-file figures.
|
||||
*/
|
||||
function estimateClaudeMdEntryTokens(file, activeConfig) {
|
||||
const totalBytes = activeConfig.claudeMd?.totalBytes || 0;
|
||||
const totalTokens = activeConfig.claudeMd?.estimatedTokens || 0;
|
||||
if (totalBytes === 0 || totalTokens === 0) return 0;
|
||||
const share = (file.bytes || 0) / totalBytes;
|
||||
return Math.round(totalTokens * share);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
let targetPath = '.';
|
||||
let outputFile = null;
|
||||
let jsonMode = false;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--json') jsonMode = true;
|
||||
else if (args[i] === '--output-file' && args[i + 1]) outputFile = args[++i];
|
||||
else if (!args[i].startsWith('-')) targetPath = args[i];
|
||||
}
|
||||
|
||||
const absPath = resolve(targetPath);
|
||||
try {
|
||||
const s = await stat(absPath);
|
||||
if (!s.isDirectory()) {
|
||||
process.stderr.write(`Error: ${absPath} is not a directory\n`);
|
||||
process.exit(3);
|
||||
}
|
||||
} catch {
|
||||
process.stderr.write(`Error: path does not exist: ${absPath}\n`);
|
||||
process.exit(3);
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
const activeConfig = await readActiveConfig(absPath, { verbose: true });
|
||||
const manifest = buildManifest(activeConfig);
|
||||
|
||||
const output = {
|
||||
meta: {
|
||||
tool: 'config-audit:manifest',
|
||||
repoPath: absPath,
|
||||
generatedAt: new Date().toISOString(),
|
||||
durationMs: Date.now() - start,
|
||||
},
|
||||
sources: manifest.sources,
|
||||
total: manifest.total,
|
||||
};
|
||||
|
||||
const json = JSON.stringify(output, null, 2);
|
||||
|
||||
if (outputFile) {
|
||||
await writeFile(outputFile, json, 'utf-8');
|
||||
}
|
||||
|
||||
if (jsonMode || !outputFile) {
|
||||
process.stdout.write(json + '\n');
|
||||
}
|
||||
}
|
||||
|
||||
const isDirectRun = process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname);
|
||||
if (isDirectRun) {
|
||||
main().catch(err => {
|
||||
process.stderr.write(`Fatal: ${err.message}\n`);
|
||||
process.exit(3);
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue