diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100755 index 0000000..23bc903 --- /dev/null +++ b/.githooks/commit-msg @@ -0,0 +1,4 @@ +#!/bin/sh +# Reject commit messages that carry AI attribution or break Conventional Commits. +# Rules live in lib/git-rules.mjs. Bypass a single commit with --no-verify. +exec node "$(dirname "$0")/lib/git-rules.mjs" commit-msg "$1" diff --git a/.githooks/lib/git-rules.mjs b/.githooks/lib/git-rules.mjs new file mode 100755 index 0000000..ff3bce2 --- /dev/null +++ b/.githooks/lib/git-rules.mjs @@ -0,0 +1,263 @@ +#!/usr/bin/env node +/** + * Git rules for this repository, shared by the commit-msg and pre-commit hooks. + * + * 1. No AI self-attribution in commit messages. + * 2. One branch per issue — never commit onto the default branch. + * 3. Conventional Commits, strictly. + * + * Run directly as a hook entry point: + * + * node .githooks/lib/git-rules.mjs commit-msg + * node .githooks/lib/git-rules.mjs pre-commit + * + * Exit 1 rejects the commit. Each rule has an environment escape hatch for the + * deliberate exception, and `git commit --no-verify` skips the hooks entirely. + */ + +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; + +export const MAX_SUBJECT_LEN = 72; + +export const TYPES = [ + "feat", + "fix", + "docs", + "style", + "refactor", + "perf", + "test", + "build", + "ci", + "chore", + "revert", +]; + +/** Scope allows spaces — this repo's history contains `feat(contract verification):`. */ +export const CONVENTIONAL_RE = new RegExp( + `^(?:${TYPES.join("|")})(?:\\([a-z0-9 ._-]+\\))?!?: .+`, +); + +/** git-generated and release subjects are never format-checked. */ +export const EXEMPT_RE = /^(?:Merge |Revert |fixup! |squash! |v?\d+\.\d+\.\d+)/; + +export const ATTRIBUTION_PATTERNS = [ + [ + /co-authored-by:\s*(?:claude|anthropic|copilot|cursor|codex|devin|aider|chatgpt|openai|gemini)/i, + "AI co-author trailer", + ], + [/co-authored-by:.*\[bot\]/i, "bot co-author trailer"], + [/noreply@anthropic\.com/i, "Anthropic noreply address"], + [/generated with \[?claude/i, "'Generated with Claude Code' footer"], + [/šŸ¤–\s*generated with/i, "'robot Generated with' footer"], + [ + /generated (?:with|by) (?:claude|ai|an ai|copilot|cursor|chatgpt)/i, + "AI generation credit", + ], + [/claude\.com\/claude-code/i, "Claude Code URL"], + [/claude\.ai\/code/i, "Claude Code URL"], + [/(?:ai|machine)-generated/i, "'AI-generated' note"], + [/written by (?:claude|ai|an ai)/i, "'written by AI' note"], +]; + +/** + * Default branch when `origin/HEAD` cannot be resolved (a clone that never set + * it). `dev` is this repository's default; main/master cover a fork that + * renamed. A fork with its own default still resolves from its `origin/HEAD`. + */ +export const DEFAULT_BRANCH_FALLBACK = "dev"; + +export const ENV_ALLOW_ATTRIBUTION = "ALLOW_AI_ATTRIBUTION"; +export const ENV_ALLOW_FORMAT = "ALLOW_NONCONVENTIONAL_COMMIT"; +export const ENV_ALLOW_DEFAULT_BRANCH = "ALLOW_DEFAULT_BRANCH_COMMIT"; + +// --------------------------------------------------------------------------- +// rules +// --------------------------------------------------------------------------- + +/** The label of the first attribution pattern found in `text`, else null. */ +export function findAttribution(text) { + if (!text) return null; + for (const [pattern, label] of ATTRIBUTION_PATTERNS) { + if (pattern.test(text)) return label; + } + return null; +} + +/** The first non-empty line of a commit message. */ +export function subjectOf(message) { + if (!message) return ""; + for (const line of message.split("\n")) { + if (line.trim()) return line.trim(); + } + return ""; +} + +/** + * Strip what git itself would strip: comment lines, and everything from the + * scissors line on (where `--verbose` puts the diff). + */ +export function stripCommitComments(raw, commentChar = "#") { + const scissors = `${commentChar} ------------------------ >8 ------------------------`; + const lines = []; + for (const line of raw.split("\n")) { + if (line.startsWith(scissors)) break; + if (line.startsWith(commentChar)) continue; + lines.push(line); + } + return lines.join("\n"); +} + +/** A description of the first format violation in `subject`, else null. */ +export function checkConventional(subject) { + if (!subject || EXEMPT_RE.test(subject)) return null; + if (!CONVENTIONAL_RE.test(subject)) { + const lowered = subject[0].toLowerCase() + subject.slice(1); + if (CONVENTIONAL_RE.test(lowered)) return "type must be lowercase"; + return `must start with a lowercase type: ${TYPES.join(", ")}`; + } + if (subject.length > MAX_SUBJECT_LEN) { + return `subject is ${subject.length} chars, limit is ${MAX_SUBJECT_LEN}`; + } + if (subject.endsWith(".")) return "subject must not end with a period"; + return null; +} + +// --------------------------------------------------------------------------- +// git +// --------------------------------------------------------------------------- + +function git(...args) { + try { + return execFileSync("git", args, { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + } catch { + return null; + } +} + +export function currentBranch() { + return git("symbolic-ref", "--short", "HEAD"); +} + +/** The branches that count as "the default branch" for this checkout. */ +export function defaultBranches() { + const remoteHead = git("symbolic-ref", "--short", "refs/remotes/origin/HEAD"); + if (remoteHead) { + const name = remoteHead.includes("/") + ? remoteHead.slice(remoteHead.indexOf("/") + 1) + : remoteHead; + return [name]; + } + return [DEFAULT_BRANCH_FALLBACK, "main", "master"]; +} + +function commentChar() { + const configured = git("config", "core.commentChar"); + if (!configured || configured === "auto") return "#"; + return configured; +} + +// --------------------------------------------------------------------------- +// reporting +// --------------------------------------------------------------------------- + +const FORMAT_HELP = `Required: type(scope)?: description — lowercase type, ${MAX_SUBJECT_LEN} chars max, no +trailing period, imperative mood. Types: ${TYPES.join(", ")}. +Examples: feat(explorer): add network page / fix: support non default name networks +Exempt: merge, revert, fixup!/squash!, and release subjects (v1.3.0). +Deliberate exception: ${ENV_ALLOW_FORMAT}=1 git commit ...`; + +const ATTRIBUTION_HELP = `Commits in this repository carry no AI attribution: no Co-Authored-By trailer, +no "Generated with Claude Code" footer, no AI credit. The author of the commit +is its author. Re-commit with the attribution removed. +Deliberate exception: ${ENV_ALLOW_ATTRIBUTION}=1 git commit ...`; + +const BRANCH_HELP = `Work on each issue in its own branch. Create one first: + git checkout -b issue-- (when there is a tracked issue) + git checkout -b / (when there is not) +Already committed here? Move the work: git branch && git reset --keep HEAD~1 +Deliberate exception: ${ENV_ALLOW_DEFAULT_BRANCH}=1 git commit ...`; + +function reject(violations) { + const blocks = violations.map( + ([title, detail, help]) => + `REJECTED — ${title}\n${detail ? `${detail}\n` : ""}\n${help}\n`, + ); + process.stderr.write(`\n${blocks.join("\n")}\n`); + process.exit(1); +} + +// --------------------------------------------------------------------------- +// hook entry points +// --------------------------------------------------------------------------- + +function runCommitMsg(messagePath) { + if (!messagePath) return; + + let raw; + try { + raw = readFileSync(messagePath, "utf8"); + } catch { + return; // nothing to check; let git proceed + } + + const message = stripCommitComments(raw, commentChar()); + const subject = subjectOf(message); + const violations = []; + + if (process.env[ENV_ALLOW_ATTRIBUTION] !== "1") { + const label = findAttribution(message); + if (label) { + violations.push([ + "attribution rule", + `Matched: ${label}.`, + ATTRIBUTION_HELP, + ]); + } + } + + if (process.env[ENV_ALLOW_FORMAT] !== "1") { + const problem = checkConventional(subject); + if (problem) { + violations.push([ + "Conventional Commits rule", + `Subject: "${subject}"\nProblem: ${problem}.`, + FORMAT_HELP, + ]); + } + } + + if (violations.length) reject(violations); +} + +function runPreCommit() { + if (process.env[ENV_ALLOW_DEFAULT_BRANCH] === "1") return; + + const current = currentBranch(); + if (!current) return; // detached HEAD, or not a repo + + if (defaultBranches().includes(current)) { + reject([ + [ + "branch rule", + `'${current}' is this repository's default branch.`, + BRANCH_HELP, + ], + ]); + } +} + +function main() { + const [mode, ...rest] = process.argv.slice(2); + if (mode === "commit-msg") runCommitMsg(rest[0]); + else if (mode === "pre-commit") runPreCommit(); +} + +// Only act when run as a hook, not when imported by the tests. +if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) { + main(); +} diff --git a/.githooks/lib/git-rules.test.mjs b/.githooks/lib/git-rules.test.mjs new file mode 100644 index 0000000..f3980dc --- /dev/null +++ b/.githooks/lib/git-rules.test.mjs @@ -0,0 +1,327 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { after, before, describe, it } from "node:test"; + +import { + checkConventional, + findAttribution, + stripCommitComments, + subjectOf, + MAX_SUBJECT_LEN, +} from "./git-rules.mjs"; + +const HOOKS_DIR = dirname(dirname(fileURLToPath(import.meta.url))); +const TRAILER = "Co-Authored-By: Claude Opus 5 "; + +describe("checkConventional", () => { + const valid = [ + "feat(explorer): add network page", + "fix: support non default name networks", + "feat(contract verification): verify on deploy", + "chore(example-project): dont track ignition dir", + "feat(api)!: drop the old option", + `feat: ${"x".repeat(MAX_SUBJECT_LEN - 6)}`, + ]; + for (const subject of valid) { + it(`accepts "${subject.slice(0, 40)}"`, () => { + assert.equal(checkConventional(subject), null); + }); + } + + it("rejects a capitalized type", () => { + assert.match(checkConventional("Docs: Update README"), /lowercase/); + }); + + it("rejects a trailing period", () => { + assert.match(checkConventional("feat: add the thing."), /period/); + }); + + it("rejects a subject over the limit", () => { + const subject = `feat: ${"x".repeat(MAX_SUBJECT_LEN - 5)}`; + assert.equal(subject.length, MAX_SUBJECT_LEN + 1); + assert.match(checkConventional(subject), /limit is 72/); + }); + + it("rejects a subject with no type", () => { + assert.match( + checkConventional("Update openscan to v1.2.0-alpha"), + /must start with a lowercase type/, + ); + }); + + it("rejects an unknown type", () => { + assert.ok(checkConventional("wip: half a thing")); + }); + + const exempt = [ + "v1.3.0", + "1.0.2", + "Merge pull request #8 from MatiasOS/dev", + 'Revert "feat: add the thing"', + "fixup! feat: add the thing", + "squash! feat: add the thing", + ]; + for (const subject of exempt) { + it(`exempts "${subject.slice(0, 40)}"`, () => { + assert.equal(checkConventional(subject), null); + }); + } + + it("ignores an empty subject", () => { + assert.equal(checkConventional(""), null); + }); +}); + +describe("findAttribution", () => { + const attributed = [ + [TRAILER, "co-author trailer"], + ["Co-authored-by: Cursor Agent ", "another AI tool"], + ["Co-authored-by: dependabot[bot] ", "bot trailer"], + [ + "šŸ¤– Generated with [Claude Code](https://claude.com/claude-code)", + "footer", + ], + ["This was AI-generated from a template", "AI-generated note"], + ["Mostly written by Claude, reviewed by me", "written by note"], + ]; + for (const [text, label] of attributed) { + it(`flags ${label}`, () => { + assert.ok(findAttribution(text), `expected a match for: ${text}`); + }); + } + + const clean = [ + "feat: add the thing\n\nA normal body explaining the change.", + "fix: handle the ai-assistant config key", + "docs: describe the code generation step", + "", + ]; + for (const text of clean) { + it(`passes "${text.slice(0, 40)}"`, () => { + assert.equal(findAttribution(text), null); + }); + } +}); + +describe("message parsing", () => { + it("takes the first non-empty line as the subject", () => { + assert.equal(subjectOf("\n\nfeat: a thing\n\nbody\n"), "feat: a thing"); + }); + + it("strips comment lines", () => { + const raw = "feat: a thing\n# Please enter the commit message\n\nbody\n"; + assert.equal(stripCommitComments(raw).includes("Please enter"), false); + }); + + it("strips everything past the scissors line", () => { + const raw = [ + "feat: a thing", + "# ------------------------ >8 ------------------------", + "diff --git a/x b/x", + `+${TRAILER}`, + ].join("\n"); + const message = stripCommitComments(raw); + assert.equal(findAttribution(message), null); + assert.equal(subjectOf(message), "feat: a thing"); + }); + + it("honours a custom comment char", () => { + const raw = "feat: a thing\n; a comment\n"; + assert.equal(stripCommitComments(raw, ";").includes("a comment"), false); + }); +}); + +describe("hooks in a real repository", () => { + let repo; + + const git = (args, env = {}) => { + try { + const stdout = execFileSync("git", args, { + cwd: repo, + encoding: "utf8", + env: { ...process.env, ...env }, + stdio: ["ignore", "pipe", "pipe"], + }); + return { code: 0, out: stdout, err: "" }; + } catch (error) { + return { + code: error.status ?? 1, + out: error.stdout ?? "", + err: error.stderr ?? "", + }; + } + }; + + // A fresh file per commit, so switching branches mid-suite never trips over + // a staged modification that the target branch would have to overwrite. + let change = 0; + const stageChange = () => { + writeFileSync(join(repo, `change-${change++}.txt`), "content\n"); + git(["add", "."]); + }; + + const commit = (message, env = {}) => { + stageChange(); + return git(["commit", "-m", message], env); + }; + + before(() => { + repo = mkdtempSync(join(tmpdir(), "githooks-test-")); + git(["init", "-b", "main"]); + git(["config", "user.email", "t@example.com"]); + git(["config", "user.name", "Test"]); + git(["config", "core.hooksPath", HOOKS_DIR]); + writeFileSync(join(repo, "file.txt"), "start\n"); + git(["add", "."]); + // The default-branch guard is exercised separately; get a base commit in. + git(["commit", "-m", "feat: initial"], { + ALLOW_DEFAULT_BRANCH_COMMIT: "1", + }); + git(["checkout", "-q", "-b", "feat/work"]); + }); + + after(() => { + if (repo) rmSync(repo, { recursive: true, force: true }); + }); + + it("accepts a conventional subject on a feature branch", () => { + const { code } = commit("feat(explorer): add network page"); + assert.equal(code, 0); + }); + + it("rejects a capitalized type", () => { + const { code, err } = commit("Docs: Update README"); + assert.equal(code, 1); + assert.match(err, /Conventional Commits rule/); + assert.match(err, /lowercase/); + }); + + it("rejects a trailing period", () => { + const { code, err } = commit("feat: add the thing."); + assert.equal(code, 1); + assert.match(err, /period/); + }); + + it("rejects an over-long subject", () => { + const { code, err } = commit(`feat: ${"x".repeat(MAX_SUBJECT_LEN - 5)}`); + assert.equal(code, 1); + assert.match(err, /limit is 72/); + }); + + it("rejects an attribution trailer", () => { + const { code, err } = commit(`feat: sneaky\n\n${TRAILER}`); + assert.equal(code, 1); + assert.match(err, /attribution rule/); + }); + + it("rejects a Generated-with footer", () => { + const { code, err } = commit( + "feat: sneaky\n\nšŸ¤– Generated with [Claude Code](https://claude.com/claude-code)", + ); + assert.equal(code, 1); + assert.match(err, /attribution rule/); + }); + + it("reports both violations at once", () => { + const { code, err } = commit(`Docs: Update README\n\n${TRAILER}`); + assert.equal(code, 1); + assert.match(err, /attribution rule/); + assert.match(err, /Conventional Commits rule/); + }); + + it("accepts an exempt release subject", () => { + assert.equal(commit("v1.3.0").code, 0); + }); + + it("accepts an exempt merge subject", () => { + assert.equal(commit("Merge pull request #8 from MatiasOS/dev").code, 0); + }); + + it("accepts a message file with no attribution", () => { + const path = join(repo, "msg.txt"); + writeFileSync(path, "feat: from a file\n\nA normal body.\n"); + stageChange(); + assert.equal(git(["commit", "-F", path]).code, 0); + }); + + it("rejects a message file carrying attribution", () => { + const path = join(repo, "msg.txt"); + writeFileSync(path, `feat: from a file\n\n${TRAILER}\n`); + stageChange(); + const { code, err } = git(["commit", "-F", path]); + assert.equal(code, 1); + assert.match(err, /attribution rule/); + }); + + it("honours the format escape hatch", () => { + const { code } = commit("Docs: Update README", { + ALLOW_NONCONVENTIONAL_COMMIT: "1", + }); + assert.equal(code, 0); + }); + + it("honours the attribution escape hatch", () => { + const { code } = commit(`feat: allowed\n\n${TRAILER}`, { + ALLOW_AI_ATTRIBUTION: "1", + }); + assert.equal(code, 0); + }); + + it("honours --no-verify", () => { + stageChange(); + assert.equal( + git(["commit", "--no-verify", "-m", "Docs: bypassed"]).code, + 0, + ); + }); + + describe("default-branch guard", () => { + before(() => git(["checkout", "-q", "main"])); + after(() => git(["checkout", "-q", "feat/work"])); + + it("rejects a commit on main when origin/HEAD is unset", () => { + const { code, err } = commit("feat: on the default branch"); + assert.equal(code, 1); + assert.match(err, /branch rule/); + assert.match(err, /default branch/); + }); + + it("honours the branch escape hatch", () => { + const { code } = commit("feat: deliberate", { + ALLOW_DEFAULT_BRANCH_COMMIT: "1", + }); + assert.equal(code, 0); + }); + }); + + describe("default branch resolved from origin/HEAD", () => { + before(() => { + git(["checkout", "-q", "-B", "dev"]); + git(["update-ref", "refs/remotes/origin/dev", "HEAD"]); + git([ + "symbolic-ref", + "refs/remotes/origin/HEAD", + "refs/remotes/origin/dev", + ]); + }); + after(() => { + git(["symbolic-ref", "-d", "refs/remotes/origin/HEAD"]); + git(["checkout", "-q", "feat/work"]); + }); + + it("rejects a commit on dev when origin/HEAD points there", () => { + const { code, err } = commit("feat: on dev"); + assert.equal(code, 1); + assert.match(err, /'dev' is this repository's default branch/); + }); + + it("allows main once origin/HEAD names dev instead", () => { + git(["checkout", "-q", "main"]); + assert.equal(commit("feat: main is not the default here").code, 0); + }); + }); +}); diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..bf648b2 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,4 @@ +#!/bin/sh +# Reject commits made directly on the default branch — one branch per issue. +# Rules live in lib/git-rules.mjs. Bypass a single commit with --no-verify. +exec node "$(dirname "$0")/lib/git-rules.mjs" pre-commit diff --git a/.gitignore b/.gitignore index 115dc8e..a2cd6b6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ # Node modules /node_modules -.DS_Store \ No newline at end of file +.DS_Store + +# Per-contributor Claude Code settings +.claude/settings.local.json \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..bdb1c23 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,50 @@ +# @openscan/hardhat-plugin + +pnpm workspace. Packages live under [packages/](packages/). + +## Git workflow + +`MatiasOS/hardhat-plugin` (`origin`) is a fork of `openscan-explorer/hardhat-plugin` +(`openscan`), where most work happens. Its default branch is **`dev`**; upstream's +is `main`. Pull requests run cross-fork: `MatiasOS:` → base `main` on +`openscan-explorer/hardhat-plugin`. + +**One branch per issue.** Never commit directly to the default branch — `dev` +here, `main` in an upstream clone; the hook resolves it per clone. Branch first: + +- `issue--` when there's a tracked issue — `issue-42-verify-on-deploy` +- `/` when there isn't — `ci/add-missing-repo-url`, `fix/network-name-restriction` + +**Conventional Commits, strictly** — `type(scope)?: description` + +- Lowercase type from: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert +- Subject ≤72 characters, no trailing period, imperative mood +- Scopes in use here: `explorer`, `plugin`, `example-project` +- Exempt: merge, revert, `fixup!`/`squash!`, and release subjects (`v1.3.0`) + +```text +feat(explorer): open browser on network page +fix(plugin): prevent multiple logs on startup +chore(example-project): dont track ignition dir for easy testing +``` + +## Enforcement + +These rules are not advisory — [.githooks/](.githooks/) enforces them for every +contributor, activated by `pnpm install` via the `prepare` script. `commit-msg` +checks the subject format and scans for attribution; `pre-commit` guards the +default branch. The rules live in one place, +[.githooks/lib/git-rules.mjs](.githooks/lib/git-rules.mjs), covered by +`pnpm test:hooks`. + +A rejection is the rule firing, not a flaky failure — fix the message or the +branch rather than retrying. For a deliberate exception, ask first, then prefix +the command with `ALLOW_NONCONVENTIONAL_COMMIT=1`, `ALLOW_DEFAULT_BRANCH_COMMIT=1` +or `ALLOW_AI_ATTRIBUTION=1`. See [CONTRIBUTING.md](CONTRIBUTING.md). + +## Authorship + +Commits and PRs carry no AI attribution — no `Co-Authored-By: Claude` trailer, no +`šŸ¤– Generated with [Claude Code]` footer, no "AI-generated" notes in code comments or +PR bodies. The human running the session is the sole author; write commit messages in +their voice, describing the change rather than what produced it. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..3bbfc98 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,85 @@ +# Contributing + +## Setup + +```bash +pnpm install +``` + +That also activates this repo's git hooks by pointing `core.hooksPath` at +[.githooks/](.githooks/). They need nothing but the Node you already have. + +## Branches + +Work on each issue in its own branch. Committing straight to your clone's +default branch is rejected — that's `dev` in the `MatiasOS/hardhat-plugin` fork +where most work happens, and `main` in `openscan-explorer/hardhat-plugin`. The +hook resolves it from your own `origin/HEAD`, so either clone is guarded. + +```bash +git checkout -b issue-42-verify-on-deploy # when there is a tracked issue +git checkout -b fix/network-name-restriction # when there is not +``` + +## Commit messages + +[Conventional Commits](https://www.conventionalcommits.org/), enforced: + +```text +type(scope): description +``` + +- Type is lowercase, one of: `feat`, `fix`, `docs`, `style`, `refactor`, + `perf`, `test`, `build`, `ci`, `chore`, `revert` +- Subject is 72 characters or fewer, imperative mood, no trailing period +- Scopes in use: `explorer`, `plugin`, `example-project` + +```text +feat(explorer): open browser on network page +fix(plugin): prevent multiple logs on startup +docs: add steps to test the plugin +``` + +Merge, revert, `fixup!`/`squash!` and release subjects (`v1.3.0`) are exempt. + +## Authorship + +Commits carry no AI attribution — no `Co-Authored-By` trailer naming an AI +assistant, no "Generated with …" footer, no "AI-generated" notes in commit +messages or code comments. Whoever makes the commit is its author; write the +message in your own voice, describing the change rather than what produced it. + +Use whatever tools you like — this is about the record they leave, not how you +work. If you use Claude Code, add this to your own user settings so it stops +appending the trailer: + +```json +{ "attribution": { "commit": "", "pr": "" } } +``` + +## Overrides + +Each rule has an escape hatch for the deliberate exception: + +```bash +ALLOW_NONCONVENTIONAL_COMMIT=1 git commit -m "..." +ALLOW_DEFAULT_BRANCH_COMMIT=1 git commit -m "..." +ALLOW_AI_ATTRIBUTION=1 git commit -m "..." +``` + +`git commit --no-verify` skips the hooks entirely. + +## Pull requests + +```bash +pnpm build && pnpm test && pnpm lint +pnpm test:hooks # only if you changed .githooks/ +``` + +Push your branch to your fork and open the PR against +`openscan-explorer/hardhat-plugin`, base `main`: + +```bash +git push -u origin +gh pr create --repo openscan-explorer/hardhat-plugin --base main +``` diff --git a/README.md b/README.md index 27a9fea..e7b10d7 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,8 @@ pnpm build pnpm test ``` +`pnpm install` also activates the repo's git hooks, which enforce the branch and commit-message rules. Read [CONTRIBUTING.md](CONTRIBUTING.md) before your first commit — the short version: branch off `dev` for every change, and use [Conventional Commits](https://www.conventionalcommits.org/). + ### Monorepo structure - `packages/plugin` — the plugin source code diff --git a/package.json b/package.json index 4b05dac..8d883cb 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,9 @@ "clean": "pnpm --recursive clean", "lint": "pnpm --recursive lint", "lint:fix": "pnpm --recursive lint:fix", + "prepare": "git config core.hooksPath .githooks || true", "test": "pnpm --recursive test", + "test:hooks": "node --test .githooks/lib/*.test.mjs", "watch": "pnpm --filter ./packages/plugin watch" }, "devDependencies": {