Archived
perf: scope pre-commit validation
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
echo "Running pre-commit checks: npm run verify"
|
||||
npm run verify
|
||||
echo "Running pre-commit checks: npm run verify:staged"
|
||||
npm run verify:staged
|
||||
|
||||
@@ -35,10 +35,12 @@
|
||||
"build:plugins": "tsc -p tsconfig.plugins.json && node scripts/build-plugins.mjs",
|
||||
"capture:screenshots": "node scripts/capture-screenshots.mjs",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"typecheck:cached": "tsc --noEmit --incremental --tsBuildInfoFile node_modules/.cache/pi-web/typecheck.tsbuildinfo",
|
||||
"knip": "knip",
|
||||
"lint": "eslint \"src/**/*.ts\" \"extensions/**/*.ts\" \"pi-web-plugins/**/*.ts\" vite.config.ts vitest.config.ts",
|
||||
"test": "vitest run --config vitest.config.ts",
|
||||
"verify": "npm run typecheck && npm run lint && npm run knip && npm test",
|
||||
"verify:staged": "node scripts/verify-staged.mjs",
|
||||
"start": "tsx src/server/index.ts",
|
||||
"start:sessiond": "tsx src/server/sessiond.ts",
|
||||
"clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
],
|
||||
]);
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -2,6 +2,6 @@ import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["src/**/*.test.ts", "pi-web-plugins/**/*.test.ts"],
|
||||
include: ["src/**/*.test.ts", "pi-web-plugins/**/*.test.ts", "scripts/**/*.test.mjs"],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user