diff --git a/standards/check-claude-ignore.sh b/standards/check-claude-ignore.sh new file mode 100755 index 0000000..d29a50b --- /dev/null +++ b/standards/check-claude-ignore.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# check-claude-ignore.sh : warn-only (never fails); see dev-env#178. +set -euo pipefail + +repo="${1:?usage: check-claude-ignore.sh }" +probe=".claude/__standards_probe__" +ref="smartwatermelon/dev-env#178" +fix="Add '.claude/' to .gitignore (or '.claude/*' plus '!.claude/' for shared files)," +fix+=" then 'git rm --cached' any tracked file listed. See ${ref}." + +# Blank global excludes. .git/info/exclude still applies. +_git() { git -C "${repo}" -c core.excludesFile=/dev/null "$@"; } + +# Encode %, CR, LF for annotations. +_esc() { + local s="$1" + s="${s//'%'/%25}" + s="${s//$'\r'/%0D}" + s="${s//$'\n'/%0A}" + printf '%s' "${s}" +} + +if ! git -C "${repo}" rev-parse --git-dir >/dev/null 2>&1; then + echo "::error::${repo} is not a git repository" + exit 2 +fi + +warned=0 + +# check-ignore: 0 ignored, 1 not, else error. +rc=0 +_git check-ignore -q --no-index -- "${probe}" || rc=$? +if ((rc == 1)); then + echo "::warning title=claude-ignore::$(_esc ".claude/ is not ignored by the committed .gitignore. ${fix}")" + warned=1 +elif ((rc != 0)); then + echo "::warning title=claude-ignore::git check-ignore failed (exit ${rc}); not checked" + exit 0 +fi + +# Tracked files matching an ignore rule. +lsrc=0 +tracked="$(_git ls-files -ci --exclude-standard -- .claude/)" || lsrc=$? +if ((lsrc != 0)); then + echo "::warning title=claude-ignore::git ls-files failed (exit ${lsrc}); tracked files not checked" + exit 0 +fi +if [[ -n "${tracked}" ]]; then + echo "::warning title=claude-ignore::$(_esc "Tracked file(s) under .claude/ match an ignore rule:"$'\n'"${tracked}"$'\n'"${fix}")" + warned=1 +fi + +if ((warned == 0)); then echo ".claude/ ignore policy: OK"; fi +exit 0 diff --git a/standards/run-standards.sh b/standards/run-standards.sh index 8cfbf4e..c606931 100755 --- a/standards/run-standards.sh +++ b/standards/run-standards.sh @@ -16,6 +16,8 @@ # opened fails their unrelated change. Measured across the fleet, that was the # single largest source of standards-check failures. # +# claude-ignore: warn-only, whole-repo. +# # Scope: the flag narrows the four linters that enumerate through _tracked # (shellcheck, yamllint, zizmor, markdownlint). actionlint and the Node-floor # check find their own inputs and stay whole-repo — both are cheap, and @@ -293,6 +295,12 @@ if _skipped node-floor; then echo "== node-floor: skipped by input"; else _lint node-floor bash "${config_dir}/check-node-floor.sh" "${repo}" "${node_floor}" fi +# claude-ignore: warning-only (dev-env#178); never adds to failed[]. +if _skipped claude-ignore; then echo "== claude-ignore: skipped by input"; else + _header claude-ignore + bash "${config_dir}/check-claude-ignore.sh" "${repo}" +fi + echo if ((${#failed[@]} > 0)); then _write_summary diff --git a/tests/test-check-claude-ignore.sh b/tests/test-check-claude-ignore.sh new file mode 100755 index 0000000..b32819b --- /dev/null +++ b/tests/test-check-claude-ignore.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# Validation for standards/check-claude-ignore.sh on throwaway git repos. +# It only warns, so cases assert on output and exit 0. +set -euo pipefail +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +checker="${here}/../standards/check-claude-ignore.sh" +# Isolate from the developer's global git config (hooks, excludes). +export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null +tmp="$(mktemp -d)" +trap 'rm -rf "${tmp}"' EXIT +pass=0; fail=0 +_ok() { echo " ok $1"; pass=$((pass + 1)); } +_bad() { echo " FAIL $1"; fail=$((fail + 1)); } + +# _repo NAME GITIGNORE-CONTENT [TRACKED-FILE...]: build a fixture repo. +_repo() { + local name="$1" ign="$2" d f + shift 2 + d="${tmp}/${name}" + git init -q "${d}" + if [[ -n "${ign}" ]]; then printf '%b' "${ign}" >"${d}/.gitignore"; fi + mkdir -p "${d}/.claude" + for f in "$@"; do echo x >"${d}/${f}"; done + git -C "${d}" add -f -A +} + +# _run NAME: run the checker; sets out and requires exit 0. +_run() { + out="$("${checker}" "${tmp}/$1" 2>&1)" || { _bad "$1: exit status nonzero"; return 1; } +} +_warns() { grep -q '^::warning title=claude-ignore::' <<<"${out}"; } + +_repo nogi "" +_run nogi && { if _warns; then _ok "no .gitignore warns"; else _bad "no .gitignore did not warn"; fi; } + +_repo clean '.claude/\n' +_run clean && { if _warns; then _bad "'.claude/' ignored warned"; else _ok "'.claude/' ignored is clean"; fi; } + +_repo shared '.claude/*\n!.claude/pre-launch.sh\n' .claude/pre-launch.sh +_run shared && { if _warns; then _bad "negated shared file warned"; else _ok "'.claude/*' + negation + tracked shared file is clean"; fi; } + +_repo tracked '.claude/\n' .claude/README.md +_run tracked && { if _warns && grep -q '\.claude/README\.md' <<<"${out}"; then _ok "tracked file under ignored .claude/ warns and is named"; else _bad "tracked file case missing warning or name"; fi; } + +_repo nonegate '.claude/*\n' .claude/pre-launch.sh +_run nonegate && { if _warns && grep -q '\.claude/pre-launch\.sh' <<<"${out}"; then _ok "'.claude/*' without negation warns and names file"; else _bad "no-negation case missing warning or name"; fi; } + +# A global excludes file must not mask a missing rule. +_repo masked "" +printf '.claude/\n' >"${tmp}/global-ignore" +printf '[core]\n\texcludesFile = %s\n' "${tmp}/global-ignore" >"${tmp}/gitconfig" +out="$(GIT_CONFIG_GLOBAL="${tmp}/gitconfig" "${checker}" "${tmp}/masked" 2>&1)" +if _warns; then _ok "global excludes does not mask a missing rule"; else _bad "global excludes masked the check"; fi + +echo "${pass} passed, ${fail} failed" +[[ "${fail}" -eq 0 ]] diff --git a/tests/test-run-standards.sh b/tests/test-run-standards.sh index 6e94c27..2735c28 100755 --- a/tests/test-run-standards.sh +++ b/tests/test-run-standards.sh @@ -113,6 +113,9 @@ printf 'on: push\npermissions:\n contents: read\njobs:\n a:\n runs-on: ubun echo "lts/krypton" >"${tmp}/clean/.nvmrc" git -C "${tmp}/clean" add -A _expect_pass "clean repo passes every linter" clean +# claude-ignore (dev-env#178) is warning-only: the clean fixture has no +# .gitignore, so the run must warn AND still pass. +if grep -q '^::warning title=claude-ignore::' "${tmp}/clean.log"; then _ok "claude-ignore warns without failing the run"; else _bad "claude-ignore warning missing from the clean run"; fi if bash "${runner}" --repo "${tmp}/bad-sh" --config-dir "${cfg}" --skip shellcheck >/dev/null 2>&1; then _ok "--skip shellcheck disables the linter"; else _bad "--skip shellcheck did not disable it"; fi