From 576d8be7d81907f0c6216e07437bf583d383d1b6 Mon Sep 17 00:00:00 2001 From: Cody Williamson Date: Sun, 19 Jul 2026 19:32:24 -0500 Subject: [PATCH 1/7] docs: add config features design spec --- .../2026-07-19-config-features-design.md | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-19-config-features-design.md diff --git a/docs/superpowers/specs/2026-07-19-config-features-design.md b/docs/superpowers/specs/2026-07-19-config-features-design.md new file mode 100644 index 0000000..320c99d --- /dev/null +++ b/docs/superpowers/specs/2026-07-19-config-features-design.md @@ -0,0 +1,123 @@ +# commit-guard configuration features — design + +Date: 2026-07-19 +Status: approved (brainstormed interactively, implementation authorized autonomously) + +## Goal + +Add a per-repo config file that both CI and local hooks read, plus four new +capabilities: AI-attribution policy, custom allowed types, custom ban patterns, +warn-only enforcement, and branch filters. + +## Config file: `.commit-guard.json` + +Single source of truth at the repo root. All keys optional. File values win +over workflow inputs; workflow inputs remain as fallbacks when the file or key +is absent. Built-in defaults preserve v0.2 behavior exactly. + +```json +{ + "config": "conventional", + "pr-mode": "smart", + "enforce": "block", + "ai-attribution": "block", + "types": ["feat", "fix", "chore", "ci", "docs", "test", "refactor", "perf", "build", "style"], + "ban-patterns": ["password", "^temp"], + "branches": ["main", "master"], + "ignore-bot-commits": true, + "ignore-merge-commits": true, + "ignore-message-patterns": ["^Initial plan$"] +} +``` + +Keys are kebab-case to mirror workflow inputs. Format is JSON per user choice. + +### Parsing strategy + +- **CI**: `jq` (preinstalled on GitHub ubuntu runners). +- **Native hook**: `jq` when available, else an inlined sed/awk fallback that + handles the flat schema. Fallback constraint (documented): arrays must be + pretty-printed one element per line, and elements must not contain `"`. + With jq installed there are no constraints. + +## Features + +### 1. AI attribution policy — `ai-attribution: allow | warn | strip | block` + +Built-in case-insensitive patterns matched against the full commit message: +co-authored-by trailers naming AI tools (claude, copilot, chatgpt, openai, +anthropic, gemini, cursor, devin, aider, codex, `[bot]`), "generated with/by" +AI bylines, and `noreply@anthropic.com`. + +- `allow` (default): no check — non-breaking for v0.2 upgrades. +- `warn`: print a warning, pass. +- `strip`: local hook rewrites the commit message file, removing matching + lines, then passes. **In CI, `strip` behaves as `block`** — CI cannot + rewrite pushed commits, so it acts as the backstop for commits made without + hooks installed. +- `block`: fail the lint. + +The installer writes `"ai-attribution": "block"` into the starter config so +new installs are protected by default while upgrades keep old behavior. + +### 2. Custom allowed types — `types` + +- Native hook: builds its validation regex from the list. +- CI: generates a commitlint config extending the preset with a `type-enum` + rule override. +- If the repo has its own commitlint config, that config wins and `types` is + ignored with a printed notice (avoids two sources of truth in the + commitlint ecosystem). + +### 3. Custom ban patterns — `ban-patterns` + +Case-insensitive ERE patterns; if any matches anywhere in the commit message +(or PR title in title lint path), the lint fails. Enforced by the bash layer +in both the hook and CI (commitlint cannot do arbitrary body-regex bans +without a plugin — rejected approach B, publishing a plugin, as YAGNI). + +### 4. Warn-only enforcement — `enforce: block | warn` + +CI collects all failures instead of exiting on the first, then: +- `block` (default): exit 1 if anything failed. +- `warn`: emit `::warning` annotations and exit 0. For adopting commit-guard + on messy repos. + +Local hook honors it too: `warn` prints the error but allows the commit. + +### 5. Branch filters — `branches` + +On push events, if the pushed branch is not in the list, CI skips linting +entirely with a notice. Empty/absent list = all branches (current behavior). +Requires passing `github.ref_name` into the lint script. + +## Components changed + +| File | Change | +|---|---| +| `scripts/validate-commit-message.sh` | read config (jq or inline fallback), custom types regex, ban patterns, ai-attribution incl. strip, enforce warn | +| `scripts/run-commitlint-ci.sh` | jq config read with env fallback, ban patterns, ai-attribution (strip→block), warn mode failure collection, branch filter | +| `.github/workflows/commitlint.yml` | new inputs `enforce`, `ai-attribution`; pass `ref_name`; generate type-enum config from file | +| `install.sh` / `install.ps1` | `--ai-attribution`, `--enforce` flags; write starter `.commit-guard.json` when absent | +| `caller-template.yml` | comment pointing at `.commit-guard.json` | +| `test/*` | native hook: types/ban/ai policies incl. strip; ci: file precedence, ban, warn exit 0, branch skip | +| `README.md`, `CHANGELOG.md` | document schema, precedence, upgrade notes | + +The hook stays a single self-contained downloadable file, so the config +parser is inlined there and duplicated (compact) in the CI script rather than +shared via a lib — deployment simplicity beats DRY for shipped artifacts. +Each copy carries a sync note. + +## Error handling + +- Malformed JSON: jq parse failure → fail loudly with a clear message (never + silently skip enforcement). +- Unknown enum values (`ai-attribution: "nope"`): fail with the allowed set. +- Missing file: use env/defaults, no error. + +## Testing + +Extend the existing bash test harness (`test/test.sh`): each feature gets +accept + reject cases; strip mode verifies the message file was rewritten; +warn mode verifies exit 0 with failing content; precedence test verifies file +beats env. From d13b460eb683b4693ab44c9de4fe33d0d2c2b10b Mon Sep 17 00:00:00 2001 From: Cody Williamson Date: Sun, 19 Jul 2026 19:37:31 -0500 Subject: [PATCH 2/7] feat: add .commit-guard.json config with ai-attribution, ban patterns, custom types, warn mode, and branch filters --- .github/workflows/commitlint.yml | 59 ++++++- scripts/run-commitlint-ci.sh | 220 ++++++++++++++++++++++- scripts/validate-commit-message.sh | 222 +++++++++++++++++++++-- test/config-features.t.sh | 275 +++++++++++++++++++++++++++++ 4 files changed, 747 insertions(+), 29 deletions(-) create mode 100644 test/config-features.t.sh diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml index ecdf583..8ad7892 100644 --- a/.github/workflows/commitlint.yml +++ b/.github/workflows/commitlint.yml @@ -27,6 +27,14 @@ on: description: "Newline-delimited regex patterns for commit subjects to skip" type: string default: "" + enforce: + description: "Enforcement mode: block or warn" + type: string + default: "block" + ai-attribution: + description: "AI attribution policy: allow, warn, strip, or block (strip acts as block in CI)" + type: string + default: "allow" permissions: contents: read @@ -79,9 +87,27 @@ jobs: with: node-version: ${{ inputs.node-version }} - - name: Install commitlint + - name: Resolve config preset + id: preset env: CONFIG_PRESET: ${{ inputs.config }} + run: | + # .commit-guard.json wins over the workflow input when present + if [ -f .commit-guard.json ] && command -v jq >/dev/null 2>&1; then + if ! jq empty .commit-guard.json 2>/dev/null; then + echo "error: .commit-guard.json is not valid JSON." >&2 + exit 1 + fi + file_preset="$(jq -r '.config // empty' .commit-guard.json)" + if [ -n "$file_preset" ]; then + CONFIG_PRESET="$file_preset" + fi + fi + echo "preset=$CONFIG_PRESET" >> "$GITHUB_OUTPUT" + + - name: Install commitlint + env: + CONFIG_PRESET: ${{ steps.preset.outputs.preset }} run: | # install in isolated temp dir to avoid peer dep conflicts with repo mkdir -p /tmp/commitlint-bin @@ -97,15 +123,31 @@ jobs: - name: Create commitlint config env: - CONFIG_PRESET: ${{ inputs.config }} + CONFIG_PRESET: ${{ steps.preset.outputs.preset }} run: | # use repo config if it exists, otherwise create a temp one - if [ ! -f commitlint.config.js ] && [ ! -f commitlint.config.mjs ] && [ ! -f commitlint.config.cjs ] && [ ! -f .commitlintrc.yml ] && [ ! -f .commitlintrc.json ]; then - if [ "$CONFIG_PRESET" = "angular" ]; then - echo 'export default { extends: ["@commitlint/config-angular"] };' > commitlint.config.mjs - else - echo 'export default { extends: ["@commitlint/config-conventional"] };' > commitlint.config.mjs + if [ -f commitlint.config.js ] || [ -f commitlint.config.mjs ] || [ -f commitlint.config.cjs ] || [ -f .commitlintrc.yml ] || [ -f .commitlintrc.json ]; then + if [ -f .commit-guard.json ] && [ "$(jq -r '.types // [] | length' .commit-guard.json)" != "0" ]; then + echo "repo commitlint config found; ignoring types from .commit-guard.json" fi + exit 0 + fi + + if [ "$CONFIG_PRESET" = "angular" ]; then + extends_pkg="@commitlint/config-angular" + else + extends_pkg="@commitlint/config-conventional" + fi + + types_json="[]" + if [ -f .commit-guard.json ]; then + types_json="$(jq -c '.types // []' .commit-guard.json)" + fi + + if [ "$types_json" != "[]" ]; then + echo "export default { extends: [\"${extends_pkg}\"], rules: { \"type-enum\": [2, \"always\", ${types_json}] } };" > commitlint.config.mjs + else + echo "export default { extends: [\"${extends_pkg}\"] };" > commitlint.config.mjs fi - name: Determine commit range @@ -145,6 +187,9 @@ jobs: CG_IGNORE_BOT_COMMITS: ${{ inputs.ignore-bot-commits }} CG_IGNORE_MERGE_COMMITS: ${{ inputs.ignore-merge-commits }} CG_IGNORE_MESSAGE_PATTERNS: ${{ inputs.ignore-message-patterns }} + CG_ENFORCE: ${{ inputs.enforce }} + CG_AI_ATTRIBUTION: ${{ inputs.ai-attribution }} + CG_REF_NAME: ${{ github.ref_name }} CG_COMMITLINT_CMD: commitlint NODE_PATH: /tmp/commitlint-bin/node_modules run: .commit-guard/scripts/run-commitlint-ci.sh diff --git a/scripts/run-commitlint-ci.sh b/scripts/run-commitlint-ci.sh index 03b6423..0fb39b1 100755 --- a/scripts/run-commitlint-ci.sh +++ b/scripts/run-commitlint-ci.sh @@ -1,16 +1,99 @@ #!/usr/bin/env bash set -euo pipefail +# reads optional .commit-guard.json in the caller repo root; file values win +# over CG_* env fallbacks. config parsing mirrors validate-commit-message.sh — +# keep in sync. jq is preinstalled on github runners. + EVENT_NAME="${CG_EVENT_NAME:-}" PR_MODE="${CG_PR_MODE:-smart}" PR_TITLE="${CG_PR_TITLE:-}" RANGE_FROM="${CG_RANGE_FROM:-}" RANGE_TO="${CG_RANGE_TO:-}" +REF_NAME="${CG_REF_NAME:-}" IGNORE_BOT_COMMITS="${CG_IGNORE_BOT_COMMITS:-true}" IGNORE_MERGE_COMMITS="${CG_IGNORE_MERGE_COMMITS:-true}" IGNORE_MESSAGE_PATTERNS="${CG_IGNORE_MESSAGE_PATTERNS:-}" +ENFORCE="${CG_ENFORCE:-block}" +AI_ATTRIBUTION="${CG_AI_ATTRIBUTION:-allow}" +BAN_PATTERNS="" +BRANCHES="" COMMITLINT_CMD="${CG_COMMITLINT_CMD:-commitlint}" +CONFIG_FILE=".commit-guard.json" + +AI_ATTRIBUTION_PATTERNS=( + '^co-authored-by:.*(claude|copilot|chatgpt|openai|anthropic|gemini|cursor|devin|aider|codex|\[bot\])' + 'generated (with|by).*(claude|chatgpt|copilot|gemini|cursor|aider|codex)' + 'noreply@anthropic\.com' +) + +FAILURE_COUNT=0 + +load_config_file() { + if [[ ! -f "$CONFIG_FILE" ]]; then + return 0 + fi + + if ! command -v jq >/dev/null 2>&1; then + echo "warning: ${CONFIG_FILE} found but jq is unavailable, using workflow inputs only." >&2 + return 0 + fi + + if ! jq empty "$CONFIG_FILE" 2>/dev/null; then + echo "error: ${CONFIG_FILE} is not valid JSON." >&2 + exit 1 + fi + + file_get() { + local key="$1" + local fallback="$2" + local value + + value="$(jq -r --arg k "$key" 'if has($k) then .[$k] | tostring else "" end' "$CONFIG_FILE")" + printf '%s\n' "${value:-$fallback}" + } + + file_get_array() { + jq -r --arg k "$1" '.[$k] // [] | .[]' "$CONFIG_FILE" + } + + PR_MODE="$(file_get pr-mode "$PR_MODE")" + ENFORCE="$(file_get enforce "$ENFORCE")" + AI_ATTRIBUTION="$(file_get ai-attribution "$AI_ATTRIBUTION")" + IGNORE_BOT_COMMITS="$(file_get ignore-bot-commits "$IGNORE_BOT_COMMITS")" + IGNORE_MERGE_COMMITS="$(file_get ignore-merge-commits "$IGNORE_MERGE_COMMITS")" + + local file_ignore_patterns + file_ignore_patterns="$(file_get_array ignore-message-patterns)" + if [[ -n "$file_ignore_patterns" ]]; then + IGNORE_MESSAGE_PATTERNS="$file_ignore_patterns" + fi + + BAN_PATTERNS="$(file_get_array ban-patterns)" + BRANCHES="$(file_get_array branches)" + + echo "loaded ${CONFIG_FILE} (file values override workflow inputs)" +} + +validate_enums() { + case "$ENFORCE" in + block|warn) ;; + *) + echo "error: invalid enforce value '${ENFORCE}'. expected block or warn." >&2 + exit 1 + ;; + esac + + case "$AI_ATTRIBUTION" in + allow|warn|strip|block) ;; + *) + echo "error: invalid ai-attribution value '${AI_ATTRIBUTION}'. expected allow, warn, strip, or block." >&2 + exit 1 + ;; + esac +} + is_truthy() { case "${1,,}" in 1|true|yes|on) return 0 ;; @@ -56,14 +139,113 @@ matches_ignore_pattern() { return 1 } +branch_filter_skips_push() { + if is_pr_event || [[ -z "$BRANCHES" ]]; then + return 1 + fi + + local branch + while IFS= read -r branch; do + if [[ "$branch" == "$REF_NAME" ]]; then + return 1 + fi + done <<< "$BRANCHES" + + return 0 +} + +record_failure() { + FAILURE_COUNT=$((FAILURE_COUNT + 1)) + if [[ "$ENFORCE" == "warn" ]]; then + echo "::warning::${1}" + else + echo "::error::${1}" + fi +} + +find_ai_attribution_lines() { + local message="$1" + local line + local lowered + local pattern + + while IFS= read -r line; do + lowered="${line,,}" + for pattern in "${AI_ATTRIBUTION_PATTERNS[@]}"; do + if [[ "$lowered" =~ $pattern ]]; then + printf '%s\n' "$line" + break + fi + done + done <<< "$message" +} + +check_ai_attribution() { + local label="$1" + local message="$2" + local matches + + if [[ "$AI_ATTRIBUTION" == "allow" ]]; then + return 0 + fi + + matches="$(find_ai_attribution_lines "$message")" + if [[ -z "$matches" ]]; then + return 0 + fi + + if [[ "$AI_ATTRIBUTION" == "warn" ]]; then + echo "::warning::${label} contains AI attribution: ${matches}" + return 0 + fi + + # strip cannot rewrite pushed commits, so it acts as block in ci + record_failure "${label} contains AI attribution: ${matches}" + return 1 +} + +check_ban_patterns() { + local label="$1" + local message="$2" + local pattern + local ok=0 + + while IFS= read -r pattern; do + if [[ -z "$pattern" ]]; then + continue + fi + if grep -Eiq -- "$pattern" <<< "$message"; then + record_failure "${label} matches banned pattern: ${pattern}" + ok=1 + fi + done <<< "$BAN_PATTERNS" + + return "$ok" +} + lint_message() { local label="$1" local message="$2" local subject + local ok=0 subject="$(printf '%s\n' "$message" | head -n 1)" echo "linting ${label}: ${subject}" - printf '%s\n' "$message" | "$COMMITLINT_CMD" --verbose + + if ! printf '%s\n' "$message" | "$COMMITLINT_CMD" --verbose; then + record_failure "${label} failed commitlint: ${subject}" + ok=1 + fi + + if ! check_ai_attribution "$label" "$message"; then + ok=1 + fi + + if ! check_ban_patterns "$label" "$message"; then + ok=1 + fi + + return "$ok" } collect_commit_shas() { @@ -82,19 +264,42 @@ collect_commit_shas() { lint_pr_title_if_present() { if [[ -z "$PR_TITLE" ]]; then echo "error: pr-mode requires a pull request title but none was provided." >&2 - return 1 + exit 1 + fi + + lint_message "PR title" "$PR_TITLE" || true +} + +finish() { + if [[ "$FAILURE_COUNT" -eq 0 ]]; then + return 0 fi - lint_message "PR title" "$PR_TITLE" + if [[ "$ENFORCE" == "warn" ]]; then + echo "::warning::commit-guard found ${FAILURE_COUNT} issue(s); enforce is 'warn', passing anyway." + return 0 + fi + + echo "commit-guard found ${FAILURE_COUNT} issue(s)." >&2 + return 1 } main() { local linted_count=0 local sha + load_config_file + validate_enums + + if branch_filter_skips_push; then + echo "branch '${REF_NAME}' is not in the configured branches list, skipping lint." + return 0 + fi + if is_pr_event && [[ "$PR_MODE" == "title" ]]; then lint_pr_title_if_present - return 0 + finish + return fi while IFS= read -r sha; do @@ -137,19 +342,22 @@ main() { continue fi - lint_message "commit ${sha}" "$message" + lint_message "commit ${sha}" "$message" || true linted_count=$((linted_count + 1)) done < <(collect_commit_shas) if is_pr_event && [[ "$PR_MODE" == "smart" ]] && [[ "$linted_count" -eq 0 ]]; then echo "no lintable commits left after filters, falling back to PR title" lint_pr_title_if_present - return 0 + finish + return fi if [[ "$linted_count" -eq 0 ]]; then echo "no commit messages to lint after filters" fi + + finish } main "$@" diff --git a/scripts/validate-commit-message.sh b/scripts/validate-commit-message.sh index 9cb4869..35b6edf 100755 --- a/scripts/validate-commit-message.sh +++ b/scripts/validate-commit-message.sh @@ -1,6 +1,10 @@ #!/usr/bin/env bash set -euo pipefail +# commit-guard native commit-msg hook +# reads optional .commit-guard.json at the repo root +# config parsing is duplicated in run-commitlint-ci.sh — keep in sync + MESSAGE_FILE="${1:-}" if [[ -z "$MESSAGE_FILE" || ! -f "$MESSAGE_FILE" ]]; then @@ -8,31 +12,217 @@ if [[ -z "$MESSAGE_FILE" || ! -f "$MESSAGE_FILE" ]]; then exit 1 fi -SUBJECT="$(head -n 1 "$MESSAGE_FILE")" -CONVENTIONAL_REGEX='^(build|chore|ci|docs|feat|fix|perf|refactor|style|test)(\([[:alnum:]./_-]+\))?(!)?: .+' +REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" +CONFIG_FILE="${REPO_ROOT}/.commit-guard.json" -if [[ "$SUBJECT" =~ ^Merge[[:space:]] ]] || \ - [[ "$SUBJECT" =~ ^Revert[[:space:]] ]] || \ - [[ "$SUBJECT" =~ ^fixup!\ ]] || \ - [[ "$SUBJECT" =~ ^squash!\ ]]; then - exit 0 -fi +DEFAULT_TYPES='build|chore|ci|docs|feat|fix|perf|refactor|style|test' +AI_ATTRIBUTION_PATTERNS=( + '^co-authored-by:.*(claude|copilot|chatgpt|openai|anthropic|gemini|cursor|devin|aider|codex|\[bot\])' + 'generated (with|by).*(claude|chatgpt|copilot|gemini|cursor|aider|codex)' + 'noreply@anthropic\.com' +) -if [[ "$SUBJECT" =~ $CONVENTIONAL_REGEX ]]; then - exit 0 +have_jq() { + [[ -z "${CG_FORCE_FALLBACK_PARSER:-}" ]] && command -v jq >/dev/null 2>&1 +} + +if [[ -f "$CONFIG_FILE" ]] && have_jq && ! jq empty "$CONFIG_FILE" 2>/dev/null; then + echo "error: ${CONFIG_FILE} is not valid JSON." >&2 + exit 1 fi -cat >&2 <&2 + exit 1 + ;; +esac + +case "$AI_ATTRIBUTION" in + allow|warn|strip|block) ;; + *) + echo "error: invalid ai-attribution value '${AI_ATTRIBUTION}'. expected allow, warn, strip, or block." >&2 + exit 1 + ;; +esac + +TYPES="$(config_get_array types | paste -sd '|' -)" +TYPES="${TYPES:-$DEFAULT_TYPES}" +CONVENTIONAL_REGEX="^(${TYPES})(\([[:alnum:]./_-]+\))?(!)?: .+" + +VIOLATIONS="" + +add_violation() { + VIOLATIONS="${VIOLATIONS}${1}"$'\n' +} + +find_ai_attribution_lines() { + local line + local lowered + local pattern + + while IFS= read -r line; do + lowered="${line,,}" + for pattern in "${AI_ATTRIBUTION_PATTERNS[@]}"; do + if [[ "$lowered" =~ $pattern ]]; then + printf '%s\n' "$line" + break + fi + done + done < "$MESSAGE_FILE" +} + +strip_ai_attribution_lines() { + local temp_file + local line + local lowered + local pattern + local keep + + temp_file="$(mktemp)" + while IFS= read -r line; do + keep=true + lowered="${line,,}" + for pattern in "${AI_ATTRIBUTION_PATTERNS[@]}"; do + if [[ "$lowered" =~ $pattern ]]; then + keep=false + break + fi + done + if [[ "$keep" == true ]]; then + printf '%s\n' "$line" >> "$temp_file" + fi + done < "$MESSAGE_FILE" + mv "$temp_file" "$MESSAGE_FILE" +} + +check_ai_attribution() { + local matches + + if [[ "$AI_ATTRIBUTION" == "allow" ]]; then + return 0 + fi + + matches="$(find_ai_attribution_lines)" + if [[ -z "$matches" ]]; then + return 0 + fi + + case "$AI_ATTRIBUTION" in + warn) + echo "warning: commit message contains AI attribution:" >&2 + printf '%s\n' "$matches" >&2 + ;; + strip) + strip_ai_attribution_lines + echo "commit-guard: stripped AI attribution from commit message:" >&2 + printf '%s\n' "$matches" >&2 + ;; + block) + add_violation "commit message contains AI attribution:"$'\n'"$matches" + ;; + esac +} + +check_ban_patterns() { + local pattern + local message + + message="$(cat "$MESSAGE_FILE")" + while IFS= read -r pattern; do + if [[ -z "$pattern" ]]; then + continue + fi + if grep -Eiq -- "$pattern" <<< "$message"; then + add_violation "commit message matches banned pattern: ${pattern}" + fi + done < <(config_get_array ban-patterns) +} + +check_conventional_subject() { + local subject + + subject="$(head -n 1 "$MESSAGE_FILE")" + + if [[ "$subject" =~ ^Merge[[:space:]] ]] || \ + [[ "$subject" =~ ^Revert[[:space:]] ]] || \ + [[ "$subject" =~ ^fixup!\ ]] || \ + [[ "$subject" =~ ^squash!\ ]]; then + return 0 + fi + + if [[ "$subject" =~ $CONVENTIONAL_REGEX ]]; then + return 0 + fi + + add_violation "commit message must follow Conventional Commits. Expected: type(scope): description -Example: - feat(ci): add smart PR title fallback +Allowed types: + $(printf '%s' "$TYPES" | tr '|' ' ') Received: - ${SUBJECT} -EOF + ${subject}" +} + +check_ai_attribution +check_ban_patterns +check_conventional_subject + +if [[ -z "$VIOLATIONS" ]]; then + exit 0 +fi + +printf 'error: %s\n' "$VIOLATIONS" >&2 + +if [[ "$ENFORCE" == "warn" ]]; then + echo "commit-guard: enforce is 'warn', allowing commit anyway." >&2 + exit 0 +fi exit 1 diff --git a/test/config-features.t.sh b/test/config-features.t.sh new file mode 100644 index 0000000..6e26c71 --- /dev/null +++ b/test/config-features.t.sh @@ -0,0 +1,275 @@ +#!/usr/bin/env bash +set -euo pipefail + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" + +VALIDATOR="${ROOT_DIR}/scripts/validate-commit-message.sh" +CI_SCRIPT="${ROOT_DIR}/scripts/run-commitlint-ci.sh" + +make_config_repo() { + local repo_dir="$1" + local config_json="$2" + + make_git_repo "$repo_dir" + printf '%s\n' "$config_json" > "${repo_dir}/.commit-guard.json" +} + +run_validator() { + local repo_dir="$1" + local message="$2" + local message_file="${repo_dir}/message.txt" + + printf '%s\n' "$message" > "$message_file" + (cd "$repo_dir" && "$VALIDATOR" message.txt) +} + +test_custom_types_accept_and_reject() { + local repo_dir + repo_dir="$(make_temp_dir)/repo" + make_config_repo "$repo_dir" '{ + "types": ["feat", "wip"] +}' + + run_validator "$repo_dir" "wip: half-done thing" + + if run_validator "$repo_dir" "chore: not in the custom list" 2>/dev/null; then + fail "expected type outside custom list to be rejected" + fi +} + +test_custom_types_with_fallback_parser() { + local repo_dir + repo_dir="$(make_temp_dir)/repo" + make_config_repo "$repo_dir" '{ + "types": [ + "feat", + "wip" + ] +}' + + ( + export CG_FORCE_FALLBACK_PARSER=1 + run_validator "$repo_dir" "wip: fallback parser works" + if run_validator "$repo_dir" "chore: rejected via fallback" 2>/dev/null; then + fail "expected fallback parser to enforce custom types" + fi + ) +} + +test_ban_patterns_reject_matching_message() { + local repo_dir + repo_dir="$(make_temp_dir)/repo" + make_config_repo "$repo_dir" '{ + "ban-patterns": ["password", "^temp"] +}' + + if run_validator "$repo_dir" "feat: add Password rotation" 2>/dev/null; then + fail "expected banned pattern to reject message" + fi + + run_validator "$repo_dir" "feat: add pin rotation" +} + +test_ai_attribution_block() { + local repo_dir + repo_dir="$(make_temp_dir)/repo" + make_config_repo "$repo_dir" '{ + "ai-attribution": "block" +}' + + if run_validator "$repo_dir" "feat: add thing + +Co-Authored-By: Claude " 2>/dev/null; then + fail "expected AI co-author trailer to be blocked" + fi + + if run_validator "$repo_dir" "feat: add thing + +🤖 Generated with Claude Code" 2>/dev/null; then + fail "expected AI byline to be blocked" + fi + + run_validator "$repo_dir" "feat: add thing + +Co-Authored-By: Human Person " +} + +test_ai_attribution_warn_allows() { + local repo_dir + repo_dir="$(make_temp_dir)/repo" + make_config_repo "$repo_dir" '{ + "ai-attribution": "warn" +}' + + run_validator "$repo_dir" "feat: add thing + +Co-Authored-By: Claude " +} + +test_ai_attribution_strip_rewrites_message() { + local repo_dir + local result + repo_dir="$(make_temp_dir)/repo" + make_config_repo "$repo_dir" '{ + "ai-attribution": "strip" +}' + + run_validator "$repo_dir" "feat: add thing + +Co-Authored-By: Claude " + + result="$(cat "${repo_dir}/message.txt")" + if [[ "$result" == *"Claude"* ]]; then + fail "expected AI trailer to be stripped from message file" + fi + assert_contains "feat: add thing" "$result" "expected real content to survive strip" +} + +test_enforce_warn_allows_bad_commit_locally() { + local repo_dir + repo_dir="$(make_temp_dir)/repo" + make_config_repo "$repo_dir" '{ + "enforce": "warn" +}' + + run_validator "$repo_dir" "totally not conventional" +} + +test_invalid_enum_fails_loudly() { + local repo_dir + repo_dir="$(make_temp_dir)/repo" + make_config_repo "$repo_dir" '{ + "ai-attribution": "nope" +}' + + if run_validator "$repo_dir" "feat: fine subject" 2>/dev/null; then + fail "expected invalid ai-attribution value to fail" + fi +} + +test_ci_file_overrides_env_enforce() { + local temp_dir repo_dir base_sha head_sha + temp_dir="$(make_temp_dir)" + repo_dir="${temp_dir}/repo" + make_config_repo "$repo_dir" '{ + "enforce": "warn" +}' + + commit_file "$repo_dir" "README.md" "base" "feat: seed repo" + base_sha="$(git -C "$repo_dir" rev-parse HEAD)" + commit_file "$repo_dir" "notes.txt" "bad" "Initial plan" + head_sha="$(git -C "$repo_dir" rev-parse HEAD)" + + make_commitlint_stub "${temp_dir}/bin" "${temp_dir}/commitlint.log" + + ( + cd "$repo_dir" + PATH="${temp_dir}/bin:${PATH}" \ + COMMITLINT_LOG="${temp_dir}/commitlint.log" \ + CG_EVENT_NAME="push" \ + CG_RANGE_FROM="$base_sha" \ + CG_RANGE_TO="$head_sha" \ + CG_ENFORCE="block" \ + "$CI_SCRIPT" + ) || fail "expected enforce=warn from config file to pass despite bad commit" +} + +test_ci_ban_pattern_fails_commit() { + local temp_dir repo_dir base_sha head_sha + temp_dir="$(make_temp_dir)" + repo_dir="${temp_dir}/repo" + make_config_repo "$repo_dir" '{ + "ban-patterns": ["hunter2"] +}' + + commit_file "$repo_dir" "README.md" "base" "feat: seed repo" + base_sha="$(git -C "$repo_dir" rev-parse HEAD)" + commit_file "$repo_dir" "notes.txt" "x" "feat: set password to hunter2" + head_sha="$(git -C "$repo_dir" rev-parse HEAD)" + + make_commitlint_stub "${temp_dir}/bin" "${temp_dir}/commitlint.log" + + if ( + cd "$repo_dir" + PATH="${temp_dir}/bin:${PATH}" \ + COMMITLINT_LOG="${temp_dir}/commitlint.log" \ + CG_EVENT_NAME="push" \ + CG_RANGE_FROM="$base_sha" \ + CG_RANGE_TO="$head_sha" \ + "$CI_SCRIPT" + ); then + fail "expected banned pattern to fail CI lint" + fi +} + +test_ci_ai_attribution_strip_acts_as_block() { + local temp_dir repo_dir base_sha head_sha + temp_dir="$(make_temp_dir)" + repo_dir="${temp_dir}/repo" + make_config_repo "$repo_dir" '{ + "ai-attribution": "strip" +}' + + commit_file "$repo_dir" "README.md" "base" "feat: seed repo" + base_sha="$(git -C "$repo_dir" rev-parse HEAD)" + commit_file "$repo_dir" "notes.txt" "x" "feat: add thing + +Co-Authored-By: Claude " + head_sha="$(git -C "$repo_dir" rev-parse HEAD)" + + make_commitlint_stub "${temp_dir}/bin" "${temp_dir}/commitlint.log" + + if ( + cd "$repo_dir" + PATH="${temp_dir}/bin:${PATH}" \ + COMMITLINT_LOG="${temp_dir}/commitlint.log" \ + CG_EVENT_NAME="push" \ + CG_RANGE_FROM="$base_sha" \ + CG_RANGE_TO="$head_sha" \ + "$CI_SCRIPT" + ); then + fail "expected strip policy to act as block in CI" + fi +} + +test_ci_branch_filter_skips_other_branches() { + local temp_dir repo_dir base_sha head_sha output + temp_dir="$(make_temp_dir)" + repo_dir="${temp_dir}/repo" + make_config_repo "$repo_dir" '{ + "branches": ["main", "master"] +}' + + commit_file "$repo_dir" "README.md" "base" "feat: seed repo" + base_sha="$(git -C "$repo_dir" rev-parse HEAD)" + commit_file "$repo_dir" "notes.txt" "bad" "Initial plan" + head_sha="$(git -C "$repo_dir" rev-parse HEAD)" + + make_commitlint_stub "${temp_dir}/bin" "${temp_dir}/commitlint.log" + + output="$( + cd "$repo_dir" + PATH="${temp_dir}/bin:${PATH}" \ + COMMITLINT_LOG="${temp_dir}/commitlint.log" \ + CG_EVENT_NAME="push" \ + CG_REF_NAME="feature/foo" \ + CG_RANGE_FROM="$base_sha" \ + CG_RANGE_TO="$head_sha" \ + "$CI_SCRIPT" + )" || fail "expected branch filter to skip lint and pass" + + assert_contains "skipping lint" "$output" "expected skip notice for filtered branch" +} + +test_custom_types_accept_and_reject +test_custom_types_with_fallback_parser +test_ban_patterns_reject_matching_message +test_ai_attribution_block +test_ai_attribution_warn_allows +test_ai_attribution_strip_rewrites_message +test_enforce_warn_allows_bad_commit_locally +test_invalid_enum_fails_loudly +test_ci_file_overrides_env_enforce +test_ci_ban_pattern_fails_commit +test_ci_ai_attribution_strip_acts_as_block +test_ci_branch_filter_skips_other_branches From 1567b2e2eb049b62e23d3f79e81f247209ebfc40 Mon Sep 17 00:00:00 2001 From: Cody Williamson Date: Sun, 19 Jul 2026 19:37:31 -0500 Subject: [PATCH 3/7] feat: add --ai-attribution and --enforce installer flags and starter config file --- caller-template.yml | 5 +++++ install.ps1 | 33 ++++++++++++++++++++++++++++++++- install.sh | 44 ++++++++++++++++++++++++++++++++++++++++++++ test/install.t.sh | 3 +++ 4 files changed, 84 insertions(+), 1 deletion(-) diff --git a/caller-template.yml b/caller-template.yml index ab36abe..f719df3 100644 --- a/caller-template.yml +++ b/caller-template.yml @@ -18,10 +18,15 @@ jobs: commitlint: uses: codywilliamson/commit-guard/.github/workflows/commitlint.yml@v0.2.2 with: + ## values in .commit-guard.json at the repo root override these inputs ## config preset: conventional, angular config: "conventional" ## PR lint strategy: smart, commits, title pr-mode: "smart" + ## enforcement: block (default), warn + # enforce: "block" + ## AI attribution policy: allow (default), warn, strip, block + # ai-attribution: "block" ## optional: skip known noisy subjects # ignore-message-patterns: | # ^Initial plan$ diff --git a/install.ps1 b/install.ps1 index 8f26aa1..2e01ddf 100644 --- a/install.ps1 +++ b/install.ps1 @@ -18,7 +18,9 @@ param( [switch]$CIOnly, [string]$HookMode = "auto", [string]$PackageManager = "", - [string]$PRMode = "smart" + [string]$PRMode = "smart", + [string]$AIAttribution = "block", + [string]$Enforce = "block" ) $ErrorActionPreference = "Stop" @@ -36,6 +38,32 @@ function Ensure-ValidPRMode { } } +function Ensure-ValidPolicies { + if ($AIAttribution -notin @("allow", "warn", "strip", "block")) { + throw "Invalid ai-attribution '$AIAttribution'. Expected allow, warn, strip, or block." + } + if ($Enforce -notin @("block", "warn")) { + throw "Invalid enforce '$Enforce'. Expected block or warn." + } +} + +function Write-ConfigFile { + if (Test-Path ".commit-guard.json") { + Write-Host " .commit-guard.json already exists, skipping" + return + } + + @" +{ + "config": "$Config", + "pr-mode": "$PRMode", + "enforce": "$Enforce", + "ai-attribution": "$AIAttribution" +} +"@ | Set-Content -Path ".commit-guard.json" + Write-Host " Created: .commit-guard.json" -ForegroundColor Green +} + function Resolve-HookMode { switch ($HookMode) { "auto" { @@ -139,11 +167,13 @@ if (-not $PackageManager) { } Ensure-ValidPRMode +Ensure-ValidPolicies $ResolvedHookMode = Resolve-HookMode Write-Host "Installing CI workflow..." New-Item -ItemType Directory -Path $WorkflowDir -Force | Out-Null Invoke-WebRequest -Uri $TemplateUrl -OutFile $WorkflowFile -UseBasicParsing +Write-ConfigFile $content = Get-Content $WorkflowFile -Raw if ($Config -ne "conventional") { @@ -174,6 +204,7 @@ switch ($ResolvedHookMode) { Write-Host "" Write-Host "Done! Installed:" -ForegroundColor Green Write-Host " - CI workflow: $WorkflowFile" +Write-Host " - Config: .commit-guard.json" if ($ResolvedHookMode -eq "native") { $hookPath = git config --get core.hooksPath Write-Host " - Local hook: $hookPath/commit-msg" diff --git a/install.sh b/install.sh index 9b4ced4..bee0402 100755 --- a/install.sh +++ b/install.sh @@ -17,6 +17,8 @@ CONFIG="conventional" HOOK_MODE="auto" PM="" PR_MODE="smart" +AI_ATTRIBUTION="block" +ENFORCE="block" replace_line() { local search="$1" @@ -70,6 +72,41 @@ ensure_valid_pr_mode() { esac } +ensure_valid_policies() { + case "$AI_ATTRIBUTION" in + allow|warn|strip|block) ;; + *) + echo "error: invalid ai-attribution '${AI_ATTRIBUTION}'. expected allow, warn, strip, or block." + exit 1 + ;; + esac + + case "$ENFORCE" in + block|warn) ;; + *) + echo "error: invalid enforce '${ENFORCE}'. expected block or warn." + exit 1 + ;; + esac +} + +write_config_file() { + if [[ -f ".commit-guard.json" ]]; then + echo " .commit-guard.json already exists, skipping" + return + fi + + cat > .commit-guard.json < Hook mode: auto (default), husky, native, none" echo " --pm Package manager: pnpm, npm, yarn (auto-detected if omitted)" echo " --pr-mode PR lint mode: smart (default), commits, title" + echo " --ai-attribution

