Archived
202 lines
5.8 KiB
JavaScript
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;
|
|
}
|
|
}
|