explore-declaration-only.test.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207
  1. /**
  2. * Regression gate for DECLARATION-ONLY files in explore ranking (task CG-28).
  3. *
  4. * A file that holds nothing but type declarations — an ambient `.d.ts`, vendored
  5. * typings, a `types.ts` of pure interfaces — cannot answer a FLOW question: no
  6. * bodies, no call edges, no behaviour. But the identifiers it declares are
  7. * exactly the generic ones a prose question uses (`Body`, `Message`,
  8. * `ImageMetadata`, `ReadableStream`), so on term overlap it out-scored the
  9. * implementation and took the envelope. Measured on this fixture before the fix:
  10. * rank #1 and 51% of delivered source on a prose flow query.
  11. *
  12. * CG-25 already covers the file that STARTED this — a Wrangler
  13. * `worker-configuration.d.ts`, which announces itself with a generated banner.
  14. * `docs/benchmarks/explore-declaration-only-cg28.md` has that measurement; the
  15. * banner alone is worth 15–46 points of envelope share. What it does not cover
  16. * is a declaration file with no banner at all, which is what this fixture's
  17. * `platform-shims.d.ts` is, and what the damping in `rankPenalty` addresses.
  18. *
  19. * Two claims, and BOTH have to hold — the counter-case is why the penalty is
  20. * guarded rather than flat:
  21. *
  22. * 1. a prose flow query must not let a declaration-only file outrank the
  23. * implementation files that answer it;
  24. * 2. a query genuinely ABOUT a declared type must still reach the declaration
  25. * at full weight.
  26. *
  27. * The suppression the issue explicitly forbids is also pinned: a damped file is
  28. * still a candidate and still named in the response, so one follow-up explore
  29. * fetches it.
  30. */
  31. import { describe, it, expect, beforeAll, afterAll } from 'vitest';
  32. import * as fs from 'fs';
  33. import * as path from 'path';
  34. import * as os from 'os';
  35. import CodeGraph from '../src/index';
  36. import { ToolHandler } from '../src/mcp/tools';
  37. import type { ExploreDiagnosticReport, ExploreDiagnosticFile } from '../src/mcp/explore-diagnostics';
  38. const FIXTURE_SRC = path.join(__dirname, 'fixtures', 'ambient-decls-ts');
  39. /** Declaration-only, hand-written, NO generated banner — the surviving gap. */
  40. const HANDWRITTEN_DECL = 'types/platform-shims.d.ts';
  41. /** Declaration-only WITH a Wrangler banner — the CG-25 control in the same run. */
  42. const GENERATED_DECL = 'types/worker-configuration.d.ts';
  43. /** Declaration-only but IMPORTED by the storage layer — must never be damped. */
  44. const SHARED_TYPES = 'src/storage/types.ts';
  45. /** Prose, naming no symbol — the query shape that let the original file in. */
  46. const FLOW_QUERY =
  47. 'how does an upload request stream the file body to storage and record image metadata';
  48. /** Prose that DOES name a declared type — the counter-case. */
  49. const TYPE_QUERY = 'what does the UploadStorage interface declare for putting an object';
  50. describe('CG-28 — a declaration-only file does not outrank implementation on a flow query', () => {
  51. let testDir: string;
  52. let cg: CodeGraph;
  53. let sidecar: string;
  54. /** One explore call; returns its diagnostic report plus the response text. */
  55. const explore = async (query: string): Promise<{ report: ExploreDiagnosticReport; text: string }> => {
  56. fs.rmSync(sidecar, { force: true });
  57. const previous = process.env.CODEGRAPH_EXPLORE_DEBUG;
  58. process.env.CODEGRAPH_EXPLORE_DEBUG = sidecar;
  59. let text: string;
  60. try {
  61. text = (await new ToolHandler(cg).execute('codegraph_explore', { query })).content?.[0]?.text ?? '';
  62. } finally {
  63. if (previous === undefined) delete process.env.CODEGRAPH_EXPLORE_DEBUG;
  64. else process.env.CODEGRAPH_EXPLORE_DEBUG = previous;
  65. }
  66. const written = fs.readFileSync(sidecar, 'utf-8').trim().split('\n').filter(Boolean);
  67. return { report: JSON.parse(written[written.length - 1]!) as ExploreDiagnosticReport, text };
  68. };
  69. const fileOf = (report: ExploreDiagnosticReport, p: string): ExploreDiagnosticFile | undefined =>
  70. report.files.find((f) => f.path === p);
  71. let flow: { report: ExploreDiagnosticReport; text: string };
  72. let typed: { report: ExploreDiagnosticReport; text: string };
  73. beforeAll(async () => {
  74. testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cg28-'));
  75. fs.cpSync(FIXTURE_SRC, testDir, { recursive: true });
  76. fs.rmSync(path.join(testDir, '.codegraph'), { recursive: true, force: true });
  77. sidecar = path.join(testDir, 'explore-diag.jsonl');
  78. cg = CodeGraph.initSync(testDir);
  79. await cg.indexAll();
  80. flow = await explore(FLOW_QUERY);
  81. typed = await explore(TYPE_QUERY);
  82. }, 120_000);
  83. afterAll(() => {
  84. if (cg) cg.destroy();
  85. if (testDir && fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
  86. });
  87. describe('fixture shape — if this rots, the gate below means nothing', () => {
  88. it('holds two declaration-only files that differ only in the banner', () => {
  89. for (const p of [HANDWRITTEN_DECL, GENERATED_DECL]) {
  90. const nodes = cg.getNodesInFile(p).filter((n) => n.kind !== 'file' && n.kind !== 'import');
  91. expect(nodes.length, `${p} declares nothing`).toBeGreaterThan(10);
  92. // Every symbol type-level, nothing with a body — the structural test the
  93. // penalty keys on. A `function`/`class` creeping in would silently exempt
  94. // the file and make every assertion below vacuous.
  95. expect(nodes.every((n) => n.kind === 'interface' || n.kind === 'type_alias'), `${p} has a non-type symbol`).toBe(true);
  96. }
  97. // Only one of them announces itself, so the CG-25 penalty is the ONLY
  98. // difference between the two — that is what makes them comparable.
  99. expect(cg.getFile(GENERATED_DECL)?.generated).toBe(true);
  100. expect(cg.getFile(HANDWRITTEN_DECL)?.generated).toBeFalsy();
  101. });
  102. it('holds a pure-type module the code IMPORTS, as the safety control', () => {
  103. // Identical to the ambient files on kinds and bodies; different only in
  104. // that the storage layer is typed by it. This is the shape the penalty
  105. // must NOT catch — a `types.ts` the codebase depends on is part of the
  106. // structure of any answer about that code.
  107. const nodes = cg.getNodesInFile(SHARED_TYPES).filter((n) => n.kind !== 'file' && n.kind !== 'import');
  108. expect(nodes.length).toBeGreaterThan(0);
  109. expect(nodes.every((n) => n.kind === 'interface' || n.kind === 'type_alias')).toBe(true);
  110. expect(cg.getFile(SHARED_TYPES)?.generated).toBeFalsy();
  111. });
  112. it('holds implementation files that DO answer the flow question', () => {
  113. for (const p of ['src/routes/upload.ts', 'src/storage/stream.ts', 'src/storage/metadata.ts']) {
  114. expect(cg.getNodesInFile(p).some((n) => n.kind === 'function'), `${p} has no functions`).toBe(true);
  115. }
  116. });
  117. });
  118. describe('the gate — a prose flow query', () => {
  119. it('damps the un-bannered declaration file rather than letting it rank free', () => {
  120. const rec = fileOf(flow.report, HANDWRITTEN_DECL);
  121. expect(rec, 'the declaration file is not even a candidate — fixture drifted').toBeDefined();
  122. expect(rec!.ambientDeclaration).toBe(true);
  123. expect(rec!.penalty).toBeLessThan(1);
  124. });
  125. it('does not let it outrank the implementation files', () => {
  126. const decl = fileOf(flow.report, HANDWRITTEN_DECL)!;
  127. const impl = flow.report.files.filter((f) => f.path.startsWith('src/') && f.finalChars > 0);
  128. expect(impl.length, 'no implementation file delivered anything').toBeGreaterThanOrEqual(2);
  129. // Measured before the fix: the declaration file was rank #1 with score 53
  130. // against the best implementation file's 34. The bar is that at least one
  131. // implementation file now ranks above it — ordinary budget movement must
  132. // not fail the suite, but the inversion coming back must.
  133. expect(impl.some((f) => f.rank < decl.rank), 'declaration file still ranks first').toBe(true);
  134. });
  135. it('still names it in the response, so one follow-up call fetches it', () => {
  136. // The issue forbids suppression: a damped file must remain reachable.
  137. expect(flow.text).toContain(HANDWRITTEN_DECL);
  138. });
  139. it('leaves the implementation files at full weight', () => {
  140. for (const f of flow.report.files.filter((x) => x.path.startsWith('src/'))) {
  141. expect(f.ambientDeclaration, `${f.path} was misread as an ambient declaration`).toBe(false);
  142. expect(f.penalty).toBe(1);
  143. }
  144. });
  145. it('does not damp a pure-type module the codebase imports', () => {
  146. // The condition that keeps this narrow enough to be safe. Without it the
  147. // same rule demotes `displacement-ts`'s pipeline `types.ts` — pure
  148. // interfaces, but 13 inbound imports — and breaks the CG-31 gate.
  149. const rec = flow.report.files.find((f) => f.path === SHARED_TYPES);
  150. if (rec) {
  151. expect(rec.ambientDeclaration, `${SHARED_TYPES} was flagged ambient`).toBe(false);
  152. expect(rec.penalty).toBe(1);
  153. }
  154. // Independent of whether this query ranked it: the predicate itself must
  155. // separate the two shapes.
  156. const isAmbient = cg.ambientDeclarationFilePredicate([SHARED_TYPES, HANDWRITTEN_DECL]);
  157. expect(isAmbient(SHARED_TYPES)).toBe(false);
  158. expect(isAmbient(HANDWRITTEN_DECL)).toBe(true);
  159. });
  160. });
  161. describe('the counter-case — a query that NAMES a declared type', () => {
  162. it('reaches the declaration at full weight, undamped', () => {
  163. const rec = fileOf(typed.report, HANDWRITTEN_DECL);
  164. expect(rec, 'the named type\'s file is not a candidate').toBeDefined();
  165. expect(rec!.ambientDeclaration).toBe(true);
  166. // Detected as declaration-only, but EXEMPT — the query asked for it.
  167. expect(rec!.penalty).toBe(1);
  168. });
  169. it('ranks it first and delivers its source', () => {
  170. const rec = fileOf(typed.report, HANDWRITTEN_DECL)!;
  171. expect(rec.rank).toBe(1);
  172. expect(rec.finalChars).toBeGreaterThan(0);
  173. });
  174. });
  175. describe('the two penalties do not stack', () => {
  176. it('charges a generated declaration file once, at the stronger rate', () => {
  177. // A file that is BOTH generated and declaration-only has ONE property two
  178. // signals happen to see. Penalising twice (0.3 * 0.5 = 0.15) is how a file
  179. // gets cliffed out of answers where it is genuinely relevant.
  180. const rec = flow.report.files.find((f) => f.generated && f.ambientDeclaration);
  181. if (!rec) return; // not a candidate for this query — nothing to assert
  182. expect(rec.penalty).toBeGreaterThanOrEqual(0.3);
  183. });
  184. });
  185. });