Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 20 additions & 1 deletion commands/repo-finder.md
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if [ -z "$LANGS" ]; then
LANGS=$(printf '%s\n' "$FILTERS" | sed -n 's/^[[:space:]]*languages:[[:space:]]*//p' | head -1)
fi
Expand Down
13 changes: 12 additions & 1 deletion scripts/lib/preferences.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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)}")" ]
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# Comma-separated list -> one trimmed entry per line. Whitespace inside an entry
# is preserved on purpose; the charset guards below reject it.
_prefs_split() {
Expand Down
7 changes: 6 additions & 1 deletion scripts/repo-finder/build_queries.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
44 changes: 44 additions & 0 deletions tests/scripts/test_build_queries.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
82 changes: 82 additions & 0 deletions tests/scripts/test_repo_finder_override_inherit.sh
Original file line number Diff line number Diff line change
@@ -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 <saved-content> <LANGS> <TOPICS> <MIN_STARS>
# 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"
Loading