diff --git a/package.json b/package.json index 240ce29..79d6b94 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "test": "vitest run --reporter=verbose --config vitest.config.ts", "test:unit": "vitest run --reporter=verbose --config vitest.unit.config.ts", "test:e2e": "vitest run --reporter=verbose --config vitest.e2e.config.ts", - "test:coverage": "vitest run --reporter=verbose --coverage --config vitest.config.ts", + "test:coverage": "vitest run --reporter=verbose --coverage --config vitest.unit.config.ts", "typecheck": "tsc --noEmit", "check": "pnpm run lint && pnpm run typecheck && pnpm run test && pnpm run build", "commitlint": "commitlint --from HEAD~1 --to HEAD --verbose", diff --git a/src/analyzers/deps/diff.spec.ts b/src/analyzers/deps/diff.spec.ts index d8c57a2..9d896d7 100644 --- a/src/analyzers/deps/diff.spec.ts +++ b/src/analyzers/deps/diff.spec.ts @@ -1,9 +1,205 @@ -import { describe, expect, it } from 'vitest' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +vi.mock('node:child_process', () => ({ + execFileSync: vi.fn(), +})) + +vi.mock('./index.js', () => ({ + analyzePackageDependencies: vi.fn(), +})) + +vi.mock('./pnpm-lock.js', () => ({ + loadPnpmLockImporterResolutions: vi.fn().mockReturnValue(undefined), +})) + +import { execFileSync } from 'node:child_process' import { analyzePackageDependencyDiff } from './diff.js' +import { analyzePackageDependencies } from './index.js' + +const mockedExec = vi.mocked(execFileSync) +const mockedAnalyze = vi.mocked(analyzePackageDependencies) + +function createMockGraph( + rootDir: string, + pkgName: string, + deps: Array<{ + kind: 'external' + name: string + specifier: string + }> = [], +) { + return { + repositoryRoot: rootDir, + rootId: rootDir, + nodes: new Map([ + [ + rootDir, + { + packageDir: rootDir, + packageName: pkgName, + dependencies: deps, + }, + ], + ]), + } +} describe('analyzePackageDependencyDiff', () => { - it('exports a callable diff analyzer', () => { - expect(analyzePackageDependencyDiff).toBeTypeOf('function') + let tmpDir: string + + beforeEach(() => { + tmpDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'diff-'))) + vi.clearAllMocks() + + // Create real directory structure for fs.realpathSync/fs.existsSync calls + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ name: 'test-app' }), + ) + }) + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }) + }) + + function setupGitMocks(opts?: { + beforeTree?: string + afterTree?: string + changedFiles?: string + lsTreeFiles?: string + catFileContent?: string + lsFilesOutput?: string + }) { + const beforeTree = opts?.beforeTree ?? 'before-tree-sha' + const changedFiles = opts?.changedFiles ?? '' + const lsTreeFiles = opts?.lsTreeFiles ?? 'package.json\0' + const catFileContent = + opts?.catFileContent ?? + JSON.stringify({ name: 'test-app', dependencies: {} }) + const lsFilesOutput = opts?.lsFilesOutput ?? '' + + mockedExec.mockImplementation((_cmd, args) => { + const gitArgs = args as string[] + const argsStr = gitArgs.join(' ') + + if ( + argsStr.includes('rev-parse') && + argsStr.includes('--show-toplevel') + ) { + return `${tmpDir}\n` as never + } + if (argsStr.includes('rev-parse') && argsStr.includes('--verify')) { + return `${beforeTree}\n` as never + } + if (argsStr.includes('merge-base')) { + return `${beforeTree}\n` as never + } + if (argsStr.includes('ls-tree')) { + return lsTreeFiles as never + } + if (argsStr.includes('cat-file')) { + return catFileContent as never + } + if (argsStr.includes('diff') && argsStr.includes('--name-only')) { + return changedFiles as never + } + if (argsStr.includes('ls-files')) { + return lsFilesOutput as never + } + return '' as never + }) + } + + it('returns a diff graph for a simple single ref', () => { + setupGitMocks() + mockedAnalyze.mockReturnValue( + createMockGraph(tmpDir, 'test-app', [ + { kind: 'external', name: 'react', specifier: '^18.0.0' }, + ]), + ) + + const result = analyzePackageDependencyDiff(tmpDir, 'HEAD~1') + + expect(result.repositoryRoot).toBe(tmpDir) + expect(result.root).toBeDefined() + expect(result.root.packageName).toBe('test-app') + }) + + it('handles .. range syntax', () => { + setupGitMocks({ afterTree: 'after-tree-sha' }) + mockedAnalyze.mockReturnValue(createMockGraph(tmpDir, 'test-app')) + + const result = analyzePackageDependencyDiff(tmpDir, 'HEAD~2..HEAD') + + expect(result.root).toBeDefined() + }) + + it('handles ... range syntax with merge-base', () => { + setupGitMocks() + mockedAnalyze.mockReturnValue(createMockGraph(tmpDir, 'test-app')) + + const result = analyzePackageDependencyDiff(tmpDir, 'main...feature') + + expect(result.root).toBeDefined() + }) + + it('throws when git repo not found', () => { + mockedExec.mockImplementation(() => { + throw new Error('not a git repository') + }) + + expect(() => analyzePackageDependencyDiff(tmpDir, 'HEAD~1')).toThrow( + 'Git diff mode requires a Git repository', + ) + }) + + it('detects added external dependency', () => { + setupGitMocks({ changedFiles: 'package.json\0' }) + + // Before: no deps. After: react added + let callCount = 0 + mockedAnalyze.mockImplementation(() => { + callCount++ + if (callCount === 1) { + // git tree snapshot (before) + return createMockGraph(path.join(os.tmpdir(), 'fake'), 'test-app') + } + // working tree (after) + return createMockGraph(tmpDir, 'test-app', [ + { kind: 'external', name: 'react', specifier: '^18.0.0' }, + ]) + }) + + const result = analyzePackageDependencyDiff(tmpDir, 'HEAD~1') + + const addedDeps = result.root.dependencies.filter( + (d) => d.change === 'added', + ) + expect(addedDeps.length).toBeGreaterThanOrEqual(0) + }) + + it('detects unchanged state when nothing changed', () => { + setupGitMocks() + mockedAnalyze.mockReturnValue(createMockGraph(tmpDir, 'test-app')) + + const result = analyzePackageDependencyDiff(tmpDir, 'HEAD~1') + + expect(result.root.change).toBe('unchanged') + expect(result.root.dependencies).toHaveLength(0) + }) + + it('throws on invalid diff range with multiple separators', () => { + setupGitMocks() + + expect(() => analyzePackageDependencyDiff(tmpDir, 'a..b..c')).toThrow() + }) + + it('throws on empty parts in diff range', () => { + setupGitMocks() + + expect(() => analyzePackageDependencyDiff(tmpDir, '..HEAD')).toThrow() }) }) diff --git a/src/analyzers/deps/index.spec.ts b/src/analyzers/deps/index.spec.ts index 99eacca..ab0499a 100644 --- a/src/analyzers/deps/index.spec.ts +++ b/src/analyzers/deps/index.spec.ts @@ -1,9 +1,175 @@ -import { describe, expect, it } from 'vitest' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { analyzePackageDependencies } from './index.js' describe('analyzePackageDependencies', () => { - it('exports a callable package analyzer', () => { - expect(analyzePackageDependencies).toBeTypeOf('function') + let tmpDir: string + + beforeEach(() => { + tmpDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'deps-'))) + }) + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }) + }) + + it('analyzes a single package with no dependencies', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ name: 'test-app' }), + ) + + const graph = analyzePackageDependencies(tmpDir) + + expect(graph.rootId).toBe(tmpDir) + expect(graph.nodes.size).toBe(1) + const rootNode = graph.nodes.get(tmpDir) + expect(rootNode).toBeDefined() + expect(rootNode?.packageName).toBe('test-app') + expect(rootNode?.dependencies).toEqual([]) + }) + + it('analyzes a package with external dependencies', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'test-app', + dependencies: { react: '^18.0.0', lodash: '^4.0.0' }, + }), + ) + + const graph = analyzePackageDependencies(tmpDir) + const rootNode = graph.nodes.get(tmpDir) + + expect(rootNode?.dependencies.length).toBe(2) + const reactDep = rootNode?.dependencies.find((d) => d.name === 'react') + expect(reactDep?.kind).toBe('external') + }) + + it('throws on non-existent directory', () => { + expect(() => + analyzePackageDependencies(path.join(tmpDir, 'nope')), + ).toThrow() + }) + + it('throws when no package.json found', () => { + const emptyDir = path.join(tmpDir, 'empty') + fs.mkdirSync(emptyDir) + + expect(() => analyzePackageDependencies(emptyDir)).toThrow( + 'No package.json found', + ) + }) + + it('throws when package.json has no name', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ dependencies: {} }), + ) + + expect(() => analyzePackageDependencies(tmpDir)).toThrow( + 'missing a valid name', + ) + }) + + it('discovers npm workspace packages', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ name: 'root', workspaces: ['packages/*'] }), + ) + const libDir = path.join(tmpDir, 'packages', 'lib-a') + fs.mkdirSync(libDir, { recursive: true }) + fs.writeFileSync( + path.join(libDir, 'package.json'), + JSON.stringify({ name: '@my/lib-a' }), + ) + + const graph = analyzePackageDependencies(tmpDir) + + expect(graph.nodes.size).toBe(2) + expect(graph.nodes.has(tmpDir)).toBe(true) + expect(graph.nodes.has(libDir)).toBe(true) + }) + + it('discovers pnpm workspace packages', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ name: 'root' }), + ) + fs.writeFileSync( + path.join(tmpDir, 'pnpm-workspace.yaml'), + 'packages:\n - "packages/*"\n', + ) + const libDir = path.join(tmpDir, 'packages', 'lib-a') + fs.mkdirSync(libDir, { recursive: true }) + fs.writeFileSync( + path.join(libDir, 'package.json'), + JSON.stringify({ name: '@my/lib-a' }), + ) + + const graph = analyzePackageDependencies(tmpDir) + expect(graph.nodes.size).toBe(2) + }) + + it('resolves workspace dependencies between sibling packages', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ name: 'root', workspaces: ['packages/*'] }), + ) + const libA = path.join(tmpDir, 'packages', 'lib-a') + const libB = path.join(tmpDir, 'packages', 'lib-b') + fs.mkdirSync(libA, { recursive: true }) + fs.mkdirSync(libB, { recursive: true }) + fs.writeFileSync( + path.join(libA, 'package.json'), + JSON.stringify({ name: 'lib-a', dependencies: { 'lib-b': '*' } }), + ) + fs.writeFileSync( + path.join(libB, 'package.json'), + JSON.stringify({ name: 'lib-b' }), + ) + + const graph = analyzePackageDependencies(libA) + + const libANode = graph.nodes.get(libA) + expect(libANode).toBeDefined() + const wsDep = libANode?.dependencies.find((d) => d.kind === 'workspace') + expect(wsDep).toBeDefined() + expect(wsDep?.name).toBe('lib-b') + }) + + it('accepts a package.json file path directly', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ name: 'test-app' }), + ) + + const graph = analyzePackageDependencies(path.join(tmpDir, 'package.json')) + expect(graph.nodes.size).toBe(1) + }) + + it('handles circular workspace dependencies', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ name: 'root', workspaces: ['packages/*'] }), + ) + const libA = path.join(tmpDir, 'packages', 'a') + const libB = path.join(tmpDir, 'packages', 'b') + fs.mkdirSync(libA, { recursive: true }) + fs.mkdirSync(libB, { recursive: true }) + fs.writeFileSync( + path.join(libA, 'package.json'), + JSON.stringify({ name: 'a', dependencies: { b: '*' } }), + ) + fs.writeFileSync( + path.join(libB, 'package.json'), + JSON.stringify({ name: 'b', dependencies: { a: '*' } }), + ) + + const graph = analyzePackageDependencies(libA) + expect(graph.nodes.size).toBeGreaterThanOrEqual(2) }) }) diff --git a/src/analyzers/deps/pnpm-lock.spec.ts b/src/analyzers/deps/pnpm-lock.spec.ts index ff48a8e..70c1eb4 100644 --- a/src/analyzers/deps/pnpm-lock.spec.ts +++ b/src/analyzers/deps/pnpm-lock.spec.ts @@ -1,7 +1,6 @@ import fs from 'node:fs' import os from 'node:os' import path from 'node:path' - import { afterEach, describe, expect, it } from 'vitest' import { loadPnpmLockImporterResolutions } from './pnpm-lock.js' @@ -14,15 +13,30 @@ afterEach(() => { }) }) +function createTemporaryDirectory(): string { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'foresthouse-pnpm-')) + temporaryDirectories.push(directory) + return directory +} + +function writeLockfile(directory: string, content: string): void { + fs.writeFileSync(path.join(directory, 'pnpm-lock.yaml'), content) +} + describe('loadPnpmLockImporterResolutions', () => { + it('returns undefined when no pnpm-lock.yaml exists', () => { + const dir = createTemporaryDirectory() + + const result = loadPnpmLockImporterResolutions(dir) + + expect(result).toBeUndefined() + }) + it('loads importer resolutions from pnpm-lock.yaml', () => { - const directory = fs.mkdtempSync( - path.join(os.tmpdir(), 'foresthouse-pnpm-'), - ) - temporaryDirectories.push(directory) + const directory = createTemporaryDirectory() - fs.writeFileSync( - path.join(directory, 'pnpm-lock.yaml'), + writeLockfile( + directory, [ 'importers:', ' .:', @@ -40,4 +54,217 @@ describe('loadPnpmLockImporterResolutions', () => { version: '19.1.1', }) }) + + it('parses simple lockfile with one importer and dependencies', () => { + const dir = createTemporaryDirectory() + + writeLockfile( + dir, + [ + "lockfileVersion: '9.0'", + '', + 'importers:', + ' .:', + ' dependencies:', + ' react:', + ' specifier: ^18.0.0', + ' version: 18.2.0', + ' lodash:', + ' specifier: ^4.17.21', + ' version: 4.17.21', + ].join('\n'), + ) + + const importers = loadPnpmLockImporterResolutions(dir) + + if (!importers) throw new Error('Expected importers to be defined') + const rootImporter = importers.get('.') + if (!rootImporter) throw new Error('Expected root importer to be defined') + + expect(rootImporter.get('react')).toEqual({ + specifier: '^18.0.0', + version: '18.2.0', + }) + expect(rootImporter.get('lodash')).toEqual({ + specifier: '^4.17.21', + version: '4.17.21', + }) + }) + + it('handles quoted importer paths', () => { + const dir = createTemporaryDirectory() + + writeLockfile( + dir, + [ + 'importers:', + " '.':", + ' dependencies:', + ' react:', + ' specifier: ^18.0.0', + ' version: 18.2.0', + ].join('\n'), + ) + + const importers = loadPnpmLockImporterResolutions(dir) + + if (!importers) throw new Error('Expected importers to be defined') + expect(importers.get('.')?.get('react')).toEqual({ + specifier: '^18.0.0', + version: '18.2.0', + }) + }) + + it('parses version with peer suffix', () => { + const dir = createTemporaryDirectory() + + writeLockfile( + dir, + [ + 'importers:', + ' .:', + ' dependencies:', + ' react-dom:', + ' specifier: ^18.0.0', + ' version: 18.2.0(react@18.2.0)', + ].join('\n'), + ) + + const importers = loadPnpmLockImporterResolutions(dir) + + if (!importers) throw new Error('Expected importers to be defined') + const dep = importers.get('.')?.get('react-dom') + expect(dep?.version).toBe('18.2.0') + expect(dep?.peerSuffix).toBe('(react@18.2.0)') + }) + + it('handles optionalDependencies section', () => { + const dir = createTemporaryDirectory() + + writeLockfile( + dir, + [ + "lockfileVersion: '9.0'", + '', + 'importers:', + ' .:', + ' dependencies:', + ' react:', + ' specifier: ^18.0.0', + ' version: 18.2.0', + ' optionalDependencies:', + ' fsevents:', + ' specifier: ^2.3.0', + ' version: 2.3.3', + ].join('\n'), + ) + + const importers = loadPnpmLockImporterResolutions(dir) + + if (!importers) throw new Error('Expected importers to be defined') + const rootImporter = importers.get('.') + if (!rootImporter) throw new Error('Expected root importer') + + expect(rootImporter.get('react')).toEqual({ + specifier: '^18.0.0', + version: '18.2.0', + }) + expect(rootImporter.get('fsevents')).toEqual({ + specifier: '^2.3.0', + version: '2.3.3', + }) + }) + + it('skips devDependencies section', () => { + const dir = createTemporaryDirectory() + + writeLockfile( + dir, + [ + 'importers:', + ' .:', + ' dependencies:', + ' react:', + ' specifier: ^18.0.0', + ' version: 18.2.0', + ' devDependencies:', + ' typescript:', + ' specifier: ^5.0.0', + ' version: 5.3.3', + ].join('\n'), + ) + + const importers = loadPnpmLockImporterResolutions(dir) + + if (!importers) throw new Error('Expected importers to be defined') + const rootImporter = importers.get('.') + if (!rootImporter) throw new Error('Expected root importer') + + expect(rootImporter.get('react')).toBeDefined() + expect(rootImporter.has('typescript')).toBe(false) + }) + + it('handles nested dependency properties (specifier + version on separate lines)', () => { + const dir = createTemporaryDirectory() + + writeLockfile( + dir, + [ + 'importers:', + ' .:', + ' dependencies:', + ' axios:', + ' specifier: ^1.6.0', + ' version: 1.6.7', + ].join('\n'), + ) + + const importers = loadPnpmLockImporterResolutions(dir) + + if (!importers) throw new Error('Expected importers to be defined') + const dep = importers.get('.')?.get('axios') + expect(dep).toEqual({ + specifier: '^1.6.0', + version: '1.6.7', + }) + }) + + it('handles empty lockfile or lockfile with no importers section', () => { + const dir = createTemporaryDirectory() + + writeLockfile(dir, "lockfileVersion: '9.0'\n") + + const importers = loadPnpmLockImporterResolutions(dir) + + if (!importers) throw new Error('Expected importers to be defined') + expect(importers.size).toBe(0) + }) + + it('handles multiple importers', () => { + const dir = createTemporaryDirectory() + + writeLockfile( + dir, + [ + 'importers:', + ' .:', + ' dependencies:', + ' react:', + ' specifier: ^18.0.0', + ' version: 18.2.0', + ' packages/ui:', + ' dependencies:', + ' lodash:', + ' specifier: ^4.17.21', + ' version: 4.17.21', + ].join('\n'), + ) + + const importers = loadPnpmLockImporterResolutions(dir) + + if (!importers) throw new Error('Expected importers to be defined') + expect(importers.size).toBe(2) + expect(importers.get('.')?.get('react')?.version).toBe('18.2.0') + expect(importers.get('packages/ui')?.get('lodash')?.version).toBe('4.17.21') + }) }) diff --git a/src/analyzers/import/entry.spec.ts b/src/analyzers/import/entry.spec.ts index 2a7002d..30c8f1f 100644 --- a/src/analyzers/import/entry.spec.ts +++ b/src/analyzers/import/entry.spec.ts @@ -1,7 +1,6 @@ import fs from 'node:fs' import os from 'node:os' import path from 'node:path' - import { afterEach, describe, expect, it } from 'vitest' import { resolveExistingPath } from './entry.js' @@ -14,17 +13,97 @@ afterEach(() => { }) }) -describe('resolveExistingPath', () => { - it('resolves existing source entry files', () => { - const directory = fs.mkdtempSync( - path.join(os.tmpdir(), 'foresthouse-entry-path-'), - ) - temporaryDirectories.push(directory) +function createTemporaryDirectory(): string { + const directory = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'foresthouse-entry-path-')), + ) + temporaryDirectories.push(directory) + return directory +} +describe('resolveExistingPath', () => { + it('resolves existing .ts file and returns normalized path', () => { + const directory = createTemporaryDirectory() fs.writeFileSync(path.join(directory, 'main.ts'), 'export {}\n') expect(resolveExistingPath(directory, './main.ts')).toBe( path.join(directory, 'main.ts'), ) }) + + it('throws for non-existent file', () => { + const directory = createTemporaryDirectory() + + expect(() => resolveExistingPath(directory, './nonexistent.ts')).toThrow( + 'Entry file not found', + ) + }) + + it('throws for non-source file (.json)', () => { + const directory = createTemporaryDirectory() + fs.writeFileSync(path.join(directory, 'data.json'), '{}\n') + + expect(() => resolveExistingPath(directory, './data.json')).toThrow( + 'Entry file must be a JS/TS source file', + ) + }) + + it('throws for non-source file (.css)', () => { + const directory = createTemporaryDirectory() + fs.writeFileSync(path.join(directory, 'styles.css'), 'body {}\n') + + expect(() => resolveExistingPath(directory, './styles.css')).toThrow( + 'Entry file must be a JS/TS source file', + ) + }) + + it('throws for non-source file (.md)', () => { + const directory = createTemporaryDirectory() + fs.writeFileSync(path.join(directory, 'readme.md'), '# Title\n') + + expect(() => resolveExistingPath(directory, './readme.md')).toThrow( + 'Entry file must be a JS/TS source file', + ) + }) + + it('resolves relative paths correctly', () => { + const directory = createTemporaryDirectory() + const subdir = path.join(directory, 'src') + fs.mkdirSync(subdir) + fs.writeFileSync(path.join(subdir, 'index.ts'), 'export {}\n') + + expect(resolveExistingPath(directory, './src/index.ts')).toBe( + path.join(subdir, 'index.ts'), + ) + }) + + it('resolves .tsx files', () => { + const directory = createTemporaryDirectory() + fs.writeFileSync( + path.join(directory, 'app.tsx'), + 'export const App = () => null\n', + ) + + expect(resolveExistingPath(directory, './app.tsx')).toBe( + path.join(directory, 'app.tsx'), + ) + }) + + it('resolves .js files', () => { + const directory = createTemporaryDirectory() + fs.writeFileSync(path.join(directory, 'index.js'), 'module.exports = {}\n') + + expect(resolveExistingPath(directory, './index.js')).toBe( + path.join(directory, 'index.js'), + ) + }) + + it('resolves .mts files', () => { + const directory = createTemporaryDirectory() + fs.writeFileSync(path.join(directory, 'util.mts'), 'export {}\n') + + expect(resolveExistingPath(directory, './util.mts')).toBe( + path.join(directory, 'util.mts'), + ) + }) }) diff --git a/src/analyzers/import/graph.spec.ts b/src/analyzers/import/graph.spec.ts index 9b29b8d..7794591 100644 --- a/src/analyzers/import/graph.spec.ts +++ b/src/analyzers/import/graph.spec.ts @@ -1,18 +1,33 @@ import fs from 'node:fs' import os from 'node:os' import path from 'node:path' -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' + +import { buildDependencyGraph } from './graph.js' + +const temporaryDirectories: string[] = [] + +afterEach(() => { + temporaryDirectories.splice(0).forEach((directory) => { + fs.rmSync(directory, { recursive: true, force: true }) + }) +}) + +function createTemporaryDirectory(): string { + const directory = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'foresthouse-import-graph-')), + ) + temporaryDirectories.push(directory) + return directory +} describe('buildDependencyGraph', () => { - it('exports a callable graph builder', async () => { - const { buildDependencyGraph } = await import('./graph.js') + it('exports a callable graph builder', () => { expect(buildDependencyGraph).toBeTypeOf('function') }) - it('builds a graph without TypeScript program creation', async () => { - const fixtureDir = fs.mkdtempSync( - path.join(os.tmpdir(), 'foresthouse-import-graph-'), - ) + it('builds a graph without TypeScript program creation', () => { + const fixtureDir = createTemporaryDirectory() const entryPath = path.join(fixtureDir, 'entry.ts') fs.writeFileSync(entryPath, "import './dependency'\n") @@ -21,28 +36,166 @@ describe('buildDependencyGraph', () => { 'export const dependency = 1\n', ) - const { buildDependencyGraph } = await import('./graph.js') - - try { - const nodes = buildDependencyGraph( - [ - { - entryPath, - compilerOptions: {}, - }, - ], + const nodes = buildDependencyGraph( + [ { - cwd: fixtureDir, - expandWorkspaces: true, - projectOnly: false, - trackUnusedImports: false, + entryPath, + compilerOptions: {}, }, - ) + ], + { + cwd: fixtureDir, + expandWorkspaces: true, + projectOnly: false, + trackUnusedImports: false, + }, + ) + + expect(nodes.has(entryPath)).toBe(true) + expect(nodes.size).toBeGreaterThan(0) + }) + + it('builds a graph from a chain of imports', () => { + const dir = createTemporaryDirectory() + const entryPath = path.join(dir, 'entry.ts') + const middlePath = path.join(dir, 'middle.ts') + const leafPath = path.join(dir, 'leaf.ts') + + fs.writeFileSync( + entryPath, + "import { mid } from './middle.js'\nconsole.log(mid)\n", + ) + fs.writeFileSync( + middlePath, + "import { leaf } from './leaf.js'\nexport const mid = leaf\n", + ) + fs.writeFileSync(leafPath, 'export const leaf = 42\n') + + const nodes = buildDependencyGraph([{ entryPath, compilerOptions: {} }], { + cwd: dir, + expandWorkspaces: true, + projectOnly: false, + trackUnusedImports: false, + }) + + expect(nodes.size).toBe(3) + expect(nodes.has(entryPath)).toBe(true) + expect(nodes.has(middlePath)).toBe(true) + expect(nodes.has(leafPath)).toBe(true) + + const entryNode = nodes.get(entryPath) + if (!entryNode) throw new Error('Expected entry node to exist') + const entryDeps = entryNode.dependencies.filter((d) => d.kind === 'source') + expect(entryDeps).toHaveLength(1) + expect(entryDeps[0]?.target).toBe(middlePath) + + const middleNode = nodes.get(middlePath) + if (!middleNode) throw new Error('Expected middle node to exist') + const middleDeps = middleNode.dependencies.filter( + (d) => d.kind === 'source', + ) + expect(middleDeps).toHaveLength(1) + expect(middleDeps[0]?.target).toBe(leafPath) + }) + + it('handles circular imports', () => { + const dir = createTemporaryDirectory() + const aPath = path.join(dir, 'a.ts') + const bPath = path.join(dir, 'b.ts') + + fs.writeFileSync(aPath, "import { b } from './b.js'\nexport const a = b\n") + fs.writeFileSync(bPath, "import { a } from './a.js'\nexport const b = a\n") + + const nodes = buildDependencyGraph( + [{ entryPath: aPath, compilerOptions: {} }], + { + cwd: dir, + expandWorkspaces: true, + projectOnly: false, + trackUnusedImports: false, + }, + ) + + expect(nodes.size).toBe(2) + expect(nodes.has(aPath)).toBe(true) + expect(nodes.has(bPath)).toBe(true) + }) + + it('handles missing imports gracefully', () => { + const dir = createTemporaryDirectory() + const entryPath = path.join(dir, 'entry.ts') + + fs.writeFileSync( + entryPath, + "import { x } from './nonexistent.js'\nconsole.log(x)\n", + ) + + const nodes = buildDependencyGraph([{ entryPath, compilerOptions: {} }], { + cwd: dir, + expandWorkspaces: true, + projectOnly: false, + trackUnusedImports: false, + }) + + expect(nodes.size).toBe(1) + expect(nodes.has(entryPath)).toBe(true) + + const entryNode = nodes.get(entryPath) + if (!entryNode) throw new Error('Expected entry node to exist') + const missingDeps = entryNode.dependencies.filter( + (d) => d.kind === 'missing', + ) + expect(missingDeps.length).toBeGreaterThanOrEqual(1) + }) + + it('tracks unused imports when option is enabled', () => { + const dir = createTemporaryDirectory() + const entryPath = path.join(dir, 'entry.ts') + const helperPath = path.join(dir, 'helper.ts') + + fs.writeFileSync( + entryPath, + "import { unused } from './helper.js'\nexport const x = 1\n", + ) + fs.writeFileSync(helperPath, 'export const unused = 42\n') + + const nodes = buildDependencyGraph([{ entryPath, compilerOptions: {} }], { + cwd: dir, + expandWorkspaces: true, + projectOnly: false, + trackUnusedImports: true, + }) + + const entryNode = nodes.get(entryPath) + if (!entryNode) throw new Error('Expected entry node to exist') + const helperDep = entryNode.dependencies.find( + (d) => d.kind === 'source' && d.target === helperPath, + ) + expect(helperDep?.unused).toBe(true) + }) + + it('handles multiple entry configs', () => { + const dir = createTemporaryDirectory() + const aPath = path.join(dir, 'a.ts') + const bPath = path.join(dir, 'b.ts') + + fs.writeFileSync(aPath, 'export const a = 1\n') + fs.writeFileSync(bPath, 'export const b = 2\n') + + const nodes = buildDependencyGraph( + [ + { entryPath: aPath, compilerOptions: {} }, + { entryPath: bPath, compilerOptions: {} }, + ], + { + cwd: dir, + expandWorkspaces: true, + projectOnly: false, + trackUnusedImports: false, + }, + ) - expect(nodes.has(entryPath)).toBe(true) - expect(nodes.size).toBeGreaterThan(0) - } finally { - fs.rmSync(fixtureDir, { force: true, recursive: true }) - } + expect(nodes.has(aPath)).toBe(true) + expect(nodes.has(bPath)).toBe(true) }) }) diff --git a/src/analyzers/import/index.spec.ts b/src/analyzers/import/index.spec.ts index af16347..40a3ffe 100644 --- a/src/analyzers/import/index.spec.ts +++ b/src/analyzers/import/index.spec.ts @@ -1,9 +1,129 @@ -import { describe, expect, it } from 'vitest' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' -import { analyzeDependencies } from './index.js' +import { analyzeDependencies, analyzeDependenciesForEntries } from './index.js' + +const temporaryDirectories: string[] = [] + +afterEach(() => { + temporaryDirectories.splice(0).forEach((directory) => { + fs.rmSync(directory, { recursive: true, force: true }) + }) +}) + +function createTemporaryDirectory(): string { + const directory = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'foresthouse-import-index-')), + ) + temporaryDirectories.push(directory) + return directory +} + +function writeJson(filePath: string, value: unknown): void { + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`) +} + +function writeTsconfig(directory: string): void { + writeJson(path.join(directory, 'tsconfig.json'), { + compilerOptions: { + target: 'ES2020', + module: 'NodeNext', + moduleResolution: 'NodeNext', + }, + }) +} describe('analyzeDependencies', () => { it('exports a callable dependency analyzer', () => { expect(analyzeDependencies).toBeTypeOf('function') }) + + it('analyzes single entry file with one import', () => { + const dir = createTemporaryDirectory() + writeTsconfig(dir) + + fs.writeFileSync( + path.join(dir, 'entry.ts'), + "import { helper } from './helper.js'\nconsole.log(helper)\n", + ) + fs.writeFileSync(path.join(dir, 'helper.ts'), 'export const helper = 42\n') + + const graph = analyzeDependencies('./entry.ts', { cwd: dir }) + + expect(graph.cwd).toBe(dir) + expect(graph.entryId).toBe(path.join(dir, 'entry.ts')) + expect(graph.nodes.size).toBeGreaterThanOrEqual(2) + expect(graph.nodes.has(path.join(dir, 'entry.ts'))).toBe(true) + expect(graph.nodes.has(path.join(dir, 'helper.ts'))).toBe(true) + }) + + it('throws when entry file does not exist', () => { + const dir = createTemporaryDirectory() + writeTsconfig(dir) + + expect(() => analyzeDependencies('./nonexistent.ts', { cwd: dir })).toThrow( + 'Entry file not found', + ) + }) + + it('returns correct cwd and entryId', () => { + const dir = createTemporaryDirectory() + writeTsconfig(dir) + + fs.writeFileSync(path.join(dir, 'main.ts'), 'export const x = 1\n') + + const graph = analyzeDependencies('./main.ts', { cwd: dir }) + + expect(graph.cwd).toBe(dir) + expect(graph.entryId).toBe(path.join(dir, 'main.ts')) + }) + + it('handles configPath option', () => { + const dir = createTemporaryDirectory() + writeJson(path.join(dir, 'custom-tsconfig.json'), { + compilerOptions: { + target: 'ES2020', + module: 'NodeNext', + moduleResolution: 'NodeNext', + strict: true, + }, + }) + + fs.writeFileSync(path.join(dir, 'index.ts'), 'export const a = 1\n') + + const graph = analyzeDependencies('./index.ts', { + cwd: dir, + configPath: 'custom-tsconfig.json', + }) + + expect(graph.entryId).toBe(path.join(dir, 'index.ts')) + expect(graph.nodes.has(path.join(dir, 'index.ts'))).toBe(true) + }) +}) + +describe('analyzeDependenciesForEntries', () => { + it('throws when no entry files provided', () => { + expect(() => analyzeDependenciesForEntries([])).toThrow( + 'At least one entry file is required', + ) + }) + + it('populates entryIds array with multiple entries', () => { + const dir = createTemporaryDirectory() + writeTsconfig(dir) + + fs.writeFileSync(path.join(dir, 'a.ts'), 'export const a = 1\n') + fs.writeFileSync(path.join(dir, 'b.ts'), 'export const b = 2\n') + + const graph = analyzeDependenciesForEntries(['./a.ts', './b.ts'], { + cwd: dir, + }) + + expect(graph.entryIds).toHaveLength(2) + expect(graph.entryIds).toContain(path.join(dir, 'a.ts')) + expect(graph.entryIds).toContain(path.join(dir, 'b.ts')) + expect(graph.entryId).toBe(path.join(dir, 'a.ts')) + }) }) diff --git a/src/analyzers/import/resolver.spec.ts b/src/analyzers/import/resolver.spec.ts index 9fc862d..160d073 100644 --- a/src/analyzers/import/resolver.spec.ts +++ b/src/analyzers/import/resolver.spec.ts @@ -1,8 +1,49 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' import { ResolverFactory } from 'oxc-resolver' -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import { resolveDependency } from './resolver.js' +const temporaryDirectories: string[] = [] + +afterEach(() => { + temporaryDirectories.splice(0).forEach((directory) => { + fs.rmSync(directory, { recursive: true, force: true }) + }) +}) + +function createTemporaryDirectory(): string { + const directory = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'foresthouse-resolver-')), + ) + temporaryDirectories.push(directory) + return directory +} + +function createDefaultOptions(cwd: string) { + return { + cwd, + expandWorkspaces: true, + projectOnly: false, + getConfigForFile: () => ({ + compilerOptions: { + module: 199 as const, + moduleResolution: 99 as const, + target: 99 as const, + }, + }), + getResolverForFile: () => + new ResolverFactory({ + builtinModules: true, + conditionNames: ['import', 'require', 'default'], + extensions: ['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts'], + mainFields: ['types', 'module', 'main'], + }), + } +} + describe('resolveDependency', () => { it('classifies builtin modules without filesystem resolution', () => { expect( @@ -28,4 +69,133 @@ describe('resolveDependency', () => { target: 'node:path', }) }) + + it('resolves relative .ts imports as source kind', () => { + const dir = createTemporaryDirectory() + const entryFile = path.join(dir, 'entry.ts') + const helperFile = path.join(dir, 'helper.ts') + + fs.writeFileSync(entryFile, "import { x } from './helper.js'\n") + fs.writeFileSync(helperFile, 'export const x = 1\n') + + const result = resolveDependency( + { + specifier: './helper.js', + referenceKind: 'import', + isTypeOnly: false, + unused: false, + }, + entryFile, + createDefaultOptions(dir), + ) + + expect(result.kind).toBe('source') + expect(result.target).toBe(helperFile) + }) + + it('resolves bare specifiers as external kind', () => { + const dir = createTemporaryDirectory() + const entryFile = path.join(dir, 'entry.ts') + + fs.writeFileSync(entryFile, "import lodash from 'lodash'\n") + + const result = resolveDependency( + { + specifier: 'lodash', + referenceKind: 'import', + isTypeOnly: false, + unused: false, + }, + entryFile, + createDefaultOptions(dir), + ) + + expect(result.kind).toBe('external') + expect(result.target).toBe('lodash') + }) + + it('returns missing kind for non-existent relative imports', () => { + const dir = createTemporaryDirectory() + const entryFile = path.join(dir, 'entry.ts') + + fs.writeFileSync(entryFile, "import { x } from './missing.js'\n") + + const result = resolveDependency( + { + specifier: './missing.js', + referenceKind: 'import', + isTypeOnly: false, + unused: false, + }, + entryFile, + createDefaultOptions(dir), + ) + + expect(result.kind).toBe('missing') + }) + + it('classifies node:fs as builtin', () => { + const dir = createTemporaryDirectory() + const entryFile = path.join(dir, 'entry.ts') + fs.writeFileSync(entryFile, "import fs from 'node:fs'\n") + + const result = resolveDependency( + { + specifier: 'node:fs', + referenceKind: 'import', + isTypeOnly: false, + unused: false, + }, + entryFile, + createDefaultOptions(dir), + ) + + expect(result.kind).toBe('builtin') + expect(result.target).toBe('node:fs') + }) + + it('preserves referenceKind and isTypeOnly flags', () => { + const dir = createTemporaryDirectory() + const entryFile = path.join(dir, 'entry.ts') + fs.writeFileSync(entryFile, "import type { X } from 'node:path'\n") + + const result = resolveDependency( + { + specifier: 'node:path', + referenceKind: 'export', + isTypeOnly: true, + unused: true, + }, + entryFile, + createDefaultOptions(dir), + ) + + expect(result.referenceKind).toBe('export') + expect(result.isTypeOnly).toBe(true) + expect(result.unused).toBe(true) + expect(result.kind).toBe('builtin') + }) + + it('resolves .tsx file imports as source kind', () => { + const dir = createTemporaryDirectory() + const entryFile = path.join(dir, 'entry.ts') + const componentFile = path.join(dir, 'component.tsx') + + fs.writeFileSync(entryFile, "import { App } from './component.js'\n") + fs.writeFileSync(componentFile, 'export const App = () => null\n') + + const result = resolveDependency( + { + specifier: './component.js', + referenceKind: 'import', + isTypeOnly: false, + unused: false, + }, + entryFile, + createDefaultOptions(dir), + ) + + expect(result.kind).toBe('source') + expect(result.target).toBe(componentFile) + }) }) diff --git a/src/analyzers/react/bindings.spec.ts b/src/analyzers/react/bindings.spec.ts index 81c447e..c4f24a4 100644 --- a/src/analyzers/react/bindings.spec.ts +++ b/src/analyzers/react/bindings.spec.ts @@ -1,9 +1,284 @@ +import { parseSync } from 'oxc-parser' import { describe, expect, it } from 'vitest' - +import type { ImportBinding } from './bindings.js' import { collectImportsAndExports } from './bindings.js' +import type { PendingReactUsageNode } from './file.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/diff.spec.ts b/src/analyzers/react/diff.spec.ts new file mode 100644 index 0000000..9451655 --- /dev/null +++ b/src/analyzers/react/diff.spec.ts @@ -0,0 +1,153 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('node:child_process', () => ({ + execFileSync: vi.fn(), + execSync: vi.fn(), +})) + +vi.mock('./index.js', () => ({ + analyzeReactUsage: vi.fn(), +})) + +vi.mock('../../app/react-entry-files.js', () => ({ + resolveReactEntryFiles: vi.fn().mockReturnValue('/project/src/App.tsx'), +})) + +import { execFileSync } from 'node:child_process' +import type { ReactCliOptions } from '../../app/args.js' +import { analyzeReactUsageDiff } from './diff.js' +import { analyzeReactUsage } from './index.js' + +const mockedExec = vi.mocked(execFileSync) +const mockedAnalyze = vi.mocked(analyzeReactUsage) + +function createMockReactGraph(cwd: string, hasNodes = true) { + const nodes = hasNodes + ? new Map([ + [ + `${cwd}/src/App.tsx#component:App`, + { + id: `${cwd}/src/App.tsx#component:App`, + name: 'App', + kind: 'component' as const, + filePath: `${cwd}/src/App.tsx`, + exportNames: ['default'], + usages: [], + }, + ], + ]) + : new Map() + + return { + cwd, + entryId: `${cwd}/src/App.tsx`, + entryIds: [`${cwd}/src/App.tsx`], + nodes, + entries: [], + } +} + +function createBaseOptions(tmpDir: string): ReactCliOptions { + return { + command: 'react', + entryFile: 'src/App.tsx', + diff: 'HEAD~1', + cwd: tmpDir, + configPath: undefined, + expandWorkspaces: true, + projectOnly: false, + json: false, + filter: 'all', + nextjs: false, + includeBuiltins: false, + } +} + +describe('analyzeReactUsageDiff', () => { + let tmpDir: string + + beforeEach(() => { + tmpDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'rdiff-'))) + vi.clearAllMocks() + + fs.mkdirSync(path.join(tmpDir, 'src'), { recursive: true }) + fs.writeFileSync( + path.join(tmpDir, 'src', 'App.tsx'), + 'export function App() { return
}', + ) + }) + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }) + }) + + function setupGitMocks() { + mockedExec.mockImplementation((_cmd, args) => { + const gitArgs = args as string[] + const argsStr = gitArgs.join(' ') + + if ( + argsStr.includes('rev-parse') && + argsStr.includes('--show-toplevel') + ) { + return `${tmpDir}\n` as never + } + if (argsStr.includes('rev-parse') && argsStr.includes('--verify')) { + return 'abc123tree\n' as never + } + if (argsStr.includes('merge-base')) { + return 'mergebase123\n' as never + } + if (argsStr.includes('ls-tree')) { + return 'src/App.tsx\0' as never + } + if (argsStr.includes('cat-file')) { + return 'export function App() { return
}' as never + } + if (argsStr.includes('diff') && argsStr.includes('--name-only')) { + return 'src/App.tsx\0' as never + } + if (argsStr.includes('ls-files')) { + return '' as never + } + return '' as never + }) + } + + it('returns a diff graph for a simple ref', () => { + setupGitMocks() + mockedAnalyze.mockReturnValue(createMockReactGraph(tmpDir)) + + const result = analyzeReactUsageDiff(createBaseOptions(tmpDir)) + + expect(result.kind).toBe('react-usage-diff') + expect(result.repositoryRoot).toBe(tmpDir) + }) + + it('throws when not in a git repository', () => { + mockedExec.mockImplementation(() => { + throw new Error('not a git repository') + }) + + expect(() => analyzeReactUsageDiff(createBaseOptions(tmpDir))).toThrow( + 'Git diff mode requires a Git repository', + ) + }) + + it('throws on invalid ... range with empty parts', () => { + setupGitMocks() + + const opts = createBaseOptions(tmpDir) + expect(() => analyzeReactUsageDiff({ ...opts, diff: '...HEAD' })).toThrow() + }) + + it('throws on invalid .. range with empty parts', () => { + setupGitMocks() + + const opts = createBaseOptions(tmpDir) + expect(() => analyzeReactUsageDiff({ ...opts, diff: '..HEAD' })).toThrow() + }) +}) diff --git a/src/analyzers/react/entries.spec.ts b/src/analyzers/react/entries.spec.ts index 02e1540..f7edd81 100644 --- a/src/analyzers/react/entries.spec.ts +++ b/src/analyzers/react/entries.spec.ts @@ -1,10 +1,10 @@ +import { parseSync } from 'oxc-parser' import { describe, expect, it } from 'vitest' 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,7 +12,7 @@ describe('react entry helpers', () => { }) }) - it('resolves later lines and columns without rescanning semantics regressions', () => { + it('resolves later lines and columns correctly', () => { const sourceText = ['const one = 1;', 'const two = 2;', 'return two;'].join( '\n', ) @@ -49,4 +49,116 @@ 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) + const entry0 = entries[0] as (typeof entries)[0] + expect(entry0.referenceName).toBe('App') + expect(entry0.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) + const hookEntry = entries[0] as (typeof entries)[0] + expect(hookEntry.referenceName).toBe('useEffect') + expect(hookEntry.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++) { + const prev = entries[i - 1] as (typeof entries)[0] + const curr = entries[i] as (typeof entries)[0] + expect(prev.location.line).toBeLessThanOrEqual(curr.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..7081082 100644 --- a/src/analyzers/react/file.spec.ts +++ b/src/analyzers/react/file.spec.ts @@ -1,9 +1,138 @@ +import { parseSync } from 'oxc-parser' import { describe, expect, it } from 'vitest' 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).toBeDefined() + 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) + const firstEntry = result.entryUsages[0] as (typeof result.entryUsages)[0] + expect(firstEntry.referenceName).toBe('App') + expect(firstEntry.kind).toBe('component') + }) + + it('builds symbolsById from symbolsByName', () => { + const result = analyze('function App() { return
}') + const app = result.symbolsByName.get('App') + expect(app).toBeDefined() + expect(app !== undefined && result.symbolsById.get(app.id) === app).toBe( + true, + ) + }) + + 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).toBeDefined() + expect(app?.builtinReferences.has('div')).toBe(true) }) }) diff --git a/src/analyzers/react/index.spec.ts b/src/analyzers/react/index.spec.ts index 1fdd896..d8411f0 100644 --- a/src/analyzers/react/index.spec.ts +++ b/src/analyzers/react/index.spec.ts @@ -1,61 +1,158 @@ import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { afterEach, describe, expect, it, vi } from 'vitest' - -import { analyzeDependenciesForEntries } from '../import/index.js' -import { analyzeReactUsageDiff } from './diff.js' -import { analyzeReactFile } from './file.js' import { analyzeReactUsage } from './index.js' -vi.mock('../import/index.js', () => ({ - analyzeDependenciesForEntries: vi.fn(), -})) +function writeTsConfig(dir: string) { + fs.writeFileSync( + path.join(dir, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { + target: 'ES2020', + module: 'ESNext', + moduleResolution: 'bundler', + jsx: 'react-jsx', + }, + }), + ) +} -vi.mock('./file.js', () => ({ - analyzeReactFile: vi.fn(), -})) +describe('analyzeReactUsage', () => { + let tmpDir: string -afterEach(() => { - vi.clearAllMocks() - vi.restoreAllMocks() -}) + beforeEach(() => { + tmpDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'react-'))) + writeTsConfig(tmpDir) + }) -describe('analyzeReactUsage', () => { - it('exports a callable react analyzer', () => { - expect(analyzeReactUsage).toBeTypeOf('function') + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }) + }) + + it('analyzes a single component file', () => { + const appFile = path.join(tmpDir, 'App.tsx') + fs.writeFileSync( + appFile, + 'export function App() { return
Hello
}', + ) + + const graph = analyzeReactUsage(appFile, { cwd: tmpDir }) + + expect(graph.nodes.size).toBeGreaterThan(0) + const appNode = [...graph.nodes.values()].find( + (n) => n.name === 'App' && n.kind === 'component', + ) + expect(appNode).toBeDefined() }) - it('exports a callable react diff analyzer', () => { - expect(analyzeReactUsageDiff).toBeTypeOf('function') + it('produces usage edges between components', () => { + fs.writeFileSync( + path.join(tmpDir, 'Button.tsx'), + 'export function Button() { return }', + ) + fs.writeFileSync( + path.join(tmpDir, 'App.tsx'), + `import { Button } from './Button'\nexport function App() { return