diff options
| author | Fuwn <[email protected]> | 2026-02-27 07:13:17 +0000 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-02-27 07:13:17 +0000 |
| commit | 856e2994722e2e7f67b47d55b8e673ddabcebe83 (patch) | |
| tree | 5a4e108384038eaa072d8e6c5f71ab68901fb431 /internal/analyze | |
| download | kivia-main.tar.xz kivia-main.zip | |
Diffstat (limited to 'internal/analyze')
| -rw-r--r-- | internal/analyze/analyze.go | 208 | ||||
| -rw-r--r-- | internal/analyze/analyze_test.go | 174 | ||||
| -rw-r--r-- | internal/analyze/resources.go | 26 |
3 files changed, 408 insertions, 0 deletions
diff --git a/internal/analyze/analyze.go b/internal/analyze/analyze.go new file mode 100644 index 0000000..315f086 --- /dev/null +++ b/internal/analyze/analyze.go @@ -0,0 +1,208 @@ +package analyze + +import ( + "github.com/Fuwn/kivia/internal/collect" + "strings" + "unicode" + "unicode/utf8" +) + +type Options struct { + MinEvaluationLength int +} + +type Result struct { + Violations []Violation `json:"violations"` +} + +type Violation struct { + Identifier collect.Identifier `json:"identifier"` + Reason string `json:"reason"` +} + +func Run(identifiers []collect.Identifier, options Options) (Result, error) { + minimumEvaluationLength := options.MinEvaluationLength + + if minimumEvaluationLength <= 0 { + minimumEvaluationLength = 1 + } + + resources, err := getResources() + + if err != nil { + return Result{}, err + } + + violations := make([]Violation, 0) + + for _, identifier := range identifiers { + if utf8.RuneCountInString(strings.TrimSpace(identifier.Name)) < minimumEvaluationLength { + continue + } + + evaluation := evaluateIdentifier(identifier, resources, minimumEvaluationLength) + + if !evaluation.isViolation { + continue + } + + violation := Violation{ + Identifier: identifier, + Reason: evaluation.reason, + } + violations = append(violations, violation) + } + + return Result{Violations: violations}, nil +} + +type evaluationResult struct { + isViolation bool + reason string +} + +func evaluateIdentifier(identifier collect.Identifier, resources resources, minimumTokenLength int) evaluationResult { + name := strings.TrimSpace(identifier.Name) + + if name == "" { + return evaluationResult{} + } + + tokens := tokenize(name) + + if len(tokens) == 0 { + return evaluationResult{} + } + + for _, token := range tokens { + if utf8.RuneCountInString(token) < minimumTokenLength { + continue + } + + if !isAlphabeticToken(token) { + continue + } + + if resources.dictionary.IsWord(token) { + continue + } + + if isUpperCaseToken(name, token) { + continue + } + + if isDisallowedAbbreviation(token, resources) { + return evaluationResult{isViolation: true, reason: "Contains abbreviation: " + token + "."} + } + + return evaluationResult{isViolation: true, reason: "Term not found in dictionary: " + token + "."} + } + + return evaluationResult{} +} + +func isUpperCaseToken(identifierName string, token string) bool { + tokenLength := utf8.RuneCountInString(token) + + if tokenLength < 2 || tokenLength > 8 { + return false + } + + return strings.Contains(identifierName, strings.ToUpper(token)) +} + +func tokenize(name string) []string { + name = strings.TrimSpace(name) + + if name == "" { + return nil + } + + parts := strings.FieldsFunc(name, func(r rune) bool { + return r == '_' || r == '-' || r == ' ' + }) + + if len(parts) == 0 { + return nil + } + + result := make([]string, 0, len(parts)*2) + + for _, part := range parts { + if part == "" { + continue + } + + result = append(result, splitCamel(part)...) + } + + return result +} + +func splitCamel(input string) []string { + if input == "" { + return nil + } + + runes := []rune(input) + + if len(runes) == 0 { + return nil + } + + tokens := make([]string, 0, 2) + start := 0 + + for index := 1; index < len(runes); index++ { + current := runes[index] + previous := runes[index-1] + next := rune(0) + + if index+1 < len(runes) { + next = runes[index+1] + } + + isBoundary := false + + if unicode.IsLower(previous) && unicode.IsUpper(current) { + isBoundary = true + } + + if unicode.IsDigit(previous) != unicode.IsDigit(current) { + isBoundary = true + } + + if unicode.IsUpper(previous) && unicode.IsUpper(current) && next != 0 && unicode.IsLower(next) { + isBoundary = true + } + + if isBoundary { + tokens = append(tokens, strings.ToLower(string(runes[start:index]))) + start = index + } + } + + tokens = append(tokens, strings.ToLower(string(runes[start:]))) + + return tokens +} + +func isDisallowedAbbreviation(token string, resources resources) bool { + _, hasExpansion := resources.dictionary.AbbreviationExpansion(token) + + return hasExpansion +} + +func isAlphabeticToken(token string) bool { + if token == "" { + return false + } + + for _, character := range token { + if !unicode.IsLetter(character) { + return false + } + } + + return true +} diff --git a/internal/analyze/analyze_test.go b/internal/analyze/analyze_test.go new file mode 100644 index 0000000..8aebf8d --- /dev/null +++ b/internal/analyze/analyze_test.go @@ -0,0 +1,174 @@ +package analyze_test + +import ( + "github.com/Fuwn/kivia/internal/analyze" + "github.com/Fuwn/kivia/internal/collect" + "os" + "path/filepath" + "testing" +) + +func dictionaryPathForTests(testingContext *testing.T) string { + testingContext.Helper() + + return filepath.Join("..", "..", "testdata", "dictionary", "words.txt") +} + +func TestAnalyzeFlagsAbbreviations(testingContext *testing.T) { + testingContext.Setenv("KIVIA_DICTIONARY_PATH", dictionaryPathForTests(testingContext)) + + root := filepath.Join("..", "..", "testdata", "samplepkg") + identifiers, err := collect.FromPath(root) + + if err != nil { + testingContext.Fatalf("collect.FromPath returned an error: %v", err) + } + + result, err := analyze.Run(identifiers, analyze.Options{}) + + if err != nil { + testingContext.Fatalf("analyze.Run returned an error: %v", err) + } + + if len(result.Violations) == 0 { + testingContext.Fatalf("Expected at least one violation, got none.") + } + + mustContainViolation(testingContext, result, "ctx") + mustContainViolation(testingContext, result, "userNum") + mustContainViolation(testingContext, result, "usr") +} + +func TestAnalyzeFlagsTechnicalTermsNotInDictionary(testingContext *testing.T) { + testingContext.Setenv("KIVIA_DICTIONARY_PATH", dictionaryPathForTests(testingContext)) + + identifiers := []collect.Identifier{ + {Name: "userID", Kind: "variable"}, + {Name: "httpClient", Kind: "variable"}, + } + result, err := analyze.Run(identifiers, analyze.Options{}) + + if err != nil { + testingContext.Fatalf("analyze.Run returned an error: %v", err) + } + + if len(result.Violations) == 0 { + testingContext.Fatalf("Expected violations, got none.") + } + + mustContainViolation(testingContext, result, "userID") + mustContainViolation(testingContext, result, "httpClient") +} + +func TestAnalyzeDoesNotFlagNormalDictionaryWords(testingContext *testing.T) { + testingContext.Setenv("KIVIA_DICTIONARY_PATH", dictionaryPathForTests(testingContext)) + + identifiers := []collect.Identifier{ + {Name: "options", Kind: "variable"}, + {Name: "parsedResource", Kind: "variable"}, + {Name: "hasResources", Kind: "variable"}, + {Name: "allowlist", Kind: "variable"}, + } + result, err := analyze.Run(identifiers, analyze.Options{}) + + if err != nil { + testingContext.Fatalf("analyze.Run returned an error: %v", err) + } + + if len(result.Violations) != 0 { + testingContext.Fatalf("Expected no violations, got %d.", len(result.Violations)) + } +} + +func TestAnalyzeMinEvaluationLengthSkipsSingleLetterIdentifiers(testingContext *testing.T) { + testingContext.Setenv("KIVIA_DICTIONARY_PATH", dictionaryPathForTests(testingContext)) + + identifiers := []collect.Identifier{ + {Name: "t", Kind: "parameter"}, + {Name: "v", Kind: "receiver"}, + {Name: "ctx", Kind: "parameter"}, + } + result, err := analyze.Run(identifiers, analyze.Options{ + MinEvaluationLength: 2, + }) + + if err != nil { + testingContext.Fatalf("analyze.Run returned an error: %v", err) + } + + if len(result.Violations) != 1 { + testingContext.Fatalf("Expected one violation, got %d.", len(result.Violations)) + } + + if result.Violations[0].Identifier.Name != "ctx" { + testingContext.Fatalf("Expected only ctx to be evaluated, got %q.", result.Violations[0].Identifier.Name) + } +} + +func TestAnalyzeFlagsExpressionAbbreviation(testingContext *testing.T) { + testingContext.Setenv("KIVIA_DICTIONARY_PATH", dictionaryPathForTests(testingContext)) + + identifiers := []collect.Identifier{ + {Name: "expr", Kind: "variable"}, + } + result, err := analyze.Run(identifiers, analyze.Options{ + MinEvaluationLength: 1, + }) + + if err != nil { + testingContext.Fatalf("analyze.Run returned an error: %v", err) + } + + if len(result.Violations) != 1 { + testingContext.Fatalf("Expected one violation, got %d.", len(result.Violations)) + } + + if result.Violations[0].Identifier.Name != "expr" { + testingContext.Fatalf("Expected expr to be flagged, got %q.", result.Violations[0].Identifier.Name) + } +} + +func TestAnalyzeAllowsUpperCaseTokens(testingContext *testing.T) { + testingContext.Setenv("KIVIA_DICTIONARY_PATH", dictionaryPathForTests(testingContext)) + + identifiers := []collect.Identifier{ + {Name: "JSON", Kind: "variable"}, + } + result, err := analyze.Run(identifiers, analyze.Options{}) + + if err != nil { + testingContext.Fatalf("analyze.Run returned an error: %v", err) + } + + if len(result.Violations) != 0 { + testingContext.Fatalf("Expected no violations, got %d.", len(result.Violations)) + } +} + +func TestAnalyzeFailsWhenDictionaryIsUnavailable(testingContext *testing.T) { + emptyDictionaryPath := filepath.Join(testingContext.TempDir(), "empty.txt") + + if err := os.WriteFile(emptyDictionaryPath, []byte("\n"), 0o644); err != nil { + testingContext.Fatalf("os.WriteFile returned an error: %v", err) + } + + testingContext.Setenv("KIVIA_DICTIONARY_PATH", emptyDictionaryPath) + + _, err := analyze.Run([]collect.Identifier{{Name: "ctx", Kind: "parameter"}}, analyze.Options{}) + + if err == nil { + testingContext.Fatalf("Expected analyze.Run to fail when dictionary data is unavailable.") + } +} + +func mustContainViolation(testingContext *testing.T, result analyze.Result, name string) { + testingContext.Helper() + + for _, violation := range result.Violations { + if violation.Identifier.Name == name { + return + } + } + + testingContext.Fatalf("Expected a violation for %q.", name) +} diff --git a/internal/analyze/resources.go b/internal/analyze/resources.go new file mode 100644 index 0000000..f42c757 --- /dev/null +++ b/internal/analyze/resources.go @@ -0,0 +1,26 @@ +package analyze + +import ( + "fmt" + "github.com/Fuwn/kivia/internal/nlp" +) + +type resources struct { + dictionary *nlp.Dictionary +} + +func getResources() (resources, error) { + return loadResources() +} + +func loadResources() (resources, error) { + dictionary, err := nlp.NewDictionary() + + if err != nil { + return resources{}, fmt.Errorf("Failed to load dictionary: %w", err) + } + + return resources{ + dictionary: dictionary, + }, nil +} |