From b842222eeb9924e64232d8e62109f1765df98a10 Mon Sep 17 00:00:00 2001 From: gloskull Date: Sat, 18 Jul 2026 13:36:40 +0100 Subject: [PATCH] Add pre-commit quality hook suite --- .githooks/pre-commit | 4 ++ docs/pre-commit-hooks.md | 25 ++++++++++ package-lock.json | 1 + package.json | 4 +- scripts/pre-commit.mjs | 97 ++++++++++++++++++++++++++++++++++++ tests/unit/preCommit.test.ts | 58 +++++++++++++++++++++ 6 files changed, 188 insertions(+), 1 deletion(-) create mode 100755 .githooks/pre-commit create mode 100644 docs/pre-commit-hooks.md create mode 100644 scripts/pre-commit.mjs create mode 100644 tests/unit/preCommit.test.ts diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..1e7c14a --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +set -eu + +npm run precommit:check diff --git a/docs/pre-commit-hooks.md b/docs/pre-commit-hooks.md new file mode 100644 index 0000000..f4dcc6e --- /dev/null +++ b/docs/pre-commit-hooks.md @@ -0,0 +1,25 @@ +# Pre-Commit Hook Suite + +The repository uses a Git `pre-commit` hook to keep code quality checks close to the point where changes are authored. + +## Architecture + +- `.githooks/pre-commit` is the Git entry point and delegates to `npm run precommit:check`. +- `scripts/pre-commit.mjs` discovers staged files with `git diff --cached --name-only --diff-filter=ACMR`. +- The hook only runs checks that are relevant to the staged files: + - text safety scan for unresolved merge markers and common secret patterns; + - ESLint for staged JavaScript and TypeScript files; + - Vitest for staged unit/component test files; + - package lock synchronization when `package.json` or `package-lock.json` changes. +- `npm install` runs the `prepare` script, which sets `core.hooksPath` to `.githooks` for the local checkout. + +## Operational Notes + +- Run `npm run precommit:check` manually before pushing to reproduce hook behavior. +- Use `git commit --no-verify` only for emergency recovery, and follow up with a normal commit that passes the hook before opening a pull request. +- CI should still run the full build, test, and security review suite; the local hook is a fast feedback layer, not a replacement for CI. + +## Monitoring and Rollout + +- Treat hook failures as local quality signals; aggregate CI failures by command name (`eslint`, `vitest`, and lockfile sync) for dashboards and alerting. +- Roll out hook policy changes in canary fashion by first documenting the expected commands, then enabling them in `.githooks/pre-commit` after developers have the dependencies installed. diff --git a/package-lock.json b/package-lock.json index fd95c3a..c523aa8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5524,6 +5524,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, diff --git a/package.json b/package.json index bb5c1b4..56dc076 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,9 @@ "test:watch": "vitest", "test:ui": "vitest --ui", "test:visual": "npx playwright test tests/visual", - "visual:update-baselines": "npx tsx scripts/update-baselines.ts" + "visual:update-baselines": "npx tsx scripts/update-baselines.ts", + "prepare": "git config core.hooksPath .githooks", + "precommit:check": "node scripts/pre-commit.mjs" }, "dependencies": { "@stellar/stellar-sdk": "^15.1.0", diff --git a/scripts/pre-commit.mjs b/scripts/pre-commit.mjs new file mode 100644 index 0000000..8528cfc --- /dev/null +++ b/scripts/pre-commit.mjs @@ -0,0 +1,97 @@ +#!/usr/bin/env node +import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; + +const TEXT_FILE_PATTERN = /\.(?:[cm]?[jt]sx?|json|md|css|scss|html|ya?ml|mjs|cjs|sh)$/i; +const LINT_FILE_PATTERN = /\.(?:[cm]?[jt]sx?|[cm]?js)$/i; +const TEST_FILE_PATTERN = /^tests\/.*\.test\.(?:[cm]?[jt]sx?)$/i; +const SECRET_PATTERN = /(AKIA[0-9A-Z]{16}|-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----|(?:api[_-]?key|secret|token|password)\s*[:=]\s*["'][^"']{16,})/i; + +export function getStagedFiles(git = runGit) { + const result = git(["diff", "--cached", "--name-only", "--diff-filter=ACMR"]); + if (result.status !== 0) { + throw new Error(result.stderr || "Unable to read staged files"); + } + return result.stdout.split(/\r?\n/).map((file) => file.trim()).filter(Boolean); +} + +export function planChecks(files) { + const existing = files.filter((file) => existsSync(file)); + const lintFiles = existing.filter((file) => LINT_FILE_PATTERN.test(file)); + const textFiles = existing.filter((file) => TEXT_FILE_PATTERN.test(file)); + const commands = []; + + if (lintFiles.length > 0) { + commands.push(["npx", ["eslint", "--max-warnings=0", ...lintFiles]]); + } + + const testFiles = existing.filter((file) => TEST_FILE_PATTERN.test(file)); + if (testFiles.length > 0) { + commands.push(["npm", ["test", "--", ...testFiles, "--passWithNoTests"]]); + } + + if (existing.includes("package.json") || existing.includes("package-lock.json")) { + commands.push(["npm", ["install", "--package-lock-only", "--ignore-scripts"]]); + } + + return { textFiles, commands }; +} + +export function scanTextFiles(files) { + const violations = []; + for (const file of files) { + const content = readFileSync(file, "utf8"); + if (/^(<<<<<<< |=======|>>>>>>> )/m.test(content)) { + violations.push(`${file}: contains unresolved merge conflict markers`); + } + if (SECRET_PATTERN.test(content)) { + violations.push(`${file}: contains a value that looks like a secret`); + } + } + return violations; +} + +function runGit(args) { + return spawnSync("git", args, { encoding: "utf8" }); +} + +function runCommand(command, args) { + process.stdout.write(`pre-commit: ${command} ${args.join(" ")}\n`); + return spawnSync(command, args, { stdio: "inherit", shell: process.platform === "win32" }); +} + +export function main({ run = runCommand, git = runGit } = {}) { + let files; + try { + files = getStagedFiles(git); + } catch (error) { + console.error(`pre-commit: ${error.message}`); + return 1; + } + + if (files.length === 0) { + process.stdout.write("pre-commit: no staged files to check\n"); + return 0; + } + + const { textFiles, commands } = planChecks(files); + const violations = scanTextFiles(textFiles); + if (violations.length > 0) { + console.error(["pre-commit: blocked commit", ...violations.map((violation) => `- ${violation}`)].join("\n")); + return 1; + } + + for (const [command, args] of commands) { + const result = run(command, args); + if (result.status !== 0) { + return result.status ?? 1; + } + } + + process.stdout.write("pre-commit: all checks passed\n"); + return 0; +} + +if (import.meta.url === `file://${process.argv[1]}`) { + process.exit(main()); +} diff --git a/tests/unit/preCommit.test.ts b/tests/unit/preCommit.test.ts new file mode 100644 index 0000000..90d1abc --- /dev/null +++ b/tests/unit/preCommit.test.ts @@ -0,0 +1,58 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { planChecks, scanTextFiles } from "../../scripts/pre-commit.mjs"; + +let tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs) { + rmSync(dir, { recursive: true, force: true }); + } + tempDirs = []; +}); + +function tempFile(name: string, content: string) { + const dir = mkdtempSync(join(tmpdir(), "pre-commit-")); + tempDirs.push(dir); + const file = join(dir, name); + writeFileSync(file, content); + return file; +} + +describe("pre-commit hook planning", () => { + it("runs lint and targeted tests for staged test files", () => { + const file = "tests/unit/preCommit.test.ts"; + + const plan = planChecks([file]); + + expect(plan.commands).toContainEqual(["npx", ["eslint", "--max-warnings=0", file]]); + expect(plan.commands).toContainEqual(["npm", ["test", "--", file, "--passWithNoTests"]]); + }); + + it("runs lockfile synchronization for package changes", () => { + const plan = planChecks(["package.json"]); + + expect(plan.commands).toContainEqual(["npm", ["install", "--package-lock-only", "--ignore-scripts"]]); + }); +}); + +describe("pre-commit text scanning", () => { + it("blocks unresolved merge conflict markers", () => { + const file = tempFile("conflict.ts", ["<<<<<<< HEAD", "const a = 1;", "=======", "const a = 2;", ">>>>>>> branch", ""].join("\n")); + + expect(scanTextFiles([file])).toEqual([ + `${file}: contains unresolved merge conflict markers`, + ]); + }); + + it("blocks common secret-shaped values", () => { + const file = tempFile("secret.ts", "const token = '" + "12345678901234567890" + "';\n"); + + expect(scanTextFiles([file])).toEqual([ + `${file}: contains a value that looks like a secret`, + ]); + }); +});