Agent-JAE/default-extensions/dependency-auditor.ts

257 lines
9.2 KiB
TypeScript

import type { ExtensionAPI } from "@jaeswift/jae-coding-agent";
import { existsSync, readFileSync, readdirSync } from "node:fs";
import { join, relative } from "node:path";
import { Type } from "@sinclair/typebox";
interface AuditResult {
outdated: string[];
vulnerabilities: string[];
unused: string[];
missing: string[];
summary: string;
}
export default function (pi: ExtensionAPI) {
async function parsePackageJson(cwd: string): Promise<{ deps: Record<string, string>; devDeps: Record<string, string>; scripts: Record<string, string> } | null> {
const pkgPath = join(cwd, "package.json");
if (!existsSync(pkgPath)) return null;
try {
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
return {
deps: pkg.dependencies || {},
devDeps: pkg.devDependencies || {},
scripts: pkg.scripts || {},
};
} catch {
return null;
}
}
async function runNpmAudit(cwd: string): Promise<string> {
try {
const result = await pi.exec("npm", ["audit", "--json"], { cwd });
if (result.stdout.trim()) {
try {
const audit = JSON.parse(result.stdout);
const meta = audit.metadata || {};
const vulns = meta.vulnerabilities || {};
const lines: string[] = [];
let totalVulns = 0;
for (const [severity, count] of Object.entries(vulns)) {
if (typeof count === "number" && count > 0) {
lines.push(` ${severity}: ${count}`);
totalVulns += count;
}
}
if (totalVulns === 0) return "\u2705 No known vulnerabilities found.";
return `\u26A0\uFE0F Found ${totalVulns} vulnerabilities:\n${lines.join("\n")}`;
} catch {
return result.stdout.substring(0, 1000);
}
}
return result.stderr.trim() || "No audit data available.";
} catch (err: any) {
return `Audit error: ${err.message}`;
}
}
async function runNpmOutdated(cwd: string): Promise<string[]> {
try {
const result = await pi.exec("npm", ["outdated", "--json"], { cwd });
if (result.stdout.trim()) {
try {
const outdated = JSON.parse(result.stdout);
const lines: string[] = [];
for (const [pkg, info] of Object.entries(outdated)) {
const i = info as any;
lines.push(` ${pkg}: ${i.current || "?"} \u2192 ${i.latest || "?"}`);
}
return lines;
} catch {
return [result.stdout.substring(0, 500)];
}
}
return [];
} catch {
return [];
}
}
async function findUnusedDeps(cwd: string, deps: Record<string, string>): Promise<string[]> {
const unused: string[] = [];
const depNames = Object.keys(deps);
if (depNames.length === 0) return unused;
for (const dep of depNames) {
try {
// Search for import/require of this dep in source files
const result = await pi.exec(
"grep",
["-r", "-l", "--include=*.ts", "--include=*.tsx", "--include=*.js", "--include=*.jsx", "--include=*.mjs", "--include=*.cjs", dep, "."],
{ cwd },
);
if (result.code !== 0 || !result.stdout.trim()) {
// Also check if referenced in config files
const configResult = await pi.exec(
"grep",
["-r", "-l", "--include=*.json", "--include=*.yaml", "--include=*.yml", "--include=*.toml", "--include=*.config.*", dep, "."],
{ cwd },
);
// Exclude package.json and lock files
const configFiles = (configResult.stdout || "").split("\n").filter(
(f) => f.trim() && !f.includes("package.json") && !f.includes("package-lock") && !f.includes("node_modules"),
);
if (configFiles.length === 0) {
unused.push(dep);
}
}
} catch {
// grep failed, skip
}
}
return unused;
}
async function fullAudit(cwd: string): Promise<AuditResult> {
const pkg = await parsePackageJson(cwd);
if (!pkg) {
return {
outdated: [],
vulnerabilities: ["No package.json found in current directory."],
unused: [],
missing: [],
summary: "\u274C No package.json found. Navigate to a Node.js project directory.",
};
}
const allDeps = { ...pkg.deps, ...pkg.devDeps };
const totalDeps = Object.keys(allDeps).length;
// Run checks in parallel
const [outdated, securityReport, unused] = await Promise.all([
runNpmOutdated(cwd),
runNpmAudit(cwd),
findUnusedDeps(cwd, pkg.deps),
]);
// Check for missing deps (referenced but not in package.json)
const missing: string[] = [];
try {
const result = await pi.exec(
"grep",
["-r", "-h", "-o", "--include=*.ts", "--include=*.tsx", "--include=*.js", "--include=*.jsx",
`from ['"][^./][^'"]*['"]`, "."],
{ cwd },
);
if (result.stdout) {
const imports = new Set(
result.stdout.split("\n")
.map((line) => {
const match = line.match(/from\s+['"]([^./][^'"]*)['"]/);
return match ? match[1].split("/")[0] : null;
})
.filter((name): name is string => name !== null && !name.startsWith("node:")),
);
for (const imp of imports) {
if (!allDeps[imp] && !imp.startsWith("@")) {
missing.push(imp);
}
}
}
} catch { /* ignore grep errors */ }
const summaryLines = [
`\u{1F4E6} Dependencies: ${totalDeps} total (${Object.keys(pkg.deps).length} prod, ${Object.keys(pkg.devDeps).length} dev)`,
`\u{1F504} Outdated: ${outdated.length > 0 ? outdated.length + " packages" : "all up to date \u2705"}`,
`\u{1F6E1}\uFE0F Security: ${securityReport}`,
`\u{1F5D1}\uFE0F Potentially unused: ${unused.length > 0 ? unused.join(", ") : "none detected \u2705"}`,
];
if (missing.length > 0) {
summaryLines.push(`\u26A0\uFE0F Possibly missing: ${missing.join(", ")}`);
}
return {
outdated,
vulnerabilities: [securityReport],
unused,
missing,
summary: summaryLines.join("\n"),
};
}
pi.registerTool({
name: "audit_dependencies",
label: "Audit Dependencies",
description: "Scan project dependencies for outdated packages, security vulnerabilities, and unused dependencies. Optionally auto-fix issues.",
parameters: Type.Object({
action: Type.Optional(Type.String({ description: "Action: 'scan' (default), 'fix', 'security'" })),
}),
execute: async (args, ctx) => {
const action = (args.action || "scan").toLowerCase();
if (action === "fix") {
const updateResult = await pi.exec("npm", ["update"], { cwd: ctx.cwd });
const auditFixResult = await pi.exec("npm", ["audit", "fix"], { cwd: ctx.cwd });
return {
output: `\u{1F527} Auto-fix results:\n\nnpm update:\n${updateResult.stdout || updateResult.stderr}\n\nnpm audit fix:\n${auditFixResult.stdout || auditFixResult.stderr}`,
};
}
if (action === "security") {
const report = await runNpmAudit(ctx.cwd);
return { output: `\u{1F6E1}\uFE0F Security Report:\n\n${report}` };
}
const result = await fullAudit(ctx.cwd);
return { output: result.summary };
},
});
pi.registerCommand("audit", {
description: "Audit dependencies: /audit [fix|security]",
handler: async (args, ctx) => {
const subcommand = args.trim().toLowerCase();
if (subcommand === "fix") {
ctx.ui.notify("\u{1F527} Running npm update and audit fix...", "info");
const updateResult = await pi.exec("npm", ["update"], { cwd: ctx.cwd });
const auditFixResult = await pi.exec("npm", ["audit", "fix"], { cwd: ctx.cwd });
ctx.ui.notify(
`\u{1F527} Fix Results:\n\nnpm update:\n${(updateResult.stdout || updateResult.stderr || "done").substring(0, 500)}\n\nnpm audit fix:\n${(auditFixResult.stdout || auditFixResult.stderr || "done").substring(0, 500)}`,
"info",
);
return;
}
if (subcommand === "security") {
ctx.ui.notify("\u{1F6E1}\uFE0F Running security audit...", "info");
const report = await runNpmAudit(ctx.cwd);
ctx.ui.notify(`\u{1F6E1}\uFE0F Security Report:\n\n${report}`, "info");
return;
}
ctx.ui.notify("\u{1F50D} Scanning dependencies...", "info");
const result = await fullAudit(ctx.cwd);
let output = `\u{1F4CB} Dependency Audit Report\n${'\u2500'.repeat(40)}\n\n${result.summary}`;
if (result.outdated.length > 0) {
output += `\n\n\u{1F504} Outdated Packages:\n${result.outdated.join("\n")}`;
}
if (result.unused.length > 0) {
output += `\n\n\u{1F5D1}\uFE0F Potentially Unused:\n ${result.unused.join(", ")}`;
output += `\n Run: npm uninstall ${result.unused.join(" ")}`;
}
if (result.missing.length > 0) {
output += `\n\n\u26A0\uFE0F Possibly Missing:\n ${result.missing.join(", ")}`;
output += `\n Run: npm install ${result.missing.join(" ")}`;
}
output += `\n\n\u{1F4A1} Tip: /audit fix to auto-update, /audit security for vuln details`;
ctx.ui.notify(output, "info");
},
});
}