1
0

dead-code.test.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485
  1. /**
  2. * Dead code and islands (CG-59).
  3. *
  4. * Two halves, both against a real indexed fixture: the derivation in
  5. * `src/graph/dead-code.ts`, and the `/api/deadcode` endpoint that renders it
  6. * over a real loopback server, like the rest of the viewer's API suite.
  7. *
  8. * The fixture is shaped to produce, deliberately, one of each thing the report
  9. * has to get RIGHT BY NOT CLAIMING IT:
  10. *
  11. * - a genuinely unreferenced helper (the only row that should survive);
  12. * - a same-name pair where the resolver attaches the call to the wrong one —
  13. * the mis-resolution that makes a used method look unreached;
  14. * - a method that overrides a base's, reached only through the base;
  15. * - a decorated method, registered by a framework the graph cannot see;
  16. * - a helper only a template mentions, so no edge records the use but the file
  17. * text does;
  18. * - an exported function nothing here calls, which an outside caller may.
  19. *
  20. * Every one of those must be OFF the list, and the reason must be counted.
  21. */
  22. import { describe, it, expect, beforeAll, afterAll } from 'vitest';
  23. import * as http from 'http';
  24. import * as fs from 'fs';
  25. import * as os from 'os';
  26. import * as path from 'path';
  27. import CodeGraph from '../src/index';
  28. import {
  29. buildDeadCodeReport,
  30. isHeaderFile,
  31. isImplicitEntryName,
  32. isTestScope,
  33. isVendoredPath,
  34. mentionCount,
  35. DEAD_CODE_KINDS,
  36. } from '../src/graph/dead-code';
  37. import { createGraphApi, startUiServer, type GraphApi, type UiServerHandle } from '../src/ui-server';
  38. let server: UiServerHandle;
  39. let api: GraphApi;
  40. let tempDir: string;
  41. let projectRoot: string;
  42. let cg: CodeGraph;
  43. function write(root: string, rel: string, body: string): void {
  44. const full = path.join(root, rel);
  45. fs.mkdirSync(path.dirname(full), { recursive: true });
  46. fs.writeFileSync(full, body);
  47. }
  48. function request(requestPath: string): Promise<{ status: number; body: string; type?: string }> {
  49. return new Promise((resolve, reject) => {
  50. const req = http.request(
  51. {
  52. host: '127.0.0.1',
  53. port: server.port,
  54. path: requestPath,
  55. method: 'GET',
  56. headers: { Host: `127.0.0.1:${server.port}` },
  57. setHost: false,
  58. },
  59. (res) => {
  60. const chunks: Buffer[] = [];
  61. res.on('data', (c: Buffer) => chunks.push(c));
  62. res.on('end', () =>
  63. resolve({
  64. status: res.statusCode ?? 0,
  65. body: Buffer.concat(chunks).toString('utf-8'),
  66. type: res.headers['content-type'],
  67. })
  68. );
  69. }
  70. );
  71. req.on('error', reject);
  72. req.end();
  73. });
  74. }
  75. async function getDeadCode(query = ''): Promise<any> {
  76. const res = await request(`/api/deadcode${query}`);
  77. expect(res.type).toBe('application/json; charset=utf-8');
  78. expect(res.status).toBe(200);
  79. return JSON.parse(res.body);
  80. }
  81. const names = (report: { entries: Array<{ node: { name: string } }> }): string[] =>
  82. report.entries.map((entry) => entry.node.name);
  83. beforeAll(async () => {
  84. tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-deadcode-'));
  85. projectRoot = path.join(tempDir, 'project');
  86. // The one genuinely dead symbol, plus a live one beside it so the file is
  87. // reached and the island rule does not swallow the whole thing.
  88. write(
  89. projectRoot,
  90. 'src/util.ts',
  91. `export function used(value: string): string {
  92. return value.trim();
  93. }
  94. function neverCalledAnywhere(value: string): string {
  95. return value.toUpperCase();
  96. }
  97. function alsoDeadButSmaller(): number {
  98. return 1;
  99. }
  100. // Exported and never called here — an outside caller may import it, so the
  101. // default list must not claim it. It lives in a REACHED file on purpose: an
  102. // unreached file is an island, which is a different exclusion.
  103. export function publicEntryPoint(): string {
  104. return 'hello';
  105. }
  106. `
  107. );
  108. // The mis-resolution: \`Facade.load\` calls \`this.inner.load()\`, and the
  109. // resolver prefers a same-name definition in the call site's own file. One of
  110. // the two ends up with no incoming edge and neither is unreferenced.
  111. write(
  112. projectRoot,
  113. 'src/inner.ts',
  114. `export class Inner {
  115. load(): string {
  116. return 'inner';
  117. }
  118. }
  119. `
  120. );
  121. // A base and an override: calls land on \`Base.run\`, never on \`Child.run\`.
  122. write(
  123. projectRoot,
  124. 'src/base.ts',
  125. `export class Base {
  126. run(): string {
  127. return 'base';
  128. }
  129. }
  130. `
  131. );
  132. write(
  133. projectRoot,
  134. 'src/child.ts',
  135. `import { Base } from './base';
  136. export class Child extends Base {
  137. run(): string {
  138. return 'child';
  139. }
  140. }
  141. `
  142. );
  143. write(
  144. projectRoot,
  145. 'src/facade.ts',
  146. `import { Inner } from './inner';
  147. import { Base } from './base';
  148. import { Child } from './child';
  149. import { used } from './util';
  150. function register(target: unknown, key: string): void {
  151. void target;
  152. void key;
  153. }
  154. export class Facade {
  155. inner = new Inner();
  156. child = new Child();
  157. load(): string {
  158. return this.inner.load();
  159. }
  160. go(): string {
  161. const base: Base = this.child;
  162. return used(base.run()) + this.load();
  163. }
  164. @register
  165. onEvent(): void {
  166. void 0;
  167. }
  168. }
  169. `
  170. );
  171. // Mentioned in a template but never called anywhere the graph can see: the
  172. // corroboration pass has to find the second mention in this file's own text.
  173. write(
  174. projectRoot,
  175. 'src/handlers.ts',
  176. `export function mountHandlers(): string {
  177. return TEMPLATE;
  178. }
  179. function onSubmit(): void {
  180. void 0;
  181. }
  182. const TEMPLATE = '<form onsubmit="onSubmit()"></form>';
  183. `
  184. );
  185. // Nothing imports this file at all: its symbols' zero fan-in describes the
  186. // file, not the symbol. That is the island rule, and it is the Map's job.
  187. write(
  188. projectRoot,
  189. 'src/orphan.ts',
  190. `function strandedHelper(): string {
  191. return 'nobody imports this file';
  192. }
  193. function alsoStranded(): number {
  194. return strandedHelper().length;
  195. }
  196. `
  197. );
  198. write(
  199. projectRoot,
  200. 'src/index.ts',
  201. `import { Facade } from './facade';
  202. import { mountHandlers } from './handlers';
  203. export function start(): string {
  204. return new Facade().go() + mountHandlers();
  205. }
  206. `
  207. );
  208. // A test helper file with a dependent, so `includeTests` is what decides
  209. // whether its dead symbol shows — not the island rule.
  210. write(
  211. projectRoot,
  212. 'tests/helpers.ts',
  213. `export function sharedHelper(): string {
  214. return 'shared';
  215. }
  216. function helperNothingCalls(): void {
  217. void 0;
  218. }
  219. `
  220. );
  221. write(
  222. projectRoot,
  223. 'tests/facade.test.ts',
  224. `import { Facade } from '../src/facade';
  225. import { sharedHelper } from './helpers';
  226. export function testFacade(): string {
  227. return new Facade().go() + sharedHelper();
  228. }
  229. `
  230. );
  231. const init = CodeGraph.initSync(projectRoot, {
  232. config: { include: ['src/**/*.ts', 'tests/**/*.ts'], exclude: [] },
  233. });
  234. await init.indexAll();
  235. init.resolveReferences();
  236. init.close();
  237. cg = CodeGraph.openSync(projectRoot);
  238. const viewerDir = path.join(tempDir, 'viewer');
  239. fs.mkdirSync(viewerDir, { recursive: true });
  240. fs.writeFileSync(path.join(viewerDir, 'index.html'), '<!doctype html><div id="app"></div>');
  241. api = createGraphApi({ projectRoot });
  242. server = await startUiServer({ projectRoot, viewerDir, port: 0, api: api.handler });
  243. }, 120_000);
  244. afterAll(async () => {
  245. cg?.close();
  246. api?.close();
  247. await server?.close();
  248. if (tempDir) fs.rmSync(tempDir, { recursive: true, force: true });
  249. });
  250. describe('buildDeadCodeReport', () => {
  251. it('finds the symbol nothing references', () => {
  252. const report = buildDeadCodeReport(cg);
  253. expect(names(report)).toContain('neverCalledAnywhere');
  254. });
  255. it('leaves nothing on the list that anything reaches', () => {
  256. const report = buildDeadCodeReport(cg);
  257. // `used`, `start`, `go` and `mountHandlers` are all called; `Inner.load`
  258. // and `Facade.load` are the same-name pair; `Child.run` is an override.
  259. for (const name of ['used', 'start', 'go', 'mountHandlers', 'load', 'run']) {
  260. expect(names(report)).not.toContain(name);
  261. }
  262. });
  263. it('excludes a symbol only its own file mentions, and counts it', () => {
  264. const report = buildDeadCodeReport(cg);
  265. expect(names(report)).not.toContain('onSubmit');
  266. expect(report.excluded.mentioned).toBeGreaterThan(0);
  267. expect(report.corroborated).toBe(true);
  268. });
  269. it('makes the claim when corroboration is switched off', () => {
  270. // The rule that catches `onSubmit` is the only one that reads a file, so
  271. // turning it off has to be visible in BOTH the list and the flag.
  272. const report = buildDeadCodeReport(cg, { readSource: null });
  273. expect(report.corroborated).toBe(false);
  274. expect(report.excluded.mentioned).toBe(0);
  275. expect(names(report)).toContain('onSubmit');
  276. });
  277. it('excludes exported symbols by default and includes them on request', () => {
  278. const strict = buildDeadCodeReport(cg);
  279. expect(names(strict)).not.toContain('publicEntryPoint');
  280. expect(strict.excluded.exported).toBeGreaterThan(0);
  281. expect(strict.includeExported).toBe(false);
  282. const wide = buildDeadCodeReport(cg, { includeExported: true });
  283. expect(names(wide)).toContain('publicEntryPoint');
  284. expect(wide.includeExported).toBe(true);
  285. expect(wide.excluded.exported).toBe(0);
  286. });
  287. it('excludes test files by default and includes them on request', () => {
  288. expect(names(buildDeadCodeReport(cg))).not.toContain('helperNothingCalls');
  289. expect(buildDeadCodeReport(cg).excluded.tests).toBeGreaterThan(0);
  290. expect(names(buildDeadCodeReport(cg, { includeTests: true }))).toContain(
  291. 'helperNothingCalls'
  292. );
  293. });
  294. it('says nothing about a file nothing in the index reaches', () => {
  295. // An island's symbols have zero fan-in because the FILE is unreached, which
  296. // is a fact about the file — the Map draws it, this list does not claim it.
  297. const report = buildDeadCodeReport(cg, { includeExported: true });
  298. expect(names(report)).not.toContain('strandedHelper');
  299. expect(report.excluded.unreachableFile).toBeGreaterThan(0);
  300. });
  301. it('excludes a decorated member — a framework registers it', () => {
  302. const report = buildDeadCodeReport(cg);
  303. expect(names(report)).not.toContain('onEvent');
  304. expect(report.excluded.decorated).toBeGreaterThan(0);
  305. });
  306. it('ranks by size and reports the real total when capped', () => {
  307. const full = buildDeadCodeReport(cg);
  308. const sizes = full.entries.map((entry) => entry.lines);
  309. expect([...sizes].sort((a, b) => b - a)).toEqual(sizes);
  310. const capped = buildDeadCodeReport(cg, { limit: 1 });
  311. expect(capped.entries).toHaveLength(1);
  312. expect(capped.total).toBe(full.total);
  313. // The cap trims the tail, not the head: the biggest finding survives.
  314. expect(capped.entries[0]?.node.name).toBe(full.entries[0]?.node.name);
  315. });
  316. it('every exclusion count is a number of candidates, and they add up', () => {
  317. const report = buildDeadCodeReport(cg);
  318. const excluded = Object.values(report.excluded).reduce((sum, n) => sum + n, 0);
  319. expect(report.candidates).toBeGreaterThan(0);
  320. expect(excluded + report.entries.length).toBeLessThanOrEqual(report.candidates);
  321. expect(report.bounded).toBe(false);
  322. });
  323. it('restricts to the kinds asked for, and ignores nonsense', () => {
  324. const classesOnly = buildDeadCodeReport(cg, { kinds: ['class'] });
  325. expect(classesOnly.kinds).toEqual(['class']);
  326. for (const entry of classesOnly.entries) expect(entry.node.kind).toBe('class');
  327. // An unknown kind is not a 500 and not an empty list: it falls back to the
  328. // default set, which is the answer the caller meant.
  329. const nonsense = buildDeadCodeReport(cg, { kinds: ['banana' as never] });
  330. expect(nonsense.kinds).toEqual([...DEAD_CODE_KINDS]);
  331. });
  332. });
  333. describe('the rules that are pure', () => {
  334. it('counts whole-identifier mentions only', () => {
  335. expect(mentionCount('const load = 1; loader(); reload();', 'load')).toBe(1);
  336. expect(mentionCount('a.load(); load();', 'load')).toBe(2);
  337. expect(mentionCount('nothing here', 'load')).toBe(0);
  338. // Stops early: the caller only ever needs to know "one, or more than one".
  339. expect(mentionCount('x x x x x', 'x', 2)).toBe(2);
  340. });
  341. it('matches vendored directories as whole segments', () => {
  342. expect(isVendoredPath('vendor/lib/a.go')).toBe(true);
  343. expect(isVendoredPath('a/node_modules/b/c.js')).toBe(true);
  344. expect(isVendoredPath('src/vendored-parser.ts')).toBe(false);
  345. });
  346. it('recognises headers as declaration surfaces', () => {
  347. expect(isHeaderFile('src/tree_sitter/parser.h')).toBe(true);
  348. expect(isHeaderFile('types/global.d.ts')).toBe(true);
  349. expect(isHeaderFile('src/parser.c')).toBe(false);
  350. });
  351. it('recognises a test scope inside a file', () => {
  352. expect(isTestScope('tests::row_sizes_match')).toBe(true);
  353. expect(isTestScope('Fixtures.Tests.Helper')).toBe(true);
  354. expect(isTestScope('Latest.value')).toBe(false);
  355. });
  356. it('recognises names the language calls by itself', () => {
  357. expect(isImplicitEntryName('constructor')).toBe(true);
  358. expect(isImplicitEntryName('__enter__')).toBe(true);
  359. expect(isImplicitEntryName('ToString')).toBe(true);
  360. expect(isImplicitEntryName('mainHandler')).toBe(false);
  361. });
  362. });
  363. describe('GET /api/deadcode', () => {
  364. it('groups the rows by file and keeps the totals honest', async () => {
  365. const payload = await getDeadCode();
  366. expect(payload.rows.total).toBe(payload.rows.items.length);
  367. expect(payload.rows.shown).toBe(payload.rows.items.length);
  368. // Every count equals a list length in the same payload.
  369. const grouped = payload.groups.reduce((sum: number, g: any) => sum + g.rows.length, 0);
  370. expect(grouped).toBe(payload.rows.shown);
  371. const files = payload.groups.map((g: any) => g.file);
  372. expect(new Set(files).size).toBe(files.length);
  373. expect(files).toContain('src/util.ts');
  374. });
  375. it('carries the exclusions with their own wording', async () => {
  376. const payload = await getDeadCode();
  377. expect(payload.excluded.length).toBeGreaterThan(0);
  378. for (const entry of payload.excluded) {
  379. expect(entry.count).toBeGreaterThan(0);
  380. expect(typeof entry.label).toBe('string');
  381. expect(entry.label.length).toBeGreaterThan(0);
  382. }
  383. const sum = payload.excluded.reduce((n: number, e: any) => n + e.count, 0);
  384. expect(payload.excludedTotal).toBe(sum);
  385. expect(payload.candidates).toBeGreaterThanOrEqual(payload.excludedTotal);
  386. expect(payload.corroborated).toBe(true);
  387. });
  388. it('widens on ?exported=1 and says which list it answered', async () => {
  389. const strict = await getDeadCode();
  390. const wide = await getDeadCode('?exported=1');
  391. expect(strict.includeExported).toBe(false);
  392. expect(wide.includeExported).toBe(true);
  393. expect(wide.rows.total).toBeGreaterThan(strict.rows.total);
  394. expect(wide.rows.items.some((r: any) => r.name === 'publicEntryPoint')).toBe(true);
  395. });
  396. it('honours ?limit= without lying about the total', async () => {
  397. const full = await getDeadCode();
  398. const capped = await getDeadCode('?limit=1');
  399. expect(capped.rows.items).toHaveLength(1);
  400. expect(capped.rows.total).toBe(full.rows.total);
  401. expect(capped.rows.truncated).toBe(full.rows.total > 1);
  402. });
  403. it('is listed on the API index', async () => {
  404. const res = await request('/api');
  405. const body = JSON.parse(res.body);
  406. expect(body.endpoints.some((e: any) => e.path === '/api/deadcode')).toBe(true);
  407. });
  408. });
  409. describe('GET /api/map — generated files and islands', () => {
  410. it('reports how many of a module’s files are tool-generated', async () => {
  411. const res = await request('/api/map');
  412. const payload = JSON.parse(res.body);
  413. for (const module of payload.modules) {
  414. expect(typeof module.generated).toBe('number');
  415. expect(module.generated).toBeLessThanOrEqual(module.files);
  416. // The dimmed rows are drawn from `fileList.items`, so the generated
  417. // subset has to be a subset of exactly that list.
  418. for (const file of module.generatedFiles) {
  419. expect(module.fileList.items).toContain(file);
  420. }
  421. }
  422. });
  423. });