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
39 changes: 39 additions & 0 deletions src/analyze.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { resolve } from 'node:path'
import { scanFiles } from './scanner.js'
import { parseImports } from './parser.js'
import { extractDependencies } from './extractor.js'
import { buildGraph } from './graph.js'
import type { DependencyGraph } from './graph.js'

export interface AnalyzeOptions {
/** Additional directories to ignore during file scanning */
ignore?: string[]
}

/**
* Full Phase-1 pipeline:
* 1. Scan for source files
* 2. Parse imports from each file
* 3. Resolve relative imports to absolute paths
* 4. Build the dependency graph
*/
export async function analyze(directory: string, options: AnalyzeOptions = {}): Promise<DependencyGraph> {
const rootDir = resolve(directory)

// 1. File Scanner
const files = await scanFiles(rootDir, { ignore: options.ignore })
if (files.length === 0) {
throw new Error(`No supported source files found in: ${rootDir}`)
}

// 2. AST Parser — parse all files in parallel
const allImports = (await Promise.all(files.map((f) => parseImports(f)))).flat()

// 3. Dependency Extractor — resolve relative specifiers
const deps = await extractDependencies(allImports)

// 4. Graph Builder
const graph = buildGraph(deps, files, rootDir)

return graph
}
23 changes: 20 additions & 3 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
#!/usr/bin/env node
import { Command } from 'commander'
import { VERSION } from './index.js'
import { analyze } from './analyze.js'
import { renderToTerminal } from './renderer.js'

const program = new Command()

