Replace monolithic ci.yml with per-platform workflows so each README badge tracks one job. Scan commit messages for tool-agnostic AI attribution in CI, sync local hooks to the canon, and refresh README status plus git-discipline Rule 8.
242 lines
8.4 KiB
Bash
Executable File
242 lines
8.4 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# pre-commit hook: reject style tells in staged file content.
|
|
#
|
|
# Companion to commit-msg-style-check (which scans the commit message).
|
|
# Scans the staged diff for the same forbidden patterns:
|
|
# - Em-dash (U+2014). github-discipline.md Rule 5.2.
|
|
# - AI-attribution literal phrases. github-discipline.md Rule 5.1.
|
|
# - AI-signature emojis used as flourish.
|
|
# - AI-tell preamble phrasings at line start.
|
|
#
|
|
# Scope: only ADDED lines (`^+` in the staged diff, excluding file headers).
|
|
# This means existing offending content stays put; only new content is gated.
|
|
# A future "sweep" task can clean up legacy content; this hook prevents new
|
|
# violations from entering staged commits.
|
|
#
|
|
# Install (per repo): git config core.hooksPath .githooks
|
|
#
|
|
# Skip mechanism (for legitimate meta-references that need to quote the
|
|
# forbidden tokens, like the rule documentation itself):
|
|
# Any line containing `# style-check: allow` is skipped. Use sparingly.
|
|
#
|
|
# Excluded paths (never scanned):
|
|
# - Generated lockfiles: Cargo.lock, Package.resolved, package-lock.json,
|
|
# pnpm-lock.yaml, yarn.lock, uv.lock, Pipfile.lock, poetry.lock, Gemfile.lock
|
|
# - Binary files (detected via `git diff --numstat` reporting `-` width)
|
|
# - Vendored / third-party directories: vendor/, third_party/, node_modules/,
|
|
# _archive/python-reference/ (frozen senko snapshot we don't author)
|
|
# - The rule files that DEFINE the prohibitions: github-discipline.md and
|
|
# similar (would otherwise self-flag for citing the forbidden strings)
|
|
#
|
|
# Portable: BSD-compatible grep, no PCRE.
|
|
|
|
set -e
|
|
|
|
# Skip when there's nothing staged (e.g. `git commit --amend` with no index).
|
|
if git diff --cached --name-only --diff-filter=ACMR | head -1 | grep -q .; then
|
|
:
|
|
else
|
|
exit 0
|
|
fi
|
|
|
|
# Build the list of staged text files that should be scanned.
|
|
# `--diff-filter=ACMR` picks Added/Copied/Modified/Renamed; we skip Deleted.
|
|
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACMR)
|
|
|
|
# Exclusion path patterns. A staged file path that matches any of these is
|
|
# skipped entirely. Match against the path-from-repo-root, no leading slash.
|
|
SKIP_PATTERNS=(
|
|
# Lockfiles (generated, not human-authored).
|
|
'Cargo\.lock$'
|
|
'Package\.resolved$'
|
|
'package-lock\.json$'
|
|
'pnpm-lock\.yaml$'
|
|
'yarn\.lock$'
|
|
'uv\.lock$'
|
|
'Pipfile\.lock$'
|
|
'poetry\.lock$'
|
|
'Gemfile\.lock$'
|
|
'composer\.lock$'
|
|
# Vendored / third-party.
|
|
'^vendor/'
|
|
'^third_party/'
|
|
'^node_modules/'
|
|
'^_archive/'
|
|
'^Tests/Fixtures/acme/'
|
|
'^Tests/Fixtures/real-yaml/'
|
|
# Rule files that DEFINE the prohibitions; they MUST be able to cite
|
|
# the forbidden strings to document them.
|
|
'/github-discipline\.md$'
|
|
'/commit-msg-style-check$'
|
|
'/pre-commit-style-check$'
|
|
# Same hooks installed under the public-kit names (.githooks/commit-msg,
|
|
# .githooks/pre-commit) rather than the canonical *-style-check names.
|
|
'/commit-msg$'
|
|
'/pre-commit$'
|
|
'/pre-push$'
|
|
'/check-style\.sh$'
|
|
'/check-commit-attribution\.sh$'
|
|
'/check-staged-style\.sh$'
|
|
)
|
|
|
|
# Filter STAGED_FILES through the skip list.
|
|
SCAN_FILES=""
|
|
while IFS= read -r f; do
|
|
[ -z "$f" ] && continue
|
|
skipped=0
|
|
for pat in "${SKIP_PATTERNS[@]}"; do
|
|
if printf '%s' "$f" | grep -qE -- "$pat"; then
|
|
skipped=1
|
|
break
|
|
fi
|
|
done
|
|
[ "$skipped" -eq 1 ] && continue
|
|
SCAN_FILES="${SCAN_FILES}${f}"$'\n'
|
|
done <<<"$STAGED_FILES"
|
|
|
|
# Exit clean if nothing to scan.
|
|
if [ -z "${SCAN_FILES//[$'\n\t ']}" ]; then
|
|
exit 0
|
|
fi
|
|
|
|
# Helper: get the ADDED lines (starting with `+` but not the `+++` file header)
|
|
# from the staged diff for a specific file, with their line numbers.
|
|
# Output format: `<line-number>:<content-after-the-plus>`.
|
|
added_lines_for() {
|
|
local file="$1"
|
|
# `git diff --cached -U0` minimises context; `--no-color` keeps output clean.
|
|
git diff --cached -U0 --no-color -- "$file" \
|
|
| awk '
|
|
/^\+\+\+ / { next } # skip the +++ file header
|
|
/^@@ / {
|
|
# Parse the hunk header "@@ -a,b +c,d @@" to seed line numbers.
|
|
# Find "+c" or "+c,d" and extract c.
|
|
match($0, /\+[0-9]+/)
|
|
ln = substr($0, RSTART+1, RLENGTH-1) + 0
|
|
next
|
|
}
|
|
/^\+/ {
|
|
# An added line. Print it (without the leading +) with line number.
|
|
printf "%d:%s\n", ln, substr($0, 2)
|
|
ln++
|
|
}
|
|
/^[^+]/ {
|
|
# Context or removed line. Context lines are normally suppressed
|
|
# by -U0, but advance the counter if any do appear.
|
|
if (/^ /) ln++
|
|
}
|
|
'
|
|
}
|
|
|
|
FAIL=0
|
|
EMDASH=$(printf '\xe2\x80\x94')
|
|
|
|
# AI-attribution literal phrases (mirror commit-msg-style-check).
|
|
LITERAL_PATTERNS=(
|
|
'Co-Authored-By: Claude'
|
|
'Co-authored-by: Claude'
|
|
'Generated with Claude Code'
|
|
'Generated with Claude'
|
|
'Generated by Claude'
|
|
'with help from Claude'
|
|
'Powered by Claude'
|
|
'powered by Claude'
|
|
'as an AI'
|
|
'As an AI'
|
|
'this commit was generated'
|
|
'This commit was generated'
|
|
'this change was generated'
|
|
'This change was generated'
|
|
)
|
|
|
|
# Cursor-attribution literal tells (case-insensitive at the call site).
|
|
CURSOR_LITERALS=(
|
|
'Co-Authored-By: Cursor'
|
|
'Co-authored-by: Cursor'
|
|
'Generated with Cursor'
|
|
'Generated by Cursor'
|
|
'with help from Cursor'
|
|
'Powered by Cursor'
|
|
)
|
|
|
|
# AI-tell signature emojis (UTF-8 byte sequences kept in a variable so the
|
|
# script source stays clean of the glyphs themselves).
|
|
EMOJI_TELLS=$(printf '\xf0\x9f\xa4\x96\n\xe2\x9c\xa8\n\xf0\x9f\xaa\x84\n\xf0\x9f\xa7\xa0\n\xf0\x9f\xa6\xbe\n\xf0\x9f\xa4\x9d')
|
|
|
|
# Scan each file's added lines.
|
|
while IFS= read -r file; do
|
|
[ -z "$file" ] && continue
|
|
test -f "$file" || continue
|
|
|
|
# Get added lines for this file, then filter out lines marked
|
|
# `# style-check: allow`.
|
|
ADDED=$(added_lines_for "$file" | grep -vF -- '# style-check: allow' || true)
|
|
[ -z "$ADDED" ] && continue
|
|
|
|
# Em-dash check.
|
|
if printf '%s' "$ADDED" | LC_ALL=C grep -qF -- "$EMDASH"; then
|
|
printf 'pre-commit: forbidden em dash (U+2014) in staged %s.\n' "$file" >&2
|
|
printf ' Replace with comma, period, colon, semicolon, or restructure.\n' >&2
|
|
printf ' Rule: github-discipline.md Rule 5.2\n' >&2
|
|
printf ' Locations (line numbers in the working tree):\n' >&2
|
|
printf '%s\n' "$ADDED" | LC_ALL=C grep -F -- "$EMDASH" | sed 's/^/ /' >&2
|
|
FAIL=1
|
|
fi
|
|
|
|
# AI-attribution literal-phrase check.
|
|
for p in "${LITERAL_PATTERNS[@]}"; do
|
|
if printf '%s' "$ADDED" | grep -qF -- "$p"; then
|
|
printf 'pre-commit: forbidden AI-attribution phrase in staged %s: %s\n' "$file" "$p" >&2
|
|
printf ' Rule: github-discipline.md Rule 5.1\n' >&2
|
|
FAIL=1
|
|
fi
|
|
done
|
|
|
|
# Cursor-attribution check (mirrors commit-msg). Specific tells only, never
|
|
# the bare word "cursor" (a legitimate programming term: text/db cursor).
|
|
for p in "${CURSOR_LITERALS[@]}"; do
|
|
if printf '%s' "$ADDED" | grep -qiF -- "$p"; then
|
|
printf 'pre-commit: forbidden Cursor attribution in staged %s: %s\n' "$file" "$p" >&2
|
|
printf ' Rule: github-discipline.md Rule 5.1\n' >&2
|
|
FAIL=1
|
|
fi
|
|
done
|
|
if printf '%s' "$ADDED" | grep -qiE -- '@cursor\.com'; then
|
|
printf 'pre-commit: forbidden Cursor email (@cursor.com) in staged %s.\n' "$file" >&2
|
|
printf ' Rule: github-discipline.md Rule 5.1\n' >&2
|
|
FAIL=1
|
|
fi
|
|
|
|
# Vendor-neutral AI attribution (Rule 5.1 covers every assistant).
|
|
AI_VENDORS="Codex Copilot Gemini ChatGPT OpenAI Anthropic"
|
|
AI_PREFIXES='Co-Authored-By:|Co-authored-by:|Generated with|Generated by|with help from|Powered by'
|
|
for vendor in $AI_VENDORS; do
|
|
if printf '%s' "$ADDED" | grep -qiE -- "(${AI_PREFIXES}) ${vendor}"; then
|
|
printf 'pre-commit: forbidden AI attribution for %s in staged %s.\n' "$vendor" "$file" >&2
|
|
printf ' Rule: github-discipline.md Rule 5.1 (tool-agnostic)\n' >&2
|
|
FAIL=1
|
|
fi
|
|
done
|
|
|
|
# AI-signature emoji check.
|
|
while IFS= read -r emoji; do
|
|
[ -z "$emoji" ] && continue
|
|
if LC_ALL=C printf '%s' "$ADDED" | grep -qF -- "$emoji"; then
|
|
printf 'pre-commit: forbidden AI-signature emoji in staged %s.\n' "$file" >&2
|
|
printf ' Rule: github-discipline.md Rule 5.1\n' >&2
|
|
FAIL=1
|
|
break
|
|
fi
|
|
done <<<"$EMOJI_TELLS"
|
|
done <<<"$SCAN_FILES"
|
|
|
|
if [ "$FAIL" -ne 0 ]; then
|
|
printf '\n' >&2
|
|
printf 'Hook scope: only ADDED lines in the staged diff are gated.\n' >&2
|
|
printf 'Existing offending content is grandfathered until a sweep is run.\n' >&2
|
|
printf 'To temporarily bypass (use sparingly): add `# style-check: allow`\n' >&2
|
|
printf 'on the same line, or `git commit --no-verify` (logged in audit).\n' >&2
|
|
fi
|
|
|
|
exit $FAIL
|