9efae0f8f2
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>
208 lines
10 KiB
TypeScript
208 lines
10 KiB
TypeScript
/**
|
||
* 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 15–46 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);
|
||
});
|
||
});
|
||
});
|