diff --git a/docs/releasing.md b/docs/releasing.md
index 12015d3..cf53b50 100644
--- a/docs/releasing.md
+++ b/docs/releasing.md
@@ -55,9 +55,11 @@ npm trust github @openclaw/plugin-inspector --repo openclaw/plugin-inspector --f
npm run release:local
```
-This runs tests and `npm pack --dry-run`. Once a version has been published,
-`npm publish --dry-run` rejects that same version, so the real publish check is
-the tag workflow.
+This runs tests and the package-contents guard, which shells through
+`npm pack --dry-run --json` and fails if exported files, README assets, or
+expected examples are missing from the npm tarball. Once a version has been
+published, `npm publish --dry-run` rejects that same version, so the real
+publish check is the tag workflow.
For normal patch prep before tagging, run the combined local readiness gate:
diff --git a/package.json b/package.json
index 4dc1011..884077e 100644
--- a/package.json
+++ b/package.json
@@ -43,13 +43,15 @@
"files": [
"src",
"examples",
+ "docs/plugin-inspector-banner.jpg",
"README.md",
"CHANGELOG.md",
"LICENSE"
],
"scripts": {
- "check": "npm test && npm pack --dry-run",
+ "check": "npm test && npm run release:contents",
"release:crabpot": "node scripts/check-crabpot-followthrough.mjs",
+ "release:contents": "node scripts/check-package-contents.mjs",
"release:readiness": "npm run release:local && npm run release:crabpot",
"release:local": "npm run check",
"release:notes": "node scripts/release-notes.mjs --unreleased",
diff --git a/scripts/check-package-contents.mjs b/scripts/check-package-contents.mjs
new file mode 100644
index 0000000..5108b1b
--- /dev/null
+++ b/scripts/check-package-contents.mjs
@@ -0,0 +1,138 @@
+#!/usr/bin/env node
+import { execFileSync } from "node:child_process";
+import { readFileSync } from "node:fs";
+import path from "node:path";
+import { pathToFileURL } from "node:url";
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ const root = path.resolve(process.argv[2] ?? ".");
+ const packageJson = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8"));
+ const readmeText = readFileSync(path.join(root, "README.md"), "utf8");
+ const filePaths = npmPackFilePaths(root);
+ const result = buildPackageContentsChecklist({ filePaths, packageJson, readmeText });
+
+ printChecklist(result);
+ if (result.status === "fail") {
+ process.exitCode = 1;
+ }
+}
+
+export function buildPackageContentsChecklist({ filePaths, packageJson, readmeText = "" }) {
+ const files = new Set(filePaths);
+ const checks = [
+ ...requiredFileChecks(files),
+ ...entrypointChecks(files, packageJson),
+ ...readmeAssetChecks(files, readmeText),
+ ...forbiddenPathChecks(filePaths),
+ ];
+
+ return {
+ status: checks.some((check) => check.status === "fail") ? "fail" : "pass",
+ entryCount: filePaths.length,
+ checks,
+ };
+}
+
+function requiredFileChecks(files) {
+ return [
+ "package.json",
+ "README.md",
+ "CHANGELOG.md",
+ "LICENSE",
+ "src/cli.js",
+ "src/index.js",
+ "examples/github-actions-plugin-inspector.yml",
+ "examples/plugin-inspector.config.json",
+ ].map((filePath) => presenceCheck(files, "package-required-file", filePath));
+}
+
+function entrypointChecks(files, packageJson) {
+ const checks = [];
+ for (const [name, filePath] of Object.entries(packageJson.bin ?? {})) {
+ checks.push(presenceCheck(files, "package-bin-entry", normalizePackagePath(filePath), name));
+ }
+ for (const [name, target] of Object.entries(packageJson.exports ?? {})) {
+ for (const filePath of exportTargets(target)) {
+ checks.push(presenceCheck(files, "package-export-entry", normalizePackagePath(filePath), name));
+ }
+ }
+ return checks;
+}
+
+function readmeAssetChecks(files, readmeText) {
+ const assetPaths = new Set();
+ for (const match of readmeText.matchAll(/
]*src=["']([^"']+)["']/gi)) {
+ assetPaths.add(match[1]);
+ }
+ for (const match of readmeText.matchAll(/!\[[^\]]*]\(([^)]+)\)/g)) {
+ assetPaths.add(match[1]);
+ }
+
+ return [...assetPaths]
+ .filter((assetPath) => isPackageRelativeAsset(assetPath))
+ .map((assetPath) => presenceCheck(files, "package-readme-asset", normalizePackagePath(assetPath)));
+}
+
+function forbiddenPathChecks(filePaths) {
+ const forbiddenPrefixes = ["test/", "scripts/", ".github/"];
+ return filePaths
+ .filter((filePath) => forbiddenPrefixes.some((prefix) => filePath.startsWith(prefix)))
+ .map((filePath) => ({
+ id: "package-forbidden-path",
+ status: "fail",
+ message: `${filePath} should not be published in the npm package`,
+ }));
+}
+
+function presenceCheck(files, id, filePath, detail = filePath) {
+ return {
+ id,
+ status: files.has(filePath) ? "pass" : "fail",
+ message: `${detail} includes ${filePath}`,
+ expected: filePath,
+ actual: files.has(filePath) ? filePath : "missing",
+ };
+}
+
+function exportTargets(target) {
+ if (typeof target === "string") {
+ return [target];
+ }
+ if (target && typeof target === "object") {
+ return Object.values(target).flatMap((value) => exportTargets(value));
+ }
+ return [];
+}
+
+function isPackageRelativeAsset(assetPath) {
+ return !assetPath.startsWith("http://")
+ && !assetPath.startsWith("https://")
+ && !assetPath.startsWith("#")
+ && !assetPath.startsWith("/");
+}
+
+function normalizePackagePath(filePath) {
+ return filePath.replace(/^\.\//, "");
+}
+
+function npmPackFilePaths(root) {
+ const output = execFileSync("npm", ["pack", "--dry-run", "--json"], {
+ cwd: root,
+ encoding: "utf8",
+ stdio: ["ignore", "pipe", "inherit"],
+ });
+ const pack = JSON.parse(output)[0];
+ return pack.files.map((file) => file.path).sort();
+}
+
+function printChecklist(result) {
+ console.log(`package contents: ${result.status}`);
+ console.log(`package entries: ${result.entryCount}`);
+ for (const check of result.checks) {
+ console.log(`- ${check.status.toUpperCase()} ${check.id}: ${check.message}`);
+ if (check.status === "fail" && check.actual !== check.expected) {
+ console.log(` expected: ${check.expected}`);
+ console.log(` actual: ${check.actual}`);
+ }
+ }
+}
diff --git a/test/package-contents.test.js b/test/package-contents.test.js
new file mode 100644
index 0000000..e84667d
--- /dev/null
+++ b/test/package-contents.test.js
@@ -0,0 +1,60 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import { buildPackageContentsChecklist } from "../scripts/check-package-contents.mjs";
+
+const packageJson = {
+ bin: {
+ "plugin-inspector": "src/cli.js",
+ },
+ exports: {
+ ".": "./src/index.js",
+ "./capture-api": "./src/capture-api.js",
+ },
+};
+
+const requiredFiles = [
+ "package.json",
+ "README.md",
+ "CHANGELOG.md",
+ "LICENSE",
+ "src/cli.js",
+ "src/index.js",
+ "src/capture-api.js",
+ "examples/github-actions-plugin-inspector.yml",
+ "examples/plugin-inspector.config.json",
+ "docs/plugin-inspector-banner.jpg",
+];
+
+test("package contents pass when entrypoints and README assets are packed", () => {
+ const result = buildPackageContentsChecklist({
+ filePaths: requiredFiles,
+ packageJson,
+ readmeText: '
',
+ });
+
+ assert.equal(result.status, "pass");
+});
+
+test("package contents fail when README assets are missing", () => {
+ const result = buildPackageContentsChecklist({
+ filePaths: requiredFiles.filter((filePath) => filePath !== "docs/plugin-inspector-banner.jpg"),
+ packageJson,
+ readmeText: '
',
+ });
+
+ assert.equal(result.status, "fail");
+ assert.equal(result.checks.find((check) => check.id === "package-readme-asset").actual, "missing");
+});
+
+test("package contents fail when private release scripts are packed", () => {
+ const result = buildPackageContentsChecklist({
+ filePaths: [...requiredFiles, "scripts/release-notes.mjs"],
+ packageJson,
+ });
+
+ assert.equal(result.status, "fail");
+ assert.equal(
+ result.checks.find((check) => check.id === "package-forbidden-path").message,
+ "scripts/release-notes.mjs should not be published in the npm package",
+ );
+});