Expand All @@ -9,9 +11,24 @@ program
.description('Analyze source code and generate interactive dependency graphs')
.version(VERSION)
.argument('[directory]', 'source directory to analyze', '.')
.option('--html', 'generate an interactive HTML report')
.action((directory: string, options: { html?: boolean }) => {
console.log(`codeviz: analysis not implemented yet (${directory}${options.html ? ', --html' : ''})`)
.option('--html', 'generate an interactive HTML report (coming in Phase 2)')
.option('--show-external', 'include external npm packages in the output')
.option('--ignore <dirs>', 'comma-separated list of directory names to ignore', '')
.action(async (directory: string, options: { html?: boolean; showExternal?: boolean; ignore?: string }) => {
if (options.html) {
console.warn('--html report generation is not yet implemented (Phase 2).')
}

const ignore = options.ignore ? options.ignore.split(',').map((d) => d.trim()).filter(Boolean) : []

try {
const graph = await analyze(directory, { ignore })
renderToTerminal(graph, { showExternal: options.showExternal })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.error(`\nError: ${message}\n`)
process.exit(1)
}
})

program.parse()
84 changes: 84 additions & 0 deletions src/extractor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { resolve, dirname, extname, basename } from 'node:path'
import { access } from 'node:fs/promises'
import type { ImportRecord } from './parser.js'

export interface Dependency {
/** Absolute path of the importing file */
from: string
/** Absolute path of the imported file (undefined for external/unresolved) */
to: string | undefined
/** Original specifier string */
specifier: string
/** Whether the target is an external package (not a relative path) */
external: boolean
}

// Extensions tried when a relative import has no extension
const RESOLVE_ORDER = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']

/**
* Attempts to resolve a relative specifier to an absolute file path.
* Handles:
* - exact paths ./foo.ts
* - extension-less ./foo → tries .ts, .tsx, …
* - directory index ./foo/ → ./foo/index.ts, …
*/
async function resolveRelative(specifier: string, fromFile: string): Promise<string | undefined> {
const base = resolve(dirname(fromFile), specifier)

// 1. Try the specifier as-is (already has an extension)
if (extname(base) !== '') {
if (await exists(base)) return base
}

// 2. Try appending each supported extension
for (const ext of RESOLVE_ORDER) {
const candidate = base + ext
if (await exists(candidate)) return candidate
}

// 3. Try as a directory with an index file
const name = basename(base)
// avoid double-appending if the specifier already ends in "index"
if (name !== 'index') {
for (const ext of RESOLVE_ORDER) {
const candidate = resolve(base, `index${ext}`)
if (await exists(candidate)) return candidate
}
}

return undefined
}

async function exists(p: string): Promise<boolean> {
try {
await access(p)
return true
} catch {
return false
}
}

/**
* Converts raw ImportRecords into resolved Dependency objects.
*/
export async function extractDependencies(imports: ImportRecord[]): Promise<Dependency[]> {
const deps: Dependency[] = []

for (const imp of imports) {
if (!imp.isRelative) {
deps.push({ from: imp.sourceFile, to: undefined, specifier: imp.moduleSpecifier, external: true })
continue
}

const resolvedTo = await resolveRelative(imp.moduleSpecifier, imp.sourceFile)
deps.push({
from: imp.sourceFile,
to: resolvedTo,
specifier: imp.moduleSpecifier,
external: false,
})
}

return deps
}
84 changes: 84 additions & 0 deletions src/graph.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { describe, it, expect } from 'vitest'
import { buildGraph } from './graph.js'
import type { Dependency } from './extractor.js'

const ROOT = '/project'

function dep(from: string, to: string | undefined, specifier: string, external = false): Dependency {
return { from: `${ROOT}/${from}`, to: to ? `${ROOT}/${to}` : undefined, specifier, external }
}

describe('buildGraph', () => {
it('creates nodes for all scanned files', () => {
const files = [`${ROOT}/a.ts`, `${ROOT}/b.ts`]
const graph = buildGraph([], files, ROOT)
expect(graph.nodes.has(`${ROOT}/a.ts`)).toBe(true)
expect(graph.nodes.has(`${ROOT}/b.ts`)).toBe(true)
})

it('creates edges for internal dependencies', () => {
const files = [`${ROOT}/a.ts`, `${ROOT}/b.ts`]
const deps = [dep('a.ts', 'b.ts', './b')]
const graph = buildGraph(deps, files, ROOT)
expect(graph.edges).toHaveLength(1)
expect(graph.edges[0]?.from).toBe(`${ROOT}/a.ts`)
expect(graph.edges[0]?.to).toBe(`${ROOT}/b.ts`)
})

it('marks files with no edges as isolated', () => {
const files = [`${ROOT}/a.ts`, `${ROOT}/b.ts`, `${ROOT}/orphan.ts`]
const deps = [dep('a.ts', 'b.ts', './b')]
const graph = buildGraph(deps, files, ROOT)
expect(graph.isolatedNodes).toContain(`${ROOT}/orphan.ts`)
expect(graph.isolatedNodes).not.toContain(`${ROOT}/a.ts`)
})

it('creates virtual nodes for external packages', () => {
const files = [`${ROOT}/a.ts`]
const externalDep: Dependency = { from: `${ROOT}/a.ts`, to: undefined, specifier: 'commander', external: true }
const graph = buildGraph([externalDep], files, ROOT)
expect(graph.nodes.has('external:commander')).toBe(true)
expect(graph.nodes.get('external:commander')?.external).toBe(true)
})

it('detects direct cycles', () => {
const files = [`${ROOT}/a.ts`, `${ROOT}/b.ts`]
const deps = [
dep('a.ts', 'b.ts', './b'),
dep('b.ts', 'a.ts', './a'),
]
const graph = buildGraph(deps, files, ROOT)
expect(graph.cycles.length).toBeGreaterThan(0)
})

it('detects transitive cycles', () => {
const files = [`${ROOT}/a.ts`, `${ROOT}/b.ts`, `${ROOT}/c.ts`]
const deps = [
dep('a.ts', 'b.ts', './b'),
dep('b.ts', 'c.ts', './c'),
dep('c.ts', 'a.ts', './a'),
]
const graph = buildGraph(deps, files, ROOT)
expect(graph.cycles.length).toBeGreaterThan(0)
const cycleFlat = graph.cycles.flat()
expect(cycleFlat.some((id) => id.endsWith('a.ts'))).toBe(true)
expect(cycleFlat.some((id) => id.endsWith('b.ts'))).toBe(true)
expect(cycleFlat.some((id) => id.endsWith('c.ts'))).toBe(true)
})

it('reports no cycles for acyclic graphs', () => {
const files = [`${ROOT}/a.ts`, `${ROOT}/b.ts`, `${ROOT}/c.ts`]
const deps = [
dep('a.ts', 'b.ts', './b'),
dep('b.ts', 'c.ts', './c'),
]
const graph = buildGraph(deps, files, ROOT)
expect(graph.cycles).toHaveLength(0)
})

it('uses relative labels for display', () => {
const files = [`${ROOT}/src/service.ts`]
const graph = buildGraph([], files, ROOT)
expect(graph.nodes.get(`${ROOT}/src/service.ts`)?.label).toBe('src/service.ts')
})
})
126 changes: 126 additions & 0 deletions src/graph.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { relative } from 'node:path'
import type { Dependency } from './extractor.js'

export interface GraphNode {
id: string // absolute path
label: string // path relative to rootDir
external: boolean
}

export interface GraphEdge {
from: string // node id
to: string // node id
specifier: string
}

export interface DependencyGraph {
nodes: Map<string, GraphNode>
edges: GraphEdge[]
/** Files that were scanned but have no imports or importers */
isolatedNodes: string[]
/** Circular dependency chains */
cycles: string[][]
}

/**
* Builds a dependency graph from a set of resolved dependencies.
*
* @param deps - Resolved dependencies from extractDependencies()
* @param allFiles - All scanned source files (ensures isolated files appear in the graph)
* @param rootDir - Project root used for computing display labels
*/
export function buildGraph(deps: Dependency[], allFiles: string[], rootDir: string): DependencyGraph {
const nodes = new Map<string, GraphNode>()
const edges: GraphEdge[] = []

// Add all scanned files as nodes first so isolated files are included
for (const file of allFiles) {
nodes.set(file, { id: file, label: relative(rootDir, file), external: false })
}

// Process edges
for (const dep of deps) {
// Ensure the source node exists (it should, but be defensive)
if (!nodes.has(dep.from)) {
nodes.set(dep.from, { id: dep.from, label: relative(rootDir, dep.from), external: false })
}

if (dep.external || dep.to === undefined) {
// External or unresolved — add a virtual node for the package name
const extId = `external:${dep.specifier}`
if (!nodes.has(extId)) {
nodes.set(extId, { id: extId, label: dep.specifier, external: true })
}
edges.push({ from: dep.from, to: extId, specifier: dep.specifier })
} else {
if (!nodes.has(dep.to)) {
nodes.set(dep.to, { id: dep.to, label: relative(rootDir, dep.to), external: false })
}
edges.push({ from: dep.from, to: dep.to, specifier: dep.specifier })
}
}

// Detect isolated nodes (no edges at all)
const connectedIds = new Set<string>()
for (const edge of edges) {
connectedIds.add(edge.from)
connectedIds.add(edge.to)
}
const isolatedNodes = [...nodes.keys()].filter(
(id) => !id.startsWith('external:') && !connectedIds.has(id),
)

// Detect cycles using DFS
const cycles = detectCycles(nodes, edges)

return { nodes, edges, isolatedNodes, cycles }
}

// ---------------------------------------------------------------------------
// Cycle detection
// ---------------------------------------------------------------------------

function detectCycles(nodes: Map<string, GraphNode>, edges: GraphEdge[]): string[][] {
// Build adjacency list (skip external virtual nodes)
const adj = new Map<string, string[]>()
for (const node of nodes.keys()) {
if (!node.startsWith('external:')) adj.set(node, [])
}
for (const edge of edges) {
if (edge.to.startsWith('external:')) continue
adj.get(edge.from)?.push(edge.to)
}

const visited = new Set<string>()
const inStack = new Set<string>()
const foundCycles: string[][] = []

function dfs(node: string, path: string[]): void {
if (inStack.has(node)) {
// Found a cycle — slice the path to just the cycle
const cycleStart = path.indexOf(node)
foundCycles.push([...path.slice(cycleStart), node])
return
}
if (visited.has(node)) return

visited.add(node)
inStack.add(node)
path.push(node)

for (const neighbor of adj.get(node) ?? []) {
dfs(neighbor, path)
}

path.pop()
inStack.delete(node)
}

for (const node of adj.keys()) {
if (!visited.has(node)) {
dfs(node, [])
}
}

return foundCycles
}
14 changes: 14 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,15 @@
export const VERSION = '0.1.0'

export { analyze } from './analyze.js'
export { scanFiles } from './scanner.js'
export { parseImports } from './parser.js'
export { extractDependencies } from './extractor.js'
export { buildGraph } from './graph.js'
export { renderToTerminal } from './renderer.js'

export type { AnalyzeOptions } from './analyze.js'
export type { ScanOptions } from './scanner.js'
export type { ImportRecord } from './parser.js'
export type { Dependency } from './extractor.js'
export type { DependencyGraph, GraphNode, GraphEdge } from './graph.js'
export type { RenderOptions } from './renderer.js'
Loading
Loading