explore-diagnostics.ts 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772
  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. import type { ExploreProjectState } from './explore-session-state';
  35. /** How a file's source was rendered into the response. */
  36. export type ExploreRenderMode =
  37. | 'whole' // whole-file window
  38. | 'clusters' // ranked contiguous clusters
  39. | 'focused' // per-symbol view, named/spine bodies full
  40. | 'skeleton' // per-symbol view, signatures only
  41. | 'stale-omitted' // drifted on disk; source deliberately withheld
  42. | 'backref' // fully served by an earlier call this session (CG-18)
  43. | 'dropped'; // rendered into `lines` but cut by the final hard ceiling
  44. /** Why a ranked candidate never reached the output. */
  45. export type ExploreSkipReason =
  46. | 'max-files' // maxFiles reached before this file
  47. | 'cliff' // below the relevance cliff — pointer, not bytes (CG-12)
  48. | 'budget-whole-file' // whole-file render wouldn't fit under the hard ceiling
  49. | 'budget-clusters' // cluster render wouldn't fit under the hard ceiling
  50. | 'unreadable' // outside root, missing, or read error
  51. | 'no-ranges'; // no renderable line ranges in this file
  52. /** Ranking inputs for one candidate file, captured before the render loop. */
  53. export interface ExploreCandidateMeta {
  54. rank: number;
  55. score: number;
  56. graphScore: number;
  57. termHits: number;
  58. nodes: number;
  59. named: boolean;
  60. central: boolean;
  61. entry: boolean;
  62. spine: boolean;
  63. lowValue: boolean;
  64. generated: boolean;
  65. /**
  66. * Multiplier `rankPenalty` applied to BOTH `score` and `graphScore` (1 = no
  67. * penalty). Generated and test/i18n files rank on discounted signals, so the
  68. * raw values are `score / penalty` — worth reporting, since "why did this
  69. * generated file lose?" is otherwise invisible in the numbers (CG-10).
  70. */
  71. penalty: number;
  72. /**
  73. * Which NodeKinds the file's matched symbols were, most-numerous first
  74. * (`function:4 constant:1`). The scoring is kind-weighted, so this is the
  75. * breakdown that explains a score — a file carried by one isolated `constant`
  76. * is the #1500 failure, and it is legible here at a glance.
  77. */
  78. kinds: string;
  79. }
  80. interface FileRecord extends ExploreCandidateMeta {
  81. path: string;
  82. /**
  83. * Chars this file was RESERVED by the proportional allocator (CG-12), before
  84. * it rendered anything. `0` = cliffed; `null` = never reached the allocator.
  85. * The gap between this and `emittedChars` is the whole story of a budget bug:
  86. * reserved-but-unspent means the file had nothing to say, spent-over-reserved
  87. * means an oversize first cluster or the whole-file grace overshot — but read
  88. * `spendable` before calling it an overshoot, since inherited slack legitimately
  89. * lifts a file above its reservation.
  90. */
  91. allowance: number | null;
  92. /**
  93. * What the file could actually SPEND: its reservation plus the slack the
  94. * files above it left on the table (bounded by MAX_SHARE). Every render bound
  95. * reads this, not `allowance`, so it — not the reservation — is what an
  96. * overshoot is measured against. `null` until the render loop reaches the
  97. * file. Reporting only `allowance` makes an ordinary carry-forward look like
  98. * a file spending over its reservation.
  99. */
  100. spendable: number | null;
  101. render?: ExploreRenderMode;
  102. /**
  103. * Source chars this call did NOT re-send because an earlier call in the
  104. * session already did (CG-18). Reclaimed, not lost: it leaves through
  105. * `sourceSpent` (the carry-forward pool hands it to lower-ranked files) and,
  106. * for a fully back-referenced file, through the freed `maxFiles` slot. The
  107. * reallocation is legible as the difference between this file's
  108. * `allowance` and `emittedChars` against the files below it in the table.
  109. */
  110. dedupSavedChars: number;
  111. /** Line spans replaced by a back-reference. */
  112. dedupCovered: Array<[number, number]>;
  113. /** Source chars the render loop handed to `lines` (pre-final-truncation). */
  114. emittedChars: number;
  115. /** Source chars present in the FINAL text — authoritative, truncation-aware. */
  116. finalChars: number;
  117. /** Share of the DELIVERED envelope, as a fraction (0–1). */
  118. share: number;
  119. /** Share of what the render loop ALLOCATED, before the hard-ceiling cut. */
  120. allocatedShare: number;
  121. clipped: boolean;
  122. skipped?: ExploreSkipReason;
  123. }
  124. /** Candidate counts down the selection pipeline, in the order it runs. */
  125. interface StageCounts {
  126. /** Files with at least one gathered node. */
  127. grouped: number;
  128. /** Survived the test/spec/icon/i18n hard-exclude. */
  129. pastLowValueFilter: number;
  130. /** Survived the `group.score >= scoreFloor` filter. */
  131. pastScoreFloor: number;
  132. /** Survived the graph-relevance gate. */
  133. pastRelevanceGate: number;
  134. }
  135. /** Budget fields the diagnostic reports. Structural, to avoid a cyclic import. */
  136. interface BudgetShape {
  137. maxOutputChars: number;
  138. maxCharsPerFile: number;
  139. defaultMaxFiles: number;
  140. }
  141. /** One file's line in the report. Also the JSONL sidecar's per-file shape. */
  142. export interface ExploreDiagnosticFile extends ExploreCandidateMeta {
  143. path: string;
  144. allowance: number | null;
  145. /** Reservation + inherited slack — the bound the render paths actually use. */
  146. spendable: number | null;
  147. render: ExploreRenderMode | null;
  148. skipped: ExploreSkipReason | null;
  149. clipped: boolean;
  150. dedupSavedChars: number;
  151. dedupCovered: Array<[number, number]>;
  152. emittedChars: number;
  153. finalChars: number;
  154. share: number;
  155. allocatedShare: number;
  156. }
  157. /**
  158. * This session's explore history for this project, as of BEFORE the call being
  159. * reported (CG-17). Present only when the caller tracks session state — the CLI
  160. * and bare-handler callers don't, so it is absent there rather than zeroed.
  161. */
  162. export interface ExploreDiagnosticSession {
  163. /** 1-based index of THIS call within the session, for this project. */
  164. callIndex: number;
  165. /** Calls already served this session for this project. */
  166. priorCalls: number;
  167. /** Response chars already served this session for this project. */
  168. priorResponseChars: number;
  169. /** Files already served source this session, most-recent call first. */
  170. priorFiles: Array<{ path: string; ranges: Array<[number, number]>; bytes: number }>;
  171. }
  172. /** The full report — one per explore call, JSON-serialized to the sink. */
  173. export interface ExploreDiagnosticReport {
  174. tool: 'codegraph_explore';
  175. query: string;
  176. projectRoot: string;
  177. indexedFileCount: number;
  178. note?: string;
  179. /** Session-scoped call state (CG-17); absent when the caller tracks none. */
  180. session?: ExploreDiagnosticSession;
  181. budget: {
  182. maxOutputChars: number;
  183. maxCharsPerFile: number;
  184. maxFiles: number;
  185. hardCeiling: number;
  186. };
  187. envelope: {
  188. /** Chars actually returned to the agent (post-truncation). */
  189. chars: number;
  190. /** Chars the render loop produced, BEFORE the hard-ceiling cut. */
  191. allocatedChars: number;
  192. overBudget: boolean;
  193. truncated: boolean;
  194. sourceChars: number;
  195. sourceShare: number;
  196. metaChars: number;
  197. metaShare: number;
  198. };
  199. selection: {
  200. scoreFloor: number;
  201. maxGraph: number;
  202. graphGateThreshold: number;
  203. graphGateApplied: boolean;
  204. filesGrouped: number;
  205. filesPastLowValueFilter: number;
  206. filesPastScoreFloor: number;
  207. filesRanked: number;
  208. filesRenderedByLoop: number;
  209. filesInFinalOutput: number;
  210. };
  211. /**
  212. * Cross-call source dedup (CG-18): what this call did NOT re-send because an
  213. * earlier call in this session already sent it, and where those bytes went.
  214. * `savedChars` 0 with a non-empty session block means nothing overlapped.
  215. */
  216. dedup: {
  217. savedChars: number;
  218. backReferenced: string[];
  219. /** Files fully replaced by a pointer — each one also freed a `maxFiles` slot. */
  220. fullyBackReferenced: string[];
  221. };
  222. /** The proportional split (CG-12): what each file was promised, and why. */
  223. allocation: {
  224. /** Chars divided among admitted files (envelope minus per-file overhead). */
  225. pool: number;
  226. /** Weight threshold the cliff fired at; 0 when nothing was cliffed. */
  227. cliffAt: number;
  228. /** Files given zero source — pointers in the not-shown list instead. */
  229. cliffed: string[];
  230. /** Sum of reservations. Must not exceed `pool`. */
  231. reserved: number;
  232. };
  233. files: ExploreDiagnosticFile[];
  234. }
  235. type Sink =
  236. | { kind: 'stderr'; json: boolean }
  237. | { kind: 'file'; path: string };
  238. const OFF = new Set(['', '0', 'false', 'off', 'no']);
  239. const STDERR_TABLE = new Set(['1', 'true', 'on', 'yes', 'stderr']);
  240. /**
  241. * Resolve the sink from the environment. `null` means the diagnostic is off —
  242. * read per call (not memoized) so a test can toggle it between invocations.
  243. */
  244. function resolveSink(): Sink | null {
  245. const raw = process.env.CODEGRAPH_EXPLORE_DEBUG;
  246. if (raw === undefined) return null;
  247. const value = raw.trim();
  248. const lower = value.toLowerCase();
  249. if (OFF.has(lower)) return null;
  250. if (STDERR_TABLE.has(lower)) return { kind: 'stderr', json: false };
  251. if (lower === 'json') return { kind: 'stderr', json: true };
  252. return { kind: 'file', path: value };
  253. }
  254. const num = (n: number) => Math.round(n).toLocaleString('en-US');
  255. const pct = (f: number) => `${(f * 100).toFixed(1)}%`;
  256. export class ExploreDiagnostics {
  257. private readonly files = new Map<string, FileRecord>();
  258. private readonly stages: StageCounts = {
  259. grouped: 0, pastScoreFloor: 0, pastLowValueFilter: 0, pastRelevanceGate: 0,
  260. };
  261. private scoreFloor = 0;
  262. private maxGraph = 0;
  263. private graphGateThreshold = 0;
  264. private graphGateApplied = false;
  265. private note = '';
  266. private session: ExploreDiagnosticSession | undefined;
  267. private allocPool = 0;
  268. private allocCliffAt = 0;
  269. private allocCliffed: string[] = [];
  270. private constructor(
  271. private readonly sink: Sink,
  272. private readonly query: string,
  273. private readonly projectRoot: string,
  274. private readonly budget: BudgetShape,
  275. private readonly maxFiles: number,
  276. private readonly indexedFileCount: number,
  277. ) {}
  278. /**
  279. * Returns `null` when `CODEGRAPH_EXPLORE_DEBUG` is unset/off — the whole
  280. * instrument then costs one env read per explore call and nothing else.
  281. */
  282. static start(
  283. query: string,
  284. projectRoot: string,
  285. budget: BudgetShape,
  286. maxFiles: number,
  287. indexedFileCount: number,
  288. ): ExploreDiagnostics | null {
  289. try {
  290. const sink = resolveSink();
  291. if (!sink) return null;
  292. return new ExploreDiagnostics(sink, query, projectRoot, budget, maxFiles, indexedFileCount);
  293. } catch {
  294. return null;
  295. }
  296. }
  297. /**
  298. * Candidate count after the test/spec/icon/i18n hard-exclude — the FIRST
  299. * selection stage, ahead of the score floor.
  300. */
  301. setLowValueFiltered(grouped: number, kept: number): void {
  302. this.stages.grouped = grouped;
  303. this.stages.pastLowValueFilter = kept;
  304. this.stages.pastScoreFloor = kept;
  305. this.stages.pastRelevanceGate = kept;
  306. }
  307. /**
  308. * Record what this session had already been served for this project (CG-17),
  309. * so the report says which call in the session it is and what the earlier ones
  310. * cost. Read-only for now: nothing in the render loop consults it, which is
  311. * what keeps the response byte-identical at this stage.
  312. *
  313. * Files are listed most-recent call first and de-duplicated by path — the same
  314. * file re-served across calls is the pattern this instrument exists to make
  315. * visible, and its ranges are unioned so a glance shows what of it the agent
  316. * already holds.
  317. */
  318. noteSession(prior: ExploreProjectState | null): void {
  319. if (!prior) return;
  320. const byPath = new Map<string, { path: string; ranges: Array<[number, number]>; bytes: number }>();
  321. for (const call of [...prior.calls].reverse()) {
  322. for (const file of call.files) {
  323. const existing = byPath.get(file.path);
  324. const spans = file.ranges.map((r) => [r.start, r.end] as [number, number]);
  325. if (existing) {
  326. existing.ranges.push(...spans);
  327. existing.bytes += file.bytes;
  328. } else {
  329. byPath.set(file.path, { path: file.path, ranges: spans, bytes: file.bytes });
  330. }
  331. }
  332. }
  333. this.session = {
  334. callIndex: prior.callCount + 1,
  335. priorCalls: prior.callCount,
  336. priorResponseChars: prior.responseBytes,
  337. priorFiles: [...byPath.values()],
  338. };
  339. }
  340. /** Candidate count after the `group.score >= floor` filter. */
  341. setScoreFloor(floor: number, kept: number): void {
  342. this.scoreFloor = floor;
  343. this.stages.pastScoreFloor = kept;
  344. this.stages.pastRelevanceGate = kept;
  345. }
  346. /** Graph-relevance gate: threshold, whether it actually pruned, what survived. */
  347. setRelevanceGate(maxGraph: number, threshold: number, applied: boolean, kept: number): void {
  348. this.maxGraph = maxGraph;
  349. this.graphGateThreshold = threshold;
  350. this.graphGateApplied = applied;
  351. this.stages.pastRelevanceGate = kept;
  352. }
  353. /** Record one ranked candidate's scoring inputs, in final sort order. */
  354. noteCandidate(path: string, meta: ExploreCandidateMeta): void {
  355. this.files.set(path, {
  356. path, ...meta, allowance: null, spendable: null,
  357. dedupSavedChars: 0, dedupCovered: [],
  358. emittedChars: 0, finalChars: 0, share: 0, allocatedShare: 0, clipped: false,
  359. });
  360. }
  361. /**
  362. * Record the proportional split (CG-12), taken right after ranking and before
  363. * a single byte renders. Called once per explore.
  364. */
  365. setAllocation(
  366. allowances: ReadonlyMap<string, number>,
  367. cliffed: readonly string[],
  368. cliffAt: number,
  369. pool: number,
  370. ): void {
  371. this.allocPool = pool;
  372. this.allocCliffAt = cliffAt;
  373. this.allocCliffed = [...cliffed];
  374. for (const [path, chars] of allowances) {
  375. const rec = this.files.get(path);
  376. if (rec) rec.allowance = chars;
  377. }
  378. for (const path of cliffed) {
  379. const rec = this.files.get(path);
  380. if (rec) rec.allowance = 0;
  381. }
  382. }
  383. /**
  384. * What the render loop will let this file spend — reservation plus inherited
  385. * slack. Called once per file, before any of its render paths run.
  386. */
  387. recordSpendable(path: string, chars: number): void {
  388. const rec = this.files.get(path);
  389. if (rec) rec.spendable = chars;
  390. }
  391. /** A candidate rendered source into the response. */
  392. recordRender(path: string, render: ExploreRenderMode, sourceChars: number, clipped: boolean): void {
  393. const rec = this.files.get(path);
  394. if (!rec) return;
  395. rec.render = render;
  396. rec.emittedChars = sourceChars;
  397. rec.clipped = clipped;
  398. rec.skipped = undefined;
  399. }
  400. /**
  401. * Source this call withheld because the session already holds it (CG-18).
  402. * Called with `(path, 0, [])` to clear a record — the anti-abandonment restore
  403. * puts a suppressed file's source back, and a diagnostic still claiming the
  404. * saving would misreport where the envelope went.
  405. */
  406. recordDedup(path: string, savedChars: number, covered: ReadonlyArray<{ start: number; end: number }>): void {
  407. const rec = this.files.get(path);
  408. if (!rec) return;
  409. rec.dedupSavedChars = savedChars;
  410. rec.dedupCovered = covered.map((r) => [r.start, r.end] as [number, number]);
  411. }
  412. /**
  413. * A candidate was passed over before rendering. First reason wins — the
  414. * blanket `max-files` sweep must not overwrite a file's specific reason.
  415. */
  416. recordSkip(path: string, reason: ExploreSkipReason): void {
  417. const rec = this.files.get(path);
  418. if (!rec || rec.render || rec.skipped) return;
  419. rec.skipped = reason;
  420. }
  421. /** Explore returned early (no subgraph). Emits a minimal record. */
  422. finishEmpty(reason: string): void {
  423. this.note = reason;
  424. this.emit(this.buildReport('', 0, 0, 0));
  425. }
  426. /**
  427. * Final pass: attribute the FINAL text's bytes back to files (so the hard
  428. * ceiling's truncation is reflected in what each file actually delivered),
  429. * then emit.
  430. *
  431. * `allocatedChars` is the pre-truncation length — the size the render loop
  432. * *chose*. Reporting both is the point: the allocator's decision and the
  433. * agent's delivered payload diverge exactly when the ceiling cuts, and
  434. * conflating them is how a dropped trailing file goes unnoticed.
  435. */
  436. finish(finalText: string, allocatedChars: number, hardCeiling: number, filesIncluded: number): void {
  437. try {
  438. const perFile = attributeSourceBytes(finalText);
  439. const envelope = finalText.length;
  440. for (const rec of this.files.values()) {
  441. rec.finalChars = perFile.get(rec.path) ?? 0;
  442. rec.share = envelope > 0 ? rec.finalChars / envelope : 0;
  443. rec.allocatedShare = allocatedChars > 0 ? rec.emittedChars / allocatedChars : 0;
  444. // Rendered into `lines` but absent from the final text → the hard
  445. // ceiling dropped its whole section. A back-referenced file has no
  446. // fenced source BY DESIGN (CG-18), so it is never "dropped".
  447. if (rec.render && rec.render !== 'stale-omitted' && rec.render !== 'backref'
  448. && rec.finalChars === 0) {
  449. rec.render = 'dropped';
  450. rec.clipped = true;
  451. }
  452. }
  453. this.emit(this.buildReport(finalText, allocatedChars, hardCeiling, filesIncluded));
  454. } catch {
  455. // A diagnostic must never fail an explore call.
  456. }
  457. }
  458. private buildReport(
  459. finalText: string,
  460. allocatedChars: number,
  461. hardCeiling: number,
  462. filesIncluded: number,
  463. ): ExploreDiagnosticReport {
  464. const envelope = finalText.length;
  465. const records = [...this.files.values()];
  466. const rendered = records.filter((r) => r.finalChars > 0);
  467. const sourceChars = rendered.reduce((s, r) => s + r.finalChars, 0);
  468. return {
  469. tool: 'codegraph_explore',
  470. query: this.query,
  471. projectRoot: this.projectRoot,
  472. indexedFileCount: this.indexedFileCount,
  473. note: this.note || undefined,
  474. session: this.session,
  475. budget: {
  476. maxOutputChars: this.budget.maxOutputChars,
  477. maxCharsPerFile: this.budget.maxCharsPerFile,
  478. maxFiles: this.maxFiles,
  479. hardCeiling,
  480. },
  481. envelope: {
  482. chars: envelope,
  483. allocatedChars,
  484. overBudget: allocatedChars > this.budget.maxOutputChars,
  485. truncated: allocatedChars > hardCeiling,
  486. sourceChars,
  487. sourceShare: envelope > 0 ? sourceChars / envelope : 0,
  488. metaChars: envelope - sourceChars,
  489. metaShare: envelope > 0 ? (envelope - sourceChars) / envelope : 0,
  490. },
  491. selection: {
  492. scoreFloor: this.scoreFloor,
  493. maxGraph: this.maxGraph,
  494. graphGateThreshold: this.graphGateThreshold,
  495. graphGateApplied: this.graphGateApplied,
  496. filesGrouped: this.stages.grouped,
  497. filesPastLowValueFilter: this.stages.pastLowValueFilter,
  498. filesPastScoreFloor: this.stages.pastScoreFloor,
  499. filesRanked: this.stages.pastRelevanceGate,
  500. filesRenderedByLoop: filesIncluded,
  501. filesInFinalOutput: rendered.length,
  502. },
  503. dedup: {
  504. savedChars: records.reduce((s, r) => s + r.dedupSavedChars, 0),
  505. backReferenced: records.filter((r) => r.dedupSavedChars > 0).map((r) => r.path),
  506. fullyBackReferenced: records.filter((r) => r.render === 'backref').map((r) => r.path),
  507. },
  508. allocation: {
  509. pool: this.allocPool,
  510. cliffAt: round6(this.allocCliffAt),
  511. cliffed: [...this.allocCliffed],
  512. reserved: records.reduce((s, r) => s + (r.allowance ?? 0), 0),
  513. },
  514. files: records
  515. .slice()
  516. .sort((a, b) => b.emittedChars - a.emittedChars || b.finalChars - a.finalChars || a.rank - b.rank)
  517. .map((r) => ({
  518. path: r.path,
  519. rank: r.rank,
  520. score: r.score,
  521. graphScore: round6(r.graphScore),
  522. termHits: r.termHits,
  523. nodes: r.nodes,
  524. named: r.named,
  525. central: r.central,
  526. entry: r.entry,
  527. spine: r.spine,
  528. lowValue: r.lowValue,
  529. generated: r.generated,
  530. penalty: round6(r.penalty),
  531. kinds: r.kinds,
  532. allowance: r.allowance,
  533. spendable: r.spendable,
  534. render: r.render ?? null,
  535. skipped: r.skipped ?? null,
  536. clipped: r.clipped,
  537. dedupSavedChars: r.dedupSavedChars,
  538. dedupCovered: r.dedupCovered.map((s) => [...s] as [number, number]),
  539. emittedChars: r.emittedChars,
  540. finalChars: r.finalChars,
  541. share: round6(r.share),
  542. allocatedShare: round6(r.allocatedShare),
  543. })),
  544. };
  545. }
  546. private emit(report: ExploreDiagnosticReport): void {
  547. try {
  548. if (this.sink.kind === 'file') {
  549. appendFileSync(this.sink.path, JSON.stringify(report) + '\n', 'utf-8');
  550. return;
  551. }
  552. if (this.sink.json) {
  553. process.stderr.write(JSON.stringify(report, null, 2) + '\n');
  554. return;
  555. }
  556. process.stderr.write(renderTable(report) + '\n');
  557. } catch {
  558. // Unwritable sidecar / closed stderr must not fail the explore call.
  559. }
  560. }
  561. }
  562. const round6 = (n: number) => Math.round(n * 1e6) / 1e6;
  563. /**
  564. * Attribute the final response's source bytes back to files by walking the
  565. * rendered markdown: a ``**`path`**`` section header followed by a fenced code
  566. * block. Reading the FINAL text (rather than trusting the render loop's running
  567. * total) is what makes the numbers truthful — it accounts for the hard-ceiling
  568. * truncation that can drop whole trailing sections after they were "emitted".
  569. *
  570. * Line numbering is on by default, so a source line that is itself a ``` fence
  571. * arrives as `42\t```` and cannot close the block early.
  572. */
  573. export function attributeSourceBytes(finalText: string): Map<string, number> {
  574. const out = new Map<string, number>();
  575. if (!finalText) return out;
  576. const lines = finalText.split('\n');
  577. let current: string | null = null;
  578. let inFence = false;
  579. let acc: string[] = [];
  580. const flush = () => {
  581. if (current && acc.length > 0) {
  582. out.set(current, (out.get(current) ?? 0) + acc.join('\n').length);
  583. }
  584. acc = [];
  585. };
  586. for (const line of lines) {
  587. if (!inFence) {
  588. const header = /^\*\*`([^`]+)`\*\*/.exec(line);
  589. if (header) {
  590. current = header[1]!;
  591. continue;
  592. }
  593. if (current && line.startsWith('```')) {
  594. inFence = true;
  595. continue;
  596. }
  597. continue;
  598. }
  599. if (line === '```') {
  600. inFence = false;
  601. flush();
  602. continue;
  603. }
  604. acc.push(line);
  605. }
  606. // Unterminated fence (final-ceiling truncation cut mid-block): count it.
  607. if (inFence) flush();
  608. return out;
  609. }
  610. /** Human-readable stderr rendering of the JSON report. */
  611. export function renderTable(report: ExploreDiagnosticReport): string {
  612. const { budget, envelope: env, selection: sel, files } = report;
  613. const out: string[] = [];
  614. out.push('');
  615. out.push(`codegraph explore diagnostic — "${report.query}"`);
  616. out.push(` project ${report.projectRoot} · ${num(report.indexedFileCount)} files indexed`);
  617. if (report.note) out.push(` note: ${report.note}`);
  618. if (report.session) {
  619. const s = report.session;
  620. out.push(
  621. ` session call #${s.callIndex} for this project` +
  622. ` · ${num(s.priorCalls)} prior call${s.priorCalls === 1 ? '' : 's'}` +
  623. ` · ${num(s.priorResponseChars)} chars already served`,
  624. );
  625. for (const f of s.priorFiles.slice(0, 12)) {
  626. const spans = f.ranges.slice(0, 6).map(([a, b]) => (a === b ? `${a}` : `${a}-${b}`)).join(',');
  627. const more = f.ranges.length > 6 ? `,+${f.ranges.length - 6}` : '';
  628. out.push(` already served ${f.path} · ${num(f.bytes)} chars · L${spans}${more}`);
  629. }
  630. if (s.priorFiles.length > 12) out.push(` … +${s.priorFiles.length - 12} more already-served file(s)`);
  631. }
  632. out.push(
  633. ` envelope ${num(env.chars)} chars delivered · ${num(env.allocatedChars)} allocated` +
  634. ` of ${num(budget.maxOutputChars)} budget (hard ceiling ${num(budget.hardCeiling)})` +
  635. `${env.overBudget ? ' [over budget]' : ''}${env.truncated ? ' [TRUNCATED]' : ''}`,
  636. );
  637. out.push(
  638. ` source ${num(env.sourceChars)} (${pct(env.sourceShare)})` +
  639. ` · meta ${num(env.metaChars)} (${pct(env.metaShare)})` +
  640. ` · per-file cap ${num(budget.maxCharsPerFile)}`,
  641. );
  642. out.push(
  643. ` files ${num(sel.filesGrouped)} grouped` +
  644. ` → ${num(sel.filesPastLowValueFilter)} past low-value filter` +
  645. ` → ${num(sel.filesPastScoreFloor)} past score floor (>=${sel.scoreFloor.toFixed(1)})` +
  646. ` → ${num(sel.filesRanked)} past relevance gate` +
  647. ` → ${num(sel.filesInFinalOutput)} in output (maxFiles ${num(budget.maxFiles)})`,
  648. );
  649. out.push(
  650. ` relevance gate ${sel.graphGateApplied ? 'applied' : 'not applied'}` +
  651. ` at graph >= ${sel.graphGateThreshold.toFixed(5)} (6% of max ${sel.maxGraph.toFixed(5)})`,
  652. );
  653. const dedup = report.dedup;
  654. if (dedup && dedup.savedChars > 0) {
  655. out.push(
  656. ` dedup ${num(dedup.savedChars)} chars not re-sent` +
  657. ` · ${dedup.fullyBackReferenced.length} file(s) fully back-referenced` +
  658. ` (each also freed a maxFiles slot)` +
  659. (dedup.backReferenced.length > 0 ? `: ${dedup.backReferenced.join(', ')}` : ''),
  660. );
  661. }
  662. const alloc = report.allocation;
  663. out.push(
  664. ` allocation ${num(alloc.reserved)} reserved of ${num(alloc.pool)} pool` +
  665. ` · cliff at weight ${alloc.cliffAt.toFixed(2)}` +
  666. (alloc.cliffed.length > 0
  667. ? ` · ${alloc.cliffed.length} cliffed to pointers: ${alloc.cliffed.join(', ')}`
  668. : ' · nothing cliffed'),
  669. );
  670. out.push('');
  671. // Allocated (not delivered) is the allocator's own decision — the number the
  672. // budget work is about. Delivered is what the agent got. They differ only
  673. // when the ceiling truncated; showing both makes that divergence obvious.
  674. const shown = files.filter((f) => f.emittedChars > 0 || f.finalChars > 0 || f.render === 'backref');
  675. if (shown.length > 0) {
  676. out.push(' # alloc% deliv% bytes reserved score graph hits pen flags render file');
  677. for (const f of shown) {
  678. out.push(
  679. ' ' +
  680. String(f.rank).padStart(2) + ' ' +
  681. pct(f.allocatedShare).padStart(6) + ' ' +
  682. pct(f.share).padStart(6) + ' ' +
  683. num(f.emittedChars).padStart(7) + ' ' +
  684. (f.allowance === null ? '-' : num(f.allowance)).padStart(8) + ' ' +
  685. f.score.toFixed(1).padStart(5) + ' ' +
  686. f.graphScore.toFixed(5).padStart(7) + ' ' +
  687. String(f.termHits).padStart(4) + ' ' +
  688. f.penalty.toFixed(2).padStart(4) + ' ' +
  689. flagString(f).padEnd(19) + ' ' +
  690. ((f.render ?? '-') + (f.clipped ? '*' : '')).padEnd(9) + ' ' +
  691. f.path,
  692. );
  693. out.push(' kinds: ' + (f.kinds || '-'));
  694. // Only when it differs: a file that spent over `reserved` but inside
  695. // `spendable` took inherited slack, not a budget bug.
  696. if (f.spendable !== null && f.allowance !== null && f.spendable !== f.allowance) {
  697. out.push(` spendable: ${num(f.spendable)} (reservation + inherited slack)`);
  698. }
  699. if (f.dedupSavedChars > 0) {
  700. const spans = f.dedupCovered.slice(0, 6).map(([a, b]) => (a === b ? `${a}` : `${a}-${b}`)).join(',');
  701. const more = f.dedupCovered.length > 6 ? `,+${f.dedupCovered.length - 6}` : '';
  702. out.push(` dedup: ${num(f.dedupSavedChars)} chars already sent this session · L${spans}${more}`);
  703. }
  704. }
  705. out.push(' (bytes = source allocated by the render loop; deliv% = 0 means the hard ceiling dropped the section)');
  706. out.push(' (* = clipped: some source in this file was elided, windowed, or its section dropped)');
  707. } else {
  708. out.push(' (no file source in the final output)');
  709. }
  710. const skipped = files.filter((f) => f.emittedChars === 0 && f.finalChars === 0);
  711. if (skipped.length > 0) {
  712. out.push('');
  713. out.push(' ranked but never rendered:');
  714. for (const f of skipped.slice(0, 15)) {
  715. out.push(
  716. ` #${String(f.rank).padStart(2)} ${f.path} — ${f.skipped ?? f.render ?? 'not reached'}` +
  717. ` (score ${f.score.toFixed(1)}, graph ${f.graphScore.toFixed(5)}, hits ${f.termHits},` +
  718. ` pen ${f.penalty.toFixed(2)}, ${flagString(f) || 'no flags'}, ${f.kinds || '-'})`,
  719. );
  720. }
  721. if (skipped.length > 15) out.push(` … and ${skipped.length - 15} more`);
  722. }
  723. return out.join('\n');
  724. }
  725. function flagString(f: ExploreDiagnosticFile): string {
  726. const flags: string[] = [];
  727. if (f.named) flags.push('named');
  728. if (f.entry) flags.push('entry');
  729. if (f.central) flags.push('central');
  730. if (f.spine) flags.push('spine');
  731. if (f.lowValue) flags.push('low-value');
  732. if (f.generated) flags.push('generated');
  733. return flags.join(' ') || '-';
  734. }