From d81f9266eb02036511ea1fec1e2dc406f0960107 Mon Sep 17 00:00:00 2001 From: "Sophia (Turner)" Date: Sat, 28 Mar 2026 17:26:12 +0900 Subject: [PATCH 1/7] test: add comprehensive unit tests to improve coverage from 27% to 56% Co-Authored-By: Claude Opus 4.6 (1M context) --- src/analyzers/react/bindings.spec.ts | 282 +++++++++++- src/analyzers/react/entries.spec.ts | 126 ++++- src/analyzers/react/file.spec.ts | 138 +++++- src/analyzers/react/queries.spec.ts | 402 ++++++++++++++-- src/analyzers/react/references.spec.ts | 608 +++++++++++++++++++++++-- src/analyzers/react/symbols.spec.ts | 188 +++++++- src/analyzers/react/usage.spec.ts | 175 ++++++- src/analyzers/react/walk.spec.ts | 359 ++++++++++++++- src/color.spec.ts | 199 ++++++-- src/output/ascii/deps.spec.ts | 305 ++++++++++++- src/output/ascii/import.spec.ts | 425 ++++++++++++++++- src/output/ascii/react.spec.ts | 307 ++++++++++++- src/output/json/deps.spec.ts | 178 +++++++- src/output/json/import.spec.ts | 334 +++++++++++++- src/output/json/react.spec.ts | 188 +++++++- 15 files changed, 4049 insertions(+), 165 deletions(-) diff --git a/src/analyzers/react/bindings.spec.ts b/src/analyzers/react/bindings.spec.ts index 81c447e..af15ec4 100644 --- a/src/analyzers/react/bindings.spec.ts +++ b/src/analyzers/react/bindings.spec.ts @@ -1,9 +1,287 @@ import { describe, expect, it } from 'vitest' +import { parseSync } from 'oxc-parser' +import type { PendingReactUsageNode } from './file.js' +import type { ImportBinding } from './bindings.js' import { collectImportsAndExports } from './bindings.js' +function createMockSymbol( + name: string, + kind: 'component' | 'hook', +): PendingReactUsageNode { + return { + id: `test.tsx#${kind}:${name}`, + name, + kind, + filePath: 'test.tsx', + declarationOffset: 0, + analysisRoot: { type: 'FunctionBody' } as never, + exportNames: new Set(), + componentReferences: new Set(), + hookReferences: new Set(), + builtinReferences: new Set(), + } +} + +function collect( + code: string, + opts?: { + sourceDeps?: Map + symbols?: Map + }, +) { + const { program } = parseSync('test.tsx', code) + const sourceDependencies = opts?.sourceDeps ?? new Map() + const symbolsByName = opts?.symbols ?? new Map() + const importsByLocalName = new Map() + const exportsByName = new Map() + const reExportBindingsByName = new Map() + const exportAllBindings: ImportBinding[] = [] + + program.body.forEach((statement) => { + collectImportsAndExports( + statement, + sourceDependencies, + symbolsByName, + importsByLocalName, + exportsByName, + reExportBindingsByName, + exportAllBindings, + ) + }) + + return { + importsByLocalName, + exportsByName, + reExportBindingsByName, + exportAllBindings, + } +} + describe('collectImportsAndExports', () => { - it('exports a callable bindings collector', () => { - expect(collectImportsAndExports).toBeTypeOf('function') + describe('import bindings', () => { + it('collects named import bindings', () => { + const { importsByLocalName } = collect('import { Foo } from "./foo"', { + sourceDeps: new Map([['./foo', '/src/foo.ts']]), + }) + + expect(importsByLocalName.get('Foo')).toEqual({ + importedName: 'Foo', + sourceSpecifier: './foo', + sourcePath: '/src/foo.ts', + }) + }) + + it('collects default import bindings', () => { + const { importsByLocalName } = collect('import Foo from "./foo"') + + expect(importsByLocalName.get('Foo')).toEqual({ + importedName: 'default', + sourceSpecifier: './foo', + }) + }) + + it('collects namespace import bindings', () => { + const { importsByLocalName } = collect('import * as Ns from "./ns"') + + expect(importsByLocalName.get('Ns')).toEqual({ + importedName: '*', + sourceSpecifier: './ns', + }) + }) + + it('skips type-only imports', () => { + const { importsByLocalName } = collect( + 'import type { Foo } from "./foo"', + ) + expect(importsByLocalName.size).toBe(0) + }) + + it('skips type-only import specifiers', () => { + const { importsByLocalName } = collect( + 'import { type Foo, Bar } from "./foo"', + ) + expect(importsByLocalName.has('Foo')).toBe(false) + expect(importsByLocalName.has('Bar')).toBe(true) + }) + + it('collects renamed import bindings', () => { + const { importsByLocalName } = collect( + 'import { Foo as MyFoo } from "./foo"', + ) + + expect(importsByLocalName.get('MyFoo')).toEqual({ + importedName: 'Foo', + sourceSpecifier: './foo', + }) + }) + }) + + describe('named exports', () => { + it('collects exported function declarations', () => { + const symbols = new Map([ + ['App', createMockSymbol('App', 'component')], + ]) + const { exportsByName } = collect('export function App() {}', { + symbols, + }) + + expect(exportsByName.get('App')).toBe('test.tsx#component:App') + expect(symbols.get('App')!.exportNames.has('App')).toBe(true) + }) + + it('collects exported variable declarations', () => { + const symbols = new Map([ + ['App', createMockSymbol('App', 'component')], + ]) + const { exportsByName } = collect('export const App = () => {}', { + symbols, + }) + + expect(exportsByName.get('App')).toBe('test.tsx#component:App') + }) + + it('collects re-exports from source', () => { + const { reExportBindingsByName } = collect( + 'export { Foo } from "./foo"', + { sourceDeps: new Map([['./foo', '/src/foo.ts']]) }, + ) + + expect(reExportBindingsByName.get('Foo')).toEqual({ + importedName: 'Foo', + sourceSpecifier: './foo', + sourcePath: '/src/foo.ts', + }) + }) + + it('collects renamed re-exports', () => { + const { reExportBindingsByName } = collect( + 'export { Foo as Bar } from "./foo"', + ) + + expect(reExportBindingsByName.get('Bar')).toEqual({ + importedName: 'Foo', + sourceSpecifier: './foo', + }) + }) + + it('skips type-only exports', () => { + const { exportsByName, reExportBindingsByName } = collect( + 'export type { Foo } from "./foo"', + ) + expect(exportsByName.size).toBe(0) + expect(reExportBindingsByName.size).toBe(0) + }) + + it('skips type-only specifiers in re-exports', () => { + const { reExportBindingsByName } = collect( + 'export { type Foo, Bar } from "./foo"', + ) + expect(reExportBindingsByName.has('Foo')).toBe(false) + expect(reExportBindingsByName.has('Bar')).toBe(true) + }) + + it('collects local named exports mapping to symbols', () => { + const symbols = new Map([ + ['App', createMockSymbol('App', 'component')], + ]) + const { exportsByName } = collect('export { App }', { symbols }) + + expect(exportsByName.get('App')).toBe('test.tsx#component:App') + }) + + it('collects renamed local exports', () => { + const symbols = new Map([ + ['App', createMockSymbol('App', 'component')], + ]) + const { exportsByName } = collect('export { App as MyApp }', { symbols }) + + expect(exportsByName.get('MyApp')).toBe('test.tsx#component:App') + }) + + it('ignores exports for names not in symbolsByName', () => { + const { exportsByName } = collect('export { Unknown }') + expect(exportsByName.size).toBe(0) + }) + }) + + describe('export all', () => { + it('collects export all bindings', () => { + const { exportAllBindings } = collect('export * from "./foo"', { + sourceDeps: new Map([['./foo', '/src/foo.ts']]), + }) + + expect(exportAllBindings).toEqual([ + { + importedName: '*', + sourceSpecifier: './foo', + sourcePath: '/src/foo.ts', + }, + ]) + }) + + it('skips type-only export all', () => { + const { exportAllBindings } = collect('export type * from "./foo"') + expect(exportAllBindings).toHaveLength(0) + }) + + it('omits sourcePath when not in sourceDependencies', () => { + const { exportAllBindings } = collect('export * from "./foo"') + expect(exportAllBindings[0]).toEqual({ + importedName: '*', + sourceSpecifier: './foo', + }) + }) + }) + + describe('default exports', () => { + it('collects default export of named function', () => { + const symbols = new Map([ + ['App', createMockSymbol('App', 'component')], + ]) + const { exportsByName } = collect('export default function App() {}', { + symbols, + }) + + expect(exportsByName.get('default')).toBe('test.tsx#component:App') + }) + + it('collects default export of identifier referencing symbol', () => { + const symbols = new Map([ + ['App', createMockSymbol('App', 'component')], + ]) + const { exportsByName } = collect('export default App', { symbols }) + + expect(exportsByName.get('default')).toBe('test.tsx#component:App') + }) + + it('creates re-export binding when default export is imported identifier', () => { + const { reExportBindingsByName, importsByLocalName } = collect( + 'import App from "./app"\nexport default App', + ) + + expect(importsByLocalName.has('App')).toBe(true) + expect(reExportBindingsByName.get('default')).toEqual({ + importedName: 'default', + sourceSpecifier: './app', + }) + }) + + it('collects default export of arrow function with symbol', () => { + const symbols = new Map([ + ['default', createMockSymbol('default', 'component')], + ]) + const { exportsByName } = collect('export default () => null', { + symbols, + }) + + expect(exportsByName.get('default')).toBe('test.tsx#component:default') + }) + }) + + it('ignores unrelated statement types', () => { + const result = collect('const x = 42') + expect(result.importsByLocalName.size).toBe(0) + expect(result.exportsByName.size).toBe(0) }) }) diff --git a/src/analyzers/react/entries.spec.ts b/src/analyzers/react/entries.spec.ts index 02e1540..24a0209 100644 --- a/src/analyzers/react/entries.spec.ts +++ b/src/analyzers/react/entries.spec.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from 'vitest' +import { parseSync } from 'oxc-parser' import { collectEntryUsages, createReactUsageLocation } from './entries.js' -describe('react entry helpers', () => { - it('exports entry collection helpers', () => { - expect(collectEntryUsages).toBeTypeOf('function') +describe('createReactUsageLocation', () => { + it('returns line 1 column 1 for offset 0', () => { expect(createReactUsageLocation('src/App.tsx', '', 0)).toEqual({ filePath: 'src/App.tsx', line: 1, @@ -12,10 +12,12 @@ describe('react entry helpers', () => { }) }) - it('resolves later lines and columns without rescanning semantics regressions', () => { - const sourceText = ['const one = 1;', 'const two = 2;', 'return two;'].join( - '\n', - ) + it('resolves later lines and columns correctly', () => { + const sourceText = [ + 'const one = 1;', + 'const two = 2;', + 'return two;', + ].join('\n') expect( createReactUsageLocation( @@ -49,4 +51,114 @@ describe('react entry helpers', () => { column: 4, }) }) + + it('clamps negative offsets to 0', () => { + expect(createReactUsageLocation('src/App.tsx', 'abc', -5)).toEqual({ + filePath: 'src/App.tsx', + line: 1, + column: 1, + }) + }) + + it('handles empty source text', () => { + expect(createReactUsageLocation('src/App.tsx', '', 0)).toEqual({ + filePath: 'src/App.tsx', + line: 1, + column: 1, + }) + }) +}) + +describe('collectEntryUsages', () => { + it('finds top-level component JSX usage', () => { + const code = '' + const { program } = parseSync('test.tsx', code) + const entries = collectEntryUsages(program, '/test.tsx', code, false) + + expect(entries).toHaveLength(1) + expect(entries[0].referenceName).toBe('App') + expect(entries[0].kind).toBe('component') + }) + + it('finds hook calls at top level', () => { + const code = 'useEffect(() => {}, [])' + const { program } = parseSync('test.tsx', code) + const entries = collectEntryUsages(program, '/test.tsx', code, false) + + expect(entries).toHaveLength(1) + expect(entries[0].referenceName).toBe('useEffect') + expect(entries[0].kind).toBe('hook') + }) + + it('includes builtin elements when includeBuiltins is true', () => { + const code = '
' + const { program } = parseSync('test.tsx', code) + const entries = collectEntryUsages(program, '/test.tsx', code, true) + + expect(entries.some((e) => e.referenceName === 'div')).toBe(true) + }) + + it('excludes builtin elements when includeBuiltins is false', () => { + const code = '
' + const { program } = parseSync('test.tsx', code) + const entries = collectEntryUsages(program, '/test.tsx', code, false) + + expect(entries.some((e) => e.referenceName === 'div')).toBe(false) + }) + + it('does not collect usages inside function bodies', () => { + const code = 'function render() { return }' + const { program } = parseSync('test.tsx', code) + const entries = collectEntryUsages(program, '/test.tsx', code, false) + + expect(entries).toHaveLength(0) + }) + + it('deduplicates entries by location', () => { + const code = '' + const { program } = parseSync('test.tsx', code) + const entries = collectEntryUsages(program, '/test.tsx', code, false) + + expect(entries).toHaveLength(1) + }) + + it('does not create separate entries for nested components', () => { + const code = '' + const { program } = parseSync('test.tsx', code) + const entries = collectEntryUsages(program, '/test.tsx', code, false) + + const parentEntry = entries.find((e) => e.referenceName === 'Parent') + const childEntry = entries.find((e) => e.referenceName === 'Child') + expect(parentEntry).toBeDefined() + expect(childEntry).toBeUndefined() + }) + + it('sorts multiple entries by location', () => { + const code = ';\n;' + const { program } = parseSync('test.tsx', code) + const entries = collectEntryUsages(program, '/test.tsx', code, false) + + expect(entries.length).toBeGreaterThanOrEqual(2) + for (let i = 1; i < entries.length; i++) { + expect(entries[i - 1].location.line).toBeLessThanOrEqual( + entries[i].location.line, + ) + } + }) + + it('finds member expression component references', () => { + const code = '' + const { program } = parseSync('test.tsx', code) + const entries = collectEntryUsages(program, '/test.tsx', code, false) + + expect(entries.some((e) => e.referenceName === 'Ns.Item')).toBe(true) + }) + + it('finds React.createElement component references', () => { + const code = 'React.createElement(MyComp, null)' + const { program } = parseSync('test.tsx', code) + const entries = collectEntryUsages(program, '/test.tsx', code, false) + + expect(entries.some((e) => e.referenceName === 'MyComp')).toBe(true) + }) }) diff --git a/src/analyzers/react/file.spec.ts b/src/analyzers/react/file.spec.ts index a1c0ef1..8076781 100644 --- a/src/analyzers/react/file.spec.ts +++ b/src/analyzers/react/file.spec.ts @@ -1,9 +1,143 @@ import { describe, expect, it } from 'vitest' +import { parseSync } from 'oxc-parser' import { analyzeReactFile } from './file.js' +function analyze( + code: string, + opts?: { + includeNestedRenderEntries?: boolean + sourceDependencies?: Map + includeBuiltins?: boolean + }, +) { + const { program } = parseSync('test.tsx', code) + return analyzeReactFile( + program, + '/test.tsx', + code, + opts?.includeNestedRenderEntries ?? true, + opts?.sourceDependencies ?? new Map(), + opts?.includeBuiltins ?? false, + ) +} + describe('analyzeReactFile', () => { - it('exports a callable file analyzer', () => { - expect(analyzeReactFile).toBeTypeOf('function') + it('detects component symbols from function declarations', () => { + const result = analyze('function App() { return
}') + expect(result.symbolsByName.has('App')).toBe(true) + expect(result.symbolsByName.get('App')!.kind).toBe('component') + }) + + it('detects hook symbols', () => { + const result = analyze('function useData() { return null }') + expect(result.symbolsByName.has('useData')).toBe(true) + expect(result.symbolsByName.get('useData')!.kind).toBe('hook') + }) + + it('collects import bindings', () => { + const result = analyze('import { useState } from "react"') + expect(result.importsByLocalName.has('useState')).toBe(true) + expect(result.importsByLocalName.get('useState')!.importedName).toBe( + 'useState', + ) + }) + + it('collects export bindings for symbols', () => { + const result = analyze( + 'export function App() { return
}', + ) + expect(result.exportsByName.has('App')).toBe(true) + expect(result.symbolsByName.get('App')!.exportNames.has('App')).toBe(true) + }) + + it('collects entry usages when includeNestedRenderEntries is true', () => { + const result = analyze( + '\nfunction App() { return
}', + { includeNestedRenderEntries: true }, + ) + expect(result.entryUsages.length).toBeGreaterThan(0) + }) + + it('returns empty entry usages when includeNestedRenderEntries is false', () => { + const result = analyze( + '\nfunction App() { return
}', + { includeNestedRenderEntries: false }, + ) + expect(result.entryUsages).toHaveLength(0) + }) + + it('analyzes symbol usages to populate references', () => { + const result = analyze( + 'function App() { useState(); return }', + ) + const app = result.allSymbolsByName.get('App')! + expect(app.hookReferences.has('useState')).toBe(true) + expect(app.componentReferences.has('Child')).toBe(true) + }) + + it('maps source dependencies to import binding source paths', () => { + const result = analyze('import { Foo } from "./foo"', { + sourceDependencies: new Map([['./foo', '/src/foo.ts']]), + }) + expect(result.importsByLocalName.get('Foo')!.sourcePath).toBe( + '/src/foo.ts', + ) + }) + + it('includes dynamic component candidates in allSymbolsByName', () => { + const result = analyze( + 'const LazyComp = lazy(() => import("./Comp"))', + ) + expect(result.allSymbolsByName.has('LazyComp')).toBe(true) + expect(result.symbolsByName.has('LazyComp')).toBe(false) + }) + + it('falls back to component declaration entries when no direct entries', () => { + const result = analyze( + 'export function App() { return
}', + { includeNestedRenderEntries: true }, + ) + expect(result.entryUsages.length).toBeGreaterThan(0) + expect(result.entryUsages[0].referenceName).toBe('App') + expect(result.entryUsages[0].kind).toBe('component') + }) + + it('builds symbolsById from symbolsByName', () => { + const result = analyze('function App() { return
}') + const app = result.symbolsByName.get('App')! + expect(result.symbolsById.get(app.id)).toBe(app) + }) + + it('builds allSymbolsById from allSymbolsByName', () => { + const result = analyze( + 'function App() { return
}\nconst LazyComp = lazy(() => import("./Comp"))', + ) + expect(result.allSymbolsById.size).toBeGreaterThanOrEqual(2) + }) + + it('sets filePath on the result', () => { + const result = analyze('const x = 1') + expect(result.filePath).toBe('/test.tsx') + }) + + it('collects re-export bindings', () => { + const result = analyze('export { Foo } from "./foo"', { + sourceDependencies: new Map([['./foo', '/src/foo.ts']]), + }) + expect(result.reExportBindingsByName.has('Foo')).toBe(true) + }) + + it('collects export all bindings', () => { + const result = analyze('export * from "./foo"') + expect(result.exportAllBindings).toHaveLength(1) + }) + + it('includes builtin references when includeBuiltins is true', () => { + const result = analyze('function App() { return
}', { + includeBuiltins: true, + }) + const app = result.allSymbolsByName.get('App')! + expect(app.builtinReferences.has('div')).toBe(true) }) }) diff --git a/src/analyzers/react/queries.spec.ts b/src/analyzers/react/queries.spec.ts index 6ceb84a..adf697c 100644 --- a/src/analyzers/react/queries.spec.ts +++ b/src/analyzers/react/queries.spec.ts @@ -1,65 +1,373 @@ import { describe, expect, it } from 'vitest' import type { ReactUsageGraph } from '../../types/react-usage-graph.js' +import type { ReactUsageNode } from '../../types/react-usage-node.js' import { getFilteredUsages, getReactUsageEntries, getReactUsageRoots, } from './queries.js' -const graph: ReactUsageGraph = { - cwd: '/repo', - entryId: 'src/main.tsx', - entryIds: ['src/main.tsx'], - entries: [ - { - target: 'component:App', - referenceName: 'App', - location: { filePath: 'src/main.tsx', line: 1, column: 1 }, - }, - ], - nodes: new Map([ - [ - 'component:App', - { - id: 'component:App', - name: 'App', - kind: 'component', - filePath: 'src/App.tsx', - exportNames: ['default'], - usages: [ +function createGraph(overrides?: Partial): ReactUsageGraph { + return { + cwd: '/repo', + entryId: 'src/main.tsx', + entryIds: ['src/main.tsx'], + entries: [], + nodes: new Map(), + ...overrides, + } +} + +describe('getReactUsageEntries', () => { + it('returns all entries with all filter', () => { + const graph = createGraph({ + entries: [ + { + target: 'comp:App', + referenceName: 'App', + location: { filePath: 'src/main.tsx', line: 1, column: 1 }, + }, + ], + nodes: new Map([ + [ + 'comp:App', { - kind: 'hook-call', - target: 'hook:useFeature', - referenceName: 'useFeature', + id: 'comp:App', + name: 'App', + kind: 'component', + filePath: 'src/App.tsx', + exportNames: [], + usages: [], }, ], - }, - ], - [ - 'hook:useFeature', - { - id: 'hook:useFeature', - name: 'useFeature', - kind: 'hook', - filePath: 'src/hooks/useFeature.ts', - exportNames: ['useFeature'], - usages: [], - }, - ], - ]), -} + ]), + }) -describe('react graph queries', () => { - it('filters entries, roots, and usages by symbol kind', () => { - const appNode = graph.nodes.get('component:App') + expect(getReactUsageEntries(graph, 'all')).toHaveLength(1) + }) + + it('filters entries by component kind', () => { + const graph = createGraph({ + entries: [ + { + target: 'comp:App', + referenceName: 'App', + location: { filePath: 'src/main.tsx', line: 1, column: 1 }, + }, + { + target: 'hook:useData', + referenceName: 'useData', + location: { filePath: 'src/main.tsx', line: 2, column: 1 }, + }, + ], + nodes: new Map([ + [ + 'comp:App', + { + id: 'comp:App', + name: 'App', + kind: 'component', + filePath: 'src/App.tsx', + exportNames: [], + usages: [], + }, + ], + [ + 'hook:useData', + { + id: 'hook:useData', + name: 'useData', + kind: 'hook', + filePath: 'src/hooks.ts', + exportNames: [], + usages: [], + }, + ], + ]), + }) expect(getReactUsageEntries(graph, 'component')).toHaveLength(1) - expect(getReactUsageRoots(graph, 'hook')).toEqual(['hook:useFeature']) - expect(appNode).toBeDefined() - if (appNode === undefined) { - return + expect(getReactUsageEntries(graph, 'hook')).toHaveLength(1) + }) + + it('filters out entries whose target node does not exist', () => { + const graph = createGraph({ + entries: [ + { + target: 'missing', + referenceName: 'Missing', + location: { filePath: 'src/main.tsx', line: 1, column: 1 }, + }, + ], + }) + + expect(getReactUsageEntries(graph)).toHaveLength(0) + }) +}) + +describe('getReactUsageRoots', () => { + it('returns entry targets when entries exist', () => { + const graph = createGraph({ + entries: [ + { + target: 'comp:App', + referenceName: 'App', + location: { filePath: 'src/main.tsx', line: 1, column: 1 }, + }, + ], + nodes: new Map([ + [ + 'comp:App', + { + id: 'comp:App', + name: 'App', + kind: 'component', + filePath: 'src/App.tsx', + exportNames: [], + usages: [], + }, + ], + ]), + }) + + expect(getReactUsageRoots(graph)).toEqual(['comp:App']) + }) + + it('deduplicates entry targets', () => { + const graph = createGraph({ + entries: [ + { + target: 'comp:App', + referenceName: 'App', + location: { filePath: 'src/a.tsx', line: 1, column: 1 }, + }, + { + target: 'comp:App', + referenceName: 'App', + location: { filePath: 'src/b.tsx', line: 1, column: 1 }, + }, + ], + nodes: new Map([ + [ + 'comp:App', + { + id: 'comp:App', + name: 'App', + kind: 'component', + filePath: 'src/App.tsx', + exportNames: [], + usages: [], + }, + ], + ]), + }) + + expect(getReactUsageRoots(graph)).toEqual(['comp:App']) + }) + + it('finds zero-inbound nodes when no entries', () => { + const graph = createGraph({ + nodes: new Map([ + [ + 'comp:A', + { + id: 'comp:A', + name: 'A', + kind: 'component', + filePath: 'src/a.tsx', + exportNames: [], + usages: [{ kind: 'render', target: 'comp:B', referenceName: 'B' }], + }, + ], + [ + 'comp:B', + { + id: 'comp:B', + name: 'B', + kind: 'component', + filePath: 'src/b.tsx', + exportNames: [], + usages: [], + }, + ], + ]), + }) + + expect(getReactUsageRoots(graph)).toEqual(['comp:A']) + }) + + it('returns all nodes sorted when all have inbound references (cycle)', () => { + const graph = createGraph({ + nodes: new Map([ + [ + 'comp:A', + { + id: 'comp:A', + name: 'A', + kind: 'component', + filePath: 'src/a.tsx', + exportNames: [], + usages: [{ kind: 'render', target: 'comp:B', referenceName: 'B' }], + }, + ], + [ + 'comp:B', + { + id: 'comp:B', + name: 'B', + kind: 'component', + filePath: 'src/b.tsx', + exportNames: [], + usages: [{ kind: 'render', target: 'comp:A', referenceName: 'A' }], + }, + ], + ]), + }) + + const roots = getReactUsageRoots(graph) + expect(roots).toHaveLength(2) + expect(roots).toContain('comp:A') + expect(roots).toContain('comp:B') + }) + + it('filters roots by kind', () => { + const graph = createGraph({ + nodes: new Map([ + [ + 'comp:A', + { + id: 'comp:A', + name: 'A', + kind: 'component', + filePath: 'src/a.tsx', + exportNames: [], + usages: [], + }, + ], + [ + 'hook:useData', + { + id: 'hook:useData', + name: 'useData', + kind: 'hook', + filePath: 'src/hooks.ts', + exportNames: [], + usages: [], + }, + ], + ]), + }) + + expect(getReactUsageRoots(graph, 'component')).toEqual(['comp:A']) + expect(getReactUsageRoots(graph, 'hook')).toEqual(['hook:useData']) + }) +}) + +describe('getFilteredUsages', () => { + it('returns all usages with all filter', () => { + const node: ReactUsageNode = { + id: 'comp:A', + name: 'A', + kind: 'component', + filePath: 'src/a.tsx', + exportNames: [], + usages: [ + { kind: 'render', target: 'comp:B', referenceName: 'B' }, + { kind: 'hook-call', target: 'hook:useData', referenceName: 'useData' }, + ], + } + + const graph = createGraph({ + nodes: new Map([ + [node.id, node], + [ + 'comp:B', + { + id: 'comp:B', + name: 'B', + kind: 'component', + filePath: 'src/b.tsx', + exportNames: [], + usages: [], + }, + ], + [ + 'hook:useData', + { + id: 'hook:useData', + name: 'useData', + kind: 'hook', + filePath: 'src/hooks.ts', + exportNames: [], + usages: [], + }, + ], + ]), + }) + + expect(getFilteredUsages(node, graph, 'all')).toHaveLength(2) + }) + + it('filters usages by target node kind', () => { + const node: ReactUsageNode = { + id: 'comp:A', + name: 'A', + kind: 'component', + filePath: 'src/a.tsx', + exportNames: [], + usages: [ + { kind: 'render', target: 'comp:B', referenceName: 'B' }, + { kind: 'hook-call', target: 'hook:useData', referenceName: 'useData' }, + ], + } + + const graph = createGraph({ + nodes: new Map([ + [node.id, node], + [ + 'comp:B', + { + id: 'comp:B', + name: 'B', + kind: 'component', + filePath: 'src/b.tsx', + exportNames: [], + usages: [], + }, + ], + [ + 'hook:useData', + { + id: 'hook:useData', + name: 'useData', + kind: 'hook', + filePath: 'src/hooks.ts', + exportNames: [], + usages: [], + }, + ], + ]), + }) + + expect(getFilteredUsages(node, graph, 'component')).toHaveLength(1) + expect(getFilteredUsages(node, graph, 'hook')).toHaveLength(1) + }) + + it('filters out usages with missing target nodes', () => { + const node: ReactUsageNode = { + id: 'comp:A', + name: 'A', + kind: 'component', + filePath: 'src/a.tsx', + exportNames: [], + usages: [{ kind: 'render', target: 'missing', referenceName: 'Missing' }], } - expect(getFilteredUsages(appNode, graph, 'hook')).toHaveLength(1) + + const graph = createGraph({ + nodes: new Map([[node.id, node]]), + }) + + expect(getFilteredUsages(node, graph)).toHaveLength(0) }) }) diff --git a/src/analyzers/react/references.spec.ts b/src/analyzers/react/references.spec.ts index e5691cd..b5a6424 100644 --- a/src/analyzers/react/references.spec.ts +++ b/src/analyzers/react/references.spec.ts @@ -1,55 +1,591 @@ import { describe, expect, it } from 'vitest' import type { ReactUsageNode } from '../../types/react-usage-node.js' +import type { ImportBinding } from './bindings.js' +import type { FileAnalysis, PendingReactUsageNode } from './file.js' import { + addBuiltinNodes, + addExternalHookNodes, compareReactNodeIds, compareReactUsageEntries, getBuiltinNodeId, + resolveReactReference, } from './references.js' -describe('react references helpers', () => { - it('exports stable builtin ids and ordering helpers', () => { - const nodes = new Map([ - [ - 'a', +function createFileAnalysis( + overrides?: Partial, +): FileAnalysis { + return { + filePath: '/test.tsx', + importsByLocalName: new Map(), + exportsByName: new Map(), + reExportBindingsByName: new Map(), + exportAllBindings: [], + entryUsages: [], + allSymbolsById: new Map(), + allSymbolsByName: new Map(), + symbolsById: new Map(), + symbolsByName: new Map(), + ...overrides, + } +} + +function createPendingSymbol( + name: string, + kind: 'component' | 'hook', + filePath = '/test.tsx', +): PendingReactUsageNode { + return { + id: `${filePath}#${kind}:${name}`, + name, + kind, + filePath, + declarationOffset: 0, + analysisRoot: { type: 'FunctionBody' } as never, + exportNames: new Set(), + componentReferences: new Set(), + hookReferences: new Set(), + builtinReferences: new Set(), + } +} + +describe('getBuiltinNodeId', () => { + it('returns builtin: prefixed id', () => { + expect(getBuiltinNodeId('div')).toBe('builtin:div') + expect(getBuiltinNodeId('span')).toBe('builtin:span') + }) +}) + +describe('resolveReactReference', () => { + it('resolves builtin references', () => { + const fileAnalysis = createFileAnalysis() + const result = resolveReactReference( + fileAnalysis, + new Map(), + 'div', + 'builtin', + ) + expect(result).toBe('builtin:div') + }) + + it('resolves local symbol by name and kind', () => { + const symbol = createPendingSymbol('App', 'component') + const fileAnalysis = createFileAnalysis({ + allSymbolsByName: new Map([['App', symbol]]), + }) + + const result = resolveReactReference( + fileAnalysis, + new Map(), + 'App', + 'component', + ) + expect(result).toBe('/test.tsx#component:App') + }) + + it('returns undefined when local symbol kind does not match', () => { + const symbol = createPendingSymbol('App', 'component') + const fileAnalysis = createFileAnalysis({ + allSymbolsByName: new Map([['App', symbol]]), + }) + + const result = resolveReactReference( + fileAnalysis, + new Map(), + 'App', + 'hook', + ) + expect(result).toBeUndefined() + }) + + it('resolves imported symbol through source file analysis', () => { + const targetSymbol = createPendingSymbol('Button', 'component', '/src/button.tsx') + targetSymbol.exportNames.add('Button') + + const sourceAnalysis = createFileAnalysis({ + filePath: '/src/button.tsx', + exportsByName: new Map([['Button', '/src/button.tsx#component:Button']]), + allSymbolsById: new Map([['/src/button.tsx#component:Button', targetSymbol]]), + }) + + const fileAnalysis = createFileAnalysis({ + importsByLocalName: new Map([ + [ + 'Button', + { + importedName: 'Button', + sourceSpecifier: './button', + sourcePath: '/src/button.tsx', + }, + ], + ]), + }) + + const result = resolveReactReference( + fileAnalysis, + new Map([['/src/button.tsx', sourceAnalysis]]), + 'Button', + 'component', + ) + expect(result).toBe('/src/button.tsx#component:Button') + }) + + it('returns external hook ID for unresolved hook import', () => { + const fileAnalysis = createFileAnalysis({ + importsByLocalName: new Map([ + [ + 'useQuery', + { + importedName: 'useQuery', + sourceSpecifier: '@tanstack/react-query', + }, + ], + ]), + }) + + const result = resolveReactReference( + fileAnalysis, + new Map(), + 'useQuery', + 'hook', + ) + expect(result).toBe('external:@tanstack/react-query#hook:useQuery') + }) + + it('returns undefined for unresolved non-hook import', () => { + const fileAnalysis = createFileAnalysis({ + importsByLocalName: new Map([ + [ + 'Button', + { + importedName: 'Button', + sourceSpecifier: 'some-lib', + }, + ], + ]), + }) + + const result = resolveReactReference( + fileAnalysis, + new Map(), + 'Button', + 'component', + ) + expect(result).toBeUndefined() + }) + + it('resolves re-export chain', () => { + const targetSymbol = createPendingSymbol('App', 'component', '/deep.tsx') + targetSymbol.exportNames.add('App') + + const deepAnalysis = createFileAnalysis({ + filePath: '/deep.tsx', + exportsByName: new Map([['App', '/deep.tsx#component:App']]), + allSymbolsById: new Map([['/deep.tsx#component:App', targetSymbol]]), + }) + + const middleAnalysis = createFileAnalysis({ + filePath: '/middle.tsx', + reExportBindingsByName: new Map([ + [ + 'App', + { + importedName: 'App', + sourceSpecifier: './deep', + sourcePath: '/deep.tsx', + }, + ], + ]), + }) + + const fileAnalysis = createFileAnalysis({ + importsByLocalName: new Map([ + [ + 'App', + { + importedName: 'App', + sourceSpecifier: './middle', + sourcePath: '/middle.tsx', + }, + ], + ]), + }) + + const fileAnalyses = new Map([ + ['/middle.tsx', middleAnalysis], + ['/deep.tsx', deepAnalysis], + ]) + + const result = resolveReactReference( + fileAnalysis, + fileAnalyses, + 'App', + 'component', + ) + expect(result).toBe('/deep.tsx#component:App') + }) + + it('resolves through export all bindings', () => { + const targetSymbol = createPendingSymbol('App', 'component', '/deep.tsx') + targetSymbol.exportNames.add('App') + + const deepAnalysis = createFileAnalysis({ + filePath: '/deep.tsx', + exportsByName: new Map([['App', '/deep.tsx#component:App']]), + allSymbolsById: new Map([['/deep.tsx#component:App', targetSymbol]]), + }) + + const middleAnalysis = createFileAnalysis({ + filePath: '/middle.tsx', + exportAllBindings: [ { - id: 'a', - name: 'App', - kind: 'component', - filePath: 'src/App.tsx', - exportNames: [], - usages: [], + importedName: '*', + sourceSpecifier: './deep', + sourcePath: '/deep.tsx', }, ], + }) + + const fileAnalysis = createFileAnalysis({ + importsByLocalName: new Map([ + [ + 'App', + { + importedName: 'App', + sourceSpecifier: './middle', + sourcePath: '/middle.tsx', + }, + ], + ]), + }) + + const result = resolveReactReference( + fileAnalysis, + new Map([ + ['/middle.tsx', middleAnalysis], + ['/deep.tsx', deepAnalysis], + ]), + 'App', + 'component', + ) + expect(result).toBe('/deep.tsx#component:App') + }) + + it('resolves namespace member references', () => { + const targetSymbol = createPendingSymbol('Item', 'component', '/ns.tsx') + targetSymbol.exportNames.add('Item') + + const nsAnalysis = createFileAnalysis({ + filePath: '/ns.tsx', + exportsByName: new Map([['Item', '/ns.tsx#component:Item']]), + allSymbolsById: new Map([['/ns.tsx#component:Item', targetSymbol]]), + }) + + const fileAnalysis = createFileAnalysis({ + importsByLocalName: new Map([ + [ + 'Ns', + { + importedName: '*', + sourceSpecifier: './ns', + sourcePath: '/ns.tsx', + }, + ], + ]), + }) + + const result = resolveReactReference( + fileAnalysis, + new Map([['/ns.tsx', nsAnalysis]]), + 'Ns.Item', + 'component', + ) + expect(result).toBe('/ns.tsx#component:Item') + }) + + it('returns undefined for namespace member without * import', () => { + const fileAnalysis = createFileAnalysis({ + importsByLocalName: new Map([ + [ + 'Ns', + { + importedName: 'default', + sourceSpecifier: './ns', + sourcePath: '/ns.tsx', + }, + ], + ]), + }) + + const result = resolveReactReference( + fileAnalysis, + new Map(), + 'Ns.Item', + 'component', + ) + expect(result).toBeUndefined() + }) +}) + +describe('addExternalHookNodes', () => { + it('creates nodes for imported hooks without sourcePath', () => { + const fileAnalyses = new Map([ [ - 'b', - { - id: 'b', - name: 'Button', - kind: 'component', - filePath: 'src/Button.tsx', - exportNames: [], - usages: [], - }, + '/test.tsx', + createFileAnalysis({ + importsByLocalName: new Map([ + [ + 'useQuery', + { + importedName: 'useQuery', + sourceSpecifier: '@tanstack/react-query', + }, + ], + ]), + }), + ], + ]) + + const nodes = new Map() + addExternalHookNodes(fileAnalyses, nodes) + + expect(nodes.size).toBe(1) + const node = [...nodes.values()][0] + expect(node.kind).toBe('hook') + expect(node.name).toBe('useQuery') + expect(node.filePath).toBe('@tanstack/react-query') + }) + + it('uses local name when imported as default', () => { + const fileAnalyses = new Map([ + [ + '/test.tsx', + createFileAnalysis({ + importsByLocalName: new Map([ + [ + 'useCustomHook', + { + importedName: 'default', + sourceSpecifier: 'some-lib', + }, + ], + ]), + }), + ], + ]) + + const nodes = new Map() + addExternalHookNodes(fileAnalyses, nodes) + + const node = [...nodes.values()][0] + expect(node.name).toBe('useCustomHook') + }) + + it('skips non-hook imports', () => { + const fileAnalyses = new Map([ + [ + '/test.tsx', + createFileAnalysis({ + importsByLocalName: new Map([ + [ + 'Button', + { + importedName: 'Button', + sourceSpecifier: 'some-lib', + }, + ], + ]), + }), + ], + ]) + + const nodes = new Map() + addExternalHookNodes(fileAnalyses, nodes) + + expect(nodes.size).toBe(0) + }) + + it('skips imports with sourcePath', () => { + const fileAnalyses = new Map([ + [ + '/test.tsx', + createFileAnalysis({ + importsByLocalName: new Map([ + [ + 'useData', + { + importedName: 'useData', + sourceSpecifier: './hooks', + sourcePath: '/src/hooks.ts', + }, + ], + ]), + }), + ], + ]) + + const nodes = new Map() + addExternalHookNodes(fileAnalyses, nodes) + + expect(nodes.size).toBe(0) + }) +}) + +describe('addBuiltinNodes', () => { + it('creates nodes from entry usages with builtin kind', () => { + const fileAnalyses = new Map([ + [ + '/test.tsx', + createFileAnalysis({ + entryUsages: [ + { + referenceName: 'div', + kind: 'builtin', + location: { filePath: '/test.tsx', line: 1, column: 1 }, + }, + ], + }), + ], + ]) + + const nodes = new Map() + addBuiltinNodes(fileAnalyses, nodes) + + expect(nodes.has('builtin:div')).toBe(true) + expect(nodes.get('builtin:div')!.kind).toBe('builtin') + expect(nodes.get('builtin:div')!.filePath).toBe('html') + }) + + it('creates nodes from symbol builtin references', () => { + const symbol = createPendingSymbol('App', 'component') + symbol.builtinReferences.add('span') + + const fileAnalyses = new Map([ + [ + '/test.tsx', + createFileAnalysis({ + allSymbolsById: new Map([[symbol.id, symbol]]), + }), + ], + ]) + + const nodes = new Map() + addBuiltinNodes(fileAnalyses, nodes) + + expect(nodes.has('builtin:span')).toBe(true) + }) + + it('does not duplicate existing builtin nodes', () => { + const symbol = createPendingSymbol('App', 'component') + symbol.builtinReferences.add('div') + + const fileAnalyses = new Map([ + [ + '/test.tsx', + createFileAnalysis({ + entryUsages: [ + { + referenceName: 'div', + kind: 'builtin', + location: { filePath: '/test.tsx', line: 1, column: 1 }, + }, + ], + allSymbolsById: new Map([[symbol.id, symbol]]), + }), ], ]) - expect(getBuiltinNodeId('button')).toBe('builtin:button') + const nodes = new Map() + addBuiltinNodes(fileAnalyses, nodes) + + expect(nodes.size).toBe(1) + }) +}) + +describe('compareReactNodeIds', () => { + const nodes = new Map([ + [ + 'a', + { + id: 'a', + name: 'App', + kind: 'component', + filePath: 'src/App.tsx', + exportNames: [], + usages: [], + }, + ], + [ + 'b', + { + id: 'b', + name: 'Button', + kind: 'component', + filePath: 'src/Button.tsx', + exportNames: [], + usages: [], + }, + ], + ]) + + it('compares by node properties', () => { expect(compareReactNodeIds('a', 'b', nodes)).toBeLessThan(0) - expect( - compareReactUsageEntries( - { - target: 'a', - referenceName: 'App', - location: { filePath: 'a', line: 1, column: 1 }, - }, - { - target: 'b', - referenceName: 'Button', - location: { filePath: 'b', line: 1, column: 1 }, - }, - nodes, - ), - ).toBeLessThan(0) + expect(compareReactNodeIds('b', 'a', nodes)).toBeGreaterThan(0) + expect(compareReactNodeIds('a', 'a', nodes)).toBe(0) + }) + + it('falls back to string comparison for missing nodes', () => { + expect(compareReactNodeIds('x', 'y', nodes)).toBeLessThan(0) + expect(compareReactNodeIds('y', 'x', nodes)).toBeGreaterThan(0) + }) +}) + +describe('compareReactUsageEntries', () => { + const nodes = new Map([ + [ + 'a', + { + id: 'a', + name: 'App', + kind: 'component', + filePath: 'src/App.tsx', + exportNames: [], + usages: [], + }, + ], + ]) + + it('compares by file path first', () => { + const result = compareReactUsageEntries( + { + target: 'a', + referenceName: 'App', + location: { filePath: 'a.tsx', line: 1, column: 1 }, + }, + { + target: 'a', + referenceName: 'App', + location: { filePath: 'b.tsx', line: 1, column: 1 }, + }, + nodes, + ) + expect(result).toBeLessThan(0) + }) + + it('compares by line when file paths match', () => { + const result = compareReactUsageEntries( + { + target: 'a', + referenceName: 'App', + location: { filePath: 'a.tsx', line: 1, column: 1 }, + }, + { + target: 'a', + referenceName: 'App', + location: { filePath: 'a.tsx', line: 5, column: 1 }, + }, + nodes, + ) + expect(result).toBeLessThan(0) }) }) diff --git a/src/analyzers/react/symbols.spec.ts b/src/analyzers/react/symbols.spec.ts index 7c3ff54..9e06d57 100644 --- a/src/analyzers/react/symbols.spec.ts +++ b/src/analyzers/react/symbols.spec.ts @@ -1,13 +1,193 @@ import { describe, expect, it } from 'vitest' +import { parseSync } from 'oxc-parser' +import type { PendingReactUsageNode } from './file.js' import { collectTopLevelDynamicComponentCandidates, collectTopLevelReactSymbols, } from './symbols.js' -describe('react symbol collectors', () => { - it('export symbol collection helpers', () => { - expect(collectTopLevelReactSymbols).toBeTypeOf('function') - expect(collectTopLevelDynamicComponentCandidates).toBeTypeOf('function') +function collectSymbols(code: string) { + const { program } = parseSync('test.tsx', code) + const symbolsByName = new Map() + program.body.forEach((statement) => { + collectTopLevelReactSymbols(statement, '/test.tsx', symbolsByName) + }) + return symbolsByName +} + +function collectDynamicCandidates(code: string) { + const { program } = parseSync('test.tsx', code) + const symbolsByName = new Map() + const dynamicCandidates = new Map() + + program.body.forEach((statement) => { + collectTopLevelReactSymbols(statement, '/test.tsx', symbolsByName) + }) + program.body.forEach((statement) => { + collectTopLevelDynamicComponentCandidates( + statement, + '/test.tsx', + symbolsByName, + dynamicCandidates, + ) + }) + + return { symbolsByName, dynamicCandidates } +} + +describe('collectTopLevelReactSymbols', () => { + it('detects function declaration component returning JSX', () => { + const symbols = collectSymbols('function App() { return
}') + const app = symbols.get('App') + expect(app).toBeDefined() + expect(app!.kind).toBe('component') + expect(app!.id).toBe('/test.tsx#component:App') + }) + + it('detects function declaration hook', () => { + const symbols = collectSymbols('function useData() { return null }') + const hook = symbols.get('useData') + expect(hook).toBeDefined() + expect(hook!.kind).toBe('hook') + }) + + it('detects arrow function component returning JSX', () => { + const symbols = collectSymbols('const App = () =>
') + const app = symbols.get('App') + expect(app).toBeDefined() + expect(app!.kind).toBe('component') + }) + + it('detects arrow function hook', () => { + const symbols = collectSymbols('const useData = () => { return null }') + expect(symbols.get('useData')).toBeDefined() + expect(symbols.get('useData')!.kind).toBe('hook') + }) + + it('detects styled-component variable', () => { + const symbols = collectSymbols('const Button = styled.button``') + expect(symbols.get('Button')).toBeDefined() + expect(symbols.get('Button')!.kind).toBe('component') + }) + + it('ignores non-react functions', () => { + const symbols = collectSymbols('function helper() { return 42 }') + expect(symbols.size).toBe(0) + }) + + it('ignores lowercase function names as components', () => { + const symbols = collectSymbols('function app() { return
}') + expect(symbols.size).toBe(0) + }) + + it('ignores variable without initializer', () => { + const symbols = collectSymbols('let App') + expect(symbols.size).toBe(0) + }) + + it('ignores destructured variables', () => { + const symbols = collectSymbols('const { App } = obj') + expect(symbols.size).toBe(0) + }) + + it('detects exported function declarations', () => { + const symbols = collectSymbols('export function App() { return
}') + expect(symbols.get('App')).toBeDefined() + }) + + it('detects default exported named function', () => { + const symbols = collectSymbols( + 'export default function App() { return
}', + ) + expect(symbols.get('App')).toBeDefined() + expect(symbols.get('App')!.kind).toBe('component') + }) + + it('does not classify anonymous default arrow function as component', () => { + const symbols = collectSymbols( + 'export default () => { return
}', + ) + expect(symbols.get('default')).toBeUndefined() + }) + + it('initializes pending symbol with empty sets', () => { + const symbols = collectSymbols('function useData() {}') + const hook = symbols.get('useData')! + expect(hook.exportNames.size).toBe(0) + expect(hook.componentReferences.size).toBe(0) + expect(hook.hookReferences.size).toBe(0) + expect(hook.builtinReferences.size).toBe(0) + }) + + it('ignores unrelated statement types', () => { + const symbols = collectSymbols('const x = 42') + expect(symbols.size).toBe(0) + }) +}) + +describe('collectTopLevelDynamicComponentCandidates', () => { + it('detects call expression dynamic candidate', () => { + const { dynamicCandidates } = collectDynamicCandidates( + 'const LazyComp = lazy(() => import("./Comp"))', + ) + expect(dynamicCandidates.get('LazyComp')).toBeDefined() + expect(dynamicCandidates.get('LazyComp')!.kind).toBe('component') + }) + + it('ignores names already in symbolsByName', () => { + const code = [ + 'function App() { return
}', + 'const App2 = lazy(() => import("./App"))', + ].join('\n') + const { program } = parseSync('test.tsx', code) + const symbolsByName = new Map() + const dynamicCandidates = new Map() + + program.body.forEach((statement) => { + collectTopLevelReactSymbols(statement, '/test.tsx', symbolsByName) + }) + + const appSymbol = symbolsByName.get('App')! + symbolsByName.set('App2', appSymbol) + + program.body.forEach((statement) => { + collectTopLevelDynamicComponentCandidates( + statement, + '/test.tsx', + symbolsByName, + dynamicCandidates, + ) + }) + + expect(dynamicCandidates.has('App2')).toBe(false) + }) + + it('ignores lowercase names', () => { + const { dynamicCandidates } = collectDynamicCandidates( + 'const lazyComp = lazy(() => import("./Comp"))', + ) + expect(dynamicCandidates.size).toBe(0) + }) + + it('ignores names with underscores', () => { + const { dynamicCandidates } = collectDynamicCandidates( + 'const Comp_A = lazy(() => import("./Comp"))', + ) + expect(dynamicCandidates.size).toBe(0) + }) + + it('handles exported variable declarations', () => { + const { dynamicCandidates } = collectDynamicCandidates( + 'export const LazyComp = lazy(() => import("./Comp"))', + ) + expect(dynamicCandidates.get('LazyComp')).toBeDefined() + }) + + it('ignores non-call/non-tagged-template initializers', () => { + const { dynamicCandidates } = collectDynamicCandidates( + 'const LazyComp = someValue', + ) + expect(dynamicCandidates.size).toBe(0) }) }) diff --git a/src/analyzers/react/usage.spec.ts b/src/analyzers/react/usage.spec.ts index 3fca4f7..b599e64 100644 --- a/src/analyzers/react/usage.spec.ts +++ b/src/analyzers/react/usage.spec.ts @@ -1,9 +1,180 @@ import { describe, expect, it } from 'vitest' +import { parseSync } from 'oxc-parser' +import type { PendingReactUsageNode } from './file.js' import { analyzeSymbolUsages } from './usage.js' +function createSymbolFromCode( + code: string, + name: string, + kind: 'component' | 'hook', +): PendingReactUsageNode { + const { program } = parseSync('test.tsx', code) + const stmt = program.body[0] + + let analysisRoot: PendingReactUsageNode['analysisRoot'] + if (stmt.type === 'FunctionDeclaration' && stmt.body !== null) { + analysisRoot = stmt.body + } else if ( + stmt.type === 'VariableDeclaration' && + stmt.declarations[0].init !== null + ) { + const init = stmt.declarations[0].init + if (init.type === 'ArrowFunctionExpression') { + analysisRoot = init.body as PendingReactUsageNode['analysisRoot'] + } else { + analysisRoot = init as PendingReactUsageNode['analysisRoot'] + } + } else { + throw new Error(`Unsupported statement type: ${stmt.type}`) + } + + return { + id: `test.tsx#${kind}:${name}`, + name, + kind, + filePath: 'test.tsx', + declarationOffset: 0, + analysisRoot, + exportNames: new Set(), + componentReferences: new Set(), + hookReferences: new Set(), + builtinReferences: new Set(), + } +} + describe('analyzeSymbolUsages', () => { - it('exports a callable usage analyzer', () => { - expect(analyzeSymbolUsages).toBeTypeOf('function') + it('detects component references from JSX', () => { + const symbol = createSymbolFromCode( + 'function App() { return }', + 'App', + 'component', + ) + analyzeSymbolUsages(symbol, false) + + expect(symbol.componentReferences.has('Child')).toBe(true) + }) + + it('detects hook references from call expressions', () => { + const symbol = createSymbolFromCode( + 'function App() { useState(0); return
}', + 'App', + 'component', + ) + analyzeSymbolUsages(symbol, false) + + expect(symbol.hookReferences.has('useState')).toBe(true) + }) + + it('detects builtin references when includeBuiltins is true', () => { + const symbol = createSymbolFromCode( + 'function App() { return
}', + 'App', + 'component', + ) + analyzeSymbolUsages(symbol, true) + + expect(symbol.builtinReferences.has('div')).toBe(true) + }) + + it('ignores builtin references when includeBuiltins is false', () => { + const symbol = createSymbolFromCode( + 'function App() { return
}', + 'App', + 'component', + ) + analyzeSymbolUsages(symbol, false) + + expect(symbol.builtinReferences.size).toBe(0) + }) + + it('detects React.createElement component references', () => { + const symbol = createSymbolFromCode( + 'function App() { return React.createElement(Child, null) }', + 'App', + 'component', + ) + analyzeSymbolUsages(symbol, false) + + expect(symbol.componentReferences.has('Child')).toBe(true) + }) + + it('detects member expression component references', () => { + const symbol = createSymbolFromCode( + 'function App() { return }', + 'App', + 'component', + ) + analyzeSymbolUsages(symbol, false) + + expect(symbol.componentReferences.has('Ns.Item')).toBe(true) + }) + + it('detects styled component references from call expressions', () => { + const symbol = createSymbolFromCode( + 'function App() { const S = styled(Button)({}); return }', + 'App', + 'component', + ) + analyzeSymbolUsages(symbol, false) + + expect(symbol.componentReferences.has('Button')).toBe(true) + }) + + it('detects styled builtin references when includeBuiltins is true', () => { + const symbol = createSymbolFromCode( + 'function App() { const S = styled.div({}); return }', + 'App', + 'component', + ) + analyzeSymbolUsages(symbol, true) + + expect(symbol.builtinReferences.has('div')).toBe(true) + }) + + it('ignores styled builtin references when includeBuiltins is false', () => { + const symbol = createSymbolFromCode( + 'function App() { const S = styled.div({}); return }', + 'App', + 'component', + ) + analyzeSymbolUsages(symbol, false) + + expect(symbol.builtinReferences.size).toBe(0) + }) + + it('detects styled component references from tagged templates', () => { + const symbol = createSymbolFromCode( + 'function App() { const S = styled(Button)``; return }', + 'App', + 'component', + ) + analyzeSymbolUsages(symbol, false) + + expect(symbol.componentReferences.has('Button')).toBe(true) + }) + + it('detects styled builtin from tagged templates when includeBuiltins is true', () => { + const symbol = createSymbolFromCode( + 'function App() { const S = styled.div``; return }', + 'App', + 'component', + ) + analyzeSymbolUsages(symbol, true) + + expect(symbol.builtinReferences.has('div')).toBe(true) + }) + + it('detects multiple references in the same function', () => { + const symbol = createSymbolFromCode( + 'function App() { useEffect(() => {}); return <> }', + 'App', + 'component', + ) + analyzeSymbolUsages(symbol, false) + + expect(symbol.hookReferences.has('useEffect')).toBe(true) + expect(symbol.componentReferences.has('Child')).toBe(true) + expect(symbol.componentReferences.has('Other')).toBe(true) }) }) diff --git a/src/analyzers/react/walk.spec.ts b/src/analyzers/react/walk.spec.ts index e73ebff..3ab8bac 100644 --- a/src/analyzers/react/walk.spec.ts +++ b/src/analyzers/react/walk.spec.ts @@ -1,22 +1,361 @@ import { describe, expect, it } from 'vitest' +import { parseSync } from 'oxc-parser' import { classifyReactSymbol, + containsReactElementLikeExpression, FUNCTION_NODE_TYPES, + getBuiltinReferenceName, + getComponentReferenceName, + getCreateElementComponentReferenceName, + getHookReferenceName, + getMemberExpressionComponentReferenceName, + getStyledBuiltinReferenceName, + getStyledComponentReferenceName, isComponentName, isHookName, + isNode, + walkNode, + walkReactUsageTree, } from './walk.js' +function parseExpression(code: string) { + const { program } = parseSync('test.tsx', code) + const stmt = program.body[0] + if (stmt.type === 'ExpressionStatement') return stmt.expression + throw new Error(`Expected ExpressionStatement, got ${stmt.type}`) +} + +function parseStatement(code: string) { + const { program } = parseSync('test.tsx', code) + return program.body[0] +} + +function parseJSXElement(code: string) { + const expr = parseExpression(code) + if (expr.type === 'JSXElement') return expr + throw new Error(`Expected JSXElement, got ${expr.type}`) +} + +function parseCallExpression(code: string) { + const expr = parseExpression(code) + if (expr.type === 'CallExpression') return expr + throw new Error(`Expected CallExpression, got ${expr.type}`) +} + describe('react walk helpers', () => { - it('classifies naming conventions for hooks and components', () => { - expect(FUNCTION_NODE_TYPES.has('FunctionDeclaration')).toBe(true) - expect(isHookName('useFeature')).toBe(true) - expect(isComponentName('AppShell')).toBe(true) - expect( - classifyReactSymbol('useFeature', { - type: 'ArrowFunctionExpression', - body: { type: 'JSXElement' }, - } as never), - ).toBe('hook') + describe('isHookName', () => { + it('returns true for valid hook names', () => { + expect(isHookName('useState')).toBe(true) + expect(isHookName('useEffect')).toBe(true) + expect(isHookName('use1')).toBe(true) + expect(isHookName('useMyCustomHook')).toBe(true) + }) + + it('returns false for non-hook names', () => { + expect(isHookName('used')).toBe(false) + expect(isHookName('user')).toBe(false) + expect(isHookName('hook')).toBe(false) + expect(isHookName('Use')).toBe(false) + expect(isHookName('usedEffect')).toBe(false) + }) + }) + + describe('isComponentName', () => { + it('returns true for names starting with uppercase', () => { + expect(isComponentName('App')).toBe(true) + expect(isComponentName('MyComponent')).toBe(true) + expect(isComponentName('A')).toBe(true) + }) + + it('returns false for names starting with lowercase', () => { + expect(isComponentName('app')).toBe(false) + expect(isComponentName('myComponent')).toBe(false) + expect(isComponentName('_App')).toBe(false) + }) + }) + + describe('FUNCTION_NODE_TYPES', () => { + it('includes all function-like node types', () => { + expect(FUNCTION_NODE_TYPES.has('FunctionDeclaration')).toBe(true) + expect(FUNCTION_NODE_TYPES.has('FunctionExpression')).toBe(true) + expect(FUNCTION_NODE_TYPES.has('ArrowFunctionExpression')).toBe(true) + expect(FUNCTION_NODE_TYPES.has('TSDeclareFunction')).toBe(true) + expect(FUNCTION_NODE_TYPES.has('TSEmptyBodyFunctionExpression')).toBe(true) + }) + }) + + describe('isNode', () => { + it('returns true for objects with a string type property', () => { + expect(isNode({ type: 'Identifier' })).toBe(true) + expect(isNode({ type: 'JSXElement', children: [] })).toBe(true) + }) + + it('returns false for non-node values', () => { + expect(isNode(null)).toBe(false) + expect(isNode(undefined)).toBe(false) + expect(isNode(42)).toBe(false) + expect(isNode('string')).toBe(false) + expect(isNode({ type: 42 })).toBe(false) + expect(isNode({})).toBe(false) + }) + }) + + describe('getComponentReferenceName', () => { + it('returns component name from JSX element', () => { + const element = parseJSXElement('') + expect(getComponentReferenceName(element)).toBe('MyComponent') + }) + + it('returns undefined for builtin elements', () => { + const element = parseJSXElement('
') + expect(getComponentReferenceName(element)).toBeUndefined() + }) + + it('returns undefined for member expressions', () => { + const element = parseJSXElement('') + expect(getComponentReferenceName(element)).toBeUndefined() + }) + }) + + describe('getMemberExpressionComponentReferenceName', () => { + it('returns dotted name from member expression JSX', () => { + const element = parseJSXElement('') + expect(getMemberExpressionComponentReferenceName(element)).toBe('Ns.Item') + }) + + it('returns undefined for simple JSX elements', () => { + const element = parseJSXElement('') + expect(getMemberExpressionComponentReferenceName(element)).toBeUndefined() + }) + + it('returns undefined when object is not a component name', () => { + const element = parseJSXElement('') + expect(getMemberExpressionComponentReferenceName(element)).toBeUndefined() + }) + }) + + describe('getBuiltinReferenceName', () => { + it('returns builtin element name', () => { + const element = parseJSXElement('
') + expect(getBuiltinReferenceName(element)).toBe('div') + }) + + it('returns undefined for component elements', () => { + const element = parseJSXElement('') + expect(getBuiltinReferenceName(element)).toBeUndefined() + }) + }) + + describe('getHookReferenceName', () => { + it('returns hook name from call expression', () => { + const call = parseCallExpression('useState(0)') + expect(getHookReferenceName(call)).toBe('useState') + }) + + it('returns undefined for non-hook calls', () => { + const call = parseCallExpression('getData()') + expect(getHookReferenceName(call)).toBeUndefined() + }) + }) + + describe('getCreateElementComponentReferenceName', () => { + it('returns component name from React.createElement', () => { + const call = parseCallExpression('React.createElement(MyComp, null)') + expect(getCreateElementComponentReferenceName(call)).toBe('MyComp') + }) + + it('returns undefined for non-createElement calls', () => { + const call = parseCallExpression('someFunc(MyComp)') + expect(getCreateElementComponentReferenceName(call)).toBeUndefined() + }) + + it('returns undefined when first arg is not a component identifier', () => { + const call = parseCallExpression('React.createElement("div", null)') + expect(getCreateElementComponentReferenceName(call)).toBeUndefined() + }) + }) + + describe('getStyledComponentReferenceName', () => { + it('returns component name from styled(Component) call', () => { + const call = parseCallExpression('styled(Button)({})') + expect(getStyledComponentReferenceName(call)).toBe('Button') + }) + + it('returns component name from styled.Component member', () => { + const expr = parseExpression('styled.Button``') + if (expr.type === 'TaggedTemplateExpression') { + expect(getStyledComponentReferenceName(expr)).toBe('Button') + } + }) + + it('returns undefined for styled.div (builtin)', () => { + const expr = parseExpression('styled.div``') + if (expr.type === 'TaggedTemplateExpression') { + expect(getStyledComponentReferenceName(expr)).toBeUndefined() + } + }) + }) + + describe('getStyledBuiltinReferenceName', () => { + it('returns builtin name from styled.div', () => { + const expr = parseExpression('styled.div``') + if (expr.type === 'TaggedTemplateExpression') { + expect(getStyledBuiltinReferenceName(expr)).toBe('div') + } + }) + + it('returns undefined for styled.Component', () => { + const expr = parseExpression('styled.Button``') + if (expr.type === 'TaggedTemplateExpression') { + expect(getStyledBuiltinReferenceName(expr)).toBeUndefined() + } + }) + }) + + describe('classifyReactSymbol', () => { + it('classifies hook from arrow function', () => { + const stmt = parseStatement( + 'const useData = () => { return null }', + ) + if ( + stmt.type === 'VariableDeclaration' && + stmt.declarations[0].init !== null + ) { + expect(classifyReactSymbol('useData', stmt.declarations[0].init as never)).toBe( + 'hook', + ) + } + }) + + it('classifies component from function returning JSX', () => { + const stmt = parseStatement( + 'function App() { return
}', + ) + if (stmt.type === 'FunctionDeclaration') { + expect(classifyReactSymbol('App', stmt as never)).toBe('component') + } + }) + + it('classifies component from styled-component expression', () => { + const stmt = parseStatement('const Button = styled.button``') + if ( + stmt.type === 'VariableDeclaration' && + stmt.declarations[0].init !== null + ) { + expect( + classifyReactSymbol('Button', stmt.declarations[0].init as never), + ).toBe('component') + } + }) + + it('returns undefined for non-react function', () => { + const stmt = parseStatement( + 'function helper() { return 42 }', + ) + if (stmt.type === 'FunctionDeclaration') { + expect(classifyReactSymbol('helper', stmt as never)).toBeUndefined() + } + }) + + it('returns undefined for component name with non-JSX return', () => { + const stmt = parseStatement( + 'function App() { return 42 }', + ) + if (stmt.type === 'FunctionDeclaration') { + expect(classifyReactSymbol('App', stmt as never)).toBeUndefined() + } + }) + }) + + describe('containsReactElementLikeExpression', () => { + it('returns true for JSX element', () => { + const expr = parseExpression('
') + expect(containsReactElementLikeExpression(expr)).toBe(true) + }) + + it('returns true for JSX fragment', () => { + const expr = parseExpression('<>') + expect(containsReactElementLikeExpression(expr)).toBe(true) + }) + + it('returns true for React.createElement call', () => { + const expr = parseExpression('React.createElement("div")') + expect(containsReactElementLikeExpression(expr)).toBe(true) + }) + + it('returns false for plain expression', () => { + const expr = parseExpression('42') + expect(containsReactElementLikeExpression(expr)).toBe(false) + }) + }) + + describe('walkReactUsageTree', () => { + it('visits nodes in the tree', () => { + const { program } = parseSync( + 'test.tsx', + 'function App() { return
}', + ) + const funcDecl = program.body[0] + if ( + funcDecl.type !== 'FunctionDeclaration' || + funcDecl.body === null + ) { + throw new Error('unexpected') + } + + const types: string[] = [] + walkReactUsageTree(funcDecl.body, (node) => { + types.push(node.type) + }) + + expect(types).toContain('ReturnStatement') + expect(types).toContain('JSXElement') + }) + }) + + describe('walkNode', () => { + it('skips nested function bodies when allowNestedFunctions is false', () => { + const { program } = parseSync( + 'test.tsx', + 'function outer() { const inner = () => { return }; return
}', + ) + const funcDecl = program.body[0] + if ( + funcDecl.type !== 'FunctionDeclaration' || + funcDecl.body === null + ) { + throw new Error('unexpected') + } + + const types: string[] = [] + walkNode( + funcDecl.body, + (node) => { + if (node.type === 'JSXElement') types.push('JSXElement') + }, + false, + ) + + expect(types).toHaveLength(1) + }) + + it('visits nested functions when allowNestedFunctions is true', () => { + const { program } = parseSync( + 'test.tsx', + '() => { const inner = () => { return }; return
}', + ) + const stmt = program.body[0] + if (stmt.type !== 'ExpressionStatement') throw new Error('unexpected') + const arrow = stmt.expression + if (arrow.type !== 'ArrowFunctionExpression') throw new Error('unexpected') + + const jsxCount: string[] = [] + walkReactUsageTree(arrow.body as never, (node) => { + if (node.type === 'JSXElement') jsxCount.push('JSXElement') + }) + + expect(jsxCount.length).toBeGreaterThanOrEqual(1) + }) }) }) diff --git a/src/color.spec.ts b/src/color.spec.ts index 2c020f2..c7e8d7f 100644 --- a/src/color.spec.ts +++ b/src/color.spec.ts @@ -1,66 +1,199 @@ import { describe, expect, it } from 'vitest' import { + colorizeMuted, colorizePackageDiff, + colorizeReactLabel, colorizeUnusedMarker, formatReactSymbolLabel, + formatReactSymbolName, resolveColorSupport, } from './color.js' -describe('color helpers', () => { - it('can colorize the unused marker in isolation', () => { +describe('resolveColorSupport', () => { + it('returns true when mode is true', () => { + expect(resolveColorSupport(true)).toBe(true) + }) + + it('returns false when mode is false', () => { + expect(resolveColorSupport(false)).toBe(false) + }) + + it('uses FORCE_COLOR when auto', () => { + expect(resolveColorSupport('auto', { forceColor: '1' })).toBe(true) + expect(resolveColorSupport('auto', { forceColor: '0' })).toBe(false) + expect(resolveColorSupport('auto', { forceColor: 'true' })).toBe(true) + }) + + it('returns false when NO_COLOR is set', () => { + expect( + resolveColorSupport('auto', { + forceColor: undefined, + noColor: '1', + }), + ).toBe(false) + expect( + resolveColorSupport('auto', { + forceColor: undefined, + noColor: '', + }), + ).toBe(false) + }) + + it('falls back to TTY detection', () => { + expect( + resolveColorSupport('auto', { + forceColor: undefined, + noColor: undefined, + isTTY: true, + }), + ).toBe(true) + expect( + resolveColorSupport('auto', { + forceColor: undefined, + noColor: undefined, + isTTY: false, + }), + ).toBe(false) + expect( + resolveColorSupport('auto', { + forceColor: undefined, + noColor: undefined, + isTTY: undefined, + }), + ).toBe(false) + }) + + it('FORCE_COLOR takes precedence over NO_COLOR', () => { + expect( + resolveColorSupport('auto', { + forceColor: '1', + noColor: '1', + }), + ).toBe(true) + }) +}) + +describe('colorizeUnusedMarker', () => { + it('wraps (unused) with ANSI when enabled', () => { expect(colorizeUnusedMarker('src/file.ts (unused)', true)).toBe( 'src/file.ts \u001B[38;5;214m(unused)\u001B[0m', ) + }) + + it('returns text unchanged when disabled', () => { expect(colorizeUnusedMarker('src/file.ts (unused)', false)).toBe( 'src/file.ts (unused)', ) }) - it('uses different colors for components, hooks, and builtins', () => { - expect(formatReactSymbolLabel('Panel', 'component', true)).toBe( - '\u001B[36m [component]\u001B[0m', + it('handles text without (unused)', () => { + expect(colorizeUnusedMarker('src/file.ts', true)).toBe('src/file.ts') + }) + + it('replaces multiple occurrences', () => { + const result = colorizeUnusedMarker('(unused) and (unused)', true) + expect(result).toContain('\u001B[38;5;214m(unused)\u001B[0m') + expect(result.split('\u001B[38;5;214m').length).toBe(3) + }) +}) + +describe('formatReactSymbolName', () => { + it('formats component names with angle brackets', () => { + expect(formatReactSymbolName('App', 'component')).toBe('') + }) + + it('formats hook names with parentheses', () => { + expect(formatReactSymbolName('useState', 'hook')).toBe('useState()') + }) + + it('formats builtin names with angle brackets (no self-close)', () => { + expect(formatReactSymbolName('div', 'builtin')).toBe('
') + }) +}) + +describe('formatReactSymbolLabel', () => { + it('formats with kind suffix when disabled', () => { + expect(formatReactSymbolLabel('App', 'component', false)).toBe( + ' [component]', ) - expect(formatReactSymbolLabel('usePanelState', 'hook', true)).toBe( - '\u001B[35musePanelState() [hook]\u001B[0m', + expect(formatReactSymbolLabel('useState', 'hook', false)).toBe( + 'useState() [hook]', ) - expect(formatReactSymbolLabel('button', 'builtin', true)).toBe( - '\u001B[34m }', + ) + fs.writeFileSync( + path.join(tmpDir, 'App.tsx'), + `import { Button } from './Button'\nexport function App() { return