189 lines
5.2 KiB
Go
189 lines
5.2 KiB
Go
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 ""
|
|
}
|