From a51366ac61306f5f2da3d9b9ebd002126421fdfc Mon Sep 17 00:00:00 2001 From: Guillaume Flambard <56681566+guillaume-flambard@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:48:29 +0100 Subject: [PATCH] chore(quality): validate staged snapshots before commits --- .githooks/pre-commit | 18 ++++++++++++ package.json | 5 +++- scripts/check-staged-syntax.mjs | 38 ++++++++++++++++++++++++ scripts/install-git-hooks.sh | 18 ++++++++++++ tests/precommit.test.mjs | 52 +++++++++++++++++++++++++++++++++ 5 files changed, 130 insertions(+), 1 deletion(-) create mode 100755 .githooks/pre-commit create mode 100644 scripts/check-staged-syntax.mjs create mode 100755 scripts/install-git-hooks.sh create mode 100644 tests/precommit.test.mjs diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..d60e420 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,18 @@ +#!/bin/sh +# Fast checks for the staged snapshot, before a commit is created. +set -eu +cd "$(git rev-parse --show-toplevel)" + +# Reject whitespace errors introduced by this commit, not pre-existing files. +git diff --cached --check + +# Reject obvious secrets and credentials before the push/CI boundary. +if git diff --cached --name-only --diff-filter=ACMR | grep -E '(^|/)\.env($|\.(local|production|development|test)$)|\.(pem|p12|p8)$' >/dev/null; then + printf '%s\n' "pre-commit: a credential or private environment file is staged; remove it from the index." >&2 + exit 1 +fi + +# Project-specific fast checks; the template runs its tiny Node smoke tests. +if [ -f package.json ] && command -v npm >/dev/null 2>&1; then + npm run check:staged --if-present +fi diff --git a/package.json b/package.json index 9fcc170..0a93027 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,10 @@ "eval:adjudicate-holdout": "node packages/agents/holdout-adjudication.mjs", "diagnostics:knn": "node packages/agents/knn-audit.mjs", "market:hypothesis": "node packages/agents/market-hypothesis.mjs", - "diagnostics:knn-load": "node packages/agents/knn-load-benchmark.mjs" + "diagnostics:knn-load": "node packages/agents/knn-load-benchmark.mjs", + "prepare": "sh scripts/install-git-hooks.sh", + "hooks:install": "sh scripts/install-git-hooks.sh", + "check:staged": "node scripts/check-staged-syntax.mjs" }, "dependencies": { "pg": "^8.23.1" diff --git a/scripts/check-staged-syntax.mjs b/scripts/check-staged-syntax.mjs new file mode 100644 index 0000000..4cdb01a --- /dev/null +++ b/scripts/check-staged-syntax.mjs @@ -0,0 +1,38 @@ +// Parse exactly the Git index snapshot, not potentially different worktree files. +import { spawnSync } from 'node:child_process'; + +function git(args) { + const result = spawnSync('git', args, { maxBuffer: 16 * 1024 * 1024 }); + if (result.status !== 0) { + process.stderr.write(result.stderr || Buffer.from('git index read failed\n')); + process.exit(1); + } + return result.stdout; +} + +const paths = git(['diff', '--cached', '--name-only', '--diff-filter=ACMR', '-z']) + .toString('utf8').split('\0').filter(Boolean); +let errors = 0; +for (const path of paths) { + if (!/\.(?:mjs|cjs|js|json)$/.test(path)) continue; + const staged = git(['show', ':' + path]); + if (path.endsWith('.json')) { + try { JSON.parse(staged.toString('utf8')); } + catch (error) { + console.error('Invalid staged JSON:', path, error.message); + errors++; + } + continue; + } + const mode = path.endsWith('.cjs') ? 'commonjs' : 'module'; + const check = spawnSync(process.execPath, ['--check', '--input-type=' + mode], { + input: staged, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, + }); + if (check.status !== 0) { + console.error('Invalid staged JavaScript:', path); + console.error(check.stderr || check.stdout); + errors++; + } +} +if (errors) process.exit(1); +console.log('staged JS/JSON syntax: OK'); diff --git a/scripts/install-git-hooks.sh b/scripts/install-git-hooks.sh new file mode 100755 index 0000000..7e484b0 --- /dev/null +++ b/scripts/install-git-hooks.sh @@ -0,0 +1,18 @@ +#!/bin/sh +# Install reproducible project hooks without replacing a custom local setup. +set -eu +cd "$(dirname "$0")/.." + +# npm install also runs prepare in non-Git deployment archives. +[ -e .git ] || exit 0 +[ "${CI:-}" = "true" ] && exit 0 + +current="$(git config --local --get core.hooksPath || true)" +if [ -n "$current" ] && [ "$current" != ".githooks" ]; then + printf '%s\n' "git hooks: preserving existing core.hooksPath=$current" + exit 0 +fi + +chmod +x .githooks/pre-commit +git config --local core.hooksPath .githooks +printf '%s\n' "git hooks: .githooks/pre-commit installed" diff --git a/tests/precommit.test.mjs b/tests/precommit.test.mjs new file mode 100644 index 0000000..604725f --- /dev/null +++ b/tests/precommit.test.mjs @@ -0,0 +1,52 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, copyFileSync, writeFileSync, chmodSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const sourceRoot = fileURLToPath(new URL('..', import.meta.url)); + +function run(root, command, args, extraEnv = {}) { + return spawnSync(command, args, { + cwd: root, + encoding: 'utf8', + env: { ...process.env, CI: '', ...extraEnv }, + }); +} + +test('installer activates a real hook that rejects bad staged content', () => { + const root = mkdtempSync(join(tmpdir(), 'memo-precommit-')); + try { + mkdirSync(join(root, '.githooks')); + mkdirSync(join(root, 'scripts')); + copyFileSync(join(sourceRoot, '.githooks/pre-commit'), join(root, '.githooks/pre-commit')); + copyFileSync(join(sourceRoot, 'scripts/install-git-hooks.sh'), join(root, 'scripts/install-git-hooks.sh')); + writeFileSync(join(root, 'package.json'), JSON.stringify({ + private: true, + scripts: { 'check:staged': 'node -e "process.exit(0)"' }, + })); + + assert.equal(run(root, 'git', ['init', '-q']).status, 0); + assert.equal(run(root, 'sh', ['scripts/install-git-hooks.sh']).status, 0); + assert.equal(run(root, 'git', ['config', '--local', '--get', 'core.hooksPath']).stdout.trim(), '.githooks'); + + writeFileSync(join(root, 'sample.txt'), 'bad whitespace \n'); + assert.equal(run(root, 'git', ['add', 'sample.txt']).status, 0); + const bad = run(root, 'sh', ['.githooks/pre-commit']); + assert.notEqual(bad.status, 0, 'trailing whitespace must fail'); + + writeFileSync(join(root, 'sample.txt'), 'clean content\n'); + assert.equal(run(root, 'git', ['add', 'sample.txt']).status, 0); + const good = run(root, 'sh', ['.githooks/pre-commit']); + assert.equal(good.status, 0, good.stderr + good.stdout); + + writeFileSync(join(root, '.env'), 'DUMMY=fake\n'); + assert.equal(run(root, 'git', ['add', '-f', '.env']).status, 0); + const secret = run(root, 'sh', ['.githooks/pre-commit']); + assert.notEqual(secret.status, 0, 'credential-like files must fail'); + } finally { + rmSync(root, { recursive: true, force: true }); + } +});