diff --git a/src/cli/index.ts b/src/cli/index.ts index 1cf0d31..7176438 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,3 +1,5 @@ +// src/cli/index.ts + import { analyzeProject } from "../core"; import { log } from "../utils"; import { disableLogPrefix, enableLogPrefix } from "../utils/logger"; @@ -6,15 +8,15 @@ import { logUnusedExports, logUnusedFiles, logUnusedLocals, logVerbose, summary import { showHelp } from "./help"; import { parseArgs } from "./parser"; -import { statSync } from "fs"; -import path from "path"; +import { statSync } from "node:fs"; +import path from "node:path"; export function isNodeError(error: unknown): error is NodeJS.ErrnoException { return ( typeof error === "object" && error !== null && "code" in error && - typeof error?.code === "string" + typeof (error as any)?.code === "string" ); } @@ -103,6 +105,7 @@ function resolveAndValidateCwd(rawCwd?: string): string { const silent = args.silent === true; const summaryOnly = args["summary-only"] === true; + const typesMode = args.types === true; // --version if (args.version) { @@ -130,8 +133,29 @@ function resolveAndValidateCwd(rawCwd?: string): string { process.exit(1); } - // Run full-project analysis - const results = analyzeProject(projectRoot, args); + // Run full-project analysis (existing behavior) + const results: any = analyzeProject(projectRoot, args); + + // Optional: run types analysis ONLY when requested + if (typesMode) { + const { walkFiles } = await import("../core/fileSystem/walkFiles"); + + const { loadTSConfig } = await import("../core/tsconfig/tsconfigLoader"); + + const { buildTypeUsageGraph } = await import("../core/type/buildTypeUsageGraph"); + + const ignorePatterns = (args.ignore || []).map(String); + const files = walkFiles(projectRoot, ignorePatterns); + const tsConfig = loadTSConfig(projectRoot); + + const typeGraph = buildTypeUsageGraph(files, tsConfig); + + results.unusedExportedTypes = typeGraph.unusedExportedTypes; + results.unusedLocalTypes = typeGraph.unusedLocalTypes; + + // Attach into verbose graphs too + if (results.graphs) results.graphs.types = typeGraph; + } // JSON output if (args.json) { @@ -147,6 +171,14 @@ function resolveAndValidateCwd(rawCwd?: string): string { if (args.exports) logUnusedExports(results.unusedExports); if (args.files) logUnusedFiles(results.unusedFiles); if (args.locals || args.identifiers) logUnusedLocals(results.unusedIdentifiers); + + if (typesMode) { + const { logUnusedExportedTypes, logUnusedLocalTypes } = await import("./typesFormat.js"); + + logUnusedExportedTypes(results.unusedExportedTypes ?? []); + logUnusedLocalTypes(results.unusedLocalTypes ?? []); + } + if (args.verbose) logVerbose(results.graphs); } } diff --git a/src/cli/internalMode.ts b/src/cli/internalMode.ts index 60ff8c5..1d8100e 100644 --- a/src/cli/internalMode.ts +++ b/src/cli/internalMode.ts @@ -1,4 +1,4 @@ -import { readFileSync } from "fs"; +import { readFileSync } from "node:fs"; import { analyzeLocalUsage } from "../core/locals/analyzeLocalUsage"; import { log } from "../utils"; diff --git a/src/cli/typesFormat.ts b/src/cli/typesFormat.ts new file mode 100644 index 0000000..358e371 --- /dev/null +++ b/src/cli/typesFormat.ts @@ -0,0 +1,56 @@ +// src/cli/typesFormat.ts + +import { log } from "../utils"; +import { colors } from "./colors"; +import { heading } from "./format"; + +export function logUnusedExportedTypes(unused: { file: string; name: string }[]): void { + log(heading("Unused Exported Types")); + + if (unused.length === 0) { + log(`${colors.dim}No unused exported types found!${colors.reset}\n`); + return; + } + + const byFile: Record = {}; + + for (const { file, name } of unused) { + if (!byFile[file]) byFile[file] = []; + + byFile[file].push(name); + } + + for (const file of Object.keys(byFile)) { + log(`${colors.bold}${file}${colors.reset}`); + + for (const name of byFile[file]) log(` • ${name}`); + + log(""); + } +} + +export function logUnusedLocalTypes(unused: { file: string; name: string }[]): void { + log(heading("Unused Local Types")); + + if (unused.length === 0) { + log(`${colors.dim}No unused local types found!${colors.reset}\n`); + + return; + } + + const byFile: Record = {}; + + for (const { file, name } of unused) { + if (!byFile[file]) byFile[file] = []; + + byFile[file].push(name); + } + + for (const file of Object.keys(byFile)) { + log(`${colors.bold}${file}${colors.reset}`); + + for (const name of byFile[file]) log(` • ${name}`); + + log(""); + } +} diff --git a/src/core/entrypoints/detectEntryPoints.ts b/src/core/entrypoints/detectEntryPoints.ts index a78382c..14d4e3e 100644 --- a/src/core/entrypoints/detectEntryPoints.ts +++ b/src/core/entrypoints/detectEntryPoints.ts @@ -7,8 +7,8 @@ * No filesystem traversal or AST parsing happens here. */ -import fs from "fs"; -import path from "path"; +import fs from "node:fs"; +import path from "node:path"; import { normalizeFilePath } from "../fileSystem/normalizePath"; export interface EntryPointInfo { diff --git a/src/core/exports/resolveExportGraph.ts b/src/core/exports/resolveExportGraph.ts index 58d46d5..68c3c7b 100644 --- a/src/core/exports/resolveExportGraph.ts +++ b/src/core/exports/resolveExportGraph.ts @@ -1,4 +1,4 @@ -import path from "path"; +import path from "node:path"; import { intern } from "../../utils"; import type { ExportInfo } from "./scanExports"; diff --git a/src/core/fileSystem/normalizePath.ts b/src/core/fileSystem/normalizePath.ts index b8df853..664e70d 100644 --- a/src/core/fileSystem/normalizePath.ts +++ b/src/core/fileSystem/normalizePath.ts @@ -1,4 +1,4 @@ -import { resolve } from "path"; +import { resolve } from "node:path"; // Memoize normalized paths, since this is called frequently const normalizedFileCache = new Map(); diff --git a/src/core/fileSystem/walkFiles.ts b/src/core/fileSystem/walkFiles.ts index bee1bc1..d80a407 100644 --- a/src/core/fileSystem/walkFiles.ts +++ b/src/core/fileSystem/walkFiles.ts @@ -1,5 +1,5 @@ -import fs from "fs"; -import path from "path"; +import fs from "node:fs"; +import path from "node:path"; import { normalizeFilePath } from "./normalizePath"; // Precompiled extension match diff --git a/src/core/imports/buildImportGraph.ts b/src/core/imports/buildImportGraph.ts index 63bd18c..864bba1 100644 --- a/src/core/imports/buildImportGraph.ts +++ b/src/core/imports/buildImportGraph.ts @@ -11,7 +11,7 @@ * This graph is later consumed by the export-usage analyzer. */ -import path from "path"; +import path from "node:path"; import ts from "typescript"; import { normalizeFilePath } from "../fileSystem/normalizePath"; import { TSConfigInfo } from "../tsconfig/tsconfigLoader"; diff --git a/src/core/tsconfig/tsconfigLoader.ts b/src/core/tsconfig/tsconfigLoader.ts index f1503ac..7822b50 100644 --- a/src/core/tsconfig/tsconfigLoader.ts +++ b/src/core/tsconfig/tsconfigLoader.ts @@ -8,7 +8,7 @@ * TypeScript compiler API. No normalization or path resolution is done here. */ -import path from "path"; +import path from "node:path"; import ts from "typescript"; export interface TSConfigInfo { diff --git a/src/core/type/analyzeTypeUsage.ts b/src/core/type/analyzeTypeUsage.ts new file mode 100644 index 0000000..5d76322 --- /dev/null +++ b/src/core/type/analyzeTypeUsage.ts @@ -0,0 +1,181 @@ +import ts from "typescript"; +import { intern } from "../../utils"; + +/** + * Type-usage results for a single file. + * Narrow, syntax-based, no type-checker required. + */ +export interface TypeUsageResult { + declaredTypes: Set; + exportedTypes: Set; + referencedTypes: Set; + qualifiedTypeRefs: Map>; +} + +function hasExportModifier(node: ts.Node): boolean { + const modifiers = (node as any).modifiers as ts.NodeArray | undefined; + + return !!modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword); +} + +/** + * Collect type declarations and references in type positions. + * This does NOT do cross-file resolution - per-file only. + */ +export function analyzeTypeUsage(filePath: string, sourceText: string): TypeUsageResult { + const ast = ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.ESNext, true); + + const declaredTypes = new Set(); + const exportedTypes = new Set(); + const referencedTypes = new Set(); + const qualifiedTypeRefs = new Map>(); + + function addDeclared(name: string, exported: boolean) { + const type = intern(name); + + declaredTypes.add(type); + + if (exported) exportedTypes.add(type); + } + + // Collecting declarations for types and interfaces + function collectDeclarations(node: ts.Node): void { + if (ts.isInterfaceDeclaration(node)) { + addDeclared(node.name.text, hasExportModifier(node)); + } else if (ts.isTypeAliasDeclaration(node)) { + addDeclared(node.name.text, hasExportModifier(node)); + } + + ts.forEachChild(node, collectDeclarations); + } + + // Collect type references + function collectTypeRefsFromTypeNode(typeNode: ts.TypeNode): void { + if (ts.isTypeReferenceNode(typeNode)) { + collectEntityName(typeNode.typeName); + + for (const arg of typeNode.typeArguments ?? []) collectTypeRefsFromTypeNode(arg); + + return; + } + + ts.forEachChild(typeNode, (child) => { + if (ts.isTypeNode(child)) collectTypeRefsFromTypeNode(child); + else + ts.forEachChild(child, (g) => { + if (ts.isTypeNode(g)) collectTypeRefsFromTypeNode(g); + }); + }); + } + + function collectEntityName(name: ts.EntityName) { + if (ts.isIdentifier(name)) { + referencedTypes.add(intern(name.text)); + + return; + } + + // QualifiedName: left.right (e.g. T.Foo) + if (ts.isIdentifier(name.left)) { + const ns = intern(name.left.text); + const member = intern(name.right.text); + + let set = qualifiedTypeRefs.get(ns); + + if (!set) qualifiedTypeRefs.set(ns, (set = new Set())); + + set.add(member); + } else { + collectEntityName(name.left); + + referencedTypes.add(intern(name.right.text)); + } + } + + function visitForTypePositions(node: ts.Node): void { + // Variable declarations: let x: Foo + if (ts.isVariableDeclaration(node) && node.type) { + collectTypeRefsFromTypeNode(node.type); + } + + // Function/method params and return types + if (ts.isParameter(node) && node.type) { + collectTypeRefsFromTypeNode(node.type); + } + if ( + (ts.isFunctionDeclaration(node) || + ts.isMethodDeclaration(node) || + ts.isArrowFunction(node)) && + node.type + ) { + collectTypeRefsFromTypeNode(node.type); + } + + // Type aliases: type X = Foo + if (ts.isTypeAliasDeclaration(node)) { + collectTypeRefsFromTypeNode(node.type); + } + + // Property and method signatures in interfaces + if (ts.isPropertySignature(node) && node.type) { + collectTypeRefsFromTypeNode(node.type); + } + + if (ts.isMethodSignature(node) && node.type) { + collectTypeRefsFromTypeNode(node.type); + } + + // Heritage clauses (extends && implements) + if ((ts.isClassDeclaration(node) || ts.isInterfaceDeclaration(node)) && node.heritageClauses) { + for (const heritageClause of node.heritageClauses) { + for (const t of heritageClause.types) { + const expression = t.expression; + + if (ts.isIdentifier(expression)) referencedTypes.add(intern(expression.text)); + + if (ts.isPropertyAccessExpression(expression) && ts.isIdentifier(expression.name)) { + referencedTypes.add(intern(expression.name.text)); + } + + for (const arg of t.typeArguments ?? []) collectTypeRefsFromTypeNode(arg); + } + } + } + + // Import declarations + if (ts.isImportDeclaration(node)) { + const clause = node.importClause; + + if (!clause) return; + + if (clause.isTypeOnly) { + if (clause.name) referencedTypes.add(intern(clause.name.text)); + + const namedBindings = clause.namedBindings; + + if (namedBindings && ts.isNamedImports(namedBindings)) { + for (const el of namedBindings.elements) { + referencedTypes.add(intern(el.name.text)); + } + } + } else { + const namedBindings = clause.namedBindings; + + if (namedBindings && ts.isNamedImports(namedBindings)) { + for (const element of namedBindings.elements) { + if (element.isTypeOnly === true) { + referencedTypes.add(intern(element.name.text)); + } + } + } + } + } + + ts.forEachChild(node, visitForTypePositions); + } + + collectDeclarations(ast); + visitForTypePositions(ast); + + return { declaredTypes, exportedTypes, referencedTypes, qualifiedTypeRefs }; +} diff --git a/src/core/type/buildTypeUsageGraph.ts b/src/core/type/buildTypeUsageGraph.ts new file mode 100644 index 0000000..1c15c25 --- /dev/null +++ b/src/core/type/buildTypeUsageGraph.ts @@ -0,0 +1,368 @@ +import path from "node:path"; +import ts from "typescript"; + +import { intern } from "../../utils"; +import { normalizeFilePath } from "../fileSystem/normalizePath"; +import type { TSConfigInfo } from "../tsconfig/tsconfigLoader"; + +import { analyzeTypeUsage } from "./analyzeTypeUsage"; +import { resolveTypeExportGraph, type ResolvedTypeExportEntry } from "./resolveTypeExportGraph"; +import { scanTypeExports } from "./scanTypeExports"; + +export interface TypeUsageGraph { + unusedExportedTypes: { file: string; name: string }[]; + unusedLocalTypes: { file: string; name: string }[]; +} + +// Cache file reads across a run +const sourceTextCache = new Map(); + +function getSourceText(filePath: string): string | undefined { + if (sourceTextCache.has(filePath)) { + const cached = sourceTextCache.get(filePath); + return cached === null ? undefined : cached; + } + + const text = ts.sys.readFile(filePath); + sourceTextCache.set(filePath, text ?? null); + return text ?? undefined; +} + +function createResolutionKey(importerFilePath: string, moduleSpecifier: string): string { + return `${importerFilePath}\x1F${moduleSpecifier}`; +} + +function lookupProjectFile( + unresolvedBasePath: string, + projectFiles: string[], + fileLookupCache: Map, +): string | null { + const cached = fileLookupCache.get(unresolvedBasePath); + if (cached !== undefined) return cached; + + const candidatePaths = [ + unresolvedBasePath, + unresolvedBasePath + ".ts", + unresolvedBasePath + ".tsx", + unresolvedBasePath + ".js", + unresolvedBasePath + ".jsx", + path.join(unresolvedBasePath, "index.ts"), + path.join(unresolvedBasePath, "index.tsx"), + path.join(unresolvedBasePath, "index.js"), + path.join(unresolvedBasePath, "index.jsx"), + ]; + + for (const candidatePath of candidatePaths) { + const match = projectFiles.find((p) => path.resolve(p) === path.resolve(candidatePath)); + + if (match) { + fileLookupCache.set(unresolvedBasePath, match); + + return match; + } + } + + fileLookupCache.set(unresolvedBasePath, null); + + return null; +} + +function resolveTSConfigAlias( + moduleSpecifier: string, + tsconfig: TSConfigInfo, + projectFiles: string[], + fileLookupCache: Map, +): string | null { + if (!tsconfig.paths) return null; + + for (const [aliasPattern, targetPatterns] of Object.entries(tsconfig.paths)) { + const wildcardIndex = aliasPattern.indexOf("*"); + + // Wildcard alias + if (wildcardIndex !== -1) { + const prefix = aliasPattern.slice(0, wildcardIndex); + const suffix = aliasPattern.slice(wildcardIndex + 1); + + if (!moduleSpecifier.startsWith(prefix) || !moduleSpecifier.endsWith(suffix)) continue; + + const wildcard = moduleSpecifier.slice(prefix.length, moduleSpecifier.length - suffix.length); + + for (const targetPattern of targetPatterns) { + const substituted = targetPattern.replace("*", wildcard); + const abs = path.resolve(tsconfig.baseUrl ?? "", substituted); + + const resolved = lookupProjectFile(abs, projectFiles, fileLookupCache); + + if (resolved) return resolved; + } + } + + // Exact alias + else if (moduleSpecifier === aliasPattern) { + for (const target of targetPatterns) { + const abs = path.resolve(tsconfig.baseUrl ?? "", target); + const resolved = lookupProjectFile(abs, projectFiles, fileLookupCache); + + if (resolved) return resolved; + } + } + } + + return null; +} + +function resolveModuleSpecifierToProjectFile( + importerFilePath: string, + moduleSpecifier: string, + projectFiles: string[], + tsconfig: TSConfigInfo, + resolutionCache: Map, + fileLookupCache: Map, +): string { + const key = createResolutionKey(importerFilePath, moduleSpecifier); + const cached = resolutionCache.get(key); + + if (cached !== undefined) return cached; + + const importerDir = path.dirname(importerFilePath); + + // Relative + if (moduleSpecifier.startsWith(".")) { + const base = path.resolve(importerDir, moduleSpecifier); + const resolved = lookupProjectFile(base, projectFiles, fileLookupCache); + + if (resolved) { + const n = normalizeFilePath(resolved); + + resolutionCache.set(key, n); + + return n; + } + } + + // TSConfig alias + const aliasResolved = resolveTSConfigAlias( + moduleSpecifier, + tsconfig, + projectFiles, + fileLookupCache, + ); + if (aliasResolved) { + const n = normalizeFilePath(aliasResolved); + + resolutionCache.set(key, n); + + return n; + } + + resolutionCache.set(key, moduleSpecifier); + + return moduleSpecifier; +} + +function extractTypeImports( + importNode: ts.ImportDeclaration, + fileAST: ts.SourceFile, +): { named: string[]; namespace: string | null } { + const clause = importNode.importClause; + + if (!clause) return { named: [], namespace: null }; + + const named = new Set(); + let namespace: string | null = null; + + // `import type Foo from "./x"` (rare, but valid) + if (clause.isTypeOnly && clause.name) { + named.add(intern(clause.name.getText(fileAST))); + } + + const bindings = clause.namedBindings; + + if (!bindings) return { named: [...named], namespace }; + + // `import type * as T from "./x"` + if (ts.isNamespaceImport(bindings)) { + if (clause.isTypeOnly) namespace = intern(bindings.name.text); + + return { named: [...named], namespace }; + } + + // `import type { Foo }` and `import { type Foo }` + if (ts.isNamedImports(bindings)) { + for (const el of bindings.elements) { + const isTypeOnly = clause.isTypeOnly || (el as any).isTypeOnly === true; + + if (!isTypeOnly) continue; + + named.add(intern(el.name.getText(fileAST))); + } + } + + return { named: [...named], namespace }; +} + +function buildResolvedExportLookup( + resolved: Record, +): Map> { + const out = new Map>(); + + for (const [file, entries] of Object.entries(resolved)) { + const table = new Map(); + + for (const e of entries) table.set(e.name, e); + + out.set(file, table); + } + + return out; +} + +/** + * Build type-usage results across a project (syntax-based). + * Marks an exported type as "used" if it is imported as a type from another file, + * including via barrel re-exports and namespace imports. + */ +export function buildTypeUsageGraph(filePaths: string[], tsconfig: TSConfigInfo): TypeUsageGraph { + const byFile = new Map>(); + + for (const file of filePaths) { + const text = getSourceText(file); + + if (!text) continue; + + byFile.set(file, analyzeTypeUsage(file, text)); + } + + // Scan type export surfaces and resolve barrel re-exports + const typeExportMap: Record> = {}; + + for (const file of filePaths) { + const text = getSourceText(file); + + if (!text) { + typeExportMap[file] = { localExported: new Set(), namedReexports: [], wildcardReexports: [] }; + + continue; + } + + typeExportMap[file] = scanTypeExports(file, text); + } + + const resolvedExports = resolveTypeExportGraph(typeExportMap, filePaths); + const resolvedLookup = buildResolvedExportLookup(resolvedExports); + + // Track which origin exported types are used by type-only imports + const usedExportedTypesByOriginFile = new Map>(); + + function markUsed(originFile: string, originName: string) { + let set = usedExportedTypesByOriginFile.get(originFile); + + if (!set) { + set = new Set(); + + usedExportedTypesByOriginFile.set(originFile, set); + } + + set.add(intern(originName)); + } + + const resolutionCache = new Map(); + const fileLookupCache = new Map(); + + // Scan type-only imports (named + namespace) and mark corresponding origin types as used + for (const importerFile of filePaths) { + const text = getSourceText(importerFile); + + if (!text) continue; + + const ast = ts.createSourceFile(importerFile, text, ts.ScriptTarget.ESNext, true); + + // namespace import name -> resolved target file + const namespaceTargets = new Map(); + + ast.forEachChild((node) => { + if (!ts.isImportDeclaration(node)) return; + + const moduleSpecifier = node.moduleSpecifier.getText(ast).replace(/['"]/g, ""); + const { named: importedTypeNames, namespace } = extractTypeImports(node, ast); + + if (importedTypeNames.length === 0 && !namespace) return; + + const resolvedTarget = resolveModuleSpecifierToProjectFile( + importerFile, + moduleSpecifier, + filePaths, + tsconfig, + resolutionCache, + fileLookupCache, + ); + + // Record namespace target if it resolves to a project file + if (namespace && resolvedLookup.has(resolvedTarget)) { + namespaceTargets.set(namespace, resolvedTarget); + } + + // Named type imports + if (importedTypeNames.length > 0) { + const table = resolvedLookup.get(resolvedTarget); + + if (!table) return; + + for (const name of importedTypeNames) { + const entry = table.get(name); + + if (entry) markUsed(entry.sourceFile, entry.originalName); + } + } + }); + + // Namespace member refs: T.Foo -> resolve Foo through export surface of module imported as T + const usage = byFile.get(importerFile); + + if (!usage) continue; + + for (const [ns, members] of usage.qualifiedTypeRefs.entries()) { + const targetFile = namespaceTargets.get(ns); + + if (!targetFile) continue; + + const table = resolvedLookup.get(targetFile); + + if (!table) continue; + + for (const member of members) { + const entry = table.get(member); + + if (entry) markUsed(entry.sourceFile, entry.originalName); + } + } + } + + // Compute unused exported types (origin declarations only) + const unusedExportedTypes: { file: string; name: string }[] = []; + + for (const file of filePaths) { + const exported = typeExportMap[file]?.localExported; + + if (!exported || exported.size === 0) continue; + + const used = usedExportedTypesByOriginFile.get(file) ?? new Set(); + + for (const t of exported) { + if (!used.has(t)) unusedExportedTypes.push({ file, name: t }); + } + } + + // Compute unused local types (per file) + const unusedLocalTypes: { file: string; name: string }[] = []; + + for (const [file, res] of byFile.entries()) { + for (const t of res.declaredTypes) { + if (!res.referencedTypes.has(t)) { + unusedLocalTypes.push({ file, name: t }); + } + } + } + + return { unusedExportedTypes, unusedLocalTypes }; +} diff --git a/src/core/type/resolveTypeExportGraph.ts b/src/core/type/resolveTypeExportGraph.ts new file mode 100644 index 0000000..e027e73 --- /dev/null +++ b/src/core/type/resolveTypeExportGraph.ts @@ -0,0 +1,147 @@ +import path from "node:path"; +import { intern } from "../../utils"; +import { normalizeFilePath } from "../fileSystem/normalizePath"; +import type { TypeExportInfo } from "./scanTypeExports"; + +export interface ResolvedTypeExportEntry { + name: string; // Final exported name + originalName: string; // Source module's exported name + sourceFile: string; // File where type is originally declared + exportChain: string[]; // Files involved in re-exporting +} + +type ResolvedMap = Record; + +function resolveTarget(reexportingFile: string, spec: string, allFiles: string[]): string | null { + const dir = path.dirname(reexportingFile); + const base = path.resolve(dir, spec); + + const candidates = [ + base, + base + ".ts", + base + ".tsx", + base + ".js", + base + ".jsx", + path.join(base, "index.ts"), + path.join(base, "index.tsx"), + path.join(base, "index.js"), + path.join(base, "index.jsx"), + ].map(normalizeFilePath); + + for (const candidate of candidates) { + const match = allFiles.find((file) => normalizeFilePath(file) === candidate); + + if (match) return match; + } + + return null; +} + +export function resolveTypeExportGraph( + typeExportMap: Record, + allFiles: string[], +): ResolvedMap { + const fileCache = new Map(); + const resolving = new Set(); + + function resolveFile(file: string): ResolvedTypeExportEntry[] { + if (fileCache.has(file)) return fileCache.get(file)!; + if (resolving.has(file)) return fileCache.get(file) ?? []; + + resolving.add(file); + + const info = typeExportMap[file]; + + if (!info) { + fileCache.set(file, []); + resolving.delete(file); + + return []; + } + + // Map of name -> entry with priority for conflict resolution + const table = new Map(); + + // Wildcard reexports + for (const spec of info.wildcardReexports) { + const target = resolveTarget(file, spec, allFiles); + + if (!target) continue; + + for (const entry of resolveFile(target)) { + const existing = table.get(entry.name); + const priority = 1; + + if (!existing || priority >= existing.priority) { + table.set(entry.name, { + priority, + entry: { + name: intern(entry.name), + originalName: intern(entry.originalName), + sourceFile: entry.sourceFile, + exportChain: [file, ...entry.exportChain], + }, + }); + } + } + } + + // Named re-exports + for (const namedReexports of info.namedReexports) { + const target = resolveTarget(file, namedReexports.from, allFiles); + + if (!target) continue; + + const resolved = resolveFile(target); + const matched = resolved.find((entry) => entry.name === namedReexports.name); + + if (!matched) continue; + + const existing = table.get(namedReexports.as); + const priority = 2; + + if (!existing || priority >= existing.priority) { + table.set(namedReexports.as, { + priority, + entry: { + name: intern(namedReexports.as), + originalName: intern(matched.originalName), + sourceFile: matched.sourceFile, + exportChain: [file, ...matched.exportChain], + }, + }); + } + } + + // Local exported types + for (const localType of info.localExported) { + const existing = table.get(localType); + const priority = 3; + + if (!existing || priority >= existing.priority) { + table.set(localType, { + priority, + entry: { + name: intern(localType), + originalName: intern(localType), + sourceFile: file, + exportChain: [], + }, + }); + } + } + + const out = [...table.values()].map((value) => value.entry); + + fileCache.set(file, out); + resolving.delete(file); + + return out; + } + + const result: ResolvedMap = {}; + + for (const file of allFiles) result[file] = resolveFile(file); + + return result; +} diff --git a/src/core/type/scanTypeExports.ts b/src/core/type/scanTypeExports.ts new file mode 100644 index 0000000..0b77eeb --- /dev/null +++ b/src/core/type/scanTypeExports.ts @@ -0,0 +1,81 @@ +import ts from "typescript"; +import { intern } from "../../utils"; + +export interface TypeExportInfo { + localExported: Set; // Locally declared & exported types + namedReexports: { name: string; as: string; from: string }[]; // e.g. export type { X as Y } from "..." + wildcardReexports: string[]; // e.g. export * from "./x" +} + +function hasExportModifier(node: ts.Node): boolean { + const modifiers = (node as any).modifiers as ts.NodeArray | undefined; + + return !!modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword); +} + +export function scanTypeExports(filePath: string, sourceText: string): TypeExportInfo { + const sourceFile = ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.ESNext, true); + + const info: TypeExportInfo = { + localExported: new Set(), + namedReexports: [], + wildcardReexports: [], + }; + + sourceFile.forEachChild((node) => { + // export interface Foo { ... } + if (ts.isInterfaceDeclaration(node) && hasExportModifier(node)) { + info.localExported.add(intern(node.name.text)); + + return; + } + + // export type Foo = ... + if (ts.isTypeAliasDeclaration(node) && hasExportModifier(node)) { + info.localExported.add(intern(node.name.text)); + + return; + } + + // export { ... } from "..." + // export type { ... } from "..." + // export * from "..." + if (ts.isExportDeclaration(node)) { + const moduleSpecifier = node.moduleSpecifier + ? node.moduleSpecifier.getText(sourceFile).replace(/['"]/g, "") + : null; + + if (!moduleSpecifier) return; + + // export * from "./x" + if (!node.exportClause) { + info.wildcardReexports.push(moduleSpecifier); + + return; + } + + // export { ... } from "./x" + if (ts.isNamedExports(node.exportClause)) { + for (const el of node.exportClause.elements) { + // Only include type-only export specifiers, or "export type { ... }" + const isTypeOnly = (node as any).isTypeOnly === true || (el as any).isTypeOnly === true; + + if (!isTypeOnly) continue; + + const originalName = el.propertyName + ? el.propertyName.getText(sourceFile) + : el.name.getText(sourceFile); + const exportedAs = el.name.getText(sourceFile); + + info.namedReexports.push({ + name: intern(originalName), + as: intern(exportedAs), + from: moduleSpecifier, + }); + } + } + } + }); + + return info; +} diff --git a/src/core/usage/buildUsageGraph.ts b/src/core/usage/buildUsageGraph.ts index bc53023..472ba3f 100644 --- a/src/core/usage/buildUsageGraph.ts +++ b/src/core/usage/buildUsageGraph.ts @@ -1,4 +1,4 @@ -import path from "path"; +import path from "node:path"; import ts from "typescript"; import { analyzeExportUsage } from "../exports/exportUsage"; diff --git a/tests/cli/cli.basic.test.ts b/tests/cli/cli.basic.test.ts index 4fd16b9..f92d45c 100644 --- a/tests/cli/cli.basic.test.ts +++ b/tests/cli/cli.basic.test.ts @@ -1,10 +1,10 @@ // tests/cli.basic.test.ts import assert from "assert/strict"; -import { spawnSync } from "child_process"; -import fs from "fs"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import { resolve } from "node:path"; import { test } from "node:test"; -import { resolve } from "path"; const CLI_PATH = resolve("bin", "devoid.js"); const PKG_PATH = resolve("package.json"); diff --git a/tests/cli/cli.cwd.test.ts b/tests/cli/cli.cwd.test.ts index ecbf2a2..193dfd4 100644 --- a/tests/cli/cli.cwd.test.ts +++ b/tests/cli/cli.cwd.test.ts @@ -1,8 +1,8 @@ import assert from "assert/strict"; -import fs from "fs"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import test from "node:test"; -import os from "os"; -import path from "path"; import { runCLI } from "./utils/runCLI"; diff --git a/tests/cli/cli.types.test.ts b/tests/cli/cli.types.test.ts new file mode 100644 index 0000000..a312cdc --- /dev/null +++ b/tests/cli/cli.types.test.ts @@ -0,0 +1,58 @@ +import assert from "assert/strict"; +import path from "node:path"; +import test from "node:test"; + +import { runCLI } from "./utils/runCLI"; + +const FIXTURE_ROOT = path.join(__dirname, "fixtures", "types"); + +test("devoid: without --types flag, type results are not included", async () => { + const res = await runCLI([FIXTURE_ROOT, "--json"]); + assert.equal(res.code, 0); + + const parsed = JSON.parse(res.stdout); + + assert.equal("unusedExportedTypes" in parsed, false); + assert.equal("unusedLocalTypes" in parsed, false); +}); + +test("devoid: --types reports unused exported and unused local types", async () => { + const res = await runCLI([FIXTURE_ROOT, "--types", "--json"]); + + assert.equal(res.code, 0); + + const parsed = JSON.parse(res.stdout); + + assert.ok(Array.isArray(parsed.unusedExportedTypes), "expected unusedExportedTypes array"); + assert.ok(Array.isArray(parsed.unusedLocalTypes), "expected unusedLocalTypes array"); + + const unusedExported = new Set(parsed.unusedExportedTypes.map((e: any) => `${e.file}:${e.name}`)); + const unusedLocal = new Set(parsed.unusedLocalTypes.map((e: any) => `${e.file}:${e.name}`)); + + const typesFile = path.join(FIXTURE_ROOT, "types.ts"); + + // Exported types + assert(!unusedExported.has(`${typesFile}:UsedExported`), "UsedExported should be used"); + assert(unusedExported.has(`${typesFile}:UnusedExported`), "UnusedExported should be unused"); + + // Local types + assert(!unusedLocal.has(`${typesFile}:UsedLocal`), "UsedLocal should be used (via Wrapper)"); + assert(unusedLocal.has(`${typesFile}:UnusedLocal`), "UnusedLocal should be unused"); +}); + +test("devoid: --types respects barrel type re-exports", async () => { + const res = await runCLI([FIXTURE_ROOT, "--types", "--json"]); + assert.equal(res.code, 0); + + const parsed = JSON.parse(res.stdout); + + const unusedExported = new Set(parsed.unusedExportedTypes.map((e: any) => `${e.file}:${e.name}`)); + + const typesFile = path.join(FIXTURE_ROOT, "types.ts"); + + // UsedExported is imported from ./index (barrel) in consumer.ts + assert( + !unusedExported.has(`${typesFile}:UsedExported`), + "UsedExported should be used via barrel", + ); +}); diff --git a/tests/cli/fixtures/types/consumer.ts b/tests/cli/fixtures/types/consumer.ts new file mode 100644 index 0000000..753526a --- /dev/null +++ b/tests/cli/fixtures/types/consumer.ts @@ -0,0 +1,5 @@ +import type { UsedExported, Wrapper } from "./index"; + +export const ok = (u: UsedExported): Wrapper => { + return 123; +}; diff --git a/tests/cli/fixtures/types/index.ts b/tests/cli/fixtures/types/index.ts new file mode 100644 index 0000000..bc1d0cd --- /dev/null +++ b/tests/cli/fixtures/types/index.ts @@ -0,0 +1,2 @@ +export type { UsedExported, UnusedExported } from "./types"; +export type { Wrapper } from "./types"; diff --git a/tests/cli/fixtures/types/types.ts b/tests/cli/fixtures/types/types.ts new file mode 100644 index 0000000..a24fb5f --- /dev/null +++ b/tests/cli/fixtures/types/types.ts @@ -0,0 +1,7 @@ +export type UsedExported = { id: string }; +export type UnusedExported = { nope: true }; + +type UsedLocal = number; +type UnusedLocal = { x: 1 }; + +export type Wrapper = UsedLocal; diff --git a/tests/cli/utils/runCLI.ts b/tests/cli/utils/runCLI.ts index 4a2098c..2ce3f80 100644 --- a/tests/cli/utils/runCLI.ts +++ b/tests/cli/utils/runCLI.ts @@ -1,5 +1,5 @@ import { spawnSync } from "child_process"; -import path from "path"; +import path from "node:path"; const NODE = process.execPath; const CLI_ENTRY = path.resolve(__dirname, "../../../bin/devoid.js"); @@ -10,6 +10,8 @@ export function runCLI(args: string[], opts?: { cwd?: string }) { encoding: "utf8", }); + console.log("Result: ", result); + return { code: result.status ?? 1, stdout: (result.stdout ?? "") + (result.stderr ?? ""), diff --git a/tests/entrypoints/entrypoint-export.test.ts b/tests/entrypoints/entrypoint-export.test.ts index fd36ca4..0dad8e2 100644 --- a/tests/entrypoints/entrypoint-export.test.ts +++ b/tests/entrypoints/entrypoint-export.test.ts @@ -1,8 +1,8 @@ import assert from "assert/strict"; -import fs from "fs"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import test from "node:test"; -import os from "os"; -import path from "path"; import { detectEntryPoints } from "../../src/core/entrypoints/detectEntryPoints"; import { normalizeFilePath } from "../../src/core/fileSystem/normalizePath"; diff --git a/tests/exports/conflictingExports.test.ts b/tests/exports/conflictingExports.test.ts index 2cc2b75..4355e1f 100644 --- a/tests/exports/conflictingExports.test.ts +++ b/tests/exports/conflictingExports.test.ts @@ -1,7 +1,7 @@ import assert from "assert/strict"; -import fs from "fs"; +import fs from "node:fs"; +import path from "node:path"; import test from "node:test"; -import path from "path"; import type { ResolvedExportEntry } from "../../src/core/exports/resolveExportGraph.js"; import { resolveExportGraph } from "../../src/core/exports/resolveExportGraph.js"; diff --git a/tests/exports/exportChains.test.ts b/tests/exports/exportChains.test.ts index 636e2de..96150aa 100644 --- a/tests/exports/exportChains.test.ts +++ b/tests/exports/exportChains.test.ts @@ -1,7 +1,7 @@ import assert from "assert"; -import fs from "fs"; +import fs from "node:fs"; +import path from "node:path"; import test from "node:test"; -import path from "path"; import { analyzeExportUsage } from "../../src/core/exports/exportUsage"; import { scanExports } from "../../src/core/exports/scanExports"; diff --git a/tests/exports/exportGraphResolver.test.ts b/tests/exports/exportGraphResolver.test.ts index 90cc3d1..9bc7649 100644 --- a/tests/exports/exportGraphResolver.test.ts +++ b/tests/exports/exportGraphResolver.test.ts @@ -1,7 +1,7 @@ import assert from "assert/strict"; -import fs from "fs"; +import fs from "node:fs"; +import path from "node:path"; import test from "node:test"; -import path from "path"; import type { ResolvedExportEntry } from "../../src/core/exports/resolveExportGraph.js"; import { resolveExportGraph } from "../../src/core/exports/resolveExportGraph.js"; diff --git a/tests/exports/exportUsage.test.ts b/tests/exports/exportUsage.test.ts index 7c9f78e..792ba02 100644 --- a/tests/exports/exportUsage.test.ts +++ b/tests/exports/exportUsage.test.ts @@ -1,7 +1,7 @@ import assert from "assert"; -import fs from "fs"; +import fs from "node:fs"; +import path from "node:path"; import test from "node:test"; -import path from "path"; import { analyzeExportUsage } from "../../src/core/exports/exportUsage"; import { scanExports } from "../../src/core/exports/scanExports.js"; diff --git a/tests/exports/localPriority.test.ts b/tests/exports/localPriority.test.ts index c97eeed..dd288b1 100644 --- a/tests/exports/localPriority.test.ts +++ b/tests/exports/localPriority.test.ts @@ -1,7 +1,7 @@ import assert from "assert/strict"; -import fs from "fs"; +import fs from "node:fs"; +import path from "node:path"; import test from "node:test"; -import path from "path"; import { resolveExportGraph } from "../../src/core/exports/resolveExportGraph.js"; import { scanExports } from "../../src/core/exports/scanExports.js"; diff --git a/tests/exports/multiHop.test.ts b/tests/exports/multiHop.test.ts index 8d845fa..590ef00 100644 --- a/tests/exports/multiHop.test.ts +++ b/tests/exports/multiHop.test.ts @@ -1,7 +1,7 @@ import assert from "assert/strict"; -import fs from "fs"; +import fs from "node:fs"; +import path from "node:path"; import test from "node:test"; -import path from "path"; import { resolveExportGraph, diff --git a/tests/exports/namedReexports.test.ts b/tests/exports/namedReexports.test.ts index dd82624..e3f876c 100644 --- a/tests/exports/namedReexports.test.ts +++ b/tests/exports/namedReexports.test.ts @@ -1,7 +1,7 @@ import assert from "assert/strict"; -import fs from "fs"; +import fs from "node:fs"; +import path from "node:path"; import test from "node:test"; -import path from "path"; import { resolveExportGraph, diff --git a/tests/exports/unresolvedReexports.test.ts b/tests/exports/unresolvedReexports.test.ts index ee0c39d..641813a 100644 --- a/tests/exports/unresolvedReexports.test.ts +++ b/tests/exports/unresolvedReexports.test.ts @@ -1,7 +1,7 @@ import assert from "assert/strict"; -import fs from "fs"; +import fs from "node:fs"; +import path from "node:path"; import test from "node:test"; -import path from "path"; import { resolveExportGraph, diff --git a/tests/exports/wildcardExportChains.test.ts b/tests/exports/wildcardExportChains.test.ts index 76a7660..d52e33e 100644 --- a/tests/exports/wildcardExportChains.test.ts +++ b/tests/exports/wildcardExportChains.test.ts @@ -1,7 +1,7 @@ import assert from "assert"; -import fs from "fs"; +import fs from "node:fs"; +import path from "node:path"; import test from "node:test"; -import path from "path"; import { analyzeExportUsage } from "../../src/core/exports/exportUsage"; import { scanExports } from "../../src/core/exports/scanExports.js"; diff --git a/tests/exports/wildcardReexport.test.ts b/tests/exports/wildcardReexport.test.ts index 77c6427..0fac81b 100644 --- a/tests/exports/wildcardReexport.test.ts +++ b/tests/exports/wildcardReexport.test.ts @@ -1,7 +1,7 @@ import assert from "assert/strict"; -import fs from "fs"; +import fs from "node:fs"; +import path from "node:path"; import test from "node:test"; -import path from "path"; import { resolveExportGraph, diff --git a/tests/imports/fixtures/unresolved/consumer.ts b/tests/imports/fixtures/unresolved/consumer.ts index 5ecab86..cdb0a45 100644 --- a/tests/imports/fixtures/unresolved/consumer.ts +++ b/tests/imports/fixtures/unresolved/consumer.ts @@ -5,7 +5,7 @@ import React from "react"; // @ts-ignore import something from "unknown-module"; -import fs from "fs"; +import fs from "node:fs"; export function useThings() { return { diff --git a/tests/imports/importGraph.test.ts b/tests/imports/importGraph.test.ts index 22defd6..41695a5 100644 --- a/tests/imports/importGraph.test.ts +++ b/tests/imports/importGraph.test.ts @@ -1,7 +1,7 @@ import assert from "assert"; -import fs from "fs"; +import fs from "node:fs"; +import path from "node:path"; import test from "node:test"; -import path from "path"; import { buildImportGraph } from "../../src/core/imports/buildImportGraph"; import { loadTSConfig } from "../../src/core/tsconfig/tsconfigLoader"; diff --git a/tests/imports/mixedImports.test.ts b/tests/imports/mixedImports.test.ts index 8618d12..2c2941f 100644 --- a/tests/imports/mixedImports.test.ts +++ b/tests/imports/mixedImports.test.ts @@ -1,7 +1,7 @@ import assert from "assert"; -import fs from "fs"; +import fs from "node:fs"; +import path from "node:path"; import test from "node:test"; -import path from "path"; import { analyzeExportUsage } from "../../src/core/exports/exportUsage.js"; import { scanExports } from "../../src/core/exports/scanExports.js"; diff --git a/tests/imports/typeOnlyImports.test.ts b/tests/imports/typeOnlyImports.test.ts index 52508c4..8d43a06 100644 --- a/tests/imports/typeOnlyImports.test.ts +++ b/tests/imports/typeOnlyImports.test.ts @@ -1,7 +1,7 @@ import assert from "assert"; -import fs from "fs"; +import fs from "node:fs"; +import path from "node:path"; import test from "node:test"; -import path from "path"; import { analyzeExportUsage } from "../../src/core/exports/exportUsage.js"; import { scanExports } from "../../src/core/exports/scanExports.js"; diff --git a/tests/imports/unresolvedImports.test.ts b/tests/imports/unresolvedImports.test.ts index deaa88b..3d5efd4 100644 --- a/tests/imports/unresolvedImports.test.ts +++ b/tests/imports/unresolvedImports.test.ts @@ -1,7 +1,7 @@ import assert from "assert"; -import fs from "fs"; +import fs from "node:fs"; +import path from "node:path"; import test from "node:test"; -import path from "path"; import { analyzeExportUsage } from "../../src/core/exports/exportUsage"; import { scanExports } from "../../src/core/exports/scanExports.js"; diff --git a/tests/integrate/barrel.test.ts b/tests/integrate/barrel.test.ts index 5df23b6..2a9418f 100644 --- a/tests/integrate/barrel.test.ts +++ b/tests/integrate/barrel.test.ts @@ -1,7 +1,7 @@ import assert from "assert/strict"; -import fs from "fs"; +import fs from "node:fs"; +import path from "node:path"; import test from "node:test"; -import path from "path"; import { analyzeProject } from "../../src/core/analyzer.js"; diff --git a/tests/integrate/endToEnd.test.ts b/tests/integrate/endToEnd.test.ts index e7bebda..a38d81e 100644 --- a/tests/integrate/endToEnd.test.ts +++ b/tests/integrate/endToEnd.test.ts @@ -1,6 +1,6 @@ import assert from "assert"; +import path from "node:path"; import test from "node:test"; -import path from "path"; import { analyzeProject } from "../../src/core/analyzer"; diff --git a/tests/integrate/realistic.test.ts b/tests/integrate/realistic.test.ts index 0686dee..96fd4d6 100644 --- a/tests/integrate/realistic.test.ts +++ b/tests/integrate/realistic.test.ts @@ -1,7 +1,7 @@ import assert from "assert/strict"; -import fs from "fs"; +import fs from "node:fs"; +import path from "node:path"; import test from "node:test"; -import path from "path"; import { analyzeProject } from "../../src/core/analyzer.js"; diff --git a/tests/internal/basicInternalUsage.test.ts b/tests/internal/basicInternalUsage.test.ts index 51866e5..7b57b98 100644 --- a/tests/internal/basicInternalUsage.test.ts +++ b/tests/internal/basicInternalUsage.test.ts @@ -1,7 +1,7 @@ import assert from "assert"; -import fs from "fs"; +import fs from "node:fs"; +import path from "node:path"; import test from "node:test"; -import path from "path"; import { analyzeLocalUsage } from "../../src/core/locals/analyzeLocalUsage"; diff --git a/tests/internal/strictInternalUsage.test.ts b/tests/internal/strictInternalUsage.test.ts index 809c28e..a3e5bac 100644 --- a/tests/internal/strictInternalUsage.test.ts +++ b/tests/internal/strictInternalUsage.test.ts @@ -1,7 +1,7 @@ import assert from "assert"; -import fs from "fs"; +import fs from "node:fs"; +import path from "node:path"; import test from "node:test"; -import path from "path"; import { analyzeLocalUsage } from "../../src/core/locals/analyzeLocalUsage"; diff --git a/tests/utils/fileWalker.test.ts b/tests/utils/fileWalker.test.ts index 0503021..85b3fe0 100644 --- a/tests/utils/fileWalker.test.ts +++ b/tests/utils/fileWalker.test.ts @@ -1,6 +1,6 @@ import assert from "assert"; +import path from "node:path"; import test from "node:test"; -import path from "path"; import { walkFiles } from "../../src/core/fileSystem/walkFiles"; diff --git a/tests/utils/pathNormalization.test.ts b/tests/utils/pathNormalization.test.ts index 58f198e..b3aabbd 100644 --- a/tests/utils/pathNormalization.test.ts +++ b/tests/utils/pathNormalization.test.ts @@ -1,6 +1,6 @@ import assert from "assert"; +import path from "node:path"; import test from "node:test"; -import path from "path"; import { normalizeFilePath } from "../../src/core/fileSystem/normalizePath";