From 205bda3480fb1164ec584b2fbd55e90b966078d3 Mon Sep 17 00:00:00 2001 From: Pi Web Agent Date: Sat, 11 Jul 2026 19:27:30 +0000 Subject: [PATCH] perf: scope pre-commit validation --- .githooks/pre-commit | 4 +- package.json | 2 + scripts/verify-staged.mjs | 202 +++++++++++++++++++++++++++++++++ scripts/verify-staged.test.mjs | 106 +++++++++++++++++ vitest.config.ts | 2 +- 5 files changed, 313 insertions(+), 3 deletions(-) create mode 100644 scripts/verify-staged.mjs create mode 100644 scripts/verify-staged.test.mjs diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 8cc1b15..39d4981 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -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 diff --git a/package.json b/package.json index 115719b..01370a4 100644 --- a/package.json +++ b/package.json @@ -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 })\"", diff --git a/scripts/verify-staged.mjs b/scripts/verify-staged.mjs new file mode 100644 index 0000000..37e4c06 --- /dev/null +++ b/scripts/verify-staged.mjs @@ -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; + } +} diff --git a/scripts/verify-staged.test.mjs b/scripts/verify-staged.test.mjs new file mode 100644 index 0000000..554dc05 --- /dev/null +++ b/scripts/verify-staged.test.mjs @@ -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", + ], + ]); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index e8f564d..da9e63a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -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"], }, });