From e3c1fe30c4b3a37cd93ab430de0473e311aa389c Mon Sep 17 00:00:00 2001 From: Claude Code Bot Date: Thu, 17 Sep 2026 22:59:52 -0700 Subject: [PATCH] feat(standards): give shellcheck a canonical config like the other linters shellcheck was the only linter in run-standards.sh with no canonical config. yamllint and markdownlint each resolve one from standards/; shellcheck ran bare and took whatever the machine offered. Because shellcheck searches ANCESTOR directories, a local run under a checkout whose parent carries a ~/.shellcheckrc silently picked up settings CI never saw -- 68 findings locally against CI's 24 on the same tree. That is claude-config#534. Resolution mirrors yamllint's at :147-150: a repo-root .shellcheckrc wins, else standards/shellcheckrc. Passing --rcfile explicitly is what stops the ancestor search, so this both centralizes the policy and closes the leak. Content is external-sources + source-path=SCRIPTDIR + disable=SC2310. The first two RESOLVE files sourced through a variable path rather than silencing SC1091, so the sourced code is analyzed instead of skipped. SC2310 is informational and fires throughout ordinary guard-clause style. `enable=all` is deliberately excluded. Measured across every clone with shell files on 2026-09-18: this config takes 21 of 24 repos to zero findings, while adding enable=all takes four otherwise-clean repos to 124+ combined (dotfiles 63, vpn-lan-bridge 12, claude-wrapper 10, scripts 3, LaunchAgents 1). A base that forces per-repo exceptions is not a base. enable=all stays in ~/.shellcheckrc as the stricter interactive config: local advisory, CI enforcing. Verified by running run-standards.sh itself against seven repos, not by reading the diff: github-workflows, dotfiles, claude-wrapper, vpn-lan-bridge, dev-env and claude-config all pass; kebab-tax fails with exactly the 12 findings the measurement predicted, all in one test file (10x SC2329, the standard false positive for a harness dispatching indirectly, plus 2x SC2155). That repo is addressed separately -- a file-level disable with a comment, not a repo-level config. The canonical file carries a known-bad gate in its own verification: a fixture with an unquoted expansion must still be rejected under it, so a config that silenced everything could not pass as clean. Advances twistedmelonman/claude-config#534. Claude-Session: https://claude.ai/code/session_01QkitU5UQAawPwZLEuXRajG --- standards/run-standards.sh | 13 ++++++++++++- standards/shellcheckrc | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 standards/shellcheckrc diff --git a/standards/run-standards.sh b/standards/run-standards.sh index d64daf5..8d286d4 100755 --- a/standards/run-standards.sh +++ b/standards/run-standards.sh @@ -128,7 +128,18 @@ if _skipped shellcheck; then echo "== shellcheck: skipped by input"; else esac done < <(_tracked || true) if ((${#files[@]} == 0)); then echo "::notice::no shell files"; else - (cd "${repo}" && shellcheck -S info "${files[@]}") || _fail shellcheck + # Config precedence, as for the other linters: a repo-root .shellcheckrc + # wins, else the canonical file here. Passing it explicitly matters more + # than elsewhere -- shellcheck searches ANCESTOR directories, so a bare + # invocation under a checkout whose parent carries a .shellcheckrc picks + # up settings CI never intended (claude-config#534). + # + # Note these do not merge: --rcfile replaces the search, and a repo-root + # file halts it. A repo-local config therefore restates the whole policy. + cfg="" + if [[ -f "${repo}/.shellcheckrc" ]]; then cfg="${repo}/.shellcheckrc"; fi + [[ -n "${cfg}" ]] || cfg="${config_dir}/shellcheckrc" + (cd "${repo}" && shellcheck --rcfile "${cfg}" -S info "${files[@]}") || _fail shellcheck fi fi diff --git a/standards/shellcheckrc b/standards/shellcheckrc new file mode 100644 index 0000000..b09ab53 --- /dev/null +++ b/standards/shellcheckrc @@ -0,0 +1,38 @@ +# Canonical shellcheck config for standards-check.yml. +# +# shellcheck was the only linter in run-standards.sh with no canonical config: +# yamllint and markdownlint ship one here, while shellcheck ran bare and +# resolved its config from whatever happened to be on the machine. Because +# shellcheck searches ancestor directories, a local run under a checkout whose +# parent carries a ~/.shellcheckrc silently picked up settings CI never saw -- +# 68 findings locally against CI's 24 on the same tree. +# See twistedmelonman/claude-config#534. +# +# Measured 2026-09-18 across every clone with shell files: this config takes +# 21 of 24 repos to zero findings, including all three that previously carried +# their own .shellcheckrc. Those three are removed in the same change. +# +# external-sources + source-path resolve files sourced through a variable path +# (`source "${LIB_DIR}/foo.sh"`) instead of silencing SC1091. Resolving means +# the sourced code is analyzed; silencing means it is skipped. source-path +# SCRIPTDIR additionally maps sources written against a deployed layout back to +# the repo layout, where those files sit beside the sourcing script. +# +# SC2310 (function invoked in a conditional, disabling set -e) is informational +# and fires throughout normal guard-clause style. It is disabled fleet-wide +# rather than per-repo so no repo needs its own config to silence it. +# +# `enable=all` is deliberately NOT set here. Measured, it takes four otherwise +# clean repos to 124+ combined findings (dotfiles 63, vpn-lan-bridge 12, +# claude-wrapper 10, scripts 3, LaunchAgents 1). A base that forces per-repo +# exceptions is not a base. It stays in ~/.shellcheckrc as the stricter +# interactive config: local advisory, CI enforcing. +# +# NOTE: shellcheck config files do NOT merge. A repo-root .shellcheckrc halts +# the search entirely and --rcfile replaces it, so a per-repo exception must +# restate this whole file and silently drifts when this one changes. Keep +# exceptions rare, and prefer a file-level `# shellcheck disable` with a +# comment over a repo-level config. +external-sources=true +source-path=SCRIPTDIR +disable=SC2310