2026-05-12 11:20:02 +00:00
|
|
|
package validators
|
|
|
|
|
|
|
|
|
|
import (
|
2026-11-11 04:47:43 +00:00
|
|
|
"fmt"
|
2026-05-12 11:20:02 +00:00
|
|
|
"regexp"
|
|
|
|
|
"sort"
|
|
|
|
|
"strings"
|
|
|
|
|
|
2026-11-10 17:25:08 +00:00
|
|
|
"gitea.maximumdirect.net/eric/audita/v3/internal/core/glossary"
|
2026-05-12 11:20:02 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type protectedTermDef struct {
|
2026-11-10 17:25:08 +00:00
|
|
|
identities []glossary.Identity
|
|
|
|
|
canonical string
|
2026-05-12 11:20:02 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type protectedOccurrence struct {
|
|
|
|
|
text string
|
2026-11-10 17:25:08 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-11-10 17:25:08 +00:00
|
|
|
// 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 {
|
2026-11-10 20:33:24 +00:00
|
|
|
if !term.Exempt {
|
|
|
|
|
termsByFolded[strings.ToLower(term.Spelling)] = protectedTermDef{identities: term.Identities, canonical: term.Spelling}
|
|
|
|
|
}
|
2026-11-10 17:25:08 +00:00
|
|
|
alternatives = append(alternatives, regexp.QuoteMeta(term.Spelling))
|
2026-05-12 11:20:02 +00:00
|
|
|
}
|
2026-11-10 20:33:24 +00:00
|
|
|
if len(termsByFolded) == 0 {
|
|
|
|
|
return ProtectedVocabulary{}
|
2026-05-12 11:20:02 +00:00
|
|
|
}
|
2026-11-10 20:33:24 +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}
|
|
|
|
|
}
|
|
|
|
|
|
2026-11-10 17:25:08 +00:00
|
|
|
// 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
|
|
|
}
|
2026-11-10 17:25:08 +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 ""
|
|
|
|
|
}
|
|
|
|
|
|
2026-11-10 17:25:08 +00:00
|
|
|
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
|
|
|
|
|
}
|
2026-11-10 17:25:08 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-11-10 17:25:08 +00:00
|
|
|
func validateIdentityPreservation(beforeByID, afterByID map[glossary.Identity][]protectedOccurrence) string {
|
2026-11-11 12:31:44 +00:00
|
|
|
var selected string
|
|
|
|
|
found := false
|
2026-05-12 11:20:02 +00:00
|
|
|
for identity, beforeItems := range beforeByID {
|
2026-11-11 12:34:20 +00:00
|
|
|
afterItems := afterByID[identity]
|
|
|
|
|
if len(afterItems) >= len(beforeItems) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
// Protect identity counts. Within an identity that lost a mention,
|
|
|
|
|
// use spelling counts to identify which alias was affected.
|
|
|
|
|
remaining := make(map[string]int)
|
|
|
|
|
for _, item := range afterItems {
|
|
|
|
|
remaining[item.canonical]++
|
|
|
|
|
}
|
|
|
|
|
for _, item := range beforeItems {
|
|
|
|
|
spelling := item.canonical
|
|
|
|
|
if remaining[spelling] > 0 {
|
|
|
|
|
remaining[spelling]--
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if !found || spelling < selected {
|
2026-11-11 12:31:44 +00:00
|
|
|
selected, found = spelling, true
|
|
|
|
|
}
|
2026-05-12 11:20:02 +00:00
|
|
|
}
|
|
|
|
|
}
|
2026-11-11 12:31:44 +00:00
|
|
|
return protectedSpellingMessage(selected, found)
|
2026-05-12 11:20:02 +00:00
|
|
|
}
|
|
|
|
|
|
2026-11-10 17:25:08 +00:00
|
|
|
func validateCapitalizationTransitions(beforeByID, afterByID map[glossary.Identity][]protectedOccurrence) string {
|
2026-11-11 12:31:44 +00:00
|
|
|
var selected string
|
|
|
|
|
found := false
|
2026-05-12 11:20:02 +00:00
|
|
|
for identity, afterItems := range afterByID {
|
|
|
|
|
beforeItems := beforeByID[identity]
|
|
|
|
|
for i, afterItem := range afterItems {
|
2026-11-11 04:47:43 +00:00
|
|
|
if i >= len(beforeItems) || afterItem.text == beforeItems[i].text || afterItem.text == afterItem.canonical {
|
2026-05-12 11:20:02 +00:00
|
|
|
continue
|
|
|
|
|
}
|
2026-11-11 12:31:44 +00:00
|
|
|
if !found || afterItem.canonical < selected {
|
|
|
|
|
selected, found = afterItem.canonical, true
|
|
|
|
|
}
|
2026-05-12 11:20:02 +00:00
|
|
|
}
|
|
|
|
|
}
|
2026-11-11 12:31:44 +00:00
|
|
|
return protectedSpellingMessage(selected, found)
|
2026-11-11 04:47:43 +00:00
|
|
|
}
|
|
|
|
|
|
2026-11-11 12:31:44 +00:00
|
|
|
func protectedSpellingMessage(spelling string, found bool) string {
|
|
|
|
|
if !found {
|
2026-11-11 04:47:43 +00:00
|
|
|
return ""
|
|
|
|
|
}
|
2026-11-11 12:31:44 +00:00
|
|
|
return fmt.Sprintf("proposal may alter protected glossary spelling %q", spelling)
|
2026-05-12 11:20:02 +00:00
|
|
|
}
|
2026-09-25 12:23:10 +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) == ""
|
|
|
|
|
}
|