From e46e9757ccc603dd56d91e71745f68593595f200 Mon Sep 17 00:00:00 2001 From: Shehroz Riaz Date: Fri, 21 Aug 2026 14:34:20 +0500 Subject: [PATCH] fix: classify large modifications as feat instead of fix A 1-line typo fix and a 200+ line rewrite were both landing on 'fix:' because inferType() only looked at file status (A/M/D), never diff size. Modified files whose total changed lines (additions + deletions) reach largeChangeThreshold (50) now classify as feat. Found via dogfooding on a real 224-line change (src/app.py in ai-research-summarizer) that was incorrectly labeled fix. --- internal/generator/heuristic/heuristic.go | 21 ++++++++++---- .../generator/heuristic/heuristic_test.go | 28 +++++++++++++++++++ 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/internal/generator/heuristic/heuristic.go b/internal/generator/heuristic/heuristic.go index 622ab44..cad852c 100644 --- a/internal/generator/heuristic/heuristic.go +++ b/internal/generator/heuristic/heuristic.go @@ -24,6 +24,12 @@ func New() *Generator { func (g *Generator) Name() string { return "heuristic" } +// largeChangeThreshold is the total number of changed lines (additions + +// deletions) across modified-only files above which we treat the change +// as substantial enough to be "feat" rather than "fix". A one-line typo +// fix and a 200-line rewrite should not both be labeled "fix". +const largeChangeThreshold = 50 + // Generate drafts a Conventional Commits message from the staged diff // using only path/status heuristics — no network, no LLM. func (g *Generator) Generate(diff *gitutil.StagedDiff) (generator.Message, error) { @@ -43,11 +49,12 @@ func (g *Generator) Generate(diff *gitutil.StagedDiff) (generator.Message, error // files. Rules are checked in priority order; the first match wins. func inferType(files []gitutil.FileChange) string { var ( - allTest = true - allDocs = true - hasNewFile = false - hasDeleted = false - hasCI = false + allTest = true + allDocs = true + hasNewFile = false + hasDeleted = false + hasCI = false + totalModifiedLines = 0 ) for _, f := range files { @@ -68,6 +75,8 @@ func inferType(files []gitutil.FileChange) string { hasNewFile = true case "D": hasDeleted = true + case "M": + totalModifiedLines += f.Additions + f.Deletions } } @@ -82,6 +91,8 @@ func inferType(files []gitutil.FileChange) string { return "chore" case hasNewFile: return "feat" + case totalModifiedLines >= largeChangeThreshold: + return "feat" default: return "fix" } diff --git a/internal/generator/heuristic/heuristic_test.go b/internal/generator/heuristic/heuristic_test.go index 6a0b521..6dc1358 100644 --- a/internal/generator/heuristic/heuristic_test.go +++ b/internal/generator/heuristic/heuristic_test.go @@ -72,6 +72,34 @@ func TestGenerate(t *testing.T) { }, wantPrefix: "feat(a): add 1, update 1, remove 1 files", }, + { + name: "small modification stays fix", + files: []gitutil.FileChange{ + {Path: "src/app.py", Status: "M", Additions: 10, Deletions: 5}, + }, + wantPrefix: "fix", + }, + { + name: "large modification becomes feat", + files: []gitutil.FileChange{ + {Path: "src/app.py", Status: "M", Additions: 200, Deletions: 24}, + }, + wantPrefix: "feat(src): update src/app.py", + }, + { + name: "modification exactly at threshold becomes feat", + files: []gitutil.FileChange{ + {Path: "src/app.py", Status: "M", Additions: 40, Deletions: 10}, + }, + wantPrefix: "feat", + }, + { + name: "modification just under threshold stays fix", + files: []gitutil.FileChange{ + {Path: "src/app.py", Status: "M", Additions: 30, Deletions: 19}, + }, + wantPrefix: "fix", + }, } g := New()