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:
co-authored by
Claude Opus 5
parent
49c11fc2e0
commit
b37f191f5a
@@ -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 (0–1). */
|
||||
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(' ') || '-';
|
||||
}
|
||||
Reference in New Issue
Block a user