forked from bchanot/claude
Surfaced by dogfooding on a real Astro repo instead of reading the spec. Claude Code installs a shell function routing `grep` to ugrep with `--ignore-files`, so grep honours .gitignore and never descends into a gitignored dist/. `find` honours nothing. seo-analyzer uses both. Measured on zenquality (Astro, dist/ gitignored but built locally), seo-analyzer.md:497 returned 92 images, 45 of them under dist/ — every asset listed twice, source and generated copy, byte-identical. Two live consequences: - "top 20 by size" was ~10 real images dressed as 20. - Batch C (`cwebp -q 80 <img> -o <img>.webp`) could target dist/og-image.png; the .webp lands in dist/ and the `npm run build` the dispatcher runs to VERIFY the fix erases it. Fix lands, verification passes, nothing survives, report says applied. lib/source-scope.sh separates source from build output, framework-aware. `public/` is deliberately NOT excluded by default: it is Astro/Vite/Next SOURCE and holds favicon.ico, apple-touch-icon.png and robots.txt — the very files STEP 4 curls. It is build output only for Hugo/Gatsby, detected from config (legacy config.toml alone is ambiguous, so it needs archetypes/ too). Blanket-excluding it would blind the audit to its own resource checks. findargs emits one token per line and MUST be consumed via a quoted array. I shipped a flat-string version first and the dogfood caught it: the shell globs */dist/* against the CWD and passes the matches to find as search paths, which turned 90 hits into 135 and kept every dist/ file. Both the header and a functional test now pin that. Also: no bundle item may target build output, in either agent. That is the real safety net — even if some future find leaks a dist path, the fix cannot land there. Fix the source that generates the artifact; if the source cannot be found, that is a finding, not a reason to patch the artifact. SCOPE CORRECTION: the proposal claimed grep was auditing 86 generated files instead of 9 templates. That was FALSE — the ugrep shim already skips them. Killed my own premise before coding it; the real bug is narrower and lives in find only. Do NOT "fix" the grep lines to match: they are already correct, and adding these exclusions there would drop public/. Verified: 90 -> 45 images on the real repo, 0 dist survivors, public/ preserved (favicon.ico still visible); 34 new assertions PASS / 0 FAIL; full suite green; shellcheck clean. Test file addition used the documented one-shot config-edit sentinel.
80 lines
3.7 KiB
Bash
80 lines
3.7 KiB
Bash
#!/usr/bin/env bash
|
|
# Emit the directory exclusions that separate SOURCE from BUILD OUTPUT.
|
|
#
|
|
# EXCL="$(bash ~/.claude/lib/source-scope.sh grep)"
|
|
# grep -rl "gtag" $EXCL --include="*.html" . # note: $EXCL unquoted
|
|
#
|
|
# mapfile -t FEXCL < <(bash ~/.claude/lib/source-scope.sh findargs)
|
|
# find . "${FEXCL[@]}" -iname '*.jpg' -printf '%s %p\n' # quoted array!
|
|
#
|
|
# findargs emits ONE TOKEN PER LINE and MUST be consumed through a quoted
|
|
# array. A flat string does not work: `find . $FEXCL ...` lets the shell glob
|
|
# `*/dist/*` against the CWD before find ever sees it, and the matches are then
|
|
# passed as search PATHS. Measured on zenquality: that turned 90 hits into 135
|
|
# and kept every dist/ file. The array form passes each token literally.
|
|
#
|
|
# WHY: grep and find disagree about what is in the repo, and seo-analyzer uses
|
|
# both.
|
|
#
|
|
# grep → Claude Code installs a shell function routing grep to ugrep with
|
|
# `--ignore-files`, i.e. .gitignore-aware. A gitignored dist/ is
|
|
# invisible to it when recursing from `.`. Verified 2026-07-17.
|
|
# find → knows nothing about .gitignore. It sees everything.
|
|
#
|
|
# So on zenquality (Astro, dist/ gitignored, built locally) the spec's image
|
|
# audit at seo-analyzer.md:497 returns 92 images of which 45 live in dist/ —
|
|
# every asset listed twice, source and generated copy, identical bytes. Two
|
|
# real consequences:
|
|
# 1. "top 20 by size" is half generated duplicates: ~10 real images audited
|
|
# while 20 are claimed.
|
|
# 2. Batch C (`cwebp -q 80 <img> -o <img>.webp`) can target dist/og-image.png.
|
|
# The .webp lands in dist/ and the `npm run build` that /seo runs to VERIFY
|
|
# the fix erases it. The fix lands, verification passes, nothing survives.
|
|
#
|
|
# The grep side is already safe by accident — do NOT "fix" it to match find.
|
|
# `grep` mode below is defence in depth for the cases the shim misses: a repo
|
|
# that COMMITS its build output (no .gitignore entry to honour), or a directory
|
|
# that is not a git repo at all.
|
|
#
|
|
# `public/` is deliberately NOT in the always-list: it is SOURCE for
|
|
# Astro/Vite/Next and holds the very files this audit checks — favicon.ico,
|
|
# apple-touch-icon.png, robots.txt, OG images. It is build OUTPUT only for
|
|
# Hugo and Gatsby, detected below. Blanket-excluding it would blind the audit
|
|
# to its own resource checks.
|
|
#
|
|
# Exclusions are by NAME, not path, so a monorepo's frontend/dist is caught
|
|
# exactly like a root ./dist.
|
|
set -uo pipefail
|
|
|
|
_die() { echo "source-scope: $1" >&2; exit 2; }
|
|
|
|
# Build output + tool caches. Never source.
|
|
ALWAYS=(node_modules .git dist build .next .nuxt .output _site .astro
|
|
.svelte-kit .cache out coverage .vercel .netlify .turbo)
|
|
|
|
# public/ is output for exactly these two generators.
|
|
_public_is_output() {
|
|
find . -maxdepth 3 \( -name "gatsby-config.js" -o -name "gatsby-config.ts" \
|
|
-o -name "gatsby-config.mjs" -o -name "hugo.toml" -o -name "hugo.yaml" \
|
|
-o -name "hugo.json" \) 2>/dev/null | read -r _ && return 0
|
|
# Hugo's legacy config.toml is ambiguous on its own — pair it with archetypes/
|
|
[ -d ./archetypes ] && [ -f ./config.toml ] && return 0
|
|
return 1
|
|
}
|
|
|
|
_list() {
|
|
printf '%s\n' "${ALWAYS[@]}"
|
|
_public_is_output && printf 'public\n'
|
|
return 0
|
|
}
|
|
|
|
case "${1:-}" in
|
|
list) _list ;;
|
|
# Safe unquoted: --exclude-dir=NAME carries no glob character.
|
|
grep) _list | while read -r d; do printf -- '--exclude-dir=%s ' "$d"; done; echo ;;
|
|
# One token per line — consume with mapfile + a QUOTED array, never a flat
|
|
# string (see header: the shell would glob */dist/* against the CWD).
|
|
findargs) _list | while read -r d; do printf '!\n-path\n*/%s/*\n' "$d"; done ;;
|
|
*) _die "usage: source-scope.sh {list|grep|findargs}" ;;
|
|
esac
|