소스 검색

feat(ui): dead code and islands — what nothing reaches, and everything that could still reach it (CG-59)

A Dead code screen and a mark on the Map, both drawn from one derivation in
src/graph/dead-code.ts so a second surface can never disagree with the first.

The SQL half is four lines — no incoming edge but `contains`. It returns ~2 500
candidates on this repository and the shipped list is 20; everything in between
is the feature. A candidate is dropped the moment there is any reason to believe
something outside the graph reaches it: exported symbols and header
declarations, test and generated files, abstract and interface members, anything
carrying a `decorates` edge, overrides of an ancestor's member, names the
language calls by itself, vendored directories, files nothing in the index
reaches (those are islands, and the Map says so instead), names the resolver
failed to resolve somewhere, and names shared with a symbol that IS referenced —
the mis-resolution that leaves a used method with a self-edge and its twin with
nothing. The last rule is the only one that is not a graph query: before a claim
is made, the declaring file and every file that reaches it are read and the
identifier counted, which is what catches the references the extractor never
recorded (`this.handleMessage.bind(this)`, a call inside an object literal, a
shorthand property).

Every subtraction is counted and printed under the list with the scale it came
from, and the caveat line above it never collapses: the claim is "no static
reference in the index", not "unused".

On the Map a module nothing depends on keeps its stroke and says so in its count
line, and tool-generated files and modules recede to ink-4 there, in the map's
file list, in search results and on the file screen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Colby McHenry 1 주 전
부모
커밋
56dfdb0655

+ 8 - 0
CHANGELOG.md

@@ -72,6 +72,14 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
   Members that redeclare something from a type above are marked in the outline ("overrides Base", or "satisfies Clock" for an interface), so a 40-member class shows at a glance which parts are its own and which are a contract it is filling. The number of implementations shown here is the same number `codegraph_explore` reports to your agent when it announces an interface dispatch.
 
+- **Find the code nothing reaches, in `codegraph ui`.** A new **Dead code** tab lists the symbols no import, call or reference anywhere in your project reaches — biggest first, grouped by the file they live in, with the number of lines each one would take with it. A class nobody uses brings its methods along as a single finding rather than eleven. Every row opens the code.
+
+  The screen is built to be believed rather than to look impressive. A line above the list says, and keeps saying, that this means "no static reference in the index" and not "unused" — reflection, a framework registry and a template can all reach code a graph cannot follow. Under the list, every reason a candidate was left off is printed with its count, so you can see the list is twenty findings out of two and a half thousand candidates rather than twenty out of twenty-one.
+
+  Those exclusions are the feature. Anything exported, or declared in a header, is off the list by default, because something outside your repository can import it — one switch adds them back, with a warning band. So are test and generated files, abstract and interface declarations, anything a decorator registers, members that override something further up, names the language calls for you (`constructor`, `__enter__`, `main`), vendored directories, and files nothing in your project reaches at all. Two more rules catch what the graph itself missed: a name CodeGraph failed to resolve somewhere is never called unreferenced, and neither is a name shared with a symbol that *is* used — the twin may simply have been picked instead. Last, before any row is shown, the files that could reach it are read and the identifier counted: written down twice, something uses it and we did not see it.
+
+  On the **Map**, a module nothing depends on now says so in its own count line instead of counting itself — usually your entry points, sometimes something you forgot to delete. Tool-generated files and modules are dimmed wherever they appear: on the map, in its file list, in search results and on the file screen.
+
 ### Fixes
 
 - Fixed a long-running `codegraph ui` session serving a symbol that a sync had already deleted. The viewer keeps one connection to your index open, and its in-memory lookup didn't notice when another process — your agent's sync, or `codegraph sync` — rewrote the file underneath it, so a symbol screen could keep showing a body with no callers while search correctly reported it had moved. Because a symbol's identity includes the line it starts on, this happened after almost any edit above it.

+ 2 - 1
CLAUDE.md

@@ -77,7 +77,8 @@ The public API surface is `src/index.ts` — the `CodeGraph` class wires all the
 - `src/db/` — `DatabaseConnection`, `QueryBuilder` (prepared statements), `schema.sql`, `sqlite-adapter.ts`. Backed by Node's built-in **`node:sqlite`** (`DatabaseSync`) — real SQLite with WAL + FTS5, exposed through a thin better-sqlite3-shaped adapter. The bundled runtime always ships Node ≥22.5, so `node:sqlite` is always available: **no native build step and no wasm fallback**. (Running from source needs Node ≥22.5.) `codegraph status` reports the live backend (`node-sqlite`, the sole backend).
 - `src/extraction/` — `ExtractionOrchestrator`, tree-sitter wrappers, per-language extractors under `languages/` (one file per language), plus standalone extractors for non-tree-sitter formats (`svelte-extractor.ts`, `vue-extractor.ts`, `liquid-extractor.ts`, `dfm-extractor.ts` for Delphi). `parse-worker.ts` runs heavy parsing off the main thread.
 - `src/resolution/` — `ReferenceResolver` orchestrates `import-resolver.ts` (with `path-aliases.ts` for tsconfig path aliases + cargo workspace member globs), `name-matcher.ts`, and `frameworks/` (Express, Laravel, Rails, FastAPI, Django, Flask, Spring, Gin, Axum, ASP.NET, Vapor, React Router, SvelteKit, Vue/Nuxt, Cargo workspaces). Frameworks emit `route` nodes and `references` edges.
-- `src/graph/` — `GraphTraverser` (BFS/DFS, impact radius, path finding) and `GraphQueryManager` (high-level queries), plus the shared query-time derivations more than one surface renders: `named-symbol-flow.ts` (the one path finder, behind `codegraph_explore`'s Flow section and the viewer's Flow strip), `dynamic-boundary-report.ts` (where the graph stops), `type-hierarchy.ts` (ancestors/subtypes and the implementation count explore prints and the viewer draws). A derivation that two callers render must live here, not in `ToolHandler` — two derivations eventually disagree.
+- `src/graph/` — `GraphTraverser` (BFS/DFS, impact radius, path finding) and `GraphQueryManager` (high-level queries), plus the shared query-time derivations more than one surface renders: `named-symbol-flow.ts` (the one path finder, behind `codegraph_explore`'s Flow section and the viewer's Flow strip), `dynamic-boundary-report.ts` (where the graph stops), `type-hierarchy.ts` (ancestors/subtypes and the implementation count explore prints and the viewer draws),
+  `dead-code.ts` (unreferenced symbols, and every reason a candidate is NOT claimed). A derivation that two callers render must live here, not in `ToolHandler` — two derivations eventually disagree.
 - `src/context/` — `ContextBuilder` + formatter for markdown/JSON output.
 - `src/search/` — full-text query parser and helpers for FTS5.
 - `src/sync/` — `FileWatcher` (native FSEvents/inotify/RDCW) with debounce + filter, and git-hook helpers.

+ 485 - 0
__tests__/dead-code.test.ts

@@ -0,0 +1,485 @@
+/**
+ * Dead code and islands (CG-59).
+ *
+ * Two halves, both against a real indexed fixture: the derivation in
+ * `src/graph/dead-code.ts`, and the `/api/deadcode` endpoint that renders it
+ * over a real loopback server, like the rest of the viewer's API suite.
+ *
+ * The fixture is shaped to produce, deliberately, one of each thing the report
+ * has to get RIGHT BY NOT CLAIMING IT:
+ *
+ * - a genuinely unreferenced helper (the only row that should survive);
+ * - a same-name pair where the resolver attaches the call to the wrong one —
+ *   the mis-resolution that makes a used method look unreached;
+ * - a method that overrides a base's, reached only through the base;
+ * - a decorated method, registered by a framework the graph cannot see;
+ * - a helper only a template mentions, so no edge records the use but the file
+ *   text does;
+ * - an exported function nothing here calls, which an outside caller may.
+ *
+ * Every one of those must be OFF the list, and the reason must be counted.
+ */
+
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import * as http from 'http';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import CodeGraph from '../src/index';
+import {
+  buildDeadCodeReport,
+  isHeaderFile,
+  isImplicitEntryName,
+  isTestScope,
+  isVendoredPath,
+  mentionCount,
+  DEAD_CODE_KINDS,
+} from '../src/graph/dead-code';
+import { createGraphApi, startUiServer, type GraphApi, type UiServerHandle } from '../src/ui-server';
+
+let server: UiServerHandle;
+let api: GraphApi;
+let tempDir: string;
+let projectRoot: string;
+let cg: CodeGraph;
+
+function write(root: string, rel: string, body: string): void {
+  const full = path.join(root, rel);
+  fs.mkdirSync(path.dirname(full), { recursive: true });
+  fs.writeFileSync(full, body);
+}
+
+function request(requestPath: string): Promise<{ status: number; body: string; type?: string }> {
+  return new Promise((resolve, reject) => {
+    const req = http.request(
+      {
+        host: '127.0.0.1',
+        port: server.port,
+        path: requestPath,
+        method: 'GET',
+        headers: { Host: `127.0.0.1:${server.port}` },
+        setHost: false,
+      },
+      (res) => {
+        const chunks: Buffer[] = [];
+        res.on('data', (c: Buffer) => chunks.push(c));
+        res.on('end', () =>
+          resolve({
+            status: res.statusCode ?? 0,
+            body: Buffer.concat(chunks).toString('utf-8'),
+            type: res.headers['content-type'],
+          })
+        );
+      }
+    );
+    req.on('error', reject);
+    req.end();
+  });
+}
+
+async function getDeadCode(query = ''): Promise<any> {
+  const res = await request(`/api/deadcode${query}`);
+  expect(res.type).toBe('application/json; charset=utf-8');
+  expect(res.status).toBe(200);
+  return JSON.parse(res.body);
+}
+
+const names = (report: { entries: Array<{ node: { name: string } }> }): string[] =>
+  report.entries.map((entry) => entry.node.name);
+
+beforeAll(async () => {
+  tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-deadcode-'));
+  projectRoot = path.join(tempDir, 'project');
+
+  // The one genuinely dead symbol, plus a live one beside it so the file is
+  // reached and the island rule does not swallow the whole thing.
+  write(
+    projectRoot,
+    'src/util.ts',
+    `export function used(value: string): string {
+  return value.trim();
+}
+
+function neverCalledAnywhere(value: string): string {
+  return value.toUpperCase();
+}
+
+function alsoDeadButSmaller(): number {
+  return 1;
+}
+
+// Exported and never called here — an outside caller may import it, so the
+// default list must not claim it. It lives in a REACHED file on purpose: an
+// unreached file is an island, which is a different exclusion.
+export function publicEntryPoint(): string {
+  return 'hello';
+}
+`
+  );
+
+  // The mis-resolution: \`Facade.load\` calls \`this.inner.load()\`, and the
+  // resolver prefers a same-name definition in the call site's own file. One of
+  // the two ends up with no incoming edge and neither is unreferenced.
+  write(
+    projectRoot,
+    'src/inner.ts',
+    `export class Inner {
+  load(): string {
+    return 'inner';
+  }
+}
+`
+  );
+
+  // A base and an override: calls land on \`Base.run\`, never on \`Child.run\`.
+  write(
+    projectRoot,
+    'src/base.ts',
+    `export class Base {
+  run(): string {
+    return 'base';
+  }
+}
+`
+  );
+  write(
+    projectRoot,
+    'src/child.ts',
+    `import { Base } from './base';
+
+export class Child extends Base {
+  run(): string {
+    return 'child';
+  }
+}
+`
+  );
+
+  write(
+    projectRoot,
+    'src/facade.ts',
+    `import { Inner } from './inner';
+import { Base } from './base';
+import { Child } from './child';
+import { used } from './util';
+
+function register(target: unknown, key: string): void {
+  void target;
+  void key;
+}
+
+export class Facade {
+  inner = new Inner();
+  child = new Child();
+
+  load(): string {
+    return this.inner.load();
+  }
+
+  go(): string {
+    const base: Base = this.child;
+    return used(base.run()) + this.load();
+  }
+
+  @register
+  onEvent(): void {
+    void 0;
+  }
+}
+`
+  );
+
+  // Mentioned in a template but never called anywhere the graph can see: the
+  // corroboration pass has to find the second mention in this file's own text.
+  write(
+    projectRoot,
+    'src/handlers.ts',
+    `export function mountHandlers(): string {
+  return TEMPLATE;
+}
+
+function onSubmit(): void {
+  void 0;
+}
+
+const TEMPLATE = '<form onsubmit="onSubmit()"></form>';
+`
+  );
+
+  // Nothing imports this file at all: its symbols' zero fan-in describes the
+  // file, not the symbol. That is the island rule, and it is the Map's job.
+  write(
+    projectRoot,
+    'src/orphan.ts',
+    `function strandedHelper(): string {
+  return 'nobody imports this file';
+}
+
+function alsoStranded(): number {
+  return strandedHelper().length;
+}
+`
+  );
+
+  write(
+    projectRoot,
+    'src/index.ts',
+    `import { Facade } from './facade';
+import { mountHandlers } from './handlers';
+
+export function start(): string {
+  return new Facade().go() + mountHandlers();
+}
+`
+  );
+
+  // A test helper file with a dependent, so `includeTests` is what decides
+  // whether its dead symbol shows — not the island rule.
+  write(
+    projectRoot,
+    'tests/helpers.ts',
+    `export function sharedHelper(): string {
+  return 'shared';
+}
+
+function helperNothingCalls(): void {
+  void 0;
+}
+`
+  );
+  write(
+    projectRoot,
+    'tests/facade.test.ts',
+    `import { Facade } from '../src/facade';
+import { sharedHelper } from './helpers';
+
+export function testFacade(): string {
+  return new Facade().go() + sharedHelper();
+}
+`
+  );
+
+  const init = CodeGraph.initSync(projectRoot, {
+    config: { include: ['src/**/*.ts', 'tests/**/*.ts'], exclude: [] },
+  });
+  await init.indexAll();
+  init.resolveReferences();
+  init.close();
+
+  cg = CodeGraph.openSync(projectRoot);
+
+  const viewerDir = path.join(tempDir, 'viewer');
+  fs.mkdirSync(viewerDir, { recursive: true });
+  fs.writeFileSync(path.join(viewerDir, 'index.html'), '<!doctype html><div id="app"></div>');
+
+  api = createGraphApi({ projectRoot });
+  server = await startUiServer({ projectRoot, viewerDir, port: 0, api: api.handler });
+}, 120_000);
+
+afterAll(async () => {
+  cg?.close();
+  api?.close();
+  await server?.close();
+  if (tempDir) fs.rmSync(tempDir, { recursive: true, force: true });
+});
+
+describe('buildDeadCodeReport', () => {
+  it('finds the symbol nothing references', () => {
+    const report = buildDeadCodeReport(cg);
+    expect(names(report)).toContain('neverCalledAnywhere');
+  });
+
+  it('leaves nothing on the list that anything reaches', () => {
+    const report = buildDeadCodeReport(cg);
+    // `used`, `start`, `go` and `mountHandlers` are all called; `Inner.load`
+    // and `Facade.load` are the same-name pair; `Child.run` is an override.
+    for (const name of ['used', 'start', 'go', 'mountHandlers', 'load', 'run']) {
+      expect(names(report)).not.toContain(name);
+    }
+  });
+
+  it('excludes a symbol only its own file mentions, and counts it', () => {
+    const report = buildDeadCodeReport(cg);
+    expect(names(report)).not.toContain('onSubmit');
+    expect(report.excluded.mentioned).toBeGreaterThan(0);
+    expect(report.corroborated).toBe(true);
+  });
+
+  it('makes the claim when corroboration is switched off', () => {
+    // The rule that catches `onSubmit` is the only one that reads a file, so
+    // turning it off has to be visible in BOTH the list and the flag.
+    const report = buildDeadCodeReport(cg, { readSource: null });
+    expect(report.corroborated).toBe(false);
+    expect(report.excluded.mentioned).toBe(0);
+    expect(names(report)).toContain('onSubmit');
+  });
+
+  it('excludes exported symbols by default and includes them on request', () => {
+    const strict = buildDeadCodeReport(cg);
+    expect(names(strict)).not.toContain('publicEntryPoint');
+    expect(strict.excluded.exported).toBeGreaterThan(0);
+    expect(strict.includeExported).toBe(false);
+
+    const wide = buildDeadCodeReport(cg, { includeExported: true });
+    expect(names(wide)).toContain('publicEntryPoint');
+    expect(wide.includeExported).toBe(true);
+    expect(wide.excluded.exported).toBe(0);
+  });
+
+  it('excludes test files by default and includes them on request', () => {
+    expect(names(buildDeadCodeReport(cg))).not.toContain('helperNothingCalls');
+    expect(buildDeadCodeReport(cg).excluded.tests).toBeGreaterThan(0);
+    expect(names(buildDeadCodeReport(cg, { includeTests: true }))).toContain(
+      'helperNothingCalls'
+    );
+  });
+
+  it('says nothing about a file nothing in the index reaches', () => {
+    // An island's symbols have zero fan-in because the FILE is unreached, which
+    // is a fact about the file — the Map draws it, this list does not claim it.
+    const report = buildDeadCodeReport(cg, { includeExported: true });
+    expect(names(report)).not.toContain('strandedHelper');
+    expect(report.excluded.unreachableFile).toBeGreaterThan(0);
+  });
+
+  it('excludes a decorated member — a framework registers it', () => {
+    const report = buildDeadCodeReport(cg);
+    expect(names(report)).not.toContain('onEvent');
+    expect(report.excluded.decorated).toBeGreaterThan(0);
+  });
+
+  it('ranks by size and reports the real total when capped', () => {
+    const full = buildDeadCodeReport(cg);
+    const sizes = full.entries.map((entry) => entry.lines);
+    expect([...sizes].sort((a, b) => b - a)).toEqual(sizes);
+
+    const capped = buildDeadCodeReport(cg, { limit: 1 });
+    expect(capped.entries).toHaveLength(1);
+    expect(capped.total).toBe(full.total);
+    // The cap trims the tail, not the head: the biggest finding survives.
+    expect(capped.entries[0]?.node.name).toBe(full.entries[0]?.node.name);
+  });
+
+  it('every exclusion count is a number of candidates, and they add up', () => {
+    const report = buildDeadCodeReport(cg);
+    const excluded = Object.values(report.excluded).reduce((sum, n) => sum + n, 0);
+    expect(report.candidates).toBeGreaterThan(0);
+    expect(excluded + report.entries.length).toBeLessThanOrEqual(report.candidates);
+    expect(report.bounded).toBe(false);
+  });
+
+  it('restricts to the kinds asked for, and ignores nonsense', () => {
+    const classesOnly = buildDeadCodeReport(cg, { kinds: ['class'] });
+    expect(classesOnly.kinds).toEqual(['class']);
+    for (const entry of classesOnly.entries) expect(entry.node.kind).toBe('class');
+
+    // An unknown kind is not a 500 and not an empty list: it falls back to the
+    // default set, which is the answer the caller meant.
+    const nonsense = buildDeadCodeReport(cg, { kinds: ['banana' as never] });
+    expect(nonsense.kinds).toEqual([...DEAD_CODE_KINDS]);
+  });
+});
+
+describe('the rules that are pure', () => {
+  it('counts whole-identifier mentions only', () => {
+    expect(mentionCount('const load = 1; loader(); reload();', 'load')).toBe(1);
+    expect(mentionCount('a.load(); load();', 'load')).toBe(2);
+    expect(mentionCount('nothing here', 'load')).toBe(0);
+    // Stops early: the caller only ever needs to know "one, or more than one".
+    expect(mentionCount('x x x x x', 'x', 2)).toBe(2);
+  });
+
+  it('matches vendored directories as whole segments', () => {
+    expect(isVendoredPath('vendor/lib/a.go')).toBe(true);
+    expect(isVendoredPath('a/node_modules/b/c.js')).toBe(true);
+    expect(isVendoredPath('src/vendored-parser.ts')).toBe(false);
+  });
+
+  it('recognises headers as declaration surfaces', () => {
+    expect(isHeaderFile('src/tree_sitter/parser.h')).toBe(true);
+    expect(isHeaderFile('types/global.d.ts')).toBe(true);
+    expect(isHeaderFile('src/parser.c')).toBe(false);
+  });
+
+  it('recognises a test scope inside a file', () => {
+    expect(isTestScope('tests::row_sizes_match')).toBe(true);
+    expect(isTestScope('Fixtures.Tests.Helper')).toBe(true);
+    expect(isTestScope('Latest.value')).toBe(false);
+  });
+
+  it('recognises names the language calls by itself', () => {
+    expect(isImplicitEntryName('constructor')).toBe(true);
+    expect(isImplicitEntryName('__enter__')).toBe(true);
+    expect(isImplicitEntryName('ToString')).toBe(true);
+    expect(isImplicitEntryName('mainHandler')).toBe(false);
+  });
+});
+
+describe('GET /api/deadcode', () => {
+  it('groups the rows by file and keeps the totals honest', async () => {
+    const payload = await getDeadCode();
+    expect(payload.rows.total).toBe(payload.rows.items.length);
+    expect(payload.rows.shown).toBe(payload.rows.items.length);
+
+    // Every count equals a list length in the same payload.
+    const grouped = payload.groups.reduce((sum: number, g: any) => sum + g.rows.length, 0);
+    expect(grouped).toBe(payload.rows.shown);
+
+    const files = payload.groups.map((g: any) => g.file);
+    expect(new Set(files).size).toBe(files.length);
+    expect(files).toContain('src/util.ts');
+  });
+
+  it('carries the exclusions with their own wording', async () => {
+    const payload = await getDeadCode();
+    expect(payload.excluded.length).toBeGreaterThan(0);
+    for (const entry of payload.excluded) {
+      expect(entry.count).toBeGreaterThan(0);
+      expect(typeof entry.label).toBe('string');
+      expect(entry.label.length).toBeGreaterThan(0);
+    }
+    const sum = payload.excluded.reduce((n: number, e: any) => n + e.count, 0);
+    expect(payload.excludedTotal).toBe(sum);
+    expect(payload.candidates).toBeGreaterThanOrEqual(payload.excludedTotal);
+    expect(payload.corroborated).toBe(true);
+  });
+
+  it('widens on ?exported=1 and says which list it answered', async () => {
+    const strict = await getDeadCode();
+    const wide = await getDeadCode('?exported=1');
+    expect(strict.includeExported).toBe(false);
+    expect(wide.includeExported).toBe(true);
+    expect(wide.rows.total).toBeGreaterThan(strict.rows.total);
+    expect(wide.rows.items.some((r: any) => r.name === 'publicEntryPoint')).toBe(true);
+  });
+
+  it('honours ?limit= without lying about the total', async () => {
+    const full = await getDeadCode();
+    const capped = await getDeadCode('?limit=1');
+    expect(capped.rows.items).toHaveLength(1);
+    expect(capped.rows.total).toBe(full.rows.total);
+    expect(capped.rows.truncated).toBe(full.rows.total > 1);
+  });
+
+  it('is listed on the API index', async () => {
+    const res = await request('/api');
+    const body = JSON.parse(res.body);
+    expect(body.endpoints.some((e: any) => e.path === '/api/deadcode')).toBe(true);
+  });
+});
+
+describe('GET /api/map — generated files and islands', () => {
+  it('reports how many of a module’s files are tool-generated', async () => {
+    const res = await request('/api/map');
+    const payload = JSON.parse(res.body);
+    for (const module of payload.modules) {
+      expect(typeof module.generated).toBe('number');
+      expect(module.generated).toBeLessThanOrEqual(module.files);
+      // The dimmed rows are drawn from `fileList.items`, so the generated
+      // subset has to be a subset of exactly that list.
+      for (const file of module.generatedFiles) {
+        expect(module.fileList.items).toContain(file);
+      }
+    }
+  });
+});

