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