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
@@ -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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user