+ 15 - 0
__tests__/ui-package.test.ts

@@ -391,6 +391,21 @@ function mockAdapter(): { adapter: GraphAdapter; calls: string[] } {
         index: { lastIndexedAt: null, files: 3 },
         timing: { elapsedMs: 1, cached: false },
       }),
+    deadCode: () =>
+      seen('deadCode', {
+        rows: { total: 0, shown: 0, truncated: false, items: [] },
+        groups: [],
+        candidates: 0,
+        excluded: [],
+        excludedTotal: 0,
+        kinds: ['function'],
+        includeExported: false,
+        includeTests: false,
+        includeGenerated: false,
+        bounded: false,
+        corroborated: true,
+        timing: { elapsedMs: 1 },
+      }),
     // Deliberately no `events`: a host without a live channel is the normal
     // case, and nothing may poll in its absence.
   };

+ 40 - 0
docs/design/codegraph-ui-design-spec.md

@@ -337,6 +337,46 @@ Nothing in the engine emits an `overrides` edge, so this is a NAME match inside
 says so. It is deliberately blind to signatures — an overload set would need type resolution the graph does not have.
 Layout is arithmetic (row height × index): no `ResizeObserver`, no measurement, same payload → same picture.
 
+### 3.11 Dead code and islands (CG-59)
+A screen (`#/dead`, `?exported=1`) and a mark on the Map.
+
+**The list.** Symbols no import, call or reference in the index reaches, ranked largest first and grouped by file with the
+Symbol view's `.filegroup` / `.row` shapes (design spec §3.2) — file path 11px mono `--ink-3` with the group's
+"N symbols · M lines" opposite it, then rows of kind glyph + 12.5px mono name + 11px mono `file:line` + an 11px `--ink-3` meta
+line ("method · 51 lines"). A dead container folds its unreachable members into a wrapped strip of 11px mono links under it
+rather than listing them as siblings — one finding, not eleven. Column max-width **760px**, 40px gutters, exactly like the
+entry-points panel.
+
+**The caveat is part of the screen, not a note on it.** A persistent 11.5px `--ink-3` line sits above the rows, between two
+hairline rules, and never collapses or dismisses: *"No static reference in the index — dynamic use is possible."* Under the list,
+every reason a candidate was left off is printed with its count ("1 677 in test files", "378 exported, or declared in a header",
+"40 overriding a member declared further up"), preceded by the scale — *"2 494 symbols in this index carry no incoming reference
+at all; 2 474 of them were left off this list."* Twenty rows drawn from twenty candidates and twenty drawn from two and a half
+thousand are different screens and only that sentence tells them apart.
+
+**One switch**, an 11px mono chip on the right of the caveat bar: `Internal only` (default) ↔ `Including exported`, carried in
+the URL. Turning it on adds symbols something outside the repository could import, and the screen grows an `--accent-soft` band
+with an `--accent-line` border saying so; each such row also carries an `exported` chip. Exported rows are never on the default
+list, because the index cannot check a caller it does not contain.
+
+**Islands, on the Map.** A module no link in the payload arrives at keeps its normal 1px `--ink` stroke — it is not a lesser
+module, it is an unreached one — and its 11px count line reads **"nothing depends on this"** in `--ink-2` *instead of* the
+symbol/file counts, which stay in the side panel. The island verdict is computed from the whole link set, so hiding test modules
+cannot manufacture one. Selecting the module adds a sentence in the panel. Note the box is sized from whichever string it will
+show, so the layout and the node must be given the same verdict.
+
+**Generated files recede everywhere** (`files.generated`, §2.6): a module whose files are *all* tool-generated draws in
+`--ink-4` with a `--rule-soft` stroke; a generated file in the Map panel's file list, a generated group on the dead code list,
+a generated result in the search palette and a generated file's title in the File view are all `--ink-4`. Partly-generated
+modules are not dimmed — a module with one `.pb.go` in it is still one somebody writes by hand.
+
+**What the list refuses to claim** is the whole design. Behind it, `src/graph/dead-code.ts` starts from "no incoming edge
+other than `contains`" and subtracts every candidate there is any reason to believe something reaches: exported symbols and
+header declarations, test and generated files, abstract and interface members, anything carrying a `decorates` edge, overrides
+of an ancestor's member, names the language calls by itself, vendored directories, files nothing in the index reaches (those are
+islands — the Map's job, not this list's), names the resolver failed to resolve somewhere, names shared with a symbol that IS
+referenced, and — the only rule that reads a file — names written more than once in a file that can reach them.
+
 ## 4. Libraries and versions
 - Svelte 5 (≥ 5.25) + Vite (workspace `ui/`), Svelte Flow `@xyflow/svelte` ^1.6 for the Map and Flow canvases only (custom nodes/edges,
   hidden handles for port spreading, local selection state — the pattern in docker-app's `StackGraph.svelte`); `@dagrejs/dagre` only as a

+ 1 - 0
scripts/check-ui-package.mjs

@@ -141,6 +141,7 @@ for (const name of [
   'TypeHierarchy',
   'FlowStrip',
   'ArchitectureMap',
+  'DeadCodeView',
   'TrailBar',
   'SearchPalette',
   'CodegraphUi',

+ 167 - 0
src/db/queries.ts

@@ -1979,6 +1979,173 @@ export class QueryBuilder {
     return out;
   }
 
+  /**
+   * Symbols nothing in the index points at — the candidate set behind the dead
+   * code list (`src/graph/dead-code.ts`).
+   *
+   * "Points at" is every edge kind EXCEPT `contains`: a class containing a
+   * method is structure, not use, and counting it would make every member look
+   * reached by its own container. A self-edge is excluded for the same reason
+   * a recursive function is not its own caller.
+   *
+   * One scan, one index probe per candidate. `NOT EXISTS` over
+   * `idx_edges_target_kind` is what keeps it that way — the alternative
+   * (`LEFT JOIN edges … GROUP BY`) builds a row per edge for the whole table
+   * before discarding all but the empty groups. Ordered by position so the
+   * answer is stable across runs and groups by file without a second sort.
+   *
+   * The result is deliberately NOT called dead code: an unreferenced symbol is
+   * a symbol with no STATIC reference, and the caller applies the exclusions
+   * (tests, generated files, overrides, unresolved names) that turn the
+   * candidate set into a claim worth making.
+   */
+  getUnreferencedNodes(
+    kinds: readonly string[],
+    limit: number
+  ): Array<{ node: Node; generated: boolean }> {
+    if (kinds.length === 0 || limit <= 0) return [];
+    const placeholders = kinds.map(() => '?').join(',');
+    const rows = this.db
+      .prepare(
+        `SELECT n.*, COALESCE(f.generated, 0) AS file_generated
+           FROM nodes n
+           LEFT JOIN files f ON f.path = n.file_path
+          WHERE n.kind IN (${placeholders})
+            AND NOT EXISTS (
+                  SELECT 1 FROM edges e
+                   WHERE e.target = n.id
+                     AND e.kind != 'contains'
+                     AND e.source != n.id
+                )
+       ORDER BY n.file_path, n.start_line, n.name
+          LIMIT ?`
+      )
+      .all(...kinds, limit) as Array<NodeRow & { file_generated: number }>;
+    return rows.map((row) => ({ node: rowToNode(row), generated: row.file_generated === 1 }));
+  }
+
+  /**
+   * Which of `names` the index holds an UNRESOLVED reference to.
+   *
+   * The point is honesty about our own blind spots. A `failed` row in
+   * `unresolved_refs` records that some file referenced a name and the resolver
+   * could not decide what it meant — so a symbol with that name cannot be
+   * called unreferenced, whatever the edge table says. It is deliberately
+   * matched loosely, on the reference name AND on its tail (`util.greet` →
+   * `greet`), because the question being asked is "could this name be the one
+   * we failed to follow", and a maybe has to count as a yes.
+   *
+   * Bounded-lookup like {@link getGeneratedPathsAmong}: the caller holds a
+   * candidate list, so this is a chunked probe over `idx_unresolved_name`, not
+   * a scan of the table.
+   */
+  getUnresolvedNamesAmong(names: Iterable<string>): Set<string> {
+    const unique = [...new Set(names)].filter((name) => name.length > 0);
+    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(',');
+      const rows = this.db
+        .prepare(
+          `SELECT DISTINCT reference_name AS name FROM unresolved_refs
+            WHERE reference_name IN (${placeholders})
+            UNION
+           SELECT DISTINCT name_tail AS name FROM unresolved_refs
+            WHERE name_tail IN (${placeholders})`
+        )
+        .all(...chunk, ...chunk) as Array<{ name: string }>;
+      for (const row of rows) found.add(row.name);
+    }
+    return found;
+  }
+
+  /**
+   * Which of `names` are carried by MORE THAN ONE symbol, at least one of which
+   * something points at.
+   *
+   * The false positive this exists to kill: `CodeGraph.getTopRouteFile` calls
+   * `this.queries.getTopRouteFile()`, and the resolver — which prefers a
+   * same-name definition in the call site's own file — attaches that edge to
+   * the *calling* method. One of the two ends up with a self-edge and the other
+   * with nothing at all, and neither is unreferenced. From the edge table the
+   * mis-resolution and a genuinely unused twin are the same picture, so the
+   * claim is not made about either.
+   *
+   * Both halves of the condition are load-bearing. **More than one symbol**:
+   * a uniquely-named function that only calls itself is genuinely dead, and
+   * excluding every recursive function would gut the list. **Self-edges
+   * counted**: the self-edge IS the fingerprint of the mis-resolution above, so
+   * it has to count as evidence that this name resolves somewhere.
+   *
+   * Chunked probe over `idx_nodes_name`, bounded by the caller's candidate list.
+   */
+  getAmbiguousReferencedNames(names: Iterable<string>): Set<string> {
+    const unique = [...new Set(names)].filter((name) => name.length > 0);
+    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(',');
+      const rows = this.db
+        .prepare(
+          `SELECT name FROM (
+             SELECT n.name AS name,
+                    EXISTS (
+                      SELECT 1 FROM edges e
+                       WHERE e.target = n.id AND e.kind != 'contains'
+                    ) AS referenced
+               FROM nodes n
+              WHERE n.name IN (${placeholders})
+           )
+         GROUP BY name
+           HAVING COUNT(*) > 1 AND SUM(referenced) > 0`
+        )
+        .all(...chunk) as Array<{ name: string }>;
+      for (const row of rows) found.add(row.name);
+    }
+    return found;
+  }
+
+  /**
+   * Which of the given languages the index records an EXPORT marker for.
+   *
+   * A self-measurement, and the honest basis for a whole class of exclusion.
+   * The dead code report's strongest filter is "exported symbols may be reached
+   * from outside this repository" — and that filter silently does nothing for a
+   * language whose exports are not recorded, either because the extractor does
+   * not record them (Rust `pub`) or because the language has no such concept at
+   * all (Python, C, Ruby: the header or the module IS the surface). Rather than
+   * carry a table of which is which, ask the index: if nothing in this language
+   * is marked exported, the filter did not run, and no claim about outside
+   * reachability can be made for it.
+   *
+   * `idx_nodes_language` covers the grouping; the caller passes the handful of
+   * languages its candidates are actually in.
+   */
+  getLanguagesWithExports(languages: Iterable<string>): Set<string> {
+    const unique = [...new Set(languages)].filter((language) => language.length > 0);
+    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(',');
+      const rows = this.db
+        .prepare(
+          `SELECT language, MAX(is_exported) AS any_exported
+             FROM nodes
+            WHERE language IN (${placeholders})
+         GROUP BY language`
+        )
+        .all(...chunk) as Array<{ language: string; any_exported: number }>;
+      for (const row of rows) if (row.any_exported === 1) found.add(row.language);
+    }
+    return found;
+  }
+
   /**
    * The nodes with the most DISTINCT dependents, most first.
    *

+ 886 - 0
src/graph/dead-code.ts

@@ -0,0 +1,886 @@
+/**
+ * Dead code and islands — one derivation of "nothing in this repository
+ * reaches here".
+ *
+ * The graph can answer that question exactly, and that is the problem: the
+ * exact answer is *no incoming edge*, and a symbol with no incoming edge is not
+ * the same thing as a symbol nobody uses. Reflection calls it. A framework
+ * registers it by name. A test file that was never indexed imports it. The
+ * resolver saw the name and could not follow it. So the honest product of this
+ * module is two things at once — a list, and everything the list could not see.
+ *
+ * ## The shape of the claim
+ *
+ * `unreferenced` is a fact: no edge in the index, other than the `contains`
+ * edge from whatever holds it, points at this symbol. `dead` is an inference on
+ * top of that fact, and every step of the inference is subtractive — a
+ * candidate is dropped from the list the moment there is any reason to believe
+ * something outside the graph reaches it:
+ *
+ * - it is **exported** (something outside this repository may import it);
+ * - it lives in a **test** or a **generated** file (not code anyone deletes by
+ *   hand);
+ * - it is **abstract** or declared on an interface (a declaration is dispatched
+ *   to, never called);
+ * - it is **decorated** (`@app.route`, `@Component`, `@EventHandler`) — a
+ *   decorator is a registration, and the framework that reads it is not in the
+ *   graph. Seen as the symbol's own outgoing `decorates` edge, which is where
+ *   the engine records it; `node.decorators` is only populated by a couple of
+ *   languages and is checked as well rather than instead;
+ * - it **overrides** a member an ancestor declares (calls land on the ancestor;
+ *   see {@link overrideCandidates} for why an ancestor we cannot read counts
+ *   the same way);
+ * - it has a name the language calls by itself (`constructor`, `__enter__`,
+ *   `main`);
+ * - it sits in a **vendored** directory (`vendor/`, `third_party/`,
+ *   `node_modules/` — code the repository carries but does not own);
+ * - it is in a **test scope** the path does not reveal — a Rust
+ *   `#[cfg(test)] mod tests`, a nested `Tests` namespace;
+ * - it is in a **component file** whose markup the index reads for calls but
+ *   not for references, so a handler passed as `{onkeydown}` is invisible;
+ * - it is in a file **nothing in the index reaches**. Then "nothing references
+ *   this symbol" is a restatement of "we cannot see how this file is wired",
+ *   not a finding about the symbol — and it is the map, not this list, that
+ *   says so: a file no one reaches is an island, and islands are drawn there;
+ * - the index holds an **unresolved reference** to its name. A `failed` row in
+ *   `unresolved_refs` is the resolver's own record of a reference it could not
+ *   follow, and a symbol whose name we failed to follow cannot be called
+ *   unreferenced.
+ * - **another symbol of the same name IS referenced.** This is the one that
+ *   matters most and the one nothing else would catch. `CodeGraph.getTopRouteFile`
+ *   calls `this.queries.getTopRouteFile()`; the resolver prefers a same-name
+ *   definition in the call site's own file, so the edge lands on the caller
+ *   itself and the real target is left with nothing. From the edge table,
+ *   "nobody calls this" and "the resolver picked the twin" are the same
+ *   picture — so the claim is not made about either;
+ * - it is declared in a **header** (`.h`, `.hpp`, `.d.ts`, `.pyi`): a header IS
+ *   the export surface, and the reference to it is an `#include` the resolver
+ *   does not follow to the declaration;
+ * - it is in a language this index records **no export marker** for. The
+ *   exported filter is the strongest one here, and for Rust (`pub` is not
+ *   recorded) or Python and C (no such concept at all) it silently does
+ *   nothing — so the index is asked, per language, whether it ran;
+ * - **its own file writes the name more than once.** The last rule, and the
+ *   only one that is not a graph query. Everything above assumes the edge
+ *   table is complete; it is not, and the gaps do not announce themselves —
+ *   `this.handleMessage.bind(this)` is a value reference the extractor does not
+ *   record, and a call inside an object-literal initialiser is another. Both
+ *   leave the name written twice in one file and no edge at all. So before the
+ *   claim is made, the identifier is counted in the file itself AND in every
+ *   file the index says depends on it — written once, in its own declaration,
+ *   nothing that can reach it writes it down; written twice, we simply did not
+ *   see the second one.
+ *
+ * Every subtraction is counted. {@link DeadCodeReport.excluded} is not
+ * diagnostics — it is the sentence under the list ("47 exported, 12 overriding
+ * an ancestor…"), because a list of eight rows drawn from four thousand
+ * candidates means something different from a list of eight drawn from nine.
+ *
+ * ## Islands
+ *
+ * The other half of the task, a *module* nothing depends on, is not computed
+ * here: it falls straight out of the map's own link set (a module with no
+ * incoming link), and the map's layout is already a pure function in the
+ * viewer. Computing it a second time on this side would be a second answer to
+ * a question the map has already answered. See `ui/src/lib/map-model.ts`.
+ *
+ * Everything here is query-time and read-only.
+ */
+
+import fs from 'fs';
+import path from 'path';
+import type CodeGraph from '../index';
+import type { Node, NodeKind } from '../types';
+import { isTestFile } from '../search/query-utils';
+
+// =============================================================================
+// Caps and defaults
+// =============================================================================
+
+/**
+ * Kinds asked about by default.
+ *
+ * Callables and types, and nothing else. `variable`/`constant`/`field` are out
+ * deliberately: a value's uses are recorded as `references` edges, and that
+ * coverage is the most language-dependent thing in the resolver — a default
+ * that included them would produce a list whose truthfulness varied by which
+ * language the reader happened to be looking at.
+ */
+export const DEAD_CODE_KINDS: readonly NodeKind[] = [
+  'function',
+  'method',
+  'class',
+  'component',
+  'interface',
+  'struct',
+  'trait',
+  'protocol',
+  'enum',
+  'union',
+  'type_alias',
+];
+
+/** Kinds a `kinds=` request may ask for. Anything else is a caller bug. */
+export const DEAD_CODE_ALLOWED_KINDS: ReadonlySet<NodeKind> = new Set<NodeKind>([
+  ...DEAD_CODE_KINDS,
+  'variable',
+  'constant',
+  'property',
+  'field',
+  'enum_member',
+  'namespace',
+  'module',
+]);
+
+/**
+ * Candidates pulled out of SQL before any exclusion runs.
+ *
+ * High enough that no real repository reaches it with the default kinds (this
+ * index produces ~1 400), and bounded so that a half-indexed monorepo cannot
+ * turn one screen into a scan of a million rows. When it bites,
+ * {@link DeadCodeReport.bounded} says so.
+ */
+export const MAX_DEAD_CODE_CANDIDATES = 20000;
+
+/** Levels walked up looking for an ancestor that declares the same member. */
+export const MAX_OVERRIDE_ANCESTOR_DEPTH = 8;
+
+/**
+ * Files read for the corroboration pass, and the biggest one read.
+ *
+ * The pass runs over the survivors only — everything cheap has already fired —
+ * so on this index it reads a few dozen files. The caps are a backstop against
+ * a repository whose survivors span a thousand files or include a generated
+ * megabyte. A file skipped for either reason counts as NOT corroborated, which
+ * drops the row: the safe direction is always the one that says less.
+ */
+export const MAX_CORROBORATION_FILES = 600;
+export const MAX_CORROBORATION_BYTES = 2_000_000;
+
+/** Kinds that can carry members, i.e. whose ancestors are worth walking. */
+const CONTAINER_KINDS: ReadonlySet<NodeKind> = new Set<NodeKind>([
+  'class',
+  'interface',
+  'struct',
+  'trait',
+  'protocol',
+  'enum',
+  'union',
+  'type_alias',
+]);
+
+/** Container kinds whose members are declarations, never call targets. */
+const DECLARATION_CONTAINER_KINDS: ReadonlySet<NodeKind> = new Set<NodeKind>([
+  'interface',
+  'trait',
+  'protocol',
+]);
+
+/** Member kinds an override can be declared on. */
+const OVERRIDABLE_KINDS: ReadonlySet<NodeKind> = new Set<NodeKind>([
+  'method',
+  'function',
+  'property',
+  'field',
+]);
+
+/**
+ * Names a language or a runtime calls without anything in the source naming
+ * them.
+ *
+ * Kept short on purpose. The temptation is a per-language table of every
+ * lifecycle hook ever written, which would be wrong twice over — it would go
+ * stale, and it would hide real dead code behind a name coincidence. The
+ * entries below are the ones where the *language itself* does the calling, so
+ * no source file could name them even in principle. Framework hooks are caught
+ * by the decorator and override rules instead, which are structural.
+ */
+const IMPLICIT_ENTRY_NAMES: ReadonlySet<string> = new Set([
+  'constructor',
+  'main',
+  'init',
+  'deinit',
+  'finalize',
+  'destructor',
+  'dispose',
+  'drop',
+  'default',
+  'tostring',
+  'equals',
+  'gethashcode',
+  'hashcode',
+]);
+
+/**
+ * Qualified-name segments that mean "inside a test scope the file path does not
+ * reveal" — a Rust `#[cfg(test)] mod tests`, a nested `Tests` class in C#, a
+ * Go `TestMain` helper block. `isTestFile` only reads paths, and an in-file test
+ * module is invisible to it.
+ */
+const TEST_SCOPE_SEGMENTS: ReadonlySet<string> = new Set([
+  'test',
+  'tests',
+  '__tests__',
+  'spec',
+  'specs',
+  'testing',
+]);
+
+/**
+ * Languages whose files are markup with a script block inside them.
+ *
+ * The extractors for these read the `<script>` region properly and scan the
+ * template for CALLS — but a handler passed by reference (`{onkeydown}`,
+ * `@click="submit"`) is a reference, not a call, and it is not extracted. Every
+ * event handler in every component would therefore head this list. The index
+ * cannot tell a handler wired in markup from one nobody uses, so it does not
+ * guess.
+ */
+const MARKUP_HOST_LANGUAGES: ReadonlySet<string> = new Set([
+  'svelte',
+  'vue',
+  'astro',
+  'liquid',
+  'html',
+  'razor',
+  'twig',
+  'blade',
+  'erb',
+  'handlebars',
+]);
+
+/**
+ * Extensions whose contents are declarations for somebody else.
+ *
+ * A C header is the translation unit's export surface: everything in it exists
+ * to be `#include`d, and the resolver does not follow an include to the
+ * declaration it lands on. `.d.ts` and `.pyi` are the same idea in TypeScript
+ * and Python. Treating these as exported is not a heuristic — it is what the
+ * file is for.
+ */
+const HEADER_EXTENSIONS: ReadonlyArray<string> = [
+  '.h',
+  '.hh',
+  '.hpp',
+  '.hxx',
+  '.h++',
+  '.inc',
+  '.d.ts',
+  '.d.mts',
+  '.d.cts',
+  '.pyi',
+  '.pxd',
+];
+
+/** Python and Ruby call these by protocol: `__enter__`, `__iter__`, `__init__`. */
+const DUNDER = /^__[a-z0-9_]+__$/i;
+
+/**
+ * Directory names that mean "this code is carried, not written here".
+ *
+ * Vendored third-party source is the second-largest source of noise after name
+ * ambiguity, and it is noise of a particular kind: the code IS reached, by a
+ * build system or a runtime that is not in the index at all (a tree-sitter
+ * scanner is called through a generated symbol table; a vendored library is
+ * called by whatever links it). Matched as a whole path segment, so
+ * `src/vendored-parser.ts` is not caught by `vendor`.
+ */
+const VENDOR_SEGMENTS: ReadonlySet<string> = new Set([
+  'vendor',
+  'vendored',
+  'third_party',
+  'third-party',
+  'thirdparty',
+  'external',
+  'externals',
+  'node_modules',
+  'bower_components',
+  'site-packages',
+  'godeps',
+  'pods',
+  '.venv',
+  'venv',
+]);
+
+// =============================================================================
+// Shapes
+// =============================================================================
+
+/** One symbol nothing reaches, and what it takes with it. */
+export interface DeadCodeEntry {
+  node: Node;
+  /**
+   * Members that are themselves unreferenced and live inside {@link node}.
+   *
+   * A class nobody instantiates takes its methods with it, and listing all
+   * eleven of them as siblings would turn one finding into eleven. They are
+   * folded in here instead and reported as a count.
+   */
+  members: Node[];
+  /** Source lines the entry spans, members included (they are inside it). */
+  lines: number;
+  /**
+   * The symbol is exported. Only ever true when the caller asked for exported
+   * symbols — and then it is the row's own caveat, because an exported symbol
+   * is reachable from outside the index by definition.
+   */
+  exported: boolean;
+}
+
+/** How many candidates each rule removed, in the order the rules ran. */
+export interface DeadCodeExclusions {
+  /** In a file that looks like test or fixture code. */
+  tests: number;
+  /** In a tool-generated file. */
+  generated: number;
+  /** Exported, or declared in a header — reachable from outside this index. */
+  exported: number;
+  /**
+   * In a language this index records no export marker for, so nothing here can
+   * be told apart from that language's public surface.
+   */
+  exportsUnknown: number;
+  /** Abstract, or a member of an interface / trait / protocol. */
+  declarations: number;
+  /** Carries a decorator, so a framework registers it. */
+  decorated: number;
+  /** Overrides a member an ancestor declares, or an ancestor we cannot read. */
+  overriding: number;
+  /** Named something the language calls by itself. */
+  implicit: number;
+  /** In a vendored directory — carried code, reached by something outside the index. */
+  vendored: number;
+  /** In a test scope the file path does not reveal (a Rust `mod tests`). */
+  testScope: number;
+  /** In a component file whose markup can reference a symbol invisibly. */
+  markup: number;
+  /** In a file nothing in the index reaches — an island, drawn on the map. */
+  unreachableFile: number;
+  /** The index holds an unresolved reference to this name. */
+  unresolvedName: number;
+  /** Another symbol of the same name IS referenced, so the resolver may have picked it. */
+  ambiguousName: number;
+  /** Its own file writes the name more than once, so something uses it there. */
+  mentioned: number;
+  /** Its file could not be read, so the mention count could not be checked. */
+  unreadable: number;
+  /** Folded into a container that is itself on the list. */
+  nested: number;
+}
+
+export interface DeadCodeReport {
+  /** Ranked, capped. */
+  entries: DeadCodeEntry[];
+  /** Entries before {@link DeadCodeQuery.limit} — always the real number. */
+  total: number;
+  /** Symbols with no incoming reference at all, before any exclusion ran. */
+  candidates: number;
+  excluded: DeadCodeExclusions;
+  /** The kinds actually asked about. */
+  kinds: NodeKind[];
+  /** Exported symbols were included, so every row carries the outside-reach caveat. */
+  includeExported: boolean;
+  /** The candidate scan stopped at {@link MAX_DEAD_CODE_CANDIDATES}. */
+  bounded: boolean;
+  /**
+   * Every surviving row was checked against its own file's text — the rule that
+   * covers the edges the extractor never recorded. False when no reader was
+   * available, and then the list is weaker than it looks.
+   */
+  corroborated: boolean;
+}
+
+export interface DeadCodeQuery {
+  kinds?: readonly NodeKind[];
+  /** Include symbols something outside the index could import. Default false. */
+  includeExported?: boolean;
+  /** Include symbols in test files. Default false. */
+  includeTests?: boolean;
+  /** Include symbols in tool-generated files. Default false. */
+  includeGenerated?: boolean;
+  /** Entries returned. `total` stays the real count. */
+  limit?: number;
+  /**
+   * How to read a project-relative source file, for the corroboration pass.
+   *
+   * Injected rather than assumed so that a caller with a read chokepoint — the
+   * viewer's API refuses any path outside the project before opening it — keeps
+   * its own rule. Return `null` for anything unreadable. Omitted entirely means
+   * the default reader, which resolves against the project root; passing `null`
+   * turns the pass off, and {@link DeadCodeReport.corroborated} then says so.
+   */
+  readSource?: ((filePath: string) => string | null) | null;
+}
+
+// =============================================================================
+// The report
+// =============================================================================
+
+/**
+ * The dead code report: unreferenced symbols, minus every reason to doubt it,
+ * plus a count of every doubt.
+ */
+export function buildDeadCodeReport(cg: CodeGraph, query: DeadCodeQuery = {}): DeadCodeReport {
+  const kinds = normalizeKinds(query.kinds);
+  const includeExported = query.includeExported === true;
+  const includeTests = query.includeTests === true;
+  const includeGenerated = query.includeGenerated === true;
+  const limit = Math.max(1, query.limit ?? 200);
+  const readSource =
+    query.readSource === undefined ? defaultSourceReader(cg) : query.readSource;
+
+  const excluded: DeadCodeExclusions = {
+    tests: 0,
+    generated: 0,
+    exported: 0,
+    exportsUnknown: 0,
+    declarations: 0,
+    decorated: 0,
+    overriding: 0,
+    implicit: 0,
+    vendored: 0,
+    testScope: 0,
+    markup: 0,
+    unreachableFile: 0,
+    unresolvedName: 0,
+    ambiguousName: 0,
+    mentioned: 0,
+    unreadable: 0,
+    nested: 0,
+  };
+
+  const raw = cg.getUnreferencedNodes(kinds, MAX_DEAD_CODE_CANDIDATES + 1);
+  const bounded = raw.length > MAX_DEAD_CODE_CANDIDATES;
+  const candidates = bounded ? raw.slice(0, MAX_DEAD_CODE_CANDIDATES) : raw;
+
+  // Asking the index whether the exported filter can run at all, per language,
+  // over the handful of languages the candidates are actually in. Skipped when
+  // the caller has already accepted outside-reachability by asking for exported
+  // symbols.
+  const languagesWithExports = includeExported
+    ? new Set<string>()
+    : cg.getLanguagesWithExports(candidates.map((row) => row.node.language));
+
+  // ---- the cheap, per-row rules -------------------------------------------
+  const surviving: Array<{ node: Node; generated: boolean }> = [];
+  for (const row of candidates) {
+    const { node } = row;
+    if (!includeTests && isTestFile(node.filePath)) {
+      excluded.tests += 1;
+      continue;
+    }
+    if (!includeGenerated && row.generated) {
+      excluded.generated += 1;
+      continue;
+    }
+    if (!includeExported && (node.isExported || isHeaderFile(node.filePath))) {
+      excluded.exported += 1;
+      continue;
+    }
+    if (!includeExported && !languagesWithExports.has(node.language)) {
+      excluded.exportsUnknown += 1;
+      continue;
+    }
+    if (node.isAbstract) {
+      excluded.declarations += 1;
+      continue;
+    }
+    if (isImplicitEntryName(node.name)) {
+      excluded.implicit += 1;
+      continue;
+    }
+    if (isVendoredPath(node.filePath)) {
+      excluded.vendored += 1;
+      continue;
+    }
+    if (!includeTests && isTestScope(node.qualifiedName)) {
+      excluded.testScope += 1;
+      continue;
+    }
+    if (MARKUP_HOST_LANGUAGES.has(node.language)) {
+      excluded.markup += 1;
+      continue;
+    }
+    surviving.push(row);
+  }
+
+  // ---- the rules that need the graph --------------------------------------
+  // A `decorates` edge runs FROM the decorated symbol to the decorator, so
+  // this is an outgoing-edge question, not something the candidate query could
+  // have answered.
+  const decorated = new Set(
+    cg
+      .getOutgoingEdgesFrom(
+        surviving.map((row) => row.node.id),
+        ['decorates']
+      )
+      .map((edge) => edge.source)
+  );
+  const containers = containersOf(cg, surviving.map((row) => row.node));
+  const overriding = overrideCandidates(cg, surviving.map((row) => row.node), containers);
+  // One batched count for every file still in play. A file nothing reaches is
+  // an island: its symbols' zero fan-in describes the file, not the symbol.
+  const unreachableFiles = filesNothingReaches(cg, surviving.map((row) => row.node.filePath));
+  const reachable = surviving.filter((row) => {
+    if (!unreachableFiles.has(row.node.filePath)) return true;
+    excluded.unreachableFile += 1;
+    return false;
+  });
+  surviving.length = 0;
+  surviving.push(...reachable);
+
+  const names = surviving.map((row) => row.node.name);
+  const unresolved = cg.getUnresolvedNamesAmong(names);
+  const ambiguousNames = cg.getAmbiguousReferencedNames(names);
+
+  const kept: Array<{ node: Node; generated: boolean }> = [];
+  for (const row of surviving) {
+    if (decorated.has(row.node.id) || (row.node.decorators?.length ?? 0) > 0) {
+      excluded.decorated += 1;
+      continue;
+    }
+    const container = containers.get(row.node.id);
+    if (container && DECLARATION_CONTAINER_KINDS.has(container.kind)) {
+      excluded.declarations += 1;
+      continue;
+    }
+    if (overriding.has(row.node.id)) {
+      excluded.overriding += 1;
+      continue;
+    }
+    if (unresolved.has(row.node.name)) {
+      excluded.unresolvedName += 1;
+      continue;
+    }
+    if (ambiguousNames.has(row.node.name)) {
+      excluded.ambiguousName += 1;
+      continue;
+    }
+    kept.push(row);
+  }
+
+  // ---- the file's own text has the last word -------------------------------
+  // Everything above is a graph query, and the graph is what has the gaps. This
+  // is the only rule that can see a reference the extractor never recorded.
+  const confirmed: Array<{ node: Node; generated: boolean }> = [];
+  if (readSource) {
+    const sources = new Map<string, string | null>();
+    const read = (file: string): string | null => {
+      if (!sources.has(file)) {
+        sources.set(file, sources.size >= MAX_CORROBORATION_FILES ? null : readSource(file));
+      }
+      return sources.get(file) ?? null;
+    };
+    // The set to search is the declaring file plus everything the index says
+    // reaches into it — the same set a call could have come from. Computed once
+    // per file, not once per candidate.
+    const scopes = new Map<string, string[]>();
+    for (const row of kept) {
+      const file = row.node.filePath;
+      if (!scopes.has(file)) scopes.set(file, [file, ...cg.getFileDependents(file)]);
+    }
+
+    for (const row of kept) {
+      const scope = scopes.get(row.node.filePath) ?? [row.node.filePath];
+      let own: string | null = null;
+      let mentions = 0;
+      for (const file of scope) {
+        const source = read(file);
+        if (file === row.node.filePath) own = source;
+        if (source === null) continue;
+        mentions += mentionCount(source, row.node.name, 2 - mentions);
+        if (mentions >= 2) break;
+      }
+      // Its OWN file has to be readable: the declaration itself is one of the
+      // two mentions, so an unreadable declaring file makes the count meaningless.
+      if (own === null) {
+        excluded.unreadable += 1;
+        continue;
+      }
+      if (mentions >= 2) {
+        excluded.mentioned += 1;
+        continue;
+      }
+      confirmed.push(row);
+    }
+  } else {
+    confirmed.push(...kept);
+  }
+
+  // ---- fold members into a container that is itself dead -------------------
+  const keptIds = new Set(confirmed.map((row) => row.node.id));
+  const entries = new Map<string, DeadCodeEntry>();
+  const pending: Array<{ node: Node; containerId: string }> = [];
+  for (const row of confirmed) {
+    const container = containers.get(row.node.id);
+    if (container && keptIds.has(container.id)) {
+      pending.push({ node: row.node, containerId: container.id });
+      excluded.nested += 1;
+      continue;
+    }
+    entries.set(row.node.id, {
+      node: row.node,
+      members: [],
+      lines: Math.max(1, row.node.endLine - row.node.startLine + 1),
+      exported: row.node.isExported === true,
+    });
+  }
+  for (const member of pending) {
+    // A member whose container was itself folded away (a dead class inside a
+    // dead class) has no entry to hang off; it was still counted as nested, so
+    // it is not silently missing from the totals.
+    entries.get(member.containerId)?.members.push(member.node);
+  }
+  for (const entry of entries.values()) {
+    entry.members.sort((a, b) => a.startLine - b.startLine || a.name.localeCompare(b.name));
+  }
+
+  // Biggest first: the list is read to decide what to delete, and a 200-line
+  // unreachable class is a different finding from a three-line helper. File and
+  // line break the tie so the order is stable across runs.
+  const ranked = [...entries.values()].sort(
+    (a, b) =>
+      b.lines - a.lines ||
+      a.node.filePath.localeCompare(b.node.filePath) ||
+      a.node.startLine - b.node.startLine
+  );
+
+  return {
+    entries: ranked.slice(0, limit),
+    total: ranked.length,
+    candidates: candidates.length,
+    excluded,
+    kinds,
+    includeExported,
+    bounded,
+    corroborated: readSource !== null,
+  };
+}
+
+/**
+ * Reads a project-relative file off disk, for callers with no chokepoint of
+ * their own (the CLI, a library user). Refuses anything that escapes the
+ * project root — a `filePath` comes out of the index, but the index is a file
+ * on disk and this module should not be the thing that trusts it.
+ */
+function defaultSourceReader(cg: CodeGraph): (filePath: string) => string | null {
+  const root = path.resolve(cg.getProjectRoot());
+  return (filePath: string): string | null => {
+    try {
+      const absolute = path.resolve(root, filePath);
+      if (absolute !== root && !absolute.startsWith(root + path.sep)) return null;
+      const stat = fs.statSync(absolute);
+      if (!stat.isFile() || stat.size > MAX_CORROBORATION_BYTES) return null;
+      return fs.readFileSync(absolute, 'utf8');
+    } catch {
+      return null;
+    }
+  };
+}
+
+/**
+ * How many times `name` is written in `source` as a whole identifier, counting
+ * no further than `stopAt`.
+ *
+ * Deliberately dumb: no parsing, no comment or string stripping. A mention in a
+ * comment or in a string is exactly the kind of thing that turns out to be a
+ * reflective call or a registration key, and the rule this serves only ever
+ * uses the count to say LESS. `\b` is not used because it is ASCII-only in
+ * JavaScript and an identifier may not be.
+ */
+export function mentionCount(source: string, name: string, stopAt = Number.MAX_SAFE_INTEGER): number {
+  if (name.length === 0) return 0;
+  let count = 0;
+  let from = 0;
+  for (;;) {
+    const at = source.indexOf(name, from);
+    if (at < 0) return count;
+    from = at + name.length;
+    if (!isIdentifierChar(source[at - 1]) && !isIdentifierChar(source[from])) {
+      count += 1;
+      if (count >= stopAt) return count;
+    }
+  }
+}
+
+function isIdentifierChar(char: string | undefined): boolean {
+  if (char === undefined) return false;
+  return char === '_' || char === '$' || /[\p{L}\p{N}]/u.test(char);
+}
+
+/**
+ * Files in the candidate set that nothing else in the index reaches.
+ *
+ * One batched query for the whole set (`getFileDependentCounts` counts through
+ * the symbols, because an `imports` edge points at the imported symbol and a
+ * file node almost never receives one). Zero means nothing else in the index
+ * reaches into this file at all.
+ */
+function filesNothingReaches(cg: CodeGraph, filePaths: readonly string[]): Set<string> {
+  const unique = [...new Set(filePaths)];
+  if (unique.length === 0) return new Set();
+  const dependents = cg.getFileDependentCounts(unique);
+  return new Set(unique.filter((path) => (dependents.get(path) ?? 0) === 0));
+}
+
+/** A file whose contents are declarations for somebody else — see {@link HEADER_EXTENSIONS}. */
+export function isHeaderFile(filePath: string): boolean {
+  const lower = filePath.toLowerCase();
+  return HEADER_EXTENSIONS.some((ext) => lower.endsWith(ext));
+}
+
+/** A qualified name that runs through a test scope — see {@link TEST_SCOPE_SEGMENTS}. */
+export function isTestScope(qualifiedName: string): boolean {
+  for (const segment of qualifiedName.split(/[.:/\\#>]+/)) {
+    if (TEST_SCOPE_SEGMENTS.has(segment.toLowerCase())) return true;
+  }
+  return false;
+}
+
+/** Code the repository carries rather than owns — see {@link VENDOR_SEGMENTS}. */
+export function isVendoredPath(filePath: string): boolean {
+  for (const segment of filePath.replace(/\\/g, '/').split('/')) {
+    if (VENDOR_SEGMENTS.has(segment.toLowerCase())) return true;
+  }
+  return false;
+}
+
+/** A name the language calls by itself, so no source file could name it. */
+export function isImplicitEntryName(name: string): boolean {
+  return DUNDER.test(name) || IMPLICIT_ENTRY_NAMES.has(name.toLowerCase());
+}
+
+function normalizeKinds(requested: readonly NodeKind[] | undefined): NodeKind[] {
+  if (!requested || requested.length === 0) return [...DEAD_CODE_KINDS];
+  const kinds = requested.filter((kind) => DEAD_CODE_ALLOWED_KINDS.has(kind));
+  return kinds.length > 0 ? [...new Set(kinds)] : [...DEAD_CODE_KINDS];
+}
+
+/**
+ * The type each candidate is declared in, for the candidates that are members.
+ *
+ * One batched query for the whole candidate set, then one for the containers
+ * themselves — never a lookup per row. Only type-ish containers are returned: a
+ * function's container is the file, which tells us nothing.
+ */
+function containersOf(cg: CodeGraph, nodes: readonly Node[]): Map<string, Node> {
+  const memberIds = nodes.filter((node) => OVERRIDABLE_KINDS.has(node.kind)).map((n) => n.id);
+  const out = new Map<string, Node>();
+  if (memberIds.length === 0) return out;
+
+  const edges = cg.getIncomingEdgesTo(memberIds, ['contains']);
+  const byMember = new Map<string, string>();
+  for (const edge of edges) if (!byMember.has(edge.target)) byMember.set(edge.target, edge.source);
+
+  const containerNodes = cg.getNodesByIds([...new Set(byMember.values())]);
+  for (const [memberId, containerId] of byMember) {
+    const container = containerNodes.get(containerId);
+    if (container && CONTAINER_KINDS.has(container.kind)) out.set(memberId, container);
+  }
+  return out;
+}
+
+/**
+ * Which candidates override something — the ids to drop.
+ *
+ * A method that overrides `Base.run` is reached through `Base.run`; the call
+ * site names the base, so the override carries no incoming edge of its own and
+ * would otherwise head the list. It is matched by NAME within a chain the graph
+ * already links (nothing in the engine emits an `overrides` edge), exactly as
+ * the type-hierarchy block does.
+ *
+ * The second rule is the one that looks wrong and is not: **an ancestor with no
+ * extracted members counts as a match.** A TypeScript interface of pure method
+ * signatures produces no `contains` edges at all, so `class X implements Y`
+ * with every member of `Y` implemented reads, structurally, as a class whose
+ * members override nothing. Answering "cannot tell" with an exclusion is the
+ * only choice that keeps the list's promise; the alternative puts every
+ * implementation of every signature-only interface at the top of a screen that
+ * says "nothing reaches this".
+ */
+function overrideCandidates(
+  cg: CodeGraph,
+  nodes: readonly Node[],
+  containers: ReadonlyMap<string, Node>
+): Set<string> {
+  const dropped = new Set<string>();
+  const containerIds = [...new Set([...containers.values()].map((node) => node.id))];
+  if (containerIds.length === 0) return dropped;
+
+  // Level-by-level upward walk over EVERY container at once: one query per
+  // level rather than one per container. `reach` maps an ancestor back to the
+  // containers it is an ancestor of.
+  const reach = new Map<string, Set<string>>();
+  const seen = new Set<string>(containerIds);
+  let frontier = containerIds.map((id) => ({ id, roots: new Set<string>([id]) }));
+
+  for (let depth = 0; depth < MAX_OVERRIDE_ANCESTOR_DEPTH && frontier.length > 0; depth++) {
+    const rootsOf = new Map(frontier.map((item) => [item.id, item.roots]));
+    const edges = cg.getOutgoingEdgesFrom(
+      frontier.map((item) => item.id),
+      ['extends', 'implements']
+    );
+    const next = new Map<string, Set<string>>();
+    for (const edge of edges) {
+      if (edge.target === edge.source) continue;
+      const roots = rootsOf.get(edge.source);
+      if (!roots) continue;
+      const merged = next.get(edge.target) ?? new Set<string>();
+      for (const root of roots) merged.add(root);
+      next.set(edge.target, merged);
+      const known = reach.get(edge.target) ?? new Set<string>();
+      for (const root of roots) known.add(root);
+      reach.set(edge.target, known);
+    }
+    frontier = [];
+    for (const [id, roots] of next) {
+      if (seen.has(id)) continue;
+      seen.add(id);
+      frontier.push({ id, roots });
+    }
+  }
+
+  if (reach.size === 0) return dropped;
+
+  // What each ancestor declares, and whether it declares anything at all.
+  const ancestorIds = [...reach.keys()];
+  const memberEdges = cg.getOutgoingEdgesFrom(ancestorIds, ['contains']);
+  const memberIdsByAncestor = new Map<string, string[]>();
+  for (const edge of memberEdges) {
+    const bucket = memberIdsByAncestor.get(edge.source);
+    if (bucket) bucket.push(edge.target);
+    else memberIdsByAncestor.set(edge.source, [edge.target]);
+  }
+  const memberNodes = cg.getNodesByIds(memberEdges.map((edge) => edge.target));
+
+  /** Member names an ancestor declares, and whether it declares none we can read. */
+  const namesByContainer = new Map<string, Set<string>>();
+  const opaqueContainers = new Set<string>();
+  for (const [ancestorId, roots] of reach) {
+    const names: string[] = [];
+    for (const memberId of memberIdsByAncestor.get(ancestorId) ?? []) {
+      const member = memberNodes.get(memberId);
+      if (member && OVERRIDABLE_KINDS.has(member.kind)) names.push(member.name);
+    }
+    for (const root of roots) {
+      if (names.length === 0) {
+        opaqueContainers.add(root);
+        continue;
+      }
+      const bucket = namesByContainer.get(root) ?? new Set<string>();
+      for (const name of names) bucket.add(name);
+      namesByContainer.set(root, bucket);
+    }
+  }
+
+  for (const node of nodes) {
+    const container = containers.get(node.id);
+    if (!container) continue;
+    if (opaqueContainers.has(container.id)) {
+      dropped.add(node.id);
+      continue;
+    }
+    if (namesByContainer.get(container.id)?.has(node.name)) dropped.add(node.id);
+  }
+  return dropped;
+}

+ 14 - 0
src/graph/index.ts

@@ -21,3 +21,17 @@ export type {
   OverrideMatch,
   TypeHierarchy,
 } from './type-hierarchy';
+export {
+  buildDeadCodeReport,
+  isImplicitEntryName,
+  DEAD_CODE_ALLOWED_KINDS,
+  DEAD_CODE_KINDS,
+  MAX_DEAD_CODE_CANDIDATES,
+  MAX_OVERRIDE_ANCESTOR_DEPTH,
+} from './dead-code';
+export type {
+  DeadCodeEntry,
+  DeadCodeExclusions,
+  DeadCodeQuery,
+  DeadCodeReport,
+} from './dead-code';

+ 41 - 0
src/index.ts

@@ -1405,6 +1405,47 @@ export class CodeGraph {
     return this.queries.countOutgoingEdges(ids);
   }
 
+  /**
+   * Symbols nothing in the index points at, by kind — the candidate set the
+   * dead code report (`src/graph/dead-code.ts`) applies its exclusions to.
+   *
+   * Every edge kind except `contains` counts as a reference, so a method is
+   * not "reached" by the class that holds it. An unreferenced symbol is not
+   * yet a dead one: see {@link buildDeadCodeReport}.
+   */
+  getUnreferencedNodes(
+    kinds: readonly Node['kind'][],
+    limit: number
+  ): Array<{ node: Node; generated: boolean }> {
+    return this.queries.getUnreferencedNodes(kinds, limit);
+  }
+
+  /**
+   * Which of the given names are carried by more than one symbol, at least one
+   * of which something references — the names a "nothing reaches this" claim
+   * must not be made about, because the resolver may have picked the twin.
+   */
+  getAmbiguousReferencedNames(names: Iterable<string>): Set<string> {
+    return this.queries.getAmbiguousReferencedNames(names);
+  }
+
+  /**
+   * Which of the given languages this index records an export marker for. A
+   * language with none has no "reachable from outside" signal at all.
+   */
+  getLanguagesWithExports(languages: Iterable<string>): Set<string> {
+    return this.queries.getLanguagesWithExports(languages);
+  }
+
+  /**
+   * Which of the given names the index holds an unresolved reference to — the
+   * resolver saw the name and could not decide what it meant. A symbol with
+   * such a name can never be called unreferenced.
+   */
+  getUnresolvedNamesAmong(names: Iterable<string>): Set<string> {
+    return this.queries.getUnresolvedNamesAmong(names);
+  }
+
   /**
    * The symbols with the most distinct dependents, most first — the index's
    * hubs. Distinct dependents, not edges: a helper called forty times from one

+ 221 - 0
src/ui-server/api/deadcode.ts

@@ -0,0 +1,221 @@
+/**
+ * `GET /api/deadcode` — symbols nothing in this repository reaches, grouped by
+ * the file they live in (design spec §3.11).
+ *
+ * The derivation is `src/graph/dead-code.ts`, shared so that a second surface
+ * asking the same question cannot get a different answer. This module is the
+ * renderer, and it has exactly two jobs beyond flattening: hand the report a
+ * source reader that goes through the viewer's read chokepoint, and carry the
+ * exclusion counts onto the wire so the screen can say what the list could not
+ * see. A dead code list without that sentence is a screen that quietly invites
+ * somebody to delete a route handler.
+ *
+ * The rows come back ranked (largest first) and are grouped by file for
+ * display, not re-ranked: the group order follows the best row in it, so the
+ * biggest finding is still at the top of the screen.
+ */
+
+import type { CodeGraph } from '../../index';
+import type { NodeKind } from '../../types';
+import {
+  buildDeadCodeReport,
+  DEAD_CODE_ALLOWED_KINDS,
+  MAX_CORROBORATION_BYTES,
+  MAX_DEAD_CODE_CANDIDATES,
+  type DeadCodeExclusions,
+} from '../../graph/dead-code';
+import { intParam } from './respond';
+import { readIndexedFileText } from './source';
+import { toNodeRef, wireList, type WireList, type WireNodeRef } from './wire';
+
+/** Rows carried on the payload. The screen shows every one it is given. */
+export const MAX_DEAD_CODE_ROWS = 300;
+
+/** Members folded under one row before the row just counts them. */
+export const MAX_DEAD_CODE_MEMBERS = 12;
+
+/** One symbol nothing reaches. */
+export interface WireDeadCodeRow extends WireNodeRef {
+  /** Source lines it spans — the rank, and what deleting it would remove. */
+  lines: number;
+  /**
+   * Members that are unreferenced and live inside this one: a class nobody
+   * instantiates takes its methods with it. Capped; `total` stays real.
+   */
+  members: WireList<WireNodeRef>;
+}
+
+/** The rows of one file, in source order. */
+export interface WireDeadCodeGroup {
+  file: string;
+  /** Tool-generated — drawn dimmed wherever it appears (design spec §2.6). */
+  generated: boolean;
+  test: boolean;
+  /** Lines the rows in this group add up to. */
+  lines: number;
+  rows: WireDeadCodeRow[];
+}
+
+/** One reason candidates were dropped, in the words the screen prints. */
+export interface WireDeadCodeExclusion {
+  reason: keyof DeadCodeExclusions;
+  count: number;
+  label: string;
+}
+
+export interface WireDeadCode {
+  /** Ranked, flat, capped. `total` is the real number of findings. */
+  rows: WireList<WireDeadCodeRow>;
+  /** The SHOWN rows, grouped by file — group order follows the best row. */
+  groups: WireDeadCodeGroup[];
+  /** Symbols with no incoming reference at all, before any exclusion ran. */
+  candidates: number;
+  /** Every exclusion that removed at least one candidate, biggest first. */
+  excluded: WireDeadCodeExclusion[];
+  /** How many candidates every exclusion removed between them. */
+  excludedTotal: number;
+  kinds: NodeKind[];
+  /** Symbols reachable from outside the index are on the list. */
+  includeExported: boolean;
+  includeTests: boolean;
+  includeGenerated: boolean;
+  /** The candidate scan stopped at its cap; there are more. */
+  bounded: boolean;
+  /** Every row was checked against the text of the files that can reach it. */
+  corroborated: boolean;
+  timing: { elapsedMs: number };
+}
+
+/**
+ * The sentence each exclusion prints under the list.
+ *
+ * Written as "N <label>" — so each one reads as a count of candidates, in the
+ * reader's language rather than in the rule's.
+ */
+const EXCLUSION_LABELS: Record<keyof DeadCodeExclusions, string> = {
+  tests: 'in test files',
+  generated: 'in generated files',
+  exported: 'exported, or declared in a header',
+  exportsUnknown: 'in languages this index records no exports for',
+  declarations: 'abstract, or declared on an interface',
+  decorated: 'carrying a decorator, so a framework registers them',
+  overriding: 'overriding a member declared further up',
+  implicit: 'named something the language calls by itself',
+  vendored: 'in vendored directories',
+  testScope: 'inside a test module',
+  markup: 'in component files, where markup can reference them invisibly',
+  unreachableFile: 'in files nothing reaches — islands, drawn on the map',
+  unresolvedName: 'sharing a name the index failed to resolve somewhere',
+  ambiguousName: 'sharing a name with a symbol that IS referenced',
+  mentioned: 'written more than once in a file that can reach them',
+  unreadable: 'in files that could not be read',
+  nested: 'folded into a container on this list',
+};
+
+export function parseDeadCodeQuery(query: URLSearchParams): {
+  limit: number;
+  includeExported: boolean;
+  includeTests: boolean;
+  includeGenerated: boolean;
+  kinds: NodeKind[] | undefined;
+} {
+  const raw = query.get('kinds');
+  const kinds = raw
+    ? (raw
+        .split(',')
+        .map((kind) => kind.trim())
+        .filter((kind) => DEAD_CODE_ALLOWED_KINDS.has(kind as NodeKind)) as NodeKind[])
+    : undefined;
+  return {
+    limit: intParam(query, 'limit', { min: 1, max: MAX_DEAD_CODE_ROWS, default: MAX_DEAD_CODE_ROWS }),
+    includeExported: query.get('exported') === '1',
+    includeTests: query.get('tests') === '1',
+    includeGenerated: query.get('generated') === '1',
+    kinds: kinds && kinds.length > 0 ? kinds : undefined,
+  };
+}
+
+export function buildDeadCode(
+  cg: CodeGraph,
+  projectRoot: string,
+  query: URLSearchParams
+): WireDeadCode {
+  const started = Date.now();
+  const options = parseDeadCodeQuery(query);
+
+  const report = buildDeadCodeReport(cg, {
+    kinds: options.kinds,
+    includeExported: options.includeExported,
+    includeTests: options.includeTests,
+    includeGenerated: options.includeGenerated,
+    limit: options.limit,
+    // The chokepoint, not `fs`: the viewer never opens a path the index does
+    // not name and `resolveProjectFile` has not cleared.
+    readSource: (filePath) =>
+      readIndexedFileText(cg, projectRoot, filePath, MAX_CORROBORATION_BYTES),
+  });
+
+  const generatedFiles = cg.generatedFilePredicate(
+    report.entries.map((entry) => entry.node.filePath)
+  );
+
+  // Groups follow the rows' order: the first time a file appears is where its
+  // group sits, so the largest finding is still at the top of the screen.
+  const rows: WireDeadCodeRow[] = [];
+  const groups: WireDeadCodeGroup[] = [];
+  const byFile = new Map<string, WireDeadCodeGroup>();
+
+  for (const entry of report.entries) {
+    const row: WireDeadCodeRow = {
+      ...toNodeRef(entry.node),
+      lines: entry.lines,
+      members: wireList(
+        entry.members.slice(0, MAX_DEAD_CODE_MEMBERS).map((member) => toNodeRef(member)),
+        entry.members.length
+      ),
+    };
+    rows.push(row);
+
+    let group = byFile.get(row.file);
+    if (!group) {
+      group = {
+        file: row.file,
+        // The path convention plus the indexed banner verdict, both, so a
+        // generated file dims here for the same reason it dims on the map.
+        generated: generatedFiles(entry.node.filePath),
+        test: row.test,
+        lines: 0,
+        rows: [],
+      };
+      byFile.set(row.file, group);
+      groups.push(group);
+    }
+    group.rows.push(row);
+    group.lines += row.lines;
+  }
+  for (const group of groups) group.rows.sort((a, b) => a.line - b.line);
+
+  const excluded: WireDeadCodeExclusion[] = (
+    Object.keys(report.excluded) as Array<keyof DeadCodeExclusions>
+  )
+    .map((reason) => ({ reason, count: report.excluded[reason], label: EXCLUSION_LABELS[reason] }))
+    .filter((entry) => entry.count > 0)
+    .sort((a, b) => b.count - a.count || a.reason.localeCompare(b.reason));
+
+  return {
+    rows: wireList(rows, report.total),
+    groups,
+    candidates: report.candidates,
+    excluded,
+    excludedTotal: excluded.reduce((sum, entry) => sum + entry.count, 0),
+    kinds: report.kinds,
+    includeExported: report.includeExported,
+    includeTests: options.includeTests,
+    includeGenerated: options.includeGenerated,
+    bounded: report.bounded,
+    corroborated: report.corroborated,
+    timing: { elapsedMs: Date.now() - started },
+  };
+}
+
+export { MAX_DEAD_CODE_CANDIDATES };

+ 18 - 1
src/ui-server/api/index.ts

@@ -1,7 +1,7 @@
 /**
  * The read-only JSON API the viewer reads its screens from.
  *
- * Eleven endpoints, one per screen, each answering in a single round-trip — the
+ * Twelve endpoints, one per screen, each answering in a single round-trip — the
  * same principle as `codegraph_explore`: return enough that the caller does not
  * have to ask a follow-up question — plus one that does not answer at all and
  * stays open instead (`/api/events`), so a screen learns that its answer went
@@ -19,6 +19,7 @@
  * GET /api/routes                    the URL to handler map, when there is one
  * GET /api/entrypoints               where to start reading: routes, roots, tests, hubs
  * GET /api/map?root=&depth=          the module map: modules, links, cycles
+ * GET /api/deadcode                  symbols nothing reaches, and what was excluded
  * GET /api/flow?from=&to=            the flow strip: one card per hop
  * GET /api/events                    the live channel (SSE): drift and refresh
  * ```
@@ -45,6 +46,7 @@ import { buildRoutes } from './routes';
 import { buildEntryPoints } from './entrypoints';
 import { buildNodeRefs } from './nodes';
 import { buildMap } from './map';
+import { buildDeadCode } from './deadcode';
 import { buildFlow } from './flow';
 import { EventHub } from './events';
 
@@ -90,6 +92,13 @@ export type {
   WireMapLink,
   WireMapCycle,
 } from './map';
+export type {
+  WireDeadCode,
+  WireDeadCodeExclusion,
+  WireDeadCodeGroup,
+  WireDeadCodeRow,
+} from './deadcode';
+export { MAX_DEAD_CODE_MEMBERS, MAX_DEAD_CODE_ROWS } from './deadcode';
 
 /**
  * A mounted API, plus the handle it holds open.
@@ -147,6 +156,12 @@ const API_INDEX = {
       description:
         'Live channel (server-sent events): source files that changed on disk, and the index moving.',
     },
+    {
+      path: '/api/deadcode',
+      description:
+        'Symbols nothing in the index reaches, grouped by file, with every reason a candidate was excluded.',
+      params: ['limit', 'kinds', 'exported', 'tests', 'generated'],
+    },
     {
       path: '/api/entrypoints',
       description: 'Where to start reading: routes, files that run something, and hubs.',
@@ -177,6 +192,8 @@ export function createGraphApi(options: GraphApiOptions): GraphApi {
           return ok(res, buildRoutes(session.acquire(), ctx.query), ctx.method);
         case '/api/map':
           return ok(res, buildMap(session.acquire(), ctx.projectRoot, ctx.query), ctx.method);
+        case '/api/deadcode':
+          return ok(res, buildDeadCode(session.acquire(), ctx.projectRoot, ctx.query), ctx.method);
         case '/api/entrypoints':
           return ok(res, buildEntryPoints(session.acquire(), ctx.query), ctx.method);
         case '/api/nodes':

+ 36 - 12
src/ui-server/api/map.ts

@@ -113,6 +113,14 @@ export interface WireMapModule {
   languages: Array<{ language: Language; files: number }>;
   /** More than half its files are tests — drawn dashed, hidden by default. */
   test: boolean;
+  /**
+   * How many of its files are tool-generated. A module whose files are ALL
+   * generated is drawn in ink-4 (design spec §2.6): code nobody wrote by hand
+   * and nobody deletes by hand.
+   */
+  generated: number;
+  /** Which of {@link fileList}'s entries are generated, so a row can dim too. */
+  generatedFiles: string[];
   /** True when this box is a single file kept out of the root bucket (a façade). */
   facade: boolean;
   /** Its files, capped — what the side panel lists when the module is selected. */
@@ -311,6 +319,7 @@ export function buildMap(cg: CodeGraph, projectRoot: string, query: URLSearchPar
       language: file.language,
       symbols: file.nodeCount ?? 0,
       test: isTestFile(path),
+      generated: file.generated === true,
     };
   });
 
