Files
colbymchenry--codegraph/__tests__/explore-declaration-only.test.ts
Colby McHenry 9efae0f8f2 fix(explore): damp ambient declaration files on flow queries (CG-28)
A file that declares nothing but types and that nothing in the index depends
on — a hand-written ambient `.d.ts` of global shims, vendored typings, module
augmentation — cannot answer a flow question: no bodies, no call edges, no
behaviour, nothing typed by it. But the identifiers it declares are exactly the
generic ones a prose question uses (`Body`, `Message`, `ImageMetadata`,
`ReadableStream`), so on term overlap it out-scored the implementation. Measured
on the new fixture: rank #1 and 51% of delivered source, with the flow's own
entry file pushed out of the response entirely.

Measured first, per the issue: the Wrangler `worker-configuration.d.ts` that
opened this is already handled by CG-25's banner detection, worth 15-46 points
of envelope share across four flow queries. CG-25 credited; only the un-bannered
case needed anything.

`rankPenalty` now multiplies score and graph mass by 0.5 for such files, taken
as the STRONGER of it and the generated penalty rather than multiplied — one
property two signals see must not be charged twice. Detection is structural, not
by extension, and four conditions deep. Two of them were forced by measurement:
requiring every symbol to be type-level takes the corpus flag rate from 1-18%
(which swept in Kotlin sealed classes, Rust mod.rs re-exports and django's
locale tables) down to 0-4%; requiring that nothing depends on the file
separates an ambient shim from a working types module, and without it the rule
demoted displacement-ts's pipeline `types.ts` and broke the CG-31 gate.

A query that NAMES a declared type is exempt, so a question about a type still
reaches its declaration at full weight. Precise tokens only, so "…the file
body…" cannot exempt a `Body` interface it never meant to name; this needs its
own set because `namedSeedIds` is callable-only and a type never becomes one.

Regression evidence in docs/benchmarks/explore-declaration-only-cg28.md:
6-repo envelope sweep byte-identical against a clean baseline build, zero
ambient files reach the candidate set on VS Code across five queries, corpus
flag rate 0-0.74%, both allocation fixtures PASS, full suite 2,978 green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 14:35:56 -05:00

