plugin-inspector/scripts/check-package-contents.mjs
2026-04-27 21:45:20 -07:00

139 lines
4.3 KiB
JavaScript

#!/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(/<img\s+[^>]*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}`);
}
}
}