@@ -340,6 +349,8 @@ export function buildMap(cg: CodeGraph, projectRoot: string, query: URLSearchPar
       files: number;
       symbols: number;
       testFiles: number;
+      generatedFiles: number;
+      generatedPaths: Set<string>;
       languages: Map<Language, number>;
       paths: string[];
     }
@@ -359,6 +370,8 @@ export function buildMap(cg: CodeGraph, projectRoot: string, query: URLSearchPar
         files: 0,
         symbols: 0,
         testFiles: 0,
+        generatedFiles: 0,
+        generatedPaths: new Set(),
         languages: new Map(),
         paths: [],
       };
@@ -368,6 +381,10 @@ export function buildMap(cg: CodeGraph, projectRoot: string, query: URLSearchPar
     entry.paths.push(file.path);
     entry.symbols += file.symbols;
     if (file.test) entry.testFiles += 1;
+    if (file.generated) {
+      entry.generatedFiles += 1;
+      entry.generatedPaths.add(file.path);
+    }
     entry.languages.set(file.language, (entry.languages.get(file.language) ?? 0) + 1);
   }
 
@@ -416,18 +433,25 @@ export function buildMap(cg: CodeGraph, projectRoot: string, query: URLSearchPar
     depth,
     roots: rootOptions(fileRecords),
     modules: [...modules.values()]
-      .map((entry) => ({
-        id: entry.id,
-        label: entry.id.slice(entry.id.lastIndexOf('/') + 1) || entry.id,
-        files: entry.files,
-        symbols: entry.symbols,
-        languages: [...entry.languages]
-          .map(([language, files]) => ({ language, files }))
-          .sort((a, b) => b.files - a.files || a.language.localeCompare(b.language)),
-        test: entry.testFiles * 2 > entry.files,
-        facade: entry.facade,
-        fileList: wireList(entry.paths.slice().sort().slice(0, MAX_FILES_PER_MODULE), entry.files),
-      }))
+      .map((entry) => {
+        const shown = entry.paths.slice().sort().slice(0, MAX_FILES_PER_MODULE);
+        return {
+          id: entry.id,
+          label: entry.id.slice(entry.id.lastIndexOf('/') + 1) || entry.id,
+          files: entry.files,
+          symbols: entry.symbols,
+          languages: [...entry.languages]
+            .map(([language, files]) => ({ language, files }))
+            .sort((a, b) => b.files - a.files || a.language.localeCompare(b.language)),
+          test: entry.testFiles * 2 > entry.files,
+          generated: entry.generatedFiles,
+          facade: entry.facade,
+          // Only the SHOWN paths, so the list the panel dims and the list it
+          // draws are the same list — the count-equals-list rule.
+          generatedFiles: shown.filter((path) => entry.generatedPaths.has(path)),
+          fileList: wireList(shown, entry.files),
+        };
+      })
       // Sorted so two runs over one index produce byte-identical payloads —
       // the layout is deterministic, and it cannot be if its input is not.
       .sort((a, b) => a.id.localeCompare(b.id)),

+ 8 - 4
src/ui-server/api/search.ts

@@ -150,10 +150,14 @@ export function buildSearch(cg: CodeGraph, query: URLSearchParams): unknown {
   });
 
   const top = scored.slice(0, limit);
