diff --git a/CHANGELOG.md b/CHANGELOG.md index d0f1c35..b2a9e38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Removed - **`repo-finder`'s Category Preference Order and its `3e` category bonus (10%).** With filtering server-side, every candidate matches the user's filters by construction, so a bonus for matching is a constant added to every row and ranks nothing. Its weight went to signals that discriminate: responsiveness 30 → **35%**, outside-contributor track 20 → **25%**. +### Fixed +- **A `preferences.md` with no `## Filters` block no longer dead-ends the scan.** A file carrying only `## Notes` (or an empty `## Filters` block) compiled to a catch-all and aborted `repo-finder` with `exit 10` and a misleading "`topics: any` with no languages" error the user never wrote — leaving `/contribute` with an empty shortlist. Such a file now means "default filters, plus advisory notes" and falls back to `DEFAULT_PROFILE`. A `## Filters` block with a real typo (`langauges: go`) still fails loudly, so the fallback never swallows a malformed filter. New `prefs_has_filters` helper in `scripts/lib/preferences.sh`; covered by `test_build_queries.sh`. +- **`/repo-finder` overrides now inherit saved axes from a non-canonical `## filters` header.** The override path (`--lang`/`--topic`/`--min-stars`) read the saved profile with an exact, case-sensitive `## Filters` matcher while `preferences.sh` accepts `## filters` and spacing variants — so `/repo-finder --topic cli` on a hand-edited lowercase-header profile silently dropped the saved `languages`/`stars` and searched with defaults. Both readers now use the same detector. Covered by the new `test_repo_finder_override_inherit.sh`. +- **The `## Filters` header is matched fully case-insensitively (`tolower`), in both readers.** Previously only `Filters`/`filters` were recognized; a `## FILTERS` block was silently ignored — and with the notes-only fallback above, that meant it emitted the default profile and dropped the user's filters with no error. `scripts/lib/preferences.sh` and the `/repo-finder` override path now normalize the header the same way. +- **An override that inherits from a malformed saved profile now fails loudly.** The override extraction greps the saved block for exact keys (`languages:`), so a mistyped `langauges: go` — which the normal path rejects with `exit 10` — was silently dropped, letting an override run proceed on a broken profile. When any axis is inherited, the saved profile is now validated with the same compiler the normal path uses (a full `--lang/--topic/--min-stars` override skips the check, since it reads nothing from the saved file). + ## [0.6.4] — 2026-07-13 ## [0.6.3] — 2026-07-12 diff --git a/commands/repo-finder.md b/commands/repo-finder.md index c17efcb..bd8b3c2 100644 --- a/commands/repo-finder.md +++ b/commands/repo-finder.md @@ -111,12 +111,31 @@ if [ -n "$LANGS$TOPICS$MIN_STARS" ]; then # fire the moment this block ends. OVERRIDE=$(mktemp "${TMPDIR:-/tmp}/superhuman-prefs.XXXXXX") || { echo "mktemp failed"; exit 1; } + # If any axis is inherited from the saved profile, that profile must itself be + # valid. A malformed saved line (e.g. `langauges: go`) is caught on the normal + # /repo-finder path but would be silently dropped by the key-specific extraction + # below, letting an override bypass the fail-loud contract. Validate with the + # same compiler the normal path uses — but only when something is actually + # inherited (a full --lang/--topic/--min-stars override never reads it). + if [ -f "$SAVED" ] && { [ -z "$LANGS" ] || [ -z "$TOPICS" ] || [ -z "$MIN_STARS" ]; } \ + && ! "${CLAUDE_PLUGIN_ROOT}/scripts/repo-finder/build_queries.sh" --file "$SAVED" --no-nudge >/dev/null 2>&1; then + echo "Saved ~/.superhuman/preferences.md is malformed; fix it or run /preferences (an override inherits the axes it doesn't name)." + rm -f "$OVERRIDE"; exit 1 + fi + # Inherit each unnamed axis from the saved profile, so --lang rust on a # backend-topics profile still searches backend. Read only the `## Filters` block: # a `## Notes` line that happens to begin `languages:`/`topics:`/`stars:` (copied # prose) must never be mistaken for a hard filter. FILTERS="" - [ -f "$SAVED" ] && FILTERS=$(awk '/^## Filters/{f=1;next} /^## /{f=0} f' "$SAVED") + # Detect the block with the SAME matcher scripts/lib/preferences.sh uses + # (case-insensitive via tolower, spacing-tolerant). A divergent reader silently + # fails to inherit saved axes on a '## filters'/'## FILTERS' file and searches + # with defaults instead — the silent-wrong-result this whole feature guards against. + [ -f "$SAVED" ] && FILTERS=$(awk ' + /^##[ \t]+/ { f = (tolower($0) ~ /^##[ \t]*filters[ \t]*$/); next } + f + ' "$SAVED") if [ -z "$LANGS" ]; then LANGS=$(printf '%s\n' "$FILTERS" | sed -n 's/^[[:space:]]*languages:[[:space:]]*//p' | head -1) fi diff --git a/scripts/lib/preferences.sh b/scripts/lib/preferences.sh index 3910fed..41d82b0 100644 --- a/scripts/lib/preferences.sh +++ b/scripts/lib/preferences.sh @@ -30,7 +30,7 @@ prefs_path() { # block that is not `key: value` is an error, not prose — prose belongs in Notes. _prefs_filter_lines() { awk ' - /^##[ \t]+/ { inblk = ($0 ~ /^##[ \t]*[Ff]ilters[ \t]*$/); next } + /^##[ \t]+/ { inblk = (tolower($0) ~ /^##[ \t]*filters[ \t]*$/); next } !inblk { next } /^[ \t]*$/ { next } /^[ \t]*#/ { next } @@ -48,6 +48,17 @@ _prefs_filter_lines() { ' "$1" } +# True when the file declares a `## Filters` block with at least one content line +# — i.e. `_prefs_filter_lines` would emit something. A file with only `## Notes`, +# or an empty `## Filters` block, emits nothing: that is "use the default +# filters", not a catch-all to reject, so callers fall back to DEFAULT_PROFILE +# rather than aborting. A malformed line is still content (it emits an `ERR` or a +# bad-key row), so `langauges: go` stays a loud parse error — the fallback can +# never silently swallow a typo'd filter. +prefs_has_filters() { + [ -n "$(_prefs_filter_lines "${1:-$(prefs_path)}")" ] +} + # Comma-separated list -> one trimmed entry per line. Whitespace inside an entry # is preserved on purpose; the charset guards below reject it. _prefs_split() { diff --git a/scripts/repo-finder/build_queries.sh b/scripts/repo-finder/build_queries.sh index 747521f..3ff6d58 100755 --- a/scripts/repo-finder/build_queries.sh +++ b/scripts/repo-finder/build_queries.sh @@ -121,7 +121,12 @@ nudge_once() { fi } -if [ ! -f "$PREFS" ]; then +# A file that exists but declares no ## Filters content (only ## Notes, or an +# empty block) means "default filters, plus advisory notes" — not a catch-all to +# reject. Fall back to DEFAULT_PROFILE. A block WITH content still goes to the +# parser below, which fails loud on a typo, so this never swallows a malformed +# filter (see the notes-only + malformed-abort cases in test_build_queries.sh). +if [ ! -f "$PREFS" ] || ! prefs_has_filters "$PREFS"; then nudge_once while IFS= read -r q; do [ -n "$q" ] && emit "$q" diff --git a/tests/scripts/test_build_queries.sh b/tests/scripts/test_build_queries.sh index 20f1adf..1a2a465 100644 --- a/tests/scripts/test_build_queries.sh +++ b/tests/scripts/test_build_queries.sh @@ -227,6 +227,50 @@ bash "$BUILD" --file "$tmpdir/bad.md" --no-nudge >/dev/null 2>&1 rc=$? [ "$rc" = "10" ] || fail "malformed preferences must exit 10, got $rc (never fall back to defaults)" +# --------------------------------------------------------------------------- +# A file with no ## Filters CONTENT is "default filters + advisory notes", NOT a +# catch-all to reject. It must fall back to DEFAULT_PROFILE, never abort with the +# misleading 'topics: any with no languages' error the user never wrote. The +# malformed-abort test above guards the other direction: a Filters block WITH a +# bad line still exits 10, so the fallback can never swallow a real typo. +# --------------------------------------------------------------------------- + +# 12. Notes-only file (no ## Filters block at all) => DEFAULT_PROFILE, exit 0. +write notes_only <<'EOF' +## Notes +Prefer small, focused libraries. I'd rather fix bugs than add features. +EOF +run notes_only > "$tmpdir/notes_only.txt" \ + || fail "notes-only preferences.md aborted the scan instead of using defaults" +diff -u "$GOLDEN" "$tmpdir/notes_only.txt" \ + || fail "notes-only file did not fall back to the default profile" + +# 12a. An empty ## Filters block (header, no filter lines) is also 'nothing +# specified' => DEFAULT_PROFILE, not a catch-all abort. +write empty_filters <<'EOF' +## Filters + +## Notes +just some guidance +EOF +run empty_filters > "$tmpdir/empty_filters.txt" \ + || fail "empty ## Filters block aborted the scan instead of using defaults" +diff -u "$GOLDEN" "$tmpdir/empty_filters.txt" \ + || fail "empty ## Filters block did not fall back to the default profile" + +# 12b. The header is matched case-INSENSITIVELY, not just 'Filters'/'filters'. A +# '## FILTERS' block must be read as a real filter, never silently ignored — +# which, combined with the fallback above, would emit the DEFAULT profile and +# drop the user's filters with no error at all. +write upper_header <<'EOF' +## FILTERS +languages: go +topics: backend +stars: 2000 +EOF +[ "$(run upper_header)" = "language:go topic:backend stars:>2000 archived:false" ] \ + || fail "'## FILTERS' not recognized as a Filters block: got '$(run upper_header)'" + # 11. Determinism: same profile, two runs, byte-identical output. [ "$(run cross3)" = "$(run cross3)" ] || fail "build is not deterministic" diff --git a/tests/scripts/test_repo_finder_override_inherit.sh b/tests/scripts/test_repo_finder_override_inherit.sh new file mode 100755 index 0000000..b4daf3c --- /dev/null +++ b/tests/scripts/test_repo_finder_override_inherit.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# The /repo-finder override path (`--lang`/`--topic`/`--min-stars`) inherits the +# axes it does NOT name from the saved profile. Two contracts: +# +# 1. It reads the saved `## Filters` block with the SAME detector +# scripts/lib/preferences.sh uses (case-insensitive, spacing-tolerant). A +# divergent matcher silently fails to inherit on a '## filters' file and +# searches with defaults instead. +# 2. When it inherits from the saved profile, that profile must be VALID. A +# malformed saved line (`langauges: go`) is caught on the normal path but +# would be silently dropped by the key-specific extraction — an override run +# must not bypass the fail-loud contract. +# +# Network-free, bash 3.2-clean. +set -euo pipefail + +ROOT="${CLAUDE_PLUGIN_ROOT:-$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)}" +export CLAUDE_PLUGIN_ROOT="$ROOT" +CMD="$ROOT/commands/repo-finder.md" +TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT +HOME="$TMP/home"; export HOME +mkdir -p "$HOME/.superhuman" + +fail() { echo "FAIL: $*"; exit 1; } + +# The override block is the 2nd ```bash fence in the command file. +extract_block2() { + awk '/^```bash$/{n++; if(n==2){inb=1; next}} /^```$/{inb=0} inb' "$CMD" +} + +# run_override +# Writes the saved profile, drives the override block with the given axes, and +# prints the block's output (including the compiled $OVERRIDE file on success). +# Returns the block's exit code. +run_override() { + printf '%s' "$1" > "$HOME/.superhuman/preferences.md" + { + printf 'LANGS=%q\nTOPICS=%q\nMIN_STARS=%q\n' "$2" "$3" "$4" + extract_block2 + printf 'echo "---OVERRIDE-FILE---"; cat "$OVERRIDE" 2>/dev/null\n' + } > "$TMP/block.sh" + bash "$TMP/block.sh" 2>&1 +} + +# --- Contract 1: inherit unnamed axes from a NON-canonical (lowercase) header. -- +SAVED_LC='## filters +languages: go +topics: backend +stars: 3000 +' +OUT=$(run_override "$SAVED_LC" "" "cli" "") || fail "override on a valid profile errored: $OUT" +case "$OUT" in *"languages: go"*) ;; *) fail "saved 'languages: go' not inherited from '## filters': +$OUT" ;; esac +case "$OUT" in *"stars: 3000"*) ;; *) fail "saved 'stars: 3000' not inherited (fell back to 2000 default): +$OUT" ;; esac +case "$OUT" in *"topics: cli"*) ;; *) fail "--topic override not applied: +$OUT" ;; esac + +# --- Contract 2: a MALFORMED saved profile must fail loud when inherited. ------- +# `langauges` (typo) is not `languages:`, so the key-specific extraction drops it +# silently; without validation the override would proceed with a broken profile. +SAVED_BAD='## Filters +langauges: go +topics: backend +' +set +e +OUT=$(run_override "$SAVED_BAD" "" "cli" ""); RC=$? +set -e +[ "$RC" -ne 0 ] || fail "an override that inherits from a MALFORMED saved profile must fail loud, got exit 0: +$OUT" +case "$OUT" in *[Mm]alformed*|*"/preferences"*) ;; *) fail "malformed-saved failure lacked an actionable message: +$OUT" ;; esac + +# --- Contract 2, negative: a FULL override never reads the saved profile, so a -- +# malformed saved file must NOT block it (nothing is inherited). --------------- +OUT=$(run_override "$SAVED_BAD" "rust" "cli" "500") \ + || fail "a full --lang/--topic/--min-stars override must not be blocked by a malformed saved profile: +$OUT" +case "$OUT" in *"language:rust topic:cli stars:>500"*) ;; *) fail "full override did not compile as given: +$OUT" ;; esac + +echo "OK test_repo_finder_override_inherit.sh"