From ec5e4c9a6fad30807cbb179ff067bf0387b71752 Mon Sep 17 00:00:00 2001 From: Rayze-B Date: Mon, 6 Apr 2026 16:11:28 -0400 Subject: [PATCH 01/11] Added a duplicate file check case into file.go --- processor/file.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/processor/file.go b/processor/file.go index 08e6fe13c..5becde7f2 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 { From 1d8880f1e832ecc0f014212eb7d6f0b8d459bfc5 Mon Sep 17 00:00:00 2001 From: Richard Simison Date: Mon, 6 Apr 2026 21:33:02 -0400 Subject: [PATCH 02/11] Created an automated test to ensure the program can detect broken symlinks and stop the program rather than hanging indefinitely --- go.mod | 2 +- go.sum | 10 ++++++++++ processor/file_test.go | 28 ++++++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 5507de57e..118d5d52e 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 4a2de53f7..3d8e18be4 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_test.go b/processor/file_test.go index d018f3136..63c28d34b 100644 --- a/processor/file_test.go +++ b/processor/file_test.go @@ -183,6 +183,34 @@ func TestNewFileJobSize(t *testing.T) { LargeByteCount = 1000000 } +func TestNewFileJobBrokenSymlink(t *testing.T) { + 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++ { From 8cd6df27742727ecf2e74988f5c2f4226518cdff Mon Sep 17 00:00:00 2001 From: m39johnsonm Date: Tue, 7 Apr 2026 14:28:02 -0400 Subject: [PATCH 03/11] Test: Created a Circular symlink test, verifies that scc detects symlink loops, and skips instead of running indefinitely (Michael) --- processor/file_test.go | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/processor/file_test.go b/processor/file_test.go index 63c28d34b..ad5ed878f 100644 --- a/processor/file_test.go +++ b/processor/file_test.go @@ -5,6 +5,8 @@ package processor import ( "math/rand/v2" "os" + "path/filepath" + "sync" "testing" ) @@ -239,3 +241,32 @@ func randStringBytes(n int) string { } return string(b) } + +func TestNewFileJobCircularSymlink(t *testing.T) { + 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.Skip("Symlinks not supported:", 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 either return nil or handle the 'too many links' error internally. + job := newFileJob(link1, "link1.go", fi) + + if job != nil { + t.Error("Expected nil for circular symlink, but got a FileJob") + } +} From 831dd939b5f1f63189ef5b14c8839d16cbb49ec9 Mon Sep 17 00:00:00 2001 From: Richard Simison Date: Tue, 7 Apr 2026 16:03:24 -0400 Subject: [PATCH 04/11] Added an if-statement to TestNewFileJobBrokenSymLink to skip the test on Windows machines, because Windows requires administrator mode or developer mode to be enabled to create symlinks. --- processor/file_test.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/processor/file_test.go b/processor/file_test.go index 63c28d34b..62b6b7503 100644 --- a/processor/file_test.go +++ b/processor/file_test.go @@ -5,6 +5,7 @@ package processor import ( "math/rand/v2" "os" + "runtime" "testing" ) @@ -184,6 +185,10 @@ func TestNewFileJobSize(t *testing.T) { } func TestNewFileJobBrokenSymlink(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("skipping symlink test on Windows due to privilege requirements") + } + ProcessConstants() IncludeSymLinks = true From bcd2a9a3a73a48fdc1e29722383395b8fa566323 Mon Sep 17 00:00:00 2001 From: m39johnsonm Date: Tue, 7 Apr 2026 16:16:09 -0400 Subject: [PATCH 05/11] Test: Created a Circular symlink test, verifies that scc detects symlink loops, and skips instead of running indefinitely (Michael) this is a recommit, the other commit by Richard, possibly might have overwritten it? overall very weird that both our code dissapered --- processor/file_test.go | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/processor/file_test.go b/processor/file_test.go index 63c28d34b..ad5ed878f 100644 --- a/processor/file_test.go +++ b/processor/file_test.go @@ -5,6 +5,8 @@ package processor import ( "math/rand/v2" "os" + "path/filepath" + "sync" "testing" ) @@ -239,3 +241,32 @@ func randStringBytes(n int) string { } return string(b) } + +func TestNewFileJobCircularSymlink(t *testing.T) { + 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.Skip("Symlinks not supported:", 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 either return nil or handle the 'too many links' error internally. + job := newFileJob(link1, "link1.go", fi) + + if job != nil { + t.Error("Expected nil for circular symlink, but got a FileJob") + } +} From 7124dce3a4d384ea09b6622095533cd4f7088ea7 Mon Sep 17 00:00:00 2001 From: Richard Simison Date: Tue, 7 Apr 2026 16:19:13 -0400 Subject: [PATCH 06/11] Added an if-statement to TestNewFileJobBrokenSymLink to skip the test on Windows machines, because Windows requires administrator mode or developer mode to be enabled to create symlinks. --- processor/file_test.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/processor/file_test.go b/processor/file_test.go index ad5ed878f..15c25c1e3 100644 --- a/processor/file_test.go +++ b/processor/file_test.go @@ -6,6 +6,7 @@ import ( "math/rand/v2" "os" "path/filepath" + "runtime" "sync" "testing" ) @@ -186,6 +187,10 @@ func TestNewFileJobSize(t *testing.T) { } func TestNewFileJobBrokenSymlink(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("skipping symlink test on Windows due to privilege requirements") + } + ProcessConstants() IncludeSymLinks = true From 07ee23a722b0533e6133aef75864be2882fc4336 Mon Sep 17 00:00:00 2001 From: Rayze-B Date: Tue, 7 Apr 2026 19:59:01 -0400 Subject: [PATCH 07/11] Added test to check for duplicate file counting via symlinks. The function TestNewFileJobDuplicateCounting checks that the same file is not processed twice when accessed through multiple paths with symLink. This should skip duplicate files and prevents double counting. --- processor/file_test.go | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/processor/file_test.go b/processor/file_test.go index 15c25c1e3..490d38b26 100644 --- a/processor/file_test.go +++ b/processor/file_test.go @@ -275,3 +275,42 @@ func TestNewFileJobCircularSymlink(t *testing.T) { t.Error("Expected nil for circular symlink, but got a FileJob") } } + +func TestNewFileJobDuplicateCounting(t *testing.T) { + 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") + } +} From e10f5105a222397ed186d7c89b4d7b4ba029ed13 Mon Sep 17 00:00:00 2001 From: m39johnsonm Date: Tue, 7 Apr 2026 21:21:55 -0400 Subject: [PATCH 08/11] Updated test functions: Used Brokensymlink window check, made an check for fist link created in my test, and used BrokenSymlink check window check in the Duplicate test --- processor/file_test.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/processor/file_test.go b/processor/file_test.go index 490d38b26..ce601e4f5 100644 --- a/processor/file_test.go +++ b/processor/file_test.go @@ -248,6 +248,9 @@ func randStringBytes(n int) string { } 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 }() @@ -258,7 +261,7 @@ func TestNewFileJobCircularSymlink(t *testing.T) { link2 := filepath.Join(dir, "link2.go") // Create a loop: link1 -> link2 and link2 -> link1 if err := os.Symlink(link2, link1); err != nil { - t.Skip("Symlinks not supported:", err) + t.Fatal("Failed to create first link:", err) } if err := os.Symlink(link1, link2); err != nil { t.Fatal("Failed to create circular link:", err) @@ -268,7 +271,7 @@ func TestNewFileJobCircularSymlink(t *testing.T) { if err != nil { t.Fatal(err) } - // It should either return nil or handle the 'too many links' error internally. + // It should return the 'too many links' error. job := newFileJob(link1, "link1.go", fi) if job != nil { @@ -277,6 +280,9 @@ func TestNewFileJobCircularSymlink(t *testing.T) { } 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 }() From 09a6941643353c93941eb25ff01a796d610d1803 Mon Sep 17 00:00:00 2001 From: Rayze-B Date: Fri, 17 Apr 2026 18:33:36 -0400 Subject: [PATCH 09/11] Fixed Issue 412 where the wide parameter failed to accurately display the complexity/lines --- processor/formatters.go | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/processor/formatters.go b/processor/formatters.go index 1d6f7877d..6927c5972 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 { From 73e93f45acceaebad8e944b2bd24ff12bb4fb4ec Mon Sep 17 00:00:00 2001 From: Rayze-B Date: Fri, 17 Apr 2026 21:47:47 -0400 Subject: [PATCH 10/11] Added test TestFileSummarizeLongComplexityLines to make sure Complexity/Lines is calculating the collective complexity divide by the total code and then multiply it by 100 to get a percent form. --- processor/formatters_test.go | 49 ++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/processor/formatters_test.go b/processor/formatters_test.go index 898c3c33e..63a02dbae 100644 --- a/processor/formatters_test.go +++ b/processor/formatters_test.go @@ -1833,3 +1833,52 @@ 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 + + // The language row and total row should show 7.99, not 44.70 + 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) + } +} From 4e13e31ae4fc88b08e07367c5b0ad027d12f6026 Mon Sep 17 00:00:00 2001 From: Rayze-B Date: Fri, 17 Apr 2026 21:49:55 -0400 Subject: [PATCH 11/11] Added test TestFileSummarizeLongComplexityLines to make sure Complexity/Lines is calculating the collective complexity divide by the total code and then multiply it by 100 to get a percent form. --- processor/formatters_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/processor/formatters_test.go b/processor/formatters_test.go index 63a02dbae..ed074fc9d 100644 --- a/processor/formatters_test.go +++ b/processor/formatters_test.go @@ -1874,7 +1874,6 @@ func TestFileSummarizeLongComplexityLines(t *testing.T) { res := fileSummarizeLong(inputChan) Files = false - // The language row and total row should show 7.99, not 44.70 if strings.Contains(res, "20.56") { t.Error("WeightedComplexity is being summed incorrectly, got 20.56 instead of 5.36") }