-  const results: WireSearchResult[] = top.map(({ node, match }) => ({
-    ...toNodeRef(node),
-    matchKind: match,
-  }));
+  // One bounded lookup for the whole page of results, so a generated stub
+  // reads as one at a glance instead of after a click.
+  const isGenerated = cg.generatedFilePredicate(top.map(({ node }) => node.filePath));
+  const results: WireSearchResult[] = top.map(({ node, match }) => {
+    const result: WireSearchResult = { ...toNodeRef(node), matchKind: match };
+    if (isGenerated(node.filePath)) result.generated = true;
+    return result;
+  });
 
   // Groups keep the ranked order: a group appears where its best result did, so
   // flattening the groups reproduces the flat ranking for keyboard navigation.

+ 35 - 0
src/ui-server/api/source.ts

@@ -149,6 +149,41 @@ export function splitLines(content: string): string[] {
   return lines;
 }
 
+/**
+ * The whole text of an INDEXED file, or `null` for anything unreadable.
+ *
+ * The dead code report's corroboration pass needs to count an identifier in a
+ * file's text, and this module is the only one in `api/` that opens a file — so
+ * the reader it uses lives here, behind the same chokepoint. Three refusals,
+ * all answering `null` rather than throwing, because the caller's rule is
+ * already "cannot read it → do not make the claim":
+ *
+ * - not in the index (the viewer never reads a file the graph does not know);
+ * - outside the project (`resolveProjectFile` throws; caught here);
+ * - bigger than `maxBytes`.
+ *
+ * Drift is deliberately NOT checked. The question being asked is "does anything
+ * in this file write this name", and the file's current bytes are the better
+ * answer to it than the bytes we indexed.
+ */
+export function readIndexedFileText(
+  cg: CodeGraph,
+  projectRoot: string,
+  requested: string,
+  maxBytes: number
+): string | null {
+  try {
+    const found = findIndexedFile(cg, requested);
+    if (!found) return null;
+    const absolute = resolveProjectFile(projectRoot, found.storedPath);
+    const stats = fs.statSync(absolute);
+    if (!stats.isFile() || stats.size > maxBytes) return null;
+    return fs.readFileSync(absolute, 'utf8');
+  } catch {
+    return null;
+  }
+}
+
 /**
  * Whether an indexed file has changed on disk since it was indexed — the same
  * verdict `/api/source` returns, for endpoints that must *flag* drift without

+ 8 - 0
src/ui-server/api/wire.ts

@@ -92,6 +92,14 @@ export interface WireNodeRef {
   exported?: boolean;
   /** The file this symbol lives in looks like test/fixture code. */
   test: boolean;
