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
63 changes: 50 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,22 +1,34 @@
# Dependency Radar

Dependency Radar is a local-first CLI tool that inspects a Node.js project’s installed dependencies and generates a single, human-readable HTML report. The report highlights dependency structure, usage, size, licences, vulnerabilities, and other signals that help you understand risk and complexity hidden in your node_modules folder.
Dependency Radar is a CLI tool that inspects a Node.js project’s installed dependencies and generates a single, human-readable HTML report. The report highlights dependency structure, usage, licences, vulnerabilities, and other signals that help you understand risk and complexity hidden in your node_modules folder.

The simplest way to get started is:

```bash
npx dependency-radar
```

This runs a scan against the current project and writes a self-contained `dependency-radar.html` report you can open locally, share with teammates, or attach to tickets and documentation.

## What it does

- Analyses installed dependencies using only local data (no SaaS, no uploads by default)
- Combines multiple tools (npm audit, npm ls, import graph analysis) into a single report
- Shows direct vs sub-dependencies, dependency depth, and parent relationships
- Analyses installed dependencies by running standard package manager tooling (npm, pnpm, or yarn)
- Combines multiple signals (audit results, dependency graph data, import usage, and heuristics) into a single report
- Shows direct vs transitive dependencies, dependency depth, and parent relationships
- Highlights licences, known vulnerabilities, install-time scripts, native modules, and package footprint
- Produces a single self-contained HTML file you can share or archive
- Produces a single self-contained HTML file with no external assets, which you can easily share

## What it is not

- Not a CI service or hosted platform
- Not a CI service or hosted scanning platform
- Not a replacement for dedicated security scanners
- Not a bundler or build tool
- Not a dependency updater

---

For teams that want deeper analysis, long-term tracking, and additional enrichment (such as ecosystem and maintenance signals), Dependency Radar also offers an optional premium service.
See https://dependency-radar.com for details.

## License Scanning

Expand Down Expand Up @@ -91,18 +103,43 @@ npx dependency-radar --help

## Scripts

- `npm run build` – compile TypeScript to `dist/`
- `npm run dev` – run a scan from source (`ts-node`)
- `npm run scan` – run a scan from the built output
- `npm run build` – generate SPDX/report assets and compile TypeScript to `dist/`
- `npm run dev` – run a scan from source (`ts-node src/cli.ts scan`)
- `npm run scan` – run a scan from the built output (`node dist/cli.js scan`)
- `npm run dev:report` – run the report UI dev server
- `npm run build:spdx` – rebuild bundled SPDX identifiers
- `npm run build:report-ui` – build report UI assets
- `npm run build:report` – rebuild report assets used by the CLI

### Fixture scripts:

- `npm run fixtures:install` – install core fixture dependencies
- `npm run fixtures:install:all` – install all fixture dependencies
- `npm run fixtures:scan` – scan the core fixture set
- `npm run fixtures:install:npm`
- `npm run fixtures:install:npm-heavy`
- `npm run fixtures:install:pnpm`
- `npm run fixtures:install:pnpm-hoisted`
- `npm run fixtures:install:yarn`
- `npm run fixtures:install:yarn-berry`
- `npm run fixtures:install:optional`
- `npm run fixtures:scan:npm`
- `npm run fixtures:scan:npm-heavy`
- `npm run fixtures:scan:pnpm`
- `npm run fixtures:scan:pnpm-hoisted`
- `npm run fixtures:scan:yarn`
- `npm run fixtures:scan:yarn-berry`
- `npm run fixtures:scan:optional`
- `npm run fixtures:scan:no-node-modules`

## Notes

- The target project must have node_modules installed (run npm install first).
- The scan is local-first and does not upload your code or dependencies anywhere.
- `npm audit` and `npm outdated` perform registry lookups; use `--offline` for offline-only scans.
- The target project must have dependencies installed (run `npm install`, `pnpm install`, or `yarn install` first).
- The scan runs on your machine and does not upload your code or dependencies anywhere.
- `npm audit`/`pnpm audit`/`yarn npm audit` and `npm outdated`/`pnpm outdated` perform registry lookups; use `--offline` for offline-only scans.
- A temporary `.dependency-radar` folder is created during the scan to store intermediate tool output.
- Use `--keep-temp` to retain this folder for debugging; otherwise it is deleted automatically.
- If a tool fails, its section is marked as unavailable, but the report is still generated.
- If some per-package tools fail (common in large workspaces), the scan continues and reports warnings; missing sections are marked unavailable where applicable.

