#!/usr/bin/env node 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; } }