From e5af4e3b5cbf4cf6a8043ae353f9eb52f3efeb5b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 21 Apr 2026 21:07:58 +0100 Subject: [PATCH] ci(deps): gate extension-owned root dependencies --- .github/workflows/ci.yml | 1 + package.json | 3 +- scripts/root-dependency-ownership-audit.mjs | 32 ++++++++ .../root-dependency-ownership-audit.test.ts | 78 ++++++++++++++++++- 4 files changed, 112 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ca3e334b76e..5920630242d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1642,6 +1642,7 @@ jobs: run_check "lint:plugins:no-extension-src-imports" pnpm run lint:plugins:no-extension-src-imports run_check "lint:plugins:no-extension-test-core-imports" pnpm run lint:plugins:no-extension-test-core-imports run_check "lint:plugins:plugin-sdk-subpaths-exported" pnpm run lint:plugins:plugin-sdk-subpaths-exported + run_check "deps:root-ownership:check" pnpm deps:root-ownership:check run_check "web-search-provider-boundary" pnpm run lint:web-search-provider-boundaries run_check "web-fetch-provider-boundary" pnpm run lint:web-fetch-provider-boundaries run_check "extension-src-outside-plugin-sdk-boundary" pnpm run lint:extensions:no-src-outside-plugin-sdk diff --git a/package.json b/package.json index 930ef9745c2..b3ddd8f6f19 100644 --- a/package.json +++ b/package.json @@ -1282,6 +1282,7 @@ "deadcode:ts-prune": "pnpm dlx ts-prune src extensions scripts", "deadcode:ts-unused": "pnpm dlx ts-unused-exports tsconfig.json --ignoreTestFiles --exitWithCount", "deps:root-ownership": "node scripts/root-dependency-ownership-audit.mjs", + "deps:root-ownership:check": "node scripts/root-dependency-ownership-audit.mjs --check", "dev": "node scripts/run-node.mjs", "docs:bin": "node scripts/build-docs-list.mjs", "docs:check-i18n-glossary": "node scripts/check-docs-i18n-glossary.mjs", @@ -1382,7 +1383,7 @@ "qa:lab:up": "node --import tsx scripts/qa-lab-up.ts", "qa:lab:up:fast": "node --import tsx scripts/qa-lab-up.ts --use-prebuilt-image --bind-ui-dist --skip-ui-build", "qa:lab:watch": "vite build --watch --config extensions/qa-lab/web/vite.config.ts", - "release:check": "pnpm check:base-config-schema && pnpm check:bundled-channel-config-metadata && pnpm config:docs:check && pnpm plugin-sdk:check-exports && pnpm plugin-sdk:api:check && node --import tsx scripts/release-check.ts", + "release:check": "pnpm deps:root-ownership:check && pnpm check:base-config-schema && pnpm check:bundled-channel-config-metadata && pnpm config:docs:check && pnpm plugin-sdk:check-exports && pnpm plugin-sdk:api:check && node --import tsx scripts/release-check.ts", "release:openclaw:npm:check": "node --import tsx scripts/openclaw-npm-release-check.ts", "release:openclaw:npm:verify-published": "node --import tsx scripts/openclaw-npm-postpublish-verify.ts", "release:plugins:clawhub:check": "node --import tsx scripts/plugin-clawhub-release-check.ts", diff --git a/scripts/root-dependency-ownership-audit.mjs b/scripts/root-dependency-ownership-audit.mjs index cb8e10925b3..6aba86c6a3c 100644 --- a/scripts/root-dependency-ownership-audit.mjs +++ b/scripts/root-dependency-ownership-audit.mjs @@ -268,6 +268,23 @@ export function collectRootDependencyOwnershipAudit(params = {}) { .toSorted((left, right) => left.depName.localeCompare(right.depName)); } +export function collectRootDependencyOwnershipCheckErrors(records) { + return records + .filter((record) => record.category === "extension_only_localizable") + .map((record) => { + const declaredInExtensions = + record.declaredInExtensions.length > 0 + ? `; extension declarations: ${record.declaredInExtensions.join(", ")}` + : ""; + const sampleFiles = + record.sampleFiles.length > 0 ? `; sample imports: ${record.sampleFiles.join(", ")}` : ""; + return ( + `root dependency '${record.depName}' is extension-owned (${record.recommendation})` + + `${declaredInExtensions}${sampleFiles}` + ); + }); +} + function printTextReport(records) { const grouped = new Map(); for (const record of records) { @@ -294,7 +311,22 @@ function printTextReport(records) { function main(argv = process.argv.slice(2)) { const asJson = argv.includes("--json"); + const check = argv.includes("--check"); const records = collectRootDependencyOwnershipAudit(); + if (check) { + const errors = collectRootDependencyOwnershipCheckErrors(records); + if (errors.length > 0) { + for (const error of errors) { + console.error(`[root-dependency-ownership] ${error}`); + } + process.exitCode = 1; + return; + } + if (!asJson) { + console.error("[root-dependency-ownership] ok"); + return; + } + } if (asJson) { console.log(JSON.stringify(records, null, 2)); return; diff --git a/test/scripts/root-dependency-ownership-audit.test.ts b/test/scripts/root-dependency-ownership-audit.test.ts index 8884344719d..3160bdca03e 100644 --- a/test/scripts/root-dependency-ownership-audit.test.ts +++ b/test/scripts/root-dependency-ownership-audit.test.ts @@ -1,9 +1,34 @@ -import { describe, expect, it } from "vitest"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; import { classifyRootDependencyOwnership, + collectRootDependencyOwnershipAudit, + collectRootDependencyOwnershipCheckErrors, collectModuleSpecifiers, } from "../../scripts/root-dependency-ownership-audit.mjs"; +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { force: true, recursive: true }); + } +}); + +function makeTempRepo() { + const dir = mkdtempSync(path.join(tmpdir(), "openclaw-root-deps-audit-")); + tempDirs.push(dir); + return dir; +} + +function writeRepoFile(repoRoot: string, relativePath: string, value: string) { + const filePath = path.join(repoRoot, relativePath); + mkdirSync(path.dirname(filePath), { recursive: true }); + writeFileSync(filePath, value, "utf8"); +} + describe("collectModuleSpecifiers", () => { it("captures require.resolve package lookups used by runtime shims and bundled plugins", () => { expect([ @@ -80,3 +105,54 @@ describe("classifyRootDependencyOwnership", () => { }); }); }); + +describe("collectRootDependencyOwnershipCheckErrors", () => { + it("catches dependencies mirrored at root but only imported by one extension", () => { + const repoRoot = makeTempRepo(); + writeRepoFile( + repoRoot, + "package.json", + JSON.stringify({ dependencies: { "vendor-sdk": "^1.0.0" } }), + ); + writeRepoFile( + repoRoot, + "extensions/qqbot/package.json", + JSON.stringify({ dependencies: { "vendor-sdk": "^1.0.0" } }), + ); + writeRepoFile( + repoRoot, + "extensions/qqbot/src/setup.ts", + 'const sdk = await import("vendor-sdk");\n', + ); + + const records = collectRootDependencyOwnershipAudit({ repoRoot, scanRoots: ["extensions"] }); + + expect(collectRootDependencyOwnershipCheckErrors(records)).toEqual([ + "root dependency 'vendor-sdk' is extension-owned (remove from root package.json and rely on owning extension manifests plus doctor --fix); extension declarations: qqbot:dependencies; sample imports: extensions/qqbot/src/setup.ts", + ]); + }); + + it("fails only extension-owned root dependencies", () => { + expect( + collectRootDependencyOwnershipCheckErrors([ + { + category: "extension_only_localizable", + declaredInExtensions: ["qqbot:dependencies"], + depName: "@tencent-connect/qqbot-connector", + recommendation: + "remove from root package.json and rely on owning extension manifests plus doctor --fix", + sampleFiles: ["extensions/qqbot/src/bridge/setup/finalize.ts"], + }, + { + category: "unreferenced", + declaredInExtensions: [], + depName: "@mozilla/readability", + recommendation: "investigate removal; no direct source imports found in scanned files", + sampleFiles: [], + }, + ]), + ).toEqual([ + "root dependency '@tencent-connect/qqbot-connector' is extension-owned (remove from root package.json and rely on owning extension manifests plus doctor --fix); extension declarations: qqbot:dependencies; sample imports: extensions/qqbot/src/bridge/setup/finalize.ts", + ]); + }); +});