Codeowners drift audit
Skill bordenet/superpowers-plus/skills/engineering/codeowners-drift-audit
Audit CODEOWNERS for drift: finds unowned files, dead rules (patterns matching zero files), and invalid owner references. Works with GitHub and GitLab remotes, and degrades to advisory-only output when neither CLI is authenticated. Run when CODEOWNERS is out of date, files lack coverage, or ownership is unclear.From its SKILL.md
npx -y skills add bordenet/superpowers-plus --skill codeowners-drift-auditAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 8 stars8 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
9.8 KB, as published. Nobody here has run it
CODEOWNERS Drift Audit
Wrong skill? PR reviewer auto-assignment → not this skill (read-only audit only). Blast radius of a code change →
blast-radius-check.Source:
superpowers-plusPart of: Engineering Rigor skill family
When to Use
- CODEOWNERS hasn't been reviewed in a while and ownership may have drifted
- Files or directories seem to lack a clear owner
- A rule in CODEOWNERS may no longer match anything (renamed/deleted paths)
- An owner reference (
@user,@org/team) may be stale or misspelled
How to Run
Run the single script below via bash (enforced at runtime, see Failure
Modes). Locates CODEOWNERS (.github/, then root, then docs/ -- GitHub's
own order), finds unowned files, dead rules, and invalid owners. Stateless.
Exit code: exit 0 with all three
===headers printed means complete. Anything else is an incomplete audit -- never read that as "no issues found."Large-repo advisory: cap
FILES_ARRpost-build for a quick sample (e.g.FILES_ARR=("${FILES_ARR[@]:0:100}")) -- but this also caps Step 2 (Dead Rules); see the Failure Modes row before deleting a rule it flags.
#!/usr/bin/env bash
set -euo pipefail
# -- Step 0: Preconditions --
# Under zsh, matches_pattern()'s case-glob silently mismatches everything.
if [ -z "${BASH_VERSION:-}" ]; then
echo "ERROR: this script requires bash -- re-run as 'bash <this-script>'" >&2; exit 1
fi
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
echo "ERROR: not inside a git working tree" >&2; exit 1
fi
# CODEOWNERS candidates and git ls-files are both cwd-relative -- root first.
cd "$(git rev-parse --show-toplevel)" || { echo "ERROR: cannot cd to repo root" >&2; exit 1; }
CODEOWNERS_PATH=""
for candidate in .github/CODEOWNERS CODEOWNERS docs/CODEOWNERS; do
[ -f "$candidate" ] && { CODEOWNERS_PATH="$candidate"; break; }
done
if [ -z "$CODEOWNERS_PATH" ]; then
echo "ERROR: no CODEOWNERS file found" >&2; exit 1
fi
if [ ! -r "$CODEOWNERS_PATH" ]; then
echo "ERROR: CODEOWNERS found but not readable: $CODEOWNERS_PATH" >&2; exit 1
fi
echo "CODEOWNERS: $CODEOWNERS_PATH"
# Match gitignore-style; anchoring MUST be decided before "/" is stripped
# (see Failure Modes for the anchoring/globstar regressions this fixes).
matches_pattern() {
local f="$1" pattern="$2"
local anchored=0
case "$pattern" in
'**/'*) pattern="${pattern#\*\*/}" ;;
*)
case "${pattern%/}" in
*/*) anchored=1 ;;
esac
;;
esac
pattern="${pattern#/}"
pattern="${pattern%/}"
# gitignore's "/**/ " also matches zero intervening dirs.
local pattern2="${pattern//\/\*\*\//\/}"
if [ "$anchored" -eq 1 ]; then
# shellcheck disable=SC2254
case "$f" in
$pattern|$pattern/*|$pattern2|$pattern2/*) return 0 ;;
esac
else
# shellcheck disable=SC2254
case "$f" in
$pattern|$pattern/*|*/$pattern|*/$pattern/*|$pattern2|$pattern2/*|*/$pattern2|*/$pattern2/*) return 0 ;;
esac
fi
return 1
}
# Rules (GitLab [Section] headers excluded, trailing "# comment" text and any
# CRLF stripped so neither pollutes Step 3's owner-token field split below).
RULES=$(grep -Ev '^\s*(#|$|\[)' "$CODEOWNERS_PATH" 2>/dev/null | tr -d '\r' | sed -E 's/[[:space:]]*#.*$//' || true)
echo "Rules: $(echo "$RULES" | grep -c . || true)"
# NUL-delimited into an array: git ls-files -z is never C-quoted, unlike
# newline-delimited output (handles non-ASCII/backslash filenames correctly).
FILES_ARR=()
while IFS= read -r -d '' f; do
FILES_ARR+=("$f")
done < <(git ls-files -z)
echo "Tracked files: ${#FILES_ARR[@]}"
# -- Step 1: Unowned files --
echo ""
echo "=== Unowned Files ==="
for f in "${FILES_ARR[@]+"${FILES_ARR[@]}"}"; do
owned=0
while IFS= read -r rule; do
[ -z "$rule" ] && continue
read -r pattern _ <<< "$rule"
if matches_pattern "$f" "$pattern"; then owned=1; break; fi
done <<RULES_EOF
$RULES
RULES_EOF
if [ "$owned" -eq 0 ]; then echo " UNOWNED: $f"; fi
done
# -- Step 2: Dead rules --
echo ""
echo "=== Dead Rules ==="
echo "$RULES" | while IFS= read -r rule; do
[ -z "$rule" ] && continue
read -r pattern _ <<< "$rule"
matched=0
for f in "${FILES_ARR[@]+"${FILES_ARR[@]}"}"; do
if matches_pattern "$f" "$pattern"; then matched=1; break; fi
done
if [ "$matched" -eq 0 ]; then echo " DEAD RULE: $rule"; fi
done
# -- Step 3: Owner validity (best-effort) --
echo ""
echo "=== Owner Validity ==="
# Check every remote, parsing the actual hostname (see Failure Modes).
IS_GITHUB=0
for remote_name in $(git remote 2>/dev/null); do
remote_url=$(git remote get-url "$remote_name" 2>/dev/null) || continue
remote_host=$(printf '%s\n' "$remote_url" | sed -E 's#^[a-zA-Z][a-zA-Z0-9+.-]*://##; s#^[^@/]*@##; s#[:/].*##')
if [ "$remote_host" = "github.com" ]; then IS_GITHUB=1; break; fi
done
# gh only for github.com, glab only otherwise (prevents cross-API misvalidation).
HAS_GH=0
if [ "$IS_GITHUB" -eq 1 ] && command -v gh >/dev/null 2>&1; then
if gh auth status >/dev/null 2>&1; then
HAS_GH=1
else
echo " ADVISORY: gh not authenticated -- owner check skipped"
fi
fi
HAS_GLAB=0
if [ "$IS_GITHUB" -eq 0 ] && command -v glab >/dev/null 2>&1; then
if glab auth status >/dev/null 2>&1; then
HAS_GLAB=1
else
echo " ADVISORY: glab not authenticated -- owner check skipped"
fi
fi
if [ "$HAS_GH" -eq 0 ] && [ "$HAS_GLAB" -eq 0 ]; then
echo " ADVISORY: owner verification skipped -- no authenticated CLI matching this remote's type on PATH"
echo " (unowned-file and dead-rule results above are unaffected -- CLI is only needed for owner validity)"
else
echo "$RULES" \
| awk '{for(i=2;i<=NF;i++) print $i}' \
| sort -u \
| while IFS= read -r owner; do
[ -z "$owner" ] && continue
# Email-format: non-@ char before @ + dot after @ = email, not username
case "$owner" in
[!@]*@*.*) echo " UNVERIFIABLE: $owner (email-format)"; continue ;;
esac
# No "@" and not email-shaped: not a valid CODEOWNERS owner reference
case "$owner" in
@*) : ;;
*) echo " UNVERIFIABLE: $owner (missing @ prefix)"; continue ;;
esac
slug="${owner#@}"
if [ "$HAS_GH" -eq 1 ]; then
# Distinguish team refs (@org/team) from user refs (@user)
if echo "$slug" | grep -q '/'; then
if gh api "orgs/${slug%%/*}/teams/${slug##*/}" --silent 2>/dev/null; then
echo " VALID: $owner"
else
echo " NOT_FOUND: $owner"
fi
else
if gh api "users/${slug}" --silent 2>/dev/null; then
echo " VALID: $owner"
else
echo " NOT_FOUND: $owner"
fi
fi
elif [ "$HAS_GLAB" -eq 1 ]; then
# glab users?username= exits 0 even for empty results -- inspect body
ubody=$(glab api "users?username=${slug}" 2>/dev/null || echo "[]")
# URL-encode slash for namespaced group paths (e.g. myorg/myteam -> myorg%2Fmyteam)
encoded_slug=$(printf '%s' "$slug" | sed 's|/|%2F|g')
gbody=$(glab api "groups/${encoded_slug}" 2>/dev/null || echo "{}")
if echo "$ubody" | grep -q '"username"' || echo "$gbody" | grep -q '"full_path"'; then
echo " VALID: $owner"
else
echo " NOT_FOUND: $owner"
fi
else
echo " UNVERIFIABLE: $owner (no matching CLI for remote type)"
fi
done
fi
echo ""
echo "=== Done. Review UNOWNED/DEAD RULE/NOT_FOUND lines above. ==="
Companion Skills
blast-radius-check— Before modifying code that a drifted CODEOWNERS rule might misspre-commit-gate— Pre-commit checks independent of ownership review
Failure Modes
Top rows only -- see reference.md for the full table.
| Failure | Prevention |
|---|---|
| zsh silently mismatches every pattern; sh/dash fail differently but still non-zero | Step 0 checks $BASH_VERSION, refuses to run without it |
Leading-slash pattern (/build/) loses root-anchoring if / is stripped before the anchor decision | anchored is decided from ${pattern%/} before the leading / is stripped |
Leading **/, or mid-string /**/, doesn't match zero intervening dirs (case has no recursive-glob) | Leading **/ special-cased to any-depth; mid-string gets a collapsed-to-/ candidate |
.github/CODEOWNERS vs root, or a subdirectory invocation, silently scopes the search/file-list wrong | Search order .github/→root→docs/; Step 0 cds to repo root first |
Non-GitHub/non-GitLab remote + authenticated glab gets a confident but meaningless result | Disclosed, unfixed (reference.md) -- don't trust Owner Validity here |
Large repo: capping FILES_ARR also corrupts Step 2 | See "Large-repo advisory" -- never delete a flagged DEAD RULE from a capped run |
| Non-zero exit, or stopping before "=== Done." | Never read as "no issues found" -- rerun |