+  /**
+   * The file this symbol lives in is tool-generated, so the row draws in ink-4.
+   *
+   * OPTIONAL and absent by default: the verdict is a bounded lookup
+   * (`generatedFilePredicate`), affordable over a screen's worth of rows and
+   * not over a 545-caller rail. An endpoint fills it where it shows.
+   */
+  generated?: boolean;
 }
 
 /** The focal symbol of a Symbol view — the ref, plus everything the header shows. */

+ 3 - 2
ui/README.md

@@ -63,7 +63,7 @@ build so that mistake cannot land twice.
 ```
 
 Exports: `SymbolView`, `FlowStrip`, `ArchitectureMap`, `FileView`,
-`FileSourceView`, `EntryPointsView`, `TypeHierarchy`, `TrailBar`,
+`FileSourceView`, `EntryPointsView`, `DeadCodeView`, `TypeHierarchy`, `TrailBar`,
 `SearchPalette`, `PalettePanel`, `PaletteRows`, `DriftBanner`, `KindGlyph`,
 `ExportButtons`, `CodegraphUi` — plus every pure model function the screens are
 built from (`buildCalleeRail`, `buildFlowLayout`, `buildMapLayout`,
@@ -90,6 +90,7 @@ interface GraphAdapter {
   map(request?, signal?): Promise<WireMapPayload>;
   routes(request?, signal?): Promise<WireRoutes>;
   entryPoints(request?, signal?): Promise<WireEntryPoints>;
+  deadCode(request?, signal?): Promise<WireDeadCode>;
   events?(handlers): () => void;   // optional: the live channel
 }
 ```