## Output

Expand Down
49 changes: 34 additions & 15 deletions dist/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -688,6 +688,7 @@ function openInBrowser(filePath) {
child.unref();
}
async function run() {
var _a;
const opts = parseArgs(process.argv.slice(2));
if (opts.command !== "scan") {
printHelp();
Expand All @@ -701,6 +702,7 @@ async function run() {
let outputPath = path_1.default.resolve(opts.out);
const startTime = Date.now();
let dependencyCount = 0;
let outputCreated = false;
try {
const stat = await promises_1.default.stat(outputPath).catch(() => undefined);
const endsWithSeparator = opts.out.endsWith("/") || opts.out.endsWith("\\");
Expand Down Expand Up @@ -781,7 +783,10 @@ async function run() {
opts.audit
? (0, npmAudit_1.runPackageAudit)(meta.path, pkgTempDir, scanManager, yarnVersion).catch((err) => ({ ok: false, error: String(err) }))
: Promise.resolve(undefined),
(0, npmLs_1.runNpmLs)(meta.path, pkgTempDir, scanManager).catch((err) => ({ ok: false, error: String(err) })),
(0, npmLs_1.runNpmLs)(meta.path, pkgTempDir, scanManager, {
contextLabel: meta.name,
onProgress: (line) => spinner.log(line),
}).catch((err) => ({ ok: false, error: String(err) })),
(0, importGraphRunner_1.runImportGraph)(meta.path, pkgTempDir).catch((err) => ({ ok: false, error: String(err) })),
opts.outdated
? (0, npmOutdated_1.runPackageOutdated)(meta.path, pkgTempDir, scanManager).catch((err) => ({ ok: false, error: String(err) }))
Expand Down Expand Up @@ -829,14 +834,20 @@ async function run() {
const auditFailure = opts.audit
? perPackageAudit.find((r) => r && !r.ok)
: undefined;
const lsFailure = perPackageLs.find((r) => r && !r.ok);
const importFailure = perPackageImportGraph.find((r) => r && !r.ok);
const lsFailures = perPackageLs
.map((result, index) => ({ result, meta: packageMetas[index] }))
.filter((entry) => entry.result && !entry.result.ok);
const importFailures = perPackageImportGraph.filter((r) => r && !r.ok);
if (auditFailure) {
spinner.log(`Audit warning: ${auditFailure.error || "Audit failed"}`);
}
if (lsFailure || importFailure) {
const err = lsFailure || importFailure;
throw new Error((err === null || err === void 0 ? void 0 : err.error) || "Tool execution failed");
if (lsFailures.length > 0) {
const packageList = lsFailures.map((entry) => { var _a; return (_a = entry.meta) === null || _a === void 0 ? void 0 : _a.name; }).filter(Boolean);
spinner.log(`Dependency tree warning: ${lsFailures.length} package${lsFailures.length === 1 ? "" : "s"} failed (${packageList.join(", ")}).`);
spinner.log(`First dependency tree error: ${((_a = lsFailures[0].result) === null || _a === void 0 ? void 0 : _a.error) || "pnpm ls failed"}`);
}
if (importFailures.length > 0) {
spinner.log(`Import graph warning: ${importFailures.length} package${importFailures.length === 1 ? "" : "s"} failed (${importFailures[0].error || "import graph failed"})`);
}
const aggregated = await (0, aggregator_1.aggregateData)({
projectPath,
Expand Down Expand Up @@ -865,17 +876,25 @@ async function run() {
if (workspace.type !== "none") {
console.log(`Detected ${workspace.type.toUpperCase()} workspace with ${packagePaths.length} package${packagePaths.length === 1 ? "" : "s"}.`);
}
if (opts.json) {
await promises_1.default.mkdir(path_1.default.dirname(outputPath), { recursive: true });
await promises_1.default.writeFile(outputPath, JSON.stringify(aggregated, null, 2), "utf8");
}
else {
await (0, report_1.renderReport)(aggregated, outputPath);
if (dependencyCount > 0) {
if (opts.json) {
await promises_1.default.mkdir(path_1.default.dirname(outputPath), { recursive: true });
await promises_1.default.writeFile(outputPath, JSON.stringify(aggregated, null, 2), "utf8");
}
else {
await (0, report_1.renderReport)(aggregated, outputPath);
}
outputCreated = true;
}
spinner.stop(true);
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
console.log(`✔ Scan complete: ${dependencyCount} dependencies analysed in ${elapsed}s`);
console.log(`✔ ${opts.json ? "JSON" : "Report"} written to ${outputPath}`);
if (outputCreated) {
console.log(`✔ ${opts.json ? "JSON" : "Report"} written to ${outputPath}`);
}
else {
console.log(`✖ No dependencies were found - ${opts.json ? "JSON file" : "Report"} not created`);
}
}
catch (err) {
spinner.stop(false);
Expand All @@ -890,11 +909,11 @@ async function run() {
console.log(`✔ Temporary data kept at ${tempDir}`);
}
}
if (opts.open && !isCI()) {
if (opts.open && outputCreated && !isCI()) {
console.log(`↗ Opening ${path_1.default.basename(outputPath)} using system default ${opts.json ? "application" : "browser"}.`);
openInBrowser(outputPath);
}
else if (opts.open && isCI()) {
else if (opts.open && outputCreated && isCI()) {
console.log("✖ Skipping auto-open in CI environment.");
}
// Always show CTA as the last output
Expand Down
126 changes: 116 additions & 10 deletions dist/runners/npmLs.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,15 @@ Object.defineProperty(exports, "__esModule", { value: true });
exports.runNpmLs = runNpmLs;
const path_1 = __importDefault(require("path"));
const utils_1 = require("../utils");
const PNPM_DEPTH_ATTEMPTS = ['Infinity', '8', '4', '2', '1'];
const PNPM_MAX_OLD_SPACE_SIZE_MB = '8192';
// Normalize package-manager-specific list output into a shared dependency tree.
async function runNpmLs(projectPath, tempDir, tool = 'npm') {
async function runNpmLs(projectPath, tempDir, tool = 'npm', options = {}) {
const targetFile = path_1.default.join(tempDir, `${tool}-ls.json`);
try {
if (tool === 'pnpm') {
return await runPnpmLsWithFallback(projectPath, targetFile, options);
}
const { args, normalize } = buildLsCommand(tool);
const result = await (0, utils_1.runCommand)(tool, args, { cwd: projectPath });
const parsed = parseJsonOutput(result.stdout);
Expand All @@ -19,9 +24,7 @@ async function runNpmLs(projectPath, tempDir, tool = 'npm') {
return { ok: true, data: normalized, file: targetFile };
}
await (0, utils_1.writeJsonFile)(targetFile, { stdout: result.stdout, stderr: result.stderr, code: result.code });
const error = result.code && result.code !== 0
? `${tool} ls exited with code ${result.code}`
: `Failed to parse ${tool} ls output`;
const error = buildLsFailureMessage(tool, result.code, result.stderr);
return { ok: false, error, file: targetFile };
}
catch (err) {
Expand All @@ -30,12 +33,6 @@ async function runNpmLs(projectPath, tempDir, tool = 'npm') {
}
}
function buildLsCommand(tool) {
if (tool === 'pnpm') {
return {
args: ['list', '--json', '--depth', 'Infinity'],
normalize: normalizePnpmTree
};
}
if (tool === 'yarn') {
return {
args: ['list', '--json', '--depth', 'Infinity'],
Expand All @@ -47,6 +44,115 @@ function buildLsCommand(tool) {
normalize: normalizeNpmTree
};
}
async function runPnpmLsWithFallback(projectPath, targetFile, options) {
const normalize = normalizePnpmTree;
const attempts = [];
const env = {
NODE_OPTIONS: ensureNodeMaxOldSpaceSize(process.env.NODE_OPTIONS, PNPM_MAX_OLD_SPACE_SIZE_MB)
};
for (let index = 0; index < PNPM_DEPTH_ATTEMPTS.length; index++) {
const depth = PNPM_DEPTH_ATTEMPTS[index];
const result = await (0, utils_1.runCommand)('pnpm', ['list', '--json', '--depth', depth], {
cwd: projectPath,
env
});
const parsed = parseJsonOutput(result.stdout);
const normalized = normalize(parsed);
const outOfMemory = isOutOfMemoryError(result.stderr);
attempts.push({
depth,
code: result.code,
stdoutBytes: Buffer.byteLength(result.stdout || '', 'utf8'),
stderrPreview: trimText(result.stderr, 1200),
outOfMemory
});
if (normalized) {
if (index > 0) {
progress(options, `✔ PNPM ls recovered for workspace: ${formatContextLabel(options)} (depth=${depth})`);
}
await (0, utils_1.writeJsonFile)(targetFile, normalized);
return { ok: true, data: normalized, file: targetFile };
}
const reason = describeAttemptFailure(result.code, result.stderr);
progress(options, `✖ Failed pnpm ls for workspace: ${formatContextLabel(options)} (depth=${depth}; ${reason})`);
const nextDepth = PNPM_DEPTH_ATTEMPTS[index + 1];
if (nextDepth) {
progress(options, `✔ Retrying pnpm ls for workspace: ${formatContextLabel(options)} (depth=${nextDepth})`);
}
}
await (0, utils_1.writeJsonFile)(targetFile, {
error: 'pnpm ls retries exhausted',
nodeOptions: env.NODE_OPTIONS,
attempts
});
const sawOom = attempts.some((attempt) => attempt.outOfMemory);
const lastAttempt = attempts[attempts.length - 1];
if (sawOom) {
return {
ok: false,
error: 'pnpm ls ran out of memory while building the dependency tree (retried with lower depths).',
file: targetFile
};
}
const suffix = lastAttempt && typeof lastAttempt.code === 'number'
? ` Last exit code: ${lastAttempt.code}.`
: '';
return {
ok: false,
error: `Failed to parse pnpm ls output after retries.${suffix}`,
file: targetFile
};
}
function progress(options, line) {
if (typeof options.onProgress === 'function') {
options.onProgress(line);
}
}
function formatContextLabel(options) {
var _a;
const label = (_a = options.contextLabel) === null || _a === void 0 ? void 0 : _a.trim();
return label || '(unknown package)';
}
function describeAttemptFailure(code, stderr) {
if (isOutOfMemoryError(stderr))
return 'out of memory';
if (typeof code === 'number' && code !== 0)
return `exit code ${code}`;
if (code === null && stderr && stderr.trim())
return 'terminated before completion';
return 'no parseable JSON output';
}
function ensureNodeMaxOldSpaceSize(existing, megabytes) {
const token = '--max-old-space-size=';
if (typeof existing === 'string' && existing.includes(token)) {
return existing;
}
const option = `${token}${megabytes}`;
return existing && existing.trim() ? `${existing.trim()} ${option}` : option;
}
function isOutOfMemoryError(stderr) {
return /heap out of memory|Reached heap limit|Allocation failed - JavaScript heap out of memory/i.test(stderr || '');
}
function trimText(text, maxChars) {
if (!text)
return '';
const trimmed = text.trim();
if (trimmed.length <= maxChars)
return trimmed;
return trimmed.slice(trimmed.length - maxChars);
}
function buildLsFailureMessage(tool, code, stderr) {
if (isOutOfMemoryError(stderr)) {
return `${tool} ls ran out of memory while building the dependency tree`;
}
if (typeof code === 'number' && code !== 0) {
return `${tool} ls exited with code ${code}`;
}
if (code === null && stderr && stderr.trim()) {
return `${tool} ls failed before completion`;
}
return `Failed to parse ${tool} ls output`;
}
function parseJsonOutput(raw) {
if (!raw)
return undefined;
Expand Down
3 changes: 2 additions & 1 deletion dist/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ function runCommand(command, args, options = {}) {
return new Promise((resolve, reject) => {
const child = (0, child_process_1.spawn)(command, args, {
cwd: options.cwd,
shell: false
shell: false,
env: options.env ? { ...process.env, ...options.env } : process.env
});
const stdoutChunks = [];
const stderrChunks = [];
Expand Down
Loading