diff --git a/go.mod b/go.mod index 5507de57..118d5d52 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/boyter/gocodewalker v1.5.2-0.20260227212453-19676720409f github.com/boyter/simplecache v0.0.0-20250113230110-8a4c9201822a github.com/json-iterator/go v1.1.12 + github.com/mark3labs/mcp-go v0.45.0 github.com/mattn/go-runewidth v0.0.19 github.com/rs/zerolog v1.30.0 github.com/spf13/cobra v1.10.1 @@ -27,7 +28,6 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/invopop/jsonschema v0.13.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect - github.com/mark3labs/mcp-go v0.45.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.19 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect diff --git a/go.sum b/go.sum index 4a2de53f..3d8e18be 100644 --- a/go.sum +++ b/go.sum @@ -21,7 +21,11 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo= github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -32,6 +36,10 @@ github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uO github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mark3labs/mcp-go v0.45.0 h1:s0S8qR/9fWaQ3pHxz7pm1uQ0DrswoSnRIxKIjbiQtkc= @@ -53,6 +61,8 @@ github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjY github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/zerolog v1.30.0 h1:SymVODrcRsaRaSInD9yQtKbtWqwsfoPcRff/oRXLj4c= github.com/rs/zerolog v1.30.0/go.mod h1:/tk+P47gFdPXq4QYjvCmT5/Gsug2nagsFWBWhAiSi1w= diff --git a/processor/file.go b/processor/file.go index 08e6fe13..5becde7f 100644 --- a/processor/file.go +++ b/processor/file.go @@ -13,6 +13,9 @@ import ( // needs to be sync.Map as it potentially could be called by many GoRoutines var extensionCache sync.Map +// Added as a way to track files per run. +var visitedPaths sync.Map + // A custom version of extracting extensions for a file // which also has a case-insensitive cache in order to save // some needless processing @@ -78,6 +81,19 @@ func newFileJob(path, name string, fileInfo os.FileInfo) *FileJob { return nil } + // This determines the real path + realPath := path + if symPath != "" { + realPath = symPath + } + + // Prevent duplicate processing and loops + if _, exists := visitedPaths.Load(realPath); exists { + printWarnF("skipping already processed file: %s", realPath) + return nil + } + visitedPaths.Store(realPath, true) + language, extension := DetectLanguage(name) if len(language) != 0 { diff --git a/processor/file_test.go b/processor/file_test.go index d018f313..ce601e4f 100644 --- a/processor/file_test.go +++ b/processor/file_test.go @@ -5,6 +5,9 @@ package processor import ( "math/rand/v2" "os" + "path/filepath" + "runtime" + "sync" "testing" ) @@ -183,6 +186,38 @@ func TestNewFileJobSize(t *testing.T) { LargeByteCount = 1000000 } +func TestNewFileJobBrokenSymlink(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("skipping symlink test on Windows due to privilege requirements") + } + + ProcessConstants() + IncludeSymLinks = true + + // Create a temp directory to work in + dir, err := os.MkdirTemp("", "scc-broken-symlink-test") + if err != nil { + t.Fatal("Failed to create temp dir:", err) + } + defer os.RemoveAll(dir) + + // Create a symlink that points to a path that doesn't exist + symPath := dir + "/broken.go" + err = os.Symlink("/this/path/does/not/exist.go", symPath) + if err != nil { + t.Fatal("Failed to create broken symlink:", err) + } + + fi, _ := os.Lstat(symPath) + job := newFileJob(symPath, "broken.go", fi) + + if job != nil { + t.Error("Expected nil for broken symlink got", job) + } + + IncludeSymLinks = false +} + func BenchmarkGetExtensionDifferent(b *testing.B) { for i := 0; i < b.N; i++ { @@ -211,3 +246,77 @@ func randStringBytes(n int) string { } return string(b) } + +func TestNewFileJobCircularSymlink(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("skipping symlink test on Windows due to privilege requirements") + } + ProcessConstants() + IncludeSymLinks = true + defer func() { IncludeSymLinks = false }() + visitedPaths = sync.Map{} + // Create a temp directory to work in + dir := t.TempDir() + link1 := filepath.Join(dir, "link1.go") + link2 := filepath.Join(dir, "link2.go") + // Create a loop: link1 -> link2 and link2 -> link1 + if err := os.Symlink(link2, link1); err != nil { + t.Fatal("Failed to create first link:", err) + } + if err := os.Symlink(link1, link2); err != nil { + t.Fatal("Failed to create circular link:", err) + } + + fi, err := os.Lstat(link1) + if err != nil { + t.Fatal(err) + } + // It should return the 'too many links' error. + job := newFileJob(link1, "link1.go", fi) + + if job != nil { + t.Error("Expected nil for circular symlink, but got a FileJob") + } +} + +func TestNewFileJobDuplicateCounting(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("skipping symlink test on Windows due to privilege requirements") + } + ProcessConstants() + IncludeSymLinks = true + defer func() { IncludeSymLinks = false }() + visitedPaths = sync.Map{} + + // Create Temp directory + dir := t.TempDir() + // Create a test file + testFile := filepath.Join(dir, "file.go") + + if err := os.WriteFile(testFile, []byte("package main"), 0644); err != nil { + t.Fatal(err) + } + + // Create a symlink to the same file + linkFile := filepath.Join(dir, "link.go") + if err := os.Symlink(testFile, linkFile); err != nil { + t.Skip("Symlinks not supported:", err) + } + // Process the test file + fi1, _ := os.Lstat(testFile) + job1 := newFileJob(testFile, "file.go", fi1) + + // Process the symlink (same target) + fi2, _ := os.Lstat(linkFile) + job2 := newFileJob(linkFile, "link.go", fi2) + + // First count should go through + if job1 == nil { + t.Fatal("Expected first file job to be created") + } + + // Second count should be skipped + if job2 != nil { + t.Error("Expected nil for duplicate file through symlink, but got a FileJob") + } +} diff --git a/processor/formatters.go b/processor/formatters.go index 1d6f7877..6927c597 100644 --- a/processor/formatters.go +++ b/processor/formatters.go @@ -907,7 +907,6 @@ func fileSummarizeLong(input chan *FileJob) string { langs := map[string]LanguageSummary{} var sumFiles, sumLines, sumCode, sumComment, sumBlank, sumComplexity, sumBytes int64 = 0, 0, 0, 0, 0, 0, 0 - var sumWeightedComplexity float64 for res := range input { sumFiles++ @@ -923,7 +922,6 @@ func fileSummarizeLong(input chan *FileJob) string { weightedComplexity = (float64(res.Complexity) / float64(res.Code)) * 100 } res.WeightedComplexity = weightedComplexity - sumWeightedComplexity += weightedComplexity _, ok := langs[res.Language] @@ -972,6 +970,11 @@ func fileSummarizeLong(input chan *FileJob) string { startTime := makeTimestampMilli() for _, summary := range language { + if summary.Code != 0 { + summary.WeightedComplexity = (float64(summary.Complexity) / float64(summary.Code)) * 100 + } else { + summary.WeightedComplexity = 0 + } if Files { str.WriteString(getTabularWideBreak()) } @@ -1028,7 +1031,11 @@ func fileSummarizeLong(input chan *FileJob) string { printDebugF("milliseconds to build formatted string: %d", makeTimestampMilli()-startTime) str.WriteString(getTabularWideBreak()) - _, _ = fmt.Fprintf(str, tabularWideFormatBody, "Total", sumFiles, sumLines, sumBlank, sumComment, sumCode, sumComplexity, sumWeightedComplexity) + var totalWeightedComplexity float64 + if sumCode != 0 { + totalWeightedComplexity = (float64(sumComplexity) / float64(sumCode)) * 100 + } + _, _ = fmt.Fprintf(str, tabularWideFormatBody, "Total", sumFiles, sumLines, sumBlank, sumComment, sumCode, sumComplexity, totalWeightedComplexity) str.WriteString(getTabularWideBreak()) if UlocMode { diff --git a/processor/formatters_test.go b/processor/formatters_test.go index 898c3c33..ed074fc9 100644 --- a/processor/formatters_test.go +++ b/processor/formatters_test.go @@ -1833,3 +1833,51 @@ func TestToJSON2Keys(t *testing.T) { t.Error("JSON2 estimatedPeople check failed") } } + +func TestFileSummarizeLongComplexityLines(t *testing.T) { + inputChan := make(chan *FileJob, 1000) + + // Creating 3 dummy files to test the Complexity/Lines + inputChan <- &FileJob{ + Language: "C Header", + Filename: "File1", + Location: "File1", + Lines: 1000, + Code: 2000, + Comment: 43, + Blank: 42, + Complexity: 100, + } + inputChan <- &FileJob{ + Language: "C Header", + Filename: "File2", + Location: "File2", + Lines: 200, + Code: 300, + Comment: 23, + Blank: 21, + Complexity: 20, + } + inputChan <- &FileJob{ + Language: "C Header", + Filename: "File3", + Location: "File3", + Lines: 10, + Code: 90, + Comment: 5, + Blank: 4, + Complexity: 8, + } + close(inputChan) + + Files = true + res := fileSummarizeLong(inputChan) + Files = false + + if strings.Contains(res, "20.56") { + t.Error("WeightedComplexity is being summed incorrectly, got 20.56 instead of 5.36") + } + if !strings.Contains(res, "5.36") { + t.Error("Expected WeightedComplexity of 5.63 ((128/2390)*100), got:", "\n", res) + } +}