@@ -98,7 +99,7 @@ The shapes are exactly what `src/ui-server/api/` serialises, and they live in
 `src/lib/wire.ts` — no imports, no runtime — so a host can depend on the
 vocabulary without depending on the viewer. The default implementation,
 `createHttpAdapter()`, is the loopback JSON API; a host that already holds the
-index implements the same eleven methods against its own reads and never makes
+index implements the same twelve methods against its own reads and never makes
 an HTTP request. `scripts/check-ui-package.mjs` asserts that no module in the
 built package but `lib/adapter.js` touches the network, because a screen that
 reached past the adapter would be a screen that ignored the host.

+ 16 - 1
ui/src/App.svelte

@@ -9,9 +9,18 @@
   import MapView from './views/MapView.svelte';
   import FlowView from './views/FlowView.svelte';
   import EntryView from './views/EntryView.svelte';
+  import DeadCodeView from './views/DeadCodeView.svelte';
   import NotFoundView from './views/NotFoundView.svelte';
   import Toast from './components/Toast.svelte';
-  import { router, navigate, back, mapHref, flowHref, entryHref } from './lib/router.svelte';
+  import {
+    router,
+    navigate,
+    back,
+    mapHref,
+    flowHref,
+    entryHref,
+    deadHref,
+  } from './lib/router.svelte';
   import { palette } from './lib/palette.svelte';
   import { trail, resolveTrailNames } from './lib/trail.svelte';
   import { project } from './lib/project.svelte';
@@ -113,6 +122,10 @@
         event.preventDefault();
         navigate(entryHref());
         break;
+      case 'd':
+        event.preventDefault();
+        navigate(deadHref());
+        break;
       case 'Backspace':
       case '[':
         event.preventDefault();
@@ -144,6 +157,8 @@
     />
   {:else if route.view === 'entry'}
     <EntryView project={project.name} />
+  {:else if route.view === 'dead'}
+    <DeadCodeView exported={route.exported} />
   {:else if route.view === 'unknown'}
     <NotFoundView path={route.path} />
   {:else}

+ 8 - 2
ui/src/components/PaletteRows.svelte

@@ -84,8 +84,10 @@
         </span>
       {:else}
         <KindGlyph kind={item.node.kind} />
-        <span class="mid">
-          <span class="nm">{item.name}</span>
+        <span class="mid" title={item.node.generated ? `${item.name} — tool-generated` : undefined}>
+          <!-- Generated code recedes here too: a `.pb.go` stub and the
+               hand-written thing beside it must not read the same. -->
+          <span class="nm" class:gen={item.node.generated}>{item.name}</span>
           {#if item.meta}<span class="sig">{item.meta}</span>{/if}
         </span>
       {/if}
@@ -145,6 +147,10 @@
     font-size: 12.5px;
   }
 
+  .nm.gen {
+    color: var(--ink-4);
+  }
+
   .sig {
     margin-left: 6px;
     color: var(--ink-3);

+ 2 - 1
ui/src/components/TopBar.svelte

@@ -1,5 +1,5 @@
 <script lang="ts">
-  import { router, mapHref, flowHref, entryHref, symbolHref } from '../lib/router.svelte';
+  import { router, mapHref, flowHref, entryHref, deadHref, symbolHref } from '../lib/router.svelte';
   import { trail } from '../lib/trail.svelte';
   import SearchPalette from './SearchPalette.svelte';
   import { live } from '../lib/live.svelte';
@@ -69,6 +69,7 @@
     <a href={mapHref()} class:active={view === 'map'}>Map</a>
     <a href={symbolTabHref} class:active={view === 'symbol' || view === 'home'}>Symbol</a>
     <a href={flowHref()} class:active={view === 'flow'}>Flow</a>
+    <a href={deadHref()} class:active={view === 'dead'}>Dead code</a>
   </nav>
 
   <SearchPalette bind:this={search} />

+ 35 - 3
ui/src/components/map/MapSidePanel.svelte

@@ -45,9 +45,12 @@
     exportName,
   }: Props = $props();
 
