Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
202 changes: 199 additions & 3 deletions src/analyzers/deps/diff.spec.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
172 changes: 169 additions & 3 deletions src/analyzers/deps/index.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading
Loading