mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-21 06:29:44 +08:00
* fix(dead-code): treat an ancestor outside the index as opaque (#1973) An override is excluded from the dead-code report when its ancestor is in the graph, or is present but declares no members we can read. An ancestor outside the project (React.Component, stream.Transform, a NestJS interface) creates no edge at all, so a lifecycle method the framework calls looked like it overrode nothing and was listed as unreferenced. The resolver does record such an ancestor, as a failed extends / implements row. A container with one, or a project ancestor of one that has it, now counts as having an opaque ancestor, and its members are excluded under "overriding". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(dead-code): propagate external ancestor uncertainty to descendants (#1973) External ancestors leave unresolved inheritance references rather than graph edges. Treat those ancestors as opaque and propagate uncertainty through every descendant root. Track visited ancestor/root pairs while preserving cycle protection and the depth bound. Cover direct and three-level inheritance, unused controls, and batched query behavior. Co-authored-by: danusha2345 <ewidusoc498@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: danusha2345 <ewidusoc498@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
904 lines
34 KiB
TypeScript
904 lines
34 KiB
TypeScript
/**
|
|
* 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".
|
|
*
|
|
* An ancestor **outside the index** is opaque in the same way, and more so: a
|
|
* class that extends `React.Component` or `stream.Transform`, or implements a
|
|
* framework interface, has lifecycle methods the framework calls, and the
|
|
* ancestor leaves no edge to walk at all — only the resolver's failed
|
|
* `extends` / `implements` row. A container with such a row, or a project
|
|
* ancestor of one that has it, counts as having an opaque ancestor (#1973).
|
|
*/
|
|
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 Map(containerIds.map((id) => [id, new Set([id])]));
|
|
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>();
|
|
const visited = seen.get(edge.target) ?? new Set<string>();
|
|
const known = reach.get(edge.target) ?? new Set<string>();
|
|
// An ancestor may already be a candidate root. Only skip pairs we have
|
|
// propagated, so later descendants still reach all of its ancestors.
|
|
for (const root of roots) {
|
|
if (visited.has(root)) continue;
|
|
visited.add(root);
|
|
merged.add(root);
|
|
known.add(root);
|
|
}
|
|
seen.set(edge.target, visited);
|
|
if (merged.size > 0) next.set(edge.target, merged);
|
|
if (known.size > 0) reach.set(edge.target, known);
|
|
}
|
|
frontier = [];
|
|
for (const [id, roots] of next) {
|
|
frontier.push({ id, roots });
|
|
}
|
|
}
|
|
|
|
const opaqueContainers = new Set<string>();
|
|
const externalBased = cg.getUnresolvedSupertypeSourcesAmong([...containerIds, ...reach.keys()]);
|
|
for (const id of containerIds) if (externalBased.has(id)) opaqueContainers.add(id);
|
|
for (const [ancestorId, roots] of reach) {
|
|
if (externalBased.has(ancestorId)) for (const root of roots) opaqueContainers.add(root);
|
|
}
|
|
|
|
// 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>>();
|
|
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;
|
|
}
|