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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2a0c6dc58f
commit
56dfdb0655
@@ -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 };
|
||||
@@ -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
@@ -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)),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user