AI attribution policy: allow, warn, strip, block (default)" + echo " --enforce Enforcement: block (default), warn" echo " --help Show this help" exit 0 ;; @@ -194,12 +235,14 @@ if ! git rev-parse --is-inside-work-tree &>/dev/null; then fi ensure_valid_pr_mode +ensure_valid_policies detect_pm resolve_hook_mode echo "installing CI workflow..." mkdir -p "$WORKFLOW_DIR" curl -sL "$TEMPLATE_URL" -o "$WORKFLOW_FILE" +write_config_file if [[ "$CONFIG" != "conventional" ]]; then replace_line 'config: "conventional"' "config: \"${CONFIG}\"" "$WORKFLOW_FILE" @@ -231,6 +274,7 @@ esac echo "" echo "done! installed:" echo " - CI workflow: ${WORKFLOW_FILE}" +echo " - Config: .commit-guard.json" if [[ "$HOOK_MODE" == "native" ]]; then echo " - Local hook: $(git config --get core.hooksPath)/commit-msg" diff --git a/test/install.t.sh b/test/install.t.sh index 369f67d..b3399fe 100755 --- a/test/install.t.sh +++ b/test/install.t.sh @@ -24,6 +24,9 @@ test_installer_supports_native_hooks_and_pr_title_mode_without_node_repo() { assert_contains "created: .githooks/commit-msg" "$output" "expected native hook install output" assert_contains 'pr-mode: "title"' "$(cat "${repo_dir}/.github/workflows/commitlint.yml")" "expected PR mode to be rendered" assert_contains ".githooks" "$(git -C "$repo_dir" config --get core.hooksPath)" "expected git hooks path to point at tracked hooks" + assert_contains "created: .commit-guard.json" "$output" "expected starter config output" + assert_contains '"pr-mode": "title"' "$(cat "${repo_dir}/.commit-guard.json")" "expected pr mode in starter config" + assert_contains '"ai-attribution": "block"' "$(cat "${repo_dir}/.commit-guard.json")" "expected ai-attribution default in starter config" } test_installer_supports_native_hooks_and_pr_title_mode_without_node_repo From 10eda75a808d657a8a368228557eb4a5c43c0c98 Mon Sep 17 00:00:00 2001 From: Cody Williamson Date: Sun, 19 Jul 2026 19:37:31 -0500 Subject: [PATCH 4/7] docs: document .commit-guard.json config file and v0.3.0 changes --- CHANGELOG.md | 10 ++++++++++ README.md | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cb4e4a..2af2730 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [0.3.0] + +- Add `.commit-guard.json` per-repo config file, read by both CI and local hooks. File values override workflow inputs. +- Add `ai-attribution` policy (`allow`, `warn`, `strip`, `block`) to catch AI co-author trailers and "generated with" bylines. `strip` rewrites the message locally and acts as `block` in CI. +- Add custom `types` list, enforced by the native hook and via a generated commitlint `type-enum` rule in CI. +- Add `ban-patterns` — case-insensitive regexes that fail the lint when matched anywhere in a commit message. +- Add `enforce: warn` mode — CI annotates failures but passes; the local hook prints the error and allows the commit. +- Add `branches` filter — push events on unlisted branches skip linting. +- Installers gain `--ai-attribution` and `--enforce` flags and write a starter `.commit-guard.json`. + ## [0.2.2] - Fix reusable-workflow self-checkout to use `github.job_workflow_sha`. The previous `github.workflow_sha` resolves to the caller's commit in a reusable-workflow context, which caused every run to fail with `remote error: upload-pack: not our ref` when trying to fetch the caller's commit from commit-guard's repo. diff --git a/README.md b/README.md index a18db8a..789565a 100644 --- a/README.md +++ b/README.md @@ -24,9 +24,57 @@ Common options: ./install.sh --pr-mode title ./install.sh --hook-mode native ./install.sh --hook-mode husky --pm pnpm +./install.sh --ai-attribution strip +./install.sh --enforce warn ./install.sh --ci-only ``` +## Configuration file + +Both CI and local hooks read an optional `.commit-guard.json` at the repo root. +File values override workflow inputs; workflow inputs apply when the file or +key is absent. All keys are optional. + +```json +{ + "config": "conventional", + "pr-mode": "smart", + "enforce": "block", + "ai-attribution": "block", + "types": ["feat", "fix", "chore", "ci", "docs", "test", "refactor", "perf", "build", "style"], + "ban-patterns": ["password"], + "branches": ["main", "master"], + "ignore-bot-commits": true, + "ignore-merge-commits": true, + "ignore-message-patterns": ["^Initial plan$"] +} +``` + +- `enforce`: `block` (default) fails on violations; `warn` annotates and passes. + Useful when adopting commit-guard on an existing repo. +- `ai-attribution`: policy for AI co-author trailers and "generated with" + bylines (`Co-Authored-By: Claude ...`, `🤖 Generated with Claude Code`). + - `allow` (default): no check + - `warn`: report but pass + - `strip`: the local hook removes matching lines before the commit lands; in + CI this acts as `block` since pushed commits can't be rewritten + - `block`: fail the lint +- `types`: custom allowed commit types. In CI this generates a commitlint + `type-enum` rule; if the repo has its own commitlint config, that config wins + and `types` is ignored. +- `ban-patterns`: case-insensitive regexes that fail the lint when matched + anywhere in the commit message (or PR title). +- `branches`: on push events, only these branches are linted. Empty or absent + means all branches. + +The native hook parses the file with `jq` when available. Without `jq`, a +built-in fallback parser is used — keep the file flat and pretty-printed with +one array element per line, and avoid `"` inside values. + +The installer writes a starter `.commit-guard.json` with +`"ai-attribution": "block"` for new installs; existing configs are never +overwritten. + ## What changed in v0.2.0 - PR linting is now configurable with `smart`, `commits`, and `title` modes. @@ -116,6 +164,8 @@ jobs: with: config: "conventional" pr-mode: "smart" + # enforce: "warn" + # ai-attribution: "block" # ignore-message-patterns: | # ^Initial plan$ ``` From fd87537cfb5246699d13567d3cc33a47b546b8c3 Mon Sep 17 00:00:00 2001 From: Cody Williamson Date: Sun, 19 Jul 2026 22:22:16 -0500 Subject: [PATCH 5/7] refactor: switch config file from json to flat yaml drops the jq dependency and fallback-parser formatting constraints; pure bash parsing locally, yq validation in ci, comments in the starter config --- .github/workflows/commitlint.yml | 27 +++-- caller-template.yml | 2 +- .../2026-07-19-config-features-design.md | 11 ++ install.ps1 | 41 +++++-- install.sh | 41 +++++-- scripts/run-commitlint-ci.sh | 112 ++++++++++++------ scripts/validate-commit-message.sh | 94 +++++++++------ test/config-features.t.sh | 88 ++++++-------- test/install.t.sh | 6 +- 9 files changed, 268 insertions(+), 154 deletions(-) diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml index 8ad7892..cb7c2f5 100644 --- a/.github/workflows/commitlint.yml +++ b/.github/workflows/commitlint.yml @@ -92,13 +92,17 @@ jobs: env: CONFIG_PRESET: ${{ inputs.config }} run: | - # .commit-guard.json wins over the workflow input when present - if [ -f .commit-guard.json ] && command -v jq >/dev/null 2>&1; then - if ! jq empty .commit-guard.json 2>/dev/null; then - echo "error: .commit-guard.json is not valid JSON." >&2 + # .commit-guard.yml wins over the workflow input when present + cfg="" + for f in .commit-guard.yml .commit-guard.yaml; do + if [ -f "$f" ]; then cfg="$f"; break; fi + done + if [ -n "$cfg" ]; then + if ! yq e '.' "$cfg" >/dev/null; then + echo "error: ${cfg} is not valid YAML." >&2 exit 1 fi - file_preset="$(jq -r '.config // empty' .commit-guard.json)" + file_preset="$(yq e '.config // ""' "$cfg")" if [ -n "$file_preset" ]; then CONFIG_PRESET="$file_preset" fi @@ -125,10 +129,15 @@ jobs: env: CONFIG_PRESET: ${{ steps.preset.outputs.preset }} run: | + cfg="" + for f in .commit-guard.yml .commit-guard.yaml; do + if [ -f "$f" ]; then cfg="$f"; break; fi + done + # use repo config if it exists, otherwise create a temp one if [ -f commitlint.config.js ] || [ -f commitlint.config.mjs ] || [ -f commitlint.config.cjs ] || [ -f .commitlintrc.yml ] || [ -f .commitlintrc.json ]; then - if [ -f .commit-guard.json ] && [ "$(jq -r '.types // [] | length' .commit-guard.json)" != "0" ]; then - echo "repo commitlint config found; ignoring types from .commit-guard.json" + if [ -n "$cfg" ] && [ "$(yq e '.types // [] | length' "$cfg")" != "0" ]; then + echo "repo commitlint config found; ignoring types from ${cfg}" fi exit 0 fi @@ -140,8 +149,8 @@ jobs: fi types_json="[]" - if [ -f .commit-guard.json ]; then - types_json="$(jq -c '.types // []' .commit-guard.json)" + if [ -n "$cfg" ]; then + types_json="$(yq e -o=json -I=0 '.types // []' "$cfg")" fi if [ "$types_json" != "[]" ]; then diff --git a/caller-template.yml b/caller-template.yml index f719df3..d717a19 100644 --- a/caller-template.yml +++ b/caller-template.yml @@ -18,7 +18,7 @@ jobs: commitlint: uses: codywilliamson/commit-guard/.github/workflows/commitlint.yml@v0.2.2 with: - ## values in .commit-guard.json at the repo root override these inputs + ## values in .commit-guard.yml at the repo root override these inputs ## config preset: conventional, angular config: "conventional" ## PR lint strategy: smart, commits, title diff --git a/docs/superpowers/specs/2026-07-19-config-features-design.md b/docs/superpowers/specs/2026-07-19-config-features-design.md index 320c99d..def9741 100644 --- a/docs/superpowers/specs/2026-07-19-config-features-design.md +++ b/docs/superpowers/specs/2026-07-19-config-features-design.md @@ -3,6 +3,17 @@ Date: 2026-07-19 Status: approved (brainstormed interactively, implementation authorized autonomously) +> **Amendment (same day):** the config format was changed from JSON to flat +> YAML (`.commit-guard.yml`) after review. Rationale: flat YAML is trivially +> and robustly parseable in pure bash (no jq dependency, no formatting +> constraints on the fallback parser), and supports comments in the starter +> config. CI validates syntax with `yq` (preinstalled on runners) and uses it +> to inject `types` into the generated commitlint config; the lint scripts use +> the same bash parser as the hook. Malformed YAML is caught loudly in CI; +> locally the parser is lenient (mis-indented keys fall back to defaults) but +> enum validation still rejects bad values. JSON references below are +> historical. + ## Goal Add a per-repo config file that both CI and local hooks read, plus four new diff --git a/install.ps1 b/install.ps1 index 2e01ddf..adbfa2d 100644 --- a/install.ps1 +++ b/install.ps1 @@ -48,20 +48,39 @@ function Ensure-ValidPolicies { } function Write-ConfigFile { - if (Test-Path ".commit-guard.json") { - Write-Host " .commit-guard.json already exists, skipping" + if ((Test-Path ".commit-guard.yml") -or (Test-Path ".commit-guard.yaml")) { + Write-Host " .commit-guard.yml already exists, skipping" return } @" -{ - "config": "$Config", - "pr-mode": "$PRMode", - "enforce": "$Enforce", - "ai-attribution": "$AIAttribution" -} -"@ | Set-Content -Path ".commit-guard.json" - Write-Host " Created: .commit-guard.json" -ForegroundColor Green +# commit-guard config — values here override the workflow inputs +# docs: https://github.com/codywilliamson/commit-guard + +# commitlint preset: conventional, angular +config: $Config +# PR lint strategy: smart, commits, title +pr-mode: $PRMode +# block fails on violations, warn only reports them +enforce: $Enforce +# AI co-author trailers and bylines: allow, warn, strip, block +ai-attribution: $AIAttribution + +# custom allowed commit types (defaults to the conventional set) +# types: +# - feat +# - fix +# - chore + +# case-insensitive regexes that fail the lint anywhere in the message +# ban-patterns: +# - password + +# only lint pushes to these branches (default: all) +# branches: +# - main +"@ | Set-Content -Path ".commit-guard.yml" + Write-Host " Created: .commit-guard.yml" -ForegroundColor Green } function Resolve-HookMode { @@ -204,7 +223,7 @@ switch ($ResolvedHookMode) { Write-Host "" Write-Host "Done! Installed:" -ForegroundColor Green Write-Host " - CI workflow: $WorkflowFile" -Write-Host " - Config: .commit-guard.json" +Write-Host " - Config: .commit-guard.yml" if ($ResolvedHookMode -eq "native") { $hookPath = git config --get core.hooksPath Write-Host " - Local hook: $hookPath/commit-msg" diff --git a/install.sh b/install.sh index bee0402..39f8229 100755 --- a/install.sh +++ b/install.sh @@ -91,20 +91,39 @@ ensure_valid_policies() { } write_config_file() { - if [[ -f ".commit-guard.json" ]]; then - echo " .commit-guard.json already exists, skipping" + if [[ -f ".commit-guard.yml" ]] || [[ -f ".commit-guard.yaml" ]]; then + echo " .commit-guard.yml already exists, skipping" return fi - cat > .commit-guard.json < .commit-guard.yml </dev/null 2>&1; then - echo "warning: ${CONFIG_FILE} found but jq is unavailable, using workflow inputs only." >&2 - return 0 - fi + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" - if ! jq empty "$CONFIG_FILE" 2>/dev/null; then - echo "error: ${CONFIG_FILE} is not valid JSON." >&2 - exit 1 + case "$value" in + \"*\") + value="${value#\"}" + value="${value%\"}" + ;; + \'*\') + value="${value#\'}" + value="${value%\'}" + ;; + *) + value="${value%%" #"*}" + value="${value%"${value##*[![:space:]]}"}" + ;; + esac + + printf '%s\n' "$value" +} + +config_get() { + local key="$1" + local default="$2" + local raw="" + + if [[ -n "$CONFIG_FILE" ]]; then + raw="$(sed -n "s/^${key}:[[:space:]]*//p" "$CONFIG_FILE" | head -n 1)" + raw="$(clean_yaml_value "$raw")" fi - file_get() { - local key="$1" - local fallback="$2" - local value + printf '%s\n' "${raw:-$default}" +} + +config_get_list() { + local key="$1" + local line + local cleaned + + if [[ -z "$CONFIG_FILE" ]]; then + return 0 + fi - value="$(jq -r --arg k "$key" 'if has($k) then .[$k] | tostring else "" end' "$CONFIG_FILE")" - printf '%s\n' "${value:-$fallback}" - } + while IFS= read -r line; do + cleaned="$(clean_yaml_value "$line")" + if [[ -n "$cleaned" ]]; then + printf '%s\n' "$cleaned" + fi + done < <(awk -v key="$key" ' + inlist { + if ($0 ~ /^[[:space:]]*(#|$)/) next + if ($0 !~ /^[[:space:]]+-[[:space:]]*/) exit + sub(/^[[:space:]]+-[[:space:]]*/, "") + print + next + } + $0 ~ "^" key ":[[:space:]]*(#.*)?$" { inlist = 1 } + ' "$CONFIG_FILE") +} - file_get_array() { - jq -r --arg k "$1" '.[$k] // [] | .[]' "$CONFIG_FILE" - } +load_config_file() { + if [[ -z "$CONFIG_FILE" ]]; then + return 0 + fi - PR_MODE="$(file_get pr-mode "$PR_MODE")" - ENFORCE="$(file_get enforce "$ENFORCE")" - AI_ATTRIBUTION="$(file_get ai-attribution "$AI_ATTRIBUTION")" - IGNORE_BOT_COMMITS="$(file_get ignore-bot-commits "$IGNORE_BOT_COMMITS")" - IGNORE_MERGE_COMMITS="$(file_get ignore-merge-commits "$IGNORE_MERGE_COMMITS")" + PR_MODE="$(config_get pr-mode "$PR_MODE")" + ENFORCE="$(config_get enforce "$ENFORCE")" + AI_ATTRIBUTION="$(config_get ai-attribution "$AI_ATTRIBUTION")" + IGNORE_BOT_COMMITS="$(config_get ignore-bot-commits "$IGNORE_BOT_COMMITS")" + IGNORE_MERGE_COMMITS="$(config_get ignore-merge-commits "$IGNORE_MERGE_COMMITS")" local file_ignore_patterns - file_ignore_patterns="$(file_get_array ignore-message-patterns)" + file_ignore_patterns="$(config_get_list ignore-message-patterns)" if [[ -n "$file_ignore_patterns" ]]; then IGNORE_MESSAGE_PATTERNS="$file_ignore_patterns" fi - BAN_PATTERNS="$(file_get_array ban-patterns)" - BRANCHES="$(file_get_array branches)" + BAN_PATTERNS="$(config_get_list ban-patterns)" + BRANCHES="$(config_get_list branches)" echo "loaded ${CONFIG_FILE} (file values override workflow inputs)" } diff --git a/scripts/validate-commit-message.sh b/scripts/validate-commit-message.sh index 35b6edf..6d4cf43 100755 --- a/scripts/validate-commit-message.sh +++ b/scripts/validate-commit-message.sh @@ -2,8 +2,9 @@ set -euo pipefail # commit-guard native commit-msg hook -# reads optional .commit-guard.json at the repo root -# config parsing is duplicated in run-commitlint-ci.sh — keep in sync +# reads optional .commit-guard.yml at the repo root (flat schema: top-level +# keys, block-style lists). config parsing is duplicated in +# run-commitlint-ci.sh — keep in sync MESSAGE_FILE="${1:-}" @@ -13,7 +14,13 @@ if [[ -z "$MESSAGE_FILE" || ! -f "$MESSAGE_FILE" ]]; then fi REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" -CONFIG_FILE="${REPO_ROOT}/.commit-guard.json" +CONFIG_FILE="" +for candidate in "${REPO_ROOT}/.commit-guard.yml" "${REPO_ROOT}/.commit-guard.yaml"; do + if [[ -f "$candidate" ]]; then + CONFIG_FILE="$candidate" + break + fi +done DEFAULT_TYPES='build|chore|ci|docs|feat|fix|perf|refactor|style|test' AI_ATTRIBUTION_PATTERNS=( @@ -22,52 +29,67 @@ AI_ATTRIBUTION_PATTERNS=( 'noreply@anthropic\.com' ) -have_jq() { - [[ -z "${CG_FORCE_FALLBACK_PARSER:-}" ]] && command -v jq >/dev/null 2>&1 -} +clean_yaml_value() { + local value="$1" -if [[ -f "$CONFIG_FILE" ]] && have_jq && ! jq empty "$CONFIG_FILE" 2>/dev/null; then - echo "error: ${CONFIG_FILE} is not valid JSON." >&2 - exit 1 -fi + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + + case "$value" in + \"*\") + value="${value#\"}" + value="${value%\"}" + ;; + \'*\') + value="${value#\'}" + value="${value%\'}" + ;; + *) + value="${value%%" #"*}" + value="${value%"${value##*[![:space:]]}"}" + ;; + esac + + printf '%s\n' "$value" +} config_get() { local key="$1" local default="$2" - local value="" + local raw="" - if [[ -f "$CONFIG_FILE" ]]; then - if have_jq; then - value="$(jq -r --arg k "$key" 'if has($k) then .[$k] | tostring else "" end' "$CONFIG_FILE")" - else - value="$(sed -n 's/^[[:space:]]*"'"$key"'"[[:space:]]*:[[:space:]]*"\{0,1\}\([^",]*\)"\{0,1\},\{0,1\}[[:space:]]*$/\1/p' "$CONFIG_FILE" | head -n 1)" - fi + if [[ -n "$CONFIG_FILE" ]]; then + raw="$(sed -n "s/^${key}:[[:space:]]*//p" "$CONFIG_FILE" | head -n 1)" + raw="$(clean_yaml_value "$raw")" fi - printf '%s\n' "${value:-$default}" + printf '%s\n' "${raw:-$default}" } -config_get_array() { +config_get_list() { local key="$1" + local line + local cleaned - if [[ ! -f "$CONFIG_FILE" ]]; then + if [[ -z "$CONFIG_FILE" ]]; then return 0 fi - if have_jq; then - jq -r --arg k "$key" '.[$k] // [] | .[]' "$CONFIG_FILE" - else - # fallback: flat pretty-printed json, one array element per line - awk -v key="\"${key}\"" ' - index($0, key) && /\[/ { inside = 1; next } - inside && /\]/ { exit } - inside { - gsub(/^[[:space:]]*"?/, "") - gsub(/"?,?[[:space:]]*$/, "") - if (length($0)) print - } - ' "$CONFIG_FILE" - fi + while IFS= read -r line; do + cleaned="$(clean_yaml_value "$line")" + if [[ -n "$cleaned" ]]; then + printf '%s\n' "$cleaned" + fi + done < <(awk -v key="$key" ' + inlist { + if ($0 ~ /^[[:space:]]*(#|$)/) next + if ($0 !~ /^[[:space:]]+-[[:space:]]*/) exit + sub(/^[[:space:]]+-[[:space:]]*/, "") + print + next + } + $0 ~ "^" key ":[[:space:]]*(#.*)?$" { inlist = 1 } + ' "$CONFIG_FILE") } ENFORCE="$(config_get enforce block)" @@ -89,7 +111,7 @@ case "$AI_ATTRIBUTION" in ;; esac -TYPES="$(config_get_array types | paste -sd '|' -)" +TYPES="$(config_get_list types | paste -sd '|' -)" TYPES="${TYPES:-$DEFAULT_TYPES}" CONVENTIONAL_REGEX="^(${TYPES})(\([[:alnum:]./_-]+\))?(!)?: .+" @@ -179,7 +201,7 @@ check_ban_patterns() { if grep -Eiq -- "$pattern" <<< "$message"; then add_violation "commit message matches banned pattern: ${pattern}" fi - done < <(config_get_array ban-patterns) + done < <(config_get_list ban-patterns) } check_conventional_subject() { diff --git a/test/config-features.t.sh b/test/config-features.t.sh index 6e26c71..71f6b2f 100644 --- a/test/config-features.t.sh +++ b/test/config-features.t.sh @@ -8,10 +8,10 @@ CI_SCRIPT="${ROOT_DIR}/scripts/run-commitlint-ci.sh" make_config_repo() { local repo_dir="$1" - local config_json="$2" + local config_yaml="$2" make_git_repo "$repo_dir" - printf '%s\n' "$config_json" > "${repo_dir}/.commit-guard.json" + printf '%s\n' "$config_yaml" > "${repo_dir}/.commit-guard.yml" } run_validator() { @@ -26,9 +26,9 @@ run_validator() { test_custom_types_accept_and_reject() { local repo_dir repo_dir="$(make_temp_dir)/repo" - make_config_repo "$repo_dir" '{ - "types": ["feat", "wip"] -}' + make_config_repo "$repo_dir" 'types: + - feat + - wip' run_validator "$repo_dir" "wip: half-done thing" @@ -37,31 +37,34 @@ test_custom_types_accept_and_reject() { fi } -test_custom_types_with_fallback_parser() { +test_parser_handles_comments_and_quotes() { local repo_dir repo_dir="$(make_temp_dir)/repo" - make_config_repo "$repo_dir" '{ - "types": [ - "feat", - "wip" - ] -}' + make_config_repo "$repo_dir" '# full-line comment +enforce: "block" +ai-attribution: block # trailing comment - ( - export CG_FORCE_FALLBACK_PARSER=1 - run_validator "$repo_dir" "wip: fallback parser works" - if run_validator "$repo_dir" "chore: rejected via fallback" 2>/dev/null; then - fail "expected fallback parser to enforce custom types" - fi - ) +types: + # comment inside list + - "feat" + + - wip' + + run_validator "$repo_dir" "wip: quoted and commented config parses" + + if run_validator "$repo_dir" "feat: with AI trailer + +Co-Authored-By: Claude " 2>/dev/null; then + fail "expected ai-attribution with trailing comment to still block" + fi } test_ban_patterns_reject_matching_message() { local repo_dir repo_dir="$(make_temp_dir)/repo" - make_config_repo "$repo_dir" '{ - "ban-patterns": ["password", "^temp"] -}' + make_config_repo "$repo_dir" 'ban-patterns: + - password + - "^temp"' if run_validator "$repo_dir" "feat: add Password rotation" 2>/dev/null; then fail "expected banned pattern to reject message" @@ -73,9 +76,7 @@ test_ban_patterns_reject_matching_message() { test_ai_attribution_block() { local repo_dir repo_dir="$(make_temp_dir)/repo" - make_config_repo "$repo_dir" '{ - "ai-attribution": "block" -}' + make_config_repo "$repo_dir" 'ai-attribution: block' if run_validator "$repo_dir" "feat: add thing @@ -97,9 +98,7 @@ Co-Authored-By: Human Person " test_ai_attribution_warn_allows() { local repo_dir repo_dir="$(make_temp_dir)/repo" - make_config_repo "$repo_dir" '{ - "ai-attribution": "warn" -}' + make_config_repo "$repo_dir" 'ai-attribution: warn' run_validator "$repo_dir" "feat: add thing @@ -110,9 +109,7 @@ test_ai_attribution_strip_rewrites_message() { local repo_dir local result repo_dir="$(make_temp_dir)/repo" - make_config_repo "$repo_dir" '{ - "ai-attribution": "strip" -}' + make_config_repo "$repo_dir" 'ai-attribution: strip' run_validator "$repo_dir" "feat: add thing @@ -128,9 +125,7 @@ Co-Authored-By: Claude " test_enforce_warn_allows_bad_commit_locally() { local repo_dir repo_dir="$(make_temp_dir)/repo" - make_config_repo "$repo_dir" '{ - "enforce": "warn" -}' + make_config_repo "$repo_dir" 'enforce: warn' run_validator "$repo_dir" "totally not conventional" } @@ -138,9 +133,7 @@ test_enforce_warn_allows_bad_commit_locally() { test_invalid_enum_fails_loudly() { local repo_dir repo_dir="$(make_temp_dir)/repo" - make_config_repo "$repo_dir" '{ - "ai-attribution": "nope" -}' + make_config_repo "$repo_dir" 'ai-attribution: nope' if run_validator "$repo_dir" "feat: fine subject" 2>/dev/null; then fail "expected invalid ai-attribution value to fail" @@ -151,9 +144,7 @@ test_ci_file_overrides_env_enforce() { local temp_dir repo_dir base_sha head_sha temp_dir="$(make_temp_dir)" repo_dir="${temp_dir}/repo" - make_config_repo "$repo_dir" '{ - "enforce": "warn" -}' + make_config_repo "$repo_dir" 'enforce: warn' commit_file "$repo_dir" "README.md" "base" "feat: seed repo" base_sha="$(git -C "$repo_dir" rev-parse HEAD)" @@ -178,9 +169,8 @@ test_ci_ban_pattern_fails_commit() { local temp_dir repo_dir base_sha head_sha temp_dir="$(make_temp_dir)" repo_dir="${temp_dir}/repo" - make_config_repo "$repo_dir" '{ - "ban-patterns": ["hunter2"] -}' + make_config_repo "$repo_dir" 'ban-patterns: + - hunter2' commit_file "$repo_dir" "README.md" "base" "feat: seed repo" base_sha="$(git -C "$repo_dir" rev-parse HEAD)" @@ -206,9 +196,7 @@ test_ci_ai_attribution_strip_acts_as_block() { local temp_dir repo_dir base_sha head_sha temp_dir="$(make_temp_dir)" repo_dir="${temp_dir}/repo" - make_config_repo "$repo_dir" '{ - "ai-attribution": "strip" -}' + make_config_repo "$repo_dir" 'ai-attribution: strip' commit_file "$repo_dir" "README.md" "base" "feat: seed repo" base_sha="$(git -C "$repo_dir" rev-parse HEAD)" @@ -236,9 +224,9 @@ test_ci_branch_filter_skips_other_branches() { local temp_dir repo_dir base_sha head_sha output temp_dir="$(make_temp_dir)" repo_dir="${temp_dir}/repo" - make_config_repo "$repo_dir" '{ - "branches": ["main", "master"] -}' + make_config_repo "$repo_dir" 'branches: + - main + - master' commit_file "$repo_dir" "README.md" "base" "feat: seed repo" base_sha="$(git -C "$repo_dir" rev-parse HEAD)" @@ -262,7 +250,7 @@ test_ci_branch_filter_skips_other_branches() { } test_custom_types_accept_and_reject -test_custom_types_with_fallback_parser +test_parser_handles_comments_and_quotes test_ban_patterns_reject_matching_message test_ai_attribution_block test_ai_attribution_warn_allows diff --git a/test/install.t.sh b/test/install.t.sh index b3399fe..af8205b 100755 --- a/test/install.t.sh +++ b/test/install.t.sh @@ -24,9 +24,9 @@ test_installer_supports_native_hooks_and_pr_title_mode_without_node_repo() { assert_contains "created: .githooks/commit-msg" "$output" "expected native hook install output" assert_contains 'pr-mode: "title"' "$(cat "${repo_dir}/.github/workflows/commitlint.yml")" "expected PR mode to be rendered" assert_contains ".githooks" "$(git -C "$repo_dir" config --get core.hooksPath)" "expected git hooks path to point at tracked hooks" - assert_contains "created: .commit-guard.json" "$output" "expected starter config output" - assert_contains '"pr-mode": "title"' "$(cat "${repo_dir}/.commit-guard.json")" "expected pr mode in starter config" - assert_contains '"ai-attribution": "block"' "$(cat "${repo_dir}/.commit-guard.json")" "expected ai-attribution default in starter config" + assert_contains "created: .commit-guard.yml" "$output" "expected starter config output" + assert_contains 'pr-mode: title' "$(cat "${repo_dir}/.commit-guard.yml")" "expected pr mode in starter config" + assert_contains 'ai-attribution: block' "$(cat "${repo_dir}/.commit-guard.yml")" "expected ai-attribution default in starter config" } test_installer_supports_native_hooks_and_pr_title_mode_without_node_repo From 5028d28ba1d3e2e148749631abf5630336ebfab6 Mon Sep 17 00:00:00 2001 From: Cody Williamson Date: Sun, 19 Jul 2026 22:22:16 -0500 Subject: [PATCH 6/7] docs: update config docs for yaml format --- CHANGELOG.md | 4 ++-- README.md | 50 ++++++++++++++++++++++++++++---------------------- 2 files changed, 30 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2af2730..740f62e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,13 +2,13 @@ ## [0.3.0] -- Add `.commit-guard.json` per-repo config file, read by both CI and local hooks. File values override workflow inputs. +- Add `.commit-guard.yml` per-repo config file, read by both CI and local hooks. File values override workflow inputs. Parsed with plain bash locally (flat schema), validated with `yq` in CI. - Add `ai-attribution` policy (`allow`, `warn`, `strip`, `block`) to catch AI co-author trailers and "generated with" bylines. `strip` rewrites the message locally and acts as `block` in CI. - Add custom `types` list, enforced by the native hook and via a generated commitlint `type-enum` rule in CI. - Add `ban-patterns` — case-insensitive regexes that fail the lint when matched anywhere in a commit message. - Add `enforce: warn` mode — CI annotates failures but passes; the local hook prints the error and allows the commit. - Add `branches` filter — push events on unlisted branches skip linting. -- Installers gain `--ai-attribution` and `--enforce` flags and write a starter `.commit-guard.json`. +- Installers gain `--ai-attribution` and `--enforce` flags and write a commented starter `.commit-guard.yml`. ## [0.2.2] diff --git a/README.md b/README.md index 789565a..9ae08a2 100644 --- a/README.md +++ b/README.md @@ -31,23 +31,28 @@ Common options: ## Configuration file -Both CI and local hooks read an optional `.commit-guard.json` at the repo root. -File values override workflow inputs; workflow inputs apply when the file or -key is absent. All keys are optional. - -```json -{ - "config": "conventional", - "pr-mode": "smart", - "enforce": "block", - "ai-attribution": "block", - "types": ["feat", "fix", "chore", "ci", "docs", "test", "refactor", "perf", "build", "style"], - "ban-patterns": ["password"], - "branches": ["main", "master"], - "ignore-bot-commits": true, - "ignore-merge-commits": true, - "ignore-message-patterns": ["^Initial plan$"] -} +Both CI and local hooks read an optional `.commit-guard.yml` (or +`.commit-guard.yaml`) at the repo root. File values override workflow inputs; +workflow inputs apply when the file or key is absent. All keys are optional. + +```yaml +config: conventional +pr-mode: smart +enforce: block +ai-attribution: block +types: + - feat + - fix + - chore +ban-patterns: + - password +branches: + - main + - master +ignore-bot-commits: true +ignore-merge-commits: true +ignore-message-patterns: + - ^Initial plan$ ``` - `enforce`: `block` (default) fails on violations; `warn` annotates and passes. @@ -67,12 +72,13 @@ key is absent. All keys are optional. - `branches`: on push events, only these branches are linted. Empty or absent means all branches. -The native hook parses the file with `jq` when available. Without `jq`, a -built-in fallback parser is used — keep the file flat and pretty-printed with -one array element per line, and avoid `"` inside values. +The hooks parse the file with plain bash — no jq, yq, or Node required. Keep +the schema flat: top-level keys and block-style lists (`- item` per line), as +shown above. Comments and quoted values are fine. CI validates the file with +`yq` and fails loudly on invalid YAML. -The installer writes a starter `.commit-guard.json` with -`"ai-attribution": "block"` for new installs; existing configs are never +The installer writes a commented starter `.commit-guard.yml` with +`ai-attribution: block` for new installs; existing configs are never overwritten. ## What changed in v0.2.0 From af176ce271210d05afea1e5461fcfc52f7d5e6d8 Mon Sep 17 00:00:00 2001 From: Cody Date: Sat, 29 Aug 2026 14:18:46 -0500 Subject: [PATCH 7/7] fix: resolve ai attribution detection skip process miss Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- scripts/validate-commit-message.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/validate-commit-message.sh b/scripts/validate-commit-message.sh index 6d4cf43..52d6de7 100755 --- a/scripts/validate-commit-message.sh +++ b/scripts/validate-commit-message.sh @@ -126,7 +126,7 @@ find_ai_attribution_lines() { local lowered local pattern - while IFS= read -r line; do + while IFS= read -r line || [[ -n "$line" ]]; do lowered="${line,,}" for pattern in "${AI_ATTRIBUTION_PATTERNS[@]}"; do if [[ "$lowered" =~ $pattern ]]; then