diff --git a/detection/coauthor/coauthor.go b/detection/coauthor/coauthor.go index 08ad342..5d97b58 100644 --- a/detection/coauthor/coauthor.go +++ b/detection/coauthor/coauthor.go @@ -14,7 +14,7 @@ var knownCoAuthorEmails = map[string]string{ "noreply@aider.chat": "Aider", } -var coAuthorPattern = regexp.MustCompile(`(?im)^co-authored-by:\s*[^<]*<([^>]+)>`) +var coAuthorPattern = regexp.MustCompile(`(?im)^co-authored-by:\s*([^<]*)<([^>]+)>`) type Detector struct{} @@ -30,11 +30,27 @@ func (d *Detector) Detect(input detection.Input) []detection.Finding { seen := map[string]bool{} for _, match := range matches { - email := strings.ToLower(strings.TrimSpace(match[1])) + namePart := strings.TrimSpace(match[1]) + email := strings.ToLower(strings.TrimSpace(match[2])) if name, ok := knownCoAuthorEmails[email]; ok && !seen[name] { + model := "" + if name == "Aider" && strings.Contains(namePart, "(") { + start := strings.Index(namePart, "(") + end := strings.Index(namePart, ")") + if end > start { + model = namePart[start+1 : end] + } + } else if name == "Claude Code" { + model = namePart + if model == "Claude" { + model = "" + } + } + findings = append(findings, detection.Finding{ Detector: d.Name(), Tool: name, + Model: model, Confidence: detection.ConfidenceHigh, Detail: fmt.Sprintf("Co-Authored-By trailer with email %s", email), }) diff --git a/detection/coauthor/coauthor_test.go b/detection/coauthor/coauthor_test.go index 908801b..5b42277 100644 --- a/detection/coauthor/coauthor_test.go +++ b/detection/coauthor/coauthor_test.go @@ -9,69 +9,82 @@ import ( func TestDetect(t *testing.T) { d := &Detector{} tests := []struct { - name string - message string - wantTools []string + name string + message string + wantTools []string + wantModels []string }{ { - name: "Claude trailer with Opus model", - message: "fix: update handler\n\nCo-Authored-By: Claude Opus 4 ", - wantTools: []string{"Claude Code"}, + name: "Claude trailer with Opus model", + message: "fix: update handler\n\nCo-Authored-By: Claude Opus 4 ", + wantTools: []string{"Claude Code"}, + wantModels: []string{"Claude Opus 4"}, }, { - name: "Claude trailer with Sonnet model", - message: "fix: update handler\n\nCo-Authored-By: Claude Sonnet 4 ", - wantTools: []string{"Claude Code"}, + name: "Claude trailer with Sonnet model", + message: "fix: update handler\n\nCo-Authored-By: Claude Sonnet 4 ", + wantTools: []string{"Claude Code"}, + wantModels: []string{"Claude Sonnet 4"}, }, { - name: "Cursor trailer", - message: "refactor: extract method\n\nCo-Authored-By: Cursor ", - wantTools: []string{"Cursor"}, + name: "Cursor trailer", + message: "refactor: extract method\n\nCo-Authored-By: Cursor ", + wantTools: []string{"Cursor"}, + wantModels: []string{""}, }, { - name: "Aider trailer with model name", - message: "feat: add endpoint\n\nCo-Authored-By: aider (gpt-4o) ", - wantTools: []string{"Aider"}, + name: "Aider trailer with model name", + message: "feat: add endpoint\n\nCo-Authored-By: aider (gpt-4o) ", + wantTools: []string{"Aider"}, + wantModels: []string{"gpt-4o"}, }, { - name: "Aider trailer with different model", - message: "feat: add endpoint\n\nCo-Authored-By: aider (claude-3.5-sonnet) ", - wantTools: []string{"Aider"}, + name: "Aider trailer with different model", + message: "feat: add endpoint\n\nCo-Authored-By: aider (claude-3.5-sonnet) ", + wantTools: []string{"Aider"}, + wantModels: []string{"claude-3.5-sonnet"}, }, { - name: "multiple trailers with Claude and human", - message: "fix: bug\n\nCo-Authored-By: Claude Opus 4 \nCo-Authored-By: Alice ", - wantTools: []string{"Claude Code"}, + name: "multiple trailers with Claude and human", + message: "fix: bug\n\nCo-Authored-By: Claude Opus 4 \nCo-Authored-By: Alice ", + wantTools: []string{"Claude Code"}, + wantModels: []string{"Claude Opus 4"}, }, { - name: "multiple AI trailers", - message: "fix: bug\n\nCo-Authored-By: Claude Opus 4 \nCo-Authored-By: aider (gpt-4o) ", - wantTools: []string{"Claude Code", "Aider"}, + name: "multiple AI trailers", + message: "fix: bug\n\nCo-Authored-By: Claude Opus 4 \nCo-Authored-By: aider (gpt-4o) ", + wantTools: []string{"Claude Code", "Aider"}, + wantModels: []string{"Claude Opus 4", "gpt-4o"}, }, { - name: "case variation", - message: "fix: thing\n\nco-authored-by: Claude ", - wantTools: []string{"Claude Code"}, + name: "case variation", + message: "fix: thing\n\nco-authored-by: Claude ", + wantTools: []string{"Claude Code"}, + wantModels: []string{""}, }, { - name: "CO-AUTHORED-BY uppercase", - message: "fix: thing\n\nCO-AUTHORED-BY: Claude ", - wantTools: []string{"Claude Code"}, + name: "CO-AUTHORED-BY uppercase", + message: "fix: thing\n\nCO-AUTHORED-BY: Claude ", + wantTools: []string{"Claude Code"}, + wantModels: []string{""}, }, { - name: "no trailers", - message: "just a normal commit message", - wantTools: nil, + name: "no trailers", + message: "just a normal commit message", + wantTools: nil, + wantModels: nil, }, { - name: "human co-author only", - message: "pair programming\n\nCo-Authored-By: Bob ", - wantTools: nil, + name: "human co-author only", + message: "pair programming\n\nCo-Authored-By: Bob ", + wantTools: nil, + wantModels: nil, }, { - name: "empty message", - message: "", - wantTools: nil, + name: "empty message", + message: "", + wantTools: nil, + wantModels: nil, }, } @@ -79,8 +92,10 @@ func TestDetect(t *testing.T) { t.Run(tt.name, func(t *testing.T) { findings := d.Detect(detection.Input{CommitMessage: tt.message}) gotTools := make([]string, len(findings)) + gotModels := make([]string, len(findings)) for i, f := range findings { gotTools[i] = f.Tool + gotModels[i] = f.Model if f.Confidence != detection.ConfidenceHigh { t.Errorf("confidence = %d, want %d", f.Confidence, detection.ConfidenceHigh) } @@ -91,6 +106,7 @@ func TestDetect(t *testing.T) { if len(gotTools) == 0 { gotTools = nil + gotModels = nil } if len(gotTools) != len(tt.wantTools) { @@ -102,6 +118,10 @@ func TestDetect(t *testing.T) { t.Errorf("tools = %v, want %v", gotTools, tt.wantTools) return } + if gotModels[i] != tt.wantModels[i] { + t.Errorf("models = %q, want %q", gotModels, tt.wantModels) + return + } } }) } diff --git a/detection/detection.go b/detection/detection.go index b38185b..2f43589 100644 --- a/detection/detection.go +++ b/detection/detection.go @@ -30,6 +30,8 @@ func (c *Confidence) Increment() { type Finding struct { Detector string `json:"detector"` Tool string `json:"tool"` + Model string `json:"model,omitempty"` + Version string `json:"version,omitempty"` Confidence Confidence `json:"confidence"` Detail string `json:"detail"` } diff --git a/detection/gitnotes/gitnotes.go b/detection/gitnotes/gitnotes.go index 5fc8e92..5b6e8f9 100644 --- a/detection/gitnotes/gitnotes.go +++ b/detection/gitnotes/gitnotes.go @@ -82,6 +82,7 @@ func (d *Detector) Detect(input detection.Input) []detection.Finding { findings = append(findings, detection.Finding{ Detector: d.Name(), Tool: tool, + Model: prompt.AgentID.Model, Confidence: detection.ConfidenceHigh, Detail: detail, }) diff --git a/output/output.go b/output/output.go index 06a97e4..6e74b6b 100644 --- a/output/output.go +++ b/output/output.go @@ -42,7 +42,7 @@ func FormatText(w io.Writer, report scan.Report) error { } fmt.Fprintf(w, "Commit %s\n", cr.Hash[:12]) for _, f := range cr.Findings { - fmt.Fprintf(w, " [%s] %s (%s): %s\n", f.Confidence, f.Tool, f.Detector, f.Detail) + fmt.Fprintf(w, " [%s] %s (%s): %s\n", f.Confidence, formatTool(f), f.Detector, f.Detail) } } @@ -58,7 +58,7 @@ func FormatTextFindings(w io.Writer, findings []detection.Finding) error { fmt.Fprintf(w, "Found %d AI signal(s):\n", len(findings)) for _, f := range findings { - fmt.Fprintf(w, " [%s] %s (%s): %s\n", f.Confidence, f.Tool, f.Detector, f.Detail) + fmt.Fprintf(w, " [%s] %s (%s): %s\n", f.Confidence, formatTool(f), f.Detector, f.Detail) } return nil } @@ -72,6 +72,20 @@ func FormatJSONFindings(w io.Writer, findings []detection.Finding) error { }{Findings: findings}) } +func formatTool(f detection.Finding) string { + var extras []string + if f.Model != "" { + extras = append(extras, f.Model) + } + if f.Version != "" { + extras = append(extras, "v"+f.Version) + } + if len(extras) > 0 { + return fmt.Sprintf("%s [%s]", f.Tool, strings.Join(extras, " ")) + } + return f.Tool +} + func sortedKeys(m map[string]int) []string { keys := make([]string, 0, len(m)) for k := range m {