Files
openclaw/scripts/dependency-changes-report.mts
Peter Steinberger c70aee247e refactor(scripts): migrate JavaScript tools to TypeScript (#121005)
* refactor(scripts): migrate JavaScript tools to TypeScript

* fix(ci): keep changed-scope preflight zero-install

* fix(ci): preserve zero-install script owners

* fix(ci): complete script migration follow-through

* fix(release): keep stable closeout zero-install

* fix(scripts): preserve standalone execution boundaries

* fix(scripts): repair standalone loader boundaries

* fix(scripts): normalize gateway observation ids

* fix(scripts): keep Docker packager standalone

* test(scripts): preserve rebase cleanup helpers

* test(sessions): use tracked temp directory
2026-08-09 07:21:35 -07:00

386 lines
12 KiB
TypeScript

#!/usr/bin/env node
// Builds dependency change reports from lockfile and manifest diffs.
import { execFileSync } from "node:child_process";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import process from "node:process";
import {
collectAllResolvedPackagesFromLockfile,
createBulkAdvisoryPayload,
} from "./pre-commit/pnpm-audit-prod.mjs";
const DEPENDENCY_FILE_PATTERNS = [
/^\.github\/release\/clawhub-cli\/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",
"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 };
const optionalString = (value: string | null) => value;
function payloadFromLockfile(lockfileText: string): DependencyPayload {
const packages = collectAllResolvedPackagesFromLockfile(lockfileText);
return createBulkAdvisoryPayload(packages) satisfies DependencyPayload;
}
function versionsFor(payload: DependencyPayload, packageName: string) {
return new Set(payload[packageName] ?? []);
}
/**
* Creates a structured dependency diff report from base/head payloads.
*/
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 = versionsFor(basePayload, packageName);
const headVersions = versionsFor(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, the trusted ClawHub CLI package lock, 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("");
}
if (report.addedPackages.length > 0) {
lines.push("## Added Resolved Packages", "");
for (const item of report.addedPackages) {
lines.push(`- ${markdownCode(item.packageName)}: ${item.versions.join(", ")}`);
}
lines.push("");
}
if (report.removedPackages.length > 0) {
lines.push("## Removed Resolved Packages", "");
for (const item of report.removedPackages) {
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,
});
}
/**
* Reports whether a path is a dependency-related file.
*/
export function isDependencyFile(filePath: unknown) {
if (typeof filePath !== "string") {
return false;
}
return DEPENDENCY_FILE_PATTERNS.some((pattern) => pattern.test(filePath));
}
/**
* Returns git pathspecs used for dependency diff collection.
*/
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);
});
}
function readRequiredValue(argv: string[], index: number, flag: string) {
const value = argv[index + 1];
if (!value || value.startsWith("-")) {
throw new Error(`${flag} requires a value`);
}
return value;
}
export function parseArgs(argv: string[]) {
const options = {
rootDir: process.cwd(),
baseRef: optionalString(null),
baseLockfile: optionalString(null),
headLockfile: "pnpm-lock.yaml",
jsonPath: optionalString(null),
markdownPath: optionalString(null),
};
const seen = new Set<string>();
const setOnce = (flag: string, key: keyof typeof options, value: string) => {
if (seen.has(flag)) {
throw new Error(`${flag} was provided more than once.`);
}
seen.add(flag);
Object.assign(options, { [key]: value });
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--") {
continue;
}
if (arg === "--root") {
setOnce(arg, "rootDir", readRequiredValue(argv, index, "--root"));
index += 1;
continue;
}
if (arg === "--base-ref") {
setOnce(arg, "baseRef", readRequiredValue(argv, index, "--base-ref"));
index += 1;
continue;
}
if (arg === "--base-lockfile") {
setOnce(arg, "baseLockfile", readRequiredValue(argv, index, "--base-lockfile"));
index += 1;
continue;
}
if (arg === "--head-lockfile") {
setOnce(arg, "headLockfile", readRequiredValue(argv, index, "--head-lockfile"));
index += 1;
continue;
}
if (arg === "--json") {
setOnce(arg, "jsonPath", readRequiredValue(argv, index, "--json"));
index += 1;
continue;
}
if (arg === "--markdown") {
setOnce(arg, "markdownPath", readRequiredValue(argv, index, "--markdown"));
index += 1;
continue;
}
throw new Error(`Unsupported argument: ${arg}`);
}
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 writeArtifact(filePath: string | null, content: string) {
if (!filePath) {
return;
}
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, content, "utf8");
}
/**
* Generates and writes dependency change report artifacts.
*/
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,
});
}
/**
* Runs the dependency changes report CLI.
*/
export async function main(argv = process.argv.slice(2)) {
const options = parseArgs(argv);
const report = await runDependencyChangesReport(options);
await writeArtifact(options.jsonPath, `${JSON.stringify(report, null, 2)}\n`);
await writeArtifact(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;
},
);
}