#!/usr/bin/env node import { execFileSync } from "node:child_process"; import { readFile } from "node:fs/promises"; import path from "node:path"; import process from "node:process"; import { parseFlagArgs, stringFlag } from "./lib/arg-utils.mts"; import { REPORT_CLI_PARSE_OPTIONS, writeReportArtifact } from "./lib/report-cli-helpers.mts"; import { collectAllResolvedPackagesFromLockfile, createBulkAdvisoryPayload, } from "./pre-commit/pnpm-audit-prod.mjs"; const DEPENDENCY_FILE_PATTERNS = [ /^\.github\/release\/[^/]+\/package-lock\.json$/u, /^package\.json$/u, /^pnpm-lock\.yaml$/u, /^pnpm-workspace\.yaml$/u, /^patches\//u, /\/package\.json$/u, ]; const DEPENDENCY_DIFF_PATHS = [ ".github/release/clawhub-cli/package-lock.json", ".github/release/vercel-cli/package-lock.json", "package.json", "pnpm-lock.yaml", "pnpm-workspace.yaml", "*package.json", "patches", ]; type DependencyPayload = Record; type DependencyFileChange = { oldPath: string | null; path: string; status: string }; function payloadFromLockfile(lockfileText: string): DependencyPayload { const packages = collectAllResolvedPackagesFromLockfile(lockfileText); return createBulkAdvisoryPayload(packages) satisfies DependencyPayload; } export function createDependencyChangesReport({ basePayload, headPayload, dependencyFileChanges = [], baseLabel = "base", headLabel = "head", generatedAt = new Date().toISOString(), }: { basePayload: DependencyPayload; headPayload: DependencyPayload; dependencyFileChanges?: DependencyFileChange[]; baseLabel?: string; headLabel?: string; generatedAt?: string; }) { const packageNames = [ ...new Set([...Object.keys(basePayload), ...Object.keys(headPayload)]), ].toSorted((left, right) => left.localeCompare(right)); const addedPackages: Array<{ packageName: string; versions: string[] }> = []; const removedPackages: Array<{ packageName: string; versions: string[] }> = []; const changedPackages: Array<{ addedVersions: string[]; packageName: string; removedVersions: string[]; }> = []; for (const packageName of packageNames) { const baseVersions = new Set(basePayload[packageName] ?? []); const headVersions = new Set(headPayload[packageName] ?? []); if (baseVersions.size === 0) { addedPackages.push({ packageName, versions: [...headVersions].toSorted((left, right) => left.localeCompare(right)), }); continue; } if (headVersions.size === 0) { removedPackages.push({ packageName, versions: [...baseVersions].toSorted((left, right) => left.localeCompare(right)), }); continue; } const addedVersions = [...headVersions] .filter((version) => !baseVersions.has(version)) .toSorted((left, right) => left.localeCompare(right)); const removedVersions = [...baseVersions] .filter((version) => !headVersions.has(version)) .toSorted((left, right) => left.localeCompare(right)); if (addedVersions.length > 0 || removedVersions.length > 0) { changedPackages.push({ packageName, addedVersions, removedVersions }); } } return { generatedAt, baseLabel, headLabel, summary: { basePackages: Object.keys(basePayload).length, headPackages: Object.keys(headPayload).length, addedPackages: addedPackages.length, removedPackages: removedPackages.length, changedPackages: changedPackages.length, dependencyFileChanges: dependencyFileChanges.length, }, dependencyFileChanges, addedPackages, removedPackages, changedPackages, }; } function markdownCode(value: unknown) { return `\`${String(value).replaceAll("`", "\\`")}\``; } function renderMarkdownReport(report: ReturnType) { const lines = [ "# Dependency Change Report", "", `Generated: ${report.generatedAt}`, "", "## Target", "", `- Base: ${report.baseLabel}`, `- Head lockfile: ${report.headLabel}`, "", "## Scope", "", "This report compares dependency-related files and resolved lockfile package versions between the selected base and the current checkout.", "", "It reports two related but different things:", "", "- Dependency file changes: package manifests, pnpm workspace config, pnpm lockfile, trusted release CLI package locks, and patches.", "- Resolved package changes: package versions added, removed, or changed in pnpm-lock.yaml.", "", "## Summary", "", "**Dependency files**", `- Changed files: ${report.summary.dependencyFileChanges}`, "", "**Resolved packages**", `- Base: ${report.summary.basePackages}`, `- Head: ${report.summary.headPackages}`, `- Added: ${report.summary.addedPackages}`, `- Removed: ${report.summary.removedPackages}`, `- Changed versions: ${report.summary.changedPackages}`, "", ]; if (report.dependencyFileChanges.length > 0) { lines.push("## Dependency File Changes", ""); for (const item of report.dependencyFileChanges) { lines.push(`- ${markdownCode(item.path)}: ${item.status}`); } lines.push(""); } for (const [title, packages] of [ ["Added", report.addedPackages], ["Removed", report.removedPackages], ] as const) { if (packages.length === 0) { continue; } lines.push(`## ${title} Resolved Packages`, ""); for (const item of packages) { lines.push(`- ${markdownCode(item.packageName)}: ${item.versions.join(", ")}`); } lines.push(""); } if (report.changedPackages.length > 0) { lines.push("## Changed Resolved Package Versions", ""); for (const item of report.changedPackages) { lines.push( `- ${markdownCode(item.packageName)}: +${item.addedVersions.join(", ") || "none"} ` + `-${item.removedVersions.join(", ") || "none"}`, ); } lines.push(""); } return `${lines.join("\n")}\n`; } function readGitFile(ref: string, filePath: string, cwd: string) { return execFileSync("git", ["show", `${ref}:${filePath}`], { cwd, encoding: "utf8", maxBuffer: 100 * 1024 * 1024, }); } export function isDependencyFile(filePath: unknown) { if (typeof filePath !== "string") { return false; } return DEPENDENCY_FILE_PATTERNS.some((pattern) => pattern.test(filePath)); } export function dependencyDiffPathspecs() { return [...DEPENDENCY_DIFF_PATHS]; } function gitDiffDependencyFiles(baseRef: string, cwd: string) { const output = execFileSync( "git", ["diff", "--name-status", baseRef, "--", ...DEPENDENCY_DIFF_PATHS], { cwd, encoding: "utf8", maxBuffer: 20 * 1024 * 1024, }, ); return output .split("\n") .filter(Boolean) .map((line) => { const [status, ...paths] = line.split("\t"); return { status, path: paths.at(-1), oldPath: paths.length > 1 ? (paths.at(0) ?? null) : null, }; }) .filter( (item): item is DependencyFileChange => typeof item.status === "string" && typeof item.path === "string" && isDependencyFile(item.path), ) .toSorted((left, right) => { if (left.path !== right.path) { return left.path.localeCompare(right.path); } return left.status.localeCompare(right.status); }); } export function parseArgs(argv: string[]) { const options: { rootDir: string; baseRef: string | null; baseLockfile: string | null; headLockfile: string; jsonPath: string | null; markdownPath: string | null; } = { rootDir: process.cwd(), baseRef: null, baseLockfile: null, headLockfile: "pnpm-lock.yaml", jsonPath: null, markdownPath: null, }; const flagEntries = [ ["--root", "rootDir"], ["--base-ref", "baseRef"], ["--base-lockfile", "baseLockfile"], ["--head-lockfile", "headLockfile"], ["--json", "jsonPath"], ["--markdown", "markdownPath"], ] satisfies Array<[string, keyof typeof options]>; parseFlagArgs( argv, options, flagEntries.map(([flag, key]) => stringFlag(flag, key, { allowInline: false, missingValueMessage: `${flag} requires a value`, rejectShortOptions: true, }), ), REPORT_CLI_PARSE_OPTIONS, ); const { baseRef, baseLockfile } = options; if (baseRef && baseLockfile) { throw new Error("Use either --base-ref or --base-lockfile, not both."); } if (baseRef) { return { ...options, baseLockfile: null, baseRef }; } if (baseLockfile) { return { ...options, baseLockfile, baseRef: null }; } throw new Error("Expected --base-ref or --base-lockfile ."); } async function runDependencyChangesReport(options: ReturnType) { const headLockfileText = await readFile(path.join(options.rootDir, options.headLockfile), "utf8"); const baseLockfileText = options.baseRef !== null ? readGitFile(options.baseRef, "pnpm-lock.yaml", options.rootDir) : await readFile(path.join(options.rootDir, options.baseLockfile), "utf8"); const dependencyFileChanges = options.baseRef !== null ? gitDiffDependencyFiles(options.baseRef, options.rootDir) : []; return createDependencyChangesReport({ basePayload: payloadFromLockfile(baseLockfileText), headPayload: payloadFromLockfile(headLockfileText), dependencyFileChanges, baseLabel: options.baseRef ?? options.baseLockfile, headLabel: options.headLockfile, }); } export async function main(argv = process.argv.slice(2)) { const options = parseArgs(argv); const report = await runDependencyChangesReport(options); await writeReportArtifact(options.jsonPath, `${JSON.stringify(report, null, 2)}\n`); await writeReportArtifact(options.markdownPath, renderMarkdownReport(report)); const artifactHint = typeof options.markdownPath === "string" ? " See ".concat(options.markdownPath, ".") : ""; process.stdout.write( `INFO dependency change report: ${report.summary.addedPackages} added, ` + `${report.summary.removedPackages} removed, ${report.summary.changedPackages} changed ` + `resolved packages and ${report.summary.dependencyFileChanges} dependency file changes ` + `relative to ${report.baseLabel}.${artifactHint}\n`, ); return 0; } if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename)) { main().then( (exitCode) => { process.exitCode = exitCode; }, (error: unknown) => { process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); process.exitCode = 1; }, ); }