explore-diagnostics.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613
  1. /**
  2. * Per-file allocation diagnostic for `codegraph_explore` (CG-4).
  3. *
  4. * The explore response is a fixed byte envelope (`budget.maxOutputChars`, hard-
  5. * capped at 25K so the host never externalizes the result). WHICH files fill it,
  6. * and in what proportion, is decided by a long chain of gates, tiers and caps
  7. * spread across `handleExplore`. That chain is currently unobservable: you can
  8. * read the output and guess, but you cannot say "this file took 16% of the
  9. * envelope and that one took 21%" without hand-counting.
  10. *
  11. * This module is the instrument. Enabled by `CODEGRAPH_EXPLORE_DEBUG`, it
  12. * records, for one explore call:
  13. * - per candidate file: relevance score, graph (RWR) mass, distinct query-term
  14. * hits, ranking flags, render mode, bytes of source actually emitted, that
  15. * file's share of the final envelope, whether it was clipped, whether it
  16. * carries a flow-spine symbol — and for the ones that didn't render, why;
  17. * - totals: envelope vs `maxOutputChars` vs the hard ceiling, source bytes vs
  18. * meta-text overhead, files considered at each filter stage, and the score
  19. * floor / relevance-gate thresholds that were applied.
  20. *
  21. * HARD CONSTRAINT — this ships in the product binary: when the env var is unset
  22. * the diagnostic must not exist. `start()` returns `null`, every call site is a
  23. * `diag?.` no-op, and the agent-facing response is byte-identical. The
  24. * diagnostic never mutates render state, and every method is wrapped so a bug in
  25. * here can never fail an explore call.
  26. *
  27. * Sinks (value of `CODEGRAPH_EXPLORE_DEBUG`):
  28. * `1` / `true` / `on` / `yes` / `stderr` → human-readable table on stderr
  29. * `json` → one JSON object on stderr
  30. * anything else → treated as a path; one JSON object
  31. * per line appended (JSONL sidecar)
  32. */
  33. import { appendFileSync } from 'fs';
  34. /** How a file's source was rendered into the response. */
  35. export type ExploreRenderMode =
  36. | 'whole' // whole-file window
  37. | 'clusters' // ranked contiguous clusters
  38. | 'focused' // per-symbol view, named/spine bodies full
  39. | 'skeleton' // per-symbol view, signatures only
  40. | 'stale-omitted' // drifted on disk; source deliberately withheld
  41. | 'dropped'; // rendered into `lines` but cut by the final hard ceiling
  42. /** Why a ranked candidate never reached the output. */
  43. export type ExploreSkipReason =
  44. | 'max-files' // maxFiles reached before this file
  45. | 'cliff' // below the relevance cliff — pointer, not bytes (CG-12)
  46. | 'budget-whole-file' // whole-file render wouldn't fit under the hard ceiling
  47. | 'budget-clusters' // cluster render wouldn't fit under the hard ceiling
  48. | 'unreadable' // outside root, missing, or read error
  49. | 'no-ranges'; // no renderable line ranges in this file
  50. /** Ranking inputs for one candidate file, captured before the render loop. */
  51. export interface ExploreCandidateMeta {
  52. rank: number;
  53. score: number;
  54. graphScore: number;
  55. termHits: number;
  56. nodes: number;
  57. named: boolean;
  58. central: boolean;
  59. entry: boolean;
  60. spine: boolean;
  61. lowValue: boolean;
  62. generated: boolean;
  63. /**
  64. * Multiplier `rankPenalty` applied to BOTH `score` and `graphScore` (1 = no
  65. * penalty). Generated and test/i18n files rank on discounted signals, so the
  66. * raw values are `score / penalty` — worth reporting, since "why did this
  67. * generated file lose?" is otherwise invisible in the numbers (CG-10).
  68. */
  69. penalty: number;
  70. /**
  71. * Which NodeKinds the file's matched symbols were, most-numerous first
  72. * (`function:4 constant:1`). The scoring is kind-weighted, so this is the
  73. * breakdown that explains a score — a file carried by one isolated `constant`
  74. * is the #1500 failure, and it is legible here at a glance.
  75. */
  76. kinds: string;
  77. }
  78. interface FileRecord extends ExploreCandidateMeta {
  79. path: string;
  80. /**
  81. * Chars this file was RESERVED by the proportional allocator (CG-12), before
  82. * it rendered anything. `0` = cliffed; `null` = never reached the allocator.
  83. * The gap between this and `emittedChars` is the whole story of a budget bug:
  84. * reserved-but-unspent means the file had nothing to say, spent-over-reserved
  85. * means an oversize first cluster or the whole-file grace overshot.
  86. */
  87. allowance: number | null;
  88. render?: ExploreRenderMode;
  89. /** Source chars the render loop handed to `lines` (pre-final-truncation). */
  90. emittedChars: number;
  91. /** Source chars present in the FINAL text — authoritative, truncation-aware. */
  92. finalChars: number;
  93. /** Share of the DELIVERED envelope, as a fraction (0–1). */
  94. share: number;
  95. /** Share of what the render loop ALLOCATED, before the hard-ceiling cut. */
  96. allocatedShare: number;
  97. clipped: boolean;
  98. skipped?: ExploreSkipReason;
  99. }
  100. /** Candidate counts down the selection pipeline, in the order it runs. */
  101. interface StageCounts {
  102. /** Files with at least one gathered node. */
  103. grouped: number;
  104. /** Survived the test/spec/icon/i18n hard-exclude. */
  105. pastLowValueFilter: number;
  106. /** Survived the `group.score >= scoreFloor` filter. */
  107. pastScoreFloor: number;
  108. /** Survived the graph-relevance gate. */
  109. pastRelevanceGate: number;
  110. }
  111. /** Budget fields the diagnostic reports. Structural, to avoid a cyclic import. */
  112. interface BudgetShape {
  113. maxOutputChars: number;
  114. maxCharsPerFile: number;
  115. defaultMaxFiles: number;
  116. }
  117. /** One file's line in the report. Also the JSONL sidecar's per-file shape. */
  118. export interface ExploreDiagnosticFile extends ExploreCandidateMeta {
  119. path: string;
  120. allowance: number | null;
  121. render: ExploreRenderMode | null;
  122. skipped: ExploreSkipReason | null;
  123. clipped: boolean;
  124. emittedChars: number;
  125. finalChars: number;
  126. share: number;
  127. allocatedShare: number;
  128. }
  129. /** The full report — one per explore call, JSON-serialized to the sink. */
  130. export interface ExploreDiagnosticReport {
  131. tool: 'codegraph_explore';
  132. query: string;
  133. projectRoot: string;
  134. indexedFileCount: number;
  135. note?: string;
  136. budget: {
  137. maxOutputChars: number;
  138. maxCharsPerFile: number;
  139. maxFiles: number;
  140. hardCeiling: number;
  141. };
  142. envelope: {
  143. /** Chars actually returned to the agent (post-truncation). */
  144. chars: number;
  145. /** Chars the render loop produced, BEFORE the hard-ceiling cut. */
  146. allocatedChars: number;
  147. overBudget: boolean;
  148. truncated: boolean;
  149. sourceChars: number;
  150. sourceShare: number;
  151. metaChars: number;
  152. metaShare: number;
  153. };
  154. selection: {
  155. scoreFloor: number;
  156. maxGraph: number;
  157. graphGateThreshold: number;
  158. graphGateApplied: boolean;
  159. filesGrouped: number;
  160. filesPastLowValueFilter: number;
  161. filesPastScoreFloor: number;
  162. filesRanked: number;
  163. filesRenderedByLoop: number;
  164. filesInFinalOutput: number;
  165. };
  166. /** The proportional split (CG-12): what each file was promised, and why. */
  167. allocation: {
  168. /** Chars divided among admitted files (envelope minus per-file overhead). */
  169. pool: number;
  170. /** Weight threshold the cliff fired at; 0 when nothing was cliffed. */
  171. cliffAt: number;
  172. /** Files given zero source — pointers in the not-shown list instead. */
  173. cliffed: string[];
  174. /** Sum of reservations. Must not exceed `pool`. */
  175. reserved: number;
  176. };
  177. files: ExploreDiagnosticFile[];
  178. }
  179. type Sink =
  180. | { kind: 'stderr'; json: boolean }
  181. | { kind: 'file'; path: string };
  182. const OFF = new Set(['', '0', 'false', 'off', 'no']);
  183. const STDERR_TABLE = new Set(['1', 'true', 'on', 'yes', 'stderr']);
  184. /**
  185. * Resolve the sink from the environment. `null` means the diagnostic is off —
  186. * read per call (not memoized) so a test can toggle it between invocations.
  187. */
  188. function resolveSink(): Sink | null {
  189. const raw = process.env.CODEGRAPH_EXPLORE_DEBUG;
  190. if (raw === undefined) return null;
  191. const value = raw.trim();
  192. const lower = value.toLowerCase();
  193. if (OFF.has(lower)) return null;
  194. if (STDERR_TABLE.has(lower)) return { kind: 'stderr', json: false };
  195. if (lower === 'json') return { kind: 'stderr', json: true };
  196. return { kind: 'file', path: value };
  197. }
  198. const num = (n: number) => Math.round(n).toLocaleString('en-US');
  199. const pct = (f: number) => `${(f * 100).toFixed(1)}%`;
  200. export class ExploreDiagnostics {
  201. private readonly files = new Map<string, FileRecord>();
  202. private readonly stages: StageCounts = {
  203. grouped: 0, pastScoreFloor: 0, pastLowValueFilter: 0, pastRelevanceGate: 0,
  204. };
  205. private scoreFloor = 0;
  206. private maxGraph = 0;
  207. private graphGateThreshold = 0;
  208. private graphGateApplied = false;
  209. private note = '';
  210. private allocPool = 0;
  211. private allocCliffAt = 0;
  212. private allocCliffed: string[] = [];
  213. private constructor(
  214. private readonly sink: Sink,
  215. private readonly query: string,
  216. private readonly projectRoot: string,
  217. private readonly budget: BudgetShape,
  218. private readonly maxFiles: number,
  219. private readonly indexedFileCount: number,
  220. ) {}
  221. /**
  222. * Returns `null` when `CODEGRAPH_EXPLORE_DEBUG` is unset/off — the whole
  223. * instrument then costs one env read per explore call and nothing else.
  224. */
  225. static start(
  226. query: string,
  227. projectRoot: string,
  228. budget: BudgetShape,
  229. maxFiles: number,
  230. indexedFileCount: number,
  231. ): ExploreDiagnostics | null {
  232. try {
  233. const sink = resolveSink();
  234. if (!sink) return null;
  235. return new ExploreDiagnostics(sink, query, projectRoot, budget, maxFiles, indexedFileCount);
  236. } catch {
  237. return null;
  238. }
  239. }
  240. /**
  241. * Candidate count after the test/spec/icon/i18n hard-exclude — the FIRST
  242. * selection stage, ahead of the score floor.
  243. */
  244. setLowValueFiltered(grouped: number, kept: number): void {
  245. this.stages.grouped = grouped;
  246. this.stages.pastLowValueFilter = kept;
  247. this.stages.pastScoreFloor = kept;
  248. this.stages.pastRelevanceGate = kept;
  249. }
  250. /** Candidate count after the `group.score >= floor` filter. */
  251. setScoreFloor(floor: number, kept: number): void {
  252. this.scoreFloor = floor;
  253. this.stages.pastScoreFloor = kept;
  254. this.stages.pastRelevanceGate = kept;
  255. }
  256. /** Graph-relevance gate: threshold, whether it actually pruned, what survived. */
  257. setRelevanceGate(maxGraph: number, threshold: number, applied: boolean, kept: number): void {
  258. this.maxGraph = maxGraph;
  259. this.graphGateThreshold = threshold;
  260. this.graphGateApplied = applied;
  261. this.stages.pastRelevanceGate = kept;
  262. }
  263. /** Record one ranked candidate's scoring inputs, in final sort order. */
  264. noteCandidate(path: string, meta: ExploreCandidateMeta): void {
  265. this.files.set(path, {
  266. path, ...meta, allowance: null,
  267. emittedChars: 0, finalChars: 0, share: 0, allocatedShare: 0, clipped: false,
  268. });
  269. }
  270. /**
  271. * Record the proportional split (CG-12), taken right after ranking and before
  272. * a single byte renders. Called once per explore.
  273. */
  274. setAllocation(
  275. allowances: ReadonlyMap<string, number>,
  276. cliffed: readonly string[],
  277. cliffAt: number,
  278. pool: number,
  279. ): void {
  280. this.allocPool = pool;
  281. this.allocCliffAt = cliffAt;
  282. this.allocCliffed = [...cliffed];
  283. for (const [path, chars] of allowances) {
  284. const rec = this.files.get(path);
  285. if (rec) rec.allowance = chars;
  286. }
  287. for (const path of cliffed) {
  288. const rec = this.files.get(path);
  289. if (rec) rec.allowance = 0;
  290. }
  291. }
  292. /** A candidate rendered source into the response. */
  293. recordRender(path: string, render: ExploreRenderMode, sourceChars: number, clipped: boolean): void {
  294. const rec = this.files.get(path);
  295. if (!rec) return;
  296. rec.render = render;
  297. rec.emittedChars = sourceChars;
  298. rec.clipped = clipped;
  299. rec.skipped = undefined;
  300. }
  301. /**
  302. * A candidate was passed over before rendering. First reason wins — the
  303. * blanket `max-files` sweep must not overwrite a file's specific reason.
  304. */
  305. recordSkip(path: string, reason: ExploreSkipReason): void {
  306. const rec = this.files.get(path);
  307. if (!rec || rec.render || rec.skipped) return;
  308. rec.skipped = reason;
  309. }
  310. /** Explore returned early (no subgraph). Emits a minimal record. */
  311. finishEmpty(reason: string): void {
  312. this.note = reason;
  313. this.emit(this.buildReport('', 0, 0, 0));
  314. }
  315. /**
  316. * Final pass: attribute the FINAL text's bytes back to files (so the hard
  317. * ceiling's truncation is reflected in what each file actually delivered),
  318. * then emit.
  319. *
  320. * `allocatedChars` is the pre-truncation length — the size the render loop
  321. * *chose*. Reporting both is the point: the allocator's decision and the
  322. * agent's delivered payload diverge exactly when the ceiling cuts, and
  323. * conflating them is how a dropped trailing file goes unnoticed.
  324. */
  325. finish(finalText: string, allocatedChars: number, hardCeiling: number, filesIncluded: number): void {
  326. try {
  327. const perFile = attributeSourceBytes(finalText);
  328. const envelope = finalText.length;
  329. for (const rec of this.files.values()) {
  330. rec.finalChars = perFile.get(rec.path) ?? 0;
  331. rec.share = envelope > 0 ? rec.finalChars / envelope : 0;
  332. rec.allocatedShare = allocatedChars > 0 ? rec.emittedChars / allocatedChars : 0;
  333. // Rendered into `lines` but absent from the final text → the hard
  334. // ceiling dropped its whole section.
  335. if (rec.render && rec.render !== 'stale-omitted' && rec.finalChars === 0) {
  336. rec.render = 'dropped';
  337. rec.clipped = true;
  338. }
  339. }
  340. this.emit(this.buildReport(finalText, allocatedChars, hardCeiling, filesIncluded));
  341. } catch {
  342. // A diagnostic must never fail an explore call.
  343. }
  344. }
  345. private buildReport(
  346. finalText: string,
  347. allocatedChars: number,
  348. hardCeiling: number,
  349. filesIncluded: number,
  350. ): ExploreDiagnosticReport {
  351. const envelope = finalText.length;
  352. const records = [...this.files.values()];
  353. const rendered = records.filter((r) => r.finalChars > 0);
  354. const sourceChars = rendered.reduce((s, r) => s + r.finalChars, 0);
  355. return {
  356. tool: 'codegraph_explore',
  357. query: this.query,
  358. projectRoot: this.projectRoot,
  359. indexedFileCount: this.indexedFileCount,
  360. note: this.note || undefined,
  361. budget: {
  362. maxOutputChars: this.budget.maxOutputChars,
  363. maxCharsPerFile: this.budget.maxCharsPerFile,
  364. maxFiles: this.maxFiles,
  365. hardCeiling,
  366. },
  367. envelope: {
  368. chars: envelope,
  369. allocatedChars,
  370. overBudget: allocatedChars > this.budget.maxOutputChars,
  371. truncated: allocatedChars > hardCeiling,
  372. sourceChars,
  373. sourceShare: envelope > 0 ? sourceChars / envelope : 0,
  374. metaChars: envelope - sourceChars,
  375. metaShare: envelope > 0 ? (envelope - sourceChars) / envelope : 0,
  376. },
  377. selection: {
  378. scoreFloor: this.scoreFloor,
  379. maxGraph: this.maxGraph,
  380. graphGateThreshold: this.graphGateThreshold,
  381. graphGateApplied: this.graphGateApplied,
  382. filesGrouped: this.stages.grouped,
  383. filesPastLowValueFilter: this.stages.pastLowValueFilter,
  384. filesPastScoreFloor: this.stages.pastScoreFloor,
  385. filesRanked: this.stages.pastRelevanceGate,
  386. filesRenderedByLoop: filesIncluded,
  387. filesInFinalOutput: rendered.length,
  388. },
  389. allocation: {
  390. pool: this.allocPool,
  391. cliffAt: round6(this.allocCliffAt),
  392. cliffed: [...this.allocCliffed],
  393. reserved: records.reduce((s, r) => s + (r.allowance ?? 0), 0),
  394. },
  395. files: records
  396. .slice()
  397. .sort((a, b) => b.emittedChars - a.emittedChars || b.finalChars - a.finalChars || a.rank - b.rank)
  398. .map((r) => ({
  399. path: r.path,
  400. rank: r.rank,
  401. score: r.score,
  402. graphScore: round6(r.graphScore),
  403. termHits: r.termHits,
  404. nodes: r.nodes,
  405. named: r.named,
  406. central: r.central,
  407. entry: r.entry,
  408. spine: r.spine,
  409. lowValue: r.lowValue,
  410. generated: r.generated,
  411. penalty: round6(r.penalty),
  412. kinds: r.kinds,
  413. allowance: r.allowance,
  414. render: r.render ?? null,
  415. skipped: r.skipped ?? null,
  416. clipped: r.clipped,
  417. emittedChars: r.emittedChars,
  418. finalChars: r.finalChars,
  419. share: round6(r.share),
  420. allocatedShare: round6(r.allocatedShare),
  421. })),
  422. };
  423. }
  424. private emit(report: ExploreDiagnosticReport): void {
  425. try {
  426. if (this.sink.kind === 'file') {
  427. appendFileSync(this.sink.path, JSON.stringify(report) + '\n', 'utf-8');
  428. return;
  429. }
  430. if (this.sink.json) {
  431. process.stderr.write(JSON.stringify(report, null, 2) + '\n');
  432. return;
  433. }
  434. process.stderr.write(renderTable(report) + '\n');
  435. } catch {
  436. // Unwritable sidecar / closed stderr must not fail the explore call.
  437. }
  438. }
  439. }
  440. const round6 = (n: number) => Math.round(n * 1e6) / 1e6;
  441. /**
  442. * Attribute the final response's source bytes back to files by walking the
  443. * rendered markdown: a ``**`path`**`` section header followed by a fenced code
  444. * block. Reading the FINAL text (rather than trusting the render loop's running
  445. * total) is what makes the numbers truthful — it accounts for the hard-ceiling
  446. * truncation that can drop whole trailing sections after they were "emitted".
  447. *
  448. * Line numbering is on by default, so a source line that is itself a ``` fence
  449. * arrives as `42\t```` and cannot close the block early.
  450. */
  451. export function attributeSourceBytes(finalText: string): Map<string, number> {
  452. const out = new Map<string, number>();
  453. if (!finalText) return out;
  454. const lines = finalText.split('\n');
  455. let current: string | null = null;
  456. let inFence = false;
  457. let acc: string[] = [];
  458. const flush = () => {
  459. if (current && acc.length > 0) {
  460. out.set(current, (out.get(current) ?? 0) + acc.join('\n').length);
  461. }
  462. acc = [];
  463. };
  464. for (const line of lines) {
  465. if (!inFence) {
  466. const header = /^\*\*`([^`]+)`\*\*/.exec(line);
  467. if (header) {
  468. current = header[1]!;
  469. continue;
  470. }
  471. if (current && line.startsWith('```')) {
  472. inFence = true;
  473. continue;
  474. }
  475. continue;
  476. }
  477. if (line === '```') {
  478. inFence = false;
  479. flush();
  480. continue;
  481. }
  482. acc.push(line);
  483. }
  484. // Unterminated fence (final-ceiling truncation cut mid-block): count it.
  485. if (inFence) flush();
  486. return out;
  487. }
  488. /** Human-readable stderr rendering of the JSON report. */
  489. export function renderTable(report: ExploreDiagnosticReport): string {
  490. const { budget, envelope: env, selection: sel, files } = report;
  491. const out: string[] = [];
  492. out.push('');
  493. out.push(`codegraph explore diagnostic — "${report.query}"`);
  494. out.push(` project ${report.projectRoot} · ${num(report.indexedFileCount)} files indexed`);
  495. if (report.note) out.push(` note: ${report.note}`);
  496. out.push(
  497. ` envelope ${num(env.chars)} chars delivered · ${num(env.allocatedChars)} allocated` +
  498. ` of ${num(budget.maxOutputChars)} budget (hard ceiling ${num(budget.hardCeiling)})` +
  499. `${env.overBudget ? ' [over budget]' : ''}${env.truncated ? ' [TRUNCATED]' : ''}`,
  500. );
  501. out.push(
  502. ` source ${num(env.sourceChars)} (${pct(env.sourceShare)})` +
  503. ` · meta ${num(env.metaChars)} (${pct(env.metaShare)})` +
  504. ` · per-file cap ${num(budget.maxCharsPerFile)}`,
  505. );
  506. out.push(
  507. ` files ${num(sel.filesGrouped)} grouped` +
  508. ` → ${num(sel.filesPastLowValueFilter)} past low-value filter` +
  509. ` → ${num(sel.filesPastScoreFloor)} past score floor (>=${sel.scoreFloor.toFixed(1)})` +
  510. ` → ${num(sel.filesRanked)} past relevance gate` +
  511. ` → ${num(sel.filesInFinalOutput)} in output (maxFiles ${num(budget.maxFiles)})`,
  512. );
  513. out.push(
  514. ` relevance gate ${sel.graphGateApplied ? 'applied' : 'not applied'}` +
  515. ` at graph >= ${sel.graphGateThreshold.toFixed(5)} (6% of max ${sel.maxGraph.toFixed(5)})`,
  516. );
  517. const alloc = report.allocation;
  518. out.push(
  519. ` allocation ${num(alloc.reserved)} reserved of ${num(alloc.pool)} pool` +
  520. ` · cliff at weight ${alloc.cliffAt.toFixed(2)}` +
  521. (alloc.cliffed.length > 0
  522. ? ` · ${alloc.cliffed.length} cliffed to pointers: ${alloc.cliffed.join(', ')}`
  523. : ' · nothing cliffed'),
  524. );
  525. out.push('');
  526. // Allocated (not delivered) is the allocator's own decision — the number the
  527. // budget work is about. Delivered is what the agent got. They differ only
  528. // when the ceiling truncated; showing both makes that divergence obvious.
  529. const shown = files.filter((f) => f.emittedChars > 0 || f.finalChars > 0);
  530. if (shown.length > 0) {
  531. out.push(' # alloc% deliv% bytes reserved score graph hits pen flags render file');
  532. for (const f of shown) {
  533. out.push(
  534. ' ' +
  535. String(f.rank).padStart(2) + ' ' +
  536. pct(f.allocatedShare).padStart(6) + ' ' +
  537. pct(f.share).padStart(6) + ' ' +
  538. num(f.emittedChars).padStart(7) + ' ' +
  539. (f.allowance === null ? '-' : num(f.allowance)).padStart(8) + ' ' +
  540. f.score.toFixed(1).padStart(5) + ' ' +
  541. f.graphScore.toFixed(5).padStart(7) + ' ' +
  542. String(f.termHits).padStart(4) + ' ' +
  543. f.penalty.toFixed(2).padStart(4) + ' ' +
  544. flagString(f).padEnd(19) + ' ' +
  545. ((f.render ?? '-') + (f.clipped ? '*' : '')).padEnd(9) + ' ' +
  546. f.path,
  547. );
  548. out.push(' kinds: ' + (f.kinds || '-'));
  549. }
  550. out.push(' (bytes = source allocated by the render loop; deliv% = 0 means the hard ceiling dropped the section)');
  551. out.push(' (* = clipped: some source in this file was elided, windowed, or its section dropped)');
  552. } else {
  553. out.push(' (no file source in the final output)');
  554. }
  555. const skipped = files.filter((f) => f.emittedChars === 0 && f.finalChars === 0);
  556. if (skipped.length > 0) {
  557. out.push('');
  558. out.push(' ranked but never rendered:');
  559. for (const f of skipped.slice(0, 15)) {
  560. out.push(
  561. ` #${String(f.rank).padStart(2)} ${f.path} — ${f.skipped ?? f.render ?? 'not reached'}` +
  562. ` (score ${f.score.toFixed(1)}, graph ${f.graphScore.toFixed(5)}, hits ${f.termHits},` +
  563. ` pen ${f.penalty.toFixed(2)}, ${flagString(f) || 'no flags'}, ${f.kinds || '-'})`,
  564. );
  565. }
  566. if (skipped.length > 15) out.push(` … and ${skipped.length - 15} more`);
  567. }
  568. return out.join('\n');
  569. }
  570. function flagString(f: ExploreDiagnosticFile): string {
  571. const flags: string[] = [];
  572. if (f.named) flags.push('named');
  573. if (f.entry) flags.push('entry');
  574. if (f.central) flags.push('central');
  575. if (f.spine) flags.push('spine');
  576. if (f.lowValue) flags.push('low-value');
  577. if (f.generated) flags.push('generated');
  578. return flags.join(' ') || '-';
  579. }