From 0f7801baf1afcb8bf6da20c5f71eab012981615d Mon Sep 17 00:00:00 2001 From: Jaco du Preez Date: Tue, 3 Nov 2026 20:25:26 +0200 Subject: [PATCH 1/3] feat(hooks): enforce conventional commits and pre-commit checks Add dependency-free tracked git hooks wired through core.hooksPath: - commit-msg rejects non-conventional headers, unknown or uppercase types, empty or malformed scopes, trailing periods, over-long headers, a missing blank line before the body, and "!" without a BREAKING CHANGE footer. Merge, revert, fixup/squash and Initial commit messages are exempt. - pre-commit blocks staged credentials (private keys plus AWS, GitHub, Slack, sk- and Google token shapes), unresolved conflict markers, files over 20 MB and broken .opencode/* redirect stubs. It runs framework validation when .agents/ or tests/agents/ is staged, and plugin tests when .opencode/plugins/ is staged. Supporting changes: - tests/agents/validate.mjs gains --no-report so hook gates never mutate the work tree. - package.json exposes hooks:install and test:hooks, runs the installer from prepare, and npm test now covers plugins and hooks. Verified: 51 new hook tests pass; bad-message and staged-token commits are rejected with HEAD unchanged; both gates pass on a real staged tree. Refs: CONTRIBUTING.md#commit-conventions --- .githooks/commit-msg | 13 + .githooks/pre-commit | 13 + package.json | 5 +- tests/agents/validate.mjs | 8 +- tests/git-hooks/test-commit-msg.mjs | 204 ++++++++++++ tests/git-hooks/test-pre-commit.mjs | 233 ++++++++++++++ tools/git-hooks/install.mjs | 84 +++++ tools/git-hooks/pre-commit.mjs | 406 ++++++++++++++++++++++++ tools/git-hooks/validate-commit-msg.mjs | 234 ++++++++++++++ 9 files changed, 1196 insertions(+), 4 deletions(-) create mode 100755 .githooks/commit-msg create mode 100755 .githooks/pre-commit create mode 100644 tests/git-hooks/test-commit-msg.mjs create mode 100644 tests/git-hooks/test-pre-commit.mjs create mode 100644 tools/git-hooks/install.mjs create mode 100644 tools/git-hooks/pre-commit.mjs create mode 100644 tools/git-hooks/validate-commit-msg.mjs diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100755 index 0000000..87a9a0f --- /dev/null +++ b/.githooks/commit-msg @@ -0,0 +1,13 @@ +#!/bin/sh +# Enforce Conventional Commits (CONTRIBUTING.md#commit-conventions). +# Installed by `npm run hooks:install` via core.hooksPath=.githooks +set -e + +root=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0 + +if ! command -v node >/dev/null 2>&1; then + echo "commit-msg: node not found — skipping Conventional Commits check." >&2 + exit 0 +fi + +exec node "$root/tools/git-hooks/validate-commit-msg.mjs" "$1" diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..e69f900 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,13 @@ +#!/bin/sh +# Repository pre-commit checks (secrets, conflict markers, framework gates). +# Installed by `npm run hooks:install` via core.hooksPath=.githooks +set -e + +root=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0 + +if ! command -v node >/dev/null 2>&1; then + echo "pre-commit: node not found — skipping repository checks." >&2 + exit 0 +fi + +exec node "$root/tools/git-hooks/pre-commit.mjs" diff --git a/package.json b/package.json index 848ccfa..85d6178 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,11 @@ "test": "tests" }, "scripts": { - "test": "node --import tsx --test .opencode/plugins/tests/test-*.mjs", + "prepare": "node tools/git-hooks/install.mjs", + "hooks:install": "node tools/git-hooks/install.mjs", + "test": "npm run test:plugins && npm run test:hooks", "test:plugins": "node --import tsx --test .opencode/plugins/tests/test-*.mjs", + "test:hooks": "node --test tests/git-hooks/test-commit-msg.mjs tests/git-hooks/test-pre-commit.mjs", "test:framework": "node tests/agents/validate.mjs", "test:parity": "node --import tsx --test tests/e2e/test-parity.test.ts" }, diff --git a/tests/agents/validate.mjs b/tests/agents/validate.mjs index d000eb3..e8f7375 100644 --- a/tests/agents/validate.mjs +++ b/tests/agents/validate.mjs @@ -510,10 +510,12 @@ function main() { // Print to console console.log(report); - // Write report file + // Write report file (skipped from git hooks: hooks must not mutate the work tree) const reportPath = join(ROOT, "tests", "agents", "validation-report.md"); - writeFileSync(reportPath, report); - console.log(`šŸ“„ Report saved to: tests/agents/validation-report.md`); + if (!process.argv.includes("--no-report")) { + writeFileSync(reportPath, report); + console.log(`šŸ“„ Report saved to: tests/agents/validation-report.md`); + } if (!passed) { console.log( diff --git a/tests/git-hooks/test-commit-msg.mjs b/tests/git-hooks/test-commit-msg.mjs new file mode 100644 index 0000000..1a16409 --- /dev/null +++ b/tests/git-hooks/test-commit-msg.mjs @@ -0,0 +1,204 @@ +/** + * Tests for tools/git-hooks/validate-commit-msg.mjs (Conventional Commits gate). + */ + +import { execFileSync } from "node:child_process" +import { mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join, resolve } from "node:path" +import { strict as assert } from "node:assert" +import { describe, it } from "node:test" + +import { + MAX_HEADER_LENGTH, + validateCommitMessage, +} from "../../tools/git-hooks/validate-commit-msg.mjs" + +const SCRIPT = resolve(import.meta.dirname, "../../tools/git-hooks/validate-commit-msg.mjs") + +const errorsFor = (message) => validateCommitMessage(message).errors +const warningsFor = (message) => validateCommitMessage(message).warnings + +describe("commit-msg: accepted messages", () => { + it("accepts a plain conventional message", () => { + const result = validateCommitMessage("fix: correct model prefix for director agents") + assert.deepEqual(result.errors, []) + assert.equal(result.exempt, false) + }) + + it("accepts a scoped message with a PR reference", () => { + assert.deepEqual(errorsFor("feat(plugin): add session-start time tracking (#109)"), []) + }) + + it("accepts a breaking change that documents the break", () => { + const message = [ + "feat(plugin)!: drop OpenCode 1.x plugin support", + "", + "BREAKING CHANGE: plugins now use the V2 id/setup contract.", + ].join("\n") + assert.deepEqual(errorsFor(message), []) + }) + + it("accepts bodies that start with markdown headings", () => { + const message = ["chore: prepare v0.13.1", "", "## Summary", "- changelog entry", "", "Closes #109"].join("\n") + assert.deepEqual(errorsFor(message), []) + }) + + it("accepts footers and multiple body paragraphs", () => { + const message = ["docs(agents): document delegation rules", "", "Explains escalation.", "", "Refs: docs/architecture/ADR-004.md"].join("\n") + assert.deepEqual(errorsFor(message), []) + }) +}) + +describe("commit-msg: rejected messages", () => { + it("rejects a non-conventional header", () => { + const errors = errorsFor("Update stuff") + assert.equal(errors.length, 1) + assert.match(errors[0], /does not match/) + }) + + it("rejects an unknown type", () => { + assert.match(errorsFor("feature: add thing").join("\n"), /Unknown type "feature"/) + }) + + it("rejects an uppercase type", () => { + assert.match(errorsFor("FEAT: add thing").join("\n"), /must be lowercase/) + }) + + it("rejects an empty scope", () => { + assert.match(errorsFor("feat(): add thing").join("\n"), /Scope is empty/) + }) + + it("rejects an uppercase or spaced scope", () => { + assert.match(errorsFor("feat(Plugin): add thing").join("\n"), /Scope "Plugin"/) + assert.match(errorsFor("feat(open code): add thing").join("\n"), /does not match|Scope/) + }) + + it("rejects a trailing period in the summary", () => { + assert.match(errorsFor("fix: correct the thing.").join("\n"), /must not end with a period/) + }) + + it("rejects an over-long header", () => { + const header = `fix: ${"a".repeat(MAX_HEADER_LENGTH)}` + assert.match(errorsFor(header).join("\n"), /maximum is 100/) + }) + + it("rejects a body without a separating blank line", () => { + assert.match(errorsFor("feat: add thing\ndetails here").join("\n"), /blank line/) + }) + + it("rejects a bare breaking marker without a BREAKING CHANGE footer", () => { + assert.match(errorsFor("feat!: drop v1 support").join("\n"), /BREAKING CHANGE/) + }) + + it("rejects an empty message", () => { + assert.deepEqual(errorsFor(""), ["Empty commit message."]) + }) + + it("treats a comments-only message as empty", () => { + const message = ["# Please enter the commit message", "#", "# On branch master"].join("\n") + assert.deepEqual(errorsFor(message), ["Empty commit message."]) + }) + + it("rejects a summary that is only whitespace after the colon", () => { + assert.match(errorsFor("feat: ").join("\n"), /does not match|Summary must not be empty/) + }) +}) + +describe("commit-msg: generated messages are exempt", () => { + for (const message of [ + 'Merge branch \'development\' into master', + 'Merge pull request #108 from striderZA/fix/v2', + 'Revert "feat(plugin): add thing"', + "fixup! fix: correct the thing", + "squash! fix: correct the thing", + "Initial commit", + ]) { + it(`exempts: ${message}`, () => { + const result = validateCommitMessage(message) + assert.equal(result.exempt, true) + assert.deepEqual(result.errors, []) + }) + } +}) + +describe("commit-msg: advisory warnings", () => { + it("warns when no issue or design reference is present", () => { + assert.match(warningsFor("fix: correct the thing").join("\n"), /No issue\/design reference/) + }) + + it("does not warn when a reference is present", () => { + assert.equal(warningsFor("fix: correct the thing (#42)").some((w) => /No issue/.test(w)), false) + }) + + it("warns about sentence-case summaries", () => { + assert.match(warningsFor("fix: Correct the thing").join("\n"), /lowercase/) + }) + + it("warns when the header is longer than 72 chars but under the limit", () => { + const header = `fix: ${"a".repeat(70)}` + const warnings = warningsFor(header).join("\n") + assert.match(warnings, /72 or fewer/) + assert.deepEqual(errorsFor(header), []) + }) + + it("warns about over-long body lines", () => { + const message = ["fix: correct the thing", "", "b".repeat(101)].join("\n") + assert.match(warningsFor(message).join("\n"), /wrap prose at 100/) + }) +}) + +describe("commit-msg: scissors block", () => { + it("ignores everything after the scissors line", () => { + const message = [ + "fix: correct the thing", + "", + "# ------------------------ >8 ------------------------", + "diff --git a/x b/x", + "this would not parse as a body", + ].join("\n") + assert.deepEqual(errorsFor(message), []) + }) +}) + +describe("commit-msg: CLI", () => { + it("exits 1 and prints guidance for a bad message", () => { + const dir = mkdtempSync(join(tmpdir(), "ocgs-msg-")) + const file = join(dir, "msg.txt") + writeFileSync(file, "bad message\n", "utf8") + + let status = 0 + let stderr = "" + try { + execFileSync(process.execPath, [SCRIPT, file], { encoding: "utf8", stdio: "pipe" }) + } catch (error) { + status = error.status + stderr = String(error.stderr) + } + + assert.equal(status, 1) + assert.match(stderr, /Commit message rejected/) + assert.match(stderr, /CONTRIBUTING\.md#commit-conventions/) + rmSync(dir, { recursive: true, force: true }) + }) + + it("exits 0 for a valid message", () => { + const dir = mkdtempSync(join(tmpdir(), "ocgs-msg-")) + const file = join(dir, "msg.txt") + writeFileSync(file, "test(hooks): cover the commit-msg gate (#1)\n", "utf8") + + const stdout = execFileSync(process.execPath, [SCRIPT, file], { encoding: "utf8" }) + assert.match(stdout, /follows Conventional Commits/) + rmSync(dir, { recursive: true, force: true }) + }) + + it("exits 2 when the message file is missing from the command line", () => { + let status = 0 + try { + execFileSync(process.execPath, [SCRIPT], { encoding: "utf8", stdio: "pipe" }) + } catch (error) { + status = error.status + } + assert.equal(status, 2) + }) +}) diff --git a/tests/git-hooks/test-pre-commit.mjs b/tests/git-hooks/test-pre-commit.mjs new file mode 100644 index 0000000..5583906 --- /dev/null +++ b/tests/git-hooks/test-pre-commit.mjs @@ -0,0 +1,233 @@ +/** + * Tests for tools/git-hooks/pre-commit.mjs (secrets, conflict markers, gates). + */ + +import { execFileSync } from "node:child_process" +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join, resolve } from "node:path" +import { strict as assert } from "node:assert" +import { describe, it } from "node:test" + +import { + checkFileSizes, + checkFrameworkLinks, + evaluatePreCommit, + parseAddedLines, + planGates, + scanAddedLines, +} from "../../tools/git-hooks/pre-commit.mjs" + +function makeTempDir(prefix = "ocgs-hook-") { + return mkdtempSync(join(tmpdir(), prefix)) +} + +function cleanup(dir) { + rmSync(dir, { recursive: true, force: true }) +} + +const DIFF = [ + "diff --git a/src/app.ts b/src/app.ts", + "index 1111111..2222222 100644", + "--- a/src/app.ts", + "+++ b/src/app.ts", + "@@ -1,2 +1,4 @@", + "-const old = 1", + "+const token = \"ghp_" + "a".repeat(36) + "\"", + "+const fine = true", + "+<<<<<<< HEAD", + "diff --git a/docs/guide.md b/docs/guide.md", + "--- a/docs/guide.md", + "+++ b/docs/guide.md", + "@@ -0,0 +1 @@", + "+API_KEY=\"not-a-real-secret\"", +].join("\n") + +describe("pre-commit: diff parsing", () => { + it("attributes added lines to their file and skips removed lines", () => { + const entries = parseAddedLines(DIFF) + assert.deepEqual( + entries.map((entry) => entry.file), + ["src/app.ts", "src/app.ts", "src/app.ts", "docs/guide.md"], + ) + assert.equal(entries[0].text, `const token = "ghp_${"a".repeat(36)}"`) + }) + + it("ignores deleted-file hunks", () => { + const entries = parseAddedLines(["--- a/x", "+++ /dev/null", "+should not count"].join("\n")) + assert.deepEqual(entries, []) + }) +}) + +describe("pre-commit: secret and conflict scanning", () => { + it("blocks a GitHub token", () => { + const { errors } = scanAddedLines(parseAddedLines(DIFF)) + assert.ok(errors.some((error) => /GitHub token/.test(error))) + }) + + it("blocks a private key block", () => { + // Assembled at runtime so this fixture does not itself trip the scanner. + const keyHeader = ["-----BEGIN RSA", "PRIVATE KEY-----"].join(" ") + const diff = ["+++ b/key.pem", `+${keyHeader}`].join("\n") + const { errors } = scanAddedLines(parseAddedLines(diff)) + assert.ok(errors.some((error) => /private key/.test(error))) + }) + + it("blocks unresolved conflict markers", () => { + const { errors } = scanAddedLines(parseAddedLines(DIFF)) + assert.ok(errors.some((error) => /conflict marker/.test(error))) + }) + + it("reports each problem once per file", () => { + // Assembled at runtime so this fixture does not itself trip the scanner. + const awsKey = ["AKIA", "IOSFODNN7EXAMPLE"].join("") + const diff = [ + "+++ b/a.ts", + `+const one = "${awsKey}"`, + `+const two = "${awsKey}"`, + ].join("\n") + const { errors } = scanAddedLines(parseAddedLines(diff)) + assert.equal(errors.filter((error) => /AWS access key/.test(error)).length, 1) + }) + + it("warns about hardcoded credentials in source files", () => { + const diff = ["+++ b/src/config.ts", "+const apiKey = \"live-credential-value\""].join("\n") + const { warnings, errors } = scanAddedLines(parseAddedLines(diff)) + assert.deepEqual(errors, []) + assert.ok(warnings.some((warning) => /hardcoded credential/.test(warning))) + }) + + it("stays quiet for placeholders and documentation", () => { + const diff = [ + "+++ b/docs/setup.md", + "+const apiKey = \"your-key-here\"", + "+++ b/tests/fixture.mjs", + "+const token = \"example-token-value\"", + ].join("\n") + const { warnings, errors } = scanAddedLines(parseAddedLines(diff)) + assert.deepEqual(errors, []) + assert.deepEqual(warnings, []) + }) + + it("does not flag clean source changes", () => { + const diff = ["+++ b/src/app.ts", "+export const add = (a: number, b: number) => a + b"].join("\n") + const { warnings, errors } = scanAddedLines(parseAddedLines(diff)) + assert.deepEqual(errors, []) + assert.deepEqual(warnings, []) + }) +}) + +describe("pre-commit: file size limits", () => { + it("blocks files over the hard limit and warns over the soft limit", () => { + const root = makeTempDir() + mkdirSync(join(root, "assets"), { recursive: true }) + writeFileSync(join(root, "assets", "huge.bin"), Buffer.alloc(2048)) + writeFileSync(join(root, "assets", "large.bin"), Buffer.alloc(1024)) + writeFileSync(join(root, "assets", "ok.bin"), Buffer.alloc(10)) + + const result = checkFileSizes(root, ["assets/huge.bin", "assets/large.bin", "assets/ok.bin"], { + maxBytes: 1500, + warnBytes: 500, + }) + + assert.equal(result.errors.length, 1) + assert.match(result.errors[0], /huge\.bin/) + assert.equal(result.warnings.length, 1) + assert.match(result.warnings[0], /large\.bin/) + cleanup(root) + }) + + it("ignores missing staged paths (deleted after staging)", () => { + const root = makeTempDir() + assert.deepEqual(checkFileSizes(root, ["gone.bin"]), { errors: [], warnings: [] }) + cleanup(root) + }) +}) + +describe("pre-commit: framework redirect integrity", () => { + it("accepts redirect stubs that resolve", () => { + const root = makeTempDir() + mkdirSync(join(root, ".agents", "agents"), { recursive: true }) + mkdirSync(join(root, ".opencode"), { recursive: true }) + for (const [name, target] of [ + ["agents", "../.agents/agents"], + ["commands", "../.agents/agents"], + ["rules", "../.agents/agents"], + ["skills", "../.agents/agents"], + ]) { + writeFileSync(join(root, ".opencode", name), target, "utf8") + } + + assert.deepEqual(checkFrameworkLinks(root).errors, []) + cleanup(root) + }) + + it("rejects a redirect that points nowhere", () => { + const root = makeTempDir() + mkdirSync(join(root, ".opencode"), { recursive: true }) + for (const name of ["agents", "commands", "rules", "skills"]) { + writeFileSync(join(root, ".opencode", name), "../.agents/missing", "utf8") + } + + const { errors } = checkFrameworkLinks(root) + assert.equal(errors.length, 4) + assert.match(errors[0], /does not exist/) + cleanup(root) + }) + + it("rejects a missing redirect", () => { + const root = makeTempDir() + mkdirSync(join(root, ".opencode"), { recursive: true }) + const { errors } = checkFrameworkLinks(root) + assert.equal(errors.length, 4) + assert.match(errors[0], /missing/) + cleanup(root) + }) +}) + +describe("pre-commit: gate planning", () => { + it("requests no gates for docs-only changes", () => { + assert.deepEqual(planGates(["README.md", "docs/guide.md"]), []) + }) + + it("requests framework validation for .agents changes", () => { + assert.deepEqual(planGates([".agents/agents/producer.md"]), ["framework"]) + }) + + it("requests plugin tests for plugin changes", () => { + assert.deepEqual(planGates([".opencode/plugins/ccgs-hooks.ts"]), ["plugins"]) + }) + + it("requests both gates when both areas change", () => { + assert.deepEqual( + planGates([".agents/skills/x/SKILL.md", ".opencode/plugins/ccgs-hooks.ts"]), + ["framework", "plugins"], + ) + }) +}) + +describe("pre-commit: end-to-end in a real git repo", () => { + it("blocks a staged token and passes a clean change", () => { + const root = makeTempDir("ocgs-repo-") + const git = (args) => + execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: "pipe" }) + git(["init", "-q"]) + git(["config", "user.email", "hooks@example.com"]) + git(["config", "user.name", "Hooks Test"]) + + const tokenFile = join(root, "leak.ts") + writeFileSync(tokenFile, `const token = "ghp_${"b".repeat(36)}"\n`, "utf8") + git(["add", "leak.ts"]) + const blocked = evaluatePreCommit(root) + assert.ok(blocked.errors.some((error) => /GitHub token/.test(error))) + + git(["rm", "--cached", "-q", "leak.ts"]) + rmSync(tokenFile) + writeFileSync(join(root, "clean.ts"), "export const add = (a: number, b: number) => a + b\n", "utf8") + git(["add", "clean.ts"]) + const clean = evaluatePreCommit(root) + assert.deepEqual(clean.errors, []) + + cleanup(root) + }) +}) diff --git a/tools/git-hooks/install.mjs b/tools/git-hooks/install.mjs new file mode 100644 index 0000000..df30646 --- /dev/null +++ b/tools/git-hooks/install.mjs @@ -0,0 +1,84 @@ +#!/usr/bin/env node + +/** + * Install or remove the repository git hooks. + * + * node tools/git-hooks/install.mjs # install (sets core.hooksPath=.githooks) + * node tools/git-hooks/install.mjs --uninstall # remove the core.hooksPath override + * + * Runs automatically on `npm install` through the `prepare` script. Outside a git + * work tree (e.g. a packaged tarball) it prints a notice and exits 0. + */ + +import { execFileSync } from "node:child_process" +import { chmodSync, existsSync } from "node:fs" +import { join } from "node:path" + +const HOOKS_PATH = ".githooks" +const HOOK_FILES = ["commit-msg", "pre-commit"] + +function git(root, args, options = {}) { + return execFileSync("git", args, { + cwd: root, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + ...options, + }) +} + +function main() { + const uninstall = process.argv.includes("--uninstall") + + let root + try { + root = git(process.cwd(), ["rev-parse", "--show-toplevel"]).trim() + } catch { + // Not a git checkout — nothing to install. + process.exit(0) + } + + if (uninstall) { + try { + git(root, ["config", "--unset", "core.hooksPath"]) + console.log("āœ” Removed core.hooksPath override; git uses .git/hooks again.") + } catch { + console.log("ℹ No core.hooksPath override to remove.") + } + process.exit(0) + } + + const hooksDir = join(root, HOOKS_PATH) + if (!existsSync(hooksDir)) { + console.error(`āœ– ${HOOKS_PATH}/ is missing; cannot install hooks.`) + process.exit(1) + } + + git(root, ["config", "core.hooksPath", HOOKS_PATH]) + + for (const hook of HOOK_FILES) { + const path = join(hooksDir, hook) + if (!existsSync(path)) continue + + // POSIX permission bit (no-op on Windows, where git executes via sh). + try { + chmodSync(path, 0o755) + } catch { + // Best effort. + } + + // Keep the executable bit in the index so Linux/macOS clones get runnable hooks. + try { + git(root, ["ls-files", "--error-unmatch", `${HOOKS_PATH}/${hook}`]) + git(root, ["update-index", "--chmod=+x", `${HOOKS_PATH}/${hook}`]) + } catch { + // Untracked hook (local experiment) — nothing to record. + } + } + + console.log(`āœ” Git hooks installed from ${HOOKS_PATH}/ (core.hooksPath=${HOOKS_PATH}).`) + console.log(" commit-msg enforces Conventional Commits") + console.log(" pre-commit scans for secrets/conflict markers and runs framework gates") + console.log(" Bypass once with: git commit --no-verify") +} + +main() diff --git a/tools/git-hooks/pre-commit.mjs b/tools/git-hooks/pre-commit.mjs new file mode 100644 index 0000000..ab853dd --- /dev/null +++ b/tools/git-hooks/pre-commit.mjs @@ -0,0 +1,406 @@ +#!/usr/bin/env node + +/** + * Pre-commit checks for the OCGS repository. + * + * Blocking checks (fix before committing): + * - credentials/private keys added in the staged diff + * - unresolved merge-conflict markers + * - staged files larger than 20 MB + * - framework validation when `.agents/` is staged + * - plugin tests when `.opencode/plugins/` is staged + * + * Advisory checks (warnings only): + * - likely-hardcoded credentials + * - staged files larger than 5 MB + * + * Environment: + * OCGS_SKIP_HOOK_CHECKS=1 skip the framework/plugin gates (secret scan still runs) + * + * Usage: node tools/git-hooks/pre-commit.mjs + * Exit codes: 0 = accepted, 1 = rejected. + */ + +import { execFileSync } from "node:child_process" +import { existsSync, lstatSync, readFileSync, realpathSync, statSync } from "node:fs" +import { dirname, join, resolve } from "node:path" +import { pathToFileURL } from "node:url" + +export const MAX_FILE_BYTES = 20 * 1024 * 1024 +export const WARN_FILE_BYTES = 5 * 1024 * 1024 + +/** High-confidence credential shapes. Matches here block the commit. */ +export const SECRET_RULES = [ + { + id: "private-key", + label: "private key block", + pattern: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----/, + }, + { + id: "aws-access-key", + label: "AWS access key id", + pattern: /\bAKIA[0-9A-Z]{16}\b/, + }, + { + id: "github-token", + label: "GitHub token", + pattern: /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{22,})\b/, + }, + { + id: "slack-token", + label: "Slack token", + pattern: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/, + }, + { + id: "api-key", + label: "API key (sk-…)", + pattern: /\bsk-(?:ant-)?[A-Za-z0-9_-]{20,}\b/, + }, + { + id: "google-api-key", + label: "Google API key", + pattern: /\bAIza[0-9A-Za-z_-]{35}\b/, + }, +] + +const CREDENTIAL_ASSIGNMENT = + /^\s*(?:(?:export|const|let|var|private|public|protected|static|readonly)\s+)*[\w.-]*(?:api[_-]?key|secret|passwd|password|credential|token)[\w.-]*\s*[:=]\s*["'][^"']{8,}["']/i + +const CREDENTIAL_PLACEHOLDER = + /(?:x{3,}|\*{3,}|<[^>]*>|\$\{|\$[A-Z_]+|process\.env|changeme|example|dummy|placeholder|redacted|your[_-]|\.\.\.)/i + +const CONFLICT_MARKER = /^(?:<{7}|>{7})(?:\s|$)/ + +const ADVICE_ONLY = (file) => + /\.md$/i.test(file) || + file.startsWith("docs/") || + file.startsWith("tests/") || + /(?:^|\/)test-[^/]*$/i.test(file) || + /(?:^|\/)\.github\//.test(file) + +const normalize = (file) => file.replace(/\\/g, "/") + +/** Run a git command, returning stdout. Throws when git fails. */ +export function git(root, args, options = {}) { + return execFileSync("git", args, { + cwd: root, + encoding: "utf8", + maxBuffer: 32 * 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + ...options, + }) +} + +/** Staged paths that represent added or modified content. */ +export function stagedFiles(root) { + const output = git(root, [ + "diff", + "--cached", + "--name-only", + "--no-renames", + "--diff-filter=ACMR", + ]) + return output + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .map(normalize) +} + +/** Staged diff with zero context, for added-line scanning. */ +export function stagedDiff(root) { + return git(root, [ + "diff", + "--cached", + "--unified=0", + "--no-color", + "--no-ext-diff", + "--diff-filter=ACMR", + ]) +} + +/** + * Extract added lines from a unified diff. + * @returns {Array<{ file: string, text: string }>} + */ +export function parseAddedLines(diff) { + const entries = [] + let current = null + for (const line of String(diff).split("\n")) { + if (line.startsWith("+++ ")) { + const target = line.slice(4).trim() + current = target === "/dev/null" ? null : normalize(target.replace(/^b\//, "")) + continue + } + if (current === null) continue + if (line.startsWith("+") && line !== "+") entries.push({ file: current, text: line.slice(1) }) + } + return entries +} + +/** + * Scan added lines for credentials and conflict markers. + * @returns {{ errors: string[], warnings: string[] }} + */ +export function scanAddedLines(entries) { + const errors = [] + const warnings = [] + const reported = new Set() + + for (const { file, text } of entries) { + for (const rule of SECRET_RULES) { + if (!rule.pattern.test(text)) continue + const key = `${rule.id}:${file}` + if (reported.has(key)) continue + reported.add(key) + errors.push(`${file}: possible ${rule.label} in staged content — remove it before committing.`) + } + + if (CONFLICT_MARKER.test(text)) { + const key = `conflict:${file}` + if (!reported.has(key)) { + reported.add(key) + errors.push(`${file}: unresolved merge conflict marker — resolve the conflict first.`) + } + } + + if (!ADVICE_ONLY(file) && CREDENTIAL_ASSIGNMENT.test(text) && !CREDENTIAL_PLACEHOLDER.test(text)) { + const key = `credential:${file}` + if (!reported.has(key)) { + reported.add(key) + warnings.push(`${file}: hardcoded credential-like assignment — confirm it is not a real secret.`) + } + } + } + + return { errors, warnings } +} + +/** + * Flag staged files by size. + * @returns {{ errors: string[], warnings: string[] }} + */ +export function checkFileSizes(root, files, thresholds = {}) { + const maxBytes = thresholds.maxBytes ?? MAX_FILE_BYTES + const warnBytes = thresholds.warnBytes ?? WARN_FILE_BYTES + const errors = [] + const warnings = [] + + for (const file of files) { + const full = join(root, file) + if (!existsSync(full)) continue + let stat + try { + stat = statSync(full) + } catch { + continue + } + if (!stat.isFile()) continue + if (stat.size > maxBytes) { + errors.push( + `${file}: ${(stat.size / 1024 / 1024).toFixed(1)} MB exceeds the ${(maxBytes / 1024 / 1024).toFixed(0)} MB commit limit.`, + ) + } else if (stat.size > warnBytes) { + warnings.push( + `${file}: ${(stat.size / 1024 / 1024).toFixed(1)} MB is large for a repository commit.`, + ) + } + } + + return { errors, warnings } +} + +/** + * Verify `.opencode/` redirects (symlinks on POSIX, text stubs on Windows + * checkouts) still resolve to their canonical `.agents/` target. + * @returns {{ errors: string[], warnings: string[] }} + */ +export function checkFrameworkLinks(root) { + const links = [ + ["agents", ".agents/agents"], + ["commands", ".agents/commands"], + ["rules", ".agents/rules"], + ["skills", ".agents/skills"], + ] + const opencode = join(root, ".opencode") + const errors = [] + const warnings = [] + + if (!existsSync(opencode)) return { errors, warnings } + + for (const [name, fallbackTarget] of links) { + const path = join(opencode, name) + if (!existsSync(path)) { + errors.push(`.opencode/${name} is missing; expected a link to ${fallbackTarget}.`) + continue + } + + let stat + try { + stat = lstatSync(path) + } catch { + continue + } + + if (stat.isSymbolicLink()) { + try { + if (!statSync(realpathSync(path)).isDirectory()) { + errors.push(`.opencode/${name} points at something that is not a directory.`) + } + } catch { + errors.push(`.opencode/${name} is a broken symlink; repoint it at ${fallbackTarget}.`) + } + continue + } + + if (stat.isFile()) { + const target = readFileSync(path, "utf8").trim() + if (target === "") { + errors.push(`.opencode/${name} is an empty redirect stub; expect "${fallbackTarget}".`) + continue + } + if (!existsSync(resolve(dirname(path), target))) { + errors.push(`.opencode/${name} redirects to "${target}", which does not exist.`) + } + continue + } + + if (!stat.isDirectory()) { + errors.push(`.opencode/${name} is neither a link, redirect stub nor directory.`) + } + } + + return { errors, warnings } +} + +/** Which repository gates the staged paths require. */ +export function planGates(files) { + const gates = [] + if ( + files.some( + (file) => + file.startsWith(".agents/") || + file.startsWith("tests/agents/") || + file === "AGENTS.md", + ) + ) { + gates.push("framework") + } + if (files.some((file) => file.startsWith(".opencode/plugins/"))) { + gates.push("plugins") + } + return gates +} + +function runCommand(root, command, args, options = {}) { + try { + const output = execFileSync(command, args, { + cwd: root, + encoding: "utf8", + maxBuffer: 32 * 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + timeout: options.timeout ?? 180_000, + shell: options.shell ?? false, + }) + return { ok: true, output } + } catch (error) { + return { + ok: false, + output: `${error.stdout ?? ""}${error.stderr ?? ""}` || error.message, + } + } +} + +function tail(text, lines = 30) { + const all = String(text).trimEnd().split("\n") + return all.slice(-lines).join("\n") +} + +/** + * Run the selected repository gates. + * @returns {{ errors: string[], warnings: string[], notes: string[] }} + */ +export function runGates(root, gates, env = process.env) { + const errors = [] + const warnings = [] + const notes = [] + + if (gates.length === 0) return { errors, warnings, notes } + + if (env.OCGS_SKIP_HOOK_CHECKS === "1") { + notes.push(`Skipped gates (${gates.join(", ")}) because OCGS_SKIP_HOOK_CHECKS=1.`) + return { errors, warnings, notes } + } + + if (gates.includes("framework")) { + const result = runCommand(root, process.execPath, ["tests/agents/validate.mjs", "--no-report"]) + if (result.ok) notes.push("framework validation passed") + else errors.push(`framework validation failed:\n${tail(result.output)}`) + } + + if (gates.includes("plugins")) { + if (!existsSync(join(root, "node_modules", "tsx"))) { + warnings.push('plugin gate skipped: run "npm install" so the tsx loader is available.') + } else { + const result = runCommand(root, "npm", ["run", "-s", "test:plugins"], { shell: true }) + if (result.ok) notes.push("plugin tests passed") + else errors.push(`plugin tests failed:\n${tail(result.output)}`) + } + } + + return { errors, warnings, notes } +} + +/** + * Full pre-commit evaluation (no printing, no exit) — used by tests. + */ +export function evaluatePreCommit(root, env = process.env) { + const files = stagedFiles(root) + const { errors: secretErrors, warnings: secretWarnings } = scanAddedLines( + parseAddedLines(stagedDiff(root)), + ) + const { errors: sizeErrors, warnings: sizeWarnings } = checkFileSizes(root, files) + const { errors: linkErrors } = checkFrameworkLinks(root) + const gates = runGates(root, planGates(files), env) + + return { + errors: [...secretErrors, ...linkErrors, ...sizeErrors, ...gates.errors], + warnings: [...secretWarnings, ...sizeWarnings, ...gates.warnings], + notes: gates.notes, + } +} + +function main() { + let root + try { + root = execFileSync("git", ["rev-parse", "--show-toplevel"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim() + } catch (error) { + console.error(`pre-commit: not inside a git work tree (${error.message})`) + process.exit(0) + } + + const { errors, warnings, notes } = evaluatePreCommit(root) + + for (const note of notes) console.log(`āœ” pre-commit: ${note}`) + for (const warning of warnings) console.warn(`āš ļø pre-commit: ${warning}`) + + if (errors.length > 0) { + console.error("\nāœ– Commit blocked by pre-commit checks:") + for (const error of errors) console.error(` - ${error}`) + console.error("\n Fix the problems above, then stage and commit again.") + console.error(" To bypass once: git commit --no-verify\n") + process.exit(1) + } + + console.log("āœ” pre-commit checks passed.") + process.exit(0) +} + +const invokedDirectly = + process.argv[1] !== undefined && + import.meta.url === pathToFileURL(process.argv[1]).href + +if (invokedDirectly) main() diff --git a/tools/git-hooks/validate-commit-msg.mjs b/tools/git-hooks/validate-commit-msg.mjs new file mode 100644 index 0000000..3aa6023 --- /dev/null +++ b/tools/git-hooks/validate-commit-msg.mjs @@ -0,0 +1,234 @@ +#!/usr/bin/env node + +/** + * Conventional Commit validator for the `commit-msg` git hook. + * + * Rules are derived from CONTRIBUTING.md#commit-conventions: + * ()!?: + * - types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert + * - optional scope: lowercase system name, no spaces + * - summary: imperative, no trailing period, <= 100 chars (72 recommended) + * - body separated from the header by one blank line + * - `!` in the header requires a `BREAKING CHANGE:` footer + * + * Usage: node tools/git-hooks/validate-commit-msg.mjs + * Exit codes: 0 = accepted, 1 = rejected, 2 = usage/IO error. + */ + +import { readFileSync } from "node:fs" +import { pathToFileURL } from "node:url" + +export const CONVENTIONAL_TYPES = [ + "feat", + "fix", + "docs", + "style", + "refactor", + "perf", + "test", + "build", + "ci", + "chore", + "revert", +] + +export const MAX_HEADER_LENGTH = 100 +export const RECOMMENDED_HEADER_LENGTH = 72 +export const MAX_BODY_LINE_LENGTH = 100 + +/** + * Messages produced by git itself (or by squash/rebase tooling) that must not be + * rewritten into Conventional Commit form. + */ +export const EXEMPT_MESSAGE_PATTERNS = [ + /^Merge (?:branch|remote-tracking branch|tag|pull request|commit)\b/, + /^Merge /, + /^Revert "/, + /^(?:fixup|squash|amend)! /, + /^Initial commit$/, +] + +const SCISSORS = /^# -+ >8 -+$/ +const HEADER = /^([A-Za-z]+)(?:\(([^()]*)\))?(!)?: (.+)$/ +const SCOPE = /^[a-z0-9][a-z0-9._/-]*$/ +const BREAKING_FOOTER = /^BREAKING[ -]CHANGE:/m +const REFERENCE = /(?:\(#\d+\)|\b(?:closes|fixes|resolves|refs?|task|design)\b[:\s]\s*(?:#\d+|[\w./-]+))/i + +/** + * Split a raw commit message into trimmed, meaningful lines. + * The scissors block (added by `git commit --verbose`) is discarded. + * + * Lines beginning with `#` are preserved: commit bodies in this repo legitimately + * use Markdown headings (`## Summary`), and `git commit -m` does not strip them. + */ +export function splitMessage(raw) { + const text = String(raw ?? "").replace(/\r\n/g, "\n") + const lines = text.split("\n") + const scissorsAt = lines.findIndex((line) => SCISSORS.test(line)) + const kept = scissorsAt === -1 ? lines : lines.slice(0, scissorsAt) + const trimmed = kept.map((line) => line.replace(/\s+$/, "")) + while (trimmed.length > 0 && trimmed[trimmed.length - 1] === "") trimmed.pop() + while (trimmed.length > 0 && trimmed[0] === "") trimmed.shift() + return trimmed +} + +/** + * Validate a raw commit message. + * @param {string} raw + * @returns {{ errors: string[], warnings: string[], exempt: boolean }} + */ +export function validateCommitMessage(raw) { + const errors = [] + const warnings = [] + const lines = splitMessage(raw) + const meaningful = lines.filter((line) => line !== "" && !line.startsWith("#")) + + if (meaningful.length === 0) { + return { errors: ["Empty commit message."], warnings, exempt: false } + } + + const header = lines[0] + + if (EXEMPT_MESSAGE_PATTERNS.some((pattern) => pattern.test(header))) { + return { errors, warnings, exempt: true } + } + + const match = header.match(HEADER) + if (!match) { + errors.push( + `Header does not match "(): ": "${header}"`, + ) + return { errors, warnings, exempt: false } + } + + const [, type, scope, bang, subject] = match + + if (type !== type.toLowerCase()) { + errors.push(`Type "${type}" must be lowercase.`) + } + if (!CONVENTIONAL_TYPES.includes(type.toLowerCase())) { + errors.push( + `Unknown type "${type}". Use one of: ${CONVENTIONAL_TYPES.join(", ")}.`, + ) + } + + if (scope !== undefined) { + if (scope.trim() === "") { + errors.push("Scope is empty. Write `type(scope): summary` or `type: summary`.") + } else if (!SCOPE.test(scope)) { + errors.push( + `Scope "${scope}" must be lowercase letters, digits, ".", "_", "-" or "/" (no spaces).`, + ) + } + } + + if (subject.trim() === "") { + errors.push("Summary must not be empty.") + } else if (subject.trim() !== subject) { + errors.push("Summary must not start or end with whitespace.") + } + + if (subject.endsWith(".")) { + errors.push(`Summary must not end with a period: "${subject}"`) + } + + if (/^[A-Z]/.test(subject)) { + warnings.push( + "Summary should start lowercase and use imperative mood (e.g. \"add\", not \"Added\").", + ) + } + + if (header.length > MAX_HEADER_LENGTH) { + errors.push( + `Header is ${header.length} chars; maximum is ${MAX_HEADER_LENGTH}. Move detail into the body.`, + ) + } else if (header.length > RECOMMENDED_HEADER_LENGTH) { + warnings.push( + `Header is ${header.length} chars; ${RECOMMENDED_HEADER_LENGTH} or fewer reads better in \`git log --oneline\`.`, + ) + } + + const body = lines.slice(1) + + if (body.length > 0) { + if (body[0] !== "") { + errors.push("Leave one blank line between the header and the body.") + } + if (bang && !BREAKING_FOOTER.test(body.join("\n"))) { + errors.push( + `Header marks a breaking change ("!") but the body has no "BREAKING CHANGE:" footer.`, + ) + } + body.forEach((line, index) => { + if (line.length > MAX_BODY_LINE_LENGTH) { + warnings.push( + `Body line ${index + 2} is ${line.length} chars; wrap prose at ${MAX_BODY_LINE_LENGTH}.`, + ) + } + }) + } else if (bang) { + errors.push( + `Header marks a breaking change ("!") but there is no "BREAKING CHANGE:" footer.`, + ) + } + + if (!REFERENCE.test(lines.join("\n"))) { + warnings.push( + 'No issue/design reference found. Add "Closes #123", "Refs: #123" or "(#123)" (docs/CONTRIBUTING.md#pr-process).', + ) + } + + return { errors, warnings, exempt: false } +} + +/** Format validation results as hook output. */ +export function formatResult({ errors, warnings, exempt }, header) { + const out = [] + if (exempt) return `āœ” "${header}" is an auto-generated message — skipped commit-msg checks.` + for (const warning of warnings) out.push(`āš ļø commit-msg: ${warning}`) + if (errors.length > 0) { + out.push("") + out.push("āœ– Commit message rejected by Conventional Commits check:") + for (const error of errors) out.push(` - ${error}`) + out.push("") + out.push(" Expected: (): ") + out.push(` Types: ${CONVENTIONAL_TYPES.join(", ")}`) + out.push(" Details: CONTRIBUTING.md#commit-conventions") + out.push("") + out.push(" To bypass once: git commit --no-verify") + return out.join("\n") + } + out.push("āœ” Commit message follows Conventional Commits.") + return out.join("\n") +} + +function main() { + const file = process.argv[2] + if (!file) { + console.error( + "usage: node tools/git-hooks/validate-commit-msg.mjs ", + ) + process.exit(2) + } + + let raw + try { + raw = readFileSync(file, "utf8") + } catch (error) { + console.error(`commit-msg: cannot read "${file}": ${error.message}`) + process.exit(2) + } + + const result = validateCommitMessage(raw) + const header = splitMessage(raw)[0] ?? "" + const isError = result.errors.length > 0 + const stream = isError ? console.error : console.log + stream(formatResult(result, header)) + process.exit(isError ? 1 : 0) +} + +const invokedDirectly = + process.argv[1] !== undefined && + import.meta.url === pathToFileURL(process.argv[1]).href + +if (invokedDirectly) main() From 68fd867dfd81434fefba09ba8ad70f00c47fe39d Mon Sep 17 00:00:00 2001 From: Jaco du Preez Date: Tue, 3 Nov 2026 20:25:35 +0200 Subject: [PATCH 2/3] docs: document git hook installation and conventions Record the commit-msg and pre-commit rules in CONTRIBUTING.md, note hook enforcement in the PR process in docs/CONTRIBUTING.md, and add .githooks/ plus tools/git-hooks/ to the AGENTS.md structure map and quality gates. Refs: CONTRIBUTING.md#git-hooks --- AGENTS.md | 9 ++++++++- CONTRIBUTING.md | 23 +++++++++++++++++++++++ docs/CONTRIBUTING.md | 2 +- 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b77838c..dcd58b5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,7 @@ Each agent owns a specific domain, enforcing separation of concerns and quality. ā”œā”€ā”€ AGENTS.md # Project configuration ā”œā”€ā”€ opencode.json # OpenCode config (permissions, plugins) ā”œā”€ā”€ .pi/ # Pi settings and extensions +ā”œā”€ā”€ .githooks/ # Tracked git hooks (commit-msg, pre-commit) ā”œā”€ā”€ .agents/ # Canonical content (harness-agnostic) │ ā”œā”€ā”€ agents/ # 52 agent definitions │ ā”œā”€ā”€ skills/ # 77 skill workflows @@ -65,6 +66,7 @@ Each agent owns a specific domain, enforcing separation of concerns and quality. ā”œā”€ā”€ design/ # Game design documents ā”œā”€ā”€ tools/ # Build and pipeline tools │ ā”œā”€ā”€ migrate-to-agents.mjs # Migration script (.opencode/ → .agents/) +│ ā”œā”€ā”€ git-hooks/ # Hook validators (commit-msg, pre-commit) + installer │ └── ... ā”œā”€ā”€ prototypes/ # Throwaway prototypes └── production/ # Sprint plans, milestones, session logs @@ -247,7 +249,12 @@ Before merging to `development`, the CI must pass: - All skill files have valid cross-references to existing agents - All command files reference valid skill directories - **Plugin tests** (`npm run test:plugins`): - - 18 test suites, 158 tests covering all hooks + - 19 test suites, 177 tests covering all hooks +- **Git hooks** (`.githooks/`, installed by `npm run hooks:install`): + - `commit-msg` enforces Conventional Commits (`npm run test:hooks`) + - `pre-commit` blocks credentials, conflict markers and >20 MB files, verifies the + `.opencode/*` redirect stubs, and runs the framework or plugin gate when their + paths are staged ## Notes diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9cc1972..847dac2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,6 +41,29 @@ Examples: - `fix(agents): correct model prefix for director agents` - `docs: add model assignment strategy section` +### Git hooks + +These rules are enforced locally by tracked hooks in `.githooks/`: + +- **`commit-msg`** — rejects non-conventional messages: unknown or uppercase type, + empty or malformed scope, trailing period, header over 100 characters, and a `!` + header without a `BREAKING CHANGE:` footer. Git-generated `Merge …`, `Revert "…"`, + `fixup!`/`squash!` and `Initial commit` messages are exempt. +- **`pre-commit`** — blocks staged credentials, unresolved conflict markers and files + over 20 MB, checks that the `.opencode/*` redirect stubs still resolve, and runs + `tests/agents/validate.mjs` when `.agents/` is staged or `npm run test:plugins` + when `.opencode/plugins/` is staged. + +Install once per clone (also runs on `npm install` via `prepare`): + +```bash +npm run hooks:install # sets core.hooksPath=.githooks +``` + +Bypass a single commit with `git commit --no-verify`. Skip only the framework/plugin +gates with `OCGS_SKIP_HOOK_CHECKS=1 git commit …`; the secret scan always runs. +Hook behavior is covered by `npm run test:hooks`. + ## Pull Request Process - Keep PRs focused — one feature/fix per PR diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 78773c2..8ef447e 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -311,7 +311,7 @@ All framework changes go through pull requests to `development`: 1. **Branch from `development`**: `feature/{issue-number}-{short-name}` 2. **One issue per branch**: Each branch addresses exactly one issue -3. **Commit convention**: Conventional Commits (`feat:`, `fix:`, `docs:`, etc.) +3. **Commit convention**: Conventional Commits (`feat:`, `fix:`, `docs:`, etc.) — enforced by `.githooks/commit-msg` (`npm run hooks:install`) 4. **Close issue reference**: Include `Closes #N` in the commit message 5. **CI must pass**: Agent validation, workflow integrity, and plugin tests 6. **Merge to `development`**: Fast-forward merge, push, close issue From a0e34bd50c39eeb84a373939e02a515c2891cf18 Mon Sep 17 00:00:00 2001 From: Jaco du Preez Date: Tue, 3 Nov 2026 20:28:43 +0200 Subject: [PATCH 3/3] fix(hooks): gate workflow integrity and qualify hook paths CI's Workflow Integrity suite failed because invariant I5 treats every single-backtick token in AGENTS.md as an agent reference, so the new hook entries were reported as unknown agents. - Reference the hooks by path (.githooks/...) in the AGENTS.md quality gates. - Run tests/workflow/run-all.mjs alongside tests/agents/validate.mjs in the pre-commit framework gate, so AGENTS.md invariant breaks are caught locally instead of only in CI. - Cover both gate commands and the skip switch in test-pre-commit.mjs. Verified: node tests/workflow/run-all.mjs 4/4 suites; npm run test:hooks 54 tests pass. Refs: tests/workflow/invariants.mjs --- AGENTS.md | 8 ++--- CONTRIBUTING.md | 3 +- tests/git-hooks/test-pre-commit.mjs | 52 +++++++++++++++++++++++++++++ tools/git-hooks/pre-commit.mjs | 12 +++++-- 4 files changed, 67 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index dcd58b5..7b942a8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -251,10 +251,10 @@ Before merging to `development`, the CI must pass: - **Plugin tests** (`npm run test:plugins`): - 19 test suites, 177 tests covering all hooks - **Git hooks** (`.githooks/`, installed by `npm run hooks:install`): - - `commit-msg` enforces Conventional Commits (`npm run test:hooks`) - - `pre-commit` blocks credentials, conflict markers and >20 MB files, verifies the - `.opencode/*` redirect stubs, and runs the framework or plugin gate when their - paths are staged + - `.githooks/commit-msg` enforces Conventional Commits (`npm run test:hooks`) + - `.githooks/pre-commit` blocks credentials, conflict markers and >20 MB files, + verifies the `.opencode/*` redirect stubs, and runs framework validation plus + workflow integrity — or plugin tests — when their paths are staged ## Notes diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 847dac2..eaa7244 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -51,7 +51,8 @@ These rules are enforced locally by tracked hooks in `.githooks/`: `fixup!`/`squash!` and `Initial commit` messages are exempt. - **`pre-commit`** — blocks staged credentials, unresolved conflict markers and files over 20 MB, checks that the `.opencode/*` redirect stubs still resolve, and runs - `tests/agents/validate.mjs` when `.agents/` is staged or `npm run test:plugins` + `tests/agents/validate.mjs --no-report` plus `tests/workflow/run-all.mjs` when + `.agents/`, `tests/agents/` or `AGENTS.md` is staged — or `npm run test:plugins` when `.opencode/plugins/` is staged. Install once per clone (also runs on `npm install` via `prepare`): diff --git a/tests/git-hooks/test-pre-commit.mjs b/tests/git-hooks/test-pre-commit.mjs index 5583906..7e29663 100644 --- a/tests/git-hooks/test-pre-commit.mjs +++ b/tests/git-hooks/test-pre-commit.mjs @@ -15,6 +15,7 @@ import { evaluatePreCommit, parseAddedLines, planGates, + runGates, scanAddedLines, } from "../../tools/git-hooks/pre-commit.mjs" @@ -206,6 +207,57 @@ describe("pre-commit: gate planning", () => { }) }) +describe("pre-commit: framework gate", () => { + function stubScripts(root, { + agentExit = 0, + workflowExit = 0, + workflowOutput = "", + } = {}) { + mkdirSync(join(root, "tests", "agents"), { recursive: true }) + mkdirSync(join(root, "tests", "workflow"), { recursive: true }) + writeFileSync( + join(root, "tests", "agents", "validate.mjs"), + `process.exit(${agentExit})\n`, + "utf8", + ) + writeFileSync( + join(root, "tests", "workflow", "run-all.mjs"), + `console.log(${JSON.stringify(workflowOutput)})\nprocess.exit(${workflowExit})\n`, + "utf8", + ) + } + + it("runs framework validation and workflow integrity", () => { + const root = makeTempDir() + stubScripts(root) + + const result = runGates(root, ["framework"]) + assert.deepEqual(result.errors, []) + assert.deepEqual(result.notes, ["framework validation passed", "workflow integrity passed"]) + cleanup(root) + }) + + it("reports which gate failed and keeps the passing note", () => { + const root = makeTempDir() + stubScripts(root, { workflowExit: 1, workflowOutput: "FAIL I5: unknown agents" }) + + const result = runGates(root, ["framework"]) + assert.equal(result.errors.length, 1) + assert.match(result.errors[0], /workflow integrity failed/) + assert.match(result.errors[0], /FAIL I5: unknown agents/) + assert.deepEqual(result.notes, ["framework validation passed"]) + cleanup(root) + }) + + it("skips all gates when OCGS_SKIP_HOOK_CHECKS=1", () => { + const root = makeTempDir() + const result = runGates(root, ["framework", "plugins"], { OCGS_SKIP_HOOK_CHECKS: "1" }) + assert.deepEqual(result.errors, []) + assert.match(result.notes[0], /Skipped gates/) + cleanup(root) + }) +}) + describe("pre-commit: end-to-end in a real git repo", () => { it("blocks a staged token and passes a clean change", () => { const root = makeTempDir("ocgs-repo-") diff --git a/tools/git-hooks/pre-commit.mjs b/tools/git-hooks/pre-commit.mjs index ab853dd..97c2b5d 100644 --- a/tools/git-hooks/pre-commit.mjs +++ b/tools/git-hooks/pre-commit.mjs @@ -333,9 +333,15 @@ export function runGates(root, gates, env = process.env) { } if (gates.includes("framework")) { - const result = runCommand(root, process.execPath, ["tests/agents/validate.mjs", "--no-report"]) - if (result.ok) notes.push("framework validation passed") - else errors.push(`framework validation failed:\n${tail(result.output)}`) + const checks = [ + ["framework validation", [process.execPath, ["tests/agents/validate.mjs", "--no-report"]]], + ["workflow integrity", [process.execPath, ["tests/workflow/run-all.mjs"]]], + ] + for (const [label, [command, args]] of checks) { + const result = runCommand(root, command, args) + if (result.ok) notes.push(`${label} passed`) + else errors.push(`${label} failed:\n${tail(result.output)}`) + } } if (gates.includes("plugins")) {