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
42 changes: 37 additions & 5 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// src/cli/index.ts

import { analyzeProject } from "../core";
import { log } from "../utils";
import { disableLogPrefix, enableLogPrefix } from "../utils/logger";
Expand All @@ -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"
);
}

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Comment thread
ElijahKotyluk marked this conversation as resolved.

// 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) {
Expand All @@ -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");
Comment thread
ElijahKotyluk marked this conversation as resolved.

logUnusedExportedTypes(results.unusedExportedTypes ?? []);
logUnusedLocalTypes(results.unusedLocalTypes ?? []);
}

if (args.verbose) logVerbose(results.graphs);
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/cli/internalMode.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { readFileSync } from "fs";
import { readFileSync } from "node:fs";

import { analyzeLocalUsage } from "../core/locals/analyzeLocalUsage";
import { log } from "../utils";
Expand Down
56 changes: 56 additions & 0 deletions src/cli/typesFormat.ts
Original file line number Diff line number Diff line change
@@ -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<string, string[]> = {};

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<string, string[]> = {};

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("");
}
}
4 changes: 2 additions & 2 deletions src/core/entrypoints/detectEntryPoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion src/core/exports/resolveExportGraph.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import path from "path";
import path from "node:path";
import { intern } from "../../utils";
import type { ExportInfo } from "./scanExports";

Expand Down
2 changes: 1 addition & 1 deletion src/core/fileSystem/normalizePath.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>();
Expand Down
4 changes: 2 additions & 2 deletions src/core/fileSystem/walkFiles.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/core/imports/buildImportGraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
2 changes: 1 addition & 1 deletion src/core/tsconfig/tsconfigLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
181 changes: 181 additions & 0 deletions src/core/type/analyzeTypeUsage.ts
Original file line number Diff line number Diff line change
@@ -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<string>;
exportedTypes: Set<string>;
referencedTypes: Set<string>;
qualifiedTypeRefs: Map<string, Set<string>>;
}

function hasExportModifier(node: ts.Node): boolean {
const modifiers = (node as any).modifiers as ts.NodeArray<ts.Modifier> | 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<string>();
const exportedTypes = new Set<string>();
const referencedTypes = new Set<string>();
const qualifiedTypeRefs = new Map<string, Set<string>>();

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 };
}
Loading