From 7fd2837a259d81f1af162947b52108166b3f7f45 Mon Sep 17 00:00:00 2001 From: Omkar P <45419097+omkar-foss@users.noreply.github.com> Date: Thu, 25 Jun 2026 16:36:26 +0530 Subject: [PATCH 1/5] Add support to detect Assisted-By trailer Signed-off-by: Omkar P <45419097+omkar-foss@users.noreply.github.com> --- cmd/cmd.go | 2 + detection/assistedby/assistedby.go | 77 ++++++++++++ detection/assistedby/assistedby_test.go | 158 ++++++++++++++++++++++++ scan/scan_test.go | 66 +++++++--- 4 files changed, 288 insertions(+), 15 deletions(-) create mode 100644 detection/assistedby/assistedby.go create mode 100644 detection/assistedby/assistedby_test.go diff --git a/cmd/cmd.go b/cmd/cmd.go index c49ec24..6458bc3 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -10,6 +10,7 @@ import ( "strings" "github.com/chaoss/disclosure/detection" + "github.com/chaoss/disclosure/detection/assistedby" "github.com/chaoss/disclosure/detection/coauthor" "github.com/chaoss/disclosure/detection/committer" "github.com/chaoss/disclosure/detection/gitnotes" @@ -32,6 +33,7 @@ const ( func allDetectors() []detection.Detector { return []detection.Detector{ + &assistedby.Detector{}, &committer.Detector{}, &coauthor.Detector{}, &gitnotes.Detector{}, diff --git a/detection/assistedby/assistedby.go b/detection/assistedby/assistedby.go new file mode 100644 index 0000000..228ffb5 --- /dev/null +++ b/detection/assistedby/assistedby.go @@ -0,0 +1,77 @@ +package assistedby + +import ( + "fmt" + "regexp" + "strings" + + "github.com/chaoss/disclosure/detection" +) + +var assistedByPattern = regexp.MustCompile(`(?im)^assisted-by\s*:\s*([^\r\n]+?)\s*$`) +var toolLineReplacePattern = regexp.MustCompile(`\s*<[^>]+>`) + +func getMatchedTools(toolLine string) []string { + toolLine = strings.TrimSpace(toolLine) + if toolLine == "" { + return nil + } + toolLine = toolLineReplacePattern.ReplaceAllString(toolLine, "") + parts := strings.Split(toolLine, "\n") + var tools []string + for _, p := range parts { + p = strings.TrimSpace(strings.Split(strings.TrimSpace(p), "(")[0]) + if p == "" { + continue + } + words := strings.Fields(p) + for i, w := range words { + if len(w) > 0 { + words[i] = strings.ToUpper(w[:1]) + w[1:] + } + } + tools = append(tools, strings.Join(words, " ")) + } + return tools +} + +type Detector struct{} + +func (d *Detector) Name() string { return "assistedby" } + +func (d *Detector) Detect(input detection.Input) []detection.Finding { + if input.CommitMessage == "" { + return nil + } + + matches := assistedByPattern.FindAllStringSubmatch( + input.CommitMessage, + -1, + ) + + if len(matches) == 0 { + return nil + } + + var findings []detection.Finding + + for _, match := range matches { + if len(match) < 2 { + continue + } + + for _, matchedTool := range getMatchedTools(match[1]) { + findings = append(findings, detection.Finding{ + Detector: d.Name(), + Tool: matchedTool, + Confidence: detection.ConfidenceHigh, + Detail: fmt.Sprintf( + "Assisted-By trailer with tool %s", + matchedTool, + ), + }) + } + } + + return findings +} diff --git a/detection/assistedby/assistedby_test.go b/detection/assistedby/assistedby_test.go new file mode 100644 index 0000000..1f8b5f5 --- /dev/null +++ b/detection/assistedby/assistedby_test.go @@ -0,0 +1,158 @@ +package assistedby + +import ( + "testing" + + "github.com/chaoss/disclosure/detection" +) + +func TestDetect(t *testing.T) { + d := &Detector{} + tests := []struct { + name string + message string + wantTools []string + }{ + { + name: "Claude trailer with Opus model", + message: "fix: update handler\n\nAssisted-By: Claude Opus 4 ", + wantTools: []string{"Claude Opus 4"}, + }, + { + name: "Claude trailer with Sonnet model", + message: "fix: update handler\n\nAssisted-By: Claude Sonnet 4 ", + wantTools: []string{"Claude Sonnet 4"}, + }, + { + name: "Cursor trailer", + message: "refactor: extract method\n\nAssisted-By: Cursor ", + wantTools: []string{"Cursor"}, + }, + { + name: "Aider trailer with model name", + message: "feat: add endpoint\n\nAssisted-By: aider (gpt-4o) ", + wantTools: []string{"Aider"}, + }, + { + name: "Aider trailer with different model", + message: "feat: add endpoint\n\nAssisted-By: aider (claude-4.7-opus) ", + wantTools: []string{"Aider"}, + }, + { + name: "multiple trailers with Claude and human", + message: "fix: bug\n\nAssisted-By: Claude Opus 4 ", + wantTools: []string{"Claude Opus 4"}, + }, + { + name: "multiple AI trailers", + message: "fix: bug\n\nAssisted-By: Claude Opus 4 \nAssisted-By: aider (gpt-4o) ", + wantTools: []string{"Claude Opus 4", "Aider"}, + }, + { + name: "case variation", + message: "fix: something\n\nassisted-by: Claude ", + wantTools: []string{"Claude"}, + }, + { + name: "ASSISTED-BY uppercase", + message: "fix: something\n\nASSISTED-BY: Claude ", + wantTools: []string{"Claude"}, + }, + { + name: "Assisted-By trailer in commit message", + message: "this is a commit message with\nAssisted-By: Claude Code", + wantTools: []string{"Claude Code"}, + }, + { + name: "Another Assisted-By trailer in commit message 1", + message: "this is a commit message with\nAssisted-By: Gemini", + wantTools: []string{"Gemini"}, + }, + { + name: "Another Assisted-By trailer in commit message 2", + message: "this is a commit message with\nAssisted-By: Kimi K2.6", + wantTools: []string{"Kimi K2.6"}, + }, + { + name: "Multiple Assisted-By trailer in commit message", + message: "this is a commit message with\nAssisted-By: Claude Code\nAssisted-By: Gemini", + wantTools: []string{"Claude Code", "Gemini"}, + }, + { + name: "Multiple Assisted-By trailers (with purpose brackets) in commit message", + message: ` +this is a commit message + +Co-Authored-By: Cursor + +Assisted-by: Claude 4.7 Opus + (logic optimization and design fixes) +Assisted-by: Kimi K2.6 (unit tests, integration tests) +Assisted-by: ChatGPT (documentation review) +Assisted-by: Gemini (documentation) +`, + wantTools: []string{"Claude 4.7 Opus", "Kimi K2.6", "ChatGPT", "Gemini"}, + }, + { + name: "Assisted-By trailer in commit message in lower case", + message: "this is a commit message with\nassisted-by: Claude Code", + wantTools: []string{"Claude Code"}, + }, + { + name: "Two different attributions (assistedby and coauthor) both with email address", + message: "Fix bug\n\nAssisted-By: Claude Sonnet 4 \nCo-Authored-By: Copilot ", + wantTools: []string{"Claude Sonnet 4"}, + }, + { + name: "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{"GitHub Copilot"}, + }, + { + name: "Claude Opus model attribution trailer", + message: "Fix bug\n\nAssisted-by: Claude Opus 4 ", + wantTools: []string{"Claude Opus 4"}, + }, + { + name: "no trailers", + message: "just a normal commit message", + wantTools: nil, + }, + { + name: "empty message", + message: "", + wantTools: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + findings := d.Detect(detection.Input{CommitMessage: tt.message}) + gotTools := make([]string, len(findings)) + for i, f := range findings { + gotTools[i] = f.Tool + if f.Confidence != detection.ConfidenceHigh { + t.Errorf("confidence = %d, want %d", f.Confidence, detection.ConfidenceHigh) + } + if f.Detector != "assistedby" { + t.Errorf("detector = %q, want %q", f.Detector, "assistedby") + } + } + + if len(gotTools) == 0 { + gotTools = nil + } + + if len(gotTools) != len(tt.wantTools) { + t.Errorf("tools = %v, want %v", gotTools, tt.wantTools) + return + } + for i := range gotTools { + if gotTools[i] != tt.wantTools[i] { + t.Errorf("tools = %v, want %v", gotTools, tt.wantTools) + return + } + } + }) + } +} diff --git a/scan/scan_test.go b/scan/scan_test.go index 1f10a83..06829fb 100644 --- a/scan/scan_test.go +++ b/scan/scan_test.go @@ -8,6 +8,7 @@ import ( "time" "github.com/chaoss/disclosure/detection" + "github.com/chaoss/disclosure/detection/assistedby" "github.com/chaoss/disclosure/detection/coauthor" "github.com/chaoss/disclosure/detection/committer" "github.com/chaoss/disclosure/detection/gitnotes" @@ -19,6 +20,7 @@ import ( func allDetectors() []detection.Detector { return []detection.Detector{ + &assistedby.Detector{}, &committer.Detector{}, &coauthor.Detector{}, &gitnotes.Detector{}, @@ -48,6 +50,19 @@ func initTestRepo(t *testing.T) (string, []string) { {"initial commit", "human@example.com"}, {"fix: update handler\n\nCo-Authored-By: Claude Opus 4 ", "human@example.com"}, {"aider: refactor auth module", "human@example.com"}, + {` +this is a commit message + +Co-Authored-By: Cursor + +Assisted-by: Claude 4.7 Opus + (logic optimization and design fixes) +Assisted-by: Kimi K2.6 (unit tests, integration tests) +Assisted-by: ChatGPT (documentation review) +Assisted-by: Gemini (documentation) +`, + "human@example.com", + }, } var hashes []string @@ -84,17 +99,17 @@ func TestScanCommitRange(t *testing.T) { dir, hashes := initTestRepo(t) detectors := allDetectors() - report, err := ScanCommitRange(dir, hashes[0]+".."+hashes[2], detectors) + report, err := ScanCommitRange(dir, hashes[0]+".."+hashes[3], detectors) if err != nil { t.Fatalf("ScanCommitRange: %v", err) } - if report.Summary.TotalCommits != 2 { - t.Errorf("total commits = %d, want 2", report.Summary.TotalCommits) + if report.Summary.TotalCommits != 3 { + t.Errorf("total commits = %d, want 3", report.Summary.TotalCommits) } - if report.Summary.AICommits != 2 { - t.Errorf("ai commits = %d, want 2", report.Summary.AICommits) + if report.Summary.AICommits != 3 { + t.Errorf("ai commits = %d, want 3", report.Summary.AICommits) } // Check that Claude Code was detected via co-author @@ -106,6 +121,21 @@ func TestScanCommitRange(t *testing.T) { if count, ok := report.Summary.ToolCounts["Aider"]; !ok || count == 0 { t.Error("expected Aider in tool counts") } + + // Check detection via assisted-by pattern 1 + if count, ok := report.Summary.ToolCounts["ChatGPT"]; !ok || count == 0 { + t.Error("expected ChatGPT in tool counts") + } + + // Check detection via assisted-by pattern 2 + if count, ok := report.Summary.ToolCounts["Claude 4.7 Opus"]; !ok || count == 0 { + t.Error("expected Claude 4.7 Opus in tool counts") + } + + // Check detection via assisted-by pattern 3 + if count, ok := report.Summary.ToolCounts["Kimi K2.6"]; !ok || count == 0 { + t.Error("expected Kimi K2.6 Opus in tool counts") + } } func TestScanCommitRangeAll(t *testing.T) { @@ -117,8 +147,8 @@ func TestScanCommitRangeAll(t *testing.T) { t.Fatalf("ScanCommitRange: %v", err) } - if report.Summary.TotalCommits != 3 { - t.Errorf("total commits = %d, want 3", report.Summary.TotalCommits) + if report.Summary.TotalCommits != 4 { + t.Errorf("total commits = %d, want 4", report.Summary.TotalCommits) } } @@ -126,28 +156,34 @@ func TestScanCommit(t *testing.T) { dir, hashes := initTestRepo(t) detectors := allDetectors() - // Scan the commit with co-author trailer - result, err := ScanCommit(dir, hashes[1], detectors) + // Scan the commit with assisted-by and co-author trailers + result, err := ScanCommit(dir, hashes[3], detectors) if err != nil { t.Fatalf("ScanCommit: %v", err) } - if result.Hash != hashes[1] { - t.Errorf("hash = %q, want %q", result.Hash, hashes[1]) + if result.Hash != hashes[3] { + t.Errorf("hash = %q, want %q", result.Hash, hashes[3]) } if len(result.Findings) == 0 { - t.Error("expected findings for co-author commit") + t.Error("expected findings for assisted-by and co-author trailers") } foundCoauthor := false + foundAssistedBy := false for _, f := range result.Findings { - if f.Detector == "coauthor" && f.Tool == "Claude Code" { + if f.Detector == "coauthor" && f.Tool == "Cursor" { foundCoauthor = true + } else if f.Detector == "assistedby" && f.Tool == "Kimi K2.6" { + foundAssistedBy = true } } if !foundCoauthor { - t.Error("expected coauthor finding for Claude Code") + t.Error("expected coauthor finding for Cursor") + } + if !foundAssistedBy { + t.Error("expected assistedby finding for Kimi K2.6") } } @@ -281,7 +317,7 @@ func TestReportSummaryByConfidence(t *testing.T) { t.Fatalf("ScanCommitRange: %v", err) } - // Co-author trailer should give high confidence + // Co-author and Assisted-By trailers should give high confidence if count, ok := report.Summary.ByConfidence["high"]; !ok || count == 0 { t.Error("expected high confidence findings") } From dfcc2ab505a5f10a251626f4c9c19fbfe9201375 Mon Sep 17 00:00:00 2001 From: Omkar P <45419097+omkar-foss@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:27:17 +0530 Subject: [PATCH 2/5] DRY out trailer-based detection into single package Signed-off-by: Omkar P <45419097+omkar-foss@users.noreply.github.com> --- README.md | 9 +- cmd/cmd.go | 12 +- cmd/cmd_test.go | 2 +- detection/assistedby/assistedby.go | 77 ----- detection/assistedby/assistedby_test.go | 158 ---------- detection/coauthor/coauthor.go | 46 --- detection/coauthor/coauthor_test.go | 108 ------- detection/committer/committer.go | 28 +- detection/committer/committer_test.go | 2 +- detection/constants.go | 90 ++++++ detection/gitnotes/gitnotes.go | 6 +- detection/message/message.go | 102 ------- detection/message/message_test.go | 194 ------------ detection/toolmention/toolmention.go | 27 +- detection/trailer/trailer.go | 189 ++++++++++++ detection/trailer/trailer_test.go | 389 ++++++++++++++++++++++++ output/output_test.go | 2 +- scan/scan_test.go | 12 +- 18 files changed, 690 insertions(+), 763 deletions(-) delete mode 100644 detection/assistedby/assistedby.go delete mode 100644 detection/assistedby/assistedby_test.go delete mode 100644 detection/coauthor/coauthor.go delete mode 100644 detection/coauthor/coauthor_test.go create mode 100644 detection/constants.go delete mode 100644 detection/message/message.go delete mode 100644 detection/message/message_test.go create mode 100644 detection/trailer/trailer.go create mode 100644 detection/trailer/trailer_test.go diff --git a/README.md b/README.md index aa89f26..6aa6acf 100644 --- a/README.md +++ b/README.md @@ -104,9 +104,8 @@ import ( "fmt" "github.com/chaoss/disclosure/detection" - "github.com/chaoss/disclosure/detection/coauthor" "github.com/chaoss/disclosure/detection/committer" - "github.com/chaoss/disclosure/detection/message" + "github.com/chaoss/disclosure/detection/trailer" "github.com/chaoss/disclosure/detection/toolmention" "github.com/chaoss/disclosure/scan" ) @@ -114,9 +113,8 @@ import ( func main() { detectors := []detection.Detector{ &committer.Detector{}, - &coauthor.Detector{}, - &message.Detector{}, &toolmention.Detector{}, + &trailer.Detector{}, } report, err := scan.ScanCommitRange("/path/to/repo", "base..head", detectors) @@ -169,10 +167,9 @@ go test ./... ``` detection/ Core types: Detector interface, Finding, Confidence, Input detection/committer/ Known AI bot committer emails -detection/coauthor/ Co-Authored-By trailer parsing detection/gitnotes/ git-ai authorship logs from refs/notes/ai -detection/message/ Commit message pattern matching detection/toolmention/ AI tool name mentions in text +detection/trailer/ Look for known trailers in commit message gitops/ go-git wrapper for reading commits scan/ Orchestration: run detectors over commits or text output/ JSON and human-readable text formatters diff --git a/cmd/cmd.go b/cmd/cmd.go index 6458bc3..38cd703 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -10,12 +10,10 @@ import ( "strings" "github.com/chaoss/disclosure/detection" - "github.com/chaoss/disclosure/detection/assistedby" - "github.com/chaoss/disclosure/detection/coauthor" "github.com/chaoss/disclosure/detection/committer" "github.com/chaoss/disclosure/detection/gitnotes" - "github.com/chaoss/disclosure/detection/message" "github.com/chaoss/disclosure/detection/toolmention" + "github.com/chaoss/disclosure/detection/trailer" "github.com/chaoss/disclosure/output" "github.com/chaoss/disclosure/scan" "github.com/spf13/cobra" @@ -33,11 +31,9 @@ const ( func allDetectors() []detection.Detector { return []detection.Detector{ - &assistedby.Detector{}, &committer.Detector{}, - &coauthor.Detector{}, &gitnotes.Detector{}, - &message.Detector{}, + &trailer.Detector{}, &toolmention.Detector{}, } } @@ -45,8 +41,8 @@ func allDetectors() []detection.Detector { // Run is the main entry point for the CLI. Returns an exit code. func Run(args []string, stdout, stderr io.Writer) int { rootCmd := &cobra.Command{ - Use: "disclosure", - Short: "Detect AI-generated contributions", + Use: "disclosure", + Short: "Detect AI-generated contributions", Long: `Disclosure is a standalone CLI tool that detects AI-generated contributions in git repositories. It works entirely from git-level data (commit emails, messages, trailers) with no platform API dependencies. diff --git a/cmd/cmd_test.go b/cmd/cmd_test.go index 1233c15..5ee0b88 100644 --- a/cmd/cmd_test.go +++ b/cmd/cmd_test.go @@ -204,7 +204,7 @@ func TestFilterReport(t *testing.T) { Hash: "abc123", Findings: []detection.Finding{ {Detector: "toolmention", Tool: "Claude", Confidence: 1, Detail: "text"}, - {Detector: "coauthor", Tool: "Claude Code", Confidence: 3, Detail: "trailer"}, + {Detector: "trailer", Tool: "Claude Code", Confidence: 3, Detail: "trailer"}, }, }, }, diff --git a/detection/assistedby/assistedby.go b/detection/assistedby/assistedby.go deleted file mode 100644 index 228ffb5..0000000 --- a/detection/assistedby/assistedby.go +++ /dev/null @@ -1,77 +0,0 @@ -package assistedby - -import ( - "fmt" - "regexp" - "strings" - - "github.com/chaoss/disclosure/detection" -) - -var assistedByPattern = regexp.MustCompile(`(?im)^assisted-by\s*:\s*([^\r\n]+?)\s*$`) -var toolLineReplacePattern = regexp.MustCompile(`\s*<[^>]+>`) - -func getMatchedTools(toolLine string) []string { - toolLine = strings.TrimSpace(toolLine) - if toolLine == "" { - return nil - } - toolLine = toolLineReplacePattern.ReplaceAllString(toolLine, "") - parts := strings.Split(toolLine, "\n") - var tools []string - for _, p := range parts { - p = strings.TrimSpace(strings.Split(strings.TrimSpace(p), "(")[0]) - if p == "" { - continue - } - words := strings.Fields(p) - for i, w := range words { - if len(w) > 0 { - words[i] = strings.ToUpper(w[:1]) + w[1:] - } - } - tools = append(tools, strings.Join(words, " ")) - } - return tools -} - -type Detector struct{} - -func (d *Detector) Name() string { return "assistedby" } - -func (d *Detector) Detect(input detection.Input) []detection.Finding { - if input.CommitMessage == "" { - return nil - } - - matches := assistedByPattern.FindAllStringSubmatch( - input.CommitMessage, - -1, - ) - - if len(matches) == 0 { - return nil - } - - var findings []detection.Finding - - for _, match := range matches { - if len(match) < 2 { - continue - } - - for _, matchedTool := range getMatchedTools(match[1]) { - findings = append(findings, detection.Finding{ - Detector: d.Name(), - Tool: matchedTool, - Confidence: detection.ConfidenceHigh, - Detail: fmt.Sprintf( - "Assisted-By trailer with tool %s", - matchedTool, - ), - }) - } - } - - return findings -} diff --git a/detection/assistedby/assistedby_test.go b/detection/assistedby/assistedby_test.go deleted file mode 100644 index 1f8b5f5..0000000 --- a/detection/assistedby/assistedby_test.go +++ /dev/null @@ -1,158 +0,0 @@ -package assistedby - -import ( - "testing" - - "github.com/chaoss/disclosure/detection" -) - -func TestDetect(t *testing.T) { - d := &Detector{} - tests := []struct { - name string - message string - wantTools []string - }{ - { - name: "Claude trailer with Opus model", - message: "fix: update handler\n\nAssisted-By: Claude Opus 4 ", - wantTools: []string{"Claude Opus 4"}, - }, - { - name: "Claude trailer with Sonnet model", - message: "fix: update handler\n\nAssisted-By: Claude Sonnet 4 ", - wantTools: []string{"Claude Sonnet 4"}, - }, - { - name: "Cursor trailer", - message: "refactor: extract method\n\nAssisted-By: Cursor ", - wantTools: []string{"Cursor"}, - }, - { - name: "Aider trailer with model name", - message: "feat: add endpoint\n\nAssisted-By: aider (gpt-4o) ", - wantTools: []string{"Aider"}, - }, - { - name: "Aider trailer with different model", - message: "feat: add endpoint\n\nAssisted-By: aider (claude-4.7-opus) ", - wantTools: []string{"Aider"}, - }, - { - name: "multiple trailers with Claude and human", - message: "fix: bug\n\nAssisted-By: Claude Opus 4 ", - wantTools: []string{"Claude Opus 4"}, - }, - { - name: "multiple AI trailers", - message: "fix: bug\n\nAssisted-By: Claude Opus 4 \nAssisted-By: aider (gpt-4o) ", - wantTools: []string{"Claude Opus 4", "Aider"}, - }, - { - name: "case variation", - message: "fix: something\n\nassisted-by: Claude ", - wantTools: []string{"Claude"}, - }, - { - name: "ASSISTED-BY uppercase", - message: "fix: something\n\nASSISTED-BY: Claude ", - wantTools: []string{"Claude"}, - }, - { - name: "Assisted-By trailer in commit message", - message: "this is a commit message with\nAssisted-By: Claude Code", - wantTools: []string{"Claude Code"}, - }, - { - name: "Another Assisted-By trailer in commit message 1", - message: "this is a commit message with\nAssisted-By: Gemini", - wantTools: []string{"Gemini"}, - }, - { - name: "Another Assisted-By trailer in commit message 2", - message: "this is a commit message with\nAssisted-By: Kimi K2.6", - wantTools: []string{"Kimi K2.6"}, - }, - { - name: "Multiple Assisted-By trailer in commit message", - message: "this is a commit message with\nAssisted-By: Claude Code\nAssisted-By: Gemini", - wantTools: []string{"Claude Code", "Gemini"}, - }, - { - name: "Multiple Assisted-By trailers (with purpose brackets) in commit message", - message: ` -this is a commit message - -Co-Authored-By: Cursor - -Assisted-by: Claude 4.7 Opus - (logic optimization and design fixes) -Assisted-by: Kimi K2.6 (unit tests, integration tests) -Assisted-by: ChatGPT (documentation review) -Assisted-by: Gemini (documentation) -`, - wantTools: []string{"Claude 4.7 Opus", "Kimi K2.6", "ChatGPT", "Gemini"}, - }, - { - name: "Assisted-By trailer in commit message in lower case", - message: "this is a commit message with\nassisted-by: Claude Code", - wantTools: []string{"Claude Code"}, - }, - { - name: "Two different attributions (assistedby and coauthor) both with email address", - message: "Fix bug\n\nAssisted-By: Claude Sonnet 4 \nCo-Authored-By: Copilot ", - wantTools: []string{"Claude Sonnet 4"}, - }, - { - name: "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{"GitHub Copilot"}, - }, - { - name: "Claude Opus model attribution trailer", - message: "Fix bug\n\nAssisted-by: Claude Opus 4 ", - wantTools: []string{"Claude Opus 4"}, - }, - { - name: "no trailers", - message: "just a normal commit message", - wantTools: nil, - }, - { - name: "empty message", - message: "", - wantTools: nil, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - findings := d.Detect(detection.Input{CommitMessage: tt.message}) - gotTools := make([]string, len(findings)) - for i, f := range findings { - gotTools[i] = f.Tool - if f.Confidence != detection.ConfidenceHigh { - t.Errorf("confidence = %d, want %d", f.Confidence, detection.ConfidenceHigh) - } - if f.Detector != "assistedby" { - t.Errorf("detector = %q, want %q", f.Detector, "assistedby") - } - } - - if len(gotTools) == 0 { - gotTools = nil - } - - if len(gotTools) != len(tt.wantTools) { - t.Errorf("tools = %v, want %v", gotTools, tt.wantTools) - return - } - for i := range gotTools { - if gotTools[i] != tt.wantTools[i] { - t.Errorf("tools = %v, want %v", gotTools, tt.wantTools) - return - } - } - }) - } -} diff --git a/detection/coauthor/coauthor.go b/detection/coauthor/coauthor.go deleted file mode 100644 index 08ad342..0000000 --- a/detection/coauthor/coauthor.go +++ /dev/null @@ -1,46 +0,0 @@ -package coauthor - -import ( - "fmt" - "regexp" - "strings" - - "github.com/chaoss/disclosure/detection" -) - -var knownCoAuthorEmails = map[string]string{ - "noreply@anthropic.com": "Claude Code", - "cursoragent@cursor.com": "Cursor", - "noreply@aider.chat": "Aider", -} - -var coAuthorPattern = regexp.MustCompile(`(?im)^co-authored-by:\s*[^<]*<([^>]+)>`) - -type Detector struct{} - -func (d *Detector) Name() string { return "coauthor" } - -func (d *Detector) Detect(input detection.Input) []detection.Finding { - if input.CommitMessage == "" { - return nil - } - - matches := coAuthorPattern.FindAllStringSubmatch(input.CommitMessage, -1) - var findings []detection.Finding - seen := map[string]bool{} - - for _, match := range matches { - email := strings.ToLower(strings.TrimSpace(match[1])) - if name, ok := knownCoAuthorEmails[email]; ok && !seen[name] { - findings = append(findings, detection.Finding{ - Detector: d.Name(), - Tool: name, - Confidence: detection.ConfidenceHigh, - Detail: fmt.Sprintf("Co-Authored-By trailer with email %s", email), - }) - seen[name] = true - } - } - - return findings -} diff --git a/detection/coauthor/coauthor_test.go b/detection/coauthor/coauthor_test.go deleted file mode 100644 index 908801b..0000000 --- a/detection/coauthor/coauthor_test.go +++ /dev/null @@ -1,108 +0,0 @@ -package coauthor - -import ( - "testing" - - "github.com/chaoss/disclosure/detection" -) - -func TestDetect(t *testing.T) { - d := &Detector{} - tests := []struct { - name string - message string - wantTools []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 Sonnet model", - message: "fix: update handler\n\nCo-Authored-By: Claude Sonnet 4 ", - wantTools: []string{"Claude Code"}, - }, - { - name: "Cursor trailer", - message: "refactor: extract method\n\nCo-Authored-By: Cursor ", - wantTools: []string{"Cursor"}, - }, - { - name: "Aider trailer with model name", - message: "feat: add endpoint\n\nCo-Authored-By: aider (gpt-4o) ", - 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"}, - }, - { - 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 AI trailers", - message: "fix: bug\n\nCo-Authored-By: Claude Opus 4 \nCo-Authored-By: aider (gpt-4o) ", - wantTools: []string{"Claude Code", "Aider"}, - }, - { - name: "case variation", - 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"}, - }, - { - name: "no trailers", - message: "just a normal commit message", - wantTools: nil, - }, - { - name: "human co-author only", - message: "pair programming\n\nCo-Authored-By: Bob ", - wantTools: nil, - }, - { - name: "empty message", - message: "", - wantTools: nil, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - findings := d.Detect(detection.Input{CommitMessage: tt.message}) - gotTools := make([]string, len(findings)) - for i, f := range findings { - gotTools[i] = f.Tool - if f.Confidence != detection.ConfidenceHigh { - t.Errorf("confidence = %d, want %d", f.Confidence, detection.ConfidenceHigh) - } - if f.Detector != "coauthor" { - t.Errorf("detector = %q, want %q", f.Detector, "coauthor") - } - } - - if len(gotTools) == 0 { - gotTools = nil - } - - if len(gotTools) != len(tt.wantTools) { - t.Errorf("tools = %v, want %v", gotTools, tt.wantTools) - return - } - for i := range gotTools { - if gotTools[i] != tt.wantTools[i] { - t.Errorf("tools = %v, want %v", gotTools, tt.wantTools) - return - } - } - }) - } -} diff --git a/detection/committer/committer.go b/detection/committer/committer.go index 25975c0..952a00a 100644 --- a/detection/committer/committer.go +++ b/detection/committer/committer.go @@ -7,33 +7,13 @@ import ( "github.com/chaoss/disclosure/detection" ) -// knownAgentCommitters maps GitHub noreply emails to AI tool names. -var knownAgentCommitters = map[string]string{ - "209825114+claude[bot]@users.noreply.github.com": "Claude", - "215619710+anthropic-claude[bot]@users.noreply.github.com": "Claude (Anthropic)", - "208546643+claude-code-action[bot]@users.noreply.github.com": "Claude Code Action", - "198982749+copilot@users.noreply.github.com": "GitHub Copilot (agent)", - "167198135+copilot[bot]@users.noreply.github.com": "GitHub Copilot (chat)", - "206951365+cursor[bot]@users.noreply.github.com": "Cursor", - "215057067+openai-codex[bot]@users.noreply.github.com": "OpenAI Codex", - "199175422+chatgpt-codex-connector[bot]@users.noreply.github.com": "Codex via ChatGPT", - "176961590+gemini-code-assist[bot]@users.noreply.github.com": "Gemini Code Assist", - "208079219+amazon-q-developer[bot]@users.noreply.github.com": "Amazon Q Developer", - "158243242+devin-ai-integration[bot]@users.noreply.github.com": "Devin", - "205137888+cline[bot]@users.noreply.github.com": "Cline", - "230936708+continue[bot]@users.noreply.github.com": "Continue.dev", - "201248094+sourcegraph-cody[bot]@users.noreply.github.com": "Sourcegraph Cody", - "220155983+jetbrains-ai[bot]@users.noreply.github.com": "JetBrains AI", - "136622811+coderabbitai[bot]@users.noreply.github.com": "CodeRabbit", -} - // numericPrefixIndex maps the numeric prefix from GitHub noreply emails to tool names. // This handles issue #4: when a bot's username changes, the numeric ID stays the same. var numericPrefixIndex map[string]string func init() { - numericPrefixIndex = make(map[string]string, len(knownAgentCommitters)) - for email, name := range knownAgentCommitters { + numericPrefixIndex = make(map[string]string, len(detection.KnownAgentCommitters)) + for email, name := range detection.KnownAgentCommitters { if idx := strings.Index(email, "+"); idx > 0 { numericPrefixIndex[email[:idx]] = name } @@ -51,7 +31,7 @@ func (d *Detector) Detect(input detection.Input) []detection.Finding { } // Direct match against known emails - if name, ok := knownAgentCommitters[email]; ok { + if name, ok := detection.KnownAgentCommitters[email]; ok { return []detection.Finding{{ Detector: d.Name(), Tool: name, @@ -62,7 +42,7 @@ func (d *Detector) Detect(input detection.Input) []detection.Finding { // Numeric prefix match for GitHub noreply emails (#4). // Format: +@users.noreply.github.com - if strings.HasSuffix(email, "@users.noreply.github.com") { + if strings.HasSuffix(email, detection.GithubNoReplyEmailSuffix) { if idx := strings.Index(email, "+"); idx > 0 { prefix := email[:idx] if name, ok := numericPrefixIndex[prefix]; ok { diff --git a/detection/committer/committer_test.go b/detection/committer/committer_test.go index 5ea084e..5cd0469 100644 --- a/detection/committer/committer_test.go +++ b/detection/committer/committer_test.go @@ -8,7 +8,7 @@ import ( func TestDetectAllKnownEmails(t *testing.T) { d := &Detector{} - for email, expectedName := range knownAgentCommitters { + for email, expectedName := range detection.KnownAgentCommitters { input := detection.Input{CommitEmail: email} findings := d.Detect(input) if len(findings) != 1 { diff --git a/detection/constants.go b/detection/constants.go new file mode 100644 index 0000000..8e88b90 --- /dev/null +++ b/detection/constants.go @@ -0,0 +1,90 @@ +package detection + +import "regexp" + +// KnownAgentCommitters maps GitHub noreply emails to AI tool names. +var KnownAgentCommitters = map[string]string{ + "209825114+claude[bot]@users.noreply.github.com": "Claude", + "215619710+anthropic-claude[bot]@users.noreply.github.com": "Claude (Anthropic)", + "208546643+claude-code-action[bot]@users.noreply.github.com": "Claude Code Action", + "198982749+copilot@users.noreply.github.com": "GitHub Copilot (agent)", + "167198135+copilot[bot]@users.noreply.github.com": "GitHub Copilot (chat)", + "206951365+cursor[bot]@users.noreply.github.com": "Cursor", + "215057067+openai-codex[bot]@users.noreply.github.com": "OpenAI Codex", + "199175422+chatgpt-codex-connector[bot]@users.noreply.github.com": "Codex via ChatGPT", + "176961590+gemini-code-assist[bot]@users.noreply.github.com": "Gemini Code Assist", + "208079219+amazon-q-developer[bot]@users.noreply.github.com": "Amazon Q Developer", + "158243242+devin-ai-integration[bot]@users.noreply.github.com": "Devin", + "205137888+cline[bot]@users.noreply.github.com": "Cline", + "230936708+continue[bot]@users.noreply.github.com": "Continue.dev", + "201248094+sourcegraph-cody[bot]@users.noreply.github.com": "Sourcegraph Cody", + "220155983+jetbrains-ai[bot]@users.noreply.github.com": "JetBrains AI", + "136622811+coderabbitai[bot]@users.noreply.github.com": "CodeRabbit", +} + +// GithubNoReplyEmailSuffix GitHub noreply emails suffix to check committer emails. +var GithubNoReplyEmailSuffix = "@users.noreply.github.com" + +// SupportedToolsInMentions List of all supported tools to detect in tool mentions within commits. +var SupportedToolsInMentions = []string{ + "Claude Code", + "Claude", + "GitHub Copilot", + "Copilot", + "Cursor", + "Aider", + "OpenAI Codex", + "Codex", + "Gemini Code Assist", + "Amazon Q Developer", + "Amazon Q", + "Devin", + "Cline", + "Continue.dev", + "Sourcegraph Cody", + "Cody", + "JetBrains AI", + "CodeRabbit", + "ChatGPT", + "t3.chat", + "GPT-4", + "Windsurf", +} + +// KnownCoAuthorEmails Known emails present with Co-Authored-By trailers +var KnownCoAuthorEmails = map[string]string{ + "noreply@anthropic.com": "Claude Code", + "cursoragent@cursor.com": "Cursor", + "noreply@aider.chat": "Aider", + "copilot@github.com": "Copilot", +} + +// CoAuthorPattern Regex to look for Co-Authroed-By trailer with 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*$`) + +// AiderCommitPrefix The commit prefix added by Aider +var AiderCommitPrefix = "aider:" + +// ClaudeAttributionText +var ClaudeAttributionText = "Generated with Claude Code" + +// EntireIOTrailers List of EntireIO related trailers to check for +var EntireIOTrailers = []string{ + "Entire-Metadata", + "Entire-Metadata-Task", + "Entire-Strategy", + "Entire-Session", + "Entire-Condensation", + "Entire-Source-Ref", + "Entire-Checkpoint", + "Entire-Agent", +} + +// ReplitAttributionRegex Regex to detect use of Replit Agent or Assistant +var ReplitAttributionRegex = `(?m)^Replit-Commit-Author:\s*(Agent|Assistant)(?:\r?\nReplit-Commit-Session-Id:\s*([a-fA-F0-9-]+))?(?:\r?\n|$)` + +// GitNotesAuthorshipPrefix Authorship prefix to check as a precondition in git notes +var GitNotesAuthorshipPrefix = "authorship/" diff --git a/detection/gitnotes/gitnotes.go b/detection/gitnotes/gitnotes.go index 5fc8e92..153e186 100644 --- a/detection/gitnotes/gitnotes.go +++ b/detection/gitnotes/gitnotes.go @@ -10,8 +10,8 @@ import ( // metadata represents the JSON metadata section of a git-ai authorship log. type metadata struct { - SchemaVersion string `json:"schema_version"` - Prompts map[string]promptRecord `json:"prompts"` + SchemaVersion string `json:"schema_version"` + Prompts map[string]promptRecord `json:"prompts"` } type promptRecord struct { @@ -45,7 +45,7 @@ func (d *Detector) Detect(input detection.Input) []detection.Finding { return nil } - if !strings.HasPrefix(meta.SchemaVersion, "authorship/") { + if !strings.HasPrefix(meta.SchemaVersion, detection.GitNotesAuthorshipPrefix) { return nil } diff --git a/detection/message/message.go b/detection/message/message.go deleted file mode 100644 index 60cb561..0000000 --- a/detection/message/message.go +++ /dev/null @@ -1,102 +0,0 @@ -package message - -import ( - "fmt" - "regexp" - "strings" - - "github.com/chaoss/disclosure/detection" -) - -var commitMessagePatterns = []struct { - check func(string) (detection.Confidence, bool) - name string -}{ - { - check: func(msg string) (detection.Confidence, bool) { - return detection.ConfidenceMedium, strings.HasPrefix(strings.ToLower(msg), "aider:") - }, - name: "Aider", - }, - { - check: func(msg string) (detection.Confidence, bool) { - return detection.ConfidenceMedium, strings.Contains(msg, "Generated with Claude Code") - }, - name: "Claude Code", - }, - { - check: func(msg string) (detection.Confidence, bool) { - trailers := []string{ - "Entire-Metadata", - "Entire-Metadata-Task", - "Entire-Strategy", - "Entire-Session", - "Entire-Condensation", - "Entire-Source-Ref", - "Entire-Checkpoint", - "Entire-Agent", - } - for _, trailer := range trailers { - if strings.Contains(msg, fmt.Sprintf("\n%s:", trailer)) { - return detection.ConfidenceMedium, true - } - } - return detection.ConfidenceMedium, false - }, - name: "EntireIO", - }, - { - check: func(msg string) (detection.Confidence, bool) { - trailerRegex := regexp.MustCompile(`(?m)^Replit-Commit-Author:\s*(Agent|Assistant)(?:\r?\nReplit-Commit-Session-Id:\s*([a-fA-F0-9-]+))?(?:\r?\n|$)`) - - matchResult := trailerRegex.FindStringSubmatch(msg) - if len(matchResult) == 0 { - // replit not detected - return detection.ConfidenceMedium, false - } - - var confidence detection.Confidence - switch matchResult[1] { - case "Agent": - confidence = detection.ConfidenceMedium - case "Assistant": - confidence = detection.ConfidenceLow - default: - // unknown replit product, we cannot confirm ai use - return detection.ConfidenceLow, false - } - - // if commit session id also present, increase confidence - if matchResult[2] != "" { - confidence.Increment() - } - - return confidence, true - }, - name: "Replit", - }, -} - -type Detector struct{} - -func (d *Detector) Name() string { return "message" } - -func (d *Detector) Detect(input detection.Input) []detection.Finding { - if input.CommitMessage == "" { - return nil - } - - var findings []detection.Finding - for _, p := range commitMessagePatterns { - if confidence, isDetected := p.check(input.CommitMessage); isDetected { - findings = append(findings, detection.Finding{ - Detector: d.Name(), - Tool: p.name, - Confidence: confidence, - Detail: fmt.Sprintf("commit message matches %s pattern", p.name), - }) - } - } - - return findings -} diff --git a/detection/message/message_test.go b/detection/message/message_test.go deleted file mode 100644 index 39caac0..0000000 --- a/detection/message/message_test.go +++ /dev/null @@ -1,194 +0,0 @@ -package message - -import ( - "testing" - - "github.com/chaoss/disclosure/detection" -) - -func TestDetect(t *testing.T) { - d := &Detector{} - tests := []struct { - name string - message string - wantTools []string - wantConfidence []detection.Confidence - }{ - { - name: "aider prefix", - message: "aider: fix the login bug", - wantTools: []string{"Aider"}, - wantConfidence: []detection.Confidence{detection.ConfidenceMedium}, - }, - { - name: "aider prefix uppercase", - message: "Aider: refactor auth module", - wantTools: []string{"Aider"}, - wantConfidence: []detection.Confidence{detection.ConfidenceMedium}, - }, - { - name: "Claude Code footer", - message: "Add user validation\n\nGenerated with Claude Code", - wantTools: []string{"Claude Code"}, - wantConfidence: []detection.Confidence{detection.ConfidenceMedium}, - }, - { - name: "Claude Code footer with link", - message: "Add validation\n\nGenerated with Claude Code\nhttps://claude.ai", - wantTools: []string{"Claude Code"}, - wantConfidence: []detection.Confidence{detection.ConfidenceMedium}, - }, - { - name: "EntireIO trailer present in commit", - message: "this is some commit message\n\nEntire-Checkpoint: ab123cdefg12", - wantTools: []string{"EntireIO"}, - wantConfidence: []detection.Confidence{detection.ConfidenceMedium}, - }, - { - name: "Another EntireIO trailer present in commit", - message: "this is some commit message\n\nEntire-Metadata: ab123cdefg12", - wantTools: []string{"EntireIO"}, - wantConfidence: []detection.Confidence{detection.ConfidenceMedium}, - }, - { - name: "Another EntireIO trailer present in commit with CRLF line endings", - message: "this is some commit message\r\n\r\nEntire-Metadata: ab123cdefg12", - wantTools: []string{"EntireIO"}, - wantConfidence: []detection.Confidence{detection.ConfidenceMedium}, - }, - { - name: "EntireIO trailer not used, only mentioned in a commit", - message: "this is a commit message with\nEntire-Metadata mentioned", - wantTools: nil, - wantConfidence: nil, - }, - { - name: "Replit Agent trailer present in a commit", - message: "this is a commit message with\nReplit-Commit-Author: Agent", - wantTools: []string{"Replit"}, - wantConfidence: []detection.Confidence{detection.ConfidenceMedium}, - }, - { - name: "Replit Agent trailer present in a commit with session id", - message: "this is a commit message with\nReplit-Commit-Author: Agent\nReplit-Commit-Session-Id: 1234a1ab-12ab-1234-abcd-0123456a1234", - wantTools: []string{"Replit"}, - wantConfidence: []detection.Confidence{detection.ConfidenceHigh}, - }, - { - name: "Replit Assistant trailer present in a commit", - message: "this is a commit message with\nReplit-Commit-Author: Assistant", - wantTools: []string{"Replit"}, - wantConfidence: []detection.Confidence{detection.ConfidenceLow}, - }, - { - name: "Replit Assistant trailer present in a commit with session id", - message: "this is a commit message with\nReplit-Commit-Author: Assistant\nReplit-Commit-Session-Id: 1234a1ab-12ab-1234-abcd-0123456a1234", - wantTools: []string{"Replit"}, - wantConfidence: []detection.Confidence{detection.ConfidenceMedium}, - }, - { - name: "Replit Agent trailer present in commit with CRLF line endings", - message: "this is some commit message\r\n\r\nReplit-Commit-Author: Agent", - wantTools: []string{"Replit"}, - wantConfidence: []detection.Confidence{detection.ConfidenceMedium}, - }, - { - name: "Replit Assistant trailer present in commit with CRLF line endings", - message: "this is some commit message\r\n\r\nReplit-Commit-Author: Assistant", - wantTools: []string{"Replit"}, - wantConfidence: []detection.Confidence{detection.ConfidenceLow}, - }, - { - name: "Replit Agent trailer present in commit with another trailer with CRLF line endings", - message: "this is some commit message\r\n\r\nReplit-Commit-Author: Agent\r\nSomeOther: Trailer", - wantTools: []string{"Replit"}, - wantConfidence: []detection.Confidence{detection.ConfidenceMedium}, - }, - { - name: "Replit Assistant trailer present in commit with another trailer with CRLF line endings", - message: "this is some commit message\r\n\r\nReplit-Commit-Author: Assistant\r\nSomeOther: Trailer", - wantTools: []string{"Replit"}, - wantConfidence: []detection.Confidence{detection.ConfidenceLow}, - }, - { - name: "Some other Replit product trailer (not agent or asst) present in a commit", - message: "this is a commit message with\nReplit-Commit-Author: SomeOtherReplitProduct", - wantTools: nil, - wantConfidence: nil, - }, - { - name: "Replit trailer not used, only mentioned in a commit", - message: "this is a commit message with\nReplit-Commit-Author: Assistant mentioned", - wantTools: nil, - wantConfidence: nil, - }, - { - name: "no patterns", - message: "normal commit message with no AI signatures", - wantTools: nil, - wantConfidence: nil, - }, - { - name: "aider in middle of message not prefix", - message: "fix the aider: integration test", - wantTools: nil, - wantConfidence: nil, - }, - { - name: "aider as substring of a word", - message: "raider: fix the tests", - wantTools: nil, - wantConfidence: nil, - }, - { - name: "empty message", - message: "", - wantTools: nil, - wantConfidence: nil, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - findings := d.Detect(detection.Input{CommitMessage: tt.message}) - gotTools := make([]string, len(findings)) - gotConfidence := make([]detection.Confidence, len(findings)) - for i, f := range findings { - gotTools[i] = f.Tool - gotConfidence[i] = f.Confidence - - if f.Detector != "message" { - t.Errorf("detector = %q, want %q", f.Detector, "message") - } - } - - if len(gotTools) == 0 { - gotTools = nil - } - if len(gotTools) != len(tt.wantTools) { - t.Errorf("tools = %v, want %v", gotTools, tt.wantTools) - return - } - for i := range gotTools { - if gotTools[i] != tt.wantTools[i] { - t.Errorf("tools = %v, want %v", gotTools, tt.wantTools) - return - } - } - - if len(gotConfidence) == 0 { - gotConfidence = nil - } - if len(gotConfidence) != len(tt.wantConfidence) { - t.Errorf("confidence = %v, want %v", gotConfidence, tt.wantConfidence) - return - } - for i := range gotConfidence { - if gotConfidence[i] != tt.wantConfidence[i] { - t.Errorf("confidence = %v, want %v", gotConfidence, tt.wantConfidence) - return - } - } - }) - } -} diff --git a/detection/toolmention/toolmention.go b/detection/toolmention/toolmention.go index 1efa707..f77f88d 100644 --- a/detection/toolmention/toolmention.go +++ b/detection/toolmention/toolmention.go @@ -15,32 +15,7 @@ var toolPatterns []struct { } func init() { - tools := []string{ - "Claude Code", - "Claude", - "GitHub Copilot", - "Copilot", - "Cursor", - "Aider", - "OpenAI Codex", - "Codex", - "Gemini Code Assist", - "Amazon Q Developer", - "Amazon Q", - "Devin", - "Cline", - "Continue.dev", - "Sourcegraph Cody", - "Cody", - "JetBrains AI", - "CodeRabbit", - "ChatGPT", - "t3.chat", - "GPT-4", - "Windsurf", - } - - for _, name := range tools { + for _, name := range detection.SupportedToolsInMentions { escaped := regexp.QuoteMeta(name) pattern := regexp.MustCompile(`(?i)\b` + escaped + `\b`) toolPatterns = append(toolPatterns, struct { diff --git a/detection/trailer/trailer.go b/detection/trailer/trailer.go new file mode 100644 index 0000000..5bc27f5 --- /dev/null +++ b/detection/trailer/trailer.go @@ -0,0 +1,189 @@ +package trailer + +import ( + "fmt" + "regexp" + "slices" + "strings" + + "github.com/chaoss/disclosure/detection" +) + +var toolLineReplacePattern = regexp.MustCompile(`\s*<[^>]+>`) + +func extractToolsFromText(text string) []string { + text = strings.TrimSpace(text) + if text == "" { + return nil + } + text = toolLineReplacePattern.ReplaceAllString(text, "") + parts := strings.Split(text, "\n") + var tools []string + for _, p := range parts { + p = strings.TrimSpace(strings.Split(strings.TrimSpace(p), "(")[0]) + if p == "" { + continue + } + words := strings.Fields(p) + for i, w := range words { + if len(w) > 0 { + words[i] = strings.ToUpper(w[:1]) + w[1:] + } + } + tools = append(tools, strings.Join(words, " ")) + } + return tools +} + +var commitMessagePatterns = []struct { + check func(string) (detection.Confidence, bool) + name string +}{ + { + check: func(msg string) (detection.Confidence, bool) { + return detection.ConfidenceMedium, strings.HasPrefix(strings.ToLower(msg), detection.AiderCommitPrefix) + }, + name: "Aider", + }, + { + check: func(msg string) (detection.Confidence, bool) { + return detection.ConfidenceMedium, strings.Contains(msg, detection.ClaudeAttributionText) + }, + name: "Claude Code", + }, + { + check: func(msg string) (detection.Confidence, bool) { + for _, trailer := range detection.EntireIOTrailers { + if strings.Contains(msg, fmt.Sprintf("\n%s:", trailer)) { + return detection.ConfidenceMedium, true + } + } + return detection.ConfidenceMedium, false + }, + name: "EntireIO", + }, + { + check: func(msg string) (detection.Confidence, bool) { + trailerRegex := regexp.MustCompile(detection.ReplitAttributionRegex) + + matchResult := trailerRegex.FindStringSubmatch(msg) + if len(matchResult) == 0 { + // replit not detected + return detection.ConfidenceMedium, false + } + + var confidence detection.Confidence + switch matchResult[1] { + case "Agent": + confidence = detection.ConfidenceMedium + case "Assistant": + confidence = detection.ConfidenceLow + default: + // unknown replit product, we cannot confirm ai use + return detection.ConfidenceLow, false + } + + // if commit session id also present, increase confidence + if matchResult[2] != "" { + confidence.Increment() + } + + return confidence, true + }, + name: "Replit", + }, +} + +type Detector struct{} + +func (d *Detector) Name() string { return "trailer" } + +func (d *Detector) detectTrailerCoauthoredBy(input detection.Input) []detection.Finding { + var findings []detection.Finding + + matches := detection.CoAuthorPattern.FindAllStringSubmatch(input.CommitMessage, -1) + if len(matches) == 0 { + return findings + } + + seen := map[string]bool{} + for _, match := range matches { + email := strings.ToLower(strings.TrimSpace(match[1])) + + if name, ok := detection.KnownCoAuthorEmails[email]; ok && !seen[name] { + findings = append(findings, detection.Finding{ + Detector: d.Name(), + Tool: name, + Confidence: detection.ConfidenceHigh, + Detail: fmt.Sprintf("Co-Authored-By trailer with email %s", email), + }) + seen[name] = true + } + } + + return findings +} + +func (d *Detector) detectTrailerAssistedBy(input detection.Input) []detection.Finding { + var findings []detection.Finding + + matches := detection.AssistedByPattern.FindAllStringSubmatch( + input.CommitMessage, + -1, + ) + if len(matches) == 0 { + return findings + } + + for _, match := range matches { + if len(match) < 2 { + continue + } + + for _, matchedTool := range extractToolsFromText(match[1]) { + findings = append(findings, detection.Finding{ + Detector: d.Name(), + Tool: matchedTool, + Confidence: detection.ConfidenceHigh, + Detail: fmt.Sprintf("Assisted-By trailer with tool %s", matchedTool), + }) + } + } + return findings +} + +func (d *Detector) detectCustomTrailers(input detection.Input) []detection.Finding { + var findings []detection.Finding + for _, p := range commitMessagePatterns { + if confidence, isDetected := p.check(input.CommitMessage); isDetected { + findings = append(findings, detection.Finding{ + Detector: d.Name(), + Tool: p.name, + Confidence: confidence, + Detail: fmt.Sprintf("commit message matches %s pattern", p.name), + }) + } + } + return findings +} + +func (d *Detector) Detect(input detection.Input) []detection.Finding { + if input.CommitMessage == "" { + return nil + } + + var findings []detection.Finding + + findings = slices.Concat( + // add findings for co-authored-by + d.detectTrailerCoauthoredBy(input), + + // add findings for assisted-by + d.detectTrailerAssistedBy(input), + + // add findings for other custom trailers + d.detectCustomTrailers(input), + ) + + return findings +} diff --git a/detection/trailer/trailer_test.go b/detection/trailer/trailer_test.go new file mode 100644 index 0000000..597b7b0 --- /dev/null +++ b/detection/trailer/trailer_test.go @@ -0,0 +1,389 @@ +package trailer + +import ( + "testing" + + "github.com/chaoss/disclosure/detection" +) + +func TestDetect(t *testing.T) { + d := &Detector{} + tests := []struct { + name string + message string + wantTools []string + wantConfidence []detection.Confidence + }{ + // Co-Authored-By tests start here + { + name: "Claude trailer with Opus model", + message: "fix: update handler\n\nCo-Authored-By: Claude Opus 4 ", + wantTools: []string{"Claude Code"}, + wantConfidence: []detection.Confidence{detection.ConfidenceHigh}, + }, + { + name: "Claude trailer with Sonnet model", + message: "fix: update handler\n\nCo-Authored-By: Claude Sonnet 4 ", + wantTools: []string{"Claude Code"}, + wantConfidence: []detection.Confidence{detection.ConfidenceHigh}, + }, + { + name: "Cursor trailer", + message: "refactor: extract method\n\nCo-Authored-By: Cursor ", + wantTools: []string{"Cursor"}, + wantConfidence: []detection.Confidence{detection.ConfidenceHigh}, + }, + { + name: "Aider trailer with model name", + message: "feat: add endpoint\n\nCo-Authored-By: aider (gpt-4o) ", + wantTools: []string{"Aider"}, + wantConfidence: []detection.Confidence{detection.ConfidenceHigh}, + }, + { + name: "Aider trailer with different model", + message: "feat: add endpoint\n\nCo-Authored-By: aider (claude-3.5-sonnet) ", + wantTools: []string{"Aider"}, + wantConfidence: []detection.Confidence{detection.ConfidenceHigh}, + }, + { + 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"}, + wantConfidence: []detection.Confidence{detection.ConfidenceHigh}, + }, + { + 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"}, + wantConfidence: []detection.Confidence{detection.ConfidenceHigh, detection.ConfidenceHigh}, + }, + { + name: "case variation", + message: "fix: thing\n\nco-authored-by: Claude ", + wantTools: []string{"Claude Code"}, + wantConfidence: []detection.Confidence{detection.ConfidenceHigh}, + }, + { + name: "CO-AUTHORED-BY uppercase", + message: "fix: thing\n\nCO-AUTHORED-BY: Claude ", + wantTools: []string{"Claude Code"}, + wantConfidence: []detection.Confidence{detection.ConfidenceHigh}, + }, + { + name: "human co-author only", + message: "pair programming\n\nCo-Authored-By: Bob ", + wantTools: nil, + wantConfidence: nil, + }, + // Co-Authored-By tests end here + + // Assisted-By tests start here + { + name: "Claude trailer with Opus model", + message: "fix: update handler\n\nAssisted-By: Claude Opus 4 ", + wantTools: []string{"Claude Opus 4"}, + wantConfidence: []detection.Confidence{detection.ConfidenceHigh}, + }, + { + name: "Claude trailer with Sonnet model", + message: "fix: update handler\n\nAssisted-By: Claude Sonnet 4 ", + wantTools: []string{"Claude Sonnet 4"}, + wantConfidence: []detection.Confidence{detection.ConfidenceHigh}, + }, + { + name: "Cursor trailer", + message: "refactor: extract method\n\nAssisted-By: Cursor ", + wantTools: []string{"Cursor"}, + wantConfidence: []detection.Confidence{detection.ConfidenceHigh}, + }, + { + name: "Aider trailer with model name", + message: "feat: add endpoint\n\nAssisted-By: aider (gpt-4o) ", + wantTools: []string{"Aider"}, + wantConfidence: []detection.Confidence{detection.ConfidenceHigh}, + }, + { + name: "Aider trailer with different model", + message: "feat: add endpoint\n\nAssisted-By: aider (claude-4.7-opus) ", + wantTools: []string{"Aider"}, + wantConfidence: []detection.Confidence{detection.ConfidenceHigh}, + }, + { + name: "multiple trailers with Claude and human", + message: "fix: bug\n\nAssisted-By: Claude Opus 4 ", + wantTools: []string{"Claude Opus 4"}, + wantConfidence: []detection.Confidence{detection.ConfidenceHigh}, + }, + { + name: "multiple AI trailers", + message: "fix: bug\n\nAssisted-By: Claude Opus 4 \nAssisted-By: aider (gpt-4o) ", + wantTools: []string{"Claude Opus 4", "Aider"}, + wantConfidence: []detection.Confidence{detection.ConfidenceHigh, detection.ConfidenceHigh}, + }, + { + name: "case variation", + message: "fix: something\n\nassisted-by: Claude ", + wantTools: []string{"Claude"}, + wantConfidence: []detection.Confidence{detection.ConfidenceHigh}, + }, + { + name: "ASSISTED-BY uppercase", + message: "fix: something\n\nASSISTED-BY: Claude ", + wantTools: []string{"Claude"}, + wantConfidence: []detection.Confidence{detection.ConfidenceHigh}, + }, + { + name: "Assisted-By trailer in commit message", + message: "this is a commit message with\nAssisted-By: Claude Code", + wantTools: []string{"Claude Code"}, + wantConfidence: []detection.Confidence{detection.ConfidenceHigh}, + }, + { + name: "Another Assisted-By trailer in commit message 1", + message: "this is a commit message with\nAssisted-By: Gemini", + wantTools: []string{"Gemini"}, + wantConfidence: []detection.Confidence{detection.ConfidenceHigh}, + }, + { + name: "Another Assisted-By trailer in commit message 2", + message: "this is a commit message with\nAssisted-By: Kimi K2.6", + wantTools: []string{"Kimi K2.6"}, + wantConfidence: []detection.Confidence{detection.ConfidenceHigh}, + }, + { + name: "Multiple Assisted-By trailer in commit message", + message: "this is a commit message with\nAssisted-By: Claude Code\nAssisted-By: Gemini", + wantTools: []string{"Claude Code", "Gemini"}, + wantConfidence: []detection.Confidence{detection.ConfidenceHigh, detection.ConfidenceHigh}, + }, + { + name: "Multiple Assisted-By trailers (with purpose brackets) in commit message", + message: ` +this is a commit message + +Co-Authored-By: Cursor +Assisted-by: Claude 4.7 Opus + (logic optimization and design fixes) +Assisted-By: Claude Sonnet 4 +Assisted-by: Kimi K2.6 (unit tests, integration tests) +Assisted-by: ChatGPT (documentation review) +Assisted-by: Gemini +Co-Authored-By: Copilot +Signed-off-by: some human +`, + wantTools: []string{"Cursor", "Copilot", "Claude 4.7 Opus", "Claude Sonnet 4", "Kimi K2.6", "ChatGPT", "Gemini"}, + wantConfidence: []detection.Confidence{ + detection.ConfidenceHigh, // Cursor + detection.ConfidenceHigh, // Copilot + detection.ConfidenceHigh, // Claude 4.7 Opus + detection.ConfidenceHigh, // Claude Sonnet 4 + detection.ConfidenceHigh, // Kimi K2.6 + detection.ConfidenceHigh, // ChatGPT + detection.ConfidenceHigh, // Gemini + }, + }, + { + name: "Assisted-By trailer in commit message in lower case", + message: "this is a commit message with\nassisted-by: Claude Code", + wantTools: []string{"Claude Code"}, + wantConfidence: []detection.Confidence{detection.ConfidenceHigh}, + }, + { + name: "Two different attributions (assistedby and coauthor) both with email address", + message: "Fix bug\n\nAssisted-By: Claude Sonnet 4 \nCo-Authored-By: Copilot ", + wantTools: []string{"Copilot", "Claude Sonnet 4"}, + wantConfidence: []detection.Confidence{detection.ConfidenceHigh, detection.ConfidenceHigh}, + }, + { + name: "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"}, + wantConfidence: []detection.Confidence{detection.ConfidenceHigh, detection.ConfidenceHigh}, + }, + { + name: "Claude Opus model attribution trailer", + message: "Fix bug\n\nAssisted-by: Claude Opus 4 ", + wantTools: []string{"Claude Opus 4"}, + wantConfidence: []detection.Confidence{detection.ConfidenceHigh}, + }, + //Assisted-by tests end here + + // Other trailer tests start here + { + name: "aider prefix", + message: "aider: fix the login bug", + wantTools: []string{"Aider"}, + wantConfidence: []detection.Confidence{detection.ConfidenceMedium}, + }, + { + name: "aider prefix uppercase", + message: "Aider: refactor auth module", + wantTools: []string{"Aider"}, + wantConfidence: []detection.Confidence{detection.ConfidenceMedium}, + }, + { + name: "Claude Code footer", + message: "Add user validation\n\nGenerated with Claude Code", + wantTools: []string{"Claude Code"}, + wantConfidence: []detection.Confidence{detection.ConfidenceMedium}, + }, + { + name: "Claude Code footer with link", + message: "Add validation\n\nGenerated with Claude Code\nhttps://claude.ai", + wantTools: []string{"Claude Code"}, + wantConfidence: []detection.Confidence{detection.ConfidenceMedium}, + }, + { + name: "EntireIO trailer present in commit", + message: "this is some commit message\n\nEntire-Checkpoint: ab123cdefg12", + wantTools: []string{"EntireIO"}, + wantConfidence: []detection.Confidence{detection.ConfidenceMedium}, + }, + { + name: "Another EntireIO trailer present in commit", + message: "this is some commit message\n\nEntire-Metadata: ab123cdefg12", + wantTools: []string{"EntireIO"}, + wantConfidence: []detection.Confidence{detection.ConfidenceMedium}, + }, + { + name: "Another EntireIO trailer present in commit with CRLF line endings", + message: "this is some commit message\r\n\r\nEntire-Metadata: ab123cdefg12", + wantTools: []string{"EntireIO"}, + wantConfidence: []detection.Confidence{detection.ConfidenceMedium}, + }, + { + name: "EntireIO trailer not used, only mentioned in a commit", + message: "this is a commit message with\nEntire-Metadata mentioned", + wantTools: nil, + wantConfidence: nil, + }, + { + name: "Replit Agent trailer present in a commit", + message: "this is a commit message with\nReplit-Commit-Author: Agent", + wantTools: []string{"Replit"}, + wantConfidence: []detection.Confidence{detection.ConfidenceMedium}, + }, + { + name: "Replit Agent trailer present in a commit with session id", + message: "this is a commit message with\nReplit-Commit-Author: Agent\nReplit-Commit-Session-Id: 1234a1ab-12ab-1234-abcd-0123456a1234", + wantTools: []string{"Replit"}, + wantConfidence: []detection.Confidence{detection.ConfidenceHigh}, + }, + { + name: "Replit Assistant trailer present in a commit", + message: "this is a commit message with\nReplit-Commit-Author: Assistant", + wantTools: []string{"Replit"}, + wantConfidence: []detection.Confidence{detection.ConfidenceLow}, + }, + { + name: "Replit Assistant trailer present in a commit with session id", + message: "this is a commit message with\nReplit-Commit-Author: Assistant\nReplit-Commit-Session-Id: 1234a1ab-12ab-1234-abcd-0123456a1234", + wantTools: []string{"Replit"}, + wantConfidence: []detection.Confidence{detection.ConfidenceMedium}, + }, + { + name: "Replit Agent trailer present in commit with CRLF line endings", + message: "this is some commit message\r\n\r\nReplit-Commit-Author: Agent", + wantTools: []string{"Replit"}, + wantConfidence: []detection.Confidence{detection.ConfidenceMedium}, + }, + { + name: "Replit Assistant trailer present in commit with CRLF line endings", + message: "this is some commit message\r\n\r\nReplit-Commit-Author: Assistant", + wantTools: []string{"Replit"}, + wantConfidence: []detection.Confidence{detection.ConfidenceLow}, + }, + { + name: "Replit Agent trailer present in commit with another trailer with CRLF line endings", + message: "this is some commit message\r\n\r\nReplit-Commit-Author: Agent\r\nSomeOther: Trailer", + wantTools: []string{"Replit"}, + wantConfidence: []detection.Confidence{detection.ConfidenceMedium}, + }, + { + name: "Replit Assistant trailer present in commit with another trailer with CRLF line endings", + message: "this is some commit message\r\n\r\nReplit-Commit-Author: Assistant\r\nSomeOther: Trailer", + wantTools: []string{"Replit"}, + wantConfidence: []detection.Confidence{detection.ConfidenceLow}, + }, + { + name: "Some other Replit product trailer (not agent or asst) present in a commit", + message: "this is a commit message with\nReplit-Commit-Author: SomeOtherReplitProduct", + wantTools: nil, + wantConfidence: nil, + }, + { + name: "Replit trailer not used, only mentioned in a commit", + message: "this is a commit message with\nReplit-Commit-Author: Assistant mentioned", + wantTools: nil, + wantConfidence: nil, + }, + { + name: "aider in middle of message not prefix", + message: "fix the aider: integration test", + wantTools: nil, + wantConfidence: nil, + }, + { + name: "aider as substring of a word", + message: "raider: fix the tests", + wantTools: nil, + wantConfidence: nil, + }, + { + name: "no trailers", + message: "just a normal commit message with no AI signatures", + wantTools: nil, + wantConfidence: nil, + }, + { + name: "empty message", + message: "", + wantTools: nil, + wantConfidence: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + findings := d.Detect(detection.Input{CommitMessage: tt.message}) + gotTools := make([]string, len(findings)) + gotConfidence := make([]detection.Confidence, len(findings)) + for i, f := range findings { + gotTools[i] = f.Tool + gotConfidence[i] = f.Confidence + + if f.Detector != "trailer" { + t.Errorf("detector = %q, want %q", f.Detector, "trailer") + } + } + + if len(gotTools) == 0 { + gotTools = nil + } + if len(gotTools) != len(tt.wantTools) { + t.Errorf("tools = %v, want %v", gotTools, tt.wantTools) + return + } + for i := range gotTools { + if gotTools[i] != tt.wantTools[i] { + t.Errorf("tools = %v, want %v", gotTools, tt.wantTools) + return + } + } + + if len(gotConfidence) == 0 { + gotConfidence = nil + } + if len(gotConfidence) != len(tt.wantConfidence) { + t.Errorf("confidence = %v, want %v", gotConfidence, tt.wantConfidence) + return + } + for i := range gotConfidence { + if gotConfidence[i] != tt.wantConfidence[i] { + t.Errorf("confidence = %v, want %v", gotConfidence, tt.wantConfidence) + return + } + } + }) + } +} diff --git a/output/output_test.go b/output/output_test.go index f7ee2fe..37af196 100644 --- a/output/output_test.go +++ b/output/output_test.go @@ -17,7 +17,7 @@ func sampleReport() scan.Report { Hash: "abc123def456", Findings: []detection.Finding{ { - Detector: "coauthor", + Detector: "trailer", Tool: "Claude Code", Confidence: detection.ConfidenceHigh, Detail: "Co-Authored-By trailer with email noreply@anthropic.com", diff --git a/scan/scan_test.go b/scan/scan_test.go index 06829fb..6678348 100644 --- a/scan/scan_test.go +++ b/scan/scan_test.go @@ -8,23 +8,19 @@ import ( "time" "github.com/chaoss/disclosure/detection" - "github.com/chaoss/disclosure/detection/assistedby" - "github.com/chaoss/disclosure/detection/coauthor" "github.com/chaoss/disclosure/detection/committer" "github.com/chaoss/disclosure/detection/gitnotes" - "github.com/chaoss/disclosure/detection/message" "github.com/chaoss/disclosure/detection/toolmention" + "github.com/chaoss/disclosure/detection/trailer" "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing/object" ) func allDetectors() []detection.Detector { return []detection.Detector{ - &assistedby.Detector{}, &committer.Detector{}, - &coauthor.Detector{}, &gitnotes.Detector{}, - &message.Detector{}, + &trailer.Detector{}, &toolmention.Detector{}, } } @@ -173,9 +169,9 @@ func TestScanCommit(t *testing.T) { foundCoauthor := false foundAssistedBy := false for _, f := range result.Findings { - if f.Detector == "coauthor" && f.Tool == "Cursor" { + if f.Detector == "trailer" && f.Tool == "Cursor" { foundCoauthor = true - } else if f.Detector == "assistedby" && f.Tool == "Kimi K2.6" { + } else if f.Detector == "trailer" && f.Tool == "Kimi K2.6" { foundAssistedBy = true } } From e6605b5c2885793939e05383c2f1485df283ef4b Mon Sep 17 00:00:00 2001 From: Omkar P <45419097+omkar-foss@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:54:59 +0530 Subject: [PATCH 3/5] Add disclosure dedupe, prefix similar test names Signed-off-by: Omkar P <45419097+omkar-foss@users.noreply.github.com> --- detection/trailer/trailer.go | 10 ++++- detection/trailer/trailer_test.go | 62 +++++++++++++++++-------------- 2 files changed, 42 insertions(+), 30 deletions(-) diff --git a/detection/trailer/trailer.go b/detection/trailer/trailer.go index 5bc27f5..884157b 100644 --- a/detection/trailer/trailer.go +++ b/detection/trailer/trailer.go @@ -135,24 +135,30 @@ func (d *Detector) detectTrailerAssistedBy(input detection.Input) []detection.Fi return findings } + seen := map[string]bool{} for _, match := range matches { if len(match) < 2 { continue } for _, matchedTool := range extractToolsFromText(match[1]) { + if seen[matchedTool] { + continue + } + findings = append(findings, detection.Finding{ Detector: d.Name(), Tool: matchedTool, Confidence: detection.ConfidenceHigh, Detail: fmt.Sprintf("Assisted-By trailer with tool %s", matchedTool), }) + seen[matchedTool] = true } } return findings } -func (d *Detector) detectCustomTrailers(input detection.Input) []detection.Finding { +func (d *Detector) detectMessagePatterns(input detection.Input) []detection.Finding { var findings []detection.Finding for _, p := range commitMessagePatterns { if confidence, isDetected := p.check(input.CommitMessage); isDetected { @@ -182,7 +188,7 @@ func (d *Detector) Detect(input detection.Input) []detection.Finding { d.detectTrailerAssistedBy(input), // add findings for other custom trailers - d.detectCustomTrailers(input), + d.detectMessagePatterns(input), ) return findings diff --git a/detection/trailer/trailer_test.go b/detection/trailer/trailer_test.go index 597b7b0..a23a425 100644 --- a/detection/trailer/trailer_test.go +++ b/detection/trailer/trailer_test.go @@ -16,61 +16,61 @@ func TestDetect(t *testing.T) { }{ // Co-Authored-By tests start here { - name: "Claude trailer with Opus model", + name: "coauthor: Claude trailer with Opus model", message: "fix: update handler\n\nCo-Authored-By: Claude Opus 4 ", wantTools: []string{"Claude Code"}, wantConfidence: []detection.Confidence{detection.ConfidenceHigh}, }, { - name: "Claude trailer with Sonnet model", + name: "coauthor: Claude trailer with Sonnet model", message: "fix: update handler\n\nCo-Authored-By: Claude Sonnet 4 ", wantTools: []string{"Claude Code"}, wantConfidence: []detection.Confidence{detection.ConfidenceHigh}, }, { - name: "Cursor trailer", + name: "coauthor: Cursor trailer", message: "refactor: extract method\n\nCo-Authored-By: Cursor ", wantTools: []string{"Cursor"}, wantConfidence: []detection.Confidence{detection.ConfidenceHigh}, }, { - name: "Aider trailer with model name", + name: "coauthor: Aider trailer with model name", message: "feat: add endpoint\n\nCo-Authored-By: aider (gpt-4o) ", wantTools: []string{"Aider"}, wantConfidence: []detection.Confidence{detection.ConfidenceHigh}, }, { - name: "Aider trailer with different model", + name: "coauthor: Aider trailer with different model", message: "feat: add endpoint\n\nCo-Authored-By: aider (claude-3.5-sonnet) ", wantTools: []string{"Aider"}, wantConfidence: []detection.Confidence{detection.ConfidenceHigh}, }, { - name: "multiple trailers with Claude and human", + 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"}, wantConfidence: []detection.Confidence{detection.ConfidenceHigh}, }, { - name: "multiple AI trailers", + 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"}, wantConfidence: []detection.Confidence{detection.ConfidenceHigh, detection.ConfidenceHigh}, }, { - name: "case variation", + name: "coauthor: case variation", message: "fix: thing\n\nco-authored-by: Claude ", wantTools: []string{"Claude Code"}, wantConfidence: []detection.Confidence{detection.ConfidenceHigh}, }, { - name: "CO-AUTHORED-BY uppercase", + name: "coauthor: CO-AUTHORED-BY uppercase", message: "fix: thing\n\nCO-AUTHORED-BY: Claude ", wantTools: []string{"Claude Code"}, wantConfidence: []detection.Confidence{detection.ConfidenceHigh}, }, { - name: "human co-author only", + name: "coauthor: human co-author only", message: "pair programming\n\nCo-Authored-By: Bob ", wantTools: nil, wantConfidence: nil, @@ -79,85 +79,85 @@ func TestDetect(t *testing.T) { // Assisted-By tests start here { - name: "Claude trailer with Opus model", + name: "assistedby: Claude trailer with Opus model", message: "fix: update handler\n\nAssisted-By: Claude Opus 4 ", wantTools: []string{"Claude Opus 4"}, wantConfidence: []detection.Confidence{detection.ConfidenceHigh}, }, { - name: "Claude trailer with Sonnet model", + name: "assistedby: Claude trailer with Sonnet model", message: "fix: update handler\n\nAssisted-By: Claude Sonnet 4 ", wantTools: []string{"Claude Sonnet 4"}, wantConfidence: []detection.Confidence{detection.ConfidenceHigh}, }, { - name: "Cursor trailer", + name: "assistedby: Cursor trailer", message: "refactor: extract method\n\nAssisted-By: Cursor ", wantTools: []string{"Cursor"}, wantConfidence: []detection.Confidence{detection.ConfidenceHigh}, }, { - name: "Aider trailer with model name", + name: "assistedby: Aider trailer with model name", message: "feat: add endpoint\n\nAssisted-By: aider (gpt-4o) ", wantTools: []string{"Aider"}, wantConfidence: []detection.Confidence{detection.ConfidenceHigh}, }, { - name: "Aider trailer with different model", + name: "assistedby: Aider trailer with different model", message: "feat: add endpoint\n\nAssisted-By: aider (claude-4.7-opus) ", wantTools: []string{"Aider"}, wantConfidence: []detection.Confidence{detection.ConfidenceHigh}, }, { - name: "multiple trailers with Claude and human", + name: "assistedby: multiple trailers with Claude and human", message: "fix: bug\n\nAssisted-By: Claude Opus 4 ", wantTools: []string{"Claude Opus 4"}, wantConfidence: []detection.Confidence{detection.ConfidenceHigh}, }, { - name: "multiple AI trailers", + name: "assistedby: multiple AI trailers", message: "fix: bug\n\nAssisted-By: Claude Opus 4 \nAssisted-By: aider (gpt-4o) ", wantTools: []string{"Claude Opus 4", "Aider"}, wantConfidence: []detection.Confidence{detection.ConfidenceHigh, detection.ConfidenceHigh}, }, { - name: "case variation", + name: "assistedby: case variation", message: "fix: something\n\nassisted-by: Claude ", wantTools: []string{"Claude"}, wantConfidence: []detection.Confidence{detection.ConfidenceHigh}, }, { - name: "ASSISTED-BY uppercase", + name: "assistedby: ASSISTED-BY uppercase", message: "fix: something\n\nASSISTED-BY: Claude ", wantTools: []string{"Claude"}, wantConfidence: []detection.Confidence{detection.ConfidenceHigh}, }, { - name: "Assisted-By trailer in commit message", + name: "assistedby: Assisted-By trailer in commit message", message: "this is a commit message with\nAssisted-By: Claude Code", wantTools: []string{"Claude Code"}, wantConfidence: []detection.Confidence{detection.ConfidenceHigh}, }, { - name: "Another Assisted-By trailer in commit message 1", + name: "assistedby: Another Assisted-By trailer in commit message 1", message: "this is a commit message with\nAssisted-By: Gemini", wantTools: []string{"Gemini"}, wantConfidence: []detection.Confidence{detection.ConfidenceHigh}, }, { - name: "Another Assisted-By trailer in commit message 2", + name: "assistedby: Another Assisted-By trailer in commit message 2", message: "this is a commit message with\nAssisted-By: Kimi K2.6", wantTools: []string{"Kimi K2.6"}, wantConfidence: []detection.Confidence{detection.ConfidenceHigh}, }, { - name: "Multiple Assisted-By trailer in commit message", + name: "assistedby: Multiple Assisted-By trailer in commit message", message: "this is a commit message with\nAssisted-By: Claude Code\nAssisted-By: Gemini", wantTools: []string{"Claude Code", "Gemini"}, wantConfidence: []detection.Confidence{detection.ConfidenceHigh, detection.ConfidenceHigh}, }, { - name: "Multiple Assisted-By trailers (with purpose brackets) in commit message", + name: "assistedby: Multiple Assisted-By trailers (with purpose brackets) in commit message", message: ` this is a commit message @@ -183,25 +183,31 @@ Signed-off-by: some human }, }, { - name: "Assisted-By trailer in commit message in lower case", + name: "assistedby: Same tool has 2 Assisted-By trailers in commit message", + message: "this is a commit message with\nAssisted-By: Claude Code\nAssisted-By: Claude Code", + wantTools: []string{"Claude Code"}, + wantConfidence: []detection.Confidence{detection.ConfidenceHigh}, + }, + { + name: "assistedby: Assisted-By trailer in commit message in lower case", message: "this is a commit message with\nassisted-by: Claude Code", wantTools: []string{"Claude Code"}, wantConfidence: []detection.Confidence{detection.ConfidenceHigh}, }, { - name: "Two different attributions (assistedby and coauthor) both with email address", + name: "assistedby: Two different attributions (assistedby and coauthor) both with email address", message: "Fix bug\n\nAssisted-By: Claude Sonnet 4 \nCo-Authored-By: Copilot ", wantTools: []string{"Copilot", "Claude Sonnet 4"}, wantConfidence: []detection.Confidence{detection.ConfidenceHigh, detection.ConfidenceHigh}, }, { - name: "Two different attributions (assistedby and coauthor) one with model name, other with email address", + 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"}, wantConfidence: []detection.Confidence{detection.ConfidenceHigh, detection.ConfidenceHigh}, }, { - name: "Claude Opus model attribution trailer", + name: "assistedby: Claude Opus model attribution trailer", message: "Fix bug\n\nAssisted-by: Claude Opus 4 ", wantTools: []string{"Claude Opus 4"}, wantConfidence: []detection.Confidence{detection.ConfidenceHigh}, From f6fe0e22d75f2926e8b6bfc7430c24b9f2c15f15 Mon Sep 17 00:00:00 2001 From: Omkar P <45419097+omkar-foss@users.noreply.github.com> Date: Wed, 8 Jul 2026 16:04:01 +0530 Subject: [PATCH 4/5] Remove redundant declaration Signed-off-by: Omkar P <45419097+omkar-foss@users.noreply.github.com> --- detection/trailer/trailer.go | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/detection/trailer/trailer.go b/detection/trailer/trailer.go index 884157b..3169d3f 100644 --- a/detection/trailer/trailer.go +++ b/detection/trailer/trailer.go @@ -178,9 +178,7 @@ func (d *Detector) Detect(input detection.Input) []detection.Finding { return nil } - var findings []detection.Finding - - findings = slices.Concat( + return slices.Concat( // add findings for co-authored-by d.detectTrailerCoauthoredBy(input), @@ -190,6 +188,4 @@ func (d *Detector) Detect(input detection.Input) []detection.Finding { // add findings for other custom trailers d.detectMessagePatterns(input), ) - - return findings } From d60bc5b70bee78c7bb67d8fd3eb34b63afd3f0c1 Mon Sep 17 00:00:00 2001 From: Omkar P <45419097+omkar-foss@users.noreply.github.com> Date: Wed, 8 Jul 2026 18:16:36 +0530 Subject: [PATCH 5/5] Make extractToolFromText simpler Signed-off-by: Omkar P <45419097+omkar-foss@users.noreply.github.com> --- detection/trailer/trailer.go | 64 ++++++++++++++++-------------------- go.mod | 1 + 2 files changed, 30 insertions(+), 35 deletions(-) diff --git a/detection/trailer/trailer.go b/detection/trailer/trailer.go index 3169d3f..1be1db7 100644 --- a/detection/trailer/trailer.go +++ b/detection/trailer/trailer.go @@ -7,32 +7,30 @@ import ( "strings" "github.com/chaoss/disclosure/detection" + "golang.org/x/text/cases" + "golang.org/x/text/language" ) -var toolLineReplacePattern = regexp.MustCompile(`\s*<[^>]+>`) +var trailerEmailPattern = regexp.MustCompile(`\s*<[^>]+>`) -func extractToolsFromText(text string) []string { +func extractToolFromText(text string) string { text = strings.TrimSpace(text) if text == "" { - return nil + return "" } - text = toolLineReplacePattern.ReplaceAllString(text, "") - parts := strings.Split(text, "\n") - var tools []string - for _, p := range parts { - p = strings.TrimSpace(strings.Split(strings.TrimSpace(p), "(")[0]) - if p == "" { - continue - } - words := strings.Fields(p) - for i, w := range words { - if len(w) > 0 { - words[i] = strings.ToUpper(w[:1]) + w[1:] - } - } - tools = append(tools, strings.Join(words, " ")) + + // removing the email part e.g. Aider + text = strings.TrimSpace(trailerEmailPattern.ReplaceAllString(text, "")) + + // trimming any values in brackets e.g. Claude (Anthropic) + text, _, _ = strings.Cut(text, "(") + + text = strings.TrimSpace(text) + if text == "" { + return "" } - return tools + + return cases.Title(language.English, cases.NoLower).String(text) } var commitMessagePatterns = []struct { @@ -127,10 +125,7 @@ func (d *Detector) detectTrailerCoauthoredBy(input detection.Input) []detection. func (d *Detector) detectTrailerAssistedBy(input detection.Input) []detection.Finding { var findings []detection.Finding - matches := detection.AssistedByPattern.FindAllStringSubmatch( - input.CommitMessage, - -1, - ) + matches := detection.AssistedByPattern.FindAllStringSubmatch(input.CommitMessage, -1) if len(matches) == 0 { return findings } @@ -141,19 +136,18 @@ func (d *Detector) detectTrailerAssistedBy(input detection.Input) []detection.Fi continue } - for _, matchedTool := range extractToolsFromText(match[1]) { - if seen[matchedTool] { - continue - } - - findings = append(findings, detection.Finding{ - Detector: d.Name(), - Tool: matchedTool, - Confidence: detection.ConfidenceHigh, - Detail: fmt.Sprintf("Assisted-By trailer with tool %s", matchedTool), - }) - seen[matchedTool] = true + matchedTool := extractToolFromText(match[1]) + if matchedTool == "" || seen[matchedTool] { + continue } + + findings = append(findings, detection.Finding{ + Detector: d.Name(), + Tool: matchedTool, + Confidence: detection.ConfidenceHigh, + Detail: fmt.Sprintf("Assisted-By trailer with tool %s", matchedTool), + }) + seen[matchedTool] = true } return findings } diff --git a/go.mod b/go.mod index 64f3e66..d79633a 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.24.0 require ( github.com/go-git/go-git/v5 v5.16.5 github.com/spf13/cobra v1.10.2 + golang.org/x/text v0.31.0 ) require (