Files
codegraph/__tests__/deprioritize-config.test.ts
T
1d9de88ef1 feat(config): add codegraph.json "deprioritize" for ranking-only path down-weighting (#982) (#1463)
* feat(config): add codegraph.json "deprioritize" for ranking-only path down-weighting

matchesNonProductionDir hardcodes example/sample/fixture/benchmark/demo,
so a peripheral tree only the project knows about — optional-skills/,
scripts/ — gets no de-prioritization. When helpers there carry generic
symbol names, an exact name match hands them a large bonus and they crowd
out the product code that answers the query (#982).

deprioritize is the RANKING counterpart to exclude: those paths stay
indexed and findable, they just stop outranking first-party code. It is
deliberately distinct from the corpus-frequency discount, which keys on a
name being common and is near-inert on #982's own repro where only two
symbols are named usage.

The -15 path penalty alone is not enough, and measuring showed why: on
that repro a usage() helper sits at 74.8 against 51.2 for the top product
symbol, so -15 lands at 59.8 and still leads. The path penalty is additive
and the name bonus it must counter is additive and larger. A de-prioritized
path is saying its symbol NAMES are not the answer, so the exact-name bonus
is damped to 0.25x there as well — damped, not zeroed, so the tree still
ranks when it genuinely is what you asked for.

Refs #982

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SKXAJMrVrdHS5Uco6ABtky

* fix(config): read deprioritize lazily and apply it in explore too

Review of the first cut found two real defects.

The matcher was built once in wireLayers(), which runs only from the
constructor and from reopenIfReplaced(). The MCP server keeps one
CodeGraph per project root alive for its whole lifetime, so editing
codegraph.json appeared to do nothing until the process restarted --
exclude and include do not behave that way. The predicate now reads
loadDeprioritizePatterns() per call (mtime-cached, one stat) and memoizes
the compiled matcher on the pattern array's identity. A regression test
writes the config after opening the project and fails on the old code.

Explore passed no matcher to scorePathRelevance at either of its two call
sites, so the setting only half-applied -- and #982's reproduction rows
B, C and D are all codegraph explore, which made this the surface the
issue actually reports on. Both sites now pass it.

Explore's hard early-continue filters and its non-production budget cap
are deliberately NOT joined: those REMOVE content, and deprioritize is a
ranking lever by definition. README narrowed accordingly -- it previously
claimed this extends the built-in list, which overstated it.

Also from review: scorePathRelevance takes a boolean rather than a
predicate (the caller already evaluated it, and it was being invoked
twice per result), the predicate body is exception-guarded so a bad path
can never take a search down, the misplaced const moved out from between
imports, two vacuous test assertions tightened, and tests added for the
single-penalty invariant, the deliberate isTestQuery asymmetry, and a
query that genuinely targets the de-prioritized tree.

Refs #982

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SKXAJMrVrdHS5Uco6ABtky

* fix(search): derive the deprioritize name-bonus damping instead of picking it (#982)

The 0.25 scale was a guess. On a 62k-node django index it measurably breaks
the "discount, don't erase" rule the lever is built on: exact-name queries for
symbols that live only in the de-prioritized tree (child, parent, method) fall
behind mere prefix matches (children, all_parents, method_decorator).

The prefix arm of nameMatchBonus tops out below 40, and a de-prioritized node
also takes the -15 path penalty, so 80 * SCALE - 15 > 40 is the bound that
keeps a damped exact match ahead of a prefix match at any corpus shape. 0.75
clears it; crowd-out removal is nearly identical to 0.5 (39 vs 40 of 88
peripheral top-10 slots cleared on django), so the deeper discount bought
almost nothing and cost the invariant.

Two tests pin the bound, including one that fails at the old 0.25.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 11:55:14 -05:00

281 lines
12 KiB
TypeScript

/**
* `codegraph.json` → `deprioritize` — user-extensible ranking de-prioritization (#982).
*
* `matchesNonProductionDir` hardcodes example/sample/fixture/benchmark/demo, so a
* peripheral tree only the project knows about — `optional-skills/`, `scripts/` —
* gets no de-prioritization. When helpers in such a tree carry generic symbol
* names, an exact name match hands them a large bonus and they crowd out the
* product code that actually answers the query.
*
* This is the *ranking* half of #982, deliberately distinct from the corpus-
* frequency discount: that one keys on a name being COMMON, and is near-inert on
* #982's own 8-file repro where only two symbols are named `usage`. The fixture
* here IS that repro, which is the point — the two levers cover different shapes.
*
* It is also distinct from `exclude`, which is a recall lever. De-prioritized
* paths stay indexed and findable; they just stop winning. Locked below.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { CodeGraph } from '../src';
import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
import { loadDeprioritizePatterns } from '../src/project-config';
import { nameMatchBonus, scorePathRelevance } from '../src/search/query-utils';
import { DEPRIORITIZED_NAME_BONUS_SCALE } from '../src/db/queries';
const QUERY = 'desktop status bar context window usage';
/** #982's minimal reproduction layout. */
function writeRepro(root: string): void {
const mk = (rel: string, content: string) => {
const p = path.join(root, rel);
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, content);
};
// Product code. No symbol here is literally named `usage`.
mk(
'apps/desktop/statusbar/StatusBar.ts',
[
'export class DesktopStatusBar {',
' render(): string { return this.refresh(); }',
' refresh(): string { return "status bar"; }',
' mount(): void {}',
'}',
].join('\n')
);
mk(
'apps/desktop/statusbar/StatusBarController.ts',
[
"import { DesktopStatusBar } from './StatusBar';",
'export class StatusBarController {',
' constructor(private readonly bar: DesktopStatusBar) {}',
' show(): string { return this.bar.render(); }',
'}',
].join('\n')
);
mk(
'apps/desktop/context/ContextWindowMeter.ts',
[
'export class ContextWindowMeter {',
' read(): number { return this.recompute(); }',
' recompute(): number { return estimateTokens("context window"); }',
'}',
'export function estimateTokens(text: string): number { return text.length; }',
].join('\n')
);
mk(
'apps/desktop/context/format.ts',
'export function formatTokens(n: number): string { return `${n} tokens`; }\n'
);
mk('gateway/server/server.ts', 'export function startServer(): void {}\n');
mk('packages/core/util/strings.ts', 'export function slugify(s: string): string { return s; }\n');
// The peripheral tree: two standalone helpers, each with a module-level `usage`.
for (const skill of ['bodyfat', 'nutrition']) {
mk(
`optional-skills/${skill}/scripts/${skill}_calc.ts`,
['export function usage(): void {', ` console.log("usage: ${skill}_calc [options]");`, '}'].join('\n')
);
}
}
const isHelper = (r: { node: { name: string; filePath: string } }): boolean =>
r.node.name.toLowerCase() === 'usage' && r.node.filePath.includes('optional-skills');
describe('codegraph.json deprioritize — parsing', () => {
let dir: string;
beforeAll(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-deprio-cfg-'));
});
afterAll(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
const write = (config: unknown): string => {
const sub = fs.mkdtempSync(path.join(dir, 'p-'));
fs.writeFileSync(path.join(sub, 'codegraph.json'), JSON.stringify(config));
return sub;
};
it('defaults to empty with no config file', () => {
const sub = fs.mkdtempSync(path.join(dir, 'none-'));
expect(loadDeprioritizePatterns(sub)).toEqual([]);
});
it('keeps gitignore-style patterns verbatim, trimmed', () => {
const sub = write({ deprioritize: ['optional-skills/', ' tools/gen ', 'vendor/**'] });
expect(loadDeprioritizePatterns(sub)).toEqual(['optional-skills/', 'tools/gen', 'vendor/**']);
});
it('warns-and-skips a non-array value instead of throwing', () => {
const sub = write({ deprioritize: 'optional-skills/' });
expect(loadDeprioritizePatterns(sub)).toEqual([]);
});
it('drops blank and non-string entries, keeping the rest', () => {
const sub = write({ deprioritize: ['optional-skills/', '', 42, ' ', 'scripts/'] });
expect(loadDeprioritizePatterns(sub)).toEqual(['optional-skills/', 'scripts/']);
});
it('does not disturb the other config keys', () => {
const sub = write({ deprioritize: ['optional-skills/'], exclude: ['static/'] });
expect(loadDeprioritizePatterns(sub)).toEqual(['optional-skills/']);
});
});
describe('#982 minimal repro — ranking with and without deprioritize', () => {
let baseDir: string;
let cfgDir: string;
let baseCg: CodeGraph;
let cfgCg: CodeGraph;
beforeAll(async () => {
await initGrammars();
await loadAllGrammars();
baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-deprio-base-'));
writeRepro(baseDir);
baseCg = CodeGraph.initSync(baseDir);
await baseCg.indexAll();
cfgDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-deprio-on-'));
writeRepro(cfgDir);
fs.writeFileSync(
path.join(cfgDir, 'codegraph.json'),
JSON.stringify({ deprioritize: ['optional-skills/'] }, null, 2)
);
cfgCg = CodeGraph.initSync(cfgDir);
await cfgCg.indexAll();
}, 180_000);
afterAll(() => {
baseCg?.destroy();
cfgCg?.destroy();
for (const d of [baseDir, cfgDir]) if (d) fs.rmSync(d, { recursive: true, force: true });
});
it('control: without the config the usage() helpers still take the top ranks', () => {
// This is the status quo the issue reports, and the shape the corpus-frequency
// discount cannot fix (only two symbols are named `usage` here, so it is rare).
const results = baseCg.searchNodes(QUERY, { limit: 20 });
expect(results.length).toBeGreaterThanOrEqual(2);
expect(results.slice(0, 2).every(isHelper)).toBe(true);
});
it('with deprioritize, product code outranks the peripheral helpers', () => {
const results = cfgCg.searchNodes(QUERY, { limit: 20 });
const firstHelper = results.findIndex(isHelper);
const firstProduct = results.findIndex((r) => r.node.filePath.includes('apps/desktop'));
expect(firstProduct).toBeGreaterThanOrEqual(0);
expect(firstHelper === -1 || firstProduct < firstHelper).toBe(true);
});
it('is a ranking lever, not exclude: the helpers stay indexed and findable', () => {
// The whole point of keeping this distinct from `exclude` — recall is intact.
expect(cfgCg.getNodesByName('usage').length).toBe(2);
const direct = cfgCg.searchNodes('usage', { limit: 20 });
expect(direct.some(isHelper)).toBe(true);
});
it('leaves paths outside the patterns alone', () => {
// gateway/ and packages/ are not named, so their scores must not move.
const score = (cg: CodeGraph, file: string): number | undefined =>
cg.searchNodes('slugify', { limit: 20 }).find((r) => r.node.filePath.includes(file))?.score;
const baseline = score(baseCg, 'packages/core/util/strings.ts');
expect(baseline).toBeDefined();
expect(score(cfgCg, 'packages/core/util/strings.ts')).toBe(baseline);
});
it('a query that genuinely targets the tree still ranks it, competitor present', () => {
// The "discount, don't erase" edge case #982 calls out. `bodyfat_calc` lives
// only in the de-prioritized tree; a query naming it must still find it
// first, even with product code competing for the same terms.
const results = cfgCg.searchNodes('bodyfat calc usage', { limit: 20 });
expect(results.length).toBeGreaterThan(0);
expect(results[0].node.filePath).toContain('optional-skills/bodyfat');
});
it('explore ranking honours the setting, not just search', () => {
// #982's reproduction rows B/C/D are all `codegraph explore`. Explore ranks
// through its own path scorer as well as through searchNodes, so a
// search-only fix would leave the reported surface unchanged.
const matcher = (cfgCg as unknown as { queries: { getDeprioritizedPathMatcher(): ((p: string) => boolean) | undefined } })
.queries.getDeprioritizedPathMatcher();
expect(matcher).toBeDefined();
expect(matcher!('optional-skills/bodyfat/scripts/bodyfat_calc.ts')).toBe(true);
expect(matcher!('apps/desktop/statusbar/StatusBar.ts')).toBe(false);
});
it('picks up a config written after the project was opened', async () => {
// wireLayers runs once per open, so a matcher captured there would freeze
// at open time — and the MCP server keeps one CodeGraph per root alive for
// its whole lifetime, which would make an edited config look like a no-op.
const late = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-deprio-late-'));
writeRepro(late);
const cg = CodeGraph.initSync(late);
try {
await cg.indexAll();
const before = cg.searchNodes(QUERY, { limit: 20 });
expect(before.length).toBeGreaterThanOrEqual(2);
expect(before.slice(0, 2).every(isHelper)).toBe(true);
fs.writeFileSync(
path.join(late, 'codegraph.json'),
JSON.stringify({ deprioritize: ['optional-skills/'] })
);
const after = cg.searchNodes(QUERY, { limit: 20 });
const firstHelper = after.findIndex(isHelper);
const firstProduct = after.findIndex((r) => r.node.filePath.includes('apps/desktop'));
expect(firstProduct).toBeGreaterThanOrEqual(0);
expect(firstHelper === -1 || firstProduct < firstHelper).toBe(true);
} finally {
cg.destroy();
fs.rmSync(late, { recursive: true, force: true });
}
}, 180_000);
});
describe('scorePathRelevance — the two deliberate asymmetries (#982)', () => {
it('docks a path that is both test-like and de-prioritized only once', () => {
const both = 'example/a/foo.ts';
const asTestOnly = scorePathRelevance(both, 'foo');
const asBoth = scorePathRelevance(both, 'foo', undefined, true);
expect(asBoth).toBe(asTestOnly);
});
it('does not waive the user penalty for a test-y query, unlike the built-ins', () => {
// The built-in classification is inferred, so a test-y query waives it. A
// `deprioritize` pattern is a standing statement by the project, so it
// stands. Asserted so the difference is a decision, not an accident.
const builtIn = scorePathRelevance('example/a/foo.ts', 'foo test');
const userDeclared = scorePathRelevance('optional-skills/a/foo.ts', 'foo test', undefined, true);
expect(userDeclared).toBe(builtIn - 15);
});
});
describe('the name-bonus damping constant is derived, not picked (#982)', () => {
it('keeps a damped exact match above the prefix arm, so it cannot lose to one', () => {
// A de-prioritized node keeps `80 * SCALE` of the whole-query exact bonus
// and also takes the -15 path penalty. The prefix arm tops out below 40, so
// `80 * SCALE - 15 > 40` is what guarantees the exact match still wins —
// "discount, don't erase" stated as arithmetic instead of taste.
expect(nameMatchBonus('child', 'child')).toBe(80);
expect(nameMatchBonus('children', 'child')).toBeLessThan(40);
expect(80 * DEPRIORITIZED_NAME_BONUS_SCALE - 15).toBeGreaterThan(40);
});
it('would fail at the originally proposed 0.25, which is why it moved', () => {
// Measured on a 62k-node django index with `deprioritize: ["tests/"]`: at
// 0.25 the exact-name queries `child`, `parent` and `method` lost rank 1 to
// the prefix matches `children`, `all_parents` and `method_decorator`.
// Asserted so nobody lowers the constant back without meeting the bound.
expect(80 * 0.25 - 15).toBeLessThan(40);
});
});