@@ -110,35 +110,43 @@ func NewTemplater(templateFile string, hist *history.CommitHistory) (*Templater,
110110
111111// GetMessage selects and formats a commit message
112112func (t * Templater ) GetMessage (msg * analyzer.CommitMessage ) (string , error ) {
113- // Map analyzer action names (feat, fix, refactor, chore, docs, test, etc.)
114- // to the template groups used in templates.json (A, M, D, R, DOC, MISC)
115- actionMap := map [string ]string {
116- "feat" : "A" ,
117- "add" : "A" ,
118- "fix" : "M" ,
119- "bugfix" : "M" ,
120- "refactor" : "R" ,
121- "chore" : "D" ,
122- "test" : "M" ,
123- "docs" : "DOC" ,
124- "ci" : "M" ,
125- "perf" : "M" ,
126- "style" : "MISC" ,
127- "build" : "MISC" ,
128- "security" : "SECURITY" ,
129- }
130-
131- // Normalize and resolve action group
132- actionLower := strings .ToLower (msg .Action )
113+ // Check if this is a special file that needs dedicated handling
114+ specialGroup := resolveSpecialFile (msg )
133115 var actionKey string
134- if key , ok := actionMap [actionLower ]; ok {
135- actionKey = key
136- } else if len (msg .Action ) == 1 {
137- // Already a single-letter action like A/M/D/R
138- actionKey = strings .ToUpper (msg .Action )
116+
117+ if specialGroup != "" {
118+ // Force use of special template group
119+ actionKey = specialGroup
139120 } else {
140- // fallback to MISC
141- actionKey = "MISC"
121+ // Map analyzer action names (feat, fix, refactor, chore, docs, test, etc.)
122+ // to the template groups used in templates.json (A, M, D, R, DOC, MISC)
123+ actionMap := map [string ]string {
124+ "feat" : "A" ,
125+ "add" : "A" ,
126+ "fix" : "M" ,
127+ "bugfix" : "M" ,
128+ "refactor" : "R" ,
129+ "chore" : "D" ,
130+ "test" : "M" ,
131+ "docs" : "DOC" ,
132+ "ci" : "M" ,
133+ "perf" : "M" ,
134+ "style" : "MISC" ,
135+ "build" : "MISC" ,
136+ "security" : "SECURITY" ,
137+ }
138+
139+ // Normalize and resolve action group
140+ actionLower := strings .ToLower (msg .Action )
141+ if key , ok := actionMap [actionLower ]; ok {
142+ actionKey = key
143+ } else if len (msg .Action ) == 1 {
144+ // Already a single-letter action like A/M/D/R
145+ actionKey = strings .ToUpper (msg .Action )
146+ } else {
147+ // fallback to MISC
148+ actionKey = "MISC"
149+ }
142150 }
143151
144152 actionTemplates , ok := t .templates [actionKey ]
@@ -323,6 +331,23 @@ func (t *Templater) GetMessage(msg *analyzer.CommitMessage) (string, error) {
323331 score -= 1.0
324332 }
325333
334+ // Bonus for templates that match project scope
335+ projectScope := inferProjectScope (msg )
336+ if projectScope != "" {
337+ templateLower := strings .ToLower (tmpl )
338+ scopeLower := strings .ToLower (projectScope )
339+
340+ // Direct scope mention in template
341+ if strings .Contains (templateLower , scopeLower ) {
342+ score += 1.5
343+ }
344+
345+ // Topic placeholder with meaningful scope
346+ if strings .Contains (tmpl , "{topic}" ) && msg .Topic != "" && msg .Topic != "core" {
347+ score += 1.0
348+ }
349+ }
350+
326351 // Small randomness for variety (0-0.5)
327352 score += rand .Float64 () * 0.5
328353
@@ -387,12 +412,22 @@ func (t *Templater) GetMessage(msg *analyzer.CommitMessage) (string, error) {
387412
388413 formattedMsg := replacer .Replace (chosen )
389414
390- // If scope exists, prefer replacing the topic scope pattern when present
415+ // Infer and apply project scope for better context
416+ projectScope := inferProjectScope (msg )
417+ if projectScope != "" {
418+ // Try common scope patterns
419+ formattedMsg = strings .Replace (formattedMsg , "(" + msg .Topic + ")" , "(" + projectScope + ")" , 1 )
420+ }
421+
422+ // If scope exists in message, prefer replacing the topic scope pattern when present
391423 if msg .Scope != "" {
392424 // try common patterns
393425 formattedMsg = strings .Replace (formattedMsg , "(" + msg .Topic + ")" , "(" + msg .Scope + ")" , 1 )
394426 }
395427
428+ // Clean and normalize the final message
429+ formattedMsg = cleanFinalMessage (formattedMsg )
430+
396431 return formattedMsg , nil
397432}
398433
@@ -482,6 +517,7 @@ func (t *Templater) GetSuggestions(msg *analyzer.CommitMessage, maxSuggestions i
482517 }
483518
484519 message := replacer .Replace (s .template )
520+ message = cleanFinalMessage (message ) // Clean the message
485521
486522 // Skip if we've seen this exact message or it's in history
487523 if usedMessages [message ] || t .history .Contains (message ) {
@@ -500,6 +536,7 @@ func (t *Templater) GetSuggestions(msg *analyzer.CommitMessage, maxSuggestions i
500536 }
501537
502538 message := replacer .Replace (s .template )
539+ message = cleanFinalMessage (message ) // Clean the message
503540 if ! usedMessages [message ] {
504541 suggestions = append (suggestions , message )
505542 usedMessages [message ] = true
@@ -589,6 +626,20 @@ func (t *Templater) scoreTemplate(template string, msg *analyzer.CommitMessage)
589626 // Base score
590627 score += 1.0
591628
629+ // PENALTY MECHANISM: Heavy penalty for templates requiring {item} but no data available
630+ if strings .Contains (template , "{item}" ) {
631+ // Check if we have any item data
632+ hasItem := msg .Item != ""
633+ hasDetectedStructures := len (msg .DetectedFunctions ) > 0 ||
634+ len (msg .DetectedStructs ) > 0 ||
635+ len (msg .DetectedMethods ) > 0
636+
637+ if ! hasItem && ! hasDetectedStructures {
638+ // Deduct 50 points - this template will never be selected
639+ score -= 50.0
640+ }
641+ }
642+
592643 // Bonus for templates that match detected patterns
593644 for _ , pattern := range msg .ChangePatterns {
594645 if strings .Contains (template , pattern ) ||
@@ -641,6 +692,23 @@ func (t *Templater) scoreTemplate(template string, msg *analyzer.CommitMessage)
641692 score += 1.0
642693 }
643694
695+ // Bonus for templates that match the project scope
696+ projectScope := inferProjectScope (msg )
697+ if projectScope != "" {
698+ templateLower := strings .ToLower (template )
699+ scopeLower := strings .ToLower (projectScope )
700+
701+ // Direct scope mention in template
702+ if strings .Contains (templateLower , scopeLower ) {
703+ score += 2.0
704+ }
705+
706+ // Scope matches topic placeholder usage
707+ if strings .Contains (template , "{topic}" ) && msg .Topic != "" {
708+ score += 1.0
709+ }
710+ }
711+
644712 return score
645713}
646714
@@ -694,6 +762,7 @@ func (t *Templater) GetAlternativeSuggestion(msg *analyzer.CommitMessage, usedSu
694762
695763 for _ , tmpl := range candidates {
696764 message := replacer .Replace (tmpl )
765+ message = cleanFinalMessage (message ) // Clean the message
697766
698767 // Skip if already used
699768 if usedSuggestions [message ] {
@@ -726,6 +795,7 @@ func (t *Templater) GetAlternativeSuggestion(msg *analyzer.CommitMessage, usedSu
726795 // If all have been used, reset and try again with lower standards
727796 for _ , tmpl := range candidates {
728797 message := replacer .Replace (tmpl )
798+ message = cleanFinalMessage (message ) // Clean the message
729799 score := t .scoreTemplate (tmpl , msg ) + rand .Float64 ()
730800 scored = append (scored , scoredTemplate {tmpl , message , score })
731801 }
@@ -743,6 +813,79 @@ func (t *Templater) GetAlternativeSuggestion(msg *analyzer.CommitMessage, usedSu
743813 return scored [0 ].message , nil
744814}
745815
816+ // resolveSpecialFile detects special files like LICENSE, COPYING, .md docs, etc.
817+ // Returns the special template group to use, or empty string if not a special file
818+ func resolveSpecialFile (msg * analyzer.CommitMessage ) string {
819+ // Check if all files are markdown documentation files
820+ if msg .IsDocsOnly {
821+ return "DOC"
822+ }
823+
824+ // Check for .md file extensions - these are documentation
825+ for _ , ext := range msg .FileExtensions {
826+ if ext == "md" {
827+ return "DOC"
828+ }
829+ }
830+
831+ // Define special file patterns
832+ specialFiles := map [string ]string {
833+ "license" : "LICENSE" ,
834+ "copying" : "LICENSE" ,
835+ "copyright" : "LICENSE" ,
836+ "readme" : "DOC" ,
837+ "changelog" : "DOC" ,
838+ "authors" : "DOC" ,
839+ "contributors" : "DOC" ,
840+ }
841+
842+ // Check topic and item for special file indicators
843+ topicLower := strings .ToLower (msg .Topic )
844+ itemLower := strings .ToLower (msg .Item )
845+
846+ for pattern , group := range specialFiles {
847+ if strings .Contains (topicLower , pattern ) || strings .Contains (itemLower , pattern ) {
848+ return group
849+ }
850+ }
851+
852+ return ""
853+ }
854+
855+ // cleanFinalMessage post-processes the commit message to normalize format
856+ func cleanFinalMessage (message string ) string {
857+ // Normalize empty parentheses patterns like "feat():" to "feat:"
858+ message = strings .ReplaceAll (message , "():" , ":" )
859+ message = strings .ReplaceAll (message , "( ):" , ":" )
860+ message = strings .ReplaceAll (message , "( ):" , ":" )
861+
862+ // Remove excessive whitespace
863+ message = strings .TrimSpace (message )
864+
865+ // Normalize multiple spaces to single space
866+ for strings .Contains (message , " " ) {
867+ message = strings .ReplaceAll (message , " " , " " )
868+ }
869+
870+ return message
871+ }
872+
873+ // inferProjectScope extracts the project scope from file paths
874+ // This helps match commit messages to the affected module/component
875+ func inferProjectScope (msg * analyzer.CommitMessage ) string {
876+ // If the analyzer already detected a scope, use it
877+ if msg .Scope != "" {
878+ return msg .Scope
879+ }
880+
881+ // Use the topic as the scope if it's meaningful
882+ if msg .Topic != "" && msg .Topic != "core" && msg .Topic != "." {
883+ return msg .Topic
884+ }
885+
886+ return ""
887+ }
888+
746889// calculateSimilarity returns a similarity score between 0.0 (completely different) and 1.0 (identical)
747890// Uses a hybrid approach combining:
748891// - Word-level Jaccard similarity (60% weight) - measures semantic overlap
0 commit comments