Kaynağa Gözat

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>
Colby McHenry 1 ay önce
ebeveyn
işleme
9efae0f8f2

+ 1 - 0
CHANGELOG.md

@@ -22,6 +22,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 - A generated CRUD or protobuf layer no longer crowds out the hand-written code sitting beside it: generated files are now recognized by the `// Code generated by … DO NOT EDIT.` style banner written at the top of the file, not just by a filename that looks generated. Re-index after upgrading to pick up the new detection. (#1500)
 - Test and spec files in a repository's top-level `test/` or `spec/` directory are now recognized as such, so they no longer take room from the code you asked about. (#1500)
 - Generated type-declaration files that announce themselves with a "Generated by … by running …" banner — Cloudflare Wrangler's `worker-configuration.d.ts` is the common one — are now recognized as generated. Previously a file like that could take most of a `codegraph_explore` answer on nothing more than a few common words, pushing the hand-written code you asked about out of the response entirely. Re-index after upgrading to pick up the new detection.
+- A hand-written type-declaration file — an ambient `.d.ts` of global shims, vendored typings, module augmentation — no longer takes over a `codegraph_explore` answer about how something works. Files like these declare common names (`Body`, `Message`, `ImageMetadata`) and nothing else, so a plainly-worded question could match one strongly enough that it ranked first and crowded the actual handler out of the answer. They are now ranked lower for questions about behaviour, and are still listed by name so one follow-up call fetches them. Asking about a type by name still returns its declaration first, and a shared types module the rest of your code imports is unaffected.
 - A CodeGraph process that gets force-killed — by the stuck-process watchdog, a crash, or the OS — no longer leaves the database's write-ahead log behind to grow without bound. Previously each killed session stacked more data onto the same log file and nothing ever shrank it, which on machines where sessions were killed regularly could quietly eat tens of gigabytes of disk. The log is now capped, and any oversized leftover is reclaimed automatically the next time the project is opened. Thanks @tiendungdev for the exceptional Windows report that pinned this down. (#1431)
 - The background server's watchdog no longer kills a healthy server that is just waiting on a slow disk: like indexing already does, it now checks whether the database files are still making progress before concluding the process is stuck. Fewer spurious kills also means fewer leftover write-ahead logs. (#1431)
 - `codegraph status` now shows the write-ahead log's size next to the database size and warns when killed sessions have left it oversized, and every line in the background server's log now carries a timestamp so kills and restarts can be placed in time. (#1431)

+ 207 - 0
__tests__/explore-declaration-only.test.ts

@@ -0,0 +1,207 @@
+/**
+ * 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);
+    });
+  });
+});

+ 7 - 0
__tests__/fixtures/ambient-decls-ts/package.json

@@ -0,0 +1,7 @@
+{
+  "name": "ambient-decls-ts-fixture",
+  "private": true,
+  "version": "0.0.0",
+  "type": "module",
+  "description": "CG-28 fixture — declaration-only files competing with implementation for one explore envelope."
+}

+ 46 - 0
__tests__/fixtures/ambient-decls-ts/src/lib/bucket.ts

@@ -0,0 +1,46 @@
+export interface BucketObject {
+  key: string;
+  body: ReadableStream<Uint8Array>;
+  size: number;
+}
+
+export interface Bucket {
+  put(
+    key: string,
+    value: ReadableStream<Uint8Array>,
+    options?: { httpMetadata?: { contentType?: string } },
+  ): Promise<void>;
+  get(key: string): Promise<BucketObject | null>;
+}
+
+export interface MetadataStore {
+  put(id: string, value: string): Promise<void>;
+  get(id: string): Promise<string | null>;
+}
+
+const objects = new Map<string, BucketObject>();
+const rows = new Map<string, string>();
+
+/** The object-storage binding. */
+export function openBucket(): Bucket {
+  return {
+    async put(key, value) {
+      objects.set(key, { key, body: value, size: 0 });
+    },
+    async get(key) {
+      return objects.get(key) ?? null;
+    },
+  };
+}
+
+/** The metadata key-value binding. */
+export function openMetadataStore(): MetadataStore {
+  return {
+    async put(id, value) {
+      rows.set(id, value);
+    },
+    async get(id) {
+      return rows.get(id) ?? null;
+    },
+  };
+}

+ 37 - 0
__tests__/fixtures/ambient-decls-ts/src/lib/queue.ts

@@ -0,0 +1,37 @@
+export interface UploadMessageBody {
+  key: string;
+  metadataId: string;
+  contentType: string;
+}
+
+/**
+ * Publish the follow-up message for a stored upload. Batched so a burst of
+ * uploads does not open one producer call per object.
+ */
+export async function enqueueUploadMessage(body: UploadMessageBody): Promise<void> {
+  const queue = openUploadQueue();
+  await queue.send(body, { contentType: 'json' });
+}
+
+/** Consumer side: process a batch of upload messages. */
+export async function consumeUploadBatch(messages: UploadMessageBody[]): Promise<number> {
+  let handled = 0;
+  for (const message of messages) {
+    if (!message.key) continue;
+    handled += 1;
+  }
+  return handled;
+}
+
+interface UploadQueue {
+  send(body: UploadMessageBody, options: { contentType: string }): Promise<void>;
+}
+
+/** The binding lookup, isolated so tests can swap it. */
+export function openUploadQueue(): UploadQueue {
+  return {
+    async send() {
+      /* binding provided by the runtime */
+    },
+  };
+}

+ 44 - 0
__tests__/fixtures/ambient-decls-ts/src/lib/request.ts

@@ -0,0 +1,44 @@
+export interface ParsedUpload {
+  ok: true;
+  key: string;
+  body: ReadableStream<Uint8Array>;
+  contentType: string;
+  width: number;
+  height: number;
+  format: string;
+}
+
+export interface ParseFailure {
+  ok: false;
+  error: string;
+}
+
+/**
+ * Pull the object key, declared dimensions and the raw body stream off an
+ * upload request. Never buffers the body — the stream is handed straight to
+ * the storage layer.
+ */
+export async function parseUploadRequest(
+  request: Request,
+): Promise<ParsedUpload | ParseFailure> {
+  const url = new URL(request.url);
+  const key = url.searchParams.get('key');
+  if (!key) return { ok: false, error: 'missing key' };
+  if (!request.body) return { ok: false, error: 'missing body' };
+
+  return {
+    ok: true,
+    key,
+    body: request.body as ReadableStream<Uint8Array>,
+    contentType: request.headers.get('content-type') ?? 'application/octet-stream',
+    width: numberParam(url, 'width'),
+    height: numberParam(url, 'height'),
+    format: url.searchParams.get('format') ?? 'jpeg',
+  };
+}
+
+function numberParam(url: URL, name: string): number {
+  const raw = url.searchParams.get(name);
+  const parsed = raw ? Number.parseInt(raw, 10) : 0;
+  return Number.isFinite(parsed) ? parsed : 0;
+}

+ 66 - 0
__tests__/fixtures/ambient-decls-ts/src/routes/upload.ts

@@ -0,0 +1,66 @@
+import { streamBodyToStorage } from '../storage/stream.js';
+import { recordImageMetadata } from '../storage/metadata.js';
+import { enqueueUploadMessage } from '../lib/queue.js';
+import { parseUploadRequest } from '../lib/request.js';
+
+export interface UploadResult {
+  key: string;
+  bytes: number;
+  contentType: string;
+}
+
+/**
+ * Entry point for an upload request: parse it, stream the body into object
+ * storage, record the image metadata, then queue the follow-up work.
+ */
+export async function handleUploadRequest(request: Request): Promise<Response> {
+  const parsed = await parseUploadRequest(request);
+  if (!parsed.ok) {
+    return new Response(JSON.stringify({ error: parsed.error }), { status: 400 });
+  }
+
+  const stored = await streamBodyToStorage(parsed.body, parsed.key, parsed.contentType);
+  const metadata = await recordImageMetadata(stored.key, {
+    width: parsed.width,
+    height: parsed.height,
+    format: parsed.format,
+    bytes: stored.bytes,
+  });
+
+  await enqueueUploadMessage({
+    key: stored.key,
+    metadataId: metadata.id,
+    contentType: stored.contentType,
+  });
+
+  return new Response(JSON.stringify(summarizeUpload(stored, metadata.id)), {
+    status: 201,
+    headers: { 'content-type': 'application/json' },
+  });
+}
+
+/** Shape the client sees back after a successful upload. */
+export function summarizeUpload(stored: UploadResult, metadataId: string) {
+  return {
+    key: stored.key,
+    bytes: stored.bytes,
+    contentType: stored.contentType,
+    metadataId,
+  };
+}
+
+/** Reject uploads whose declared size exceeds the per-account ceiling. */
+export function isWithinUploadLimit(bytes: number, limit: number): boolean {
+  if (!Number.isFinite(bytes) || bytes < 0) return false;
+  return bytes <= limit;
+}
+
+/** Delete-side counterpart, kept here so the route module is not a one-liner. */
+export async function handleDeleteRequest(request: Request, key: string): Promise<Response> {
+  const parsed = await parseUploadRequest(request);
+  if (!parsed.ok) {
+    return new Response(JSON.stringify({ error: parsed.error }), { status: 400 });
+  }
+  await enqueueUploadMessage({ key, metadataId: '', contentType: 'application/x-delete' });
+  return new Response(null, { status: 204 });
+}

+ 54 - 0
__tests__/fixtures/ambient-decls-ts/src/storage/metadata.ts

@@ -0,0 +1,54 @@
+import { openMetadataStore } from '../lib/bucket.js';
+
+export interface ImageMetadataInput {
+  width: number;
+  height: number;
+  format: string;
+  bytes: number;
+}
+
+export interface ImageMetadataRecord extends ImageMetadataInput {
+  id: string;
+  key: string;
+  recordedAt: number;
+}
+
+/**
+ * Record the image metadata for a stored object. Writes go to the metadata
+ * store keyed by object key; the returned record carries the id the queue
+ * message references.
+ */
+export async function recordImageMetadata(
+  key: string,
+  input: ImageMetadataInput,
+): Promise<ImageMetadataRecord> {
+  const store = openMetadataStore();
+  const record: ImageMetadataRecord = {
+    ...input,
+    id: metadataIdFor(key, input),
+    key,
+    recordedAt: 0,
+  };
+  await store.put(record.id, JSON.stringify(record));
+  return record;
+}
+
+/** Deterministic id so a retried upload records the same metadata row. */
+export function metadataIdFor(key: string, input: ImageMetadataInput): string {
+  return `${key}:${input.format}:${input.width}x${input.height}`;
+}
+
+/** Read a metadata record back for the download and listing paths. */
+export async function loadImageMetadata(id: string): Promise<ImageMetadataRecord | null> {
+  const store = openMetadataStore();
+  const raw = await store.get(id);
+  return raw ? (JSON.parse(raw) as ImageMetadataRecord) : null;
+}
+
+/** Normalize a client-declared format string to the canonical set. */
+export function normalizeFormat(format: string): string {
+  const lowered = format.trim().toLowerCase();
+  if (lowered === 'jpg') return 'jpeg';
+  if (lowered === 'tif') return 'tiff';
+  return lowered;
+}

+ 86 - 0
__tests__/fixtures/ambient-decls-ts/src/storage/stream.ts

@@ -0,0 +1,86 @@
+import { openBucket } from '../lib/bucket.js';
+import type { StorageFailure, UploadTelemetry } from './types.js';
+
+export interface StoredObject {
+  key: string;
+  bytes: number;
+  contentType: string;
+}
+
+/**
+ * Stream a request body into object storage without buffering it in memory.
+ * The body is piped through a counting transform so the byte total is known
+ * by the time the put resolves.
+ */
+export async function streamBodyToStorage(
+  body: ReadableStream<Uint8Array>,
+  key: string,
+  contentType: string,
+): Promise<StoredObject> {
+  const bucket = openBucket();
+  const counter = createByteCounter();
+  const piped = body.pipeThrough(counter.transform, { preventClose: false });
+
+  await bucket.put(key, piped, { httpMetadata: { contentType } });
+
+  return { key, bytes: counter.total(), contentType };
+}
+
+/**
+ * A transform stream that counts the bytes flowing through it. Separated from
+ * the pipe above so the byte total can be read after the stream settles.
+ */
+export function createByteCounter() {
+  let total = 0;
+  const transform = new TransformStream<Uint8Array, Uint8Array>({
+    transform(chunk, controller) {
+      total += chunk.byteLength;
+      controller.enqueue(chunk);
+    },
+  });
+  return { transform, total: () => total };
+}
+
+/**
+ * Read a stored object back out of the bucket as a stream, for the download
+ * path. Mirrors the upload side so both directions live in one module.
+ */
+export async function readObjectStream(key: string): Promise<ReadableStream<Uint8Array> | null> {
+  const bucket = openBucket();
+  const object = await bucket.get(key);
+  if (!object) return null;
+  return object.body;
+}
+
+/** Timing/retry record for one stored object, handed to the metrics sink. */
+export function telemetryFor(stored: StoredObject, durationMs: number): UploadTelemetry {
+  return { key: stored.key, bytes: stored.bytes, durationMs, retries: 0 };
+}
+
+/** Describe a failed stage so the caller can report it without re-deriving it. */
+export function storageFailure(
+  key: string,
+  stage: StorageFailure['stage'],
+  message: string,
+): StorageFailure {
+  return { key, stage, message };
+}
+
+/** Cap a stream at `limit` bytes, erroring out rather than storing an overrun. */
+export function limitStream(
+  source: ReadableStream<Uint8Array>,
+  limit: number,
+): ReadableStream<Uint8Array> {
+  let seen = 0;
+  const guard = new TransformStream<Uint8Array, Uint8Array>({
+    transform(chunk, controller) {
+      seen += chunk.byteLength;
+      if (seen > limit) {
+        controller.error(new Error(`upload exceeded ${limit} bytes`));
+        return;
+      }
+      controller.enqueue(chunk);
+    },
+  });
+  return source.pipeThrough(guard);
+}

+ 18 - 0
__tests__/fixtures/ambient-decls-ts/src/storage/types.ts

@@ -0,0 +1,18 @@
+/**
+ * Shared shapes for the storage layer. Declaration-only like the ambient files
+ * under `types/` — but the modules that answer a flow question are typed BY it,
+ * so it is part of that answer's structure rather than a global shim.
+ */
+
+export interface UploadTelemetry {
+  key: string;
+  bytes: number;
+  durationMs: number;
+  retries: number;
+}
+
+export interface StorageFailure {
+  key: string;
+  stage: 'parse' | 'stream' | 'metadata' | 'queue';
+  message: string;
+}

+ 212 - 0
__tests__/fixtures/ambient-decls-ts/types/platform-shims.d.ts

@@ -0,0 +1,212 @@
+// Hand-maintained ambient declarations for the parts of the platform our
+// runtime exposes but the published typings do not cover yet. Edit freely —
+// nothing regenerates this file. Kept alongside the app so module augmentation
+// and the global shims live in one place.
+
+declare global {
+  interface UploadStorage {
+    put(
+      key: string,
+      body: ReadableStream<Uint8Array>,
+      options?: UploadPutOptions,
+    ): Promise<StoredUploadObject>;
+    get(key: string): Promise<StoredUploadObject | null>;
+    head(key: string): Promise<StoredUploadHead | null>;
+    delete(key: string | string[]): Promise<void>;
+    list(options?: UploadListOptions): Promise<UploadListResult>;
+  }
+
+  interface StoredUploadObject {
+    readonly key: string;
+    readonly size: number;
+    readonly etag: string;
+    readonly uploaded: Date;
+    readonly body: ReadableStream<Uint8Array>;
+    readonly contentType: string;
+    readonly metadata?: ImageMetadataShim;
+    arrayBuffer(): Promise<ArrayBuffer>;
+    text(): Promise<string>;
+    json<T>(): Promise<T>;
+  }
+
+  interface StoredUploadHead {
+    readonly key: string;
+    readonly size: number;
+    readonly etag: string;
+    readonly uploaded: Date;
+    readonly contentType: string;
+  }
+
+  interface UploadPutOptions {
+    contentType?: string;
+    cacheControl?: string;
+    customMetadata?: Record<string, string>;
+    checksum?: string;
+    storageClass?: 'standard' | 'infrequent';
+  }
+
+  interface UploadListOptions {
+    prefix?: string;
+    cursor?: string;
+    limit?: number;
+    delimiter?: string;
+    include?: ('metadata' | 'contentType')[];
+  }
+
+  interface UploadListResult {
+    objects: StoredUploadHead[];
+    truncated: boolean;
+    cursor?: string;
+    prefixes: string[];
+  }
+
+  interface ImageMetadataShim {
+    format: string;
+    fileSize: number;
+    width: number;
+    height: number;
+    orientation?: number;
+    colorSpace?: string;
+  }
+
+  interface MetadataRowShim {
+    id: string;
+    key: string;
+    recordedAt: number;
+    format: string;
+    bytes: number;
+    width: number;
+    height: number;
+  }
+
+  interface MetadataStoreShim {
+    put(id: string, value: string, options?: MetadataPutOptions): Promise<void>;
+    get(id: string): Promise<string | null>;
+    getWithMetadata<T>(id: string): Promise<{ value: string | null; metadata: T | null }>;
+    delete(id: string): Promise<void>;
+    list(options?: MetadataListOptions): Promise<MetadataListResult>;
+  }
+
+  interface MetadataPutOptions {
+    expiration?: number;
+    expirationTtl?: number;
+    metadata?: unknown;
+  }
+
+  interface MetadataListOptions {
+    prefix?: string | null;
+    cursor?: string | null;
+    limit?: number;
+  }
+
+  interface MetadataListResult {
+    keys: { name: string; expiration?: number }[];
+    list_complete: boolean;
+    cursor?: string;
+  }
+
+  interface UploadQueueShim<Body = unknown> {
+    send(body: Body, options?: UploadSendOptions): Promise<void>;
+    sendBatch(bodies: Iterable<UploadSendRequest<Body>>): Promise<void>;
+  }
+
+  interface UploadSendOptions {
+    contentType?: UploadContentType;
+    delaySeconds?: number;
+  }
+
+  type UploadContentType = 'text' | 'bytes' | 'json' | 'v8';
+
+  interface UploadSendRequest<Body = unknown> {
+    body: Body;
+    options?: UploadSendOptions;
+  }
+
+  interface UploadMessageShim<Body = unknown> {
+    readonly id: string;
+    readonly timestamp: Date;
+    readonly body: Body;
+    readonly attempts: number;
+    retry(options?: UploadRetryOptions): void;
+    ack(): void;
+  }
+
+  interface UploadRetryOptions {
+    delaySeconds?: number;
+  }
+
+  interface UploadMessageBatch<Body = unknown> {
+    readonly messages: readonly UploadMessageShim<Body>[];
+    readonly queue: string;
+    retryAll(options?: UploadRetryOptions): void;
+    ackAll(): void;
+  }
+
+  interface StreamPipeOptionsShim {
+    preventClose?: boolean;
+    preventAbort?: boolean;
+    preventCancel?: boolean;
+    signal?: AbortSignal;
+  }
+
+  interface ByteCounterShim {
+    readonly transform: TransformStream<Uint8Array, Uint8Array>;
+    total(): number;
+  }
+
+  interface StreamLimitShim {
+    readonly limit: number;
+    readonly seen: number;
+    exceeded(): boolean;
+  }
+
+  interface RequestBodyShim {
+    readonly body: ReadableStream<Uint8Array> | null;
+    readonly bodyUsed: boolean;
+    readonly headers: Headers;
+    readonly url: string;
+    arrayBuffer(): Promise<ArrayBuffer>;
+    formData(): Promise<FormData>;
+    blob(): Promise<Blob>;
+  }
+
+  interface ParsedUploadShim {
+    key: string;
+    contentType: string;
+    width: number;
+    height: number;
+    format: string;
+  }
+
+  interface ImageTransformerShim {
+    transform(transform: ImageTransformShim): ImageTransformerShim;
+    output(options: ImageOutputShim): Promise<ImageResultShim>;
+  }
+
+  interface ImageTransformShim {
+    width?: number;
+    height?: number;
+    fit?: 'scale-down' | 'contain' | 'cover' | 'crop' | 'pad';
+    rotate?: number;
+  }
+
+  interface ImageOutputShim {
+    format?: string;
+    quality?: number;
+    background?: string;
+  }
+
+  interface ImageResultShim {
+    contentType(): string;
+    image(): ReadableStream<Uint8Array>;
+    response(): Response;
+  }
+
+  interface UploadEnvShim {
+    UPLOADS: UploadStorage;
+    METADATA: MetadataStoreShim;
+    UPLOAD_QUEUE: UploadQueueShim<unknown>;
+  }
+}
+
+export {};

+ 271 - 0
__tests__/fixtures/ambient-decls-ts/types/worker-configuration.d.ts

@@ -0,0 +1,271 @@
+// Generated by Wrangler by running `wrangler types` (hash: 4f1c8ad2b90e)
+// Runtime types generated with workerd@1.20260701.0 2026-07-01 nodejs_compat
+declare namespace Cloudflare {
+  interface Env {
+    UPLOADS: R2Bucket;
+    METADATA: KVNamespace;
+    UPLOAD_QUEUE: Queue<UploadMessageBody>;
+    IMAGES: ImagesBinding;
+  }
+}
+
+interface UploadMessageBody {
+  key: string;
+  metadataId: string;
+  contentType: string;
+}
+
+interface R2Bucket {
+  head(key: string): Promise<R2Object | null>;
+  get(key: string, options?: R2GetOptions): Promise<R2ObjectBody | null>;
+  put(
+    key: string,
+    value: ReadableStream | ArrayBuffer | string | null,
+    options?: R2PutOptions,
+  ): Promise<R2Object>;
+  delete(keys: string | string[]): Promise<void>;
+  list(options?: R2ListOptions): Promise<R2Objects>;
+  createMultipartUpload(key: string, options?: R2MultipartOptions): Promise<R2MultipartUpload>;
+  resumeMultipartUpload(key: string, uploadId: string): R2MultipartUpload;
+}
+
+interface R2Object {
+  readonly key: string;
+  readonly version: string;
+  readonly size: number;
+  readonly etag: string;
+  readonly httpEtag: string;
+  readonly checksums: R2Checksums;
+  readonly uploaded: Date;
+  readonly httpMetadata?: R2HTTPMetadata;
+  readonly customMetadata?: Record<string, string>;
+  readonly range?: R2Range;
+  readonly storageClass: string;
+  writeHttpMetadata(headers: Headers): void;
+}
+
+interface R2ObjectBody extends R2Object {
+  get body(): ReadableStream;
+  get bodyUsed(): boolean;
+  arrayBuffer(): Promise<ArrayBuffer>;
+  text(): Promise<string>;
+  json<T>(): Promise<T>;
+  blob(): Promise<Blob>;
+  bytes(): Promise<Uint8Array>;
+}
+
+interface R2GetOptions {
+  onlyIf?: R2Conditional | Headers;
+  range?: R2Range;
+  ssecKey?: ArrayBuffer | string;
+}
+
+interface R2PutOptions {
+  onlyIf?: R2Conditional | Headers;
+  httpMetadata?: R2HTTPMetadata | Headers;
+  customMetadata?: Record<string, string>;
+  md5?: ArrayBuffer | string;
+  sha1?: ArrayBuffer | string;
+  sha256?: ArrayBuffer | string;
+  storageClass?: string;
+  ssecKey?: ArrayBuffer | string;
+}
+
+interface R2ListOptions {
+  limit?: number;
+  prefix?: string;
+  cursor?: string;
+  delimiter?: string;
+  startAfter?: string;
+  include?: ('httpMetadata' | 'customMetadata')[];
+}
+
+interface R2Objects {
+  objects: R2Object[];
+  truncated: boolean;
+  cursor?: string;
+  delimitedPrefixes: string[];
+}
+
+interface R2MultipartOptions {
+  httpMetadata?: R2HTTPMetadata | Headers;
+  customMetadata?: Record<string, string>;
+  storageClass?: string;
+}
+
+interface R2MultipartUpload {
+  readonly key: string;
+  readonly uploadId: string;
+  uploadPart(
+    partNumber: number,
+    value: ReadableStream | ArrayBuffer | string | Blob,
+  ): Promise<R2UploadedPart>;
+  abort(): Promise<void>;
+  complete(uploadedParts: R2UploadedPart[]): Promise<R2Object>;
+}
+
+interface R2UploadedPart {
+  partNumber: number;
+  etag: string;
+}
+
+interface R2HTTPMetadata {
+  contentType?: string;
+  contentLanguage?: string;
+  contentDisposition?: string;
+  contentEncoding?: string;
+  cacheControl?: string;
+  cacheExpiry?: Date;
+}
+
+interface R2Checksums {
+  readonly md5?: ArrayBuffer;
+  readonly sha1?: ArrayBuffer;
+  readonly sha256?: ArrayBuffer;
+  toJSON(): R2StringChecksums;
+}
+
+interface R2StringChecksums {
+  md5?: string;
+  sha1?: string;
+  sha256?: string;
+}
+
+interface R2Conditional {
+  etagMatches?: string;
+  etagDoesNotMatch?: string;
+  uploadedBefore?: Date;
+  uploadedAfter?: Date;
+  secondsGranularity?: boolean;
+}
+
+interface R2Range {
+  offset?: number;
+  length?: number;
+  suffix?: number;
+}
+
+interface KVNamespace<Key extends string = string> {
+  get(key: Key, options?: Partial<KVNamespaceGetOptions<undefined>>): Promise<string | null>;
+  getWithMetadata<Metadata = unknown>(
+    key: Key,
+    options?: Partial<KVNamespaceGetOptions<undefined>>,
+  ): Promise<KVNamespaceGetWithMetadataResult<string, Metadata>>;
+  put(
+    key: Key,
+    value: string | ArrayBuffer | ArrayBufferView | ReadableStream,
+    options?: KVNamespacePutOptions,
+  ): Promise<void>;
+  delete(key: Key): Promise<void>;
+  list<Metadata = unknown>(
+    options?: KVNamespaceListOptions,
+  ): Promise<KVNamespaceListResult<Metadata, Key>>;
+}
+
+interface KVNamespaceGetOptions<Type> {
+  type: Type;
+  cacheTtl?: number;
+}
+
+interface KVNamespacePutOptions {
+  expiration?: number;
+  expirationTtl?: number;
+  metadata?: unknown | null;
+}
+
+interface KVNamespaceListOptions {
+  limit?: number;
+  prefix?: string | null;
+  cursor?: string | null;
+}
+
+interface KVNamespaceListResult<Metadata, Key extends string = string> {
+  keys: KVNamespaceListKey<Metadata, Key>[];
+  list_complete: boolean;
+  cursor?: string;
+}
+
+interface KVNamespaceListKey<Metadata, Key extends string = string> {
+  name: Key;
+  expiration?: number;
+  metadata?: Metadata;
+}
+
+interface KVNamespaceGetWithMetadataResult<Value, Metadata> {
+  value: Value | null;
+  metadata: Metadata | null;
+  cacheStatus: string | null;
+}
+
+interface Queue<Body = unknown> {
+  send(message: Body, options?: QueueSendOptions): Promise<void>;
+  sendBatch(messages: Iterable<MessageSendRequest<Body>>): Promise<void>;
+}
+
+interface QueueSendOptions {
+  contentType?: QueueContentType;
+  delaySeconds?: number;
+}
+
+type QueueContentType = 'text' | 'bytes' | 'json' | 'v8';
+
+interface MessageSendRequest<Body = unknown> {
+  body: Body;
+  options?: QueueSendOptions;
+}
+
+interface Message<Body = unknown> {
+  readonly id: string;
+  readonly timestamp: Date;
+  readonly body: Body;
+  readonly attempts: number;
+  retry(options?: QueueRetryOptions): void;
+  ack(): void;
+}
+
+interface QueueRetryOptions {
+  delaySeconds?: number;
+}
+
+interface MessageBatch<Body = unknown> {
+  readonly messages: readonly Message<Body>[];
+  readonly queue: string;
+  retryAll(options?: QueueRetryOptions): void;
+  ackAll(): void;
+}
+
+interface ImagesBinding {
+  info(stream: ReadableStream<Uint8Array>): Promise<ImageMetadata>;
+  input(stream: ReadableStream<Uint8Array>): ImageTransformer;
+}
+
+interface ImageMetadata {
+  format: string;
+  fileSize: number;
+  width: number;
+  height: number;
+}
+
+interface ImageTransformer {
+  transform(transform: ImageTransform): ImageTransformer;
+  output(options: ImageOutputOptions): Promise<ImageTransformationResult>;
+}
+
+interface ImageTransform {
+  width?: number;
+  height?: number;
+  fit?: 'scale-down' | 'contain' | 'cover' | 'crop' | 'pad';
+  rotate?: number;
+}
+
+interface ImageOutputOptions {
+  format?: string;
+  quality?: number;
+  background?: string;
+}
+
+interface ImageTransformationResult {
+  contentType(): string;
+  image(): ReadableStream<Uint8Array>;
+  response(): Response;
+}

+ 149 - 0
docs/benchmarks/explore-declaration-only-cg28.md

@@ -0,0 +1,149 @@
+# Deterministic measurement — declaration-only files in the explore envelope (task CG-28)
+
+**Date:** 2026-08-06 · **Baseline:** `feature/CG-24` @ `463f6e7` ·
+**Harness:** `scripts/agent-eval/probe-decl-only.mjs` against a hermetic fixture
+(`__tests__/fixtures/ambient-decls-ts/`, copied to a temp dir and indexed per run, so two runs on
+one build give identical numbers), plus `probe-suite-envelope.mjs` and a corpus-wide flag-rate
+survey for the regression side. No agent A/B: the claim under test is which FILES get selected and
+in what order, and the agent runs are far too noisy to see that.
+
+**Verdict, both halves:**
+
+- **The motivating file is already handled — CG-25 credited.** The Wrangler `worker-configuration.d.ts`
+  that opened this issue is demoted by the generated penalty alone, worth 15–46 points of envelope
+  share on the four flow queries measured. No new mechanism needed for it.
+- **The narrower gap is real and was fixed.** A declaration file with NO banner carried `pen 1.00`,
+  took **rank #1 and 51% of delivered source** on a prose flow query, and displaced the flow's own
+  entry file out of the response entirely. It is now damped — but only when nothing in the index
+  depends on it, which is the condition that makes the rule safe.
+
+---
+
+## The fixture
+
+`__tests__/fixtures/ambient-decls-ts/` — an upload path (route → stream → metadata → queue) with
+four declaration-shaped files competing against it for one envelope. All four declare nothing but
+`interface`/`type_alias` and have no bodies; they differ only in the two properties under test.
+
+| file | banner | depended on | lines |
+|---|---|---|---|
+| `types/worker-configuration.d.ts` | Wrangler | no | 271 |
+| `types/platform-shims.d.ts` | none | no | 212 |
+| `src/storage/types.ts` | none | **yes** (2 imports, 3 references) | 18 |
+| implementation (`routes/`, `storage/`, `lib/`) | — | — | 37–71 each |
+
+The declaration files carry the same generic identifiers the prose queries use — `Body`, `Message`,
+`ImageMetadata`, `ReadableStream`, `Upload*` — which is the whole mechanism of the original report.
+
+## Result 1 — what CG-25 is worth (the obsolescence leg)
+
+Same fixture, same queries, one variable: `--variant strip-banner` deletes the two banner COMMENT
+lines from `worker-configuration.d.ts` and changes nothing else, so the two declaration files become
+indistinguishable to the ranker. Delivered share of the envelope for that file:
+
+| query | with banner | banner stripped |
+|---|---|---|
+| flow-upload | not a candidate | 15.1% (2,271 chars) |
+| flow-pipe | not a candidate | 38.5% (4,398 chars) |
+| flow-generic | cliffed to a pointer, 0 chars | 35.1% (3,076 chars) |
+| flow-queue | 9.4% via clusters (1,264 chars) | **46.1%, rank #1, whole file** (7,390 chars) |
+
+The generated penalty alone is the difference between "rank #1 and nearly half the answer" and
+"named in the not-shown list". **The file this issue was filed about needs nothing further.**
+
+## Result 2 — the gap that survived
+
+`platform-shims.d.ts` — hand-written, no banner — on the `feature/CG-24` tip:
+
+| query | rank | score | pen | delivered |
+|---|---|---|---|---|
+| flow-upload | **#1** | 53.0 | 1.00 | 6,044 chars (**50.7%**) |
+| flow-queue | **#1** | 21.0 | 1.00 | 6,044 chars (44.9%) |
+
+On `flow-upload` the response carried three files and `src/routes/upload.ts` — the handler the
+question is *about* — was not one of them. That is the CG-24 epic symptom, reproduced with no
+generated banner anywhere in it.
+
+Note also: `.pyi` is **not an indexed extension**, so Python stubs never enter the graph and cannot
+take an envelope. That third of the issue's premise does not occur today.
+
+## The mechanism, and why it is drawn this tight
+
+`AMBIENT_DECLARATION_RANK_PENALTY` (0.5) multiplies score and graph mass in `rankPenalty`, for files
+`QueryBuilder.getAmbientDeclarationPathsAmong` flags. Four conditions, all required — the first
+three were the obvious rule, the fourth is the one that makes it safe:
+
+1. declares ≥1 symbol;
+2. **every** declared symbol is type-level (`interface`, `type_alias`, `enum`, `enum_member`,
+   `namespace`);
+3. originates no `calls`/`instantiates` edge;
+4. **nothing outside the file points at it.**
+
+Conditions 2 and 4 were both forced by measurement, not taste:
+
+**Why not just "no callables" (condition 2).** Surveyed across the corpus, a rule of "declares no
+callable and calls nothing" flags **1.1%–18.0%** of files, and what it catches is real source:
+okhttp's `SocketPolicy.kt` (19 declarations, a Kotlin sealed hierarchy), `BrotliInterceptor.kt`,
+`tokio/src/runtime/mod.rs`, Alamofire's umbrella `Alamofire.swift`, and all 500+ of django's
+`conf/locale/*/formats.py` constant tables. Requiring every symbol to be type-level drops that to
+**0%–4%**.
+
+**Why "nothing depends on it" (condition 4).** Without it the rule also flags
+`__tests__/fixtures/displacement-ts/src/pipeline/types.ts` — pure interfaces, no bodies, structurally
+identical to an ambient shim — and demoting it **broke the CG-31 displacement gate**, which is a
+different invariant entirely. That file carries 13 inbound imports and 21 references: the pipeline
+stages that answer a query about the pipeline are typed *by* it, so it is part of that answer's
+structure. The ambient shims carry **zero** inbound edges — reachable by name, attached to nothing.
+That is the real distinction, and the graph already holds it.
+
+**The counter-case guard.** A query that NAMES a declared type is a question about the declaration,
+so its file is exempt and ranks at full weight. Only shape-precise tokens count (the same
+NL-stopword reasoning as named-seed selection) — "…the file **body**…" must not exempt a `Body`
+interface it never meant to name. This needed its own set: `namedSeedIds` is callable-only by
+construction, so a type can never become a named seed.
+
+**No double-charging.** Generated and ambient-declaration are combined with `Math.min`, not
+multiplied. A generated `.d.ts` has one property that two signals happen to see; charging it twice
+(0.3 × 0.5 = 0.15) is how a file gets cliffed out of answers where it is genuinely relevant. The
+low-value multiplier is orthogonal and still compounds.
+
+## Result 3 — after the fix
+
+| query | before | after |
+|---|---|---|
+| flow-upload | rank **#1**, 50.7% | rank **#2**, 38.6% — and `src/routes/upload.ts` now delivered (2,417 chars) |
+| flow-queue | rank **#1**, 44.9% | rank **#3**, 41.3% |
+| flow-pipe / flow-generic | not a candidate | unchanged |
+| type-shim (`UploadStorage StoredUploadObject ImageMetadataShim`) | rank #1, `pen 1.00` | **unchanged** — exempt |
+| type-prose (*what does the UploadStorage interface declare…*) | rank #1, `pen 1.00` | **unchanged** — exempt |
+
+The byte share falls less than the rank does, and that is the correct outcome rather than a weak
+fix: on this fixture every implementation file already delivers its entire contents, so the
+declaration file is filling envelope nobody else needs. What it was actually taking was a **file
+slot** — which is why the entry file came back. The issue explicitly forbids suppression, and a
+damped file is still a candidate, still named in the response, and one follow-up explore away.
+
+## Regression evidence
+
+- **`probe-suite-envelope.mjs`, 6 repos, new build vs a clean `feature/CG-24` baseline build:
+  byte-identical.** django 20,878 · excalidraw 19,652 · okhttp 18,870 · tokio 21,607 · gin 10,776 ·
+  alamofire 11,662 source chars, same file counts, on both builds.
+- **VS Code** — the repo the issue names for `.d.ts` surface — across five flow and type queries:
+  **zero** ambient-declaration files reach the ranked candidate set, so the output cannot differ.
+- **Corpus-wide flag rate:** django 0.00% · okhttp 0.00% · gin 0.00% · alamofire 0.00% ·
+  tokio 0.12% · vscode 0.53% · excalidraw 0.74%. What it catches is `global.d.ts`, `vite-env.d.ts`,
+  `css.d.ts`, unreferenced vendored headers and test fixtures — exactly the intended shape.
+- `probe-allocation.mjs`: `payroll-go` PASS, `self-query` PASS.
+- Full suite: **178 files, 2,978 passed**, 6 skipped.
+
+The change is inert everywhere the shape does not occur, which is most places. That is the point:
+the defect is real but rare, and the mechanism costs nothing where it does not apply.
+
+## Reproducing
+
+```bash
+npm run build
+node scripts/agent-eval/probe-decl-only.mjs                        # as committed
+node scripts/agent-eval/probe-decl-only.mjs --variant strip-banner # what CG-25 is worth
+npx vitest run __tests__/explore-declaration-only.test.ts          # the standing gate
+```

+ 198 - 0
scripts/agent-eval/probe-decl-only.mjs

@@ -0,0 +1,198 @@
+#!/usr/bin/env node
+/**
+ * CG-28 measurement probe — what a DECLARATION-ONLY file takes from an explore
+ * envelope, with and without a generated banner.
+ *
+ * The issue was filed because a Wrangler `worker-configuration.d.ts` scored 49
+ * at `pen 1.00` and took 60.7% of an envelope on generic identifier overlap
+ * (`ReadableStream`, `Body`, `ImageMetadata`, `Message`, …) with a prose query.
+ * CG-25 has since taught `GENERATED_CONTENT_PATTERNS` the Wrangler banner, so
+ * the first thing to measure is whether that alone settles it — it does, and
+ * this probe quantifies it. What CG-25 does NOT cover is a declaration-only file
+ * that carries no banner at all: a hand-maintained ambient `.d.ts`, vendored
+ * typings, module augmentation. This probe puts both shapes in ONE fixture
+ * against ONE envelope so the banner is the only difference between them.
+ * (`.pyi` is not an indexed extension, so Python stubs never enter the graph.)
+ *
+ * Findings and the full regression evidence:
+ * `docs/benchmarks/explore-declaration-only-cg28.md`.
+ *
+ * Fixture: `__tests__/fixtures/ambient-decls-ts/` — an upload path (route →
+ * stream → metadata → queue) competing with:
+ *   types/worker-configuration.d.ts  declaration-only, Wrangler banner (CG-25)
+ *   types/platform-shims.d.ts        declaration-only, hand-written, NO banner
+ *   src/storage/types.ts             declaration-only but IMPORTED — the control
+ *                                    that must never be damped
+ *
+ * Variants (`--variant`):
+ *   both          as committed — the controlled comparison
+ *   strip-banner  the banner is deleted from worker-configuration.d.ts, so the
+ *                 two declaration files differ in NOTHING the ranker can see;
+ *                 the delta against `both` is exactly what CG-25 buys
+ *
+ * Usage (needs a current `npm run build`):
+ *   node scripts/agent-eval/probe-decl-only.mjs
+ *   node scripts/agent-eval/probe-decl-only.mjs --variant strip-banner
+ *   node scripts/agent-eval/probe-decl-only.mjs --json
+ *   node scripts/agent-eval/probe-decl-only.mjs --query "..."
+ */
+import { cpSync, mkdtempSync, readFileSync, writeFileSync, rmSync, existsSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { dirname, join, resolve } from 'node:path';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+const REPO_ROOT = resolve(HERE, '../..');
+const FIXTURE = join(REPO_ROOT, '__tests__/fixtures/ambient-decls-ts');
+
+const GENERATED_DECL = 'types/worker-configuration.d.ts';
+const HANDWRITTEN_DECL = 'types/platform-shims.d.ts';
+
+/**
+ * The query shapes. The flow ones are prose and name no symbol — the shape that
+ * let the original file in. The last one is the counter-case the issue requires:
+ * a question genuinely ABOUT a declared type must still reach the declaration.
+ */
+const QUERIES = [
+  { id: 'flow-upload', kind: 'flow', text: 'how does an upload request stream the file body to storage and record image metadata' },
+  { id: 'flow-pipe', kind: 'flow', text: 'where does the upload body get piped into the bucket and the metadata written' },
+  { id: 'flow-generic', kind: 'flow', text: 'how are streams and messages and image metadata handled for uploads' },
+  { id: 'flow-queue', kind: 'flow', text: 'what happens after an object is stored and the follow-up message is queued' },
+  { id: 'type-shim', kind: 'type', text: 'UploadStorage StoredUploadObject ImageMetadataShim' },
+  { id: 'type-prose', kind: 'type', text: 'what does the UploadStorage interface declare for putting an object' },
+];
+
+const argv = process.argv.slice(2);
+const asJson = argv.includes('--json');
+const at = (flag) => { const i = argv.indexOf(flag); return i >= 0 ? argv[i + 1] : undefined; };
+const VARIANT = at('--variant') ?? 'both';
+const ONE_QUERY = at('--query');
+const ONE_ID = at('--only');
+
+const say = (s = '') => { if (!asJson) console.log(s); };
+const num = (n) => Math.round(n).toLocaleString('en-US');
+const pct = (f) => `${(f * 100).toFixed(1)}%`;
+
+if (!existsSync(join(REPO_ROOT, 'dist/index.js'))) {
+  console.error('dist/ not built — run `npm run build` first.');
+  process.exit(2);
+}
+const load = (rel) => import(pathToFileURL(resolve(REPO_ROOT, rel)).href);
+const idxMod = await load('dist/index.js');
+const toolsMod = await load('dist/mcp/tools.js');
+const CodeGraph = idxMod.default?.default ?? idxMod.default ?? idxMod.CodeGraph;
+const ToolHandler = toolsMod.ToolHandler ?? toolsMod.default?.ToolHandler;
+
+/** Copy the fixture, apply the variant, index it. Hermetic per run. */
+function materialize(variant) {
+  const dir = mkdtempSync(join(tmpdir(), 'cg-decl-'));
+  cpSync(FIXTURE, dir, { recursive: true });
+  rmSync(join(dir, '.codegraph'), { recursive: true, force: true });
+  if (variant === 'strip-banner') {
+    const p = join(dir, GENERATED_DECL);
+    // Drop only the banner comment lines; every declaration stays.
+    const kept = readFileSync(p, 'utf8').split('\n').filter((l) => !/^\/\/ .*(Generated by Wrangler|Runtime types generated)/.test(l));
+    writeFileSync(p, kept.join('\n'));
+  } else if (variant !== 'both') {
+    rmSync(dir, { recursive: true, force: true });
+    throw new Error(`unknown --variant ${variant} (both | strip-banner)`);
+  }
+  return dir;
+}
+
+const queries = ONE_QUERY
+  ? [{ id: 'custom', kind: 'flow', text: ONE_QUERY }]
+  : QUERIES.filter((q) => !ONE_ID || q.id === ONE_ID);
+
+const dir = materialize(VARIANT);
+let rows;
+try {
+  let cg = CodeGraph.initSync(dir);
+  await cg.indexAll();
+  cg.close?.();
+
+  const sidecar = join(dir, 'diag.jsonl');
+  rows = [];
+  for (const q of queries) {
+    rmSync(sidecar, { force: true });
+    process.env.CODEGRAPH_EXPLORE_DEBUG = sidecar;
+    cg = CodeGraph.openSync(dir);
+    const res = await new ToolHandler(cg).execute('codegraph_explore', { query: q.text });
+    const text = res.content?.[0]?.text ?? '';
+    cg.close?.();
+    delete process.env.CODEGRAPH_EXPLORE_DEBUG;
+    const report = JSON.parse(readFileSync(sidecar, 'utf8').trim().split('\n').pop());
+
+    const pick = (path) => {
+      const f = report.files.find((x) => x.path === path);
+      if (!f) return null;
+      return {
+        path, rank: f.rank, score: f.score, graph: f.graphScore, hits: f.termHits,
+        penalty: f.penalty, generated: f.generated, render: f.render,
+        named: f.named, entry: f.entry, central: f.central,
+        allocatedShare: f.allocatedShare, share: f.share,
+        emitted: f.emittedChars, final: f.finalChars, skipped: f.skipped,
+      };
+    };
+    const declPaths = new Set([GENERATED_DECL, HANDWRITTEN_DECL]);
+    const totalSource = report.files.reduce((a, f) => a + f.finalChars, 0);
+    const declSource = report.files
+      .filter((f) => declPaths.has(f.path))
+      .reduce((a, f) => a + f.finalChars, 0);
+    // "Named in the response but carrying no source" is the correct outcome for
+    // a cliffed declaration file — the agent can still fetch it in one call.
+    const namedInResponse = (p) => text.includes(p);
+
+    rows.push({
+      query: q.id, kind: q.kind, text: q.text,
+      envelope: report.envelope,
+      generatedDecl: pick(GENERATED_DECL),
+      handwrittenDecl: pick(HANDWRITTEN_DECL),
+      declSourceShare: totalSource > 0 ? declSource / totalSource : 0,
+      implSourceShare: totalSource > 0 ? (totalSource - declSource) / totalSource : 0,
+      topFile: report.files.filter((f) => f.finalChars > 0).sort((a, b) => b.finalChars - a.finalChars)[0]?.path ?? null,
+      generatedNamed: namedInResponse(GENERATED_DECL),
+      handwrittenNamed: namedInResponse(HANDWRITTEN_DECL),
+      files: report.files
+        .filter((f) => f.emittedChars > 0 || f.finalChars > 0)
+        .map((f) => ({ rank: f.rank, path: f.path, score: f.score, graph: f.graphScore, hits: f.termHits, penalty: f.penalty, generated: f.generated, declOnly: f.ambientDeclaration, named: f.named, entry: f.entry, central: f.central, render: f.render, final: f.finalChars, share: f.share })),
+    });
+  }
+} finally {
+  rmSync(dir, { recursive: true, force: true });
+}
+
+if (asJson) {
+  console.log(JSON.stringify({ variant: VARIANT, rows }, null, 2));
+} else {
+  say(`variant  ${VARIANT}`);
+  say('');
+  for (const r of rows) {
+    say(`── ${r.query} [${r.kind}]  "${r.text}"`);
+    say(`   envelope ${num(r.envelope.chars)} chars · decl-only files hold ${pct(r.declSourceShare)} of delivered source`);
+    say('    #  deliv%    bytes  score    graph  hits  pen   gen  flags               render     file');
+    for (const f of r.files) {
+      const flags = [f.named && "named", f.entry && "entry", f.central && "central", f.declOnly && "decl-only"].filter(Boolean).join(" ") || "-";
+      say(
+        '   ' + String(f.rank).padStart(2) + '  ' +
+        pct(f.share).padStart(6) + '  ' +
+        num(f.final).padStart(7) + '  ' +
+        Number(f.score).toFixed(1).padStart(5) + '  ' +
+        f.graph.toFixed(5).padStart(7) + '  ' +
+        String(f.hits).padStart(4) + '  ' +
+        f.penalty.toFixed(2).padStart(4) + '  ' +
+        (f.generated ? ' ✓ ' : '   ') + '  ' +
+        flags.padEnd(18) + '  ' +
+        (f.render ?? '-').padEnd(9) + '  ' +
+        f.path,
+      );
+    }
+    for (const [label, d, named] of [
+      ['generated  ', r.generatedDecl, r.generatedNamed],
+      ['handwritten', r.handwrittenDecl, r.handwrittenNamed],
+    ]) {
+      say(`   ${label} ${d ? `rank #${d.rank}, score ${Number(d.score).toFixed(1)}, pen ${d.penalty.toFixed(2)}, ${num(d.final)} chars (${pct(d.share)})${d.final === 0 ? ` — ${d.skipped ?? d.render ?? 'not rendered'}` : ''}` : 'not a candidate'}${named ? ' · named in response' : ''}`);
+    }
+    say('');
+  }
+}

+ 95 - 0
src/db/queries.ts

@@ -1944,6 +1944,101 @@ export class QueryBuilder {
     return (filePath: string) => flagged.has(filePath) || isGeneratedFile(filePath);
   }
 
+  /**
+   * Which of `filePaths` are AMBIENT DECLARATION files — they declare nothing
+   * but types, and nothing in the index depends on them (CG-28). A hand-written
+   * ambient `.d.ts` of global shims, a vendored typings file, module
+   * augmentation: reachable only by name, structurally attached to nothing.
+   *
+   * Structural, not extension-based, so a hand-written `types.ts` and a `.d.ts`
+   * are judged by the same rule and a `.d.ts` that does declare a class or a
+   * const is (correctly) not caught. Four conditions, all required:
+   *
+   *   1. it declares at least one symbol — an empty or unparsed file is not a
+   *      declaration file, it is a file we know nothing about;
+   *   2. EVERY declared symbol is a type-level kind (interface / type alias /
+   *      enum / namespace). The narrowness is deliberate and measured: a rule
+   *      of "no callables" alone flags 1–18% of a repo, including Kotlin sealed
+   *      classes, Rust `mod.rs` re-exports and django's locale constant tables —
+   *      real source that must not be demoted. This rule flags 0–4%;
+   *   3. no symbol in it originates a `calls`/`instantiates` edge — the direct
+   *      evidence that nothing here has a body;
+   *   4. NOTHING ELSE IN THE INDEX points at it. This is the condition that
+   *      separates an ambient shim from a working type module, and it is why
+   *      the flag is narrow enough to be safe: `displacement-ts`'s pipeline
+   *      `types.ts` passes 1–3 identically but carries 13 inbound imports and
+   *      21 references, so the files that answer a query about the pipeline are
+   *      typed BY it — it is part of that answer's structure. An ambient
+   *      `declare global` shim has zero. Deliberately index-wide rather than
+   *      restricted to the candidate list: the file that imports it is usually
+   *      not itself a candidate.
+   *
+   * Bounded-lookup like {@link getGeneratedPathsAmong}: callers hold a ranked
+   * candidate list, so this is a partial-index probe over a handful of paths.
+   */
+  getAmbientDeclarationPathsAmong(filePaths: Iterable<string>): Set<string> {
+    const unique = [...new Set(filePaths)];
+    const found = new Set<string>();
+    if (unique.length === 0) return found;
+
+    for (let i = 0; i < unique.length; i += SQLITE_PARAM_CHUNK_SIZE) {
+      const chunk = unique.slice(i, i + SQLITE_PARAM_CHUNK_SIZE);
+      const placeholders = chunk.map(() => '?').join(',');
+      // `file`/`import`/`export`/`parameter` are structural bookkeeping, not
+      // things the file declares, so they neither qualify nor disqualify.
+      const rows = this.db
+        .prepare(`
+          SELECT file_path,
+                 SUM(CASE WHEN kind NOT IN ('file','import','export','parameter')
+                          THEN 1 ELSE 0 END) AS declared,
+                 SUM(CASE WHEN kind IN ('interface','type_alias','enum','enum_member','namespace')
+                          THEN 1 ELSE 0 END) AS typeDeclared
+          FROM nodes
+          WHERE file_path IN (${placeholders})
+          GROUP BY file_path
+        `)
+        .all(...chunk) as Array<{ file_path: string; declared: number; typeDeclared: number }>;
+      let candidates = rows
+        .filter((r) => r.declared > 0 && r.declared === r.typeDeclared)
+        .map((r) => r.file_path);
+      if (candidates.length === 0) continue;
+
+      const disqualify = (sql: string): void => {
+        if (candidates.length === 0) return;
+        const hit = new Set(
+          (this.db
+            .prepare(sql.replace('$IN$', candidates.map(() => '?').join(',')))
+            .all(...candidates) as Array<{ file_path: string }>).map((r) => r.file_path),
+        );
+        candidates = candidates.filter((p) => !hit.has(p));
+      };
+      // (3) originates behaviour
+      disqualify(`
+        SELECT DISTINCT n.file_path AS file_path
+        FROM edges e JOIN nodes n ON n.id = e.source
+        WHERE e.kind IN ('calls','instantiates') AND n.file_path IN ($IN$)
+      `);
+      // (4) something outside the file depends on it
+      disqualify(`
+        SELECT DISTINCT t.file_path AS file_path
+        FROM edges e JOIN nodes t ON t.id = e.target JOIN nodes s ON s.id = e.source
+        WHERE t.file_path IN ($IN$) AND s.file_path <> t.file_path
+      `);
+      for (const path of candidates) found.add(path);
+    }
+    return found;
+  }
+
+  /**
+   * A reusable `(path) => boolean` ambient-declaration test over a bounded
+   * candidate list — the shape a ranking comparator wants: one query up front,
+   * O(1) per comparison.
+   */
+  ambientDeclarationPredicateFor(filePaths: Iterable<string>): (filePath: string) => boolean {
+    const flagged = this.getAmbientDeclarationPathsAmong(filePaths);
+    return (filePath: string) => flagged.has(filePath);
+  }
+
   /** How many indexed files carry the generated flag. Surfaced by `status`. */
   countGeneratedFiles(): number {
     const row = this.db

+ 13 - 0
src/index.ts

@@ -1550,6 +1550,19 @@ export class CodeGraph {
     return this.queries.generatedPredicateFor(filePaths);
   }
 
+  /**
+   * A `(path) => boolean` ambient-declaration test over a BOUNDED candidate
+   * list: true for a file that declares nothing but types, originates no call
+   * edge, and that nothing in the index depends on — an ambient `.d.ts` of
+   * global shims, vendored typings, module augmentation (CG-28). Structural
+   * rather than extension-based, and deliberately narrow: see
+   * `QueryBuilder.getAmbientDeclarationPathsAmong` for why each condition is
+   * there, in particular why a `types.ts` the codebase imports is NOT flagged.
+   */
+  ambientDeclarationFilePredicate(filePaths: Iterable<string>): (filePath: string) => boolean {
+    return this.queries.ambientDeclarationPredicateFor(filePaths);
+  }
+
   /** How many indexed files are flagged tool-generated. Reported by `status`. */
   getGeneratedFileCount(): number {
     return this.queries.countGeneratedFiles();

+ 8 - 0
src/mcp/explore-diagnostics.ts

@@ -66,6 +66,12 @@ export interface ExploreCandidateMeta {
   spine: boolean;
   lowValue: boolean;
   generated: boolean;
+  /**
+   * Nothing but type declarations in this file, and nothing in the index
+   * depends on it (CG-28) — it cannot answer a flow question, so it ranks on
+   * discounted signals unless the query named one of the types it declares.
+   */
+  ambientDeclaration: boolean;
   /**
    * Multiplier `rankPenalty` applied to BOTH `score` and `graphScore` (1 = no
    * penalty). Generated and test/i18n files rank on discounted signals, so the
@@ -579,6 +585,7 @@ export class ExploreDiagnostics {
           spine: r.spine,
           lowValue: r.lowValue,
           generated: r.generated,
+          ambientDeclaration: r.ambientDeclaration,
           penalty: round6(r.penalty),
           kinds: r.kinds,
           allowance: r.allowance,
@@ -796,5 +803,6 @@ function flagString(f: ExploreDiagnosticFile): string {
   if (f.spine) flags.push('spine');
   if (f.lowValue) flags.push('low-value');
   if (f.generated) flags.push('generated');
+  if (f.ambientDeclaration) flags.push('ambient-decl');
   return flags.join(' ') || '-';
 }

+ 70 - 3
src/mcp/tools.ts

@@ -409,6 +409,36 @@ const GENERATED_RANK_PENALTY = 0.3;
  * that case: down-weighted rather than removed.
  */
 const LOW_VALUE_RANK_PENALTY = 0.5;
+/**
+ * Ambient declaration files — a hand-written `.d.ts` of global shims, vendored
+ * typings, module augmentation (CG-28). Declares nothing but types, and nothing
+ * in the index depends on it.
+ *
+ * Such a file cannot answer a FLOW question no matter how much its identifiers
+ * overlap the query: no bodies, no call edges, no behaviour, and nothing typed
+ * by it. Its ceiling of usefulness is a type signature, and one follow-up
+ * explore fetches that. But the identifiers it declares are exactly the generic
+ * ones a prose question uses (`Body`, `Message`, `ImageMetadata`,
+ * `ReadableStream`), so on term overlap it out-scores the implementation and
+ * takes the envelope — measured at rank #1 and 51% of delivered source, with
+ * the flow's own entry file getting none.
+ *
+ * Softer than {@link GENERATED_RANK_PENALTY} on purpose: "generated" is a claim
+ * about provenance the file itself makes, while this is an inference about what
+ * a file can be USEFUL for. A demoted declaration file that is still the best
+ * candidate should keep its place; the penalty only has to stop it beating real
+ * implementation. It does NOT stack with the generated penalty (see rankPenalty)
+ * — penalising twice for the same property is how a file gets cliffed out of
+ * answers where it is genuinely relevant.
+ */
+const AMBIENT_DECLARATION_RANK_PENALTY = 0.5;
+/**
+ * The type-level NodeKinds. Must stay in step with the kind list in
+ * `QueryBuilder.getAmbientDeclarationPathsAmong` — that query decides which
+ * files are ambient declarations, this set decides which symbols in them the
+ * agent can name to lift the penalty back off.
+ */
+const DECLARATION_KINDS = new Set(['interface', 'type_alias', 'enum', 'enum_member', 'namespace']);
 
 /**
  * Score floor: `clamp(topScore * FRACTION, ABSOLUTE, MAX)`.
@@ -3275,6 +3305,9 @@ export class ToolHandler {
     // and crowd out the real answer file (grpc's `dialoptions.go`). Corroborated
     // overloads (the query also named the type) all earn it. (#1064)
     const tierSeedIds = new Set<string>();
+    // Files declaring a TYPE the query named by name — the counter-case guard
+    // for the declaration-only penalty (CG-28). Populated in the token loop.
+    const namedTypeFiles = new Set<string>();
     {
       const FILE_EXT = /\.(?:java|kt|kts|ts|tsx|js|jsx|mjs|cjs|cs|py|go|rb|php|swift|rs|cpp|cc|cxx|c|h|hpp|scala|lua|dart|vue|svelte|astro|erl|hrl)$/i;
       const CALLABLE = new Set(['method', 'function', 'component', 'constructor']);
@@ -3341,6 +3374,21 @@ export class ToolHandler {
         // codegraph_node's findSymbolMatches.) Qualified tokens keep findAllSymbols.
         const isQual = /[.\/]|::/.test(t);
         const raw = isQual ? this.findAllSymbols(cg, t).nodes : cg.getNodesByName(t);
+        // A query that NAMES a declared type is a question ABOUT that type, and
+        // must still reach its declaration file at full weight — so record the
+        // files those declarations live in and exempt them from the
+        // declaration-only penalty below (CG-28). Only PRECISE tokens count, by
+        // the same NL-stopword reasoning as the seeding above: "…the file body…"
+        // must not exempt a `Body` interface it never meant to name. Kept
+        // separate from `namedSeedIds`, which is callable-only by construction —
+        // a type never becomes a named seed, so it cannot be the guard here.
+        if (isPreciseToken(t)) {
+          for (const n of raw) {
+            if (DECLARATION_KINDS.has(n.kind) && n.name.toLowerCase() === t.toLowerCase()) {
+              namedTypeFiles.add(n.filePath);
+            }
+          }
+        }
         let cands = raw
           .filter((n) => CALLABLE.has(n.kind) && !isTestPath(n.filePath))
           .sort((a, b) => (bodyLines(b) > 1 ? 1 : 0) - (bodyLines(a) > 1 ? 1 : 0) || bodyLines(b) - bodyLines(a));
@@ -3572,10 +3620,18 @@ export class ToolHandler {
     // DO-NOT-EDIT banner and nothing in its name) down-ranks the same way
     // `.pb.go` always has (#1500). Covers the whole subgraph, not just the
     // grouped files, because the graph-mass penalty below is keyed on it too.
-    const isGeneratedCandidate = cg.generatedFilePredicate(new Set([
+    const penaltyCandidates = new Set([
       ...fileGroups.keys(),
       ...[...subgraph.nodes.values()].map((n) => n.filePath),
-    ]));
+    ]);
+    const isGeneratedCandidate = cg.generatedFilePredicate(penaltyCandidates);
+    // Second bounded probe over the same set: files declaring nothing but types
+    // that nothing in the index depends on (CG-28). A query that NAMED one of
+    // those types is asking about the declaration, so its file is exempt and
+    // ranks at full weight.
+    const isAmbientDeclaration = cg.ambientDeclarationFilePredicate(penaltyCandidates);
+    const isDampedDeclaration = (filePath: string): boolean =>
+      isAmbientDeclaration(filePath) && !namedTypeFiles.has(filePath);
 
     /**
      * Rank penalty for a file, applied to its relevance score AND (below) to its
@@ -3583,9 +3639,19 @@ export class ToolHandler {
      * score alone would leave the #1500 case unfixed: the generated CRUD carries
      * MORE graph mass than the hand-written use-case, and graph mass outranks
      * score in the comparator.
+     *
+     * Generated and ambient-declaration are taken as the STRONGER of the two,
+     * never multiplied: a generated `.d.ts` has one property — "not the
+     * implementation" — that both signals happen to see, and charging it twice is
+     * how a file gets cliffed out of answers where it is genuinely relevant
+     * (CG-28). The low-value multiplier is orthogonal (a test file that is also
+     * generated is two independent reasons) and still compounds.
      */
     const rankPenalty = (filePath: string): number =>
-      (isGeneratedCandidate(filePath) ? GENERATED_RANK_PENALTY : 1)
+      Math.min(
+        isGeneratedCandidate(filePath) ? GENERATED_RANK_PENALTY : 1,
+        isDampedDeclaration(filePath) ? AMBIENT_DECLARATION_RANK_PENALTY : 1,
+      )
       * (isLowValue(filePath) ? LOW_VALUE_RANK_PENALTY : 1);
 
     for (const [filePath, group] of fileGroups) {
@@ -3931,6 +3997,7 @@ export class ToolHandler {
           spine: group.nodes.some((n) => flow.pathNodeIds.has(n.id)),
           lowValue: isLowValue(fp),
           generated: isGeneratedCandidate(fp),
+          ambientDeclaration: isAmbientDeclaration(fp),
           penalty: rankPenalty(fp),
           kinds: kindMix(group.nodes),
         });