Skip to content

Commit 5c8e67a

Browse files
committed
feat: use word boundary matching for negated assertions
Negated assertions (e.g. "Not FAIL") now use \b word boundary regex instead of substring matching. This prevents false positives where "0 failed" would incorrectly trigger "Not FAIL". Positive assertions remain substring-based. Also add dev-skillshare Makefile target and narrow .gitignore docs/ exclusion.
1 parent 63f484b commit 5c8e67a

4 files changed

Lines changed: 45 additions & 11 deletions

File tree

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,4 @@ coverage.out
2020
.claude
2121
.feature-radar
2222
.mdproof/
23-
docs/
23+
docs/superpowers/

Makefile

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.PHONY: help build run test test-unit test-docker lint fmt fmt-check check devc devc-up devc-down devc-restart devc-reset devc-status install clean
1+
.PHONY: help build run test test-unit test-docker lint fmt fmt-check check devc devc-up devc-down devc-restart devc-reset devc-status install dev-skillshare clean
22

33
help:
44
@echo "Common tasks:"
@@ -17,6 +17,7 @@ help:
1717
@echo " make devc-reset # full reset (remove volumes)"
1818
@echo " make devc-status # show devcontainer status"
1919
@echo " make clean # remove build artifacts"
20+
@echo " make dev-skillshare # cross-compile Linux binary to ../skillshare/bin/"
2021

2122
build:
2223
mkdir -p bin && go build -o bin/mdproof ./cmd/mdproof
@@ -66,5 +67,10 @@ devc-status:
6667
install:
6768
go install ./cmd/mdproof
6869

70+
dev-skillshare:
71+
@mkdir -p ../skillshare/bin
72+
GOOS=linux GOARCH=$$(uname -m | sed 's/x86_64/amd64/' | sed 's/aarch64/arm64/') CGO_ENABLED=0 go build -o ../skillshare/bin/mdproof ./cmd/mdproof
73+
@echo "Installed Linux binary → ../skillshare/bin/mdproof"
74+
6975
clean:
7076
rm -rf bin coverage.out

internal/assertion/assertion.go

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ func dispatchAssertion(pat, combined, combinedLower, stdout string, exitCode int
8484
}
8585

8686
// checkSubstring performs case-insensitive substring matching with negation.
87+
// Negated assertions use word boundary matching (\b) to avoid false positives
88+
// (e.g. "Not FAIL" should not trigger on "0 failed").
8789
func checkSubstring(pat, combinedLower string) core.AssertionResult {
8890
r := core.AssertionResult{Pattern: pat, Type: core.AssertSubstring}
8991

@@ -97,21 +99,32 @@ func checkSubstring(pat, combinedLower string) core.AssertionResult {
9799
}
98100

99101
needle := strings.ToLower(inner)
100-
found := strings.Contains(combinedLower, needle)
101102
if r.Negated {
103+
// Use word boundary matching for negated assertions.
104+
found := negatedContains(combinedLower, needle)
102105
r.Matched = !found
103106
if !r.Matched {
104-
// Show the line that triggered the match to help debug false positives.
105107
r.Detail = fmt.Sprintf("negated pattern %q was found in: %s",
106108
inner, findMatchLine(combinedLower, needle))
107109
}
108110
} else {
109-
r.Matched = found
111+
r.Matched = strings.Contains(combinedLower, needle)
110112
}
111113

112114
return r
113115
}
114116

117+
// negatedContains checks if inner appears as a whole word (word boundary match)
118+
// in text, case-insensitively. Falls back to substring if regex compilation fails.
119+
func negatedContains(text, inner string) bool {
120+
pattern := `(?i)\b` + regexp.QuoteMeta(inner) + `\b`
121+
re, err := regexp.Compile(pattern)
122+
if err != nil {
123+
return strings.Contains(strings.ToLower(text), strings.ToLower(inner))
124+
}
125+
return re.MatchString(text)
126+
}
127+
115128
// findMatchLine returns the trimmed line containing the first occurrence of needle.
116129
func findMatchLine(text, needle string) string {
117130
idx := strings.Index(text, needle)

internal/assertion/assertion_test.go

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,15 +37,30 @@ func TestMatchAssertions_NegatedFails(t *testing.T) {
3737
}
3838
}
3939

40-
func TestMatchAssertions_NegatedFails_FalsePositive(t *testing.T) {
41-
// "Not FAIL" should trigger on "0 failed" (substring match).
42-
// The Detail should help users see WHY it triggered.
40+
func TestMatchAssertions_NegatedWordBoundary(t *testing.T) {
41+
// "Not FAIL" should NOT trigger on "0 failed" — word boundary prevents it.
4342
results := MatchAssertions("Uninstall complete: 2 removed, 0 failed (0.0s)", []string{"Not FAIL"})
43+
if !results[0].Matched {
44+
t.Fatal("Not FAIL should pass because 'failed' is not the word 'FAIL'")
45+
}
46+
}
47+
48+
func TestMatchAssertions_NegatedWordBoundary_StillCatches(t *testing.T) {
49+
// "Not FAIL" SHOULD trigger on "FAIL: something" — exact word match.
50+
results := MatchAssertions("FAIL: config has skills", []string{"Not FAIL"})
4451
if results[0].Matched {
45-
t.Fatal("Not FAIL should fail because 'failed' contains 'fail'")
52+
t.Fatal("Not FAIL should fail because 'FAIL' appears as a word")
4653
}
47-
if !strings.Contains(results[0].Detail, "0 failed") {
48-
t.Fatalf("detail should show the triggering line, got: %s", results[0].Detail)
54+
if results[0].Detail == "" {
55+
t.Fatal("should include detail on failure")
56+
}
57+
}
58+
59+
func TestMatchAssertions_NegatedWordBoundary_Standalone(t *testing.T) {
60+
// "Not FAIL" triggers on standalone "FAIL" at end of line.
61+
results := MatchAssertions("test result: FAIL", []string{"Not FAIL"})
62+
if results[0].Matched {
63+
t.Fatal("Not FAIL should fail on standalone FAIL")
4964
}
5065
}
5166

0 commit comments

Comments
 (0)