diff --git a/plugins/ai-slop/.claude-plugin/plugin.json b/plugins/ai-slop/.claude-plugin/plugin.json index d546dc1b5..7c6f435b0 100644 --- a/plugins/ai-slop/.claude-plugin/plugin.json +++ b/plugins/ai-slop/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "ai-slop", - "version": "0.3.7", + "version": "0.3.8", "description": "Detects and removes AI-writing tells (slop) in checked-in markdown prose: em dashes, emoji formatting, AI vocabulary, negative parallelisms, chatbot phrases, filler, stacked hedging, citation artifacts, and the rest of a catalog distilled from Wikipedia's Signs of AI writing. Read-only audit by default with a deterministic detector plus a judgment rubric; an explicit fix action rewrites findings behind a semantic-diff guard. Findings conform to the detector-findings convention so the review fanout fix relay can consume them.", "author": { "name": "Melodic Software", diff --git a/plugins/ai-slop/CHANGELOG.md b/plugins/ai-slop/CHANGELOG.md index cfd78e4d1..d99231567 100644 --- a/plugins/ai-slop/CHANGELOG.md +++ b/plugins/ai-slop/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## [0.3.8] + +- **Directory targets silently fell back to an untracked-inclusive filesystem + walk when git and the shell spelled the same checkout differently.** + `detect.sh` built its expansion prefix from `git rev-parse --show-toplevel` + and its filter from `pwd`. On Git Bash those disagree (`D:/repo` vs + `/d/repo`), so no prefixed candidate survived the filter, `grep` exited + non-zero, and `|| find` ran in place of the tracked-files listing it was + meant to back up. The walk returns untracked and ignored markdown, so a + directory target audited files the checkout does not track and said + nothing about it. Expansion now runs `git ls-files` with `-C `, + which is already restricted to that directory's subtree and answers in + paths relative to it. The caller's own spelling of the directory is the + only anchor. The branch is chosen up front from `--is-inside-work-tree` + rather than from an empty pipeline, so a filesystem walk is only ever + the answer for a directory genuinely outside a checkout; inside one, a + listing that fails reports on stderr instead of degrading into a + different set of files. A Windows drive-root target such as `C:/` keeps + its trailing slash: stripping it produced `C:`, which Windows treats as + drive-relative (the cwd on that drive), so `git -C` / `find` can scan + the wrong tree or nothing. Ordinary directory targets still lose one + trailing slash. + ## [0.3.7] - **A branch name beginning with a YAML indicator silently dropped every finding it emitted.** diff --git a/plugins/ai-slop/skills/audit/scripts/detect.sh b/plugins/ai-slop/skills/audit/scripts/detect.sh index 275ee2cbb..4a16cc6e3 100755 --- a/plugins/ai-slop/skills/audit/scripts/detect.sh +++ b/plugins/ai-slop/skills/audit/scripts/detect.sh @@ -259,17 +259,69 @@ fi # Directory targets expand to the markdown beneath them (tracked files when the # directory is inside a git checkout, a filesystem walk otherwise). Without # this a directory fails the scan loop's -f test and is skipped silently. +# +# ONE anchor, the caller's own spelling of the directory. A directory has +# several spellings on Git Bash: git answers `C:/Users/...` for the same +# checkout a shell reaches as `/tmp/...`. The earlier expansion built its +# prefix from `git rev-parse --show-toplevel` and its filter from `pwd`, so on +# any host where those disagree no candidate survived the filter and the walk +# below silently replaced the tracked-files listing it was meant to back up. +# Running `ls-files` with `-C ` needs neither: it is already +# restricted to that directory's subtree and answers in paths relative to it, +# so `` is the only anchor and cannot disagree with itself. +# +# The branch is chosen up front from `--is-inside-work-tree`, never from an +# empty pipeline, so a walk is only ever the answer for a directory that is +# genuinely outside a checkout. Inside one, a listing that fails says so on +# stderr rather than degrading into a different set of files. +# +# Strip one trailing slash, except when that would turn a Windows drive root +# (`C:/`) into a drive-relative path (`C:`). Windows then treats the target as +# "cwd on that drive", so git -C / find can scan the wrong tree or nothing. +# Unix root `/` is the same class: stripping leaves empty, so the original +# spelling is kept. `C:\` is unchanged because this strip only removes `/`. +normalize_dir_target() { + local dir="${1%/}" + if [[ -z "$dir" || "$dir" == [A-Za-z]: ]]; then + printf '%s\n' "$1" + return 0 + fi + printf '%s\n' "$dir" +} + +expand_dir_target() { + local dir inside listing status + dir="$(normalize_dir_target "$1")" + + inside="$(git -C "$dir" rev-parse --is-inside-work-tree 2>/dev/null || true)" + if [[ "$inside" != "true" ]]; then + find "$dir" -name '*.md' -type f 2>/dev/null + return 0 + fi + + listing="$(git -C "$dir" ls-files '*.md')" + status=$? + if [[ "$status" -ne 0 ]]; then + echo "detect.sh: git ls-files failed under $dir (exit $status); that directory expanded to nothing" >&2 + return 0 + fi + + while IFS= read -r rel; do + [[ -n "$rel" ]] || continue + if [[ "$dir" == */ || "$dir" == *\\ ]]; then + printf '%s%s\n' "$dir" "$rel" + else + printf '%s/%s\n' "$dir" "$rel" + fi + done <<<"$listing" +} + EXPANDED=() for t in ${TARGETS[@]+"${TARGETS[@]}"}; do if [[ -d "$t" ]]; then while IFS= read -r line; do [[ -n "$line" ]] && EXPANDED+=("$line") - done < <( - git -C "$t" ls-files --full-name '*.md' 2>/dev/null | - sed "s|^|$(git -C "$t" rev-parse --show-toplevel 2>/dev/null)/|" | - grep -F "$(cd "$t" && pwd)/" || - find "$t" -name '*.md' -type f 2>/dev/null - ) + done < <(expand_dir_target "$t") else EXPANDED+=("$t") fi diff --git a/plugins/ai-slop/skills/audit/scripts/detect.test.sh b/plugins/ai-slop/skills/audit/scripts/detect.test.sh index e8293652c..512bbe219 100755 --- a/plugins/ai-slop/skills/audit/scripts/detect.test.sh +++ b/plugins/ai-slop/skills/audit/scripts/detect.test.sh @@ -59,6 +59,9 @@ assert_not_contains() { *) pass "$1" ;; esac } +assert_eq() { + if [[ "$2" == "$3" ]]; then pass "$1"; else fail "$1" "$3" "$2"; fi +} EM=$'\xe2\x80\x94' CHECKMARK=$'\xe2\x9c\x85' @@ -404,6 +407,55 @@ out="$(bash "$DETECT" "$GITDIR/docs" 2>&1)" assert_contains "dir target in git repo: tracked file scanned via ls-files" "$out" "tracked.md" assert_contains "dir target in git repo: only the tracked file counts" "$out" "1 files scanned" +# Path agreement. One directory has several SPELLINGS on Git Bash: git answers +# the Windows form of the same checkout a shell reaches as "/tmp/...". An +# expansion that derives its prefix from one source and its filter from another +# drops every candidate wherever the two disagree, and the walk that backs it up +# returns untracked markdown in place of the tracked listing. The +# assertion is that the SPELLING of the target cannot change the answer. On a +# host where the two forms already agree these two cases restate the one above, +# which is the point: the invariant holds everywhere, and it is only observable +# here. +GITSPELL="$(git -C "$GITDIR/docs" rev-parse --show-toplevel)/docs" +PWDSPELL="$(cd "$GITDIR/docs" && pwd)" + +out="$(bash "$DETECT" "$GITSPELL" 2>&1)" +assert_contains "dir target, git spelling: only the tracked file counts" "$out" "1 files scanned" + +out="$(bash "$DETECT" "$PWDSPELL" 2>&1)" +assert_contains "dir target, shell spelling: only the tracked file counts" "$out" "1 files scanned" + +# A trailing slash is the same directory and must expand identically; the +# expansion builds paths by concatenation, so an unnormalized target would emit +# a doubled separator and scan nothing. +out="$(bash "$DETECT" "$GITDIR/docs/" 2>&1)" +assert_contains "dir target with a trailing slash: only the tracked file counts" "$out" "1 files scanned" + +# Drive-root slash preservation is a string contract, not a host contract: the +# suite does not need Windows. Source the production helper so this case cannot +# drift from the function expand_dir_target actually calls. +# shellcheck disable=SC1090 +source <(sed -n '/^normalize_dir_target()/,/^}/p' "$DETECT") +assert_eq "ordinary trailing slash is stripped" "$(normalize_dir_target "docs/")" "docs" +assert_eq "nested trailing slash is stripped" "$(normalize_dir_target "C:/tmp/")" "C:/tmp" +assert_eq "unix root keeps its slash" "$(normalize_dir_target "/")" "/" +assert_eq "windows drive root keeps its slash" "$(normalize_dir_target "C:/")" "C:/" +assert_eq "lowercase windows drive root keeps its slash" "$(normalize_dir_target "d:/")" "d:/" +assert_eq "windows drive-root backslash is unchanged" "$(normalize_dir_target "C:\\")" "C:\\" +assert_eq "already-drive-relative spelling is left alone" "$(normalize_dir_target "C:")" "C:" +assert_eq "ordinary path without a slash is unchanged" "$(normalize_dir_target "docs")" "docs" + +# The walk is reserved for a directory genuinely outside a checkout. A directory +# INSIDE one that holds only untracked markdown expands to nothing rather than +# falling through to a filesystem walk, which is what the documented +# tracked-files-only contract means. +mkdir -p "$GITDIR/untrackedonly" +cat >"$GITDIR/untrackedonly/loose.md" <&1)" +assert_contains "dir target in git repo, no tracked markdown: expands to nothing" "$out" "0 files scanned" + # --- Excerpt truncation at the byte boundary -------------------------------------- # 79 ASCII bytes then an em dash: a byte cut at 80 would keep only the first