From ba7df9c68064dedeb3c046a1bc3870f07984068c Mon Sep 17 00:00:00 2001 From: millareskenneth Date: Fri, 5 Jun 2026 22:07:25 +0800 Subject: [PATCH] feat(core): implement Phase 1 analysis pipeline with CLI integration - Add `analyze()` function orchestrating file scanning, parsing, extraction, and graph building - Implement file scanner with TypeScript/JavaScript detection and ignore list support - Add AST parser using swc for extracting import statements from source files - Implement dependency extractor with relative path resolution and extension handling - Add graph builder creating nodes, edges, and detecting isolated files and cycles - Implement terminal renderer with ASCII output and optional external package filtering - Update CLI to invoke analysis pipeline with --show-external and --ignore options - Add comprehensive test suites for parser, scanner, and graph builder - Update tsup config to generate both ESM and CommonJS outputs with proper entry points - Update package exports to include analyze function and type definitions --- src/analyze.ts | 39 +++++++++++++ src/cli.ts | 23 +++++++- src/extractor.ts | 84 +++++++++++++++++++++++++++ src/graph.test.ts | 84 +++++++++++++++++++++++++++ src/graph.ts | 126 ++++++++++++++++++++++++++++++++++++++++ src/index.ts | 14 +++++ src/parser.test.ts | 65 +++++++++++++++++++++ src/parser.ts | 75 ++++++++++++++++++++++++ src/renderer.ts | 137 ++++++++++++++++++++++++++++++++++++++++++++ src/scanner.test.ts | 74 ++++++++++++++++++++++++ src/scanner.ts | 73 +++++++++++++++++++++++ tsup.config.ts | 41 +++++++------ 12 files changed, 816 insertions(+), 19 deletions(-) create mode 100644 src/analyze.ts create mode 100644 src/extractor.ts create mode 100644 src/graph.test.ts create mode 100644 src/graph.ts create mode 100644 src/parser.test.ts create mode 100644 src/parser.ts create mode 100644 src/renderer.ts create mode 100644 src/scanner.test.ts create mode 100644 src/scanner.ts diff --git a/src/analyze.ts b/src/analyze.ts new file mode 100644 index 0000000..18aeb94 --- /dev/null +++ b/src/analyze.ts @@ -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 { + 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 +} diff --git a/src/cli.ts b/src/cli.ts index ebfe790..24b87c0 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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() @@ -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 ', '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() diff --git a/src/extractor.ts b/src/extractor.ts new file mode 100644 index 0000000..7ebaea5 --- /dev/null +++ b/src/extractor.ts @@ -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 { + 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 { + try { + await access(p) + return true + } catch { + return false + } +} + +/** + * Converts raw ImportRecords into resolved Dependency objects. + */ +export async function extractDependencies(imports: ImportRecord[]): Promise { + 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 +} diff --git a/src/graph.test.ts b/src/graph.test.ts new file mode 100644 index 0000000..925901e --- /dev/null +++ b/src/graph.test.ts @@ -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') + }) +}) diff --git a/src/graph.ts b/src/graph.ts new file mode 100644 index 0000000..d044880 --- /dev/null +++ b/src/graph.ts @@ -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 + 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() + 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() + 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, edges: GraphEdge[]): string[][] { + // Build adjacency list (skip external virtual nodes) + const adj = new Map() + 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() + const inStack = new Set() + 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 +} diff --git a/src/index.ts b/src/index.ts index faab3a3..0638c19 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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' diff --git a/src/parser.test.ts b/src/parser.test.ts new file mode 100644 index 0000000..969d6a7 --- /dev/null +++ b/src/parser.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest' +import { mkdtemp, writeFile, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { parseImports } from './parser.js' + +let tmpDir: string + +beforeAll(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'codeviz-parser-')) +}) + +afterAll(async () => { + await rm(tmpDir, { recursive: true, force: true }) +}) + +async function parse(filename: string, source: string) { + const file = join(tmpDir, filename) + await writeFile(file, source) + return parseImports(file) +} + +describe('parseImports', () => { + it('captures static import declarations', async () => { + const records = await parse('static.ts', ` + import { foo } from './foo' + import bar from '../bar/index.js' + import 'side-effect' + `) + const specs = records.map((r) => r.moduleSpecifier) + expect(specs).toContain('./foo') + expect(specs).toContain('../bar/index.js') + expect(specs).toContain('side-effect') + }) + + it('marks relative imports as isRelative=true', async () => { + const records = await parse('relative.ts', `import x from './x'`) + expect(records[0]?.isRelative).toBe(true) + }) + + it('marks external packages as isRelative=false', async () => { + const records = await parse('external.ts', `import x from 'commander'`) + expect(records[0]?.isRelative).toBe(false) + }) + + it('captures re-exports', async () => { + const records = await parse('reexport.ts', ` + export { foo } from './utils' + export * from './helpers' + `) + const specs = records.map((r) => r.moduleSpecifier) + expect(specs).toContain('./utils') + expect(specs).toContain('./helpers') + }) + + it('captures CommonJS require calls', async () => { + const records = await parse('cjs.js', `const fs = require('fs')`) + expect(records[0]?.moduleSpecifier).toBe('fs') + }) + + it('returns empty array for files with no imports', async () => { + const records = await parse('empty.ts', `const x = 1\nexport { x }`) + expect(records).toHaveLength(0) + }) +}) diff --git a/src/parser.ts b/src/parser.ts new file mode 100644 index 0000000..7852f02 --- /dev/null +++ b/src/parser.ts @@ -0,0 +1,75 @@ +import ts from 'typescript' +import { readFile } from 'node:fs/promises' + +export interface ImportRecord { + /** Absolute path of the file that contains the import */ + sourceFile: string + /** The raw module specifier (e.g. './auth', 'fs', '../utils/index.js') */ + moduleSpecifier: string + /** True when the specifier looks like a relative path */ + isRelative: boolean +} + +/** + * Parses a single source file and returns every static import/export it contains. + * Dynamic `import()` expressions are also captured. + */ +export async function parseImports(filePath: string): Promise { + const source = await readFile(filePath, 'utf8') + + const sourceFile = ts.createSourceFile( + filePath, + source, + ts.ScriptTarget.Latest, + /* setParentNodes */ true, + ts.ScriptKind.Unknown, // handles .ts, .tsx, .js, .jsx + ) + + const records: ImportRecord[] = [] + + function visit(node: ts.Node): void { + // import "mod" / import x from "mod" / import { x } from "mod" + if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) { + push(node.moduleSpecifier.text) + } + + // export { x } from "mod" / export * from "mod" + if (ts.isExportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) { + push(node.moduleSpecifier.text) + } + + // const x = require("mod") + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === 'require' && + node.arguments.length > 0 + ) { + const arg = node.arguments[0] + if (arg && ts.isStringLiteral(arg)) { + push(arg.text) + } + } + + // import("mod") + if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) { + const arg = node.arguments[0] + if (arg && ts.isStringLiteral(arg)) { + push(arg.text) + } + } + + ts.forEachChild(node, visit) + } + + function push(specifier: string): void { + records.push({ + sourceFile: filePath, + moduleSpecifier: specifier, + isRelative: specifier.startsWith('.'), + }) + } + + visit(sourceFile) + return records +} diff --git a/src/renderer.ts b/src/renderer.ts new file mode 100644 index 0000000..bc5389f --- /dev/null +++ b/src/renderer.ts @@ -0,0 +1,137 @@ +import type { DependencyGraph } from './graph.js' + +// --------------------------------------------------------------------------- +// Minimal ANSI helpers — no external dependency needed +// --------------------------------------------------------------------------- +const c = { + reset: '\x1b[0m', + bold: '\x1b[1m', + dim: '\x1b[2m', + cyan: '\x1b[36m', + yellow: '\x1b[33m', + red: '\x1b[31m', + green: '\x1b[32m', + gray: '\x1b[90m', +} + +function bold(s: string) { return `${c.bold}${s}${c.reset}` } +function dim(s: string) { return `${c.dim}${s}${c.reset}` } +function cyan(s: string) { return `${c.cyan}${s}${c.reset}` } +function yellow(s: string) { return `${c.yellow}${s}${c.reset}` } +function red(s: string) { return `${c.red}${s}${c.reset}` } +function green(s: string) { return `${c.green}${s}${c.reset}` } +function gray(s: string) { return `${c.gray}${s}${c.reset}` } + +function hr(char = '─', width = 60) { + return gray(char.repeat(width)) +} + +// --------------------------------------------------------------------------- +// Public render function +// --------------------------------------------------------------------------- + +export interface RenderOptions { + /** Whether to include external (npm) packages in the dependency list */ + showExternal?: boolean + /** Max number of edges to print per file to keep output readable */ + maxEdgesPerFile?: number +} + +export function renderToTerminal(graph: DependencyGraph, options: RenderOptions = {}): void { + const { showExternal = false, maxEdgesPerFile = 20 } = options + + const internalNodes = [...graph.nodes.values()].filter((n) => !n.external) + const internalEdges = graph.edges.filter((e) => !e.to.startsWith('external:')) + + // ── Header ────────────────────────────────────────────────────────────── + console.log('') + console.log(hr('═')) + console.log(bold(' codeviz — Dependency Analysis')) + console.log(hr('═')) + + // ── Summary ───────────────────────────────────────────────────────────── + console.log('') + console.log(bold(' Summary')) + console.log(hr()) + console.log(` ${cyan('Files scanned')} ${internalNodes.length}`) + console.log(` ${cyan('Dependencies')} ${internalEdges.length} internal`) + console.log(` ${cyan('Isolated files')} ${graph.isolatedNodes.length}`) + console.log(` ${cyan('Circular deps')} ${graph.cycles.length === 0 ? green('none') : red(String(graph.cycles.length))}`) + + // ── Dependency tree ────────────────────────────────────────────────────── + console.log('') + console.log(bold(' Dependency Tree')) + console.log(hr()) + + // Build a map: file → files it imports (internal only) + const importedBy = new Map() + for (const edge of internalEdges) { + const list = importedBy.get(edge.from) ?? [] + list.push(edge.to) + importedBy.set(edge.from, list) + } + + // Print each internal file that has at least one import + let printed = 0 + for (const [from, tos] of importedBy) { + const fromLabel = graph.nodes.get(from)?.label ?? from + console.log(` ${cyan(fromLabel)}`) + + const visible = tos.slice(0, maxEdgesPerFile) + for (const to of visible) { + const toLabel = graph.nodes.get(to)?.label ?? to + console.log(` ${gray('└─')} ${toLabel}`) + } + if (tos.length > maxEdgesPerFile) { + console.log(` ${dim(`… and ${tos.length - maxEdgesPerFile} more`)}`) + } + printed++ + } + + if (printed === 0) { + console.log(dim(' No internal dependencies found.')) + } + + // ── External packages ──────────────────────────────────────────────────── + if (showExternal) { + const externalEdges = graph.edges.filter((e) => e.to.startsWith('external:')) + if (externalEdges.length > 0) { + const packageSet = new Set(externalEdges.map((e) => e.specifier.split('/')[0] ?? e.specifier)) + console.log('') + console.log(bold(' External Packages')) + console.log(hr()) + for (const pkg of [...packageSet].sort()) { + console.log(` ${gray('·')} ${pkg}`) + } + } + } + + // ── Isolated files ─────────────────────────────────────────────────────── + if (graph.isolatedNodes.length > 0) { + console.log('') + console.log(bold(' Isolated Files') + dim(' (no imports, not imported)')) + console.log(hr()) + for (const id of graph.isolatedNodes) { + const label = graph.nodes.get(id)?.label ?? id + console.log(` ${yellow('⚠')} ${label}`) + } + } + + // ── Circular dependencies ──────────────────────────────────────────────── + if (graph.cycles.length > 0) { + console.log('') + console.log(bold(' Circular Dependencies')) + console.log(hr()) + for (const cycle of graph.cycles) { + const labels = cycle.map((id) => graph.nodes.get(id)?.label ?? id) + console.log(` ${red('⊗')} ${labels.join(gray(' → '))}`) + } + } else { + console.log('') + console.log(` ${green('✓')} No circular dependencies detected.`) + } + + console.log('') + console.log(hr('═')) + console.log('') +} diff --git a/src/scanner.test.ts b/src/scanner.test.ts new file mode 100644 index 0000000..becb7ae --- /dev/null +++ b/src/scanner.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest' +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { scanFiles } from './scanner.js' + +let tmpDir: string + +beforeAll(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'codeviz-scanner-')) + // Supported files + await writeFile(join(tmpDir, 'a.ts'), '') + await writeFile(join(tmpDir, 'b.tsx'), '') + await writeFile(join(tmpDir, 'c.js'), '') + await writeFile(join(tmpDir, 'c.mjs'), '') + // Unsupported + await writeFile(join(tmpDir, 'README.md'), '') + await writeFile(join(tmpDir, 'data.json'), '') + // Nested + await mkdir(join(tmpDir, 'src')) + await writeFile(join(tmpDir, 'src', 'index.ts'), '') + // Should be ignored by default + await mkdir(join(tmpDir, 'node_modules')) + await writeFile(join(tmpDir, 'node_modules', 'pkg.ts'), '') + await mkdir(join(tmpDir, 'dist')) + await writeFile(join(tmpDir, 'dist', 'out.js'), '') +}) + +afterAll(async () => { + await rm(tmpDir, { recursive: true, force: true }) +}) + +describe('scanFiles', () => { + it('collects supported source files', async () => { + const files = await scanFiles(tmpDir) + const names = files.map((f) => f.replace(tmpDir + '/', '')) + expect(names).toContain('a.ts') + expect(names).toContain('b.tsx') + expect(names).toContain('c.js') + expect(names).toContain('c.mjs') + expect(names).toContain('src/index.ts') + }) + + it('ignores node_modules and dist by default', async () => { + const files = await scanFiles(tmpDir) + const names = files.map((f) => f.replace(tmpDir + '/', '')) + expect(names).not.toContain('node_modules/pkg.ts') + expect(names).not.toContain('dist/out.js') + }) + + it('excludes unsupported extensions', async () => { + const files = await scanFiles(tmpDir) + const names = files.map((f) => f.replace(tmpDir + '/', '')) + expect(names).not.toContain('README.md') + expect(names).not.toContain('data.json') + }) + + it('returns sorted paths', async () => { + const files = await scanFiles(tmpDir) + const sorted = [...files].sort() + expect(files).toEqual(sorted) + }) + + it('respects custom ignore list', async () => { + await mkdir(join(tmpDir, 'custom-ignore')) + await writeFile(join(tmpDir, 'custom-ignore', 'x.ts'), '') + const files = await scanFiles(tmpDir, { ignore: ['custom-ignore'] }) + expect(files.some((f) => f.includes('custom-ignore'))).toBe(false) + }) + + it('throws when path is not a directory', async () => { + await expect(scanFiles(join(tmpDir, 'a.ts'))).rejects.toThrow('Expected a directory') + }) +}) diff --git a/src/scanner.ts b/src/scanner.ts new file mode 100644 index 0000000..c5f8bad --- /dev/null +++ b/src/scanner.ts @@ -0,0 +1,73 @@ +import { readdir, stat } from 'node:fs/promises' +import { join, extname, relative } from 'node:path' + +const SUPPORTED_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']) + +const DEFAULT_IGNORE = new Set([ + 'node_modules', + 'dist', + 'build', + 'coverage', + '.git', + '.next', + '.nuxt', + '.turbo', + 'out', +]) + +export interface ScanOptions { + /** Directories to skip. Merged with the default ignore list. */ + ignore?: string[] +} + +/** + * Recursively collects all supported source files under `rootDir`. + * Returns absolute paths sorted alphabetically. + */ +export async function scanFiles(rootDir: string, options: ScanOptions = {}): Promise { + const extraIgnore = new Set(options.ignore ?? []) + const results: string[] = [] + + async function walk(dir: string): Promise { + let entries: import('node:fs').Dirent[] + + try { + entries = await readdir(dir, { withFileTypes: true, encoding: 'utf8' }) + } catch { + // Directory is unreadable — skip silently + return + } + + for (const entry of entries) { + const fullPath = join(dir, entry.name) + + if (entry.isDirectory()) { + if (!DEFAULT_IGNORE.has(entry.name) && !extraIgnore.has(entry.name)) { + await walk(fullPath) + } + continue + } + + if (entry.isFile() && SUPPORTED_EXTENSIONS.has(extname(entry.name))) { + results.push(fullPath) + } + } + } + + // Make sure rootDir actually exists and is a directory + const info = await stat(rootDir) + if (!info.isDirectory()) { + throw new Error(`Expected a directory but got: ${rootDir}`) + } + + await walk(rootDir) + results.sort() + return results +} + +/** + * Returns paths relative to `rootDir` for nicer display. + */ +export function toRelativePaths(files: string[], rootDir: string): string[] { + return files.map((f) => relative(rootDir, f)) +} diff --git a/tsup.config.ts b/tsup.config.ts index e7888b1..426e183 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,20 +1,29 @@ import { defineConfig } from 'tsup' -export default defineConfig({ - entry: { - cli: 'src/cli.ts', - index: 'src/index.ts', +export default defineConfig([ + // CLI entry — shebang injected via banner + { + entry: { cli: 'src/cli.ts' }, + format: ['esm'], + target: 'node18', + outDir: 'dist', + clean: false, + dts: false, + sourcemap: true, + splitting: false, + shims: false, + banner: { js: '#!/usr/bin/env node' }, }, - format: ['esm'], - target: 'node18', - outDir: 'dist', - clean: true, - dts: true, - sourcemap: true, - splitting: false, - shims: false, - banner: { - // Makes the CLI entry executable when run via npx - js: '#!/usr/bin/env node', + // Library entry — no shebang + { + entry: { index: 'src/index.ts' }, + format: ['esm'], + target: 'node18', + outDir: 'dist', + clean: true, + dts: true, + sourcemap: true, + splitting: false, + shims: false, }, -}) +])