perf: scope pre-commit validation

This commit is contained in:
Pi Web Agent
2026-07-12 17:20:53 +00:00
parent 338faf4b81
commit 205bda3480
5 changed files with 313 additions and 3 deletions
+202
View File
@@ -0,0 +1,202 @@
#!/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;
}
}
+106
View File
@@ -0,0 +1,106 @@
import { describe, expect, it } from "vitest";
import {
createValidationPlan,
createValidationSteps,
parseNullDelimitedPaths,
} from "./verify-staged.mjs";
describe("staged validation planning", () => {
it("parses NUL-delimited Git paths without breaking spaces", () => {
expect(parseNullDelimitedPaths(Buffer.from("src/one.ts\0src/path with spaces/two.ts\0"))).toEqual([
"src/one.ts",
"src/path with spaces/two.ts",
]);
});
it("scopes ESLint and Vitest to staged source files", () => {
const plan = createValidationPlan([
"src/client/src/components/ChatView.ts",
"src/client/src/components/ChatView.test.ts",
"README.md",
], { pathExists: () => true });
expect(plan).toEqual({
paths: [
"README.md",
"src/client/src/components/ChatView.test.ts",
"src/client/src/components/ChatView.ts",
],
lint: {
mode: "scoped",
files: [
"src/client/src/components/ChatView.test.ts",
"src/client/src/components/ChatView.ts",
],
},
tests: {
mode: "related",
files: [
"src/client/src/components/ChatView.test.ts",
"src/client/src/components/ChatView.ts",
"src/docker/piWebDockerDocs.test.ts",
],
},
});
});
it("does not lint deleted files but still gives them to Vitest dependency analysis", () => {
const plan = createValidationPlan(["src/shared/deleted.ts"], { pathExists: () => false });
expect(plan.lint).toEqual({ mode: "skip", files: [] });
expect(plan.tests).toEqual({ mode: "related", files: ["src/shared/deleted.ts"] });
});
it("adds tests for repository assets that are read dynamically", () => {
const plan = createValidationPlan([
"docker/internal/image/install-opensuse-base",
"pi-web-plugins/updates/updatesLogic.ts",
], { pathExists: () => true });
expect(plan.tests).toEqual({
mode: "related",
files: [
"pi-web-plugins/pluginPublicApi.test.ts",
"pi-web-plugins/updates/updatesLogic.ts",
"src/docker/piWebDockerDocs.test.ts",
"src/docker/piWebDockerEntrypoint.test.ts",
"src/server/dockerControlAssets.test.ts",
],
});
});
it("runs only the affected full validator when its configuration changes", () => {
const eslintPlan = createValidationPlan(["eslint.config.js"], { pathExists: () => true });
expect(eslintPlan.lint).toEqual({ mode: "full", files: [] });
expect(eslintPlan.tests).toEqual({ mode: "skip", files: [] });
const vitestPlan = createValidationPlan(["vitest.config.ts"], { pathExists: () => true });
expect(vitestPlan.lint).toEqual({ mode: "scoped", files: ["vitest.config.ts"] });
expect(vitestPlan.tests).toEqual({ mode: "full", files: [] });
const typescriptPlan = createValidationPlan(["tsconfig.json"], { pathExists: () => true });
expect(typescriptPlan.lint).toEqual({ mode: "full", files: [] });
expect(typescriptPlan.tests).toEqual({ mode: "full", files: [] });
});
it("always includes cached typechecking and Knip before scoped checks", () => {
const plan = createValidationPlan(["./src/path with spaces/example.ts"], { pathExists: () => true });
expect(createValidationSteps(plan).map((step) => step.npmArgs)).toEqual([
["run", "typecheck:cached"],
["run", "knip"],
["exec", "--", "eslint", "--", "src/path with spaces/example.ts"],
[
"exec",
"--",
"vitest",
"related",
"--run",
"--config",
"vitest.config.ts",
"--passWithNoTests",
"src/path with spaces/example.ts",
],
]);
});
});