Files
audita/internal/framework/validators/protected_terms.go

148 lines
4.7 KiB
Go
Raw Normal View History

2026-05-12 11:20:02 +00:00
package validators
import (
"fmt"
2026-05-12 11:20:02 +00:00
"regexp"
"sort"
"strings"
"gitea.maximumdirect.net/eric/audita/v3/internal/core/glossary"
2026-05-12 11:20:02 +00:00
)
type protectedTermDef struct {
identities []glossary.Identity
canonical string
2026-05-12 11:20:02 +00:00
}
type protectedOccurrence struct {
text string
identity glossary.Identity
2026-05-12 11:20:02 +00:00
canonical string
}
// ProtectedVocabulary is a deterministic glossary-derived protected term set.
type ProtectedVocabulary struct {
termsByFolded map[string]protectedTermDef
pattern *regexp.Regexp
}
// NewProtectedVocabulary prepares the independently selected terms for matching.
func NewProtectedVocabulary(terms []glossary.Term) ProtectedVocabulary {
termsByFolded := make(map[string]protectedTermDef, len(terms))
alternatives := make([]string, 0, len(terms))
for _, term := range terms {
if !term.Exempt {
termsByFolded[strings.ToLower(term.Spelling)] = protectedTermDef{identities: term.Identities, canonical: term.Spelling}
}
alternatives = append(alternatives, regexp.QuoteMeta(term.Spelling))
2026-05-12 11:20:02 +00:00
}
if len(termsByFolded) == 0 {
return ProtectedVocabulary{}
2026-05-12 11:20:02 +00:00
}
sort.SliceStable(alternatives, func(i, j int) bool { return len(alternatives[i]) > len(alternatives[j]) })
2026-05-12 11:20:02 +00:00
pattern := regexp.MustCompile(`(?i)\b(?:` + strings.Join(alternatives, "|") + `)\b`)
return ProtectedVocabulary{termsByFolded: termsByFolded, pattern: pattern}
}
// AllowsChange checks a proposed segment against the effective protection view.
// Spoken-word cleanup may reduce repeated mentions while preserving each identity.
func (v ProtectedVocabulary) AllowsChange(before, after string, allowRepetitionCleanup bool) bool {
if v.violationReason(before, after) == "" {
return true
2026-05-12 11:20:02 +00:00
}
return allowRepetitionCleanup && v.preservesRepeatedIdentities(before, after)
2026-05-12 11:20:02 +00:00
}
func (v ProtectedVocabulary) violationReason(before, after string) string {
beforeByID := v.occurrencesByIdentity(before)
afterByID := v.occurrencesByIdentity(after)
if reason := validateIdentityPreservation(beforeByID, afterByID); reason != "" {
return reason
}
if reason := validateCapitalizationTransitions(beforeByID, afterByID); reason != "" {
return reason
}
return ""
}
func (v ProtectedVocabulary) occurrencesByIdentity(text string) map[glossary.Identity][]protectedOccurrence {
byID := make(map[glossary.Identity][]protectedOccurrence)
2026-05-12 11:20:02 +00:00
for _, o := range v.occurrences(text) {
byID[o.identity] = append(byID[o.identity], o)
}
return byID
}
func (v ProtectedVocabulary) occurrences(text string) []protectedOccurrence {
if v.pattern == nil {
return nil
}
matches := v.pattern.FindAllStringIndex(text, -1)
if len(matches) == 0 {
return nil
}
out := make([]protectedOccurrence, 0, len(matches))
for _, idx := range matches {
matched := text[idx[0]:idx[1]]
def, ok := v.termsByFolded[strings.ToLower(matched)]
if !ok {
continue
}
for _, identity := range def.identities {
out = append(out, protectedOccurrence{text: matched, identity: identity, canonical: def.canonical})
}
2026-05-12 11:20:02 +00:00
}
return out
}
func validateIdentityPreservation(beforeByID, afterByID map[glossary.Identity][]protectedOccurrence) string {
var spellings []string
2026-05-12 11:20:02 +00:00
for identity, beforeItems := range beforeByID {
if len(afterByID[identity]) < len(beforeItems) && len(beforeItems) > 0 {
spellings = append(spellings, beforeItems[0].canonical)
2026-05-12 11:20:02 +00:00
}
}
return protectedSpellingMessage(spellings)
2026-05-12 11:20:02 +00:00
}
func validateCapitalizationTransitions(beforeByID, afterByID map[glossary.Identity][]protectedOccurrence) string {
var spellings []string
2026-05-12 11:20:02 +00:00
for identity, afterItems := range afterByID {
beforeItems := beforeByID[identity]
for i, afterItem := range afterItems {
if i >= len(beforeItems) || afterItem.text == beforeItems[i].text || afterItem.text == afterItem.canonical {
2026-05-12 11:20:02 +00:00
continue
}
spellings = append(spellings, afterItem.canonical)
2026-05-12 11:20:02 +00:00
}
}
return protectedSpellingMessage(spellings)
}
func protectedSpellingMessage(spellings []string) string {
if len(spellings) == 0 {
return ""
}
sort.Strings(spellings)
return fmt.Sprintf("proposal may alter protected glossary spelling %q", spellings[0])
2026-05-12 11:20:02 +00:00
}
// preservesRepeatedIdentities permits a spoken-word proposal to reduce repeated
// mentions, but not remove an identity or replace its surviving spelling.
// Editorial and meaning validators still decide whether the cleanup is warranted.
func (v ProtectedVocabulary) preservesRepeatedIdentities(before, after string) bool {
original := v.occurrencesByIdentity(before)
result := v.occurrencesByIdentity(after)
reduced := false
for identity, occurrences := range original {
remaining := result[identity]
if len(remaining) == 0 {
return false
}
if len(remaining) < len(occurrences) {
reduced = true
}
}
return reduced && validateCapitalizationTransitions(original, result) == ""
}