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
61 changes: 49 additions & 12 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"packageManager": "npm@10.9.2",
"dependencies": {
"commander": "13.1.0",
"minimatch": "^10.2.5",
"open": "^10.1.0",
"typescript": "5.8.3"
},
Expand Down
25 changes: 17 additions & 8 deletions src/analyze.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,32 +8,41 @@ import type { DependencyGraph } from './graph.js'
export interface AnalyzeOptions {
/** Additional directories to ignore during file scanning */
ignore?: string[]
/**
* Extra glob patterns (relative to the scanned directory) that identify
* intentional entry points. Merged with the built-in defaults.
* Example: ['src/server.ts', 'workers/**']
*/
entryPatterns?: string[]
/**
* When true, the built-in default entry patterns are not used — only the
* patterns you provide in `entryPatterns` are active.
*/
replaceDefaultEntryPatterns?: boolean
}

/**
* Full Phase-1 pipeline:
* Full analysis pipeline:
* 1. Scan for source files
* 2. Parse imports from each file
* 3. Resolve relative imports to absolute paths
* 4. Build the dependency graph
* 4. Build and classify 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)
const graph = buildGraph(deps, files, rootDir, {
entryPatterns: options.entryPatterns,
replaceDefaultEntryPatterns: options.replaceDefaultEntryPatterns,
})

return graph
}
54 changes: 51 additions & 3 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { analyze } from './analyze.js'
import { renderToTerminal } from './renderer.js'
import { serveGraph } from './serve.js'
import { generateHtmlReport } from './html-report.js'
import { getDeadFiles, DEFAULT_ENTRY_PATTERNS } from './graph.js'

const program = new Command()

Expand All @@ -17,16 +18,59 @@ program
.option('--save', 'write the HTML report to disk instead of serving it')
.option('--out-dir <dir>', 'output directory when using --save', 'report')
.option('--port <number>', 'port for the live server (default: 4242)', '4242')
.option('--dead-code', 'report dead code (unreferenced + isolated files), exit 1 if any found')
.option(
'--entry <globs>',
'comma-separated glob patterns that mark extra entry points (merged with built-in defaults)',
'',
)
.option(
'--show-entry-patterns',
'print the active entry-point patterns and exit',
)
.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; save?: boolean; outDir?: string; port?: string; showExternal?: boolean; ignore?: string },
options: {
html?: boolean
save?: boolean
outDir?: string
port?: string
deadCode?: boolean
entry?: string
showEntryPatterns?: boolean
showExternal?: boolean
ignore?: string
},
) => {
const ignore = options.ignore ? options.ignore.split(',').map((d) => d.trim()).filter(Boolean) : []
// --show-entry-patterns: just print defaults and exit, no analysis needed
if (options.showEntryPatterns) {
console.log('\n Active entry-point patterns (built-in defaults):\n')
for (const p of DEFAULT_ENTRY_PATTERNS) {
console.log(` ${p}`)
}
if (options.entry) {
const extra = options.entry.split(',').map((s) => s.trim()).filter(Boolean)
if (extra.length > 0) {
console.log('\n Extra patterns (--entry):\n')
for (const p of extra) console.log(` ${p}`)
}
}
console.log('')
return
}

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

const entryPatterns = options.entry
? options.entry.split(',').map((s) => s.trim()).filter(Boolean)
: []

try {
const graph = await analyze(directory, { ignore })
const graph = await analyze(directory, { ignore, entryPatterns })

if (options.save) {
const { htmlFile, jsonFile } = await generateHtmlReport(graph, { outDir: options.outDir })
Expand All @@ -39,6 +83,10 @@ program
const url = await serveGraph(graph, { port })
console.log(`\n Serving graph at ${url}`)
console.log(` Press Ctrl+C to stop.\n`)
} else if (options.deadCode) {
renderToTerminal(graph, { deadCodeOnly: true })
const dead = getDeadFiles(graph)
if (dead.length > 0) process.exit(1)
} else {
renderToTerminal(graph, { showExternal: options.showExternal })
}
Expand Down
48 changes: 34 additions & 14 deletions src/extractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,33 +16,53 @@ export interface Dependency {
// Extensions tried when a relative import has no extension
const RESOLVE_ORDER = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']

// JS→TS extension remapping for TypeScript ESM projects that write `.js` imports
const JS_TO_TS: Record<string, string[]> = {
'.js': ['.ts', '.tsx'],
'.jsx': ['.tsx', '.jsx'],
'.mjs': ['.mts'],
'.cjs': ['.cts'],
}

/**
* 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, …
* - exact paths ./foo.ts
* - TS ESM remapped ./foo.js → ./foo.ts (TypeScript ESM convention)
* - 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)
const ext = extname(base)

// 1. Try the specifier as-is (already has an extension)
if (extname(base) !== '') {
if (ext !== '') {
// 1a. Try as-is (e.g. import './foo.ts' directly)
if (await exists(base)) return base

// 1b. TypeScript ESM remapping: ./foo.js → ./foo.ts
const tsAlternatives = JS_TO_TS[ext]
if (tsAlternatives) {
const stem = base.slice(0, -ext.length)
for (const tsExt of tsAlternatives) {
const candidate = stem + tsExt
if (await exists(candidate)) return candidate
}
}

return undefined
}

// 2. Try appending each supported extension
for (const ext of RESOLVE_ORDER) {
const candidate = base + ext
// 2. Extension-less: try appending each supported extension
for (const tryExt of RESOLVE_ORDER) {
const candidate = base + tryExt
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}`)
// 3. Directory index: ./foo → ./foo/index.ts, …
if (basename(base) !== 'index') {
for (const tryExt of RESOLVE_ORDER) {
const candidate = resolve(base, `index${tryExt}`)
if (await exists(candidate)) return candidate
}
}
Expand Down
Loading
Loading