config-audit/scanners/token-hotspots-cli.mjs
Kjell Tore Guttormsen de8a7b5d51 fix(scanners): stop discarding our own stdout when it is a pipe
process.exit() terminates immediately, but Node writes stdout asynchronously
when stdout is a pipe — everything still buffered is dropped. scan-orchestrator
measured 246 854 bytes to a file against 65 536 to a pipe (131 072 on another
run; the cut point is a flush race), so every machine consumer that pipes the
envelope got truncated, unparseable JSON. The failure reads like a corrupt file,
not like a cut-off, which is what made it survive this long. Reported by
org-ops, whose census pipes our output.

Closes the class rather than the one CLI where it was visible. campaign-cli,
campaign-export-cli, campaign-write-cli, knowledge-refresh-cli, drift-cli and
fix-cli all exited the same way on their success paths and were green only
because their payloads fit the pipe buffer today; size is not correctness. All
38 sites across 14 files now set process.exitCode and return, which is the
pattern self-audit.mjs already used.

Two contracts needed care rather than substitution: fail() is a never-returns
guard at ~25 call sites, so it throws a CliUsageError the top-level catch
renders with the identical "Error: " prefix and exit code 3; the path guards
needed an explicit return so main() stops instead of running on. Exit codes and
stderr text are unchanged, and the frozen v5.0.0 snapshots are untouched.

The class sweep is landed as a test, not as fourteen edits — it caught one site
this commit had missed. Suite 1443/0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8sS1DuDV6bUJcyumLwbvj
2026-07-31 21:40:15 +02:00

148 lines
4.8 KiB
JavaScript
Executable file

#!/usr/bin/env node
/**
* token-hotspots CLI — emit ranked token hotspots and prompt-cache pattern findings
* for a target repo path.
*
* Usage:
* node token-hotspots-cli.mjs [path] [--json] [--output-file <path>] [--global]
* [--with-telemetry-recipe] [--accurate-tokens]
*
* Exit codes: 0=ok, 3=unrecoverable error.
* Zero external dependencies.
*/
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { writeFile, readFile, stat } from 'node:fs/promises';
import { discoverConfigFiles } from './lib/file-discovery.mjs';
import { resetCounter } from './lib/output.mjs';
import { scan } from './token-hotspots.mjs';
import * as tokenizerApi from './lib/tokenizer-api.mjs';
import { humanizeFindings } from './lib/humanizer.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const TELEMETRY_RECIPE_PATH = resolve(__dirname, '..', 'knowledge', 'cache-telemetry-recipe.md');
const ACCURATE_TOKENS_SAMPLE_SIZE = 3;
async function calibrateAgainstApi(hotspots, apiKey) {
const sampled = hotspots.slice(0, ACCURATE_TOKENS_SAMPLE_SIZE);
let actualTokens = 0;
for (const hotspot of sampled) {
if (!hotspot?.path) continue;
let content;
try {
content = await readFile(hotspot.path, 'utf-8');
} catch {
continue;
}
const result = await tokenizerApi.callCountTokensApi(content, apiKey);
actualTokens += result.input_tokens;
}
return {
actual_tokens: actualTokens,
source: 'count_tokens_api',
sampled_hotspots: sampled.length,
};
}
async function main() {
const args = process.argv.slice(2);
let targetPath = '.';
let outputFile = null;
let jsonMode = false;
let rawMode = false;
let includeGlobal = false;
let withTelemetryRecipe = false;
let accurateTokens = false;
// Default ON for this live-cost scan: stale plugin-cache versions pollute the
// hotspot ranking with config that loads on zero turns. --no-exclude-cache
// restores the full walk. (B3)
let excludeCache = true;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--json') jsonMode = true;
else if (args[i] === '--raw') rawMode = true;
else if (args[i] === '--global') includeGlobal = true;
else if (args[i] === '--with-telemetry-recipe') withTelemetryRecipe = true;
else if (args[i] === '--accurate-tokens') accurateTokens = true;
else if (args[i] === '--exclude-cache') excludeCache = true;
else if (args[i] === '--no-exclude-cache') excludeCache = false;
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.exitCode = 3;
return;
}
} catch {
process.stderr.write(`Error: path does not exist: ${absPath}\n`);
process.exitCode = 3;
return;
}
resetCounter();
const discovery = await discoverConfigFiles(absPath, { includeGlobal, excludeCache });
const result = await scan(absPath, discovery);
const payload = {
scanner: result.scanner,
status: result.status,
files_scanned: result.files_scanned,
duration_ms: result.duration_ms,
total_estimated_tokens: result.total_estimated_tokens,
hotspots: result.hotspots,
findings: result.findings,
counts: result.counts,
};
if (withTelemetryRecipe) {
payload.telemetry_recipe_path = TELEMETRY_RECIPE_PATH;
}
if (accurateTokens) {
const apiKey = process.env.ANTHROPIC_API_KEY;
if (!apiKey || apiKey.length === 0) {
process.stderr.write('ANTHROPIC_API_KEY not set — skipping API calibration\n');
payload.calibration = { skipped: 'no-api-key' };
} else {
try {
payload.calibration = await calibrateAgainstApi(result.hotspots || [], apiKey);
} catch (err) {
// Error message is already key-masked by tokenizer-api.mjs.
process.stderr.write(`Calibration error: ${err.message}\n`);
payload.calibration = { skipped: 'api-error', error: err.message };
}
}
}
// Default mode humanizes payload.findings (NOT result.findings).
// --json and --raw bypass for v5.0.0 byte-equal output.
if (!jsonMode && !rawMode) {
payload.findings = humanizeFindings(payload.findings);
}
const json = JSON.stringify(payload, null, 2);
if (outputFile) {
await writeFile(outputFile, json, 'utf-8');
}
if (jsonMode || rawMode || !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.exitCode = 3;
});
}