diff --git a/detection/constants.go b/detection/constants.go index 8c3000f..689e1db 100644 --- a/detection/constants.go +++ b/detection/constants.go @@ -59,8 +59,8 @@ var KnownCoAuthorEmails = map[string]string{ "copilot@github.com": "Copilot", } -// CoAuthorPattern Regex to look for Co-Authroed-By trailer with email -var CoAuthorPattern = regexp.MustCompile(`(?im)^co-authored-by:\s*[^<]*<([^>]+)>`) +// CoAuthorPattern Regex to look for Co-Authored-By trailer with name and email +var CoAuthorPattern = regexp.MustCompile(`(?im)^co-authored-by:\s*([^<]*)<([^>]+)>`) // AssistedByPattern Regex to look for Assisted-By trailer with tool name var AssistedByPattern = regexp.MustCompile(`(?im)^assisted-by\s*:\s*([^\r\n]+?)\s*$`) diff --git a/detection/detection.go b/detection/detection.go index 36be94f..bb71690 100644 --- a/detection/detection.go +++ b/detection/detection.go @@ -35,10 +35,25 @@ func (c *Confidence) Increment() { type Finding struct { Detector string `json:"detector"` Tool string `json:"tool"` + Model string `json:"model,omitempty"` Confidence Confidence `json:"confidence"` Detail string `json:"detail"` } +func (f Finding) DisplayTool() string { + tool := strings.TrimSpace(f.Tool) + model := strings.TrimSpace(f.Model) + + switch { + case tool == "": + return model + case model == "": + return tool + default: + return fmt.Sprintf("%s [%s]", tool, model) + } +} + // Detector is the interface that all detection strategies implement. type Detector interface { Name() string diff --git a/detection/detection_test.go b/detection/detection_test.go new file mode 100644 index 0000000..97d5d4c --- /dev/null +++ b/detection/detection_test.go @@ -0,0 +1,50 @@ +package detection + +import "testing" + +func TestFindingDisplayTool(t *testing.T) { + tests := []struct { + name string + finding Finding + want string + }{ + { + name: "tool only", + finding: Finding{ + Tool: "Cursor", + }, + want: "Cursor", + }, + { + name: "tool and model", + finding: Finding{ + Tool: "Claude Code", + Model: "Opus 4", + }, + want: "Claude Code [Opus 4]", + }, + { + name: "model only", + finding: Finding{ + Model: "gpt-4o", + }, + want: "gpt-4o", + }, + { + name: "empty finding", + finding: Finding{ + Tool: " ", + Model: " ", + }, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.finding.DisplayTool(); got != tt.want { + t.Errorf("DisplayTool() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/detection/gitnotes/gitnotes.go b/detection/gitnotes/gitnotes.go index b9d28d3..84a4c76 100644 --- a/detection/gitnotes/gitnotes.go +++ b/detection/gitnotes/gitnotes.go @@ -2,6 +2,7 @@ package gitnotes import ( "fmt" + "sort" "github.com/chaoss/disclosure/detection" ) @@ -10,24 +11,41 @@ type Detector struct{} func (d *Detector) Name() string { return "gitnotes" } +type toolModelPair struct { + tool string + model string +} + func (d *Detector) Detect(input detection.Input) []detection.Finding { parseResult, err := input.GetNotes() if err != nil { return []detection.Finding{} } - seen := map[string]bool{} + seen := map[toolModelPair]bool{} var findings []detection.Finding - for _, prompt := range parseResult.Metadata.Prompts { + promptIDs := make([]string, 0, len(parseResult.Metadata.Prompts)) + for promptID := range parseResult.Metadata.Prompts { + promptIDs = append(promptIDs, promptID) + } + sort.Strings(promptIDs) + + for _, promptID := range promptIDs { + prompt := parseResult.Metadata.Prompts[promptID] tool := prompt.AgentID.Tool - if tool == "" || seen[tool] { + model := prompt.AgentID.Model + if tool == "" { + continue + } + key := toolModelPair{tool: tool, model: model} + if seen[key] { continue } - seen[tool] = true + seen[key] = true detail := fmt.Sprintf("git-ai authorship log (refs/notes/ai) attributes code to %s", tool) - if prompt.AgentID.Model != "" { - detail += fmt.Sprintf(" (model: %s)", prompt.AgentID.Model) + if model != "" { + detail += fmt.Sprintf(" (model: %s)", model) } if parseResult.AttributionFileCount > 0 { detail += fmt.Sprintf(", %d file(s) attributed", parseResult.AttributionFileCount) @@ -36,6 +54,7 @@ func (d *Detector) Detect(input detection.Input) []detection.Finding { findings = append(findings, detection.Finding{ Detector: d.Name(), Tool: tool, + Model: model, Confidence: detection.ConfidenceHigh, Detail: detail, }) diff --git a/detection/gitnotes/gitnotes_test.go b/detection/gitnotes/gitnotes_test.go index c31850f..373cc20 100644 --- a/detection/gitnotes/gitnotes_test.go +++ b/detection/gitnotes/gitnotes_test.go @@ -64,19 +64,22 @@ src/lib.rs }` tests := []struct { - name string - notes string - wantTools []string + name string + notes string + wantTools []string + wantModels []string }{ { - name: "valid git-ai note with single tool", - notes: validNote, - wantTools: []string{"cursor"}, + name: "valid git-ai note with single tool", + notes: validNote, + wantTools: []string{"cursor"}, + wantModels: []string{"claude-4.5-opus"}, }, { - name: "multiple tools in note", - notes: multiToolNote, - wantTools: []string{"cursor", "claude-code"}, + name: "multiple tools in note", + notes: multiToolNote, + wantTools: []string{"cursor", "claude-code"}, + wantModels: []string{"claude-4.5-opus", "claude-3-sonnet"}, }, { name: "empty notes", @@ -109,8 +112,10 @@ src/lib.rs t.Run(tt.name, func(t *testing.T) { findings := d.Detect(detection.Input{Notes: tt.notes}) 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) } @@ -121,6 +126,7 @@ src/lib.rs if len(gotTools) == 0 { gotTools = nil + gotModels = nil } if len(gotTools) != len(tt.wantTools) { @@ -138,10 +144,72 @@ src/lib.rs t.Errorf("unexpected tool %q, want one of %v", g, tt.wantTools) } } + + if tt.wantModels != nil { + if len(gotModels) != len(tt.wantModels) { + t.Errorf("models = %v, want %v", gotModels, tt.wantModels) + return + } + for i := range gotModels { + if gotModels[i] != tt.wantModels[i] { + t.Errorf("models = %v, want %v", gotModels, tt.wantModels) + return + } + } + } }) } } +func TestDetectPreservesDistinctToolModelPairs(t *testing.T) { + d := &Detector{} + note := `src/main.rs + first 1-10 + second 11-20 + third 21-30 +--- +{ + "schema_version": "authorship/3.0.0", + "base_commit_sha": "abc", + "prompts": { + "a-first": { + "agent_id": { + "tool": "cursor", + "model": "claude-4.5-opus" + } + }, + "b-second": { + "agent_id": { + "tool": "cursor", + "model": "gpt-4o" + } + }, + "c-third": { + "agent_id": { + "tool": "cursor", + "model": "claude-4.5-opus" + } + } + } +}` + + findings := d.Detect(detection.Input{Notes: note}) + wantTools := []string{"cursor", "cursor"} + wantModels := []string{"claude-4.5-opus", "gpt-4o"} + + if len(findings) != len(wantTools) { + t.Fatalf("expected %d findings, got %d: %#v", len(wantTools), len(findings), findings) + } + for i, finding := range findings { + if finding.Tool != wantTools[i] { + t.Errorf("tool[%d] = %q, want %q", i, finding.Tool, wantTools[i]) + } + if finding.Model != wantModels[i] { + t.Errorf("model[%d] = %q, want %q", i, finding.Model, wantModels[i]) + } + } +} + func TestDetectDetailIncludesModel(t *testing.T) { d := &Detector{} note := `src/main.rs diff --git a/detection/trailer/trailer.go b/detection/trailer/trailer.go index 033ee38..fcfec3a 100644 --- a/detection/trailer/trailer.go +++ b/detection/trailer/trailer.go @@ -94,6 +94,49 @@ type Detector struct{} func (d *Detector) Name() string { return "trailer" } +type toolModelPair struct { + tool string + model string +} + +func extractParenthesizedModel(text string) string { + start := strings.Index(text, "(") + if start < 0 { + return "" + } + + end := strings.Index(text[start:], ")") + if end <= 0 { + return "" + } + + return strings.TrimSpace(text[start+1 : start+end]) +} + +func extractCoauthorModel(tool, namePart string) string { + namePart = strings.TrimSpace(namePart) + if model := extractParenthesizedModel(namePart); model != "" { + return model + } + + switch tool { + case "Claude Code": + if strings.EqualFold(namePart, "Claude") || strings.EqualFold(namePart, "Claude Code") { + return "" + } + namePartLower := strings.ToLower(namePart) + if strings.HasPrefix(namePartLower, "claude code ") { + return strings.TrimSpace(namePart[len("Claude Code "):]) + } + if strings.HasPrefix(namePartLower, "claude ") { + return strings.TrimSpace(namePart[len("Claude "):]) + } + return namePart + } + + return "" +} + func (d *Detector) detectTrailerCoauthoredBy(commitMessage string) []detection.Finding { var findings []detection.Finding @@ -102,18 +145,29 @@ func (d *Detector) detectTrailerCoauthoredBy(commitMessage string) []detection.F return findings } - seen := map[string]bool{} + seen := map[toolModelPair]bool{} for _, match := range matches { - email := strings.ToLower(strings.TrimSpace(match[1])) + if len(match) < 3 { + continue + } - if name, ok := detection.KnownCoAuthorEmails[email]; ok && !seen[name] { + namePart := strings.TrimSpace(match[1]) + email := strings.ToLower(strings.TrimSpace(match[2])) + + if name, ok := detection.KnownCoAuthorEmails[email]; ok { + model := extractCoauthorModel(name, namePart) + key := toolModelPair{tool: name, model: model} + if seen[key] { + continue + } 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), }) - seen[name] = true + seen[key] = true } } diff --git a/detection/trailer/trailer_test.go b/detection/trailer/trailer_test.go index a23a425..5354a9a 100644 --- a/detection/trailer/trailer_test.go +++ b/detection/trailer/trailer_test.go @@ -12,6 +12,7 @@ func TestDetect(t *testing.T) { name string message string wantTools []string + wantModels []string wantConfidence []detection.Confidence }{ // Co-Authored-By tests start here @@ -19,54 +20,98 @@ func TestDetect(t *testing.T) { name: "coauthor: Claude trailer with Opus model", message: "fix: update handler\n\nCo-Authored-By: Claude Opus 4 ", wantTools: []string{"Claude Code"}, + wantModels: []string{"Opus 4"}, wantConfidence: []detection.Confidence{detection.ConfidenceHigh}, }, { name: "coauthor: Claude trailer with Sonnet model", message: "fix: update handler\n\nCo-Authored-By: Claude Sonnet 4 ", wantTools: []string{"Claude Code"}, + wantModels: []string{"Sonnet 4"}, wantConfidence: []detection.Confidence{detection.ConfidenceHigh}, }, { name: "coauthor: Cursor trailer", message: "refactor: extract method\n\nCo-Authored-By: Cursor ", wantTools: []string{"Cursor"}, + wantModels: []string{""}, wantConfidence: []detection.Confidence{detection.ConfidenceHigh}, }, { name: "coauthor: Aider trailer with model name", message: "feat: add endpoint\n\nCo-Authored-By: aider (gpt-4o) ", wantTools: []string{"Aider"}, + wantModels: []string{"gpt-4o"}, wantConfidence: []detection.Confidence{detection.ConfidenceHigh}, }, { name: "coauthor: 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"}, + wantConfidence: []detection.Confidence{detection.ConfidenceHigh}, + }, + { + name: "coauthor: Cursor trailer with parenthesized model", + message: "refactor: extract method\n\nCo-Authored-By: Cursor (composer 2.5) ", + wantTools: []string{"Cursor"}, + wantModels: []string{"composer 2.5"}, + wantConfidence: []detection.Confidence{detection.ConfidenceHigh}, + }, + { + name: "coauthor: Copilot trailer with parenthesized model", + message: "feat: add endpoint\n\nCo-Authored-By: Copilot (gpt-4.1) ", + wantTools: []string{"Copilot"}, + wantModels: []string{"gpt-4.1"}, + wantConfidence: []detection.Confidence{detection.ConfidenceHigh}, + }, + { + name: "coauthor: Claude trailer with parenthesized model", + message: "fix: update handler\n\nCo-Authored-By: Claude Code (Opus 4.1) ", + wantTools: []string{"Claude Code"}, + wantModels: []string{"Opus 4.1"}, wantConfidence: []detection.Confidence{detection.ConfidenceHigh}, }, { name: "coauthor: 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{"Opus 4"}, wantConfidence: []detection.Confidence{detection.ConfidenceHigh}, }, { name: "coauthor: 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{"Opus 4", "gpt-4o"}, wantConfidence: []detection.Confidence{detection.ConfidenceHigh, detection.ConfidenceHigh}, }, + { + name: "coauthor: same tool with distinct models", + message: "fix: bug\n\nCo-Authored-By: Claude Opus 4 \nCo-Authored-By: Claude Sonnet 4 ", + wantTools: []string{"Claude Code", "Claude Code"}, + wantModels: []string{"Opus 4", "Sonnet 4"}, + wantConfidence: []detection.Confidence{detection.ConfidenceHigh, detection.ConfidenceHigh}, + }, + { + name: "coauthor: same tool and model deduplicated", + message: "fix: bug\n\nCo-Authored-By: Claude Opus 4 \nCo-Authored-By: Claude Opus 4 ", + wantTools: []string{"Claude Code"}, + wantModels: []string{"Opus 4"}, + wantConfidence: []detection.Confidence{detection.ConfidenceHigh}, + }, { name: "coauthor: case variation", message: "fix: thing\n\nco-authored-by: Claude ", wantTools: []string{"Claude Code"}, + wantModels: []string{""}, wantConfidence: []detection.Confidence{detection.ConfidenceHigh}, }, { name: "coauthor: CO-AUTHORED-BY uppercase", message: "fix: thing\n\nCO-AUTHORED-BY: Claude ", wantTools: []string{"Claude Code"}, + wantModels: []string{""}, wantConfidence: []detection.Confidence{detection.ConfidenceHigh}, }, { @@ -204,6 +249,7 @@ Signed-off-by: some human name: "assistedby: Two different attributions (assistedby and coauthor) one with model name, other with email address", message: "Add validation logic\n\nCo-Authored-By: Claude Sonnet 4.6 \nAssisted-by: GitHub Copilot", wantTools: []string{"Claude Code", "GitHub Copilot"}, + wantModels: []string{"Sonnet 4.6", ""}, wantConfidence: []detection.Confidence{detection.ConfidenceHigh, detection.ConfidenceHigh}, }, { @@ -353,9 +399,11 @@ Signed-off-by: some human 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)) gotConfidence := make([]detection.Confidence, len(findings)) for i, f := range findings { gotTools[i] = f.Tool + gotModels[i] = f.Model gotConfidence[i] = f.Confidence if f.Detector != "trailer" { @@ -365,6 +413,7 @@ Signed-off-by: some human if len(gotTools) == 0 { gotTools = nil + gotModels = nil } if len(gotTools) != len(tt.wantTools) { t.Errorf("tools = %v, want %v", gotTools, tt.wantTools) @@ -377,6 +426,19 @@ Signed-off-by: some human } } + if tt.wantModels != nil { + if len(gotModels) != len(tt.wantModels) { + t.Errorf("models = %v, want %v", gotModels, tt.wantModels) + return + } + for i := range gotModels { + if gotModels[i] != tt.wantModels[i] { + t.Errorf("models = %v, want %v", gotModels, tt.wantModels) + return + } + } + } + if len(gotConfidence) == 0 { gotConfidence = nil } diff --git a/output/output.go b/output/output.go index 06a97e4..d6025be 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, f.DisplayTool(), 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, f.DisplayTool(), f.Detector, f.Detail) } return nil } diff --git a/output/output_test.go b/output/output_test.go index 37af196..4659dde 100644 --- a/output/output_test.go +++ b/output/output_test.go @@ -19,6 +19,7 @@ func sampleReport() scan.Report { { Detector: "trailer", Tool: "Claude Code", + Model: "Opus 4", Confidence: detection.ConfidenceHigh, Detail: "Co-Authored-By trailer with email noreply@anthropic.com", }, @@ -77,6 +78,9 @@ func TestFormatText(t *testing.T) { if !strings.Contains(out, "Claude Code") { t.Errorf("expected tool name in output, got:\n%s", out) } + if !strings.Contains(out, "Claude Code [Opus 4]") { + t.Errorf("expected tool model in output, got:\n%s", out) + } if !strings.Contains(out, "abc123def456") { t.Errorf("expected commit hash in output, got:\n%s", out) } @@ -123,6 +127,7 @@ func TestFormatTextFindings(t *testing.T) { var buf bytes.Buffer findings := []detection.Finding{ {Detector: "toolmention", Tool: "Claude", Confidence: detection.ConfidenceLow, Detail: "text mentions Claude"}, + {Detector: "gitnotes", Model: "gpt-4o", Confidence: detection.ConfidenceHigh, Detail: "git notes declares model"}, } if err := FormatTextFindings(&buf, findings); err != nil { @@ -132,7 +137,10 @@ func TestFormatTextFindings(t *testing.T) { if !strings.Contains(buf.String(), "Claude") { t.Errorf("expected Claude in text output, got:\n%s", buf.String()) } - if !strings.Contains(buf.String(), "1 AI signal") { + if !strings.Contains(buf.String(), "gpt-4o") { + t.Errorf("expected model-only finding in text output, got:\n%s", buf.String()) + } + if !strings.Contains(buf.String(), "2 AI signal") { t.Errorf("expected signal count in output, got:\n%s", buf.String()) } }