208 lines
10 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Regression gate for DECLARATION-ONLY files in explore ranking (task CG-28).
*
* A file that holds nothing but type declarations — an ambient `.d.ts`, vendored
* typings, a `types.ts` of pure interfaces — cannot answer a FLOW question: no
* bodies, no call edges, no behaviour. But the identifiers it declares are
* exactly the generic ones a prose question uses (`Body`, `Message`,
* `ImageMetadata`, `ReadableStream`), so on term overlap it out-scored the
* implementation and took the envelope. Measured on this fixture before the fix:
* rank #1 and 51% of delivered source on a prose flow query.
*
* CG-25 already covers the file that STARTED this — a Wrangler
* `worker-configuration.d.ts`, which announces itself with a generated banner.
* `docs/benchmarks/explore-declaration-only-cg28.md` has that measurement; the
* banner alone is worth 1546 points of envelope share. What it does not cover
* is a declaration file with no banner at all, which is what this fixture's
* `platform-shims.d.ts` is, and what the damping in `rankPenalty` addresses.
*
* Two claims, and BOTH have to hold — the counter-case is why the penalty is
* guarded rather than flat:
*
* 1. a prose flow query must not let a declaration-only file outrank the
* implementation files that answer it;
* 2. a query genuinely ABOUT a declared type must still reach the declaration
* at full weight.
*
* The suppression the issue explicitly forbids is also pinned: a damped file is
* still a candidate and still named in the response, so one follow-up explore
* fetches it.
*/
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/index';
import { ToolHandler } from '../src/mcp/tools';
import type { ExploreDiagnosticReport, ExploreDiagnosticFile } from '../src/mcp/explore-diagnostics';
const FIXTURE_SRC = path.join(__dirname, 'fixtures', 'ambient-decls-ts');
/** Declaration-only, hand-written, NO generated banner — the surviving gap. */
const HANDWRITTEN_DECL = 'types/platform-shims.d.ts';
/** Declaration-only WITH a Wrangler banner — the CG-25 control in the same run. */
const GENERATED_DECL = 'types/worker-configuration.d.ts';
/** Declaration-only but IMPORTED by the storage layer — must never be damped. */
const SHARED_TYPES = 'src/storage/types.ts';
/** Prose, naming no symbol — the query shape that let the original file in. */
const FLOW_QUERY =
'how does an upload request stream the file body to storage and record image metadata';
/** Prose that DOES name a declared type — the counter-case. */
const TYPE_QUERY = 'what does the UploadStorage interface declare for putting an object';
describe('CG-28 — a declaration-only file does not outrank implementation on a flow query', () => {
let testDir: string;
let cg: CodeGraph;
let sidecar: string;
/** One explore call; returns its diagnostic report plus the response text. */
const explore = async (query: string): Promise<{ report: ExploreDiagnosticReport; text: string }> => {
fs.rmSync(sidecar, { force: true });
const previous = process.env.CODEGRAPH_EXPLORE_DEBUG;
process.env.CODEGRAPH_EXPLORE_DEBUG = sidecar;
let text: string;
try {
text = (await new ToolHandler(cg).execute('codegraph_explore', { query })).content?.[0]?.text ?? '';
} finally {
if (previous === undefined) delete process.env.CODEGRAPH_EXPLORE_DEBUG;
else process.env.CODEGRAPH_EXPLORE_DEBUG = previous;
}
const written = fs.readFileSync(sidecar, 'utf-8').trim().split('\n').filter(Boolean);
return { report: JSON.parse(written[written.length - 1]!) as ExploreDiagnosticReport, text };
};
const fileOf = (report: ExploreDiagnosticReport, p: string): ExploreDiagnosticFile | undefined =>
report.files.find((f) => f.path === p);
let flow: { report: ExploreDiagnosticReport; text: string };
let typed: { report: ExploreDiagnosticReport; text: string };
beforeAll(async () => {
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cg28-'));
fs.cpSync(FIXTURE_SRC, testDir, { recursive: true });
fs.rmSync(path.join(testDir, '.codegraph'), { recursive: true, force: true });
sidecar = path.join(testDir, 'explore-diag.jsonl');
cg = CodeGraph.initSync(testDir);
await cg.indexAll();
flow = await explore(FLOW_QUERY);
typed = await explore(TYPE_QUERY);
}, 120_000);
afterAll(() => {
if (cg) cg.destroy();
if (testDir && fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
});
describe('fixture shape — if this rots, the gate below means nothing', () => {
it('holds two declaration-only files that differ only in the banner', () => {
for (const p of [HANDWRITTEN_DECL, GENERATED_DECL]) {
const nodes = cg.getNodesInFile(p).filter((n) => n.kind !== 'file' && n.kind !== 'import');
expect(nodes.length, `${p} declares nothing`).toBeGreaterThan(10);
// Every symbol type-level, nothing with a body — the structural test the
// penalty keys on. A `function`/`class` creeping in would silently exempt
// the file and make every assertion below vacuous.
expect(nodes.every((n) => n.kind === 'interface' || n.kind === 'type_alias'), `${p} has a non-type symbol`).toBe(true);
}
// Only one of them announces itself, so the CG-25 penalty is the ONLY
// difference between the two — that is what makes them comparable.
expect(cg.getFile(GENERATED_DECL)?.generated).toBe(true);
expect(cg.getFile(HANDWRITTEN_DECL)?.generated).toBeFalsy();
});
it('holds a pure-type module the code IMPORTS, as the safety control', () => {
// Identical to the ambient files on kinds and bodies; different only in
// that the storage layer is typed by it. This is the shape the penalty
// must NOT catch — a `types.ts` the codebase depends on is part of the
// structure of any answer about that code.
const nodes = cg.getNodesInFile(SHARED_TYPES).filter((n) => n.kind !== 'file' && n.kind !== 'import');
expect(nodes.length).toBeGreaterThan(0);
expect(nodes.every((n) => n.kind === 'interface' || n.kind === 'type_alias')).toBe(true);
expect(cg.getFile(SHARED_TYPES)?.generated).toBeFalsy();
});
it('holds implementation files that DO answer the flow question', () => {
for (const p of ['src/routes/upload.ts', 'src/storage/stream.ts', 'src/storage/metadata.ts']) {
expect(cg.getNodesInFile(p).some((n) => n.kind === 'function'), `${p} has no functions`).toBe(true);
}
});
});
describe('the gate — a prose flow query', () => {
it('damps the un-bannered declaration file rather than letting it rank free', () => {
const rec = fileOf(flow.report, HANDWRITTEN_DECL);
expect(rec, 'the declaration file is not even a candidate — fixture drifted').toBeDefined();
expect(rec!.ambientDeclaration).toBe(true);
expect(rec!.penalty).toBeLessThan(1);
});
it('does not let it outrank the implementation files', () => {
const decl = fileOf(flow.report, HANDWRITTEN_DECL)!;
const impl = flow.report.files.filter((f) => f.path.startsWith('src/') && f.finalChars > 0);
expect(impl.length, 'no implementation file delivered anything').toBeGreaterThanOrEqual(2);
// Measured before the fix: the declaration file was rank #1 with score 53
// against the best implementation file's 34. The bar is that at least one
// implementation file now ranks above it — ordinary budget movement must
// not fail the suite, but the inversion coming back must.
expect(impl.some((f) => f.rank < decl.rank), 'declaration file still ranks first').toBe(true);
});
it('still names it in the response, so one follow-up call fetches it', () => {
// The issue forbids suppression: a damped file must remain reachable.
expect(flow.text).toContain(HANDWRITTEN_DECL);
});
it('leaves the implementation files at full weight', () => {
for (const f of flow.report.files.filter((x) => x.path.startsWith('src/'))) {
expect(f.ambientDeclaration, `${f.path} was misread as an ambient declaration`).toBe(false);
expect(f.penalty).toBe(1);
}
});
it('does not damp a pure-type module the codebase imports', () => {
// The condition that keeps this narrow enough to be safe. Without it the
// same rule demotes `displacement-ts`'s pipeline `types.ts` — pure
// interfaces, but 13 inbound imports — and breaks the CG-31 gate.
const rec = flow.report.files.find((f) => f.path === SHARED_TYPES);
if (rec) {
expect(rec.ambientDeclaration, `${SHARED_TYPES} was flagged ambient`).toBe(false);
expect(rec.penalty).toBe(1);
}
// Independent of whether this query ranked it: the predicate itself must
// separate the two shapes.
const isAmbient = cg.ambientDeclarationFilePredicate([SHARED_TYPES, HANDWRITTEN_DECL]);
expect(isAmbient(SHARED_TYPES)).toBe(false);
expect(isAmbient(HANDWRITTEN_DECL)).toBe(true);
});
});
describe('the counter-case — a query that NAMES a declared type', () => {
it('reaches the declaration at full weight, undamped', () => {
const rec = fileOf(typed.report, HANDWRITTEN_DECL);
expect(rec, 'the named type\'s file is not a candidate').toBeDefined();
expect(rec!.ambientDeclaration).toBe(true);
// Detected as declaration-only, but EXEMPT — the query asked for it.
expect(rec!.penalty).toBe(1);
});
it('ranks it first and delivers its source', () => {
const rec = fileOf(typed.report, HANDWRITTEN_DECL)!;
expect(rec.rank).toBe(1);
expect(rec.finalChars).toBeGreaterThan(0);
});
});
describe('the two penalties do not stack', () => {
it('charges a generated declaration file once, at the stronger rate', () => {
// A file that is BOTH generated and declaration-only has ONE property two
// signals happen to see. Penalising twice (0.3 * 0.5 = 0.15) is how a file
// gets cliffed out of answers where it is genuinely relevant.
const rec = flow.report.files.find((f) => f.generated && f.ambientDeclaration);
if (!rec) return; // not a candidate for this query — nothing to assert
expect(rec.penalty).toBeGreaterThanOrEqual(0.3);
});
});
});