| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485 |
- /**
- * Dead code and islands (CG-59).
- *
- * Two halves, both against a real indexed fixture: the derivation in
- * `src/graph/dead-code.ts`, and the `/api/deadcode` endpoint that renders it
- * over a real loopback server, like the rest of the viewer's API suite.
- *
- * The fixture is shaped to produce, deliberately, one of each thing the report
- * has to get RIGHT BY NOT CLAIMING IT:
- *
- * - a genuinely unreferenced helper (the only row that should survive);
- * - a same-name pair where the resolver attaches the call to the wrong one —
- * the mis-resolution that makes a used method look unreached;
- * - a method that overrides a base's, reached only through the base;
- * - a decorated method, registered by a framework the graph cannot see;
- * - a helper only a template mentions, so no edge records the use but the file
- * text does;
- * - an exported function nothing here calls, which an outside caller may.
- *
- * Every one of those must be OFF the list, and the reason must be counted.
- */
- import { describe, it, expect, beforeAll, afterAll } from 'vitest';
- import * as http from 'http';
- import * as fs from 'fs';
- import * as os from 'os';
- import * as path from 'path';
- import CodeGraph from '../src/index';
- import {
- buildDeadCodeReport,
- isHeaderFile,
- isImplicitEntryName,
- isTestScope,
- isVendoredPath,
- mentionCount,
- DEAD_CODE_KINDS,
- } from '../src/graph/dead-code';
- import { createGraphApi, startUiServer, type GraphApi, type UiServerHandle } from '../src/ui-server';
- let server: UiServerHandle;
- let api: GraphApi;
- let tempDir: string;
- let projectRoot: string;
- let cg: CodeGraph;
- function write(root: string, rel: string, body: string): void {
- const full = path.join(root, rel);
- fs.mkdirSync(path.dirname(full), { recursive: true });
- fs.writeFileSync(full, body);
- }
- function request(requestPath: string): Promise<{ status: number; body: string; type?: string }> {
- return new Promise((resolve, reject) => {
- const req = http.request(
- {
- host: '127.0.0.1',
- port: server.port,
- path: requestPath,
- method: 'GET',
- headers: { Host: `127.0.0.1:${server.port}` },
- setHost: false,
- },
- (res) => {
- const chunks: Buffer[] = [];
- res.on('data', (c: Buffer) => chunks.push(c));
- res.on('end', () =>
- resolve({
- status: res.statusCode ?? 0,
- body: Buffer.concat(chunks).toString('utf-8'),
- type: res.headers['content-type'],
- })
- );
- }
- );
- req.on('error', reject);
- req.end();
- });
- }
- async function getDeadCode(query = ''): Promise<any> {
- const res = await request(`/api/deadcode${query}`);
- expect(res.type).toBe('application/json; charset=utf-8');
- expect(res.status).toBe(200);
- return JSON.parse(res.body);
- }
- const names = (report: { entries: Array<{ node: { name: string } }> }): string[] =>
- report.entries.map((entry) => entry.node.name);
- beforeAll(async () => {
- tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-deadcode-'));
- projectRoot = path.join(tempDir, 'project');
- // The one genuinely dead symbol, plus a live one beside it so the file is
- // reached and the island rule does not swallow the whole thing.
- write(
- projectRoot,
- 'src/util.ts',
- `export function used(value: string): string {
- return value.trim();
- }
- function neverCalledAnywhere(value: string): string {
- return value.toUpperCase();
- }
- function alsoDeadButSmaller(): number {
- return 1;
- }
- // Exported and never called here — an outside caller may import it, so the
- // default list must not claim it. It lives in a REACHED file on purpose: an
- // unreached file is an island, which is a different exclusion.
- export function publicEntryPoint(): string {
- return 'hello';
- }
- `
- );
- // The mis-resolution: \`Facade.load\` calls \`this.inner.load()\`, and the
- // resolver prefers a same-name definition in the call site's own file. One of
- // the two ends up with no incoming edge and neither is unreferenced.
- write(
- projectRoot,
- 'src/inner.ts',
- `export class Inner {
- load(): string {
- return 'inner';
- }
- }
- `
- );
- // A base and an override: calls land on \`Base.run\`, never on \`Child.run\`.
- write(
- projectRoot,
- 'src/base.ts',
- `export class Base {
- run(): string {
- return 'base';
- }
- }
- `
- );
- write(
- projectRoot,
- 'src/child.ts',
- `import { Base } from './base';
- export class Child extends Base {
- run(): string {
- return 'child';
- }
- }
- `
- );
- write(
- projectRoot,
- 'src/facade.ts',
- `import { Inner } from './inner';
- import { Base } from './base';
- import { Child } from './child';
- import { used } from './util';
- function register(target: unknown, key: string): void {
- void target;
- void key;
- }
- export class Facade {
- inner = new Inner();
- child = new Child();
- load(): string {
- return this.inner.load();
- }
- go(): string {
- const base: Base = this.child;
- return used(base.run()) + this.load();
- }
- @register
- onEvent(): void {
- void 0;
- }
- }
- `
- );
- // Mentioned in a template but never called anywhere the graph can see: the
- // corroboration pass has to find the second mention in this file's own text.
- write(
- projectRoot,
- 'src/handlers.ts',
- `export function mountHandlers(): string {
- return TEMPLATE;
- }
- function onSubmit(): void {
- void 0;
- }
- const TEMPLATE = '<form onsubmit="onSubmit()"></form>';
- `
- );
- // Nothing imports this file at all: its symbols' zero fan-in describes the
- // file, not the symbol. That is the island rule, and it is the Map's job.
- write(
- projectRoot,
- 'src/orphan.ts',
- `function strandedHelper(): string {
- return 'nobody imports this file';
- }
- function alsoStranded(): number {
- return strandedHelper().length;
- }
- `
- );
- write(
- projectRoot,
- 'src/index.ts',
- `import { Facade } from './facade';
- import { mountHandlers } from './handlers';
- export function start(): string {
- return new Facade().go() + mountHandlers();
- }
- `
- );
- // A test helper file with a dependent, so `includeTests` is what decides
- // whether its dead symbol shows — not the island rule.
- write(
- projectRoot,
- 'tests/helpers.ts',
- `export function sharedHelper(): string {
- return 'shared';
- }
- function helperNothingCalls(): void {
- void 0;
- }
- `
- );
- write(
- projectRoot,
- 'tests/facade.test.ts',
- `import { Facade } from '../src/facade';
- import { sharedHelper } from './helpers';
- export function testFacade(): string {
- return new Facade().go() + sharedHelper();
- }
- `
- );
- const init = CodeGraph.initSync(projectRoot, {
- config: { include: ['src/**/*.ts', 'tests/**/*.ts'], exclude: [] },
- });
- await init.indexAll();
- init.resolveReferences();
- init.close();
- cg = CodeGraph.openSync(projectRoot);
- const viewerDir = path.join(tempDir, 'viewer');
- fs.mkdirSync(viewerDir, { recursive: true });
- fs.writeFileSync(path.join(viewerDir, 'index.html'), '<!doctype html><div id="app"></div>');
- api = createGraphApi({ projectRoot });
- server = await startUiServer({ projectRoot, viewerDir, port: 0, api: api.handler });
- }, 120_000);
- afterAll(async () => {
- cg?.close();
- api?.close();
- await server?.close();
- if (tempDir) fs.rmSync(tempDir, { recursive: true, force: true });
- });
- describe('buildDeadCodeReport', () => {
- it('finds the symbol nothing references', () => {
- const report = buildDeadCodeReport(cg);
- expect(names(report)).toContain('neverCalledAnywhere');
- });
- it('leaves nothing on the list that anything reaches', () => {
- const report = buildDeadCodeReport(cg);
- // `used`, `start`, `go` and `mountHandlers` are all called; `Inner.load`
- // and `Facade.load` are the same-name pair; `Child.run` is an override.
- for (const name of ['used', 'start', 'go', 'mountHandlers', 'load', 'run']) {
- expect(names(report)).not.toContain(name);
- }
- });
- it('excludes a symbol only its own file mentions, and counts it', () => {
- const report = buildDeadCodeReport(cg);
- expect(names(report)).not.toContain('onSubmit');
- expect(report.excluded.mentioned).toBeGreaterThan(0);
- expect(report.corroborated).toBe(true);
- });
- it('makes the claim when corroboration is switched off', () => {
- // The rule that catches `onSubmit` is the only one that reads a file, so
- // turning it off has to be visible in BOTH the list and the flag.
- const report = buildDeadCodeReport(cg, { readSource: null });
- expect(report.corroborated).toBe(false);
- expect(report.excluded.mentioned).toBe(0);
- expect(names(report)).toContain('onSubmit');
- });
- it('excludes exported symbols by default and includes them on request', () => {
- const strict = buildDeadCodeReport(cg);
- expect(names(strict)).not.toContain('publicEntryPoint');
- expect(strict.excluded.exported).toBeGreaterThan(0);
- expect(strict.includeExported).toBe(false);
- const wide = buildDeadCodeReport(cg, { includeExported: true });
- expect(names(wide)).toContain('publicEntryPoint');
- expect(wide.includeExported).toBe(true);
- expect(wide.excluded.exported).toBe(0);
- });
- it('excludes test files by default and includes them on request', () => {
- expect(names(buildDeadCodeReport(cg))).not.toContain('helperNothingCalls');
- expect(buildDeadCodeReport(cg).excluded.tests).toBeGreaterThan(0);
- expect(names(buildDeadCodeReport(cg, { includeTests: true }))).toContain(
- 'helperNothingCalls'
- );
- });
- it('says nothing about a file nothing in the index reaches', () => {
- // An island's symbols have zero fan-in because the FILE is unreached, which
- // is a fact about the file — the Map draws it, this list does not claim it.
- const report = buildDeadCodeReport(cg, { includeExported: true });
- expect(names(report)).not.toContain('strandedHelper');
- expect(report.excluded.unreachableFile).toBeGreaterThan(0);
- });
- it('excludes a decorated member — a framework registers it', () => {
- const report = buildDeadCodeReport(cg);
- expect(names(report)).not.toContain('onEvent');
- expect(report.excluded.decorated).toBeGreaterThan(0);
- });
- it('ranks by size and reports the real total when capped', () => {
- const full = buildDeadCodeReport(cg);
- const sizes = full.entries.map((entry) => entry.lines);
- expect([...sizes].sort((a, b) => b - a)).toEqual(sizes);
- const capped = buildDeadCodeReport(cg, { limit: 1 });
- expect(capped.entries).toHaveLength(1);
- expect(capped.total).toBe(full.total);
- // The cap trims the tail, not the head: the biggest finding survives.
- expect(capped.entries[0]?.node.name).toBe(full.entries[0]?.node.name);
- });
- it('every exclusion count is a number of candidates, and they add up', () => {
- const report = buildDeadCodeReport(cg);
- const excluded = Object.values(report.excluded).reduce((sum, n) => sum + n, 0);
- expect(report.candidates).toBeGreaterThan(0);
- expect(excluded + report.entries.length).toBeLessThanOrEqual(report.candidates);
- expect(report.bounded).toBe(false);
- });
- it('restricts to the kinds asked for, and ignores nonsense', () => {
- const classesOnly = buildDeadCodeReport(cg, { kinds: ['class'] });
- expect(classesOnly.kinds).toEqual(['class']);
- for (const entry of classesOnly.entries) expect(entry.node.kind).toBe('class');
- // An unknown kind is not a 500 and not an empty list: it falls back to the
- // default set, which is the answer the caller meant.
- const nonsense = buildDeadCodeReport(cg, { kinds: ['banana' as never] });
- expect(nonsense.kinds).toEqual([...DEAD_CODE_KINDS]);
- });
- });
- describe('the rules that are pure', () => {
- it('counts whole-identifier mentions only', () => {
- expect(mentionCount('const load = 1; loader(); reload();', 'load')).toBe(1);
- expect(mentionCount('a.load(); load();', 'load')).toBe(2);
- expect(mentionCount('nothing here', 'load')).toBe(0);
- // Stops early: the caller only ever needs to know "one, or more than one".
- expect(mentionCount('x x x x x', 'x', 2)).toBe(2);
- });
- it('matches vendored directories as whole segments', () => {
- expect(isVendoredPath('vendor/lib/a.go')).toBe(true);
- expect(isVendoredPath('a/node_modules/b/c.js')).toBe(true);
- expect(isVendoredPath('src/vendored-parser.ts')).toBe(false);
- });
- it('recognises headers as declaration surfaces', () => {
- expect(isHeaderFile('src/tree_sitter/parser.h')).toBe(true);
- expect(isHeaderFile('types/global.d.ts')).toBe(true);
- expect(isHeaderFile('src/parser.c')).toBe(false);
- });
- it('recognises a test scope inside a file', () => {
- expect(isTestScope('tests::row_sizes_match')).toBe(true);
- expect(isTestScope('Fixtures.Tests.Helper')).toBe(true);
- expect(isTestScope('Latest.value')).toBe(false);
- });
- it('recognises names the language calls by itself', () => {
- expect(isImplicitEntryName('constructor')).toBe(true);
- expect(isImplicitEntryName('__enter__')).toBe(true);
- expect(isImplicitEntryName('ToString')).toBe(true);
- expect(isImplicitEntryName('mainHandler')).toBe(false);
- });
- });
- describe('GET /api/deadcode', () => {
- it('groups the rows by file and keeps the totals honest', async () => {
- const payload = await getDeadCode();
- expect(payload.rows.total).toBe(payload.rows.items.length);
- expect(payload.rows.shown).toBe(payload.rows.items.length);
- // Every count equals a list length in the same payload.
- const grouped = payload.groups.reduce((sum: number, g: any) => sum + g.rows.length, 0);
- expect(grouped).toBe(payload.rows.shown);
- const files = payload.groups.map((g: any) => g.file);
- expect(new Set(files).size).toBe(files.length);
- expect(files).toContain('src/util.ts');
- });
- it('carries the exclusions with their own wording', async () => {
- const payload = await getDeadCode();
- expect(payload.excluded.length).toBeGreaterThan(0);
- for (const entry of payload.excluded) {
- expect(entry.count).toBeGreaterThan(0);
- expect(typeof entry.label).toBe('string');
- expect(entry.label.length).toBeGreaterThan(0);
- }
- const sum = payload.excluded.reduce((n: number, e: any) => n + e.count, 0);
- expect(payload.excludedTotal).toBe(sum);
- expect(payload.candidates).toBeGreaterThanOrEqual(payload.excludedTotal);
- expect(payload.corroborated).toBe(true);
- });
- it('widens on ?exported=1 and says which list it answered', async () => {
- const strict = await getDeadCode();
- const wide = await getDeadCode('?exported=1');
- expect(strict.includeExported).toBe(false);
- expect(wide.includeExported).toBe(true);
- expect(wide.rows.total).toBeGreaterThan(strict.rows.total);
- expect(wide.rows.items.some((r: any) => r.name === 'publicEntryPoint')).toBe(true);
- });
- it('honours ?limit= without lying about the total', async () => {
- const full = await getDeadCode();
- const capped = await getDeadCode('?limit=1');
- expect(capped.rows.items).toHaveLength(1);
- expect(capped.rows.total).toBe(full.rows.total);
- expect(capped.rows.truncated).toBe(full.rows.total > 1);
- });
- it('is listed on the API index', async () => {
- const res = await request('/api');
- const body = JSON.parse(res.body);
- expect(body.endpoints.some((e: any) => e.path === '/api/deadcode')).toBe(true);
- });
- });
- describe('GET /api/map — generated files and islands', () => {
- it('reports how many of a module’s files are tool-generated', async () => {
- const res = await request('/api/map');
- const payload = JSON.parse(res.body);
- for (const module of payload.modules) {
- expect(typeof module.generated).toBe('number');
- expect(module.generated).toBeLessThanOrEqual(module.files);
- // The dimmed rows are drawn from `fileList.items`, so the generated
- // subset has to be a subset of exactly that list.
- for (const file of module.generatedFiles) {
- expect(module.fileList.items).toContain(file);
- }
- }
- });
- });
|