package validators import ( "regexp" "sort" "strings" "gitea.maximumdirect.net/eric/audita/internal/core/schema" ) type protectedTermDef struct { identity int canonical string } type protectedOccurrence struct { text string identity int canonical string } // ProtectedVocabulary is a deterministic glossary-derived protected term set. type ProtectedVocabulary struct { termsByFolded map[string]protectedTermDef pattern *regexp.Regexp } // ExtractProtectedTerms returns a stable, de-duplicated list of protected terms // derived from glossary names, aliases, synthetic plural forms, and explicit // plural fields. func ExtractProtectedTerms(glossary *schema.Glossary) []string { vocab := NewProtectedVocabulary(glossary) out := make([]string, 0, len(vocab.termsByFolded)) for _, def := range vocab.termsByFolded { out = append(out, def.canonical) } sort.SliceStable(out, func(i, j int) bool { li := strings.ToLower(out[i]) lj := strings.ToLower(out[j]) if li == lj { return out[i] < out[j] } return li < lj }) return out } // NewProtectedVocabulary builds a deterministic protected vocabulary from a glossary. func NewProtectedVocabulary(glossary *schema.Glossary) ProtectedVocabulary { termsByFolded := make(map[string]protectedTermDef) if glossary != nil { for identity, entry := range glossary.Entries { entryTerms := append([]string{entry.Name}, entry.Aliases...) for _, term := range entryTerms { trimmed := strings.TrimSpace(term) if trimmed == "" { continue } addProtectedTerm(termsByFolded, trimmed, identity) addProtectedTerm(termsByFolded, trimmed+"s", identity) } addProtectedTerm(termsByFolded, entry.Plural, identity) } } alternatives := make([]string, 0, len(termsByFolded)) for _, def := range termsByFolded { alternatives = append(alternatives, regexp.QuoteMeta(def.canonical)) } sort.SliceStable(alternatives, func(i, j int) bool { return len(alternatives[i]) > len(alternatives[j]) }) if len(alternatives) == 0 { return ProtectedVocabulary{termsByFolded: termsByFolded} } pattern := regexp.MustCompile(`(?i)\b(?:` + strings.Join(alternatives, "|") + `)\b`) return ProtectedVocabulary{termsByFolded: termsByFolded, pattern: pattern} } func addProtectedTerm(terms map[string]protectedTermDef, term string, identity int) { trimmed := strings.TrimSpace(term) if trimmed == "" { return } folded := strings.ToLower(trimmed) if _, ok := terms[folded]; ok { return } terms[folded] = protectedTermDef{identity: identity, canonical: trimmed} } 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) glossaryStageViolationReason(before, after string) string { beforeByID := v.occurrencesByIdentity(before) afterByID := v.occurrencesByIdentity(after) if reason := validateGlossaryStageIdentityPreservation(beforeByID, afterByID); reason != "" { return reason } if reason := validateCapitalizationTransitions(beforeByID, afterByID); reason != "" { return reason } return "" } func (v ProtectedVocabulary) occurrencesByIdentity(text string) map[int][]protectedOccurrence { byID := make(map[int][]protectedOccurrence) 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 } out = append(out, protectedOccurrence{ text: matched, identity: def.identity, canonical: def.canonical, }) } return out } func validateIdentityPreservation(beforeByID, afterByID map[int][]protectedOccurrence) string { for identity, beforeItems := range beforeByID { if len(afterByID[identity]) < len(beforeItems) { return "proposal may alter protected glossary terminology" } } return "" } func validateGlossaryStageIdentityPreservation(beforeByID, afterByID map[int][]protectedOccurrence) string { beforeTotal := 0 for _, items := range beforeByID { beforeTotal += len(items) } afterTotal := 0 for _, items := range afterByID { afterTotal += len(items) } if afterTotal < beforeTotal { return "proposal may alter protected glossary terminology" } return "" } func validateCapitalizationTransitions(beforeByID, afterByID map[int][]protectedOccurrence) string { for identity, afterItems := range afterByID { beforeItems := beforeByID[identity] for i, afterItem := range afterItems { if i >= len(beforeItems) { continue } beforeItem := beforeItems[i] if afterItem.text == beforeItem.text { continue } if afterItem.text == afterItem.canonical { continue } return "proposal may alter protected glossary terminology" } } return "" }