deprioritize-config.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280
  1. /**
  2. * `codegraph.json` → `deprioritize` — user-extensible ranking de-prioritization (#982).
  3. *
  4. * `matchesNonProductionDir` hardcodes example/sample/fixture/benchmark/demo, so a
  5. * peripheral tree only the project knows about — `optional-skills/`, `scripts/` —
  6. * gets no de-prioritization. When helpers in such a tree carry generic symbol
  7. * names, an exact name match hands them a large bonus and they crowd out the
  8. * product code that actually answers the query.
  9. *
  10. * This is the *ranking* half of #982, deliberately distinct from the corpus-
  11. * frequency discount: that one keys on a name being COMMON, and is near-inert on
  12. * #982's own 8-file repro where only two symbols are named `usage`. The fixture
  13. * here IS that repro, which is the point — the two levers cover different shapes.
  14. *
  15. * It is also distinct from `exclude`, which is a recall lever. De-prioritized
  16. * paths stay indexed and findable; they just stop winning. Locked below.
  17. */
  18. import { describe, it, expect, beforeAll, afterAll } from 'vitest';
  19. import * as fs from 'fs';
  20. import * as path from 'path';
  21. import * as os from 'os';
  22. import { CodeGraph } from '../src';
  23. import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
  24. import { loadDeprioritizePatterns } from '../src/project-config';
  25. import { nameMatchBonus, scorePathRelevance } from '../src/search/query-utils';
  26. import { DEPRIORITIZED_NAME_BONUS_SCALE } from '../src/db/queries';
  27. const QUERY = 'desktop status bar context window usage';
  28. /** #982's minimal reproduction layout. */
  29. function writeRepro(root: string): void {
  30. const mk = (rel: string, content: string) => {
  31. const p = path.join(root, rel);
  32. fs.mkdirSync(path.dirname(p), { recursive: true });
  33. fs.writeFileSync(p, content);
  34. };
  35. // Product code. No symbol here is literally named `usage`.
  36. mk(
  37. 'apps/desktop/statusbar/StatusBar.ts',
  38. [
  39. 'export class DesktopStatusBar {',
  40. ' render(): string { return this.refresh(); }',
  41. ' refresh(): string { return "status bar"; }',
  42. ' mount(): void {}',
  43. '}',
  44. ].join('\n')
  45. );
  46. mk(
  47. 'apps/desktop/statusbar/StatusBarController.ts',
  48. [
  49. "import { DesktopStatusBar } from './StatusBar';",
  50. 'export class StatusBarController {',
  51. ' constructor(private readonly bar: DesktopStatusBar) {}',
  52. ' show(): string { return this.bar.render(); }',
  53. '}',
  54. ].join('\n')
  55. );
  56. mk(
  57. 'apps/desktop/context/ContextWindowMeter.ts',
  58. [
  59. 'export class ContextWindowMeter {',
  60. ' read(): number { return this.recompute(); }',
  61. ' recompute(): number { return estimateTokens("context window"); }',
  62. '}',
  63. 'export function estimateTokens(text: string): number { return text.length; }',
  64. ].join('\n')
  65. );
  66. mk(
  67. 'apps/desktop/context/format.ts',
  68. 'export function formatTokens(n: number): string { return `${n} tokens`; }\n'
  69. );
  70. mk('gateway/server/server.ts', 'export function startServer(): void {}\n');
  71. mk('packages/core/util/strings.ts', 'export function slugify(s: string): string { return s; }\n');
  72. // The peripheral tree: two standalone helpers, each with a module-level `usage`.
  73. for (const skill of ['bodyfat', 'nutrition']) {
  74. mk(
  75. `optional-skills/${skill}/scripts/${skill}_calc.ts`,
  76. ['export function usage(): void {', ` console.log("usage: ${skill}_calc [options]");`, '}'].join('\n')
  77. );
  78. }
  79. }
  80. const isHelper = (r: { node: { name: string; filePath: string } }): boolean =>
  81. r.node.name.toLowerCase() === 'usage' && r.node.filePath.includes('optional-skills');
  82. describe('codegraph.json deprioritize — parsing', () => {
  83. let dir: string;
  84. beforeAll(() => {
  85. dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-deprio-cfg-'));
  86. });
  87. afterAll(() => {
  88. fs.rmSync(dir, { recursive: true, force: true });
  89. });
  90. const write = (config: unknown): string => {
  91. const sub = fs.mkdtempSync(path.join(dir, 'p-'));
  92. fs.writeFileSync(path.join(sub, 'codegraph.json'), JSON.stringify(config));
  93. return sub;
  94. };
  95. it('defaults to empty with no config file', () => {
  96. const sub = fs.mkdtempSync(path.join(dir, 'none-'));
  97. expect(loadDeprioritizePatterns(sub)).toEqual([]);
  98. });
  99. it('keeps gitignore-style patterns verbatim, trimmed', () => {
  100. const sub = write({ deprioritize: ['optional-skills/', ' tools/gen ', 'vendor/**'] });
  101. expect(loadDeprioritizePatterns(sub)).toEqual(['optional-skills/', 'tools/gen', 'vendor/**']);
  102. });
  103. it('warns-and-skips a non-array value instead of throwing', () => {
  104. const sub = write({ deprioritize: 'optional-skills/' });
  105. expect(loadDeprioritizePatterns(sub)).toEqual([]);
  106. });
  107. it('drops blank and non-string entries, keeping the rest', () => {
  108. const sub = write({ deprioritize: ['optional-skills/', '', 42, ' ', 'scripts/'] });
  109. expect(loadDeprioritizePatterns(sub)).toEqual(['optional-skills/', 'scripts/']);
  110. });
  111. it('does not disturb the other config keys', () => {
  112. const sub = write({ deprioritize: ['optional-skills/'], exclude: ['static/'] });
  113. expect(loadDeprioritizePatterns(sub)).toEqual(['optional-skills/']);
  114. });
  115. });
  116. describe('#982 minimal repro — ranking with and without deprioritize', () => {
  117. let baseDir: string;
  118. let cfgDir: string;
  119. let baseCg: CodeGraph;
  120. let cfgCg: CodeGraph;
  121. beforeAll(async () => {
  122. await initGrammars();
  123. await loadAllGrammars();
  124. baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-deprio-base-'));
  125. writeRepro(baseDir);
  126. baseCg = CodeGraph.initSync(baseDir);
  127. await baseCg.indexAll();
  128. cfgDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-deprio-on-'));
  129. writeRepro(cfgDir);
  130. fs.writeFileSync(
  131. path.join(cfgDir, 'codegraph.json'),
  132. JSON.stringify({ deprioritize: ['optional-skills/'] }, null, 2)
  133. );
  134. cfgCg = CodeGraph.initSync(cfgDir);
  135. await cfgCg.indexAll();
  136. }, 180_000);
  137. afterAll(() => {
  138. baseCg?.destroy();
  139. cfgCg?.destroy();
  140. for (const d of [baseDir, cfgDir]) if (d) fs.rmSync(d, { recursive: true, force: true });
  141. });
  142. it('control: without the config the usage() helpers still take the top ranks', () => {
  143. // This is the status quo the issue reports, and the shape the corpus-frequency
  144. // discount cannot fix (only two symbols are named `usage` here, so it is rare).
  145. const results = baseCg.searchNodes(QUERY, { limit: 20 });
  146. expect(results.length).toBeGreaterThanOrEqual(2);
  147. expect(results.slice(0, 2).every(isHelper)).toBe(true);
  148. });
  149. it('with deprioritize, product code outranks the peripheral helpers', () => {
  150. const results = cfgCg.searchNodes(QUERY, { limit: 20 });
  151. const firstHelper = results.findIndex(isHelper);
  152. const firstProduct = results.findIndex((r) => r.node.filePath.includes('apps/desktop'));
  153. expect(firstProduct).toBeGreaterThanOrEqual(0);
  154. expect(firstHelper === -1 || firstProduct < firstHelper).toBe(true);
  155. });
  156. it('is a ranking lever, not exclude: the helpers stay indexed and findable', () => {
  157. // The whole point of keeping this distinct from `exclude` — recall is intact.
  158. expect(cfgCg.getNodesByName('usage').length).toBe(2);
  159. const direct = cfgCg.searchNodes('usage', { limit: 20 });
  160. expect(direct.some(isHelper)).toBe(true);
  161. });
  162. it('leaves paths outside the patterns alone', () => {
  163. // gateway/ and packages/ are not named, so their scores must not move.
  164. const score = (cg: CodeGraph, file: string): number | undefined =>
  165. cg.searchNodes('slugify', { limit: 20 }).find((r) => r.node.filePath.includes(file))?.score;
  166. const baseline = score(baseCg, 'packages/core/util/strings.ts');
  167. expect(baseline).toBeDefined();
  168. expect(score(cfgCg, 'packages/core/util/strings.ts')).toBe(baseline);
  169. });
  170. it('a query that genuinely targets the tree still ranks it, competitor present', () => {
  171. // The "discount, don't erase" edge case #982 calls out. `bodyfat_calc` lives
  172. // only in the de-prioritized tree; a query naming it must still find it
  173. // first, even with product code competing for the same terms.
  174. const results = cfgCg.searchNodes('bodyfat calc usage', { limit: 20 });
  175. expect(results.length).toBeGreaterThan(0);
  176. expect(results[0].node.filePath).toContain('optional-skills/bodyfat');
  177. });
  178. it('explore ranking honours the setting, not just search', () => {
  179. // #982's reproduction rows B/C/D are all `codegraph explore`. Explore ranks
  180. // through its own path scorer as well as through searchNodes, so a
  181. // search-only fix would leave the reported surface unchanged.
  182. const matcher = (cfgCg as unknown as { queries: { getDeprioritizedPathMatcher(): ((p: string) => boolean) | undefined } })
  183. .queries.getDeprioritizedPathMatcher();
  184. expect(matcher).toBeDefined();
  185. expect(matcher!('optional-skills/bodyfat/scripts/bodyfat_calc.ts')).toBe(true);
  186. expect(matcher!('apps/desktop/statusbar/StatusBar.ts')).toBe(false);
  187. });
  188. it('picks up a config written after the project was opened', async () => {
  189. // wireLayers runs once per open, so a matcher captured there would freeze
  190. // at open time — and the MCP server keeps one CodeGraph per root alive for
  191. // its whole lifetime, which would make an edited config look like a no-op.
  192. const late = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-deprio-late-'));
  193. writeRepro(late);
  194. const cg = CodeGraph.initSync(late);
  195. try {
  196. await cg.indexAll();
  197. const before = cg.searchNodes(QUERY, { limit: 20 });
  198. expect(before.length).toBeGreaterThanOrEqual(2);
  199. expect(before.slice(0, 2).every(isHelper)).toBe(true);
  200. fs.writeFileSync(
  201. path.join(late, 'codegraph.json'),
  202. JSON.stringify({ deprioritize: ['optional-skills/'] })
  203. );
  204. const after = cg.searchNodes(QUERY, { limit: 20 });
  205. const firstHelper = after.findIndex(isHelper);
  206. const firstProduct = after.findIndex((r) => r.node.filePath.includes('apps/desktop'));
  207. expect(firstProduct).toBeGreaterThanOrEqual(0);
  208. expect(firstHelper === -1 || firstProduct < firstHelper).toBe(true);
  209. } finally {
  210. cg.destroy();
  211. fs.rmSync(late, { recursive: true, force: true });
  212. }
  213. }, 180_000);
  214. });
  215. describe('scorePathRelevance — the two deliberate asymmetries (#982)', () => {
  216. it('docks a path that is both test-like and de-prioritized only once', () => {
  217. const both = 'example/a/foo.ts';
  218. const asTestOnly = scorePathRelevance(both, 'foo');
  219. const asBoth = scorePathRelevance(both, 'foo', undefined, true);
  220. expect(asBoth).toBe(asTestOnly);
  221. });
  222. it('does not waive the user penalty for a test-y query, unlike the built-ins', () => {
  223. // The built-in classification is inferred, so a test-y query waives it. A
  224. // `deprioritize` pattern is a standing statement by the project, so it
  225. // stands. Asserted so the difference is a decision, not an accident.
  226. const builtIn = scorePathRelevance('example/a/foo.ts', 'foo test');
  227. const userDeclared = scorePathRelevance('optional-skills/a/foo.ts', 'foo test', undefined, true);
  228. expect(userDeclared).toBe(builtIn - 15);
  229. });
  230. });
  231. describe('the name-bonus damping constant is derived, not picked (#982)', () => {
  232. it('keeps a damped exact match above the prefix arm, so it cannot lose to one', () => {
  233. // A de-prioritized node keeps `80 * SCALE` of the whole-query exact bonus
  234. // and also takes the -15 path penalty. The prefix arm tops out below 40, so
  235. // `80 * SCALE - 15 > 40` is what guarantees the exact match still wins —
  236. // "discount, don't erase" stated as arithmetic instead of taste.
  237. expect(nameMatchBonus('child', 'child')).toBe(80);
  238. expect(nameMatchBonus('children', 'child')).toBeLessThan(40);
  239. expect(80 * DEPRIORITIZED_NAME_BONUS_SCALE - 15).toBeGreaterThan(40);
  240. });
  241. it('would fail at the originally proposed 0.25, which is why it moved', () => {
  242. // Measured on a 62k-node django index with `deprioritize: ["tests/"]`: at
  243. // 0.25 the exact-name queries `child`, `parent` and `method` lost rank 1 to
  244. // the prefix matches `children`, `all_parents` and `method_decorator`.
  245. // Asserted so nobody lowers the constant back without meeting the bound.
  246. expect(80 * 0.25 - 15).toBeLessThan(40);
  247. });
  248. });