feat(mcp): per-file allocation diagnostic for explore (CG-4)

How codegraph_explore divides its byte envelope among files was
unobservable — you could read a response and guess, but not say "this
file took 16% and that one took 20%." Nothing else in the budget-
allocation epic is measurable without that.

CODEGRAPH_EXPLORE_DEBUG now emits one report per explore call (stderr
table, stderr JSON, or a JSONL sidecar path). Per file: relevance score,
graph mass, term hits, ranking flags, render mode, bytes allocated vs
delivered, both shares, and whether it was clipped — plus why a ranked
candidate never rendered. Totals cover envelope vs maxOutputChars vs the
hard ceiling, the source/meta split, the selection funnel, and the score
floor and relevance-gate thresholds applied.

Allocated and delivered are reported separately on purpose: they diverge
exactly when the 25K ceiling truncates, and conflating them is how a
dropped trailing file goes unnoticed.

Off by default and byte-identical when off — it ships in the product
binary, and a diagnostic that perturbs the response by one byte would
invalidate every A/B taken with it on. ExploreDiagnostics.start() returns
null unless the env var is set, so every call site is a `diag?.` no-op.

Baseline recorded in docs/design/explore-budget-allocation.md: on this
repo, src/mcp/tools.ts gets 15.8% of the envelope while three weakly-
relevant agent-eval scripts take 61% between them — despite tools.ts
carrying 5.4x the score and 2.6x the graph mass of any of them. Small
files ship whole; the large answer file is clipped at maxCharsPerFile.
Rank ordering is correct and buys nothing. The loop also allocated 23,193
chars against an 18,000 budget.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Colby McHenry
2026-08-03 22:51:18 -05:00
co-authored by Claude Opus 5
parent 49c11fc2e0
commit b37f191f5a
4 changed files with 990 additions and 6 deletions
+528
View File
@@ -0,0 +1,528 @@
/**
* Per-file allocation diagnostic for `codegraph_explore` (CG-4).
*
* The explore response is a fixed byte envelope (`budget.maxOutputChars`, hard-
* capped at 25K so the host never externalizes the result). WHICH files fill it,
* and in what proportion, is decided by a long chain of gates, tiers and caps
* spread across `handleExplore`. That chain is currently unobservable: you can
* read the output and guess, but you cannot say "this file took 16% of the
* envelope and that one took 21%" without hand-counting.
*
* This module is the instrument. Enabled by `CODEGRAPH_EXPLORE_DEBUG`, it
* records, for one explore call:
* - per candidate file: relevance score, graph (RWR) mass, distinct query-term
* hits, ranking flags, render mode, bytes of source actually emitted, that
* file's share of the final envelope, whether it was clipped, whether it
* carries a flow-spine symbol — and for the ones that didn't render, why;
* - totals: envelope vs `maxOutputChars` vs the hard ceiling, source bytes vs
* meta-text overhead, files considered at each filter stage, and the score
* floor / relevance-gate thresholds that were applied.
*
* HARD CONSTRAINT — this ships in the product binary: when the env var is unset
* the diagnostic must not exist. `start()` returns `null`, every call site is a
* `diag?.` no-op, and the agent-facing response is byte-identical. The
* diagnostic never mutates render state, and every method is wrapped so a bug in
* here can never fail an explore call.
*
* Sinks (value of `CODEGRAPH_EXPLORE_DEBUG`):
* `1` / `true` / `on` / `yes` / `stderr` → human-readable table on stderr
* `json` → one JSON object on stderr
* anything else → treated as a path; one JSON object
* per line appended (JSONL sidecar)
*/
import { appendFileSync } from 'fs';
/** How a file's source was rendered into the response. */
export type ExploreRenderMode =
| 'whole' // whole-file window
| 'clusters' // ranked contiguous clusters
| 'focused' // per-symbol view, named/spine bodies full
| 'skeleton' // per-symbol view, signatures only
| 'stale-omitted' // drifted on disk; source deliberately withheld
| 'dropped'; // rendered into `lines` but cut by the final hard ceiling
/** Why a ranked candidate never reached the output. */
export type ExploreSkipReason =
| 'max-files' // maxFiles reached before this file
| 'budget-90pct' // incidental file past the 90%-of-budget soft stop
| 'budget-whole-file' // incidental whole-file render wouldn't fit
| 'budget-clusters' // incidental cluster render wouldn't fit
| 'unreadable' // outside root, missing, or read error
| 'no-ranges'; // no renderable line ranges in this file
/** Ranking inputs for one candidate file, captured before the render loop. */
export interface ExploreCandidateMeta {
rank: number;
score: number;
graphScore: number;
termHits: number;
nodes: number;
named: boolean;
central: boolean;
entry: boolean;
spine: boolean;
lowValue: boolean;
generated: boolean;
}
interface FileRecord extends ExploreCandidateMeta {
path: string;
render?: ExploreRenderMode;
/** Source chars the render loop handed to `lines` (pre-final-truncation). */
emittedChars: number;
/** Source chars present in the FINAL text — authoritative, truncation-aware. */
finalChars: number;
/** Share of the DELIVERED envelope, as a fraction (01). */
share: number;
/** Share of what the render loop ALLOCATED, before the hard-ceiling cut. */
allocatedShare: number;
clipped: boolean;
skipped?: ExploreSkipReason;
}
interface StageCounts {
/** Files with at least one gathered node. */
grouped: number;
/** Survived the `group.score >= scoreFloor` filter. */
pastScoreFloor: number;
/** Survived the test/spec/icon/i18n hard-exclude. */
pastLowValueFilter: number;
/** Survived the graph-relevance gate. */
pastRelevanceGate: number;
}
/** Budget fields the diagnostic reports. Structural, to avoid a cyclic import. */
interface BudgetShape {
maxOutputChars: number;
maxCharsPerFile: number;
defaultMaxFiles: number;
}
/** One file's line in the report. Also the JSONL sidecar's per-file shape. */
export interface ExploreDiagnosticFile extends ExploreCandidateMeta {
path: string;
render: ExploreRenderMode | null;
skipped: ExploreSkipReason | null;
clipped: boolean;
emittedChars: number;
finalChars: number;
share: number;
allocatedShare: number;
}
/** The full report — one per explore call, JSON-serialized to the sink. */
export interface ExploreDiagnosticReport {
tool: 'codegraph_explore';
query: string;
projectRoot: string;
indexedFileCount: number;
note?: string;
budget: {
maxOutputChars: number;
maxCharsPerFile: number;
maxFiles: number;
hardCeiling: number;
};
envelope: {
/** Chars actually returned to the agent (post-truncation). */
chars: number;
/** Chars the render loop produced, BEFORE the hard-ceiling cut. */
allocatedChars: number;
overBudget: boolean;
truncated: boolean;
sourceChars: number;
sourceShare: number;
metaChars: number;
metaShare: number;
};
selection: {
scoreFloor: number;
maxGraph: number;
graphGateThreshold: number;
graphGateApplied: boolean;
filesGrouped: number;
filesPastScoreFloor: number;
filesPastLowValueFilter: number;
filesRanked: number;
filesRenderedByLoop: number;
filesInFinalOutput: number;
};
files: ExploreDiagnosticFile[];
}
type Sink =
| { kind: 'stderr'; json: boolean }
| { kind: 'file'; path: string };
const OFF = new Set(['', '0', 'false', 'off', 'no']);
const STDERR_TABLE = new Set(['1', 'true', 'on', 'yes', 'stderr']);
/**
* Resolve the sink from the environment. `null` means the diagnostic is off —
* read per call (not memoized) so a test can toggle it between invocations.
*/
function resolveSink(): Sink | null {
const raw = process.env.CODEGRAPH_EXPLORE_DEBUG;
if (raw === undefined) return null;
const value = raw.trim();
const lower = value.toLowerCase();
if (OFF.has(lower)) return null;
if (STDERR_TABLE.has(lower)) return { kind: 'stderr', json: false };
if (lower === 'json') return { kind: 'stderr', json: true };
return { kind: 'file', path: value };
}
const num = (n: number) => Math.round(n).toLocaleString('en-US');
const pct = (f: number) => `${(f * 100).toFixed(1)}%`;
export class ExploreDiagnostics {
private readonly files = new Map<string, FileRecord>();
private readonly stages: StageCounts = {
grouped: 0, pastScoreFloor: 0, pastLowValueFilter: 0, pastRelevanceGate: 0,
};
private scoreFloor = 0;
private maxGraph = 0;
private graphGateThreshold = 0;
private graphGateApplied = false;
private note = '';
private constructor(
private readonly sink: Sink,
private readonly query: string,
private readonly projectRoot: string,
private readonly budget: BudgetShape,
private readonly maxFiles: number,
private readonly indexedFileCount: number,
) {}
/**
* Returns `null` when `CODEGRAPH_EXPLORE_DEBUG` is unset/off — the whole
* instrument then costs one env read per explore call and nothing else.
*/
static start(
query: string,
projectRoot: string,
budget: BudgetShape,
maxFiles: number,
indexedFileCount: number,
): ExploreDiagnostics | null {
try {
const sink = resolveSink();
if (!sink) return null;
return new ExploreDiagnostics(sink, query, projectRoot, budget, maxFiles, indexedFileCount);
} catch {
return null;
}
}
/** Candidate count after the initial `group.score >= floor` filter. */
setScoreFloor(floor: number, grouped: number, kept: number): void {
this.scoreFloor = floor;
this.stages.grouped = grouped;
this.stages.pastScoreFloor = kept;
this.stages.pastLowValueFilter = kept;
this.stages.pastRelevanceGate = kept;
}
/** Candidate count after the test/spec/icon/i18n hard-exclude. */
setLowValueFiltered(kept: number): void {
this.stages.pastLowValueFilter = kept;
this.stages.pastRelevanceGate = kept;
}
/** Graph-relevance gate: threshold, whether it actually pruned, what survived. */
setRelevanceGate(maxGraph: number, threshold: number, applied: boolean, kept: number): void {
this.maxGraph = maxGraph;
this.graphGateThreshold = threshold;
this.graphGateApplied = applied;
this.stages.pastRelevanceGate = kept;
}
/** Record one ranked candidate's scoring inputs, in final sort order. */
noteCandidate(path: string, meta: ExploreCandidateMeta): void {
this.files.set(path, {
path, ...meta,
emittedChars: 0, finalChars: 0, share: 0, allocatedShare: 0, clipped: false,
});
}
/** A candidate rendered source into the response. */
recordRender(path: string, render: ExploreRenderMode, sourceChars: number, clipped: boolean): void {
const rec = this.files.get(path);
if (!rec) return;
rec.render = render;
rec.emittedChars = sourceChars;
rec.clipped = clipped;
rec.skipped = undefined;
}
/**
* A candidate was passed over before rendering. First reason wins — the
* blanket `max-files` sweep must not overwrite a file's specific reason.
*/
recordSkip(path: string, reason: ExploreSkipReason): void {
const rec = this.files.get(path);
if (!rec || rec.render || rec.skipped) return;
rec.skipped = reason;
}
/** Explore returned early (no subgraph). Emits a minimal record. */
finishEmpty(reason: string): void {
this.note = reason;
this.emit(this.buildReport('', 0, 0, 0));
}
/**
* Final pass: attribute the FINAL text's bytes back to files (so the hard
* ceiling's truncation is reflected in what each file actually delivered),
* then emit.
*
* `allocatedChars` is the pre-truncation length — the size the render loop
* *chose*. Reporting both is the point: the allocator's decision and the
* agent's delivered payload diverge exactly when the ceiling cuts, and
* conflating them is how a dropped trailing file goes unnoticed.
*/
finish(finalText: string, allocatedChars: number, hardCeiling: number, filesIncluded: number): void {
try {
const perFile = attributeSourceBytes(finalText);
const envelope = finalText.length;
for (const rec of this.files.values()) {
rec.finalChars = perFile.get(rec.path) ?? 0;
rec.share = envelope > 0 ? rec.finalChars / envelope : 0;
rec.allocatedShare = allocatedChars > 0 ? rec.emittedChars / allocatedChars : 0;
// Rendered into `lines` but absent from the final text → the hard
// ceiling dropped its whole section.
if (rec.render && rec.render !== 'stale-omitted' && rec.finalChars === 0) {
rec.render = 'dropped';
rec.clipped = true;
}
}
this.emit(this.buildReport(finalText, allocatedChars, hardCeiling, filesIncluded));
} catch {
// A diagnostic must never fail an explore call.
}
}
private buildReport(
finalText: string,
allocatedChars: number,
hardCeiling: number,
filesIncluded: number,
): ExploreDiagnosticReport {
const envelope = finalText.length;
const records = [...this.files.values()];
const rendered = records.filter((r) => r.finalChars > 0);
const sourceChars = rendered.reduce((s, r) => s + r.finalChars, 0);
return {
tool: 'codegraph_explore',
query: this.query,
projectRoot: this.projectRoot,
indexedFileCount: this.indexedFileCount,
note: this.note || undefined,
budget: {
maxOutputChars: this.budget.maxOutputChars,
maxCharsPerFile: this.budget.maxCharsPerFile,
maxFiles: this.maxFiles,
hardCeiling,
},
envelope: {
chars: envelope,
allocatedChars,
overBudget: allocatedChars > this.budget.maxOutputChars,
truncated: allocatedChars > hardCeiling,
sourceChars,
sourceShare: envelope > 0 ? sourceChars / envelope : 0,
metaChars: envelope - sourceChars,
metaShare: envelope > 0 ? (envelope - sourceChars) / envelope : 0,
},
selection: {
scoreFloor: this.scoreFloor,
maxGraph: this.maxGraph,
graphGateThreshold: this.graphGateThreshold,
graphGateApplied: this.graphGateApplied,
filesGrouped: this.stages.grouped,
filesPastScoreFloor: this.stages.pastScoreFloor,
filesPastLowValueFilter: this.stages.pastLowValueFilter,
filesRanked: this.stages.pastRelevanceGate,
filesRenderedByLoop: filesIncluded,
filesInFinalOutput: rendered.length,
},
files: records
.slice()
.sort((a, b) => b.emittedChars - a.emittedChars || b.finalChars - a.finalChars || a.rank - b.rank)
.map((r) => ({
path: r.path,
rank: r.rank,
score: r.score,
graphScore: round6(r.graphScore),
termHits: r.termHits,
nodes: r.nodes,
named: r.named,
central: r.central,
entry: r.entry,
spine: r.spine,
lowValue: r.lowValue,
generated: r.generated,
render: r.render ?? null,
skipped: r.skipped ?? null,
clipped: r.clipped,
emittedChars: r.emittedChars,
finalChars: r.finalChars,
share: round6(r.share),
allocatedShare: round6(r.allocatedShare),
})),
};
}
private emit(report: ExploreDiagnosticReport): void {
try {
if (this.sink.kind === 'file') {
appendFileSync(this.sink.path, JSON.stringify(report) + '\n', 'utf-8');
return;
}
if (this.sink.json) {
process.stderr.write(JSON.stringify(report, null, 2) + '\n');
return;
}
process.stderr.write(renderTable(report) + '\n');
} catch {
// Unwritable sidecar / closed stderr must not fail the explore call.
}
}
}
const round6 = (n: number) => Math.round(n * 1e6) / 1e6;
/**
* Attribute the final response's source bytes back to files by walking the
* rendered markdown: a ``**`path`**`` section header followed by a fenced code
* block. Reading the FINAL text (rather than trusting the render loop's running
* total) is what makes the numbers truthful — it accounts for the hard-ceiling
* truncation that can drop whole trailing sections after they were "emitted".
*
* Line numbering is on by default, so a source line that is itself a ``` fence
* arrives as `42\t```` and cannot close the block early.
*/
export function attributeSourceBytes(finalText: string): Map<string, number> {
const out = new Map<string, number>();
if (!finalText) return out;
const lines = finalText.split('\n');
let current: string | null = null;
let inFence = false;
let acc: string[] = [];
const flush = () => {
if (current && acc.length > 0) {
out.set(current, (out.get(current) ?? 0) + acc.join('\n').length);
}
acc = [];
};
for (const line of lines) {
if (!inFence) {
const header = /^\*\*`([^`]+)`\*\*/.exec(line);
if (header) {
current = header[1]!;
continue;
}
if (current && line.startsWith('```')) {
inFence = true;
continue;
}
continue;
}
if (line === '```') {
inFence = false;
flush();
continue;
}
acc.push(line);
}
// Unterminated fence (final-ceiling truncation cut mid-block): count it.
if (inFence) flush();
return out;
}
/** Human-readable stderr rendering of the JSON report. */
export function renderTable(report: ExploreDiagnosticReport): string {
const { budget, envelope: env, selection: sel, files } = report;
const out: string[] = [];
out.push('');
out.push(`codegraph explore diagnostic — "${report.query}"`);
out.push(` project ${report.projectRoot} · ${num(report.indexedFileCount)} files indexed`);
if (report.note) out.push(` note: ${report.note}`);
out.push(
` envelope ${num(env.chars)} chars delivered · ${num(env.allocatedChars)} allocated` +
` of ${num(budget.maxOutputChars)} budget (hard ceiling ${num(budget.hardCeiling)})` +
`${env.overBudget ? ' [over budget]' : ''}${env.truncated ? ' [TRUNCATED]' : ''}`,
);
out.push(
` source ${num(env.sourceChars)} (${pct(env.sourceShare)})` +
` · meta ${num(env.metaChars)} (${pct(env.metaShare)})` +
` · per-file cap ${num(budget.maxCharsPerFile)}`,
);
out.push(
` files ${num(sel.filesGrouped)} grouped` +
`${num(sel.filesPastScoreFloor)} past score floor (>=${sel.scoreFloor})` +
`${num(sel.filesPastLowValueFilter)} past low-value filter` +
`${num(sel.filesRanked)} past relevance gate` +
`${num(sel.filesInFinalOutput)} in output (maxFiles ${num(budget.maxFiles)})`,
);
out.push(
` relevance gate ${sel.graphGateApplied ? 'applied' : 'not applied'}` +
` at graph >= ${sel.graphGateThreshold.toFixed(5)} (6% of max ${sel.maxGraph.toFixed(5)})`,
);
out.push('');
// Allocated (not delivered) is the allocator's own decision — the number the
// budget work is about. Delivered is what the agent got. They differ only
// when the ceiling truncated; showing both makes that divergence obvious.
const shown = files.filter((f) => f.emittedChars > 0 || f.finalChars > 0);
if (shown.length > 0) {
out.push(' # alloc% deliv% bytes score graph hits flags render file');
for (const f of shown) {
out.push(
' ' +
String(f.rank).padStart(2) + ' ' +
pct(f.allocatedShare).padStart(6) + ' ' +
pct(f.share).padStart(6) + ' ' +
num(f.emittedChars).padStart(7) + ' ' +
String(f.score).padStart(5) + ' ' +
f.graphScore.toFixed(5).padStart(7) + ' ' +
String(f.termHits).padStart(4) + ' ' +
flagString(f).padEnd(19) + ' ' +
((f.render ?? '-') + (f.clipped ? '*' : '')).padEnd(9) + ' ' +
f.path,
);
}
out.push(' (bytes = source allocated by the render loop; deliv% = 0 means the hard ceiling dropped the section)');
out.push(' (* = clipped: some source in this file was elided, windowed, or its section dropped)');
} else {
out.push(' (no file source in the final output)');
}
const skipped = files.filter((f) => f.emittedChars === 0 && f.finalChars === 0);
if (skipped.length > 0) {
out.push('');
out.push(' ranked but never rendered:');
for (const f of skipped.slice(0, 15)) {
out.push(
` #${String(f.rank).padStart(2)} ${f.path}${f.skipped ?? f.render ?? 'not reached'}` +
` (score ${f.score}, graph ${f.graphScore.toFixed(5)}, hits ${f.termHits})`,
);
}
if (skipped.length > 15) out.push(` … and ${skipped.length - 15} more`);
}
return out.join('\n');
}
function flagString(f: ExploreDiagnosticFile): string {
const flags: string[] = [];
if (f.named) flags.push('named');
if (f.entry) flags.push('entry');
if (f.central) flags.push('central');
if (f.spine) flags.push('spine');
if (f.lowValue) flags.push('low-value');
if (f.generated) flags.push('generated');
return flags.join(' ') || '-';
}
+66 -6
View File
@@ -42,6 +42,7 @@ import { clamp, validatePathWithinRoot, validateProjectPath, isConfigLeafNode, C
import { isGeneratedFile } from '../extraction/generated-detection';
import { scanDynamicDispatch } from './dynamic-boundaries';
import { getUpdateNotice } from '../upgrade/update-check';
import { ExploreDiagnostics } from './explore-diagnostics';
/**
* An expected, recoverable "codegraph can't serve this" condition — most
@@ -2630,13 +2631,20 @@ export class ToolHandler {
// largest-tier defaults if stats aren't available, which preserves
// pre-#185 behavior for callers that hit the rare stats failure.
let budget: ExploreOutputBudget;
let indexedFileCount = -1;
try {
budget = getExploreOutputBudget(cg.getStats().fileCount);
indexedFileCount = cg.getStats().fileCount;
budget = getExploreOutputBudget(indexedFileCount);
} catch {
budget = getExploreOutputBudget(Infinity);
}
const maxFiles = clamp((args.maxFiles as number) || budget.defaultMaxFiles, 1, 20);
// Per-file allocation diagnostic (CG-4). `null` unless CODEGRAPH_EXPLORE_DEBUG
// is set — every `diag?.` below is then a no-op and the response is
// byte-identical. It only OBSERVES: it must never feed back into rendering.
const diag = ExploreDiagnostics.start(query, projectRoot, budget, maxFiles, indexedFileCount);
// Step 1: Find relevant context with generous parameters.
// Use a large maxNodes budget — explore has its own 35k char output limit
// that prevents context bloat, so more nodes just means better coverage
@@ -2649,6 +2657,7 @@ export class ToolHandler {
});
if (subgraph.nodes.size === 0) {
diag?.finishEmpty('no relevant code found — empty subgraph');
return this.textResult(`No relevant code found for "${query}"`);
}
@@ -2913,7 +2922,9 @@ export class ToolHandler {
}
// Only include files that have entry points or nodes directly connected to entry points
let relevantFiles = [...fileGroups.entries()].filter(([, group]) => group.score >= 3);
const SCORE_FLOOR = 3;
let relevantFiles = [...fileGroups.entries()].filter(([, group]) => group.score >= SCORE_FLOOR);
diag?.setScoreFloor(SCORE_FLOOR, fileGroups.size, relevantFiles.length);
// Extract query terms for relevance checking
const queryTerms = query.toLowerCase().split(/\s+/).filter(t => t.length >= 3);
@@ -2955,6 +2966,7 @@ export class ToolHandler {
relevantFiles = nonLow;
}
}
diag?.setLowValueFiltered(relevantFiles.length);
}
// Secondary signal: how many DISTINCT query terms each file matches (path +
@@ -3057,6 +3069,9 @@ export class ToolHandler {
|| (fileTermHits.get(fp) ?? 0) >= 2,
);
if (gated.length >= 2) relevantFiles = gated;
diag?.setRelevanceGate(maxGraph, maxGraph * 0.06, gated.length >= 2, relevantFiles.length);
} else {
diag?.setRelevanceGate(maxGraph, 0, false, relevantFiles.length);
}
// Sort files: graph-central first, then distinct-term match, then the
@@ -3202,6 +3217,27 @@ export class ToolHandler {
// off-spine peers skeletonize.
const flow = this.buildFlowFromNamedSymbols(cg, query);
// Snapshot every ranked candidate's scoring inputs, in final sort order, so
// the diagnostic can show what each file's share of the envelope was BOUGHT
// with (score, graph mass, term hits, flags) — not just what it cost.
if (diag) {
sortedFiles.forEach(([fp, group], i) => {
diag.noteCandidate(fp, {
rank: i + 1,
score: group.score,
graphScore: fileGraphScore.get(fp) ?? 0,
termHits: fileTermHits.get(fp) ?? 0,
nodes: group.nodes.length,
named: namedSeedFiles.has(fp),
central: centralFiles.has(fp),
entry: entryFiles.has(fp),
spine: group.nodes.some((n) => flow.pathNodeIds.has(n.id)),
lowValue: isLowValue(fp),
generated: isGeneratedFile(fp),
});
});
}
// Polymorphic-sibling detector for adaptive sizing. A class that implements/
// extends a supertype shared by >= MIN_SIBLINGS classes is one of many
// INTERCHANGEABLE implementations (OkHttp's 14 `: Interceptor` classes —
@@ -3278,7 +3314,10 @@ export class ToolHandler {
const staleOmitted: string[] = [];
for (const [filePath, group] of sortedFiles) {
if (filesIncluded >= maxFiles) break;
if (filesIncluded >= maxFiles) {
if (diag) for (const [fp] of sortedFiles) diag.recordSkip(fp, 'max-files');
break;
}
// A file DEFINES a named/spine symbol (the answer) vs merely references the
// flow. Past 90% budget, stop pulling INCIDENTAL files — but keep scanning
// for necessary ones, which render even past the cap (bounded by maxFiles).
@@ -3287,15 +3326,22 @@ export class ToolHandler {
// validate-logic file (Alamofire's Validation.swift).
const fileNecessary = group.nodes.some(n =>
entryNodeIds.has(n.id) || flow.pathNodeIds.has(n.id) || flow.uniqueNamedNodeIds.has(n.id));
if (!fileNecessary && totalChars > budget.maxOutputChars * 0.9) continue;
if (!fileNecessary && totalChars > budget.maxOutputChars * 0.9) {
diag?.recordSkip(filePath, 'budget-90pct');
continue;
}
const absPath = validatePathWithinRoot(projectRoot, filePath);
if (!absPath || !existsSync(absPath)) continue;
if (!absPath || !existsSync(absPath)) {
diag?.recordSkip(filePath, 'unreadable');
continue;
}
let fileContent: string;
try {
fileContent = readFileSync(absPath, 'utf-8');
} catch {
diag?.recordSkip(filePath, 'unreadable');
continue;
}
@@ -3419,6 +3465,8 @@ export class ToolHandler {
: 'skeleton (signatures only — codegraph_explore a name for its full body; do NOT Read)';
lines.push(fileSectionHeader(filePath, `${names} · ${tag}`), '', '```' + lang, skel.join('\n'), '```', '');
totalChars += skel.join('\n').length + 120;
// Always "clipped": the per-symbol view elides bodies by construction.
diag?.recordRender(filePath, bodyIds.size > 0 ? 'focused' : 'skeleton', skel.join('\n').length, true);
renderedFilePaths.push(filePath);
filesIncluded++;
continue;
@@ -3470,10 +3518,12 @@ export class ToolHandler {
// fit is skipped; a necessary one (below) renders in full. Half a file
// forces the Read this is meant to prevent.
anyFileTrimmed = true;
diag?.recordSkip(filePath, 'budget-whole-file');
continue;
}
lines.push(wholeHeader, '', '```' + lang, wholeSection, '```', '');
totalChars += wholeSection.length + 200;
diag?.recordRender(filePath, 'whole', wholeSection.length, false);
renderedFilePaths.push(filePath);
filesIncluded++;
if (fileStale) staleRendered.push(filePath);
@@ -3492,6 +3542,7 @@ export class ToolHandler {
'',
);
totalChars += 260;
diag?.recordRender(filePath, 'stale-omitted', 0, true);
continue;
}
@@ -3567,7 +3618,10 @@ export class ToolHandler {
ranges.sort((a, b) => a.start - b.start);
if (ranges.length === 0) continue;
if (ranges.length === 0) {
diag?.recordSkip(filePath, 'no-ranges');
continue;
}
const gapThreshold = budget.gapThreshold;
const clusters: Array<{ start: number; end: number; symbols: string[]; score: number; maxImportance: number; hasSpine: boolean; spineCallLine?: number }> = [];
@@ -3764,6 +3818,7 @@ export class ToolHandler {
// Keep scanning for necessary files (which bypass this cap and render in
// full, bounded by the hard ceiling).
anyFileTrimmed = true;
diag?.recordSkip(filePath, 'budget-clusters');
continue;
}
@@ -3775,6 +3830,7 @@ export class ToolHandler {
lines.push('');
totalChars += fileSection.length + 200;
diag?.recordRender(filePath, 'clusters', fileSection.length, chosenIndices.size < clusters.length);
renderedFilePaths.push(filePath);
filesIncluded++;
}
@@ -3896,6 +3952,10 @@ export class ToolHandler {
: `Found ${subgraph.nodes.size} symbol${subgraph.nodes.size === 1 ? '' : 's'} across ${fileGroups.size} file${fileGroups.size === 1 ? '' : 's'}.`;
finalText = finalText.replace(SUMMARY_SENTINEL, summaryLine);
// Emit the allocation diagnostic from the FINAL text, so per-file bytes and
// shares account for the hard-ceiling truncation above (CG-4).
diag?.finish(finalText, output.length, hardCeiling, filesIncluded);
return this.textResult(finalText);
}