-  const selectedModule = $derived(
-    selected === null ? null : (layout.nodes.find((n) => n.id === selected)?.module ?? null)
+  const selectedNode = $derived(
+    selected === null ? null : (layout.nodes.find((n) => n.id === selected) ?? null)
   );
+  const selectedModule = $derived(selectedNode?.module ?? null);
+  /** Which of the listed files are tool-generated — the rows drawn in ink-4. */
+  const generatedFiles = $derived(new Set(selectedModule?.generatedFiles ?? []));
 
   const dependencies = $derived(
     selected === null
@@ -205,15 +208,32 @@
         {#if selectedModule.languages.length > 0}
           · {selectedModule.languages.map((l) => `${l.language} ${l.files}`).join(', ')}
         {/if}
+        {#if selectedModule.generated > 0}
+          · {selectedModule.generated === selectedModule.files
+            ? 'all tool-generated'
+            : `${selectedModule.generated} tool-generated`}
+        {/if}
       </p>
 
+      {#if selectedNode?.island}
+        <p class="island">
+          Nothing in the index depends on this module — no import, call or reference crosses into
+          it. It may be an entry point, or reached in a way the graph cannot see.
+        </p>
+      {/if}
+
       {@render linkList('depends on', dependencies, 'target')}
       {@render linkList('depended on by', dependents, 'source')}
 
       <div class="pair label">files</div>
       {#if files.length > 0}
         {#each files as file (file)}
-          <a class="filerow" href={fileHref(file)}>{file}</a>
+          <a
+            class="filerow"
+            class:gen={generatedFiles.has(file)}
+            href={fileHref(file)}
+            title={generatedFiles.has(file) ? `${file} — tool-generated` : file}>{file}</a
+          >
         {/each}
       {:else}
         <div class="pair dim">no files in the index for this module</div>
@@ -381,4 +401,16 @@
     color: var(--accent);
     text-decoration: underline;
   }
+
+  /* Generated code recedes wherever it appears (design spec §2.6). */
+  .filerow.gen {
+    color: var(--ink-4);
+  }
+
+  .island {
+    margin: 6px 0 0;
+    color: var(--ink-2);
+    font-size: 11.5px;
+    line-height: 1.45;
+  }
 </style>

+ 22 - 2
ui/src/components/map/ModuleNode.svelte

@@ -46,14 +46,21 @@
   class:sel={node.selected}
   class:dimmed={node.dimmed}
   class:test={module.test}
+  class:gen={layout.generated}
   style={`width:${layout.width}px;height:${layout.height}px`}
   onclick={() => node.onSelect(layout.id)}
   aria-pressed={node.selected}
-  title={`${module.id} — ${module.symbols} symbols in ${module.files} file${module.files === 1 ? '' : 's'}`}
+  title={`${module.id} — ${module.symbols} symbols in ${module.files} file${
+    module.files === 1 ? '' : 's'
+  }${layout.island ? '. Nothing in the index depends on it.' : ''}${
+    layout.generated ? '. Every file in it is tool-generated.' : ''
+  }`}
 >
   <span class="name">{module.id}</span>
   <!-- The same string nodeWidth() sized the box for; they must not drift. -->
-  <span class="count">{moduleMetaLabel(module)}</span>
+  <span class="count" class:island={layout.island}
+    >{moduleMetaLabel(module, layout.island)}</span
+  >
 </button>
 
 {#each layout.sourceHandles as handle, i (handle)}
@@ -96,6 +103,19 @@
   .mnode.dimmed .count {
     color: var(--ink-4);
   }
+  /* Nothing depends on it — the stroke stays normal (it is not a lesser module,
+     it is an unreached one); only the count line changes what it says. */
+  .count.island {
+    color: var(--ink-2);
+  }
+  /* Generated code: nobody wrote it by hand and nobody deletes it by hand. */
+  .mnode.gen {
+    color: var(--ink-4);
+    border-color: var(--rule-soft);
+  }
+  .mnode.gen .count {
+    color: var(--ink-4);
+  }
   /* Test modules read as scaffolding, not as part of the program. */
   .mnode.test {
     border-style: dashed;

+ 15 - 0
ui/src/index.ts

@@ -46,6 +46,7 @@ export {
   setGraphAdapter,
 } from './lib/adapter';
 export type {
+  DeadCodeRequest,
   EntryPointsRequest,
   FlowRequest,
   GraphAdapter,
@@ -59,6 +60,7 @@ export type {
 
 export {
   back,
+  deadHref,
   entryHref,
   fileHref,
   flowHref,
@@ -70,6 +72,7 @@ export {
   symbolHref,
 } from './lib/navigation';
 export type {
+  DeadCodeHrefOptions,
   FileHrefOptions,
   FlowHrefOptions,
   MapHrefOptions,
@@ -94,6 +97,8 @@ export { default as FileView } from './views/FileView.svelte';
 export { default as FileSourceView } from './views/FileCodeView.svelte';
 /** Where a reader starts: routes, files that run something, tests, hubs. */
 export { default as EntryPointsView } from './views/EntryView.svelte';
+/** Symbols nothing reaches, grouped by file, with every exclusion printed. */
+export { default as DeadCodeView } from './views/DeadCodeView.svelte';
 
 /* -------------------------------------------------------- the furniture -- */
 
@@ -215,6 +220,16 @@ export {
 } from './lib/filecode-model';
 export type { FileArc, FileCallRow, SourcePage } from './lib/filecode-model';
 
+export {
+  deadCodeHeadline,
+  deadCodeRowMeta,
+  deadCodeScale,
+  emptyMessage as deadCodeEmptyMessage,
+  exclusionPhrases,
+  groupMeta as deadCodeGroupMeta,
+  DEAD_CODE_CAVEAT,
+} from './lib/deadcode-model';
+
 export { buildEntryPanel, flowPair, matchEntries } from './lib/entry-model';
 export type {
   EntryGroup,

+ 37 - 5
ui/src/lib/adapter.ts

@@ -4,7 +4,7 @@
  *
  * The viewer shipped by `codegraph ui` uses {@link createHttpAdapter}, which is
  * the read-only JSON API over loopback. A host that already holds the graph —
- * CodeGraph Pro, which opens the index in-process — implements the same eleven
+ * CodeGraph Pro, which opens the index in-process — implements the same twelve
  * methods against its own reads and never makes an HTTP request. The components
  * cannot tell the difference, which is the whole point: one implementation of
  * the Symbol view, the Flow strip and the Map, drawn from whichever side of the
@@ -29,6 +29,7 @@
  */
 
 import type {
+  WireDeadCode,
   WireEntryPoints,
   WireFilePayload,
   WireFileCodePayload,
@@ -125,6 +126,24 @@ export interface RoutesRequest {
   limit?: number;
 }
 
+/**
+ * What the dead code list should be allowed to claim.
+ *
+ * Every flag widens the list by switching one honesty rule off, so each one is
+ * a thing the screen then has to say out loud. `exported` is the big one: a
+ * symbol something outside the repository could import is not dead in any sense
+ * the index can check, and turning it on also turns off the "this language
+ * records no exports at all" guard.
+ */
+export interface DeadCodeRequest {
+  limit?: number;
+  /** Node kinds to consider. Omitted means callables and types. */
+  kinds?: readonly string[];
+  includeExported?: boolean;
+  includeTests?: boolean;
+  includeGenerated?: boolean;
+}
+
 /* ----------------------------------------------------------------- live -- */
 
 /**
@@ -149,10 +168,11 @@ export interface LiveHandlers {
  * Everything the components ask of a project.
  *
  * Seven of these are the reading surface named in the task — `search`, `node`,
- * `source`, `file`, `flow`, `map`, `routes` — and the other four are what the
- * screens around them need: `stats` (the blast bar's denominator and the top
- * bar's counts), `nodes` (a trail arrives from a URL as bare ids), `fileCode`
- * (the whole-file view) and `entryPoints` (where a reader starts).
+ * `source`, `file`, `flow`, `map`, `routes` — and the rest are what the screens
+ * around them need: `stats` (the blast bar's denominator and the top bar's
+ * counts), `nodes` (a trail arrives from a URL as bare ids), `fileCode` (the
+ * whole-file view), `entryPoints` (where a reader starts) and `deadCode` (where
+ * nobody goes).
  */
 export interface GraphAdapter {
   /** The index's own facts: counts, thresholds, the blast scale. */
@@ -176,6 +196,8 @@ export interface GraphAdapter {
   routes(request?: RoutesRequest, signal?: AbortSignal): Promise<WireRoutes>;
   /** Where a reader starts: routes, files that run something, tests, hubs. */
   entryPoints(request?: EntryPointsRequest, signal?: AbortSignal): Promise<WireEntryPoints>;
+  /** Symbols nothing reaches, grouped by file, with every exclusion counted. */
+  deadCode(request?: DeadCodeRequest, signal?: AbortSignal): Promise<WireDeadCode>;
   /**
    * Subscribe to index/disk changes. Optional — a host without a live channel
    * omits it and nothing polls. Returns a function that closes the stream.
@@ -314,6 +336,16 @@ export function createHttpAdapter(options: HttpAdapterOptions = {}): GraphAdapte
       return getJson<WireEntryPoints>(`api/entrypoints${query(params)}`, signal);
     },
 
+    deadCode(request = {}, signal) {
+      const params = new URLSearchParams();
+      if (request.limit) params.set('limit', String(request.limit));
+      if (request.kinds?.length) params.set('kinds', request.kinds.join(','));
+      if (request.includeExported) params.set('exported', '1');
+      if (request.includeTests) params.set('tests', '1');
+      if (request.includeGenerated) params.set('generated', '1');
+      return getJson<WireDeadCode>(`api/deadcode${query(params)}`, signal);
+    },
+
     events(handlers) {
       if (typeof EventSource === 'undefined') return () => {};
       const stream = new EventSource(`${base}api/events`);

+ 19 - 0
ui/src/lib/api.ts

@@ -13,6 +13,7 @@
 
 import { getGraphAdapter } from './adapter';
 import type {
+  WireDeadCode,
   WireEntryPoints,
   WireFilePayload,
   WireFileCodePayload,
@@ -30,6 +31,7 @@ export * from './wire';
 export { ApiFailure } from './adapter';
 export type {
   GraphAdapter,
+  DeadCodeRequest,
   EntryPointsRequest,
   FlowRequest,
   HttpAdapterOptions,
@@ -68,6 +70,23 @@ export function fetchEntryPoints(
   return getGraphAdapter().entryPoints(opts, signal);
 }
 
+/**
+ * Symbols nothing in the index reaches, grouped by file — and, just as
+ * importantly, every reason a candidate was left off. The screen prints both.
+ */
+export function fetchDeadCode(
+  opts: {
+    limit?: number;
+    kinds?: readonly string[];
+    includeExported?: boolean;
+    includeTests?: boolean;
+    includeGenerated?: boolean;
+  } = {},
+  signal?: AbortSignal
+): Promise<WireDeadCode> {
+  return getGraphAdapter().deadCode(opts, signal);
+}
+
 /** The URL → handler map. The palette reads routes through `fetchEntryPoints`. */
 export function fetchRoutes(
   opts: { limit?: number } = {},

+ 95 - 0
ui/src/lib/deadcode-model.ts

@@ -0,0 +1,95 @@
+/**
+ * The dead code list's arithmetic and its sentences (design spec §3.11).
+ *
+ * Pure functions over the `/api/deadcode` payload: no DOM, no fetch. The
+ * screen's whole job is to be believed, and everything that decides whether it
+ * should be lives here — the caveat that never goes away, the headline that
+ * says how much of the index is behind the list, and the sentence that names
+ * every reason a candidate was left off.
+ *
+ * The rule this file exists to enforce: **the list and its caveats are one
+ * thing.** A screen that draws the rows and leaves the exclusions to a
+ * collapsed panel is a screen that gets somebody to delete a route handler.
+ */
+
+import { plural } from './symbol-model';
+import { kindWord } from './kinds';
+import type { WireDeadCode, WireDeadCodeGroup, WireDeadCodeRow } from './wire';
+
+/**
+ * The line that is always on screen, whatever the list says.
+ *
+ * Not a dismissible note and not a tooltip: the claim this screen makes is
+ * "no static reference in the index", which is a strictly weaker claim than
+ * "unused", and the difference is the whole risk of acting on it.
+ */
+export const DEAD_CODE_CAVEAT =
+  'No static reference in the index — dynamic use is possible.';
+
+/** "symbol" / "symbols" — the noun without its count, for sentences that count twice. */
+function noun(count: number, one: string): string {
+  return count === 1 ? one : `${one}s`;
+}
+
+/** The one-line summary above the list. */
+export function deadCodeHeadline(payload: WireDeadCode | null): string {
+  if (!payload) return '';
+  const { rows } = payload;
+  if (rows.total === 0) return 'Nothing on this list.';
+  const lines = payload.groups.reduce((sum, group) => sum + group.lines, 0);
+  const shown = rows.truncated
+    ? `${rows.shown} of ${rows.total} ${noun(rows.total, 'symbol')}`
+    : plural(rows.total, 'symbol');
+  return `${shown} in ${plural(payload.groups.length, 'file')} · ${plural(lines, 'line')}`;
+}
+
+/**
+ * "2 478 of 2 498 candidates were left off" — the number that gives the list
+ * its scale.
+ *
+ * Twenty rows drawn from twenty candidates and twenty drawn from two and a half
+ * thousand are different screens, and only this sentence tells them apart.
+ */
+export function deadCodeScale(payload: WireDeadCode | null): string {
+  if (!payload || payload.candidates === 0) return '';
+  return `${payload.candidates.toLocaleString()} ${noun(payload.candidates, 'symbol')} in this index carry no incoming reference at all; ${payload.excludedTotal.toLocaleString()} of them were left off this list.`;
+}
+
+/** Each exclusion as "N <label>", biggest first — the sentence under the list. */
+export function exclusionPhrases(payload: WireDeadCode | null): string[] {
+  if (!payload) return [];
+  return payload.excluded.map((entry) => `${entry.count.toLocaleString()} ${entry.label}`);
+}
+
+/** The row's second line: what it is, and what deleting it would remove. */
+export function deadCodeRowMeta(row: WireDeadCodeRow): string {
+  const parts = [kindWord(row.kind), plural(row.lines, 'line')];
+  if (row.members.total > 0) {
+    parts.push(`${plural(row.members.total, 'member')} unreachable with it`);
+  }
+  return parts.join(' · ');
+}
+
+/** The group header's right-hand count. */
+export function groupMeta(group: WireDeadCodeGroup): string {
+  const parts = [plural(group.rows.length, 'symbol'), plural(group.lines, 'line')];
+  if (group.generated) parts.push('generated');
+  return parts.join(' · ');
+}
+
+/**
+ * What the screen says when the list is empty.
+ *
+ * An empty list is a real answer and never an error — but "nothing found" and
+ * "nothing survived the filters" are different answers, and the second one
+ * points at the toggle that would widen it.
+ */
+export function emptyMessage(payload: WireDeadCode): string {
+  if (payload.candidates === 0) {
+    return 'Every symbol in this index is referenced by something. Nothing to show.';
+  }
+  if (payload.includeExported) {
+    return `Every one of the ${payload.candidates.toLocaleString()} symbols with no incoming reference has a reason to be reachable anyway — see the list of exclusions below.`;
+  }
+  return `All ${payload.candidates.toLocaleString()} symbols with no incoming reference are either reachable from outside this repository or excluded for the reasons below. Turn on "including exported" to see the ones the index cannot check.`;
+}

+ 34 - 4
ui/src/lib/map-model.ts

@@ -95,8 +95,17 @@ export function nodeWidth(label: string, meta = ''): number {
   );
 }
 
-/** The second line of a module box — and the string {@link nodeWidth} sizes for. */
-export function moduleMetaLabel(module: WireMapModule): string {
+/**
+ * The second line of a module box — and the string {@link nodeWidth} sizes for.
+ *
+ * An island says so INSTEAD of counting itself. "Nothing depends on this" is
+ * the only fact about such a module a reader needs from twenty boxes away, and
+ * the counts are still one click away in the side panel. Both callers — the
+ * width calculation and the box itself — must pass the same `island`, or the
+ * text will not fit the box that was sized for it.
+ */
+export function moduleMetaLabel(module: WireMapModule, island = false): string {
+  if (island) return 'nothing depends on this';
   const symbols = `${module.symbols} symbol${module.symbols === 1 ? '' : 's'}`;
   const files = `${module.files} file${module.files === 1 ? '' : 's'}`;
   return `${symbols} · ${files}`;
@@ -105,6 +114,16 @@ export function moduleMetaLabel(module: WireMapModule): string {
 export interface MapNodeLayout {
   id: string;
   module: WireMapModule;
+  /**
+   * No link in the payload arrives here — an island (task CG-59).
+   *
+   * Computed from the WHOLE link set, not the filtered one, so hiding test
+   * modules or raising the weight threshold cannot manufacture an island that
+   * the index does not agree is one.
+   */
+  island: boolean;
+  /** Every file in it is tool-generated, so it draws in ink-4. */
+  generated: boolean;
   layer: number;
   x: number;
   y: number;
@@ -189,6 +208,9 @@ export function buildMapLayout(
 ): MapLayout {
   const modules = payload.modules.filter((m) => options.includeTests || !m.test);
   const present = new Set(modules.map((m) => m.id));
+  // Islands come off the UNFILTERED link set: a module a hidden test module
+  // depends on is depended on, whatever this screen is currently showing.
+  const depended = new Set(payload.links.map((l) => l.target));
   const links = payload.links.filter((l) => present.has(l.source) && present.has(l.target));
   const minWeight = options.includeTests ? MIN_WEIGHT_WITH_TESTS : MIN_WEIGHT;
 
@@ -258,7 +280,10 @@ export function buildMapLayout(
   }
 
   // --- placement -----------------------------------------------------------
-  const widths = new Map(modules.map((m) => [m.id, nodeWidth(m.id, moduleMetaLabel(m))]));
+  const islands = new Set(modules.filter((m) => !depended.has(m.id)).map((m) => m.id));
+  const widths = new Map(
+    modules.map((m) => [m.id, nodeWidth(m.id, moduleMetaLabel(m, islands.has(m.id)))])
+  );
   const rowSums = rows.map((row) => row.reduce((sum, id) => sum + (widths.get(id) ?? 0), 0));
   // Natural span = the boxes shoulder to shoulder. The content width is the
   // widest of those, and NOTHING may exceed it — a row of forty leaf modules
@@ -286,9 +311,14 @@ export function buildMapLayout(
     const y = PADDING + (layerCount - 1 - index) * (NODE_HEIGHT + LAYER_GAP);
     for (const id of row) {
       const w = widths.get(id) ?? MIN_NODE_WIDTH;
+      const module = byId.get(id)!;
       nodesById.set(id, {
         id,
-        module: byId.get(id)!,
+        module,
+        island: islands.has(id),
+        // Every file generated, not merely some: a module with one `.pb.go` in
+        // it is still a module somebody writes by hand.
+        generated: module.files > 0 && module.generated === module.files,
         layer: index,
         x,
         y,

+ 16 - 0
ui/src/lib/navigation.ts

@@ -43,6 +43,11 @@ export interface MapHrefOptions {
   tests?: boolean;
 }
 
+export interface DeadCodeHrefOptions {
+  /** Include symbols something outside the index could import. */
+  exported?: boolean;
+}
+
 export interface FlowHrefOptions {
   from?: string;
   to?: string;
@@ -63,6 +68,7 @@ export interface NavigationDriver {
   mapHref(opts?: MapHrefOptions): string;
   flowHref(opts?: FlowHrefOptions): string;
   entryHref(): string;
+  deadHref(opts?: DeadCodeHrefOptions): string;
   /** Go to an href this driver built. */
   navigate(href: string, opts?: { replace?: boolean }): void;
   /** Back one entry in the host's history. */
@@ -127,6 +133,12 @@ export const hashNavigation: NavigationDriver = {
     return '#/entry';
   },
 
+  deadHref(opts = {}) {
+    const params = new URLSearchParams();
+    if (opts.exported) params.set('exported', '1');
+    return `#/dead${query(params)}`;
+  },
+
   navigate(href, opts = {}) {
     const target = href.startsWith('#') ? href : `#${href}`;
     if (opts.replace) {
@@ -200,6 +212,10 @@ export function entryHref(): string {
   return driver.entryHref();
 }
 
+export function deadHref(opts: DeadCodeHrefOptions = {}): string {
+  return driver.deadHref(opts);
+}
+
 export function navigate(href: string, opts: { replace?: boolean } = {}): void {
   driver.navigate(href, opts);
 }

+ 12 - 0
ui/src/lib/router.svelte.ts

@@ -11,6 +11,7 @@
  *   #/map                  module map       (?root=&depth=&tests=1)
  *   #/flow                 flow strip       (?from=&to= | ?symbols= | ?t=<trail>)
  *   #/entry                entry points     (where a flow starts)
+ *   #/dead                 dead code        (?exported=1 widens the claim)
  *
  * Node ids are opaque engine strings shaped `<kind>:<hash>` or
  * `<kind>:<relative/path>` (see src/extraction/tree-sitter-helpers.ts), so
@@ -31,6 +32,7 @@ import { registerHashSync } from './navigation';
 
 export {
   back,
+  deadHref,
   entryHref,
   fileHref,
   flowHref,
@@ -42,6 +44,7 @@ export {
   symbolHref,
 } from './navigation';
 export type {
+  DeadCodeHrefOptions,
   FileHrefOptions,
   FlowHrefOptions,
   MapHrefOptions,
@@ -71,6 +74,11 @@ export type Route =
       trail: string | null;
     }
   | { view: 'entry' }
+  | {
+      view: 'dead';
+      /** Symbols reachable from outside the index are on the list. */
+      exported: boolean;
+    }
   | { view: 'unknown'; path: string };
 
 export type ViewName = Route['view'];
@@ -128,6 +136,10 @@ export function parseHash(hash: string): RouterLocation {
     };
   } else if (head === 'entry' && rest.length === 0) {
     route = { view: 'entry' };
+  } else if (head === 'dead' && rest.length === 0) {
+    // The widening travels in the URL like the map's shape does: a link to
+    // "including exported symbols" has to reopen the same list.
+    route = { view: 'dead', exported: params.get('exported') === '1' };
   } else if (head === 'flow' && rest.length === 0) {
     // The question travels in the URL exactly as it was asked, so a flow can be
     // linked in a review and reopen as the same path.

+ 55 - 0
ui/src/lib/wire.ts

@@ -34,6 +34,12 @@ export interface WireNodeRef {
   exported?: boolean;
   /** Lives in a file that looks like test or fixture code. */
   test: boolean;
+  /**
+   * Lives in a tool-generated file, so the row draws in ink-4. Optional: only
+   * the endpoints that show it pay for the lookup, so `undefined` means "not
+   * asked", never "no".
+   */
+  generated?: boolean;
 }
 
 export interface WireNodeDetail extends WireNodeRef {
@@ -594,6 +600,10 @@ export interface WireMapModule {
   languages: Array<{ language: string; files: number }>;
   /** More than half its files are tests. */
   test: boolean;
+  /** How many of its files are tool-generated. All of them → drawn in ink-4. */
+  generated: number;
+  /** Which of `fileList.items` are generated, so a row in the panel can dim too. */
+  generatedFiles: string[];
   /** A single file kept out of the root bucket because it is the façade. */
   facade: boolean;
   /** Its files, capped — the side panel's list when the module is selected. */
@@ -631,3 +641,48 @@ export interface WireMapPayload {
   index: { lastIndexedAt: number | null; edges: number; files: number };
   timing: { elapsedMs: number; cached: boolean };
 }
+
+/* -------------------------------------------------------------- dead code -- */
+
+/** One symbol nothing in the index reaches. */
+export interface WireDeadCodeRow extends WireNodeRef {
+  /** Source lines it spans — the rank, and what deleting it would remove. */
+  lines: number;
+  /** Unreferenced members inside it: a dead class takes its methods with it. */
+  members: WireList<WireNodeRef>;
+}
+
+/** The rows of one file, in source order. */
+export interface WireDeadCodeGroup {
+  file: string;
+  /** Tool-generated — drawn dimmed wherever it appears. */
+  generated: boolean;
+  test: boolean;
+  lines: number;
+  rows: WireDeadCodeRow[];
+}
+
+/** One reason candidates were dropped, already worded for the screen. */
+export interface WireDeadCodeExclusion {
+  reason: string;
+  count: number;
+  label: string;
+}
+
+export interface WireDeadCode {
+  rows: WireList<WireDeadCodeRow>;
+  /** The SHOWN rows, grouped by file — group order follows the best row. */
+  groups: WireDeadCodeGroup[];
+  /** Symbols with no incoming reference at all, before any exclusion ran. */
+  candidates: number;
+  excluded: WireDeadCodeExclusion[];
+  excludedTotal: number;
+  kinds: string[];
+  includeExported: boolean;
+  includeTests: boolean;
+  includeGenerated: boolean;
+  bounded: boolean;
+  /** Every row was checked against the text of the files that can reach it. */
+  corroborated: boolean;
+  timing: { elapsedMs: number };
+}

+ 440 - 0
ui/src/views/DeadCodeView.svelte

@@ -0,0 +1,440 @@
+<script lang="ts">
+  /**
+   * Dead code — symbols nothing in this repository reaches, grouped by file
+   * (design spec §3.11).
+   *
+   * The screen is a list and a disclaimer, deliberately in that order and
+   * deliberately inseparable. The caveat line sits above the rows and never
+   * goes away, because the claim behind every row is "no static reference in
+   * the index" and not "unused": reflection, a framework registry and a
+   * template can all reach code the graph cannot follow. Underneath, every
+   * reason a candidate was left off is printed with its count — a list of
+   * twenty drawn from two and a half thousand candidates means something very
+   * different from a list of twenty drawn from twenty-one.
+   *
+   * The one switch is "including exported". Off (the default) the list is only
+   * symbols nothing outside this repository could import either; on, it widens
+   * to symbols the index has no way to check, and says so. It travels in the
+   * URL like the map's shape does, so a link reopens the same list.
+   *
+   * The other half of this task lives on the Map: a module nothing depends on
+   * says so in its own count line. See `lib/map-model.ts`.
+   */
+  import KindGlyph from '../components/KindGlyph.svelte';
+  import { fetchDeadCode, ApiFailure, type WireDeadCode, type WireDeadCodeRow } from '../lib/api';
+  import { deadHref, fileHref, navigate, symbolHref } from '../lib/navigation';
+  import { live } from '../lib/live.svelte';
+  import {
+    DEAD_CODE_CAVEAT,
+    deadCodeHeadline,
+    deadCodeRowMeta,
+    deadCodeScale,
+    emptyMessage,
+    exclusionPhrases,
+    groupMeta,
+  } from '../lib/deadcode-model';
+
+  interface Props {
+    /** Include symbols something outside the index could import. */
+    exported?: boolean;
+  }
+
+  let { exported = false }: Props = $props();
+
+  let payload = $state<WireDeadCode | null>(null);
+  let failure = $state<string | null>(null);
+  let loading = $state(true);
+
+  $effect(() => {
+    const includeExported = exported;
+    // The index moving invalidates every row: a symbol is on this list because
+    // of what the graph does NOT contain, which is exactly what a sync changes.
+    void live.indexTick;
+    const controller = new AbortController();
+    loading = true;
+    failure = null;
+    fetchDeadCode({ includeExported }, controller.signal)
+      .then((next) => {
+        payload = next;
+        loading = false;
+      })
+      .catch((error: unknown) => {
+        if (controller.signal.aborted) return;
+        failure = error instanceof ApiFailure ? error.message : 'The list could not be read.';
+        loading = false;
+      });
+    return () => controller.abort();
+  });
+
+  let headline = $derived(deadCodeHeadline(payload));
+  let scale = $derived(deadCodeScale(payload));
+  let phrases = $derived(exclusionPhrases(payload));
+
+  function open(row: WireDeadCodeRow): void {
+    navigate(symbolHref(row.id, { line: row.line }));
+  }
+</script>
+
+<div class="scroll">
+  <div class="head">
+    <h2>Dead code</h2>
+    <p>
+      Symbols no import, call or reference in this index reaches, largest first. Everything below
+      is what the graph can see; the notes under the list are what it cannot.
+    </p>
+  </div>
+
+  <div class="bar">
+    <p class="caveat">{DEAD_CODE_CAVEAT}</p>
+    <a
+      class="toggle"
+      class:on={exported}
+      href={deadHref({ exported: !exported })}
+      title={exported
+        ? 'Back to symbols nothing outside this repository could import either'
+        : 'Also list symbols something outside this repository could import — the index cannot check those'}
+      onclick={(event) => {
+        event.preventDefault();
+        navigate(deadHref({ exported: !exported }));
+      }}>{exported ? 'Including exported' : 'Internal only'}</a
+    >
+  </div>
+
+  {#if failure}
+    <p class="state">Could not read the list — {failure}</p>
+  {:else if loading && payload === null}
+    <p class="state">Reading the graph…</p>
+  {:else if payload}
+    {#if exported}
+      <p class="warn">
+        Exported symbols are on this list. Nothing in this repository references them, but anything
+        outside it can — a published package, another service, a script. Read each one before you
+        believe it.
+      </p>
+    {/if}
+
+    {#if payload.groups.length === 0}
+      <p class="state">{emptyMessage(payload)}</p>
+    {:else}
+      <p class="headline">{headline}</p>
+      <div class="groups">
+        {#each payload.groups as group (group.file)}
+          <div class="filegroup" class:gen={group.generated}>
+            <div class="fpath">
+              <a href={fileHref(group.file)} title={group.file}>{group.file}</a>
+              <b>{groupMeta(group)}</b>
+            </div>
+            {#each group.rows as row (row.id)}
+              <div class="row">
+                <KindGlyph kind={row.kind} />
+                <div class="body">
+                  <div class="line">
+                    <button
+                      type="button"
+                      class="nm"
+                      title={row.qualifiedName}
+                      data-dead-row={row.id}
+                      onclick={() => open(row)}>{row.name}</button
+                    >
+                    <a class="ln" href={fileHref(group.file, { source: true, line: row.line })}
+                      >{row.file}:{row.line}</a
+                    >
+                    {#if row.exported}<span class="chip">exported</span>{/if}
+                  </div>
+                  <div class="meta">{deadCodeRowMeta(row)}</div>
+                  {#if row.members.items.length > 0}
+                    <div class="members">
+                      {#each row.members.items as member (member.id)}
+                        <a class="member" href={symbolHref(member.id)}>{member.name}</a>
+                      {/each}
+                      {#if row.members.truncated}
+                        <span class="member more"
+                          >+{row.members.total - row.members.shown} more</span
+                        >
+                      {/if}
+                    </div>
+                  {/if}
+                </div>
+              </div>
+            {/each}
+          </div>
+        {/each}
+      </div>
+    {/if}
+
+    <div class="notes">
+      <p>{scale}</p>
+      {#if phrases.length > 0}
+        <ul>
+          {#each phrases as phrase (phrase)}
+            <li>{phrase}</li>
+          {/each}
+        </ul>
+      {/if}
+      {#if !payload.corroborated}
+        <p>
+          The rows were not checked against the text of the files that can reach them, so a
+          reference the extractor did not record would not have been caught.
+        </p>
+      {/if}
+      {#if payload.bounded}
+        <p>
+          The scan stopped at its cap — this index holds more unreferenced symbols than were
+          considered.
+        </p>
+      {/if}
+      {#if payload.rows.truncated}
+        <p>
+          Showing {payload.rows.shown} of {payload.rows.total} — the rest are in the index, not on
+          this list.
+        </p>
+      {/if}
+    </div>
+  {/if}
+</div>
+
+<style>
+  .scroll {
+    height: 100%;
+    overflow: auto;
+  }
+
+  .head {
+    max-width: 760px;
+    padding: 26px 40px 6px;
+  }
+
+  .head h2 {
+    margin: 0 0 6px;
+    font-size: 20px;
+    font-weight: 600;
+    letter-spacing: -0.01em;
+  }
+
+  .head p {
+    margin: 0;
+    color: var(--ink-2);
+    font-size: 13px;
+    line-height: 1.45;
+  }
+
+  .bar {
+    display: flex;
+    align-items: baseline;
+    justify-content: space-between;
+    gap: 16px;
+    max-width: 760px;
+    margin: 14px 40px 0;
+    padding: 6px 0;
+    border-top: 1px solid var(--rule-soft);
+    border-bottom: 1px solid var(--rule-soft);
+  }
+
+  /* The caveat is never dismissible and never collapsed: it is the difference
+     between "nothing references this" and "nobody uses this". */
+  .caveat {
+    margin: 0;
+    color: var(--ink-3);
+    font-size: 11.5px;
+    line-height: 1.4;
+  }
+
+  .toggle {
+    flex: none;
+    padding: 1px 6px;
+    border: 1px solid var(--rule-soft);
+    color: var(--ink-2);
+    font: 11px var(--mono);
+    text-decoration: none;
+  }
+
+  .toggle:hover {
+    border-color: var(--ink);
+    color: var(--ink);
+  }
+
+  .toggle.on {
+    border-color: var(--accent-line);
+    background: var(--accent-soft);
+    color: var(--accent);
+  }
+
+  .warn {
+    max-width: 760px;
+    margin: 12px 40px 0;
+    padding: 8px 12px;
+    border: 1px solid var(--accent-line);
+    background: var(--accent-soft);
+    color: var(--ink-2);
+    font-size: 11.5px;
+    line-height: 1.45;
+  }
+
+  .headline {
+    max-width: 760px;
+    margin: 14px 40px 0;
+    color: var(--ink-2);
+    font-size: 12.5px;
+  }
+
+  .state {
+    max-width: 760px;
+    padding: 16px 40px 40px;
+    color: var(--ink-3);
+    font-size: 12.5px;
+    line-height: 1.5;
+  }
+
+  .groups {
+    max-width: 760px;
+    margin: 8px 40px 0;
+    border: 1px solid var(--rule-soft);
+  }
+
+  .filegroup {
+    padding: 10px 14px 6px;
+    border-bottom: 1px solid var(--rule-faint);
+  }
+
+  .filegroup:last-child {
+    border-bottom: 0;
+  }
+
+  .fpath {
+    display: flex;
+    justify-content: space-between;
+    gap: 8px;
+    margin-bottom: 4px;
+    color: var(--ink-3);
+    font: 11px var(--mono);
+  }
+
+  .fpath a {
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+
+  .fpath a:hover {
+    color: var(--ink);
+    text-decoration: underline;
+  }
+
+  .fpath b {
+    flex: none;
+    color: var(--ink-2);
+    font-weight: 500;
+  }
+
+  /* Generated code recedes wherever it appears (design spec §2.6). */
+  .filegroup.gen .fpath,
+  .filegroup.gen .fpath b,
+  .filegroup.gen .nm,
+  .filegroup.gen .meta {
+    color: var(--ink-4);
+  }
+
+  .row {
+    display: grid;
+    grid-template-columns: 16px 1fr;
+    gap: 8px;
+    align-items: start;
+    margin: 0 -6px;
+    padding: 5px 6px 5px 4px;
+    border: 1px solid transparent;
+  }
+
+  .row:hover {
+    background: var(--press);
+  }
+
+  .body {
+    min-width: 0;
+  }
+
+  .line {
+    display: flex;
+    align-items: baseline;
+    gap: 8px;
+  }
+
+  .nm {
+    overflow: hidden;
+    min-width: 0;
+    color: var(--ink);
+    font: 12.5px var(--mono);
+    text-align: left;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    cursor: pointer;
+  }
+
+  .ln {
+    flex: none;
+    color: var(--ink-3);
+    font: 11px var(--mono);
+    text-decoration: none;
+  }
+
+  .ln:hover {
+    color: var(--accent);
+    text-decoration: underline;
+  }
+
+  .chip {
+    flex: none;
+    padding: 0 4px;
+    border: 1px solid var(--accent-line);
+    background: var(--accent-soft);
+    color: var(--accent);
+    font: 11px var(--mono);
+  }
+
+  .meta {
+    margin-top: 1px;
+    overflow: hidden;
+    color: var(--ink-3);
+    font-size: 11px;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+
+  .members {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 4px 8px;
+    margin-top: 3px;
+  }
+
+  .member {
+    color: var(--ink-3);
+    font: 11px var(--mono);
+    text-decoration: none;
+  }
+
+  .member:hover {
+    color: var(--accent);
+    text-decoration: underline;
+  }
+
+  .member.more {
+    color: var(--ink-4);
+  }
+
+  .notes {
+    max-width: 760px;
+    margin: 14px 40px 48px;
+    color: var(--ink-3);
+    font-size: 11.5px;
+    line-height: 1.5;
+  }
+
+  .notes p {
+    margin: 0 0 6px;
+  }
+
+  .notes ul {
+    margin: 0;
+    padding-left: 16px;
+  }
+</style>

+ 6 - 1
ui/src/views/FileView.svelte

@@ -276,7 +276,8 @@
     <section class="center" bind:this={centerEl}>
       <div class="card-h">
         <KindGlyph kind="file" />
-        <h1>{basename(payload.file.path)}</h1>
+        <!-- Generated code recedes wherever it appears (design spec §2.6). -->
+        <h1 class:gen={payload.file.generated}>{basename(payload.file.path)}</h1>
         <span class="kindword">{fileMetaLine(payload)}</span>
         <span class="loc">{payload.file.path}</span>
         <div class="spacer"></div>
@@ -381,6 +382,10 @@
     letter-spacing: -0.01em;
   }
 
+  .card-h h1.gen {
+    color: var(--ink-4);
+  }
+
   .spacer {
     flex: 1 1 auto;
   }