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
+37
-5
@@ -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`);
|
||||
|
||||
@@ -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 } = {},
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user