This repository has been archived on 2026-08-23. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
pi-web/scripts/verify-staged.mjs

202 lines
5.8 KiB
JavaScript

import { execFileSync, spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { resolve } from "node:path";
import { pathToFileURL } from "node:url";
const FULL_LINT_TRIGGERS = new Set([
"eslint.config.js",
"tsconfig.json",
]);
const FULL_TEST_TRIGGERS = new Set([
"tsconfig.json",
"vitest.config.ts",
]);
const LINTABLE_ROOT_FILES = new Set([
"vite.config.ts",
"vitest.config.ts",
]);
const LINTABLE_DIRECTORIES = [
"extensions/",
"pi-web-plugins/",
"src/",
];
const RELATED_SOURCE_DIRECTORIES = [
"extensions/",
"pi-web-plugins/",
"plugin-api/",
"scripts/",
"src/",
];
// `vitest related` follows imports, but these suites inspect repository assets at runtime.
const DOCKER_TESTS = [
"src/docker/piWebDockerDocs.test.ts",
"src/docker/piWebDockerEntrypoint.test.ts",
"src/server/dockerControlAssets.test.ts",
];
const DOCKER_DOCS_TEST = "src/docker/piWebDockerDocs.test.ts";
const PLUGIN_PUBLIC_API_TEST = "pi-web-plugins/pluginPublicApi.test.ts";
export function parseNullDelimitedPaths(output) {
const value = Buffer.isBuffer(output) ? output.toString("utf8") : output;
return value.split("\0").filter((path) => path.length > 0);
}
export function createValidationPlan(stagedPaths, options = {}) {
const pathExists = options.pathExists ?? existsSync;
const paths = [...new Set(stagedPaths.map(normalizeRepoPath).filter((path) => path.length > 0))].sort();
const lint = paths.some((path) => FULL_LINT_TRIGGERS.has(path))
? { mode: "full", files: [] }
: scopedValidation(paths.filter((path) => isLintablePath(path) && pathExists(path)), "scoped");
const tests = paths.some((path) => FULL_TEST_TRIGGERS.has(path))
? { mode: "full", files: [] }
: scopedValidation(relatedTestInputs(paths), "related");
return { paths, lint, tests };
}
export function createValidationSteps(plan) {
const steps = [
{
label: "cached whole-project typecheck",
npmArgs: ["run", "typecheck:cached"],
},
{
label: "whole-project Knip analysis",
npmArgs: ["run", "knip"],
},
];
if (plan.lint.mode === "full") {
steps.push({ label: "full ESLint validation (configuration changed)", npmArgs: ["run", "lint"] });
} else if (plan.lint.mode === "scoped") {
steps.push({
label: `ESLint validation for ${String(plan.lint.files.length)} staged file(s)`,
npmArgs: ["exec", "--", "eslint", "--", ...plan.lint.files],
});
}
if (plan.tests.mode === "full") {
steps.push({ label: "full Vitest validation (configuration changed)", npmArgs: ["test"] });
} else if (plan.tests.mode === "related") {
steps.push({
label: `Vitest validation related to ${String(plan.tests.files.length)} staged input(s)`,
npmArgs: [
"exec",
"--",
"vitest",
"related",
"--run",
"--config",
"vitest.config.ts",
"--passWithNoTests",
...plan.tests.files,
],
});
}
return steps;
}
function readStagedPaths() {
const output = execFileSync(
"git",
["diff", "--cached", "--name-only", "--diff-filter=ACMRD", "-z"],
{ encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] },
);
return parseNullDelimitedPaths(output);
}
function relatedTestInputs(paths) {
const inputs = new Set();
for (const path of paths) {
if (isRelatedSourcePath(path)) inputs.add(path);
if (path.startsWith("docker/")) {
for (const test of DOCKER_TESTS) inputs.add(test);
} else if (path === "README.md" || path.startsWith("docs/")) {
inputs.add(DOCKER_DOCS_TEST);
}
if (path.startsWith("pi-web-plugins/")) inputs.add(PLUGIN_PUBLIC_API_TEST);
}
return [...inputs].sort();
}
function isLintablePath(path) {
if (LINTABLE_ROOT_FILES.has(path)) return true;
return path.endsWith(".ts") && LINTABLE_DIRECTORIES.some((directory) => path.startsWith(directory));
}
function isRelatedSourcePath(path) {
if (path === "plugin-api.d.ts") return true;
if (!/\.(?:[cm]?[jt]s|[jt]sx|json)$/u.test(path)) return false;
return RELATED_SOURCE_DIRECTORIES.some((directory) => path.startsWith(directory));
}
function normalizeRepoPath(path) {
return path.replaceAll("\\", "/").replace(/^\.\//u, "");
}
function scopedValidation(files, mode) {
return files.length > 0 ? { mode, files } : { mode: "skip", files: [] };
}
function runNpmStep(step) {
console.log(`\n[pre-commit] ${step.label}`);
const invocation = npmInvocation(step.npmArgs);
const result = spawnSync(invocation.command, invocation.args, { stdio: "inherit" });
if (result.error !== undefined) throw result.error;
return result.status ?? 1;
}
function npmInvocation(npmArgs) {
const npmExecPath = process.env["npm_execpath"];
if (npmExecPath !== undefined && npmExecPath.length > 0) {
return { command: process.execPath, args: [npmExecPath, ...npmArgs] };
}
return {
command: process.platform === "win32" ? "npm.cmd" : "npm",
args: npmArgs,
};
}
function main() {
const plan = createValidationPlan(readStagedPaths());
console.log(`[pre-commit] Planning validation for ${String(plan.paths.length)} staged file(s).`);
for (const step of createValidationSteps(plan)) {
const status = runNpmStep(step);
if (status !== 0) return status;
}
if (plan.lint.mode === "skip") console.log("\n[pre-commit] No staged files require ESLint.");
if (plan.tests.mode === "skip") console.log("[pre-commit] No staged files have related Vitest coverage.");
return 0;
}
function isDirectExecution() {
const entryPath = process.argv[1];
if (entryPath === undefined) return false;
return pathToFileURL(resolve(entryPath)).href === import.meta.url;
}
if (isDirectExecution()) {
try {
process.exitCode = main();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`[pre-commit] ${message}`);
process.exitCode = 1;
}
}