openclaw/scripts/dependency-changes-report.mts
2026-09-28 17:20:56 -07:00

332 lines
10 KiB
TypeScript

#!/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<string, string[]>;
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<typeof createDependencyChangesReport>) {
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<typeof options>(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 <git-ref> or --base-lockfile <path>.");
}
async function runDependencyChangesReport(options: ReturnType<typeof parseArgs>) {
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;
},
);
}