Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .husky/commit-msg
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -e
# Skip for pure vendored commits (repos/) and the subtrees.toml registration.
if git diff --cached --name-only | grep -qvE '^(repos/.*|subtrees\.toml)$'; then
deno run --allow-read --allow-run --allow-env --allow-sys npm:@commitlint/cli@19.8.1 --edit "$1"
fi
22 changes: 22 additions & 0 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
#!/usr/bin/env sh
# Skip during a merge: the staged set is the merge result, not authored work.
if [ -e "$(git rev-parse --git-path MERGE_HEAD)" ]; then
exit 0
fi

# Skip for pure repos/ subtree commits and the subtrees.toml registration commit.
if git diff --cached --name-only | grep -qvE '^(repos/.*|subtrees\.toml)$'; then
# Format staged files that are ours (skip vendored repos/)
staged=$(git diff --cached --name-only --diff-filter=ACMR | grep -vE '^repos/' | tr '\n' ' ')
if [ -n "$staged" ]; then
# dprint on staged non-vendored files
echo "$staged" | xargs dprint fmt --allow-no-files -- 2>/dev/null || true
# deno lint on staged TS/JS files (commitlint config excluded via deno.json)
lint_staged=$(echo "$staged" | tr ' ' '\n' | grep -E '\.(ts|tsx|js|jsx|mjs|cjs)$' | grep -vE 'commitlint\.config\.ts' | tr '\n' ' ')
if [ -n "$lint_staged" ]; then
echo "$lint_staged" | xargs deno lint 2>/dev/null || true
fi
# re-add formatted files
echo "$staged" | xargs git add -- 2>/dev/null || true
fi
fi
22 changes: 22 additions & 0 deletions .husky/pre-push
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
#!/usr/bin/env sh
# Refuse a push whose refs do not contain remote main.
# Local main is not the source of truth — fetch the remote tip.
set -e
remote=origin
# No remote main yet (new repo) — allow
if ! git ls-remote --exit-code "$remote" refs/heads/main >/dev/null 2>&1; then
exit 0
fi
main_sha=$(git ls-remote "$remote" refs/heads/main | cut -f1)
if [ -z "$main_sha" ]; then
exit 0
fi
# Ensure we have the object locally; fetch if missing
if ! git cat-file -e "$main_sha^{commit}" 2>/dev/null; then
git fetch --quiet "$remote" refs/heads/main || exit 1
fi
if ! git merge-base --is-ancestor "$main_sha" HEAD; then
echo "pre-push: behind remote main ($main_sha)." >&2
echo "Fetch and rebase onto $remote/main before pushing." >&2
exit 1
fi
24 changes: 24 additions & 0 deletions .husky/prepare-commit-msg
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
#!/bin/sh
# prepare-commit-msg — Strip AI co-author trailers
# Preserves human pair attribution while removing AI credit lines.

COMMIT_MSG_FILE=$1
COMMIT_SOURCE=$2

# Only process standard commits
case "$COMMIT_SOURCE" in
merge|squash)
exit 0
;;
esac

if [ -f "$COMMIT_MSG_FILE" ]; then
temp_file=$(mktemp)
grep -v -E -i "^Co-?-?[Aa]uthored-by:.*<.*(noreply@anthropic\.com|cursoragent@cursor\.com|noreply@aider\.dev|cascade@windsurf\.com|clio-agent@sisyphuslabs\.ai)>" "$COMMIT_MSG_FILE" > "$temp_file"
if [ -s "$temp_file" ]; then
mv "$temp_file" "$COMMIT_MSG_FILE"
else
rm "$temp_file"
exit 1
fi
fi
194 changes: 194 additions & 0 deletions commitlint.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import type { UserConfig } from '@commitlint/types'
import { execFileSync } from 'node:child_process'

const EXTRA_SCOPES = ['repo', 'deps', 'release', 'ci', 'scripts', 'hooks'] as const

const matchesAny = (...patterns: readonly RegExp[]) => (path: string) => patterns.some((p) => p.test(path))

const isDoc = matchesAny(
/\.mdx?$/,
/^docs\//,
/(^|\/)README\.md$/i,
/(^|\/)AGENTS\.md$/i,
/(^|\/)CLAUDE\.md$/i,
/(^|\/)CHANGELOG\.md$/i,
)

const isTest = matchesAny(
/\.(test|spec|tst)\.(ts|tsx|js|jsx|mjs|cjs)$/,
/(^|\/)__tests__\//,
/(^|\/)__mocks__\//,
/(^|\/)tests\//,
/(^|\/)test-helpers\//,
/(^|\/)e2e\//,
/(^|\/)fixtures\//,
)

const isCI = matchesAny(
/^\.github\/workflows\//,
/^\.github\/actions\//,
/^\.github\/dependabot\.ya?ml$/,
)

const isLockfile = matchesAny(
/(^|\/)pnpm-lock\.yaml$/,
/(^|\/)package-lock\.json$/,
/(^|\/)bun\.lockb?$/,
/(^|\/)yarn\.lock$/,
/(^|\/)deno\.lock$/,
)

const isTooling = matchesAny(
/^\.husky\//,
/(^|\/)commitlint\.config\.[mc]?[jt]s$/,
/(^|\/)\.releaserc(\..+)?$/,
/(^|\/)\.lintstagedrc(\..+)?$/,
/(^|\/)tsconfig.*\.json$/,
/(^|\/)deno\.jsonc?$/,
/(^|\/)dprint\.json$/,
/(^|\/)\.editorconfig$/,
/(^|\/)\.gitignore$/,
/(^|\/)\.prettierrc(\..+)?$/,
/(^|\/)package\.json$/,
/(^|\/)pnpm-workspace\.yaml$/,
)

