openclaw/scripts/dependency-changes-report.mjs
Josh Avant bd4db5ee62
Add dependency release safety evidence and PR awareness (#81325)
* test: cover dependency pin guard

* build: add dependency vulnerability gate

* build: add dependency risk report

* build: add dependency drift reports

* build: include dependency ownership surface evidence

* build: rename dependency report commands

* build: respect release age exclusions in risk report

* build: clarify transitive risk accounting

* build: remove transitive risk exception registry

* build: clarify transitive risk signal wording

* ci: attach dependency evidence to release preflight

* ci: extract dependency release evidence generator

* build: rename ownership surface dependency report

* ci: clarify release evidence naming

* build: clarify recently published risk report

* build: reorder transitive risk report sections

* build: fix ownership surface pluralization

* ci: surface dependency changes on PRs

* ci: harden dependency change awareness

* ci: use dependency changed PR label

* build: fix dependency report lint

* docs: add dependency safety changelog
2026-05-13 03:05:09 -05:00

312 lines
9.2 KiB
JavaScript

#!/usr/bin/env node
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 = [
/^package\.json$/u,
/^pnpm-lock\.yaml$/u,
/^pnpm-workspace\.yaml$/u,
/^patches\//u,
/\/package\.json$/u,
];
function payloadFromLockfile(lockfileText) {
return createBulkAdvisoryPayload(collectAllResolvedPackagesFromLockfile(lockfileText));
}
function versionsFor(payload, packageName) {
return new Set(payload[packageName] ?? []);
}
export function createDependencyChangesReport({
basePayload,
headPayload,
dependencyFileChanges = [],
baseLabel = "base",
headLabel = "head",
generatedAt = new Date().toISOString(),
}) {
const packageNames = [
...new Set([...Object.keys(basePayload), ...Object.keys(headPayload)]),
].toSorted((left, right) => left.localeCompare(right));
const addedPackages = [];
const removedPackages = [];
const changedPackages = [];
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) {
return `\`${String(value).replaceAll("`", "\\`")}\``;
}
function renderMarkdownReport(report) {
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, lockfile, 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, filePath, cwd) {
return execFileSync("git", ["show", `${ref}:${filePath}`], {
cwd,
encoding: "utf8",
maxBuffer: 100 * 1024 * 1024,
});
}
function isDependencyFile(filePath) {
return DEPENDENCY_FILE_PATTERNS.some((pattern) => pattern.test(filePath));
}
function gitDiffDependencyFiles(baseRef, cwd) {
const output = execFileSync(
"git",
[
"diff",
"--name-status",
baseRef,
"--",
"package.json",
"pnpm-lock.yaml",
"pnpm-workspace.yaml",
"*package.json",
"patches",
],
{
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[0] : null,
};
})
.filter((item) => item.path && isDependencyFile(item.path))
.toSorted((left, right) => {
if (left.path !== right.path) {
return left.path.localeCompare(right.path);
}
return left.status.localeCompare(right.status);
});
}
function parseArgs(argv) {
const options = {
rootDir: process.cwd(),
baseRef: null,
baseLockfile: null,
headLockfile: "pnpm-lock.yaml",
jsonPath: null,
markdownPath: null,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--") {
continue;
}
if (arg === "--root") {
options.rootDir = argv[++index];
continue;
}
if (arg === "--base-ref") {
options.baseRef = argv[++index];
continue;
}
if (arg === "--base-lockfile") {
options.baseLockfile = argv[++index];
continue;
}
if (arg === "--head-lockfile") {
options.headLockfile = argv[++index];
continue;
}
if (arg === "--json") {
options.jsonPath = argv[++index];
continue;
}
if (arg === "--markdown") {
options.markdownPath = argv[++index];
continue;
}
throw new Error(`Unsupported argument: ${arg}`);
}
if (!options.baseRef && !options.baseLockfile) {
throw new Error("Expected --base-ref <git-ref> or --base-lockfile <path>.");
}
return options;
}
async function writeArtifact(filePath, content) {
if (!filePath) {
return;
}
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, content, "utf8");
}
export async function runDependencyChangesReport(options) {
const headLockfileText = await readFile(path.join(options.rootDir, options.headLockfile), "utf8");
const baseLockfileText = options.baseRef
? readGitFile(options.baseRef, "pnpm-lock.yaml", options.rootDir)
: await readFile(path.join(options.rootDir, options.baseLockfile), "utf8");
const dependencyFileChanges = options.baseRef
? 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 writeArtifact(options.jsonPath, `${JSON.stringify(report, null, 2)}\n`);
await writeArtifact(options.markdownPath, renderMarkdownReport(report));
const artifactHint =
typeof options.markdownPath === "string" ? " See " + 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) => {
process.stderr.write(`${error.stack ?? error.message ?? String(error)}\n`);
process.exitCode = 1;
},
);
}