aboutsummaryrefslogtreecommitdiff
path: root/internal/analyze
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-02-27 07:13:17 +0000
committerFuwn <[email protected]>2026-02-27 07:13:17 +0000
commit856e2994722e2e7f67b47d55b8e673ddabcebe83 (patch)
tree5a4e108384038eaa072d8e6c5f71ab68901fb431 /internal/analyze
downloadkivia-main.tar.xz
kivia-main.zip
feat: Initial commitHEADmain
Diffstat (limited to 'internal/analyze')
-rw-r--r--internal/analyze/analyze.go208
-rw-r--r--internal/analyze/analyze_test.go174
-rw-r--r--internal/analyze/resources.go26
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
+}