const ALLOWED_BY_SHAPE: readonly {
readonly name: string
readonly match: (path: string) => boolean
readonly allowed: Readonly<Record<string, true>>
}[] = [
{ name: 'docs', match: isDoc, allowed: { docs: true, chore: true, ai: true } },
{ name: 'test', match: isTest, allowed: { test: true, chore: true } },
{ name: 'CI', match: isCI, allowed: { ci: true, chore: true } },
{ name: 'lockfile', match: isLockfile, allowed: { deps: true, chore: true } },
{
name: 'tooling',
match: isTooling,
allowed: { chore: true, build: true, ci: true, deps: true, ai: true, security: true },
},
]

const stagedFiles = (): readonly string[] => {
try {
return execFileSync('git', ['diff', '--cached', '--name-only'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
})
.split('\n')
.map((l: string) => l.trim())
.filter(Boolean)
} catch {
return []
}
}

const configuration: UserConfig = {
extends: ['@commitlint/config-conventional'],
plugins: [
{
rules: {
'no-ai-coauthors': ({ raw }: { raw: string }) => {
if (!raw) {
return [true, 'OK']
}
const aiEmailPatterns = [
/noreply@anthropic\.com/i,
/cursoragent@cursor\.com/i,
/noreply@aider\.dev/i,
/cascade@windsurf\.com/i,
/noreply@codeium\.com/i,
/clio-agent@sisyphuslabs\.ai/i,
/factory-droid\[bot\]@users\.noreply\.github\.com/i,
] as const
const coauthorLines = raw.match(/^Co-?-?[Aa]uthored-by:.*$/gmi) || []
const aiModelPatterns = [
/\b(Claude\s+)?(Opus|Sonnet|Haiku)\b/i,
/\bgpt-4o\b/i,
/\bClaude\b.*\b3\.\d+\b/i,
] as const
const hasAIModelInCoauthor = coauthorLines.some((line: string) =>
aiModelPatterns.some((pattern) => pattern.test(line))
)
const hasAIEmail = aiEmailPatterns.some((pattern) => pattern.test(raw))
const hasAICoauthor = hasAIEmail || hasAIModelInCoauthor
return [
!hasAICoauthor,
hasAICoauthor
? 'AI co-authors and AI model references are not allowed in commit messages'
: 'OK',
]
},
'type-matches-diff-shape': ({ type }: { type?: string }) => {
const files = stagedFiles()
if (files.length === 0 || !type) return [true, 'OK']
const allMatch = (m: (p: string) => boolean) => files.every(m)
for (const shape of ALLOWED_BY_SHAPE) {
if (allMatch(shape.match) && !shape.allowed[type]) {
const allowed = Object.keys(shape.allowed).sort().join(' / ')
return [false, `'${type}' with 100% ${shape.name} paths — REQUIRED type: ${allowed}`]
}
}
if (type === 'feat' || type === 'fix') {
const hasProductionSource = files.some(
(p) => !isDoc(p) && !isTest(p) && !isCI(p) && !isLockfile(p) && !isTooling(p),
)
if (!hasProductionSource) {
return [
false,
`'${type}' MUST touch >=1 production source file (none of: docs, test, CI, lockfile, tooling)`,
]
}
}
return [true, 'OK']
},
},
},
],
rules: {
'no-ai-coauthors': [2, 'always'],
'type-matches-diff-shape': [2, 'always'],
'type-enum': [
2,
'always',
[
'ai',
'api',
'build',
'chore',
'ci',
'deps',
'docs',
'feat',
'fix',
'improvement',
'perf',
'refactor',
'revert',
'security',
'style',
'test',
],
],
'scope-enum': [2, 'always', [...EXTRA_SCOPES]],
'scope-case': [2, 'always', 'kebab-case'],
'type-case': [2, 'always', 'lower-case'],
'type-empty': [2, 'never'],
'subject-case': [0],
'subject-empty': [2, 'never'],
'subject-full-stop': [2, 'never', '.'],
'header-max-length': [0],
'body-max-line-length': [0],
'footer-max-line-length': [0],
'body-leading-blank': [0],
'footer-leading-blank': [0],
'header-full-stop': [2, 'never', '.'],
'body-full-stop': [2, 'never', '.'],
'references-empty': [1, 'never'],
},
defaultIgnores: true,
ignores: [(commit: string) => commit.startsWith("Squashed '") || commit.includes('git-subtree-dir:')],
formatter: '@commitlint/format',
}

export default configuration
11 changes: 8 additions & 3 deletions deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,22 @@
"lint": "deno lint",
"fmt": "dprint fmt",
"fmt:check": "dprint check",
"test": "deno test --allow-read --allow-write --allow-run --allow-env"
"test": "deno test --allow-read --allow-write --allow-run --allow-env",
"commitlint": "deno run --allow-read --allow-run --allow-env --allow-sys npm:@commitlint/cli@19.8.1 --edit",
"prepare": "deno run --allow-read --allow-write --allow-run --allow-sys npm:husky@9.1.7"
},
"imports": {
"@std/assert": "jsr:@std/assert@^1.0.14",
"@std/fs": "jsr:@std/fs@^1.0.19",
"@std/path": "jsr:@std/path@^1.0.9"
"@std/path": "jsr:@std/path@^1.0.9",
"@commitlint/cli": "npm:@commitlint/cli@19.8.1",
"@commitlint/config-conventional": "npm:@commitlint/config-conventional@19.8.1"
},
"nodeModulesDir": "auto",
"lint": {
"rules": {
"exclude": ["no-slow-types"]
}
},
"exclude": [".codegraph", ".git"]
"exclude": [".codegraph", ".git", "commitlint.config.ts"]
}
Loading
Loading