Skip to content

Commit 743fd4c

Browse files
committed
fix(ci): scope changed shrinkwrap checks
1 parent 33df3be commit 743fd4c

5 files changed

Lines changed: 194 additions & 18 deletions

File tree

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1441,6 +1441,8 @@
14411441
"deps:patches:check": "node scripts/check-package-patches.mjs",
14421442
"deps:pins:check": "node scripts/check-dependency-pins.mjs",
14431443
"deps:shrinkwrap:check": "node scripts/generate-npm-shrinkwrap.mjs --all --check",
1444+
"deps:shrinkwrap:changed:check": "node scripts/generate-npm-shrinkwrap.mjs --changed --check",
1445+
"deps:shrinkwrap:changed:generate": "node scripts/generate-npm-shrinkwrap.mjs --changed",
14441446
"deps:shrinkwrap:generate": "node scripts/generate-npm-shrinkwrap.mjs --all",
14451447
"deps:shrinkwrap:root:check": "node scripts/generate-npm-shrinkwrap.mjs --check",
14461448
"deps:shrinkwrap:root:generate": "node scripts/generate-npm-shrinkwrap.mjs",

scripts/check-changed.mjs

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
listStagedChangedPaths,
99
normalizeChangedPath,
1010
} from "./changed-lanes.mjs";
11+
import { shrinkwrapPackageDirsForChangedPaths } from "./generate-npm-shrinkwrap.mjs";
1112
import { booleanFlag, parseFlagArgs, stringFlag } from "./lib/arg-utils.mjs";
1213
import { printTimingSummary } from "./lib/check-timing-summary.mjs";
1314
import {
@@ -128,6 +129,28 @@ export function shouldRunShrinkwrapGuard(paths) {
128129
return paths.some((changedPath) => SHRINKWRAP_POLICY_PATH_RE.test(changedPath));
129130
}
130131

132+
export function createShrinkwrapGuardCommand(paths) {
133+
if (!shouldRunShrinkwrapGuard(paths)) {
134+
return null;
135+
}
136+
const packageDirs = shrinkwrapPackageDirsForChangedPaths(paths);
137+
if (packageDirs.length === 0) {
138+
return null;
139+
}
140+
return {
141+
name:
142+
packageDirs.length === 1
143+
? "npm shrinkwrap guard"
144+
: `npm shrinkwrap guard (${packageDirs.length} packages)`,
145+
bin: "node",
146+
args: [
147+
"scripts/generate-npm-shrinkwrap.mjs",
148+
"--check",
149+
...packageDirs.flatMap((packageDir) => ["--package-dir", packageDir]),
150+
],
151+
};
152+
}
153+
131154
export async function runChangedCheckViaCrabbox(argv = [], env = process.env) {
132155
console.error(
133156
"[check:changed] OPENCLAW_TESTBOX=1 set; delegating to Blacksmith Testbox via `pnpm crabbox:run`.",
@@ -165,8 +188,14 @@ export function createChangedCheckPlan(result, options = {}) {
165188
add("plugin-sdk wildcard re-exports", ["lint:extensions:no-plugin-sdk-wildcard-reexports"]);
166189
add("duplicate scan target coverage", ["dup:check:coverage"]);
167190
add("dependency pin guard", ["deps:pins:check"]);
168-
if (shouldRunShrinkwrapGuard(result.paths)) {
169-
add("npm shrinkwrap guard", ["deps:shrinkwrap:check"]);
191+
const shrinkwrapGuardCommand = createShrinkwrapGuardCommand(result.paths);
192+
if (shrinkwrapGuardCommand) {
193+
addCommand(
194+
shrinkwrapGuardCommand.name,
195+
shrinkwrapGuardCommand.bin,
196+
shrinkwrapGuardCommand.args,
197+
baseEnv,
198+
);
170199
}
171200
add("package patch guard", ["deps:patches:check"]);
172201

@@ -285,7 +314,10 @@ export function createChangedCheckPlan(result, options = {}) {
285314
export async function runChangedCheck(result, options = {}) {
286315
const baseEnv = resolveLocalHeavyCheckEnv(options.env ?? process.env);
287316
const childEnv = createChangedCheckChildEnv(baseEnv);
288-
const plan = createChangedCheckPlan(result, { ...options, env: childEnv });
317+
const plan = createChangedCheckPlan(result, {
318+
...options,
319+
env: childEnv,
320+
});
289321
const releaseLock = options.dryRun
290322
? () => {}
291323
: acquireLocalHeavyCheckLockSync({
@@ -375,7 +407,7 @@ function ensureCorepackPnpmShimDir() {
375407
}
376408
const dir = mkdtempSync(path.join(tmpdir(), "openclaw-corepack-pnpm-"));
377409
const pnpmPath = path.join(dir, "pnpm");
378-
writeFileSync(pnpmPath, "#!/bin/sh\nexec corepack pnpm \"$@\"\n", "utf8");
410+
writeFileSync(pnpmPath, '#!/bin/sh\nexec corepack pnpm "$@"\n', "utf8");
379411
chmodSync(pnpmPath, 0o755);
380412
writeFileSync(path.join(dir, "pnpm.cmd"), "@echo off\r\ncorepack pnpm %*\r\n", "utf8");
381413
corepackPnpmShimDir = dir;

scripts/generate-npm-shrinkwrap.mjs

Lines changed: 119 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,14 @@ import { tmpdir } from "node:os";
55
import path from "node:path";
66
import { fileURLToPath } from "node:url";
77
import { parse as parseYaml } from "yaml";
8+
import { listChangedPathsFromGit, listStagedChangedPaths } from "./changed-lanes.mjs";
89

910
const ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
1011
const EXACT_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/u;
1112

1213
function usage() {
1314
return [
14-
"Usage: node scripts/generate-npm-shrinkwrap.mjs [--check] [--all|--plugins|--package-dir <dir>]",
15+
"Usage: node scripts/generate-npm-shrinkwrap.mjs [--check] [--all|--plugins|--changed|--package-dir <dir>] [--base <ref>] [--head <ref>] [--staged]",
1516
" default: root package only",
1617
].join("\n");
1718
}
@@ -81,11 +82,10 @@ function readPnpmLockPackages() {
8182
);
8283
}
8384

84-
function readPnpmLockSingleVersionOverrides() {
85-
const lockfile = parseYaml(readFileSync(path.join(ROOT_DIR, "pnpm-lock.yaml"), "utf8"));
85+
function collectPnpmLockPackageVersions(lockfile) {
8686
const packages = lockfile?.packages;
8787
if (!packages || typeof packages !== "object" || Array.isArray(packages)) {
88-
throw new Error("pnpm-lock.yaml is missing package resolution data.");
88+
return new Map();
8989
}
9090
const versionsByName = new Map();
9191
for (const packageKey of Object.keys(packages)) {
@@ -97,6 +97,15 @@ function readPnpmLockSingleVersionOverrides() {
9797
versions.add(parsed.version);
9898
versionsByName.set(parsed.name, versions);
9999
}
100+
return versionsByName;
101+
}
102+
103+
function readPnpmLockSingleVersionOverrides() {
104+
const lockfile = parseYaml(readFileSync(path.join(ROOT_DIR, "pnpm-lock.yaml"), "utf8"));
105+
const versionsByName = collectPnpmLockPackageVersions(lockfile);
106+
if (versionsByName.size === 0) {
107+
throw new Error("pnpm-lock.yaml is missing package resolution data.");
108+
}
100109
return Object.fromEntries(
101110
[...versionsByName.entries()]
102111
.filter(([, versions]) => versions.size === 1)
@@ -105,6 +114,10 @@ function readPnpmLockSingleVersionOverrides() {
105114
);
106115
}
107116

117+
function setKey(values) {
118+
return [...values].toSorted((left, right) => left.localeCompare(right)).join("\0");
119+
}
120+
108121
function mergeOverrides(packageOverrides, workspaceOverrides, pnpmLockOverrides) {
109122
const merged = normalizeOverrides(packageOverrides);
110123
for (const [name, spec] of [
@@ -113,9 +126,7 @@ function mergeOverrides(packageOverrides, workspaceOverrides, pnpmLockOverrides)
113126
]) {
114127
const current = merged[name];
115128
if (current !== undefined && JSON.stringify(current) !== JSON.stringify(spec)) {
116-
throw new Error(
117-
`package.json overrides.${name} conflicts with pnpm lock policy for ${name}`,
118-
);
129+
throw new Error(`package.json overrides.${name} conflicts with pnpm lock policy for ${name}`);
119130
}
120131
merged[name] = spec;
121132
}
@@ -394,21 +405,86 @@ function listPublishablePluginPackageDirs() {
394405
.toSorted((left, right) => left.localeCompare(right));
395406
}
396407

408+
function shrinkwrapPackageDirsForChangedPaths(changedPaths) {
409+
const packageDirs = new Set();
410+
const publishablePluginPackageDirs = new Set(listPublishablePluginPackageDirs());
411+
let hasAmbiguousDependencyPolicyChange = false;
412+
let hasLockfileChange = false;
413+
414+
for (const rawPath of changedPaths) {
415+
const changedPath = String(rawPath ?? "")
416+
.trim()
417+
.replaceAll("\\", "/")
418+
.replace(/^\.\/+/u, "");
419+
if (!changedPath) {
420+
continue;
421+
}
422+
if (changedPath === "package.json" || changedPath === "npm-shrinkwrap.json") {
423+
packageDirs.add(ROOT_DIR);
424+
continue;
425+
}
426+
const extensionMatch = changedPath.match(
427+
/^(extensions\/[^/]+)\/(?:package\.json|npm-shrinkwrap\.json)$/u,
428+
);
429+
if (extensionMatch && publishablePluginPackageDirs.has(extensionMatch[1])) {
430+
packageDirs.add(path.resolve(ROOT_DIR, extensionMatch[1]));
431+
continue;
432+
}
433+
if (changedPath === "pnpm-lock.yaml") {
434+
hasLockfileChange = true;
435+
continue;
436+
}
437+
if (
438+
changedPath === "pnpm-workspace.yaml" ||
439+
changedPath === "scripts/generate-npm-shrinkwrap.mjs"
440+
) {
441+
hasAmbiguousDependencyPolicyChange = true;
442+
}
443+
}
444+
445+
if (hasAmbiguousDependencyPolicyChange) {
446+
return [
447+
ROOT_DIR,
448+
...listPublishablePluginPackageDirs().map((dir) => path.resolve(ROOT_DIR, dir)),
449+
];
450+
}
451+
452+
if (hasLockfileChange) {
453+
return [
454+
ROOT_DIR,
455+
...listPublishablePluginPackageDirs().map((dir) => path.resolve(ROOT_DIR, dir)),
456+
];
457+
}
458+
return [...packageDirs].toSorted((left, right) =>
459+
packageLabel(left).localeCompare(packageLabel(right)),
460+
);
461+
}
462+
397463
function resolvePackageDirs(args) {
398464
const packageDirs = [];
399465
const check = args.includes("--check");
400466
const all = args.includes("--all");
401467
const plugins = args.includes("--plugins");
468+
const changed = args.includes("--changed");
469+
const staged = args.includes("--staged");
402470
const packageDirIndex = args.indexOf("--package-dir");
403-
if (packageDirIndex !== -1 && (all || plugins)) {
404-
throw new Error("--package-dir cannot be combined with --all or --plugins.");
471+
const baseIndex = args.indexOf("--base");
472+
const headIndex = args.indexOf("--head");
473+
if (packageDirIndex !== -1 && (all || plugins || changed)) {
474+
throw new Error("--package-dir cannot be combined with --all, --plugins, or --changed.");
405475
}
406-
if (all && plugins) {
407-
throw new Error("--all cannot be combined with --plugins.");
476+
if ([all, plugins, changed].filter(Boolean).length > 1) {
477+
throw new Error("--all, --plugins, and --changed cannot be combined.");
408478
}
409479
for (let index = 0; index < args.length; index += 1) {
410480
const arg = args[index];
411-
if (arg === "--check" || arg === "--all" || arg === "--plugins") {
481+
if (
482+
arg === "--check" ||
483+
arg === "--all" ||
484+
arg === "--plugins" ||
485+
arg === "--changed" ||
486+
arg === "--staged"
487+
) {
412488
continue;
413489
}
414490
if (arg === "--package-dir") {
@@ -420,9 +496,21 @@ function resolvePackageDirs(args) {
420496
index += 1;
421497
continue;
422498
}
499+
if (arg === "--base" || arg === "--head") {
500+
const value = args[index + 1];
501+
if (!value || value.startsWith("--")) {
502+
throw new Error(`${arg} requires a git ref.`);
503+
}
504+
index += 1;
505+
continue;
506+
}
423507
throw new Error(usage());
424508
}
425509

510+
if (!changed && (baseIndex !== -1 || headIndex !== -1 || staged)) {
511+
throw new Error("--base, --head, and --staged require --changed.");
512+
}
513+
426514
if (all) {
427515
return {
428516
check,
@@ -438,6 +526,20 @@ function resolvePackageDirs(args) {
438526
packageDirs: listPublishablePluginPackageDirs().map((dir) => path.resolve(ROOT_DIR, dir)),
439527
};
440528
}
529+
if (changed) {
530+
const base = baseIndex === -1 ? "origin/main" : args[baseIndex + 1];
531+
const head = headIndex === -1 ? "HEAD" : args[headIndex + 1];
532+
const changedPaths = staged
533+
? listStagedChangedPaths()
534+
: listChangedPathsFromGit({
535+
base,
536+
head,
537+
});
538+
return {
539+
check,
540+
packageDirs: shrinkwrapPackageDirsForChangedPaths(changedPaths),
541+
};
542+
}
441543
return { check, packageDirs: packageDirs.length > 0 ? packageDirs : [ROOT_DIR] };
442544
}
443545

@@ -469,6 +571,10 @@ function updateOrCheckPackage(packageDir, check) {
469571

470572
function main() {
471573
const { check, packageDirs } = resolvePackageDirs(process.argv.slice(2));
574+
if (packageDirs.length === 0) {
575+
process.stdout.write("No shrinkwrap-managed package changes detected.\n");
576+
return;
577+
}
472578
for (const packageDir of packageDirs) {
473579
updateOrCheckPackage(packageDir, check);
474580
}
@@ -492,4 +598,5 @@ export {
492598
normalizeNpmVersionDrift,
493599
parsePnpmPackageKey,
494600
parseLockPackagePath,
601+
shrinkwrapPackageDirsForChangedPaths,
495602
};

test/scripts/changed-lanes.test.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
createPnpmManagedCommand,
1616
shouldDelegateChangedCheckToCrabbox,
1717
shouldRunShrinkwrapGuard,
18+
createShrinkwrapGuardCommand,
1819
} from "../../scripts/check-changed.mjs";
1920
import { cleanupTempDirs, makeTempRepoRoot } from "../helpers/temp-repo.js";
2021

@@ -828,7 +829,7 @@ describe("scripts/changed-lanes", () => {
828829
"lint:extensions:no-plugin-sdk-wildcard-reexports",
829830
"dup:check:coverage",
830831
"deps:pins:check",
831-
"deps:shrinkwrap:check",
832+
"scripts/generate-npm-shrinkwrap.mjs",
832833
"deps:patches:check",
833834
"release-metadata:check",
834835
"ios:version:check",
@@ -861,8 +862,11 @@ describe("scripts/changed-lanes", () => {
861862

862863
const result = detectChangedLanes(["extensions/slack/package.json"]);
863864
const plan = createChangedCheckPlan(result);
865+
const shrinkwrapGuard = createShrinkwrapGuardCommand(["extensions/slack/package.json"]);
864866

865-
expect(plan.commands.map((command) => command.args[0])).toContain("deps:shrinkwrap:check");
867+
expect(shrinkwrapGuard?.args.some((arg) => arg.endsWith("extensions/slack"))).toBe(true);
868+
expect(plan.commands.map((command) => command.name)).toContain("npm shrinkwrap guard");
869+
expect(plan.commands.map((command) => command.args[0])).not.toContain("deps:shrinkwrap:check");
866870
});
867871

868872
it("guards release metadata package changes to the top-level version field", () => {

test/scripts/generate-npm-shrinkwrap.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import path from "node:path";
12
import { describe, expect, it } from "vitest";
23
import {
34
collectOverrideViolations,
@@ -8,6 +9,7 @@ import {
89
normalizeNpmVersionDrift,
910
parsePnpmPackageKey,
1011
parseLockPackagePath,
12+
shrinkwrapPackageDirsForChangedPaths,
1113
} from "../../scripts/generate-npm-shrinkwrap.mjs";
1214

1315
describe("generate-npm-shrinkwrap", () => {
@@ -144,4 +146,33 @@ describe("generate-npm-shrinkwrap", () => {
144146
},
145147
});
146148
});
149+
150+
it("targets changed publishable plugin shrinkwraps", () => {
151+
expect(
152+
shrinkwrapPackageDirsForChangedPaths([
153+
"extensions/acpx/package.json",
154+
"extensions/acpx/npm-shrinkwrap.json",
155+
]).map((packageDir) => path.relative(process.cwd(), packageDir)),
156+
).toEqual(["extensions/acpx"]);
157+
});
158+
159+
it("falls back to every shrinkwrap when lockfile ownership is ambiguous", () => {
160+
const packageDirs = shrinkwrapPackageDirsForChangedPaths(["pnpm-lock.yaml"]).map((packageDir) =>
161+
path.relative(process.cwd(), packageDir),
162+
);
163+
164+
expect(packageDirs).toContain("");
165+
expect(packageDirs).toContain("extensions/acpx");
166+
});
167+
168+
it("falls back to every shrinkwrap when mixed lockfile changes do not map to packages", () => {
169+
const packageDirs = shrinkwrapPackageDirsForChangedPaths([
170+
"extensions/acpx/package.json",
171+
"pnpm-lock.yaml",
172+
]).map((packageDir) => path.relative(process.cwd(), packageDir));
173+
174+
expect(packageDirs).toContain("");
175+
expect(packageDirs).toContain("extensions/acpx");
176+
expect(packageDirs.length).toBeGreaterThan(1);
177+
});
147178
});

0 commit comments

Comments
 (0)