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
12 changes: 9 additions & 3 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));

export default [
{
ignores: ["dist/**", "node_modules/**", "tests/**/fixtures/**"],
ignores: ["dist/**", "node_modules/**", "test/**/fixtures/**"],
},
{
files: ["src/**/*.ts"],
Expand Down Expand Up @@ -40,17 +40,23 @@ export default [
},

{
files: ["tests/**/*.ts"],
files: ["test/**/*.ts"],
ignores: ["**/fixtures/**"],
languageOptions: {
parser: tsParser,
parserOptions: {
project: null, // disable project mode for test files
project: path.join(__dirname, "test", "tsconfig.json"),
sourceType: "module",
ecmaVersion: "latest",
},
},
plugins: {
"@typescript-eslint": tsPlugin,
},
rules: {
"@typescript-eslint/no-explicit-any": "off",
"no-console": "off",
},
},
{
files: ["**/*.js"],
Expand Down
6 changes: 6 additions & 0 deletions src/cli/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ ${colors.bold}Analysis Options:${colors.reset}
--locals, --identifiers List unused local identifiers
--types Enable type-only analysis (unused exported + unused local types)
--track-all-locals Count all variable declarations (strict mode)
--deps Detect unused dependencies from package.json
--explain <target> Show why a file or export is used (e.g. --explain src/utils.ts:add)
--fail-on-unused Exit with code 1 if any unused items are found (useful for CI)

${colors.bold}Output Options:${colors.reset}
--verbose Print full graphs (imports, exports, usage)
Expand All @@ -41,6 +44,9 @@ ${colors.bold}Examples:${colors.reset}
devoid src/ --types
devoid src/ --types --json
devoid src/ --track-all-locals
devoid src/ --fail-on-unused
devoid src/ --explain src/utils.ts:add
devoid --cwd packages/core src/
devoid internal src/utils/helpers.ts
devoid internal src/module.ts --track-all-locals
`);
Expand Down
142 changes: 124 additions & 18 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// src/cli/index.ts

import { analyzeProject } from "../core";
import { loadConfig } from "../core/config/loadConfig";
import { log } from "../utils";
import { disableLogPrefix, enableLogPrefix } from "../utils/logger";
import { disableColors, enableColors, isColorSupported } from "./colors";
Expand All @@ -16,7 +17,7 @@ export function isNodeError(error: unknown): error is NodeJS.ErrnoException {
typeof error === "object" &&
error !== null &&
"code" in error &&
typeof (error as any)?.code === "string"
typeof (error as NodeJS.ErrnoException).code === "string"
);
}

Expand Down Expand Up @@ -67,7 +68,7 @@ function resolveAndValidateCwd(rawCwd?: string): string {
const args = parseArgs(process.argv.slice(2));
const [command, targetPath] = args._;

const cwd = resolveAndValidateCwd(args.cwd);
const cwd = resolveAndValidateCwd(args.cwd as string | undefined);

// Internal single-file analysis mode
if (command === "internal") {
Expand Down Expand Up @@ -105,7 +106,6 @@ 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 All @@ -128,39 +128,125 @@ function resolveAndValidateCwd(rawCwd?: string): string {
if (!projectRoot) {
if (!silent) {
log("Error: No project path provided.");
log("Run `devoid --help` for usage.");
log("Usage: devoid <path> [options]");
log("Run `devoid --help` for all options.");
}
process.exit(1);
}

// Run full-project analysis (existing behavior)
const results: any = analyzeProject(projectRoot, args);
// Load config file (CLI flags override config values)
const config = loadConfig(cwd);

const ignorePatterns = args.ignore
? (Array.isArray(args.ignore) ? args.ignore : [args.ignore]).map(String)
: (config.ignore ?? []);
const trackAllLocals = args["track-all-locals"] === true || config.trackAllLocals === true;
const typesMode = args.types === true || config.types === true;
const failOnUnused = args["fail-on-unused"] === true || config.failOnUnused === true;

// --explain mode: show why a file/export is used
if (args.explain && typeof args.explain === "string") {
const { walkFiles } = await import("../core/fileSystem/walkFiles");
const { loadTSConfig } = await import("../core/tsconfig/tsconfigLoader");
const { scanExports } = await import("../core/exports/scanExports");
const { buildImportGraph } = await import("../core/imports/buildImportGraph");
const { explainUsage } = await import("../core/explain/explainUsage");

const files = walkFiles(projectRoot, ignorePatterns);
const tsConfig = loadTSConfig(projectRoot);
const exportMap = scanExports(files);
const importGraph = buildImportGraph(files, tsConfig);

// Parse target — supports "file:export" or just "file"
const parts = args.explain.split(":");
const targetPath = path.resolve(cwd, parts[0]);
const exportName = parts[1] || undefined;

const result = explainUsage(targetPath, exportName, exportMap, importGraph);

if (args.json) {
log(JSON.stringify(result, null, 2));
} else if (!silent) {
const { colors } = await import("./colors.js");
log(
`\n${colors.cyan}${colors.bold}Explain: ${result.target}${exportName ? ":" + exportName : ""}${colors.reset}`,
);
log(
`Status: ${result.status === "used" ? colors.green + "USED" : colors.yellow + "UNUSED"}${colors.reset}`,
);

if (result.importedBy.length > 0) {
log(`\nImported by:`);
for (const imp of result.importedBy) {
log(` ${imp.file}`);
log(` symbols: ${imp.symbols.join(", ")}`);
}
} else {
log(`\nNo imports found for this target.`);
}

if (result.reexportChain.length > 0) {
log(`\nRe-export chain:`);
log(` ${result.reexportChain.join(" → ")}`);
}
log("");
}

process.exit(0);
}

// Run full-project analysis
const results = analyzeProject(projectRoot, {
ignore: ignorePatterns,
trackAllLocals,
});

// Optional type analysis results
let unusedExportedTypes: { file: string; name: string }[] = [];
let unusedLocalTypes: { file: string; name: string }[] = [];

// 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;
unusedExportedTypes = typeGraph.unusedExportedTypes;
unusedLocalTypes = typeGraph.unusedLocalTypes;
}

// Optional: unused dependency detection
let unusedDeps: string[] = [];
if (args.deps) {
const { walkFiles } = await import("../core/fileSystem/walkFiles");
const { detectUnusedDeps } = await import("../core/dependencies/detectUnusedDeps");

// Attach into verbose graphs too
if (results.graphs) results.graphs.types = typeGraph;
const files = walkFiles(projectRoot, ignorePatterns);
const depsResult = detectUnusedDeps(cwd, files);
unusedDeps = depsResult.unused;
}

// Determine if anything unused was found
const hasUnused =
results.unusedExports.length > 0 ||
results.unusedFiles.length > 0 ||
results.unusedIdentifiers.length > 0 ||
unusedExportedTypes.length > 0 ||
unusedLocalTypes.length > 0 ||
unusedDeps.length > 0;

// JSON output
if (args.json) {
log(JSON.stringify(results, null, 2));
process.exit(0);
const jsonOutput: Record<string, unknown> = typesMode
? { ...results, unusedExportedTypes, unusedLocalTypes }
: { ...results };
if (args.deps) jsonOutput.unusedDependencies = unusedDeps;
log(JSON.stringify(jsonOutput, null, 2));
process.exit(failOnUnused && hasUnused ? 1 : 0);
}

// Human-readable output
Expand All @@ -175,13 +261,33 @@ function resolveAndValidateCwd(rawCwd?: string): string {
if (typesMode) {
const { logUnusedExportedTypes, logUnusedLocalTypes } = await import("./typesFormat.js");

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

if (args.deps && unusedDeps.length > 0) {
const { colors } = await import("./colors.js");
const { heading } = await import("./format.js");
log(heading("Unused Dependencies"));
for (const dep of unusedDeps) {
log(` ${colors.yellow}${dep}${colors.reset}`);
}
log("");
} else if (args.deps) {
const { colors } = await import("./colors.js");
const { heading } = await import("./format.js");
log(heading("Unused Dependencies"));
log(` ${colors.dim}No unused dependencies found!${colors.reset}\n`);
}

if (args.verbose) logVerbose(results.graphs);
}
}

// CI mode: exit non-zero if unused items found
if (failOnUnused && hasUnused) {
process.exit(1);
}
})().catch((err) => {
enableLogPrefix();
log(`\nFatal error: ${err?.message ?? String(err)}`);
Expand Down
4 changes: 3 additions & 1 deletion src/cli/internalMode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ import { heading } from "./format";
* • referenced identifiers
* • unused identifiers
*/
export async function runInternalMode(filePath: string, args: any): Promise<void> {
import type { ParsedArgs } from "./parser";

export async function runInternalMode(filePath: string, args: ParsedArgs): Promise<void> {
const SILENT_MODE = !!args.silent;
let sourceText: string;

Expand Down
9 changes: 7 additions & 2 deletions src/cli/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,13 @@
* -x
* positional args
*/
export function parseArgs(argv: string[]) {
const args: Record<string, any> = { _: [] };
export interface ParsedArgs {
_: string[];
[key: string]: string | boolean | string[];
}

export function parseArgs(argv: string[]): ParsedArgs {
const args: ParsedArgs = { _: [] };

for (let i = 0; i < argv.length; i++) {
const token = argv[i];
Expand Down
7 changes: 6 additions & 1 deletion src/core/analyzer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,15 @@ import { buildImportGraph } from "./imports/buildImportGraph";
import { loadTSConfig } from "./tsconfig/tsconfigLoader";
import { buildUsageGraph } from "./usage/buildUsageGraph";

export interface AnalyzeOptions {
ignore?: string[];
trackAllLocals?: boolean;
}

// Cache tsconfig loads per project root
const tsconfigCache = new Map<string, ReturnType<typeof loadTSConfig>>();

export function analyzeProject(root: string, options: any) {
export function analyzeProject(root: string, options: AnalyzeOptions = {}) {
const ignorePatterns = (options.ignore || []).map(String);
const trackAllLocals = options.trackAllLocals === true;

Expand Down
67 changes: 67 additions & 0 deletions src/core/config/loadConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import fs from "node:fs";
import path from "node:path";

export interface DevoidConfig {
ignore?: string[];
entry?: string[];
trackAllLocals?: boolean;
types?: boolean;
failOnUnused?: boolean;
}

const CONFIG_FILENAMES = ["devoid.config.json", ".devoidrc", ".devoidrc.json"];

/**
* Load a devoid config file from the project root.
* Searches for devoid.config.json, .devoidrc, or .devoidrc.json.
* Returns an empty config if no file is found.
*/
export function loadConfig(projectRoot: string): DevoidConfig {
for (const filename of CONFIG_FILENAMES) {
const configPath = path.join(projectRoot, filename);

if (!fs.existsSync(configPath)) continue;

try {
const raw = fs.readFileSync(configPath, "utf8");
const parsed = JSON.parse(raw);
return validateConfig(parsed, configPath);
} catch {
// Invalid JSON — skip silently
continue;
}
}

return {};
}

function validateConfig(raw: unknown, _configPath: string): DevoidConfig {
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
return {};
}

const obj = raw as Record<string, unknown>;
const config: DevoidConfig = {};

if (Array.isArray(obj.ignore)) {
config.ignore = obj.ignore.filter((v): v is string => typeof v === "string");
}

if (Array.isArray(obj.entry)) {
config.entry = obj.entry.filter((v): v is string => typeof v === "string");
}

if (typeof obj.trackAllLocals === "boolean") {
config.trackAllLocals = obj.trackAllLocals;
}

if (typeof obj.types === "boolean") {
config.types = obj.types;
}

if (typeof obj.failOnUnused === "boolean") {
config.failOnUnused = obj.failOnUnused;
}

return config;
}
Loading
Loading