Skip to content

Commit e3d5eed

Browse files
feat(contract): test_count_baseline post-commit guard (#1584) (#1619)
* feat(contract): add test_count_baseline post-commit guard Closes #1584. Defense-in-depth alongside test_diff (#1583) + llm_judge (#1582). Compares COMMITTED tree counts of test declarations: HEAD vs BaseRef (default HEAD~1). Catches deletions slipping past diff inspection — file moves, force-pushes, multi-commit sequences. Language-agnostic: shares TestFilePattern + TestFuncPattern with test_diff. wave.yaml documents the Go defaults explicitly so non-Go projects know the knobs. Tests cover: no-change, addition, deletion, file-move, tolerance config, no-base-ref / no-git silent pass, Python config. * fix(manifest): declare test_file_pattern + test_func_pattern on Project Without these fields the wave.yaml entries from the same PR fail YAML unmarshal in TestLoadWaveYAML_PersonaPermissions. Mirrors the contract fields added in internal/contract/contract.go.
1 parent a148614 commit e3d5eed

6 files changed

Lines changed: 268 additions & 1 deletion

File tree

internal/contract/contract.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@ type ContractConfig struct {
6363
TestFilePattern []string `json:"test_file_pattern,omitempty" yaml:"test_file_pattern,omitempty"` // Pathspecs (e.g. ["*_test.go"], ["**/test_*.py"], ["**/*.test.ts"])
6464
TestFuncPattern string `json:"test_func_pattern,omitempty" yaml:"test_func_pattern,omitempty"` // Regex matching one test declaration per line
6565

66+
// test_count_baseline contract fields — post-commit defense-in-depth alongside test_diff.
67+
BaseRef string `json:"base_ref,omitempty" yaml:"base_ref,omitempty"` // Git ref to compare HEAD against (default HEAD~1)
68+
6669
// event_contains contract fields — validated by executor (needs event store access)
6770
Events []EventPattern `json:"events,omitempty" yaml:"events,omitempty"` // Expected event patterns to match against the step's event log
6871

@@ -130,6 +133,8 @@ func NewValidator(cfg ContractConfig) ContractValidator {
130133
return &sourceDiffValidator{}
131134
case "test_diff":
132135
return &testDiffValidator{}
136+
case "test_count_baseline":
137+
return &testCountBaselineValidator{}
133138
case "agent_review":
134139
// agent_review requires an adapter runner — NewValidator returns nil.
135140
// The executor uses ValidateWithRunner() instead for this type.
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
package contract
2+
3+
import (
4+
"bytes"
5+
"fmt"
6+
"os/exec"
7+
"path"
8+
"regexp"
9+
"strings"
10+
)
11+
12+
// testCountBaselineValidator is the post-commit "last line of defense"
13+
// against test deletions. Where test_diff inspects the working-tree
14+
// diff, this one compares COMMITTED tree counts: HEAD vs BaseRef
15+
// (default HEAD~1). Catches deletions that slipped past diff inspection
16+
// (file moves, force-pushes within session, multi-commit sequences).
17+
//
18+
// Language-agnostic: shares TestFilePattern + TestFuncPattern with
19+
// test_diff so a project configures patterns once.
20+
//
21+
// Operation:
22+
// 1. `git ls-tree -r --name-only <ref>` → filter by TestFilePattern globs
23+
// 2. `git show <ref>:<path>` per file → count regex matches
24+
// 3. Fail if (base - head) > MaxTestDeletions
25+
type testCountBaselineValidator struct{}
26+
27+
func (v *testCountBaselineValidator) Validate(cfg ContractConfig, workspacePath string) error {
28+
baseRef := cfg.BaseRef
29+
if baseRef == "" {
30+
baseRef = "HEAD~1"
31+
}
32+
max := cfg.MaxTestDeletions
33+
34+
globs := cfg.TestFilePattern
35+
if len(globs) == 0 {
36+
globs = []string{defaultTestFilePathspec}
37+
}
38+
patternStr := cfg.TestFuncPattern
39+
if patternStr == "" {
40+
patternStr = defaultTestFuncPattern
41+
}
42+
re, err := regexp.Compile(patternStr)
43+
if err != nil {
44+
return fmt.Errorf("test_count_baseline: invalid TestFuncPattern %q: %w", patternStr, err)
45+
}
46+
47+
headCount, err := countTestFuncsAtRef(workspacePath, "HEAD", globs, re)
48+
if err != nil {
49+
return nil
50+
}
51+
baseCount, err := countTestFuncsAtRef(workspacePath, baseRef, globs, re)
52+
if err != nil {
53+
return nil
54+
}
55+
56+
net := baseCount - headCount
57+
if net > max {
58+
return fmt.Errorf("test_count_baseline: HEAD has %d test declarations vs %s=%d (net deletion %d, max allowed %d); persona must replace removed tests, not net-delete them across commits",
59+
headCount, baseRef, baseCount, net, max)
60+
}
61+
return nil
62+
}
63+
64+
func countTestFuncsAtRef(dir, ref string, globs []string, re *regexp.Regexp) (int, error) {
65+
out, err := runGitCmd(dir, "ls-tree", "-r", "--name-only", ref)
66+
if err != nil {
67+
return 0, err
68+
}
69+
total := 0
70+
for _, p := range strings.Split(strings.TrimSpace(out), "\n") {
71+
if p == "" || !matchesAnyGlob(p, globs) {
72+
continue
73+
}
74+
blob, err := runGitCmd(dir, "show", ref+":"+p)
75+
if err != nil {
76+
continue
77+
}
78+
total += len(re.FindAllString(blob, -1))
79+
}
80+
return total, nil
81+
}
82+
83+
// matchesAnyGlob checks both the full path and the basename against each
84+
// pattern. path.Match doesn't grok `**`, so basename match is a
85+
// pragmatic fallback covering `*_test.go`, `test_*.py`, `*.test.ts`, etc.
86+
func matchesAnyGlob(p string, globs []string) bool {
87+
base := path.Base(p)
88+
for _, g := range globs {
89+
if ok, _ := path.Match(g, p); ok {
90+
return true
91+
}
92+
if ok, _ := path.Match(g, base); ok {
93+
return true
94+
}
95+
}
96+
return false
97+
}
98+
99+
func runGitCmd(dir string, args ...string) (string, error) {
100+
cmd := exec.Command("git", args...)
101+
cmd.Dir = dir
102+
var out bytes.Buffer
103+
cmd.Stdout = &out
104+
cmd.Stderr = &bytes.Buffer{}
105+
if err := cmd.Run(); err != nil {
106+
return "", err
107+
}
108+
return out.String(), nil
109+
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
package contract
2+
3+
import (
4+
"testing"
5+
)
6+
7+
// initRepoTwoCommits seeds a repo with one *_test.go (2 tests), commits,
8+
// then optionally mutates and recommits. Returns the dir.
9+
func initRepoTwoCommits(t *testing.T, mutate func(dir string)) string {
10+
t.Helper()
11+
dir := t.TempDir()
12+
runGit(t, dir, "init", "-q")
13+
writeFile(t, dir, "x_test.go", `package x
14+
15+
import "testing"
16+
17+
func TestAlpha(t *testing.T) { _ = t }
18+
func TestBeta(t *testing.T) { _ = t }
19+
`)
20+
runGit(t, dir, "add", "x_test.go")
21+
runGit(t, dir, "commit", "-q", "-m", "base")
22+
if mutate != nil {
23+
mutate(dir)
24+
runGit(t, dir, "add", "-A")
25+
runGit(t, dir, "commit", "-q", "-m", "head")
26+
}
27+
return dir
28+
}
29+
30+
func TestTestCountBaseline_NoChange_Passes(t *testing.T) {
31+
dir := initRepoTwoCommits(t, func(d string) {
32+
writeFile(t, d, "y.go", `package x
33+
`)
34+
})
35+
v := &testCountBaselineValidator{}
36+
if err := v.Validate(ContractConfig{Type: "test_count_baseline"}, dir); err != nil {
37+
t.Fatalf("expected pass, got: %v", err)
38+
}
39+
}
40+
41+
func TestTestCountBaseline_Addition_Passes(t *testing.T) {
42+
dir := initRepoTwoCommits(t, func(d string) {
43+
writeFile(t, d, "x_test.go", `package x
44+
45+
import "testing"
46+
47+
func TestAlpha(t *testing.T) { _ = t }
48+
func TestBeta(t *testing.T) { _ = t }
49+
func TestGamma(t *testing.T) { _ = t }
50+
`)
51+
})
52+
v := &testCountBaselineValidator{}
53+
if err := v.Validate(ContractConfig{Type: "test_count_baseline"}, dir); err != nil {
54+
t.Fatalf("expected pass on addition, got: %v", err)
55+
}
56+
}
57+
58+
func TestTestCountBaseline_Deletion_Fails(t *testing.T) {
59+
dir := initRepoTwoCommits(t, func(d string) {
60+
writeFile(t, d, "x_test.go", `package x
61+
62+
import "testing"
63+
64+
func TestAlpha(t *testing.T) { _ = t }
65+
`)
66+
})
67+
v := &testCountBaselineValidator{}
68+
if err := v.Validate(ContractConfig{Type: "test_count_baseline"}, dir); err == nil {
69+
t.Fatal("expected error on deletion, got nil")
70+
}
71+
}
72+
73+
func TestTestCountBaseline_FileMove_NetsZero(t *testing.T) {
74+
dir := initRepoTwoCommits(t, func(d string) {
75+
// Delete x_test.go, recreate same tests under different filename.
76+
runGit(t, d, "rm", "-q", "x_test.go")
77+
writeFile(t, d, "renamed_test.go", `package x
78+
79+
import "testing"
80+
81+
func TestAlpha(t *testing.T) { _ = t }
82+
func TestBeta(t *testing.T) { _ = t }
83+
`)
84+
})
85+
v := &testCountBaselineValidator{}
86+
if err := v.Validate(ContractConfig{Type: "test_count_baseline"}, dir); err != nil {
87+
t.Fatalf("expected pass on file move, got: %v", err)
88+
}
89+
}
90+
91+
func TestTestCountBaseline_HigherTolerance_Passes(t *testing.T) {
92+
dir := initRepoTwoCommits(t, func(d string) {
93+
writeFile(t, d, "x_test.go", `package x
94+
`)
95+
})
96+
v := &testCountBaselineValidator{}
97+
cfg := ContractConfig{Type: "test_count_baseline", MaxTestDeletions: 2}
98+
if err := v.Validate(cfg, dir); err != nil {
99+
t.Fatalf("expected pass with tolerance=2, got: %v", err)
100+
}
101+
}
102+
103+
func TestTestCountBaseline_NoBaseRef_PassesSilently(t *testing.T) {
104+
// Single-commit repo — HEAD~1 doesn't resolve.
105+
dir := initRepoTwoCommits(t, nil)
106+
v := &testCountBaselineValidator{}
107+
if err := v.Validate(ContractConfig{Type: "test_count_baseline"}, dir); err != nil {
108+
t.Fatalf("expected silent pass without base ref, got: %v", err)
109+
}
110+
}
111+
112+
func TestTestCountBaseline_PythonConfig_DetectsDeletion(t *testing.T) {
113+
dir := t.TempDir()
114+
runGit(t, dir, "init", "-q")
115+
writeFile(t, dir, "test_things.py", `def test_alpha():
116+
pass
117+
118+
def test_beta():
119+
pass
120+
`)
121+
runGit(t, dir, "add", "-A")
122+
runGit(t, dir, "commit", "-q", "-m", "base")
123+
writeFile(t, dir, "test_things.py", `def test_alpha():
124+
pass
125+
`)
126+
runGit(t, dir, "add", "-A")
127+
runGit(t, dir, "commit", "-q", "-m", "head")
128+
v := &testCountBaselineValidator{}
129+
cfg := ContractConfig{
130+
Type: "test_count_baseline",
131+
TestFilePattern: []string{"test_*.py", "*_test.py"},
132+
TestFuncPattern: `(?m)^[ \t]*def[ \t]+test_\w+`,
133+
}
134+
if err := v.Validate(cfg, dir); err == nil {
135+
t.Fatal("expected error for python deletion, got nil")
136+
}
137+
}
138+
139+
func TestTestCountBaseline_NoGit_PassesSilently(t *testing.T) {
140+
dir := t.TempDir()
141+
v := &testCountBaselineValidator{}
142+
if err := v.Validate(ContractConfig{Type: "test_count_baseline"}, dir); err != nil {
143+
t.Fatalf("expected silent pass without git, got: %v", err)
144+
}
145+
}

internal/contract/test_diff.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ type testDiffValidator struct{}
2626

2727
const (
2828
defaultTestFilePathspec = "*_test.go"
29-
defaultTestFuncPattern = `^[ \t]*func[ \t]+(Test|Example|Benchmark|Fuzz)[A-Za-z0-9_]*\b`
29+
defaultTestFuncPattern = `(?m)^[ \t]*func[ \t]+(Test|Example|Benchmark|Fuzz)[A-Za-z0-9_]*\b`
3030
)
3131

3232
func (v *testDiffValidator) Validate(cfg ContractConfig, workspacePath string) error {

internal/manifest/types.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ type Project struct {
2626
BuildCommand string `yaml:"build_command,omitempty"`
2727
FormatCommand string `yaml:"format_command,omitempty"`
2828
SourceGlob string `yaml:"source_glob,omitempty"`
29+
TestFilePattern []string `yaml:"test_file_pattern,omitempty"` // test_diff / test_count_baseline pathspecs (#1583, #1584)
30+
TestFuncPattern string `yaml:"test_func_pattern,omitempty"` // test_diff / test_count_baseline regex (#1583, #1584)
2931
Skill string `yaml:"skill,omitempty"`
3032
Services map[string]ServiceConfig `yaml:"services,omitempty"`
3133
}

wave.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ project:
1414
lint_command: "go vet ./..."
1515
build_command: "go build ./..."
1616
source_glob: "*.go"
17+
# test_diff / test_count_baseline contracts (#1583, #1584): how to
18+
# spot test declarations in this codebase. Defaults already match
19+
# Go, but make it explicit so non-Go projects know the knobs.
20+
test_file_pattern:
21+
- "*_test.go"
22+
test_func_pattern: '(?m)^[ \t]*func[ \t]+(Test|Example|Benchmark|Fuzz)[A-Za-z0-9_]*\b'
1723
adapters:
1824
claude:
1925
binary: claude

0 commit comments

Comments
 (0)