import { parse as parseYaml } from "yaml"; export type Dependency = { name: string; requirement: string | null; scope: "runtime" | "development"; direct: boolean; }; type JsonObject = Record; const MAX_LOCK_DEPTH = 8; function isObject(value: unknown): value is JsonObject { return typeof value === "object" && value !== null && !Array.isArray(value); } function stringMap(value: unknown): Array<[string, string]> { if (!isObject(value)) return []; return Object.entries(value).flatMap(([key, item]) => (typeof item === "string" ? [[key, item]] : [])); } function parseJson(text: string): JsonObject | null { try { const value: unknown = JSON.parse(text); return isObject(value) ? value : null; } catch { return null; } } /** Parses JSON with comments and trailing commas, as used by bun.lock. */ function parseJsonc(text: string): JsonObject | null { let out = ""; let inString = false; for (let index = 0; index < text.length; index++) { const char = text[index]; if (inString) { out += char; if (char === "\\") out += text[++index] ?? ""; else if (char === '"') inString = false; continue; } if (char === '"') inString = true; else if (char === "/" && text[index + 1] === "/") { while (index < text.length && text[index] !== "\n") index++; continue; } out += char; } return parseJson(out.replace(/,(\s*[}\]])/g, "$1")); } export function parsePackageJson(text: string): Dependency[] { const json = parseJson(text); if (!json) return []; const sections: Array<[string, Dependency["scope"]]> = [ ["dependencies", "runtime"], ["optionalDependencies", "runtime"], ["peerDependencies", "runtime"], ["devDependencies", "development"], ]; return sections.flatMap(([key, scope]) => stringMap(json[key]).map(([name, requirement]) => ({ name, requirement, scope, direct: true })), ); } function rootDirectNames(root: unknown): Set { if (!isObject(root)) return new Set(); return new Set( ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"].flatMap((key) => stringMap(root[key]).map(([name]) => name), ), ); } export function parsePackageLock(text: string): Dependency[] { const json = parseJson(text); if (!json) return []; const out: Dependency[] = []; if (isObject(json.packages)) { const direct = rootDirectNames(json.packages[""]); for (const [key, value] of Object.entries(json.packages)) { if (!key || !isObject(value)) continue; const marker = key.lastIndexOf("node_modules/"); if (marker === -1) continue; const name = key.slice(marker + "node_modules/".length); out.push({ name, requirement: typeof value.version === "string" ? value.version : null, scope: value.dev === true ? "development" : "runtime", direct: marker === 0 && direct.has(name), }); } return out; } const walk = (dependencies: unknown, depth: number) => { if (!isObject(dependencies) || depth > MAX_LOCK_DEPTH) return; for (const [name, value] of Object.entries(dependencies)) { if (!isObject(value)) continue; out.push({ name, requirement: typeof value.version === "string" ? value.version : null, scope: value.dev === true ? "development" : "runtime", direct: false, }); walk(value.dependencies, depth + 1); } }; walk(json.dependencies, 0); return out; } export function parseBunLock(text: string): Dependency[] { const json = parseJsonc(text); if (!json || !isObject(json.packages)) return []; const workspaces = isObject(json.workspaces) ? json.workspaces[""] : null; const direct = rootDirectNames(workspaces); const dev = new Set(isObject(workspaces) ? stringMap(workspaces.devDependencies).map(([name]) => name) : []); const out: Dependency[] = []; for (const [key, value] of Object.entries(json.packages)) { if (!Array.isArray(value) || typeof value[0] !== "string") continue; const spec = value[0]; const at = spec.lastIndexOf("@"); if (at <= 0) continue; out.push({ name: spec.slice(0, at), requirement: spec.slice(at + 1), scope: dev.has(key) ? "development" : "runtime", direct: direct.has(key), }); } return out; } export function parseYarnLock(text: string): Dependency[] { const out: Dependency[] = []; let names: string[] = []; for (const line of text.split("\n")) { if (!line.startsWith(" ") && line.trimEnd().endsWith(":") && !line.startsWith("#")) { names = line .slice(0, -1) .split(",") .map((part) => part.trim().replace(/^"|"$/g, "")) .map((spec) => spec.slice(0, spec.indexOf("@", 1))) .filter((name) => name && name !== "__metadata"); continue; } const version = /^\s{2}version:?\s+"?([^"\s]+)"?/.exec(line); if (version && names[0]) { out.push({ name: names[0], requirement: version[1], scope: "runtime", direct: false }); names = []; } } return out; } function pnpmName(key: string): { name: string; version: string | null } { const clean = key.replace(/^\//, "").replace(/\(.*$/, ""); const at = clean.lastIndexOf("@"); if (at <= 0) return { name: clean, version: null }; return { name: clean.slice(0, at), version: clean.slice(at + 1) }; } export function parsePnpmLock(text: string): Dependency[] { let doc: unknown; try { doc = parseYaml(text, { maxAliasCount: 100 }); } catch { return []; } if (!isObject(doc)) return []; const importer = isObject(doc.importers) ? doc.importers["."] : null; const direct = new Set(); const dev = new Set(); if (isObject(importer)) { for (const key of ["dependencies", "optionalDependencies", "devDependencies"]) { const section = importer[key]; if (!isObject(section)) continue; for (const name of Object.keys(section)) { direct.add(name); if (key === "devDependencies") dev.add(name); } } } const packages = isObject(doc.packages) ? doc.packages : {}; return Object.entries(packages).map(([key, value]) => { const { name, version } = pnpmName(key); return { name, requirement: version, scope: dev.has(name) || (isObject(value) && value.dev === true) ? "development" : "runtime", direct: direct.has(name), }; }); } export function parseComposer(text: string, lock: boolean): Dependency[] { const json = parseJson(text); if (!json) return []; if (!lock) { return [ ...stringMap(json.require).map(([name, requirement]) => ({ name, requirement, scope: "runtime" as const, direct: true, })), ...stringMap(json["require-dev"]).map(([name, requirement]) => ({ name, requirement, scope: "development" as const, direct: true, })), ].filter((dependency) => dependency.name.includes("/")); } const fromList = (list: unknown, scope: Dependency["scope"]): Dependency[] => Array.isArray(list) ? list.flatMap((item) => isObject(item) && typeof item.name === "string" ? [ { name: item.name, requirement: typeof item.version === "string" ? item.version : null, scope, direct: false, }, ] : [], ) : []; return [...fromList(json.packages, "runtime"), ...fromList(json["packages-dev"], "development")]; } export function parsePipfileLock(text: string): Dependency[] { const json = parseJson(text); if (!json) return []; const section = (key: string, scope: Dependency["scope"]): Dependency[] => isObject(json[key]) ? Object.entries(json[key]).map(([name, value]) => ({ name, requirement: isObject(value) && typeof value.version === "string" ? value.version : null, scope, direct: false, })) : []; return [...section("default", "runtime"), ...section("develop", "development")]; }