├── version
├── .gitignore
├── testing
└── data
│ ├── edge_cases
│ ├── go.mod
│ ├── empty
│ │ └── empty.go
│ └── unicode
│ │ └── тест.go
│ ├── complex_project
│ ├── go.mod
│ ├── main.go
│ ├── service
│ │ └── service.go
│ └── handler
│ │ └── handler.go
│ └── simple_project
│ ├── go.mod
│ ├── main.go
│ ├── util
│ └── util.go
│ ├── cmd
│ └── cli.go
│ └── app
│ └── app.go
├── screenshot.png
├── internal
├── scanner
│ ├── testdata
│ │ └── fuzz
│ │ │ └── FuzzDirectoryPathHandling
│ │ │ └── 5838cdfae7b16cde
│ ├── scanner_fuzz_test.go
│ ├── scanner.go
│ └── scanner_test.go
├── visualizer
│ ├── testdata
│ │ └── fuzz
│ │ │ └── FuzzGenerateDOTContent
│ │ │ └── 500626cc88eedf7f
│ ├── visualizer_fuzz_test.go
│ ├── visualizer_test.go
│ └── visualizer.go
└── analyzer
│ └── analyzer_fuzz_test.go
├── web
├── css
│ ├── common.css
│ ├── graph.css
│ └── index.css
├── index.html
├── graph
│ └── index.html
└── js
│ └── index.js
├── go.mod
├── go.sum
├── .github
├── dependabot.yml
└── workflows
│ ├── test.yml
│ ├── scorecard.yml
│ └── release.yml
├── readme.md
├── SECURITY.md
├── cmd
├── server_test.go
└── server.go
├── squash.sh
└── .golangci.yml
/version:
--------------------------------------------------------------------------------
1 | 1.1
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .idea
--------------------------------------------------------------------------------
/testing/data/edge_cases/go.mod:
--------------------------------------------------------------------------------
1 | module testing/data/edge_cases
2 |
3 | go 1.21
--------------------------------------------------------------------------------
/testing/data/complex_project/go.mod:
--------------------------------------------------------------------------------
1 | module testing/data/complex_project
2 |
3 | go 1.21
--------------------------------------------------------------------------------
/testing/data/simple_project/go.mod:
--------------------------------------------------------------------------------
1 | module testing/data/simple_project
2 |
3 | go 1.21
--------------------------------------------------------------------------------
/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cvsouth/go-package-analyzer/HEAD/screenshot.png
--------------------------------------------------------------------------------
/internal/scanner/testdata/fuzz/FuzzDirectoryPathHandling/5838cdfae7b16cde:
--------------------------------------------------------------------------------
1 | go test fuzz v1
2 | string("")
3 |
--------------------------------------------------------------------------------
/testing/data/edge_cases/empty/empty.go:
--------------------------------------------------------------------------------
1 | package empty
2 |
3 | // This package is intentionally minimal for testing
4 |
--------------------------------------------------------------------------------
/testing/data/simple_project/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import "fmt"
4 |
5 | func main() {
6 | fmt.Println("Hello, World!")
7 | }
8 |
--------------------------------------------------------------------------------
/internal/visualizer/testdata/fuzz/FuzzGenerateDOTContent/500626cc88eedf7f:
--------------------------------------------------------------------------------
1 | go test fuzz v1
2 | string("{")
3 | string("0")
4 | byte('\f')
5 |
--------------------------------------------------------------------------------
/testing/data/edge_cases/unicode/тест.go:
--------------------------------------------------------------------------------
1 | package unicode
2 |
3 | // Package with unicode characters
4 | func Test() string {
5 | return "unicode test"
6 | }
7 |
--------------------------------------------------------------------------------
/testing/data/complex_project/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "testing/data/complex_project/service"
5 | )
6 |
7 | func main() {
8 | service.Start()
9 | }
10 |
--------------------------------------------------------------------------------
/testing/data/simple_project/util/util.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import "strings"
4 |
5 | // Helper is a simple utility function
6 | func Helper(s string) string {
7 | return strings.ToUpper(s)
8 | }
9 |
--------------------------------------------------------------------------------
/web/css/common.css:
--------------------------------------------------------------------------------
1 |
2 | * {
3 | font-family: 'JetBrains Mono', 'Consolas', 'Monaco', 'Courier New', monospace !important;
4 | }
5 |
6 | html, body {
7 | margin: 0;
8 | padding: 0;
9 | height: 100%;
10 | }
--------------------------------------------------------------------------------
/testing/data/simple_project/cmd/cli.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "testing/data/simple_project/app"
5 | "testing/data/simple_project/util"
6 | )
7 |
8 | func main() {
9 | app.Run()
10 | message := util.Helper("CLI mode")
11 | println(message)
12 | }
13 |
--------------------------------------------------------------------------------
/testing/data/simple_project/app/app.go:
--------------------------------------------------------------------------------
1 | package app
2 |
3 | import (
4 | "fmt"
5 | "testing/data/simple_project/util"
6 | )
7 |
8 | // Run executes the application logic
9 | func Run() {
10 | message := util.Helper("hello world")
11 | fmt.Println(message)
12 | }
13 |
--------------------------------------------------------------------------------
/testing/data/complex_project/service/service.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "fmt"
5 | "testing/data/complex_project/handler"
6 | )
7 |
8 | // Start initializes the service
9 | func Start() {
10 | fmt.Println("Starting service...")
11 | handler.Handle()
12 | }
13 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/cvsouth/go-package-analyzer
2 |
3 | go 1.24
4 |
5 | require github.com/stretchr/testify v1.11.1
6 |
7 | require (
8 | github.com/davecgh/go-spew v1.1.1 // indirect
9 | github.com/pmezard/go-difflib v1.0.0 // indirect
10 | gopkg.in/yaml.v3 v3.0.1 // indirect
11 | )
12 |
--------------------------------------------------------------------------------
/testing/data/complex_project/handler/handler.go:
--------------------------------------------------------------------------------
1 | package handler
2 |
3 | import (
4 | "fmt"
5 | )
6 |
7 | // Handle processes requests
8 | func Handle() {
9 | fmt.Println("Handling request...")
10 | }
11 |
12 | // GetServiceInfo creates a circular dependency
13 | func GetServiceInfo() string {
14 | // This would normally import service, creating a cycle
15 | // but we'll avoid actual circular imports for valid Go code
16 | return "service info"
17 | }
18 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
3 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
4 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
5 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
6 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
7 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
8 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
9 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
10 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
11 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | # Enable version updates for Go modules
4 | - package-ecosystem: "gomod"
5 | directory: "/"
6 | schedule:
7 | interval: "weekly"
8 | day: "monday"
9 | time: "09:00"
10 | open-pull-requests-limit: 10
11 | reviewers:
12 | - "cvsouth" # Replace with your GitHub username or team
13 | assignees:
14 | - "cvsouth" # Replace with your GitHub username or team
15 | commit-message:
16 | prefix: "chore"
17 | prefix-development: "chore"
18 | include: "scope"
19 | labels:
20 | - "dependencies"
21 | - "go"
22 |
23 | # Enable version updates for GitHub Actions
24 | - package-ecosystem: "github-actions"
25 | directory: "/"
26 | schedule:
27 | interval: "weekly"
28 | day: "monday"
29 | time: "09:00"
30 | open-pull-requests-limit: 5
31 | reviewers:
32 | - "cvsouth" # Replace with your GitHub username or team
33 | assignees:
34 | - "cvsouth" # Replace with your GitHub username or team
35 | commit-message:
36 | prefix: "chore"
37 | prefix-development: "chore"
38 | include: "scope"
39 | labels:
40 | - "dependencies"
41 | - "github-actions"
--------------------------------------------------------------------------------
/web/css/graph.css:
--------------------------------------------------------------------------------
1 |
2 | .graph-container {
3 | background: transparent;
4 | width: 100%;
5 | height: 100vh;
6 | position: relative;
7 | }
8 | .graph-container svg {
9 | width: 100%;
10 | height: 100%;
11 | opacity: 0;
12 | }
13 | .graph-container svg.visible {
14 | opacity: 1;
15 | }
16 | .zoom-controls {
17 | position: fixed;
18 | top: 0;
19 | right: 0;
20 | z-index: 100;
21 | display: flex;
22 | flex-direction: row;
23 | gap: 0;
24 | }
25 | .zoom-btn {
26 | width: 40px;
27 | height: 40px;
28 | background: rgba(0, 0, 0, 1);
29 | border: none;
30 | color: rgba(255, 255, 255, 0.25);
31 | display: flex;
32 | align-items: center;
33 | justify-content: center;
34 | cursor: pointer;
35 | border-radius: 0;
36 | font-size: 18px;
37 | font-weight: bold;
38 | margin: 0;
39 | padding: 0;
40 | transition: color 75ms ease;
41 | }
42 | .zoom-btn:hover {
43 | color: rgba(255, 255, 255, 1);
44 | }
45 | #resultsContainer {
46 | height: 100vh;
47 | }
48 | .cog-btn {
49 | width: 40px;
50 | height: 40px;
51 | background: rgba(0, 0, 0, 1);
52 | border: none;
53 | color: rgba(255, 255, 255, 0.25);
54 | display: flex;
55 | align-items: center;
56 | justify-content: center;
57 | cursor: pointer;
58 | border-radius: 0;
59 | font-size: 18px;
60 | font-weight: bold;
61 | margin: 0;
62 | padding: 0;
63 | transition: color 75ms ease;
64 | }
65 | .cog-btn:hover {
66 | color: rgba(255, 255, 255, 1);
67 | }
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 | # Go Package Analyzer
2 |
3 | 
4 | [](https://github.com/cvsouth/go-package-analyzer/actions/workflows/test.yml)
5 | [](https://goreportcard.com/report/github.com/cvsouth/go-package-analyzer)
6 | [](https://scorecard.dev/viewer/?uri=github.com/cvsouth/go-package-analyzer)
7 |
8 | A simple tool to analyze and visualize Go package dependencies.
9 |
10 | ## Setup
11 |
12 | 1. **Clone the repository**
13 | ```bash
14 | git clone git@github.com:cvsouth/go-package-analyzer.git
15 | cd go-package-analyzer
16 | ```
17 |
18 | 2. **Run the application**
19 | ```bash
20 | go run cmd/server.go
21 | ```
22 |
23 | ## Usage
24 |
25 | Open `http://localhost:6333`.
26 |
27 | ## Screenshot
28 |
29 | 
30 |
31 | ## Development
32 |
33 | ### Static analysis
34 |
35 | This project uses [golangci-lint](https://golangci-lint.run/) for code linting. If you have it installed locally, you can run:
36 |
37 | ```bash
38 | golangci-lint run
39 | ```
40 |
41 | ### Testing
42 |
43 | To run the test suite:
44 |
45 | ```bash
46 | go test ./...
47 | ```
48 |
49 | ## Coming soon
50 |
51 | - [ ] Improved readme usage docs
52 | - [ ] Customizable styling
53 | - [ ] Export diagram as SVG / PNG / drawio file
54 | - [ ] Each package in the graph having its public interface displayed in some sort of collapsible list
55 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Test
2 |
3 | on:
4 | push:
5 | branches: [ main ]
6 | pull_request:
7 | branches: [ main ]
8 |
9 | # Declare default permissions as read only.
10 | permissions: read-all
11 |
12 | jobs:
13 | test:
14 | runs-on: ubuntu-latest
15 | steps:
16 | - name: Checkout code
17 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
18 |
19 | - name: Set up Go
20 | uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
21 | with:
22 | go-version-file: 'go.mod'
23 |
24 | - name: Cache Go modules
25 | uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
26 | with:
27 | path: |
28 | ~/.cache/go-build
29 | ~/go/pkg/mod
30 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
31 | restore-keys: |
32 | ${{ runner.os }}-go-
33 |
34 | - name: Download dependencies
35 | run: go mod download
36 |
37 | - name: Run tests
38 | run: go test -v ./...
39 |
40 | - name: Run fuzz tests
41 | run: |
42 | go test -fuzz=FuzzAnalyzeFromFile -fuzztime=10s ./internal/analyzer
43 | go test -fuzz=FuzzFindEntryPoints -fuzztime=10s ./internal/analyzer
44 | go test -fuzz=FuzzAnalyzeMultipleEntryPoints -fuzztime=10s ./internal/analyzer
45 | go test -fuzz=FuzzListDirectory -fuzztime=10s ./internal/scanner
46 | go test -fuzz=FuzzGetFilesystemRoots -fuzztime=10s ./internal/scanner
47 | go test -fuzz=FuzzDirectoryPathHandling -fuzztime=10s ./internal/scanner
48 | go test -fuzz=FuzzGenerateDOTContent -fuzztime=10s ./internal/visualizer
49 |
50 | - name: Run golangci-lint
51 | uses: golangci/golangci-lint-action@0a35821d5c230e903fcfe077583637dea1b27b47 # v9.0.0
52 | with:
53 | version: latest
54 |
55 | - name: Clean test cache
56 | run: go clean -testcache
57 |
58 | - name: Test summary
59 | if: always()
60 | run: |
61 | if [ $? -eq 0 ]; then
62 | echo "✅ All tests passed!"
63 | else
64 | echo "❌ Tests failed - PR cannot be merged"
65 | exit 1
66 | fi
--------------------------------------------------------------------------------
/SECURITY.md:
--------------------------------------------------------------------------------
1 | # Security Policy
2 |
3 | ## Supported Versions
4 |
5 | We currently support the following versions with security updates:
6 |
7 | | Version | Supported |
8 | | ------- | ------------------ |
9 | | latest | :white_check_mark: |
10 |
11 | ## Reporting a Vulnerability
12 |
13 | We take security vulnerabilities seriously. If you discover a security vulnerability in this project, please report it responsibly.
14 |
15 | ### How to Report
16 |
17 | **Please do NOT report security vulnerabilities through public GitHub issues.**
18 |
19 | Instead, please report security vulnerabilities by:
20 |
21 | 1. **Email**: contact@colinsouth.com
22 | 2. **GitHub Security Advisories**: Use GitHub's private vulnerability reporting feature by going to the Security tab of this repository and clicking "Report a vulnerability"
23 |
24 | ### What to Include
25 |
26 | When reporting a vulnerability, please include:
27 |
28 | - A description of the vulnerability
29 | - Steps to reproduce the issue
30 | - Potential impact of the vulnerability
31 | - Any suggested fixes or mitigation strategies
32 | - Your contact information for follow-up questions
33 |
34 | ### Response Timeline
35 |
36 | - **Acknowledgment**: We will acknowledge receipt of your vulnerability report within 48 hours
37 | - **Initial Assessment**: We will provide an initial assessment within 5 business days
38 | - **Resolution**: We aim to resolve critical vulnerabilities within 30 days
39 |
40 | ### Disclosure Policy
41 |
42 | - We follow responsible disclosure practices
43 | - We will work with you to understand and resolve the issue before any public disclosure
44 | - We will credit you for the discovery (unless you prefer to remain anonymous)
45 | - We may request that you keep the vulnerability confidential until we have a fix available
46 |
47 | ## Security Best Practices
48 |
49 | When using this tool:
50 |
51 | - Always use the latest version
52 | - Validate any configuration files before use
53 | - Be cautious when analyzing untrusted code repositories
54 | - Report any suspicious behavior
55 |
56 | ## Contact
57 |
58 | For security-related questions or concerns, please use the reporting methods outlined above.
59 |
60 | Thank you for helping to keep our project and community safe!
--------------------------------------------------------------------------------
/.github/workflows/scorecard.yml:
--------------------------------------------------------------------------------
1 | # This workflow uses actions that are not certified by GitHub. They are provided
2 | # by a third-party and are governed by separate terms of service, privacy
3 | # policy, and support documentation.
4 |
5 | name: Scorecard supply-chain security
6 | on:
7 | # For Branch-Protection check. Only the default branch is supported. See
8 | # https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection
9 | branch_protection_rule:
10 | # To guarantee Maintained check is occasionally updated. See
11 | # https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained
12 | schedule:
13 | - cron: '15 22 * * 3'
14 | push:
15 | branches: [ "main" ]
16 |
17 | # Declare default permissions as read only.
18 | permissions: read-all
19 |
20 | jobs:
21 | analysis:
22 | name: Scorecard analysis
23 | runs-on: ubuntu-latest
24 | # `publish_results: true` only works when run from the default branch. conditional can be removed if disabled.
25 | if: github.event.repository.default_branch == github.ref_name || github.event_name == 'pull_request'
26 | permissions:
27 | # Needed to upload the results to code-scanning dashboard.
28 | security-events: write
29 | # Needed to publish results and get a badge (see publish_results below).
30 | id-token: write
31 | # Uncomment the permissions below if installing in a private repository.
32 | # contents: read
33 | # actions: read
34 |
35 | steps:
36 | - name: "Checkout code"
37 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
38 | with:
39 | persist-credentials: false
40 |
41 | - name: "Run analysis"
42 | uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
43 | with:
44 | results_file: results.sarif
45 | results_format: sarif
46 | # (Optional) "write" PAT token. Uncomment the `repo_token` line below if:
47 | # - you want to enable the Branch-Protection check on a *public* repository, or
48 | # - you are installing Scorecard on a *private* repository
49 | # To create the PAT, follow the steps in https://github.com/ossf/scorecard-action?tab=readme-ov-file#authentication-with-fine-grained-pat-optional.
50 | # repo_token: ${{ secrets.SCORECARD_TOKEN }}
51 |
52 | # Public repositories:
53 | # - Publish results to OpenSSF REST API for easy access by consumers
54 | # - Allows the repository to include the Scorecard badge.
55 | # - See https://github.com/ossf/scorecard-action#publishing-results.
56 | # For private repositories:
57 | # - `publish_results` will always be set to `false`, regardless
58 | # of the value entered here.
59 | publish_results: true
60 |
61 | # (Optional) Uncomment file_mode if you have a .gitattributes with files marked export-ignore
62 | # file_mode: git
63 |
64 | # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
65 | # format to the repository Actions tab.
66 | - name: "Upload artifact"
67 | uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
68 | with:
69 | name: SARIF file
70 | path: results.sarif
71 | retention-days: 5
72 |
73 | # Upload the results to GitHub's code scanning dashboard (optional).
74 | # Commenting out will disable upload of results to your repo's Code Scanning dashboard
75 | - name: "Upload to code-scanning"
76 | uses: github/codeql-action/upload-sarif@0499de31b99561a6d14a36a5f662c2a54f91beee # tag=v4.31.2
77 | with:
78 | sarif_file: results.sarif
79 |
--------------------------------------------------------------------------------
/cmd/server_test.go:
--------------------------------------------------------------------------------
1 | package main_test
2 |
3 | import (
4 | "encoding/json"
5 | "testing"
6 |
7 | "github.com/cvsouth/go-package-analyzer/internal/analyzer"
8 | )
9 |
10 | // Test the JSON serialization of response types that would be used by the server.
11 | type APIResponse struct {
12 | Success bool `json:"success"`
13 | DOT string `json:"dot,omitempty"`
14 | Error string `json:"error,omitempty"`
15 | }
16 |
17 | type MultiEntryAPIResponse struct {
18 | Success bool `json:"success"`
19 | EntryPoints []analyzer.EntryPoint `json:"entryPoints,omitempty"`
20 | Error string `json:"error,omitempty"`
21 | RepoRoot string `json:"repoRoot,omitempty"`
22 | ModuleName string `json:"moduleName,omitempty"`
23 | }
24 |
25 | func TestAPIResponse_JSONSerialization(t *testing.T) {
26 | // Test successful response
27 | response := APIResponse{
28 | Success: true,
29 | DOT: "digraph test { a -> b; }",
30 | }
31 |
32 | data, err := json.Marshal(response)
33 | if err != nil {
34 | t.Fatalf("Failed to marshal APIResponse: %v", err)
35 | }
36 |
37 | var unmarshaled APIResponse
38 | if unmarshalErr := json.Unmarshal(data, &unmarshaled); unmarshalErr != nil {
39 | t.Fatalf("Failed to unmarshal APIResponse: %v", unmarshalErr)
40 | }
41 |
42 | if unmarshaled.Success != response.Success {
43 | t.Errorf("Expected Success=%v, got %v", response.Success, unmarshaled.Success)
44 | }
45 |
46 | if unmarshaled.DOT != response.DOT {
47 | t.Errorf("Expected DOT='%s', got '%s'", response.DOT, unmarshaled.DOT)
48 | }
49 | }
50 |
51 | func TestAPIResponse_ErrorSerialization(t *testing.T) {
52 | // Test error response
53 | response := APIResponse{
54 | Success: false,
55 | Error: "test error message",
56 | }
57 |
58 | data, err := json.Marshal(response)
59 | if err != nil {
60 | t.Fatalf("Failed to marshal APIResponse: %v", err)
61 | }
62 |
63 | var unmarshaled APIResponse
64 | if unmarshalErr := json.Unmarshal(data, &unmarshaled); unmarshalErr != nil {
65 | t.Fatalf("Failed to unmarshal APIResponse: %v", unmarshalErr)
66 | }
67 |
68 | if unmarshaled.Success != false {
69 | t.Error("Expected Success=false for error response")
70 | }
71 |
72 | if unmarshaled.Error != "test error message" {
73 | t.Errorf("Expected Error='test error message', got '%s'", unmarshaled.Error)
74 | }
75 |
76 | if unmarshaled.DOT != "" {
77 | t.Errorf("Expected empty DOT for error response, got '%s'", unmarshaled.DOT)
78 | }
79 | }
80 |
81 | func TestMultiEntryAPIResponse_JSONSerialization(t *testing.T) {
82 | response := MultiEntryAPIResponse{
83 | Success: true,
84 | EntryPoints: []analyzer.EntryPoint{},
85 | RepoRoot: "/path/to/repo",
86 | ModuleName: "test-module",
87 | }
88 |
89 | jsonData, err := json.Marshal(response)
90 | if err != nil {
91 | t.Fatalf("Failed to marshal response: %v", err)
92 | }
93 |
94 | var unmarshaled MultiEntryAPIResponse
95 | if unmarshalErr := json.Unmarshal(jsonData, &unmarshaled); unmarshalErr != nil {
96 | t.Fatalf("Failed to unmarshal response: %v", unmarshalErr)
97 | }
98 |
99 | if unmarshaled.Success != response.Success {
100 | t.Errorf("Success field mismatch: got %v, want %v", unmarshaled.Success, response.Success)
101 | }
102 |
103 | if unmarshaled.RepoRoot != response.RepoRoot {
104 | t.Errorf("RepoRoot field mismatch: got %v, want %v", unmarshaled.RepoRoot, response.RepoRoot)
105 | }
106 |
107 | if unmarshaled.ModuleName != response.ModuleName {
108 | t.Errorf("ModuleName field mismatch: got %v, want %v", unmarshaled.ModuleName, response.ModuleName)
109 | }
110 |
111 | if len(unmarshaled.EntryPoints) != len(response.EntryPoints) {
112 | t.Errorf("EntryPoints length mismatch: got %d, want %d",
113 | len(unmarshaled.EntryPoints), len(response.EntryPoints))
114 | }
115 | }
116 |
--------------------------------------------------------------------------------
/web/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Go Package Analyzer
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
19 |
20 |
21 |
22 |
23 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
Select Go Project
35 |
36 |
37 |
38 |
39 |
40 |
41 |
Failed to scan directories
42 |
43 |
44 |
45 |
46 |
47 |
52 |
53 |
54 |
60 |
61 |
62 |
63 |
64 |
--------------------------------------------------------------------------------
/web/css/index.css:
--------------------------------------------------------------------------------
1 |
2 | /* Chrome flexbox fixes */
3 | .directory-tree {
4 | max-height: 60vh;
5 | overflow-y: auto;
6 | /* Chrome scrollbar styling */
7 | scrollbar-width: thin;
8 | scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
9 | /* Prevent text selection in entire tree */
10 | -webkit-user-select: none;
11 | -moz-user-select: none;
12 | -ms-user-select: none;
13 | user-select: none;
14 | }
15 |
16 | .directory-tree::-webkit-scrollbar {
17 | width: 8px;
18 | }
19 |
20 | .directory-tree::-webkit-scrollbar-track {
21 | background: transparent;
22 | }
23 |
24 | .directory-tree::-webkit-scrollbar-thumb {
25 | background-color: rgba(255, 255, 255, 0.2);
26 | border-radius: 4px;
27 | }
28 |
29 | .directory-tree::-webkit-scrollbar-thumb:hover {
30 | background-color: rgba(255, 255, 255, 0.3);
31 | }
32 |
33 | .directory-item {
34 | cursor: pointer;
35 | padding: 6px 8px;
36 | border-radius: 4px;
37 | transition: background-color 0.2s ease;
38 | display: flex;
39 | align-items: center;
40 | min-height: 32px;
41 | /* Chrome rendering optimization */
42 | will-change: background-color;
43 | -webkit-transform: translateZ(0);
44 | transform: translateZ(0);
45 | /* Prevent text selection */
46 | -webkit-user-select: none;
47 | -moz-user-select: none;
48 | -ms-user-select: none;
49 | user-select: none;
50 | }
51 |
52 | .directory-item:hover {
53 | background-color: rgba(255, 255, 255, 0.1);
54 | }
55 |
56 | .directory-item.selected {
57 | background-color: rgba(255, 255, 255, 0.2);
58 | }
59 |
60 | .directory-item.is-go-project {
61 | font-weight: 600;
62 | color: #00ACD7;
63 | }
64 |
65 | .expand-icon {
66 | display: inline-flex;
67 | width: 16px;
68 | height: 16px;
69 | align-items: center;
70 | justify-content: center;
71 | transition: transform 0.2s ease;
72 | /* Chrome rendering optimization */
73 | will-change: transform;
74 | -webkit-transform: translateZ(0);
75 | transform: translateZ(0);
76 | /* Prevent text selection */
77 | -webkit-user-select: none;
78 | -moz-user-select: none;
79 | -ms-user-select: none;
80 | user-select: none;
81 | }
82 |
83 | .expand-icon.expanded {
84 | transform: rotate(90deg);
85 | }
86 |
87 | .modal-overlay {
88 | background-color: rgba(0, 0, 0, 0.85);
89 | backdrop-filter: blur(4px);
90 | -webkit-backdrop-filter: blur(4px);
91 | }
92 |
93 | /* Chrome-specific fixes for modal */
94 | .modal-content {
95 | /* Ensure proper rendering in Chrome */
96 | -webkit-transform: translateZ(0);
97 | transform: translateZ(0);
98 | /* Better border rendering */
99 | border: 1px solid rgba(255, 255, 255, 0.2);
100 | box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
101 | }
102 |
103 | /* Go logo optimization for Chrome */
104 | .go-logo {
105 | display: inline-block;
106 | width: auto;
107 | height: 12px;
108 | vertical-align: middle;
109 | /* Chrome SVG rendering optimization */
110 | shape-rendering: geometricPrecision;
111 | image-rendering: -webkit-optimize-contrast;
112 | }
113 |
114 | /* Sidebar fixes for Chrome */
115 | .recent-projects-sidebar {
116 | /* Ensure proper layering in Chrome */
117 | -webkit-transform: translateZ(0);
118 | transform: translateZ(0);
119 | }
120 |
121 | /* Button improvements for Chrome */
122 | .btn-primary {
123 | /* Better button rendering in Chrome */
124 | -webkit-appearance: none;
125 | appearance: none;
126 | border: none;
127 | outline: none;
128 | }
129 |
130 | .btn-primary:focus {
131 | outline: 2px solid rgba(255, 255, 255, 0.3);
132 | outline-offset: 2px;
133 | }
134 |
135 | /* Directory name truncation fix for Chrome */
136 | .directory-name {
137 | flex: 1;
138 | overflow: hidden;
139 | text-overflow: ellipsis;
140 | white-space: nowrap;
141 | min-width: 0; /* Chrome flexbox fix */
142 | max-width: 100%;
143 | }
144 |
145 | /* Chrome-specific fixes for directory tree container */
146 | #directoryContent {
147 | min-height: 0;
148 | flex: 1;
149 | }
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | on:
4 | push:
5 | branches:
6 | - main # Change this to your default branch if different
7 |
8 | jobs:
9 | release:
10 | runs-on: ubuntu-latest
11 | permissions:
12 | contents: write
13 |
14 | steps:
15 | - name: Checkout code
16 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
17 | with:
18 | fetch-depth: 0
19 |
20 | - name: Set up Go
21 | uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
22 | with:
23 | go-version: '1.24'
24 | cache: true
25 |
26 | - name: Get next version number
27 | id: version
28 | run: |
29 | # Read major.minor version from version file and normalize across platforms
30 | # Handle Windows (CRLF), Unix (LF), and Mac line endings, plus any whitespace
31 | base_version=$(cat version | tr -d '\000-\037\177-\377' | tr -d '\n\r\t ' | sed 's/^[[:space:]]*//' | sed 's/[[:space:]]*$//')
32 | echo "Base version from file: '$base_version'"
33 | echo "Base version length: ${#base_version}"
34 |
35 | # Validate that base_version is not empty and has correct format
36 | if [ -z "$base_version" ]; then
37 | echo "Error: version file is empty or not found"
38 | exit 1
39 | fi
40 |
41 | # Validate version format (should be like "1.0", "2.3", etc.)
42 | if ! echo "$base_version" | grep -E '^[0-9]+\.[0-9]+$' > /dev/null; then
43 | echo "Error: version file must contain format like '1.0' or '2.3', got: '$base_version'"
44 | exit 1
45 | fi
46 |
47 | # Get the latest release tag that matches this base version
48 | latest_tag=$(git tag -l --sort=-version:refname "v${base_version}.*" | head -n 1)
49 | echo "Latest matching tag: '$latest_tag'"
50 |
51 | if [ -z "$latest_tag" ]; then
52 | # No tags exist for this base version, start with patch version 0
53 | next_patch=0
54 | echo "No existing tags found for base version $base_version, starting with patch 0"
55 | else
56 | # Extract patch version from tag (e.g., v1.0.5 -> 5)
57 | current_patch=$(echo "$latest_tag" | sed "s/v${base_version}\.//")
58 | next_patch=$((current_patch + 1))
59 | echo "Found existing tag $latest_tag, incrementing patch from $current_patch to $next_patch"
60 | fi
61 |
62 | next_version="v${base_version}.${next_patch}"
63 |
64 | echo "next_version=$next_version" >> $GITHUB_OUTPUT
65 | echo "tag_name=$next_version" >> $GITHUB_OUTPUT
66 | echo "Next version will be: $next_version"
67 |
68 | - name: Build binaries
69 | run: |
70 | # Create release directory
71 | mkdir -p release
72 |
73 | # Build for different platforms
74 | platforms=(
75 | "linux/amd64"
76 | "linux/arm64"
77 | "windows/amd64"
78 | "darwin/amd64"
79 | "darwin/arm64"
80 | )
81 |
82 | for platform in "${platforms[@]}"; do
83 | IFS='/' read -r GOOS GOARCH <<< "$platform"
84 |
85 | # Set binary name with platform suffix
86 | if [ "$GOOS" = "windows" ]; then
87 | binary_name="go-package-analyzer-${GOOS}-${GOARCH}.exe"
88 | else
89 | binary_name="go-package-analyzer-${GOOS}-${GOARCH}"
90 | fi
91 |
92 | # Build the binary
93 | echo "Building for $GOOS/$GOARCH..."
94 | env GOOS=$GOOS GOARCH=$GOARCH go build -ldflags="-s -w -X main.Version=${{ steps.version.outputs.next_version }}" -o "release/${binary_name}" ./cmd/server.go
95 | done
96 |
97 | # Generate checksums
98 | cd release
99 | sha256sum * > checksums.txt
100 | cd ..
101 |
102 | - name: Create Release
103 | env:
104 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
105 | run: |
106 | # Create release with GitHub CLI and auto-generated release notes
107 | gh release create ${{ steps.version.outputs.tag_name }} \
108 | --title "${{ steps.version.outputs.next_version }}" \
109 | ./release/go-package-analyzer-linux-amd64 \
110 | ./release/go-package-analyzer-linux-arm64 \
111 | ./release/go-package-analyzer-windows-amd64.exe \
112 | ./release/go-package-analyzer-darwin-amd64 \
113 | ./release/go-package-analyzer-darwin-arm64 \
114 | ./release/checksums.txt
--------------------------------------------------------------------------------
/web/graph/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Go Package Analyzer
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
45 |
46 |
47 |
48 |
49 |
50 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
66 |
Graph will appear here
67 |
68 |
69 |
70 |
71 |
76 |
77 |
78 |
79 |
--------------------------------------------------------------------------------
/internal/scanner/scanner_fuzz_test.go:
--------------------------------------------------------------------------------
1 | package scanner_test
2 |
3 | import (
4 | "path/filepath"
5 | "strings"
6 | "testing"
7 |
8 | "github.com/cvsouth/go-package-analyzer/internal/scanner"
9 | )
10 |
11 | // FuzzListDirectory tests the ListDirectory function with various directory paths.
12 | func FuzzListDirectory(f *testing.F) {
13 | // Add seed corpus with various directory path formats
14 | f.Add(".")
15 | f.Add("/tmp")
16 | f.Add("relative/path")
17 | f.Add("")
18 |
19 | s := scanner.New()
20 | f.Fuzz(func(t *testing.T, dirPath string) {
21 | // Test that ListDirectory doesn't panic with any input
22 | defer func() {
23 | if r := recover(); r != nil {
24 | t.Errorf("ListDirectory panicked with dirPath=%q: %v", dirPath, r)
25 | }
26 | }()
27 |
28 | // Skip problematic inputs
29 | if strings.Contains(dirPath, "\x00") || len(dirPath) > 500 {
30 | return
31 | }
32 |
33 | result, err := s.ListDirectory(dirPath)
34 |
35 | // Function should never return nil result
36 | if result == nil {
37 | t.Errorf("ListDirectory returned nil result for dirPath=%q", dirPath)
38 | return
39 | }
40 |
41 | validateListDirectoryResult(t, result, err, dirPath)
42 | })
43 | }
44 |
45 | // validateListDirectoryResult validates the result of ListDirectory.
46 | func validateListDirectoryResult(t *testing.T, result *scanner.DirectoryListResult, err error, dirPath string) {
47 | t.Helper()
48 |
49 | // If there's an error, should be reflected in result
50 | if err != nil && result.Success {
51 | t.Errorf("ListDirectory returned error but Success=true: dirPath=%q, error=%v", dirPath, err)
52 | }
53 |
54 | // If operation failed, should have an error message
55 | if !result.Success && result.Error == "" {
56 | t.Errorf("ListDirectory failed but no error message: dirPath=%q", dirPath)
57 | }
58 |
59 | // If successful, directories should be valid
60 | if result.Success {
61 | validateDirectoryEntries(t, result.Directories, dirPath)
62 | }
63 | }
64 |
65 | // validateDirectoryEntries validates individual directory entries.
66 | func validateDirectoryEntries(t *testing.T, directories []*scanner.DirectoryNode, dirPath string) {
67 | t.Helper()
68 |
69 | for _, dir := range directories {
70 | // Each directory should have a name
71 | if dir.Name == "" {
72 | t.Errorf("Directory entry has empty name: dirPath=%q", dirPath)
73 | }
74 |
75 | // Path should be absolute or relative but not empty
76 | if dir.Path == "" {
77 | t.Errorf("Directory entry has empty path: dirPath=%q, name=%q", dirPath, dir.Name)
78 | }
79 | }
80 | }
81 |
82 | // FuzzGetFilesystemRoots tests filesystem root detection.
83 | func FuzzGetFilesystemRoots(f *testing.F) {
84 | s := scanner.New()
85 |
86 | // This function doesn't take parameters, but we can test it for stability
87 | f.Fuzz(func(t *testing.T, _ uint8) { // dummy parameter to enable fuzzing
88 | // Test that GetFilesystemRoots doesn't panic
89 | defer func() {
90 | if r := recover(); r != nil {
91 | t.Errorf("GetFilesystemRoots panicked: %v", r)
92 | }
93 | }()
94 |
95 | result, err := s.GetFilesystemRoots()
96 |
97 | // Function should handle errors gracefully
98 | if err != nil {
99 | return
100 | }
101 |
102 | // Result should not be nil
103 | if result == nil {
104 | t.Errorf("GetFilesystemRoots returned nil result")
105 | return
106 | }
107 |
108 | if result.Tree == nil {
109 | t.Errorf("GetFilesystemRoots returned nil tree")
110 | return
111 | }
112 |
113 | // Validate filesystem roots
114 | validateFilesystemRoots(t, result.Tree.Children)
115 | })
116 | }
117 |
118 | // validateFilesystemRoots validates filesystem root entries.
119 | func validateFilesystemRoots(t *testing.T, roots []*scanner.DirectoryNode) {
120 | t.Helper()
121 |
122 | // Each root should be a valid path
123 | for i, root := range roots {
124 | if root.Name == "" {
125 | t.Errorf("Filesystem root %d has empty name", i)
126 | }
127 |
128 | // Roots should not contain relative path elements
129 | if strings.Contains(root.Path, "..") || strings.Contains(root.Path, "./") {
130 | t.Errorf("Filesystem root should not contain relative elements: %q", root.Path)
131 | }
132 | }
133 | }
134 |
135 | // FuzzDirectoryPathHandling tests directory path normalization and validation.
136 | func FuzzDirectoryPathHandling(f *testing.F) {
137 | // Add seed corpus with various path formats
138 | f.Add("/")
139 | f.Add(".")
140 | f.Add("..")
141 | f.Add("relative/path")
142 | f.Add("///multiple///slashes")
143 | f.Add("path/with spaces")
144 |
145 | f.Fuzz(func(t *testing.T, dirPath string) {
146 | // Skip extremely problematic inputs
147 | if strings.Contains(dirPath, "\x00") ||
148 | len(dirPath) > 1000 {
149 | return
150 | }
151 |
152 | // Test path cleaning behavior
153 | cleanPath := filepath.Clean(dirPath)
154 |
155 | // Cleaned path should not be excessively longer than original
156 | // Note: filepath.Clean("") returns "." which is expected behavior
157 | if dirPath != "" && len(cleanPath) > len(dirPath)*3 {
158 | t.Errorf("Cleaned path unexpectedly long: original=%q, cleaned=%q", dirPath, cleanPath)
159 | }
160 |
161 | // Test that path operations don't panic
162 | defer func() {
163 | if r := recover(); r != nil {
164 | t.Errorf("Path operation panicked with dirPath=%q: %v", dirPath, r)
165 | }
166 | }()
167 |
168 | _ = filepath.IsAbs(dirPath)
169 | _ = filepath.Dir(dirPath)
170 | })
171 | }
172 |
--------------------------------------------------------------------------------
/internal/analyzer/analyzer_fuzz_test.go:
--------------------------------------------------------------------------------
1 | package analyzer_test
2 |
3 | import (
4 | "os"
5 | "path/filepath"
6 | "strings"
7 | "testing"
8 |
9 | "github.com/cvsouth/go-package-analyzer/internal/analyzer"
10 | )
11 |
12 | // FuzzAnalyzeFromFile tests the AnalyzeFromFile function with various file paths.
13 | func FuzzAnalyzeFromFile(f *testing.F) {
14 | // Add seed corpus with various file path formats
15 | f.Add("./cmd/server.go", true)
16 | f.Add("internal/analyzer/analyzer.go", false)
17 | f.Add("test.go", true)
18 | f.Add("", false)
19 |
20 | a := analyzer.New()
21 | f.Fuzz(func(t *testing.T, filePath string, excludeExternal bool) {
22 | // Test that AnalyzeFromFile doesn't panic with any input
23 | defer func() {
24 | if r := recover(); r != nil {
25 | t.Errorf("AnalyzeFromFile panicked with filePath=%q: %v", filePath, r)
26 | }
27 | }()
28 |
29 | // Create a temporary file if the path doesn't exist
30 | if filePath != "" && !strings.Contains(filePath, "..") {
31 | tmpDir := t.TempDir()
32 | testFile := filepath.Join(tmpDir, "test.go")
33 | goModFile := filepath.Join(tmpDir, "go.mod")
34 |
35 | // Create a basic go.mod
36 | goModContent := "module test\n\ngo 1.21\n"
37 | if err := os.WriteFile(goModFile, []byte(goModContent), 0644); err != nil {
38 | return // Skip if we can't create test files
39 | }
40 |
41 | // Create a basic Go file
42 | goContent := "package main\n\nfunc main() {}\n"
43 | if err := os.WriteFile(testFile, []byte(goContent), 0644); err != nil {
44 | return
45 | }
46 |
47 | filePath = testFile
48 | }
49 |
50 | _, err := a.AnalyzeFromFile(filePath, excludeExternal, nil)
51 |
52 | // Function should handle errors gracefully
53 | if err != nil {
54 | // Errors are expected for invalid file paths
55 | return
56 | }
57 | })
58 | }
59 |
60 | // FuzzFindEntryPoints tests the FindEntryPoints function with various directory paths.
61 | func FuzzFindEntryPoints(f *testing.F) {
62 | // Add seed corpus with various directory paths
63 | f.Add(".")
64 | f.Add("./cmd")
65 | f.Add("./internal")
66 | f.Add("")
67 | f.Add("/tmp")
68 |
69 | a := analyzer.New()
70 | f.Fuzz(func(t *testing.T, repoRoot string) {
71 | // Test that FindEntryPoints doesn't panic with any input
72 | defer func() {
73 | if r := recover(); r != nil {
74 | t.Errorf("FindEntryPoints panicked with repoRoot=%q: %v", repoRoot, r)
75 | }
76 | }()
77 |
78 | // Skip obviously problematic paths
79 | if strings.Contains(repoRoot, "..") ||
80 | strings.Contains(repoRoot, "\x00") ||
81 | len(repoRoot) > 1000 {
82 | return
83 | }
84 |
85 | entryPoints, err := a.FindEntryPoints(repoRoot)
86 |
87 | // Function should handle errors gracefully
88 | if err != nil {
89 | // Errors are expected for invalid paths
90 | return
91 | }
92 |
93 | // Validate returned entry points
94 | for _, entryPoint := range entryPoints {
95 | if entryPoint == "" {
96 | t.Errorf("FindEntryPoints returned empty entry point")
97 | }
98 |
99 | if !strings.HasSuffix(entryPoint, ".go") {
100 | t.Errorf("Entry point should be a .go file: %q", entryPoint)
101 | }
102 | }
103 | })
104 | }
105 |
106 | // FuzzAnalyzeMultipleEntryPoints tests multi-entry analysis with various parameters.
107 | func FuzzAnalyzeMultipleEntryPoints(f *testing.F) {
108 | // Add seed corpus
109 | f.Add(".", true, "vendor,node_modules")
110 | f.Add("./cmd", false, "")
111 | f.Add("", true, "test")
112 |
113 | a := analyzer.New()
114 | f.Fuzz(func(t *testing.T, repoRoot string, excludeExternal bool, excludeDirsStr string) {
115 | // Test that AnalyzeMultipleEntryPoints doesn't panic
116 | defer func() {
117 | if r := recover(); r != nil {
118 | t.Errorf("AnalyzeMultipleEntryPoints panicked: %v", r)
119 | }
120 | }()
121 |
122 | // Skip problematic inputs
123 | if shouldSkipInput(repoRoot, excludeDirsStr) {
124 | return
125 | }
126 |
127 | // Parse exclude directories and run analysis
128 | excludeDirs := parseExcludeDirs(excludeDirsStr)
129 | result, err := a.AnalyzeMultipleEntryPoints(repoRoot, excludeExternal, excludeDirs)
130 |
131 | // Function should handle errors gracefully
132 | if err != nil {
133 | return
134 | }
135 |
136 | validateAnalysisResult(t, result)
137 | })
138 | }
139 |
140 | // shouldSkipInput checks if inputs should be skipped for fuzzing.
141 | func shouldSkipInput(repoRoot, excludeDirsStr string) bool {
142 | return strings.Contains(repoRoot, "..") ||
143 | strings.Contains(repoRoot, "\x00") ||
144 | len(repoRoot) > 500 ||
145 | len(excludeDirsStr) > 1000
146 | }
147 |
148 | // parseExcludeDirs parses the exclude directories string.
149 | func parseExcludeDirs(excludeDirsStr string) []string {
150 | var excludeDirs []string
151 | if excludeDirsStr != "" {
152 | excludeDirs = strings.Split(excludeDirsStr, ",")
153 | for i, dir := range excludeDirs {
154 | excludeDirs[i] = strings.TrimSpace(dir)
155 | }
156 | }
157 | return excludeDirs
158 | }
159 |
160 | // validateAnalysisResult validates the analysis result.
161 | func validateAnalysisResult(t *testing.T, result *analyzer.MultiEntryAnalysisResult) {
162 | // Validate result structure
163 | if result == nil {
164 | t.Errorf("AnalyzeMultipleEntryPoints returned nil result")
165 | return
166 | }
167 |
168 | // Check result consistency
169 | if result.Success && result.Error != "" {
170 | t.Errorf("Result shows success but has error message: %q", result.Error)
171 | }
172 |
173 | if !result.Success && result.Error == "" {
174 | t.Errorf("Result shows failure but no error message")
175 | }
176 | }
177 |
--------------------------------------------------------------------------------
/internal/visualizer/visualizer_fuzz_test.go:
--------------------------------------------------------------------------------
1 | package visualizer_test
2 |
3 | import (
4 | "strings"
5 | "testing"
6 |
7 | "github.com/cvsouth/go-package-analyzer/internal/analyzer"
8 | "github.com/cvsouth/go-package-analyzer/internal/visualizer"
9 | )
10 |
11 | // FuzzGenerateDOTContent tests DOT content generation with various graph inputs.
12 | func FuzzGenerateDOTContent(f *testing.F) {
13 | // Add seed corpus with valid inputs
14 | f.Add("main", "test.com/module", uint8(3))
15 | f.Add("cmd/server", "github.com/user/project", uint8(5))
16 | f.Add("app", "example.org/app", uint8(1))
17 |
18 | f.Fuzz(func(t *testing.T, entryPackage, moduleName string, packageCount uint8) {
19 | // Limit package count to reasonable range
20 | if packageCount == 0 || packageCount > 10 {
21 | return
22 | }
23 |
24 | // Skip problematic inputs
25 | if len(entryPackage) > 100 || len(moduleName) > 100 ||
26 | strings.Contains(entryPackage, "\x00") ||
27 | strings.Contains(moduleName, "\x00") ||
28 | strings.Contains(entryPackage, "{") ||
29 | strings.Contains(entryPackage, "}") ||
30 | strings.Contains(moduleName, "{") ||
31 | strings.Contains(moduleName, "}") {
32 | return
33 | }
34 |
35 | // Sanitize inputs to ensure valid package names
36 | entryPackage = sanitizePackageName(entryPackage)
37 | moduleName = sanitizePackageName(moduleName)
38 |
39 | // Skip empty module names as they would be invalid
40 | if moduleName == "" {
41 | moduleName = "test.com/module"
42 | }
43 |
44 | // Skip empty entry packages
45 | if entryPackage == "" {
46 | entryPackage = "main"
47 | }
48 |
49 | // Ensure entry package is within module
50 | if !strings.HasPrefix(entryPackage, moduleName) {
51 | entryPackage = moduleName + "/" + entryPackage
52 | }
53 |
54 | graph := createTestGraphForFuzz(entryPackage, moduleName, packageCount)
55 | testDOTGenerationForFuzz(t, graph, entryPackage)
56 | })
57 | }
58 |
59 | // sanitizePackageName removes problematic characters from package names.
60 | func sanitizePackageName(name string) string {
61 | // Remove null bytes and other problematic characters
62 | name = strings.ReplaceAll(name, "\x00", "")
63 | name = strings.ReplaceAll(name, "{", "")
64 | name = strings.ReplaceAll(name, "}", "")
65 | name = strings.ReplaceAll(name, "\"", "")
66 | name = strings.ReplaceAll(name, "'", "")
67 | name = strings.TrimSpace(name)
68 |
69 | // Replace invalid characters with underscores
70 | result := ""
71 | for _, r := range name {
72 | if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
73 | (r >= '0' && r <= '9') || r == '/' || r == '.' || r == '-' || r == '_' {
74 | result += string(r)
75 | } else if r == ' ' {
76 | result += "_"
77 | }
78 | }
79 |
80 | return result
81 | }
82 |
83 | // createTestGraphForFuzz creates a test dependency graph.
84 | func createTestGraphForFuzz(entryPackage, moduleName string, packageCount uint8) *analyzer.DependencyGraph {
85 | graph := &analyzer.DependencyGraph{
86 | EntryPackage: entryPackage,
87 | ModuleName: moduleName,
88 | Packages: make(map[string]*analyzer.PackageInfo),
89 | Layers: [][]string{},
90 | }
91 |
92 | // Add the entry package
93 | graph.Packages[entryPackage] = &analyzer.PackageInfo{
94 | Name: "main",
95 | Path: entryPackage,
96 | Dependencies: []string{},
97 | FileCount: 1,
98 | Layer: 0,
99 | }
100 |
101 | // Create layers structure
102 | layers := make([][]string, 3)
103 | layers[0] = []string{entryPackage}
104 |
105 | // Add some additional packages
106 | for i := range packageCount {
107 | pkgName := "pkg" + string(rune('a'+int(i)))
108 | pkgPath := moduleName + "/" + pkgName
109 | layer := int(i%2) + 1 // Distribute between layers 1 and 2
110 |
111 | graph.Packages[pkgPath] = &analyzer.PackageInfo{
112 | Name: pkgName,
113 | Path: pkgPath,
114 | Dependencies: []string{},
115 | FileCount: int(i + 1),
116 | Layer: layer,
117 | }
118 |
119 | // Add to appropriate layer
120 | if layer < len(layers) {
121 | layers[layer] = append(layers[layer], pkgPath)
122 | }
123 | }
124 |
125 | // Set the layers in the graph
126 | graph.Layers = layers
127 |
128 | return graph
129 | }
130 |
131 | // testDOTGenerationForFuzz tests DOT content generation and validates the result.
132 | func testDOTGenerationForFuzz(t *testing.T, graph *analyzer.DependencyGraph, _ string) {
133 | t.Helper()
134 |
135 | // Test that GenerateDOTContent doesn't panic
136 | defer func() {
137 | if r := recover(); r != nil {
138 | t.Errorf("GenerateDOTContent panicked: %v", r)
139 | }
140 | }()
141 |
142 | v := visualizer.New()
143 | result := v.GenerateDOTContent(graph)
144 |
145 | // Basic validation - just ensure it doesn't crash and produces some output
146 | if result == "" {
147 | t.Errorf("GenerateDOTContent returned empty result")
148 | return
149 | }
150 |
151 | // Very basic DOT format validation
152 | if !strings.Contains(result, "digraph") {
153 | t.Errorf("Result should contain 'digraph'")
154 | }
155 |
156 | // Count braces - they should be balanced
157 | openBraces := strings.Count(result, "{")
158 | closeBraces := strings.Count(result, "}")
159 |
160 | // Allow some tolerance for edge cases, but they should be reasonably balanced
161 | if openBraces == 0 && closeBraces == 0 {
162 | t.Errorf("DOT content should contain braces")
163 | } else if abs(openBraces-closeBraces) > 1 {
164 | t.Errorf("Braces significantly unbalanced: %d open, %d close", openBraces, closeBraces)
165 | }
166 | }
167 |
168 | // abs returns the absolute value of an integer.
169 | func abs(x int) int {
170 | if x < 0 {
171 | return -x
172 | }
173 | return x
174 | }
175 |
--------------------------------------------------------------------------------
/squash.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | # Cross-platform compatibility checks
4 | if [ -z "$BASH_VERSION" ]; then
5 | echo "❌ This script requires bash. Please run with: bash $0"
6 | exit 1
7 | fi
8 |
9 | # Use bash-specific features only if in bash
10 | set -euo pipefail
11 |
12 | # Usage: ./squash.sh [optional commit message] [--target-branch=branch] [--dry-run] [--force]
13 |
14 | # Parse arguments
15 | COMMIT_MSG=""
16 | TARGET_BRANCH_OVERRIDE=""
17 | DRY_RUN=false
18 | FORCE=false
19 |
20 | while [[ $# -gt 0 ]]; do
21 | case $1 in
22 | --target-branch=*)
23 | TARGET_BRANCH_OVERRIDE="${1#*=}"
24 | shift
25 | ;;
26 | --dry-run)
27 | DRY_RUN=true
28 | shift
29 | ;;
30 | --force|-f)
31 | FORCE=true
32 | shift
33 | ;;
34 | --help|-h)
35 | echo "Usage: $0 [commit-message] [--target-branch=branch] [--dry-run] [--force]"
36 | echo " commit-message: Optional commit message for squashed commit"
37 | echo " --target-branch: Specify target branch instead of auto-detection"
38 | echo " --dry-run: Show what would be done without making changes"
39 | echo " --force: Skip confirmation prompts"
40 | exit 0
41 | ;;
42 | *)
43 | if [[ -z "$COMMIT_MSG" ]]; then
44 | COMMIT_MSG="$1"
45 | else
46 | echo "❌ Unknown argument: $1"
47 | exit 1
48 | fi
49 | shift
50 | ;;
51 | esac
52 | done
53 |
54 | # Cross-platform date function
55 | get_timestamp() {
56 | # Try GNU date first (Linux), then BSD date (macOS)
57 | if date --version >/dev/null 2>&1; then
58 | # GNU date (Linux)
59 | date +%Y%m%d-%H%M%S
60 | else
61 | # BSD date (macOS)
62 | date +%Y%m%d-%H%M%S
63 | fi
64 | }
65 |
66 | # Cross-platform read function with fallback
67 | read_confirmation() {
68 | local prompt="$1"
69 |
70 | # Try different read options for compatibility
71 | if printf "%s" "$prompt"; then
72 | if read -n 1 -r REPLY 2>/dev/null; then
73 | echo # Add newline after single character input
74 | elif read -r REPLY; then
75 | # Fallback for systems where -n doesn't work
76 | REPLY="${REPLY:0:1}" # Take only first character
77 | else
78 | echo "Failed to read input"
79 | return 1
80 | fi
81 | fi
82 | }
83 |
84 | # --- 1. Validate environment ---
85 | # Check if we're in a git repository
86 | if ! git rev-parse --git-dir >/dev/null 2>&1; then
87 | echo "❌ Not in a git repository"
88 | exit 1
89 | fi
90 |
91 | # Check for required git commands
92 | for cmd in "git rev-parse" "git status" "git for-each-ref" "git cherry" "git merge-base" "git log" "git reset" "git commit" "git push"; do
93 | if ! command -v ${cmd%% *} >/dev/null 2>&1; then
94 | echo "❌ Required command not found: ${cmd%% *}"
95 | exit 1
96 | fi
97 | done
98 |
99 | # --- 2. Validate working directory ---
100 | if [[ -n "$(git status --porcelain)" ]]; then
101 | echo "❌ Working directory is not clean. Please commit or stash changes first."
102 | git status --short
103 | exit 1
104 | fi
105 |
106 | # --- 3. Get the current working branch ---
107 | CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
108 |
109 | if [[ "$CURRENT_BRANCH" == "HEAD" ]]; then
110 | echo "❌ You are in a detached HEAD state. Please switch to a branch first."
111 | exit 1
112 | fi
113 |
114 | # --- 4. Determine the target branch ---
115 | if [[ -n "$TARGET_BRANCH_OVERRIDE" ]]; then
116 | TARGET_BRANCH="$TARGET_BRANCH_OVERRIDE"
117 | # Validate the specified target branch exists
118 | if ! git show-ref --verify --quiet "refs/heads/$TARGET_BRANCH"; then
119 | echo "❌ Target branch '$TARGET_BRANCH' does not exist."
120 | exit 1
121 | fi
122 | else
123 | # Auto-detect target branch
124 | POSSIBLE_TARGETS=$(git for-each-ref --format='%(refname:short)' refs/heads/ | grep -v "^${CURRENT_BRANCH}$")
125 |
126 | TARGET_BRANCH=""
127 |
128 | # Prefer common branch names first
129 | for preferred in "main" "master" "develop" "dev"; do
130 | if echo "$POSSIBLE_TARGETS" | grep -q "^${preferred}$"; then
131 | if git cherry "$preferred" "$CURRENT_BRANCH" | grep -q '^+'; then
132 | TARGET_BRANCH="$preferred"
133 | break
134 | fi
135 | fi
136 | done
137 |
138 | # If no preferred branch found, use the original logic
139 | if [[ -z "$TARGET_BRANCH" ]]; then
140 | for branch in $POSSIBLE_TARGETS; do
141 | if git cherry "$branch" "$CURRENT_BRANCH" | grep -q '^+'; then
142 | TARGET_BRANCH="$branch"
143 | break
144 | fi
145 | done
146 | fi
147 |
148 | if [[ -z "$TARGET_BRANCH" ]]; then
149 | echo "❌ Could not determine the target branch this branch was based on."
150 | echo "Available branches: $POSSIBLE_TARGETS"
151 | echo "Use --target-branch= to specify manually."
152 | exit 1
153 | fi
154 | fi
155 |
156 | # --- 5. Find the common ancestor commit ---
157 | BASE=$(git merge-base "$TARGET_BRANCH" "$CURRENT_BRANCH")
158 |
159 | # --- 6. Count commits to be squashed ---
160 | COMMIT_COUNT=$(git rev-list --count "${BASE}..${CURRENT_BRANCH}")
161 |
162 | if [[ "$COMMIT_COUNT" -eq 0 ]]; then
163 | echo "❌ No commits found to squash between '$TARGET_BRANCH' and '$CURRENT_BRANCH'."
164 | exit 1
165 | fi
166 |
167 | if [[ "$COMMIT_COUNT" -eq 1 ]]; then
168 | echo "ℹ️ Only one commit found. No squashing needed."
169 | exit 0
170 | fi
171 |
172 | # --- 7. Get the default commit message ---
173 | if [[ -z "$COMMIT_MSG" ]]; then
174 | COMMIT_MSG=$(git log --reverse --format=%s "${BASE}..${CURRENT_BRANCH}" | head -n 1)
175 | fi
176 |
177 | # --- 8. Show what will be done ---
178 | echo "🔍 Squash Summary:"
179 | echo " Current branch: $CURRENT_BRANCH"
180 | echo " Target branch: $TARGET_BRANCH"
181 | echo " Commits to squash: $COMMIT_COUNT"
182 | echo " Base commit: ${BASE:0:7}"
183 | echo " New commit message: \"$COMMIT_MSG\""
184 | echo
185 |
186 | echo "📋 Commits to be squashed:"
187 | git log --oneline "${BASE}..${CURRENT_BRANCH}"
188 | echo
189 |
190 | if [[ "$DRY_RUN" == true ]]; then
191 | echo "🔍 DRY RUN: Would squash $COMMIT_COUNT commits on '$CURRENT_BRANCH'."
192 | exit 0
193 | fi
194 |
195 | # --- 9. Confirmation prompt ---
196 | if [[ "$FORCE" != true ]]; then
197 | read_confirmation "❓ Do you want to proceed with squashing? [y/N] "
198 | if [[ ! "$REPLY" =~ ^[Yy]$ ]]; then
199 | echo "❌ Aborted."
200 | exit 1
201 | fi
202 | fi
203 |
204 | # --- 10. Create backup branch ---
205 | BACKUP_BRANCH="${CURRENT_BRANCH}-backup-$(get_timestamp)"
206 | echo "💾 Creating backup branch: $BACKUP_BRANCH"
207 | git branch "$BACKUP_BRANCH"
208 |
209 | # --- 11. Perform the squash ---
210 | echo "🔄 Squashing commits..."
211 | git reset --soft "$BASE"
212 | git commit -m "$COMMIT_MSG"
213 |
214 | # --- 12. Push with safety ---
215 | echo "📤 Pushing changes..."
216 |
217 | # Check if remote exists and branch is tracked
218 | if git ls-remote --exit-code origin >/dev/null 2>&1; then
219 | # Check if current branch exists on remote
220 | if git ls-remote --exit-code origin "$CURRENT_BRANCH" >/dev/null 2>&1; then
221 | # Use --force-with-lease for safety
222 | if git push --force-with-lease origin "$CURRENT_BRANCH"; then
223 | echo "✅ Successfully squashed '$CURRENT_BRANCH' into a single commit:"
224 | echo " \"$COMMIT_MSG\""
225 | echo "💾 Backup branch created: $BACKUP_BRANCH"
226 | echo " To restore: git reset --hard $BACKUP_BRANCH"
227 | else
228 | echo "❌ Push failed. Your local changes are preserved."
229 | echo "💾 Backup branch available: $BACKUP_BRANCH"
230 | echo " This might happen if someone else pushed to the branch."
231 | echo " You may need to force push: git push --force origin $CURRENT_BRANCH"
232 | exit 1
233 | fi
234 | else
235 | # Branch doesn't exist on remote, do a regular push
236 | if git push --set-upstream origin "$CURRENT_BRANCH"; then
237 | echo "✅ Successfully squashed '$CURRENT_BRANCH' into a single commit:"
238 | echo " \"$COMMIT_MSG\""
239 | echo "💾 Backup branch created: $BACKUP_BRANCH"
240 | else
241 | echo "❌ Push failed. Your local changes are preserved."
242 | echo "💾 Backup branch available: $BACKUP_BRANCH"
243 | exit 1
244 | fi
245 | fi
246 | else
247 | echo "⚠️ No remote 'origin' found. Local squash completed but not pushed."
248 | echo "✅ Successfully squashed '$CURRENT_BRANCH' into a single commit:"
249 | echo " \"$COMMIT_MSG\""
250 | echo "💾 Backup branch created: $BACKUP_BRANCH"
251 | echo " To push: git push --set-upstream origin $CURRENT_BRANCH"
252 | fi
253 |
--------------------------------------------------------------------------------
/cmd/server.go:
--------------------------------------------------------------------------------
1 | // Package main provides the HTTP server for the Go package analyzer.
2 | package main
3 |
4 | import (
5 | "context"
6 | "encoding/json"
7 | "errors"
8 | "fmt"
9 | "log/slog"
10 | "net/http"
11 | "os"
12 | "os/signal"
13 | "path/filepath"
14 | "runtime/debug"
15 | "strings"
16 | "syscall"
17 | "time"
18 |
19 | "github.com/cvsouth/go-package-analyzer/internal/analyzer"
20 | "github.com/cvsouth/go-package-analyzer/internal/scanner"
21 | "github.com/cvsouth/go-package-analyzer/internal/visualizer"
22 | )
23 |
24 | // Server timeout constants.
25 | const (
26 | serverReadTimeout = 60 * time.Second // HTTP server read timeout
27 | serverWriteTimeout = 60 * time.Second // HTTP server write timeout
28 | serverIdleTimeout = 120 * time.Second // HTTP server idle timeout
29 | serverReadHeaderTimeout = 10 * time.Second // HTTP server read header timeout
30 | serverMaxHeaderBytes = 1 << 20 // HTTP server max header bytes (1 MB)
31 | serverShutdownTimeout = 30 * time.Second // Server graceful shutdown timeout
32 | )
33 |
34 | // APIResponse represents the response structure for the API.
35 | type APIResponse struct {
36 | Success bool `json:"success"`
37 | DOT string `json:"dot,omitempty"`
38 | Error string `json:"error,omitempty"`
39 | }
40 |
41 | // MultiEntryAPIResponse represents the response structure for multi-entry analysis.
42 | type MultiEntryAPIResponse struct {
43 | Success bool `json:"success"`
44 | EntryPoints []analyzer.EntryPoint `json:"entryPoints,omitempty"`
45 | Error string `json:"error,omitempty"`
46 | RepoRoot string `json:"repoRoot,omitempty"`
47 | ModuleName string `json:"moduleName,omitempty"`
48 | }
49 |
50 | func main() {
51 | port := os.Getenv("PORT")
52 | if port == "" {
53 | port = "6333"
54 | }
55 |
56 | server := &http.Server{
57 | Addr: ":" + port,
58 | ReadTimeout: serverReadTimeout,
59 | WriteTimeout: serverWriteTimeout,
60 | IdleTimeout: serverIdleTimeout,
61 | ReadHeaderTimeout: serverReadHeaderTimeout,
62 | MaxHeaderBytes: serverMaxHeaderBytes,
63 | }
64 |
65 | mux := http.NewServeMux()
66 |
67 | mux.Handle("/", http.FileServer(http.Dir("./web/")))
68 |
69 | mux.HandleFunc("/api/analyze", handleAnalyze)
70 | mux.HandleFunc("/api/analyze-repo", handleAnalyzeRepo)
71 | mux.HandleFunc("/api/scan-directories", handleScanDirectories)
72 | mux.HandleFunc("/api/list-directory", handleListDirectory)
73 |
74 | server.Handler = mux
75 |
76 | slog.Info("Server starting on http://localhost:" + port)
77 |
78 | sigChan := make(chan os.Signal, 1)
79 | // Use only cross-platform signals that work on all systems
80 | signal.Notify(sigChan,
81 | syscall.SIGINT, // Ctrl+C
82 | syscall.SIGTERM, // Termination request
83 | )
84 |
85 | // Start server in a goroutine
86 | go func() {
87 | defer func() {
88 | if r := recover(); r != nil {
89 | slog.Error("PANIC in server goroutine",
90 | slog.Any("panic", r),
91 | slog.String("stack", string(debug.Stack())))
92 | sigChan <- syscall.SIGTERM
93 | }
94 | }()
95 |
96 | err := server.ListenAndServe()
97 |
98 | if err != nil && !errors.Is(err, http.ErrServerClosed) {
99 | slog.Error("FATAL: HTTP server error", slog.Any("error", err))
100 | sigChan <- syscall.SIGTERM
101 | }
102 | }()
103 |
104 | <-sigChan
105 |
106 | // Give outstanding requests a deadline to complete
107 | shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), serverShutdownTimeout)
108 | defer shutdownCancel()
109 |
110 | // Shut down server gracefully
111 | if err := server.Shutdown(shutdownCtx); err != nil {
112 | slog.Error("Server forced to shutdown",
113 | slog.Any("error", err))
114 | }
115 | }
116 |
117 | func handleAnalyze(w http.ResponseWriter, r *http.Request) {
118 | w.Header().Set("Content-Type", "application/json")
119 | w.Header().Set("Access-Control-Allow-Origin", "*")
120 |
121 | if r.Method != http.MethodGet {
122 | slog.Info("handleAnalyze: Method not allowed", slog.String("method", r.Method))
123 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
124 | return
125 | }
126 |
127 | // Get query parameters
128 | entryFile := r.URL.Query().Get("entry")
129 | showExternalStr := r.URL.Query().Get("external")
130 | excludeDirsStr := r.URL.Query().Get("exclude")
131 |
132 | if entryFile == "" {
133 | sendJSONResponse(w, APIResponse{
134 | Success: false,
135 | Error: "entry parameter is required",
136 | })
137 | return
138 | }
139 |
140 | // Convert relative path to absolute
141 | absEntryFile, err := filepath.Abs(entryFile)
142 | if err != nil {
143 | sendJSONResponse(w, APIResponse{
144 | Success: false,
145 | Error: fmt.Sprintf("Error resolving entry file path: %v", err),
146 | })
147 | return
148 | }
149 |
150 | // Check if entry file exists
151 | if _, statErr := os.Stat(absEntryFile); os.IsNotExist(statErr) {
152 | sendJSONResponse(w, APIResponse{
153 | Success: false,
154 | Error: fmt.Sprintf("Entry file does not exist: %s", absEntryFile),
155 | })
156 | return
157 | }
158 |
159 | // Parse parameters
160 | showExternal := showExternalStr == "true"
161 | var excludeList []string
162 | if excludeDirsStr != "" {
163 | excludeList = strings.Split(excludeDirsStr, ",")
164 | for i, dir := range excludeList {
165 | excludeList[i] = strings.TrimSpace(dir)
166 | }
167 | }
168 |
169 | // Analyze the codebase
170 | analyze := analyzer.New()
171 | graph, err := analyze.AnalyzeFromFile(absEntryFile, !showExternal, excludeList)
172 | if err != nil {
173 | slog.Error("handleAnalyze: Analysis failed", slog.Any("error", err))
174 | sendJSONResponse(w, APIResponse{
175 | Success: false,
176 | Error: fmt.Sprintf("Error analyzing codebase: %v", err),
177 | })
178 | return
179 | }
180 |
181 | if len(graph.Packages) == 0 {
182 | sendJSONResponse(w, APIResponse{
183 | Success: false,
184 | Error: "No packages found to analyze",
185 | })
186 | return
187 | }
188 |
189 | // Generate DOT content
190 | viz := visualizer.New()
191 | dotContent := viz.GenerateDOTContent(graph)
192 |
193 | sendJSONResponse(w, APIResponse{
194 | Success: true,
195 | DOT: dotContent,
196 | })
197 | }
198 |
199 | func handleAnalyzeRepo(w http.ResponseWriter, r *http.Request) {
200 | w.Header().Set("Content-Type", "application/json")
201 | w.Header().Set("Access-Control-Allow-Origin", "*")
202 |
203 | if r.Method != http.MethodGet {
204 | slog.Info("handleAnalyzeRepo: Method not allowed", slog.String("method", r.Method))
205 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
206 | return
207 | }
208 |
209 | // Get query parameters
210 | repoRoot := r.URL.Query().Get("repo")
211 | showExternalStr := r.URL.Query().Get("external")
212 | excludeDirsStr := r.URL.Query().Get("exclude")
213 |
214 | if repoRoot == "" {
215 | sendMultiEntryJSONResponse(w, MultiEntryAPIResponse{
216 | Success: false,
217 | Error: "repo parameter is required",
218 | })
219 | return
220 | }
221 |
222 | // Convert relative path to absolute
223 | absRepoRoot, err := filepath.Abs(repoRoot)
224 | if err != nil {
225 | sendMultiEntryJSONResponse(w, MultiEntryAPIResponse{
226 | Success: false,
227 | Error: fmt.Sprintf("Error resolving repository path: %v", err),
228 | })
229 | return
230 | }
231 |
232 | // Check if repository root exists
233 | if _, statErr := os.Stat(absRepoRoot); os.IsNotExist(statErr) {
234 | sendMultiEntryJSONResponse(w, MultiEntryAPIResponse{
235 | Success: false,
236 | Error: fmt.Sprintf("Repository root does not exist: %s", absRepoRoot),
237 | })
238 | return
239 | }
240 |
241 | // Parse parameters
242 | showExternal := showExternalStr == "true"
243 | var excludeList []string
244 | if excludeDirsStr != "" {
245 | excludeList = strings.Split(excludeDirsStr, ",")
246 | for i, dir := range excludeList {
247 | excludeList[i] = strings.TrimSpace(dir)
248 | }
249 | }
250 |
251 | // Analyze the repository
252 | analyze := analyzer.New()
253 | result, err := analyze.AnalyzeMultipleEntryPoints(absRepoRoot, !showExternal, excludeList)
254 | if err != nil {
255 | slog.Error("handleAnalyzeRepo: Repository analysis failed", slog.Any("error", err))
256 | sendMultiEntryJSONResponse(w, MultiEntryAPIResponse{
257 | Success: false,
258 | Error: fmt.Sprintf("Error analyzing repository: %v", err),
259 | })
260 | return
261 | }
262 |
263 | if !result.Success {
264 | sendMultiEntryJSONResponse(w, MultiEntryAPIResponse{
265 | Success: false,
266 | Error: result.Error,
267 | })
268 | return
269 | }
270 |
271 | // Generate DOT content for each entry point
272 | viz := visualizer.New()
273 | for i := range result.EntryPoints {
274 | if result.EntryPoints[i].Graph != nil {
275 | result.EntryPoints[i].DOTContent = viz.GenerateDOTContent(result.EntryPoints[i].Graph)
276 | }
277 | }
278 | sendMultiEntryJSONResponse(w, MultiEntryAPIResponse{
279 | Success: true,
280 | EntryPoints: result.EntryPoints,
281 | RepoRoot: result.RepoRoot,
282 | ModuleName: result.ModuleName,
283 | })
284 | }
285 |
286 | func handleScanDirectories(w http.ResponseWriter, r *http.Request) {
287 | w.Header().Set("Content-Type", "application/json")
288 | w.Header().Set("Access-Control-Allow-Origin", "*")
289 |
290 | if r.Method != http.MethodGet {
291 | slog.Info("handleScanDirectories: Method not allowed", slog.String("method", r.Method))
292 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
293 | return
294 | }
295 |
296 | // Create scanner and get filesystem roots
297 | scan := scanner.New()
298 | result, err := scan.GetFilesystemRoots()
299 | if err != nil {
300 | slog.Error("handleScanDirectories: Scan failed", slog.Any("error", err))
301 | if encodeErr := json.NewEncoder(w).Encode(scanner.ScanResult{
302 | Success: false,
303 | Error: fmt.Sprintf("Error getting filesystem roots: %v", err),
304 | }); encodeErr != nil {
305 | slog.Error("handleScanDirectories: Error encoding error response", slog.Any("error", encodeErr))
306 | }
307 | return
308 | }
309 |
310 | // Return the scan result
311 | if encodeErr := json.NewEncoder(w).Encode(result); encodeErr != nil {
312 | slog.Error("handleScanDirectories: Error encoding response", slog.Any("error", encodeErr))
313 | return
314 | }
315 | }
316 |
317 | func handleListDirectory(w http.ResponseWriter, r *http.Request) {
318 | w.Header().Set("Content-Type", "application/json")
319 | w.Header().Set("Access-Control-Allow-Origin", "*")
320 |
321 | if r.Method != http.MethodGet {
322 | slog.Info("handleListDirectory: Method not allowed", slog.String("method", r.Method))
323 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
324 | return
325 | }
326 |
327 | // Get the directory path from query parameter
328 | dirPath := r.URL.Query().Get("path")
329 | if dirPath == "" {
330 | if encodeErr := json.NewEncoder(w).Encode(scanner.DirectoryListResult{
331 | Success: false,
332 | Error: "path parameter is required",
333 | }); encodeErr != nil {
334 | slog.Error("handleListDirectory: Error encoding error response", slog.Any("error", encodeErr))
335 | }
336 | return
337 | }
338 |
339 | // Create scanner and list directory
340 | scan := scanner.New()
341 | result, err := scan.ListDirectory(dirPath)
342 | if err != nil {
343 | slog.Error("handleListDirectory: List failed", slog.Any("error", err), slog.String("path", dirPath))
344 | if encodeErr := json.NewEncoder(w).Encode(scanner.DirectoryListResult{
345 | Success: false,
346 | Error: fmt.Sprintf("Error listing directory: %v", err),
347 | }); encodeErr != nil {
348 | slog.Error("handleListDirectory: Error encoding error response", slog.Any("error", encodeErr))
349 | }
350 | return
351 | }
352 |
353 | // Return the list result
354 | if encodeErr := json.NewEncoder(w).Encode(result); encodeErr != nil {
355 | slog.Error("handleListDirectory: Error encoding response", slog.Any("error", encodeErr))
356 | return
357 | }
358 | }
359 |
360 | func sendJSONResponse(w http.ResponseWriter, response APIResponse) {
361 | if err := json.NewEncoder(w).Encode(response); err != nil {
362 | slog.Error("sendJSONResponse: Error encoding response", slog.Any("error", err))
363 | return
364 | }
365 | }
366 |
367 | func sendMultiEntryJSONResponse(w http.ResponseWriter, response MultiEntryAPIResponse) {
368 | if err := json.NewEncoder(w).Encode(response); err != nil {
369 | slog.Error("sendMultiEntryJSONResponse: Error encoding response", slog.Any("error", err))
370 | return
371 | }
372 | }
373 |
--------------------------------------------------------------------------------
/web/js/index.js:
--------------------------------------------------------------------------------
1 |
2 | let selectedDirectoryPath = null;
3 | let directoryData = null;
4 |
5 | // Load recent projects when page loads
6 | window.addEventListener('load', function() {
7 | renderRecentProjects();
8 | });
9 |
10 | // Open project directory selection
11 | async function openProject() {
12 | document.getElementById('directoryModal').style.display = 'flex';
13 | loadDirectoryTree();
14 | }
15 |
16 | // Close the directory modal
17 | function closeDirectoryModal() {
18 | document.getElementById('directoryModal').style.display = 'none';
19 | selectedDirectoryPath = null;
20 | directoryData = null;
21 |
22 | // Reset button state
23 | const selectButton = document.getElementById('selectButton');
24 | selectButton.disabled = true;
25 | }
26 |
27 | // Load directory tree from backend
28 | async function loadDirectoryTree() {
29 | try {
30 | const response = await fetch('/api/scan-directories');
31 | const result = await response.json();
32 |
33 | if (result.success && result.tree) {
34 | directoryData = result.tree;
35 | renderDirectoryTree(result.tree);
36 | showDirectoryContent();
37 | } else {
38 | showErrorState(result.error || 'Failed to scan directories');
39 | }
40 | } catch (error) {
41 | console.error('Error loading directory tree:', error);
42 | showErrorState('Network error occurred while scanning directories');
43 | }
44 | }
45 |
46 | // Show error state
47 | function showErrorState(message) {
48 | document.getElementById('errorState').style.display = 'flex';
49 | document.getElementById('directoryContent').style.display = 'none';
50 | document.getElementById('modalFooter').style.display = 'none';
51 | document.getElementById('errorMessage').textContent = message;
52 | }
53 |
54 | // Show directory content
55 | function showDirectoryContent() {
56 | document.getElementById('errorState').style.display = 'none';
57 | document.getElementById('directoryContent').style.display = 'block';
58 | document.getElementById('modalFooter').style.display = 'flex';
59 | }
60 |
61 | // Render directory tree
62 | function renderDirectoryTree(node, container = null, level = 0) {
63 | if (!container) {
64 | container = document.getElementById('directoryTree');
65 | container.innerHTML = '';
66 | }
67 |
68 | if (!node.children || node.children.length === 0) {
69 | return;
70 | }
71 |
72 | for (const child of node.children) {
73 | const item = createDirectoryItem(child, level);
74 | container.appendChild(item);
75 |
76 | // Create a container for potential child directories (will be loaded on demand)
77 | const childContainer = document.createElement('div');
78 | childContainer.className = 'ml-4';
79 | childContainer.style.display = 'none';
80 | childContainer.id = `children-${child.path.replace(/[^a-zA-Z0-9]/g, '_')}`;
81 | container.appendChild(childContainer);
82 | }
83 | }
84 |
85 | // Render directory list (from API response)
86 | function renderDirectoryList(directories, container, level) {
87 | container.innerHTML = ''; // Clear existing content
88 |
89 | for (const dir of directories) {
90 | const item = createDirectoryItem(dir, level);
91 | container.appendChild(item);
92 |
93 | // Create a container for potential child directories
94 | const childContainer = document.createElement('div');
95 | childContainer.className = 'ml-4';
96 | childContainer.style.display = 'none';
97 | childContainer.id = `children-${dir.path.replace(/[^a-zA-Z0-9]/g, '_')}`;
98 | container.appendChild(childContainer);
99 | }
100 | }
101 |
102 | // Create directory item element
103 | function createDirectoryItem(node, level) {
104 | const item = document.createElement('div');
105 | item.className = `directory-item ${node.isGoProject ? 'is-go-project' : ''}`;
106 | item.style.marginLeft = `${level * 16}px`;
107 | // Chrome-specific fix for proper rendering
108 | item.style.position = 'relative';
109 |
110 | const childrenId = `children-${node.path.replace(/[^a-zA-Z0-9]/g, '_')}`;
111 |
112 | // Expand/collapse icon (show for all directories)
113 | const expandIcon = document.createElement('span');
114 | expandIcon.className = 'expand-icon mr-2 text-white/50 cursor-pointer';
115 | expandIcon.innerHTML = '▶';
116 | expandIcon.dataset.expanded = 'false';
117 | expandIcon.dataset.loaded = 'false';
118 | expandIcon.dataset.path = node.path;
119 | // Chrome rendering fix
120 | expandIcon.style.flexShrink = '0';
121 |
122 | // Directory name with better Chrome compatibility
123 | const nameSpan = document.createElement('span');
124 | nameSpan.textContent = node.name;
125 | nameSpan.className = 'directory-name';
126 | // Chrome text rendering fix
127 | nameSpan.style.lineHeight = '1.4';
128 |
129 | // Go project indicator with optimized SVG
130 | if (node.isGoProject) {
131 | const indicator = document.createElement('span');
132 | indicator.innerHTML = ``;
139 | nameSpan.appendChild(indicator);
140 | }
141 |
142 | item.appendChild(expandIcon);
143 | item.appendChild(nameSpan);
144 |
145 | // Click handlers
146 | expandIcon.addEventListener('click', function(e) {
147 | e.stopPropagation();
148 | toggleDirectoryLazy(childrenId, expandIcon, node.path, level + 1);
149 | });
150 |
151 | item.addEventListener('click', function() {
152 | selectDirectoryItem(node, item);
153 | });
154 |
155 | // Double-click handler to expand/collapse directory
156 | item.addEventListener('dblclick', function(e) {
157 | e.stopPropagation();
158 | toggleDirectoryLazy(childrenId, expandIcon, node.path, level + 1);
159 | });
160 |
161 | return item;
162 | }
163 |
164 | // Toggle directory expansion with lazy loading
165 | async function toggleDirectoryLazy(childrenId, expandIcon, dirPath, level) {
166 | const childrenContainer = document.getElementById(childrenId);
167 | if (!childrenContainer) return;
168 |
169 | const isExpanded = expandIcon.dataset.expanded === 'true';
170 | const isLoaded = expandIcon.dataset.loaded === 'true';
171 |
172 | if (isExpanded) {
173 | // Collapse
174 | childrenContainer.style.display = 'none';
175 | expandIcon.classList.remove('expanded');
176 | expandIcon.dataset.expanded = 'false';
177 | } else {
178 | // Expand
179 | if (!isLoaded) {
180 | try {
181 | // Load directory contents
182 | const response = await fetch(`/api/list-directory?path=${encodeURIComponent(dirPath)}`);
183 | const result = await response.json();
184 |
185 | if (result.success && result.directories) {
186 | if (result.directories.length === 0) {
187 | childrenContainer.innerHTML = ''; // Show nothing - directory may contain files but no subdirectories
188 | } else {
189 | renderDirectoryList(result.directories, childrenContainer, level);
190 | }
191 | expandIcon.dataset.loaded = 'true';
192 | } else {
193 | // Show the specific error message from the API
194 | const errorMsg = result.error || 'Error loading directory';
195 | childrenContainer.innerHTML = `${errorMsg}
`;
196 | }
197 | } catch (error) {
198 | console.error('Error loading directory:', error);
199 | childrenContainer.innerHTML = 'Network error
';
200 | }
201 | childrenContainer.style.display = 'block';
202 | } else {
203 | // Already loaded, just show
204 | childrenContainer.style.display = 'block';
205 | }
206 |
207 | expandIcon.classList.add('expanded');
208 | expandIcon.dataset.expanded = 'true';
209 | }
210 | }
211 |
212 | // Toggle directory expansion (legacy function - kept for compatibility)
213 | function toggleDirectory(childrenId, expandIcon) {
214 | const childrenContainer = document.getElementById(childrenId);
215 | if (childrenContainer) {
216 | if (childrenContainer.style.display === 'none') {
217 | childrenContainer.style.display = 'block';
218 | expandIcon.classList.add('expanded');
219 | } else {
220 | childrenContainer.style.display = 'none';
221 | expandIcon.classList.remove('expanded');
222 | }
223 | }
224 | }
225 |
226 | // Select directory item
227 | function selectDirectoryItem(node, itemElement) {
228 | // Clear previous selection
229 | document.querySelectorAll('.directory-item.selected').forEach(item => {
230 | item.classList.remove('selected');
231 | });
232 |
233 | // Select this item
234 | itemElement.classList.add('selected');
235 | selectedDirectoryPath = node.path;
236 |
237 | // Update UI based on whether this is a Go project
238 | const selectButton = document.getElementById('selectButton');
239 |
240 | if (node.isGoProject) {
241 | selectButton.disabled = false;
242 | } else {
243 | selectButton.disabled = true;
244 | }
245 | }
246 |
247 | // Select directory and navigate
248 | function selectDirectory() {
249 | if (selectedDirectoryPath) {
250 | const pathToNavigate = selectedDirectoryPath; // Store the path before closing modal
251 | closeDirectoryModal();
252 | navigateToGraphWithPath(pathToNavigate);
253 | // Note: addToRecentProjects is called from the graph page only on successful analysis
254 | }
255 | }
256 |
257 | // Navigate to graph page with selected path
258 | function navigateToGraphWithPath(projectPath) {
259 | if (projectPath) {
260 | window.location.href = `/graph?repo=${encodeURIComponent(projectPath)}`;
261 | } else {
262 | window.location.href = '/graph';
263 | }
264 | }
265 |
266 | // Add project to recent projects list
267 | function addToRecentProjects(projectPath) {
268 | let recent = [];
269 | try {
270 | recent = JSON.parse(localStorage.getItem('recentProjects') || '[]');
271 | } catch (e) {
272 | recent = [];
273 | }
274 |
275 | // Remove if already exists (to avoid duplicates)
276 | recent = recent.filter(proj => proj.path !== projectPath);
277 |
278 | // Add to beginning of list
279 | recent.unshift({
280 | path: projectPath,
281 | timestamp: Date.now()
282 | });
283 |
284 | // Keep only last 10 projects
285 | recent = recent.slice(0, 10);
286 |
287 | localStorage.setItem('recentProjects', JSON.stringify(recent));
288 | }
289 |
290 | // Render recent projects list in sidebar
291 | function createRecentProjectListItem(proj, idx, recent, renderRecentProjects) {
292 | const li = document.createElement('li');
293 | li.className = 'flex items-center justify-between px-4 py-3 border-b border-white/10 bg-transparent hover:bg-white/10 transition cursor-pointer';
294 | li.title = proj.path;
295 | const left = document.createElement('div');
296 | left.className = 'flex-1 text-left truncate';
297 | left.innerHTML = `${proj.path}`;
298 | const removeBtn = document.createElement('button');
299 | removeBtn.className = 'ml-3 text-white/30 hover:text-red-400 text-2xl font-bold px-2 py-0.5 rounded';
300 | removeBtn.title = 'Remove from recent';
301 | removeBtn.innerHTML = '×';
302 | removeBtn.onclick = function(e) {
303 | e.stopPropagation();
304 | let updated = recent.filter((_, i) => i !== idx);
305 | localStorage.setItem('recentProjects', JSON.stringify(updated));
306 | renderRecentProjects();
307 | };
308 | li.onclick = function(e) {
309 | if (e.target !== removeBtn) {
310 | window.location.href = `/graph?repo=${encodeURIComponent(proj.path)}`;
311 | }
312 | };
313 | li.appendChild(left);
314 | li.appendChild(removeBtn);
315 | return li;
316 | }
317 |
318 | function renderRecentProjects() {
319 | let recent = [];
320 | try {
321 | recent = JSON.parse(localStorage.getItem('recentProjects') || '[]');
322 | } catch (e) { recent = []; }
323 | const list = document.getElementById('recentProjectsList');
324 | if (list) list.innerHTML = '';
325 | const sidebar = document.getElementById('recentProjectsSidebar');
326 | const sidebarSpacer = document.getElementById('recentProjectsSidebarSpacer');
327 | if (!recent.length) {
328 | if (sidebar) sidebar.style.display = 'none';
329 | if (sidebarSpacer) sidebarSpacer.style.display = 'none';
330 | return;
331 | } else {
332 | if (sidebar) sidebar.style.display = '';
333 | if (sidebarSpacer) sidebarSpacer.style.display = '';
334 | }
335 | recent.forEach((proj, idx) => {
336 | list.appendChild(createRecentProjectListItem(proj, idx, recent, renderRecentProjects));
337 | });
338 | }
339 |
340 | // Also update after navigation
341 | window.addEventListener('storage', function(e) {
342 | if (e.key === 'recentProjects') renderRecentProjects();
343 | });
344 |
345 | // Close modal when clicking outside
346 | document.getElementById('directoryModal').addEventListener('click', function(e) {
347 | if (e.target === this) {
348 | closeDirectoryModal();
349 | }
350 | });
351 |
352 | // Close modal with Escape key
353 | document.addEventListener('keydown', function(e) {
354 | if (e.key === 'Escape' && document.getElementById('directoryModal').style.display !== 'none') {
355 | closeDirectoryModal();
356 | }
357 | });
--------------------------------------------------------------------------------
/internal/visualizer/visualizer_test.go:
--------------------------------------------------------------------------------
1 | package visualizer_test
2 |
3 | import (
4 | "strings"
5 | "testing"
6 |
7 | "github.com/cvsouth/go-package-analyzer/internal/analyzer"
8 | "github.com/cvsouth/go-package-analyzer/internal/visualizer"
9 | )
10 |
11 | func TestNew(t *testing.T) {
12 | viz := visualizer.New()
13 | if viz == nil {
14 | t.Fatal("New() returned nil")
15 | }
16 | }
17 |
18 | func TestGenerateDOTContent_BasicGraph(t *testing.T) {
19 | // Create a simple dependency graph
20 | graph := &analyzer.DependencyGraph{
21 | EntryPackage: "test/main",
22 | ModuleName: "test",
23 | Packages: map[string]*analyzer.PackageInfo{
24 | "test/main": {
25 | Name: "main",
26 | Path: "test/main",
27 | Dependencies: []string{"test/util"},
28 | FileCount: 1,
29 | },
30 | "test/util": {
31 | Name: "util",
32 | Path: "test/util",
33 | Dependencies: []string{},
34 | FileCount: 2,
35 | },
36 | },
37 | Layers: [][]string{
38 | {"test/util"},
39 | {"test/main"},
40 | },
41 | }
42 |
43 | viz := visualizer.New()
44 | dotContent := viz.GenerateDOTContent(graph)
45 |
46 | // Basic checks
47 | if !strings.Contains(dotContent, "digraph dependencies") {
48 | t.Error("DOT content should contain 'digraph dependencies'")
49 | }
50 |
51 | if !strings.Contains(dotContent, "test_main") {
52 | t.Error("DOT content should contain sanitized node ID for main package")
53 | }
54 |
55 | if !strings.Contains(dotContent, "test_util") {
56 | t.Error("DOT content should contain sanitized node ID for util package")
57 | }
58 |
59 | if !strings.Contains(dotContent, "1 files") {
60 | t.Error("DOT content should contain file count for main package")
61 | }
62 |
63 | if !strings.Contains(dotContent, "2 files") {
64 | t.Error("DOT content should contain file count for util package")
65 | }
66 |
67 | // Check for edge
68 | if !strings.Contains(dotContent, "test_main -> test_util") {
69 | t.Error("DOT content should contain edge from main to util")
70 | }
71 | }
72 |
73 | func TestGenerateDOTContent_CircularDependencies(t *testing.T) {
74 | // Create a graph with circular dependencies
75 | graph := &analyzer.DependencyGraph{
76 | EntryPackage: "test/main",
77 | ModuleName: "test",
78 | Packages: map[string]*analyzer.PackageInfo{
79 | "test/main": {
80 | Name: "main",
81 | Path: "test/main",
82 | Dependencies: []string{"test/a"},
83 | FileCount: 1,
84 | },
85 | "test/a": {
86 | Name: "a",
87 | Path: "test/a",
88 | Dependencies: []string{"test/b"},
89 | FileCount: 1,
90 | },
91 | "test/b": {
92 | Name: "b",
93 | Path: "test/b",
94 | Dependencies: []string{"test/a"}, // Circular dependency
95 | FileCount: 1,
96 | },
97 | },
98 | }
99 |
100 | viz := visualizer.New()
101 | dotContent := viz.GenerateDOTContent(graph)
102 |
103 | // Should contain red edges for circular dependencies
104 | if !strings.Contains(dotContent, `color="red"`) {
105 | t.Error("DOT content should contain red edges for circular dependencies")
106 | }
107 | }
108 |
109 | func TestGenerateDOTContent_LayerConstraints(t *testing.T) {
110 | // Create a graph with multiple layers
111 | graph := &analyzer.DependencyGraph{
112 | EntryPackage: "test/main",
113 | ModuleName: "test",
114 | Packages: map[string]*analyzer.PackageInfo{
115 | "test/main": {
116 | Name: "main",
117 | Path: "test/main",
118 | Dependencies: []string{"test/middleware", "test/util"},
119 | FileCount: 1,
120 | },
121 | "test/middleware": {
122 | Name: "middleware",
123 | Path: "test/middleware",
124 | Dependencies: []string{"test/util"},
125 | FileCount: 3,
126 | },
127 | "test/util": {
128 | Name: "util",
129 | Path: "test/util",
130 | Dependencies: []string{},
131 | FileCount: 2,
132 | },
133 | },
134 | Layers: [][]string{
135 | {"test/util"},
136 | {"test/middleware"},
137 | {"test/main"},
138 | },
139 | }
140 |
141 | viz := visualizer.New()
142 | dotContent := viz.GenerateDOTContent(graph)
143 |
144 | // Should contain rank constraints
145 | if !strings.Contains(dotContent, "rank=") {
146 | t.Error("DOT content should contain rank constraints for layering")
147 | }
148 |
149 | // Should contain rank=source for entry package
150 | if !strings.Contains(dotContent, "rank=source") {
151 | t.Error("DOT content should contain rank=source for entry package")
152 | }
153 | }
154 |
155 | func TestGenerateDOTContent_EmptyGraph(t *testing.T) {
156 | // Test with minimal graph
157 | graph := &analyzer.DependencyGraph{
158 | EntryPackage: "test/main",
159 | ModuleName: "test",
160 | Packages: map[string]*analyzer.PackageInfo{
161 | "test/main": {
162 | Name: "main",
163 | Path: "test/main",
164 | Dependencies: []string{},
165 | FileCount: 1,
166 | },
167 | },
168 | Layers: [][]string{
169 | {"test/main"},
170 | },
171 | }
172 |
173 | viz := visualizer.New()
174 | dotContent := viz.GenerateDOTContent(graph)
175 |
176 | // Should still generate valid DOT
177 | if !strings.Contains(dotContent, "digraph dependencies") {
178 | t.Error("DOT content should contain 'digraph dependencies' even for empty graph")
179 | }
180 |
181 | if !strings.Contains(dotContent, "test_main") {
182 | t.Error("DOT content should contain the main package node")
183 | }
184 |
185 | // Should not contain any edges
186 | if strings.Contains(dotContent, "->") {
187 | t.Error("Empty graph should not contain any edges")
188 | }
189 | }
190 |
191 | func TestGenerateDOTContent_DeterministicOutput(t *testing.T) {
192 | // Create a simple graph
193 | createGraph := func() *analyzer.DependencyGraph {
194 | return &analyzer.DependencyGraph{
195 | EntryPackage: "test/main",
196 | ModuleName: "test",
197 | Packages: map[string]*analyzer.PackageInfo{
198 | "test/main": {
199 | Name: "main",
200 | Path: "test/main",
201 | Dependencies: []string{"test/util"},
202 | FileCount: 1,
203 | },
204 | "test/util": {
205 | Name: "util",
206 | Path: "test/util",
207 | Dependencies: []string{},
208 | FileCount: 2,
209 | },
210 | },
211 | }
212 | }
213 |
214 | viz := visualizer.New()
215 | dotContent1 := viz.GenerateDOTContent(createGraph())
216 | dotContent2 := viz.GenerateDOTContent(createGraph())
217 |
218 | // Should generate identical output for identical input
219 | if dotContent1 != dotContent2 {
220 | t.Error("GenerateDOTContent should produce deterministic output for identical graphs")
221 | }
222 | }
223 |
224 | // TestGenerateDOTContent_NodeSanitization tests node ID sanitization through black-box approach.
225 | func TestGenerateDOTContent_NodeSanitization(t *testing.T) {
226 | testCases := []struct {
227 | name string
228 | packagePath string
229 | expectedSanitized string // What we expect to see in DOT output
230 | shouldNotContain []string // Characters that should not appear in node IDs
231 | }{
232 | {
233 | name: "dots in package path",
234 | packagePath: "github.com/user/repo",
235 | expectedSanitized: "github_com_user_repo",
236 | shouldNotContain: []string{".", "/"},
237 | },
238 | {
239 | name: "slashes in package path",
240 | packagePath: "test/internal/package",
241 | expectedSanitized: "test_internal_package",
242 | shouldNotContain: []string{"/"},
243 | },
244 | {
245 | name: "dashes in package path",
246 | packagePath: "my-project/sub-package",
247 | expectedSanitized: "my_project_sub_package",
248 | shouldNotContain: []string{"-"},
249 | },
250 | {
251 | name: "mixed special characters",
252 | packagePath: "complex.package-name/with_chars",
253 | expectedSanitized: "complex_package_name_with_chars",
254 | shouldNotContain: []string{".", "-", "/"},
255 | },
256 | }
257 |
258 | for _, tc := range testCases {
259 | t.Run(tc.name, func(t *testing.T) {
260 | graph := createTestGraph(tc.packagePath)
261 | viz := visualizer.New()
262 | dotContent := viz.GenerateDOTContent(graph)
263 |
264 | validateSanitizedNodeID(t, dotContent, tc.expectedSanitized)
265 | validateNoProblematicChars(t, dotContent, tc.shouldNotContain)
266 | })
267 | }
268 | }
269 |
270 | // TestGenerateDOTContent_TextHandling tests text wrapping and escaping through black-box approach.
271 | func TestGenerateDOTContent_TextHandling(t *testing.T) {
272 | testCases := []struct {
273 | name string
274 | packageName string
275 | packagePath string
276 | shouldEscape []string // Characters that should be escaped in labels
277 | shouldWrap bool // Whether long text should be wrapped
278 | }{
279 | {
280 | name: "HTML characters in package name",
281 | packageName: "test",
282 | packagePath: "test/package",
283 | shouldEscape: []string{"<", ">"},
284 | shouldWrap: false,
285 | },
286 | {
287 | name: "quotes in package name",
288 | packageName: `test"package"`,
289 | packagePath: "test/package",
290 | shouldEscape: []string{"""},
291 | shouldWrap: false,
292 | },
293 | {
294 | name: "ampersand in package name",
295 | packageName: "test&package",
296 | packagePath: "test/package",
297 | shouldEscape: []string{"&"},
298 | shouldWrap: false,
299 | },
300 | {
301 | name: "very long package path",
302 | packageName: "verylongpackagename",
303 | packagePath: "very/long/package/path/that/should/be/wrapped/because/it/exceeds/normal/length/limits",
304 | shouldEscape: []string{},
305 | shouldWrap: true,
306 | },
307 | }
308 |
309 | for _, tc := range testCases {
310 | t.Run(tc.name, func(t *testing.T) {
311 | graph := &analyzer.DependencyGraph{
312 | EntryPackage: tc.packagePath,
313 | ModuleName: "test",
314 | Packages: map[string]*analyzer.PackageInfo{
315 | tc.packagePath: {
316 | Name: tc.packageName,
317 | Path: tc.packagePath,
318 | Dependencies: []string{},
319 | FileCount: 1,
320 | },
321 | },
322 | }
323 |
324 | viz := visualizer.New()
325 | dotContent := viz.GenerateDOTContent(graph)
326 |
327 | // Check for proper HTML escaping
328 | for _, escaped := range tc.shouldEscape {
329 | if !strings.Contains(dotContent, escaped) {
330 | t.Errorf("Expected to find escaped sequence '%s' in DOT output", escaped)
331 | }
332 | }
333 |
334 | // Check for text wrapping (presence of \\n in labels)
335 | if tc.shouldWrap {
336 | if !strings.Contains(dotContent, "\\n") {
337 | t.Error("Expected to find line breaks (\\n) in DOT output for long text")
338 | }
339 | }
340 |
341 | // Verify the label is properly quoted and contains expected content
342 | if !strings.Contains(dotContent, "label=") {
343 | t.Error("Expected to find label attribute in DOT output")
344 | }
345 | })
346 | }
347 | }
348 |
349 | // TestGenerateDOTContent_ColorHandling tests color conversion through black-box approach.
350 | func TestGenerateDOTContent_ColorHandling(t *testing.T) {
351 | // Create a graph with multiple packages to trigger different colors
352 | graph := &analyzer.DependencyGraph{
353 | EntryPackage: "test/main",
354 | ModuleName: "test",
355 | Packages: map[string]*analyzer.PackageInfo{
356 | "test/main": {
357 | Name: "main",
358 | Path: "test/main",
359 | Dependencies: []string{"test/util", "test/service"},
360 | FileCount: 1,
361 | },
362 | "test/util": {
363 | Name: "util",
364 | Path: "test/util",
365 | Dependencies: []string{},
366 | FileCount: 2,
367 | },
368 | "test/service": {
369 | Name: "service",
370 | Path: "test/service",
371 | Dependencies: []string{},
372 | FileCount: 3,
373 | },
374 | },
375 | }
376 |
377 | viz := visualizer.New()
378 | dotContent := viz.GenerateDOTContent(graph)
379 |
380 | // Check that colors are properly formatted
381 | colorPatterns := []string{
382 | "fillcolor=", // Should have fill colors
383 | "color=", // Should have border colors
384 | "rgba(", // Should use RGBA format for fill colors
385 | "#", // Should use hex format for border colors
386 | }
387 |
388 | for _, pattern := range colorPatterns {
389 | if !strings.Contains(dotContent, pattern) {
390 | t.Errorf("Expected to find color pattern '%s' in DOT output", pattern)
391 | }
392 | }
393 |
394 | // Verify RGBA format is correct (rgba(r,g,b,opacity))
395 | validateRGBAFormat(t, dotContent)
396 |
397 | // Verify hex colors are properly formatted
398 | validateHexColors(t, dotContent)
399 | }
400 |
401 | // validateHexColors is a helper function to validate hex color formatting in DOT content.
402 | func validateHexColors(t *testing.T, dotContent string) {
403 | if !strings.Contains(dotContent, "#") {
404 | return // No hex colors to validate
405 | }
406 |
407 | lines := strings.Split(dotContent, "\n")
408 | for _, line := range lines {
409 | if !strings.Contains(line, "color=") || !strings.Contains(line, "#") {
410 | continue
411 | }
412 |
413 | // Extract hex color and verify it's 6 characters
414 | colorStart := strings.Index(line, "#")
415 | if colorStart < 0 || colorStart+7 >= len(line) {
416 | continue
417 | }
418 |
419 | hexColor := line[colorStart : colorStart+7]
420 | if len(hexColor) != 7 { // # + 6 hex digits
421 | t.Errorf("Hex color should be 7 characters (#RRGGBB): %s", hexColor)
422 | }
423 | }
424 | }
425 |
426 | // validateRGBAFormat is a helper function to validate RGBA color formatting in DOT content.
427 | func validateRGBAFormat(t *testing.T, dotContent string) {
428 | if !strings.Contains(dotContent, "rgba(") {
429 | return // No RGBA colors to validate
430 | }
431 |
432 | rgbaStart := strings.Index(dotContent, "rgba(")
433 | if rgbaStart < 0 {
434 | return
435 | }
436 |
437 | rgbaEnd := strings.Index(dotContent[rgbaStart:], ")")
438 | if rgbaEnd < 0 {
439 | return
440 | }
441 |
442 | rgbaValue := dotContent[rgbaStart : rgbaStart+rgbaEnd+1]
443 | // Should contain comma-separated values
444 | if !strings.Contains(rgbaValue, ",") {
445 | t.Errorf("RGBA value should contain comma-separated components: %s", rgbaValue)
446 | }
447 | }
448 |
449 | // TestGenerateDOTContent_ComplexStructures tests complex scenarios through black-box approach.
450 | func TestGenerateDOTContent_ComplexStructures(t *testing.T) {
451 | // Test with a complex graph that exercises multiple private function behaviors
452 | graph := &analyzer.DependencyGraph{
453 | EntryPackage: "github.com/complex-project/main",
454 | ModuleName: "github.com/complex-project",
455 | Packages: map[string]*analyzer.PackageInfo{
456 | "github.com/complex-project/main": {
457 | Name: "mainspecial&chars",
458 | Path: "github.com/complex-project/main",
459 | Dependencies: []string{"github.com/complex-project/very-long-package-name"},
460 | FileCount: 1,
461 | },
462 | "github.com/complex-project/very-long-package-name": {
463 | Name: "verylongpackagename",
464 | Path: "github.com/complex-project/very-long-package-name",
465 | Dependencies: []string{},
466 | FileCount: 10,
467 | },
468 | },
469 | Layers: [][]string{
470 | {"github.com/complex-project/very-long-package-name"},
471 | {"github.com/complex-project/main"},
472 | },
473 | }
474 |
475 | viz := visualizer.New()
476 | dotContent := viz.GenerateDOTContent(graph)
477 |
478 | // Verify all major components are present and properly formatted
479 | checks := []struct {
480 | description string
481 | test func(string) bool
482 | }{
483 | {
484 | "should contain digraph declaration",
485 | func(content string) bool {
486 | return strings.Contains(content, "digraph dependencies")
487 | },
488 | },
489 | {
490 | "should contain sanitized node IDs",
491 | func(content string) bool {
492 | return strings.Contains(content, "github_com_complex_project")
493 | },
494 | },
495 | {
496 | "should contain escaped HTML characters",
497 | func(content string) bool {
498 | return strings.Contains(content, "<") &&
499 | strings.Contains(content, ">") &&
500 | strings.Contains(content, "&")
501 | },
502 | },
503 | {
504 | "should contain file count information",
505 | func(content string) bool {
506 | return strings.Contains(content, "1 files") &&
507 | strings.Contains(content, "10 files")
508 | },
509 | },
510 | {
511 | "should contain dependency edges",
512 | func(content string) bool {
513 | return strings.Contains(content, "->")
514 | },
515 | },
516 | {
517 | "should contain color information",
518 | func(content string) bool {
519 | return strings.Contains(content, "fillcolor=") &&
520 | strings.Contains(content, "color=")
521 | },
522 | },
523 | {
524 | "should contain rank constraints for layering",
525 | func(content string) bool {
526 | return strings.Contains(content, "rank=")
527 | },
528 | },
529 | {
530 | "should properly close the digraph",
531 | func(content string) bool {
532 | return strings.HasSuffix(strings.TrimSpace(content), "}")
533 | },
534 | },
535 | }
536 |
537 | for _, check := range checks {
538 | if !check.test(dotContent) {
539 | t.Errorf("Failed check: %s", check.description)
540 | }
541 | }
542 |
543 | // Verify the DOT content is valid by checking basic structure
544 | if !strings.HasPrefix(strings.TrimSpace(dotContent), "digraph") {
545 | t.Error("DOT content should start with 'digraph'")
546 | }
547 |
548 | // Count braces to ensure they're balanced
549 | openBraces := strings.Count(dotContent, "{")
550 | closeBraces := strings.Count(dotContent, "}")
551 | if openBraces != closeBraces {
552 | t.Errorf("Unbalanced braces in DOT output: %d open, %d close", openBraces, closeBraces)
553 | }
554 | }
555 |
556 | // Helper functions for visualizer test support
557 |
558 | // createTestGraph creates a simple test graph with a single package.
559 | func createTestGraph(packagePath string) *analyzer.DependencyGraph {
560 | return &analyzer.DependencyGraph{
561 | EntryPackage: packagePath,
562 | ModuleName: "test",
563 | Packages: map[string]*analyzer.PackageInfo{
564 | packagePath: {
565 | Name: "test",
566 | Path: packagePath,
567 | Dependencies: []string{},
568 | FileCount: 1,
569 | },
570 | },
571 | }
572 | }
573 |
574 | // shouldSkipDOTLine determines if a DOT line should be skipped during node ID validation.
575 | func shouldSkipDOTLine(line string) bool {
576 | trimmed := strings.TrimSpace(line)
577 |
578 | skipPrefixes := []string{
579 | "node [", "edge [", "digraph", "{", "}", "",
580 | }
581 |
582 | for _, prefix := range skipPrefixes {
583 | if strings.HasPrefix(trimmed, prefix) || trimmed == prefix {
584 | return true
585 | }
586 | }
587 |
588 | return false
589 | }
590 |
591 | // extractNodeIDsFromDOT extracts node IDs from DOT content lines.
592 | func extractNodeIDsFromDOT(dotContent string) []string {
593 | var nodeIDs []string
594 | lines := strings.Split(dotContent, "\n")
595 |
596 | for _, line := range lines {
597 | if shouldSkipDOTLine(line) {
598 | continue
599 | }
600 |
601 | trimmedLine := strings.TrimSpace(line)
602 | if strings.Contains(line, "[") && strings.Contains(line, "]") {
603 | // Extract the node ID (part before the first '[')
604 | nodeIDPart := strings.Split(trimmedLine, "[")[0]
605 | nodeIDs = append(nodeIDs, strings.TrimSpace(nodeIDPart))
606 | }
607 | }
608 |
609 | return nodeIDs
610 | }
611 |
612 | // validateSanitizedNodeID checks that the expected sanitized node ID appears in DOT output.
613 | func validateSanitizedNodeID(t *testing.T, dotContent, expectedSanitized string) {
614 | t.Helper()
615 | if !strings.Contains(dotContent, expectedSanitized) {
616 | t.Errorf("Expected to find sanitized node ID '%s' in DOT output", expectedSanitized)
617 | }
618 | }
619 |
620 | // validateNoProblematicChars checks that problematic characters don't appear in node IDs.
621 | func validateNoProblematicChars(t *testing.T, dotContent string, problematicChars []string) {
622 | t.Helper()
623 | nodeIDs := extractNodeIDsFromDOT(dotContent)
624 |
625 | for _, char := range problematicChars {
626 | for _, nodeID := range nodeIDs {
627 | if strings.Contains(nodeID, char) {
628 | t.Errorf("Found problematic character '%s' in node ID '%s'", char, nodeID)
629 | }
630 | }
631 | }
632 | }
633 |
--------------------------------------------------------------------------------
/.golangci.yml:
--------------------------------------------------------------------------------
1 | # This file is licensed under the terms of the MIT license https://opensource.org/license/mit
2 | # Copyright (c) 2021-2025 Marat Reymers
3 |
4 | ## Golden config for golangci-lint v2.1.6
5 | #
6 | # This is the best config for golangci-lint based on my experience and opinion.
7 | # It is very strict, but not extremely strict.
8 | # Feel free to adapt it to suit your needs.
9 | # If this config helps you, please consider keeping a link to this file (see the next comment).
10 |
11 | # Based on https://gist.github.com/maratori/47a4d00457a92aa426dbd48a18776322
12 |
13 | version: "2"
14 |
15 | issues:
16 | # Maximum count of issues with the same text.
17 | # Set to 0 to disable.
18 | # Default: 3
19 | max-same-issues: 50
20 |
21 | formatters:
22 | enable:
23 | - goimports # checks if the code and import statements are formatted according to the 'goimports' command
24 | - golines # checks if code is formatted, and fixes long lines
25 |
26 | ## you may want to enable
27 | #- gci # checks if code and import statements are formatted, with additional rules
28 | #- gofmt # checks if the code is formatted according to 'gofmt' command
29 | #- gofumpt # enforces a stricter format than 'gofmt', while being backwards compatible
30 |
31 | # All settings can be found here https://github.com/golangci/golangci-lint/blob/HEAD/.golangci.reference.yml
32 | settings:
33 | goimports:
34 | # A list of prefixes, which, if set, checks import paths
35 | # with the given prefixes are grouped after 3rd-party packages.
36 | # Default: []
37 | local-prefixes:
38 | - github.com/my/project
39 |
40 | golines:
41 | # Target maximum line length.
42 | # Default: 100
43 | max-len: 120
44 |
45 | linters:
46 | enable:
47 | - asasalint # checks for pass []any as any in variadic func(...any)
48 | - asciicheck # checks that your code does not contain non-ASCII identifiers
49 | - bidichk # checks for dangerous unicode character sequences
50 | - bodyclose # checks whether HTTP response body is closed successfully
51 | - canonicalheader # checks whether net/http.Header uses canonical header
52 | - copyloopvar # detects places where loop variables are copied (Go 1.22+)
53 | - cyclop # checks function and package cyclomatic complexity
54 | - depguard # checks if package imports are in a list of acceptable packages
55 | - dupl # tool for code clone detection
56 | - durationcheck # checks for two durations multiplied together
57 | - errcheck # checking for unchecked errors, these unchecked errors can be critical bugs in some cases
58 | - errname # checks that sentinel errors are prefixed with the Err and error types are suffixed with the Error
59 | - errorlint # finds code that will cause problems with the error wrapping scheme introduced in Go 1.13
60 | - exhaustive # checks exhaustiveness of enum switch statements
61 | - exptostd # detects functions from golang.org/x/exp/ that can be replaced by std functions
62 | - fatcontext # detects nested contexts in loops
63 | - forbidigo # forbids identifiers
64 | - funcorder # checks the order of functions, methods, and constructors
65 | - funlen # tool for detection of long functions
66 | - gocheckcompilerdirectives # validates go compiler directive comments (//go:)
67 | - gochecknoglobals # checks that no global variables exist
68 | - gochecknoinits # checks that no init functions are present in Go code
69 | - gochecksumtype # checks exhaustiveness on Go "sum types"
70 | - gocognit # computes and checks the cognitive complexity of functions
71 | - goconst # finds repeated strings that could be replaced by a constant
72 | - gocritic # provides diagnostics that check for bugs, performance and style issues
73 | - gocyclo # computes and checks the cyclomatic complexity of functions
74 | - godot # checks if comments end in a period
75 | - gomoddirectives # manages the use of 'replace', 'retract', and 'excludes' directives in go.mod
76 | - goprintffuncname # checks that printf-like functions are named with f at the end
77 | - gosec # inspects source code for security problems
78 | - govet # reports suspicious constructs, such as Printf calls whose arguments do not align with the format string
79 | - iface # checks the incorrect use of interfaces, helping developers avoid interface pollution
80 | - ineffassign # detects when assignments to existing variables are not used
81 | - intrange # finds places where for loops could make use of an integer range
82 | - loggercheck # checks key value pairs for common logger libraries (kitlog,klog,logr,zap)
83 | - makezero # finds slice declarations with non-zero initial length
84 | - mirror # reports wrong mirror patterns of bytes/strings usage
85 | - mnd # detects magic numbers
86 | - musttag # enforces field tags in (un)marshaled structs
87 | - nakedret # finds naked returns in functions greater than a specified function length
88 | - nestif # reports deeply nested if statements
89 | - nilerr # finds the code that returns nil even if it checks that the error is not nil
90 | - nilnesserr # reports that it checks for err != nil, but it returns a different nil value error (powered by nilness and nilerr)
91 | - nilnil # checks that there is no simultaneous return of nil error and an invalid value
92 | - noctx # finds sending http request without context.Context
93 | - nolintlint # reports ill-formed or insufficient nolint directives
94 | - nonamedreturns # reports all named returns
95 | - nosprintfhostport # checks for misuse of Sprintf to construct a host with port in a URL
96 | - perfsprint # checks that fmt.Sprintf can be replaced with a faster alternative
97 | - predeclared # finds code that shadows one of Go's predeclared identifiers
98 | - promlinter # checks Prometheus metrics naming via promlint
99 | - protogetter # reports direct reads from proto message fields when getters should be used
100 | - reassign # checks that package variables are not reassigned
101 | - recvcheck # checks for receiver type consistency
102 | - revive # fast, configurable, extensible, flexible, and beautiful linter for Go, drop-in replacement of golint
103 | - rowserrcheck # checks whether Err of rows is checked successfully
104 | - sloglint # ensure consistent code style when using log/slog
105 | - spancheck # checks for mistakes with OpenTelemetry/Census spans
106 | - sqlclosecheck # checks that sql.Rows and sql.Stmt are closed
107 | - staticcheck # is a go vet on steroids, applying a ton of static analysis checks
108 | - testableexamples # checks if examples are testable (have an expected output)
109 | - testifylint # checks usage of github.com/stretchr/testify
110 | - testpackage # makes you use a separate _test package
111 | - tparallel # detects inappropriate usage of t.Parallel() method in your Go test codes
112 | - unconvert # removes unnecessary type conversions
113 | - unparam # reports unused function parameters
114 | - unused # checks for unused constants, variables, functions and types
115 | - usestdlibvars # detects the possibility to use variables/constants from the Go standard library
116 | - usetesting # reports uses of functions with replacement inside the testing package
117 | - wastedassign # finds wasted assignment statements
118 | - whitespace # detects leading and trailing whitespace
119 |
120 | ## you may want to enable
121 | - decorder # checks declaration order and count of types, constants, variables and functions
122 | # - exhaustruct # [highly recommend to enable] checks if all structure fields are initialized
123 | - ginkgolinter # [if you use ginkgo/gomega] enforces standards of using ginkgo and gomega
124 | - godox # detects usage of FIXME, TODO and other keywords inside comments
125 | - goheader # checks is file header matches to pattern
126 | - inamedparam # [great idea, but too strict, need to ignore a lot of cases by default] reports interfaces with unnamed method parameters
127 | - interfacebloat # checks the number of methods inside an interface
128 | - ireturn # accept interfaces, return concrete types
129 | # - prealloc # [premature optimization, but can be used in some cases] finds slice declarations that could potentially be preallocated
130 | - tagalign # checks that struct tags are well aligned
131 | # - varnamelen # [great idea, but too many false positives] checks that the length of a variable's name matches its scope
132 | # - wrapcheck # checks that errors returned from external packages are wrapped
133 | - zerologlint # detects the wrong usage of zerolog that a user forgets to dispatch zerolog.Event
134 |
135 | ## disabled
136 | #- containedctx # detects struct contained context.Context field
137 | #- contextcheck # [too many false positives] checks the function whether use a non-inherited context
138 | #- dogsled # checks assignments with too many blank identifiers (e.g. x, _, _, _, := f())
139 | #- dupword # [useless without config] checks for duplicate words in the source code
140 | #- err113 # [too strict] checks the errors handling expressions
141 | #- errchkjson # [don't see profit + I'm against of omitting errors like in the first example https://github.com/breml/errchkjson] checks types passed to the json encoding functions. Reports unsupported types and optionally reports occasions, where the check for the returned error can be omitted
142 | #- forcetypeassert # [replaced by errcheck] finds forced type assertions
143 | #- gomodguard # [use more powerful depguard] allow and block lists linter for direct Go module dependencies
144 | #- gosmopolitan # reports certain i18n/l10n anti-patterns in your Go codebase
145 | #- grouper # analyzes expression groups
146 | #- importas # enforces consistent import aliases
147 | #- lll # [replaced by golines] reports long lines
148 | #- maintidx # measures the maintainability index of each function
149 | #- misspell # [useless] finds commonly misspelled English words in comments
150 | #- nlreturn # [too strict and mostly code is not more readable] checks for a new line before return and branch statements to increase code clarity
151 | #- paralleltest # [too many false positives] detects missing usage of t.Parallel() method in your Go test
152 | #- tagliatelle # checks the struct tags
153 | #- thelper # detects golang test helpers without t.Helper() call and checks the consistency of test helpers
154 | #- wsl # [too strict and mostly code is not more readable] whitespace linter forces you to use empty lines
155 |
156 | # All settings can be found here https://github.com/golangci/golangci-lint/blob/HEAD/.golangci.reference.yml
157 | settings:
158 | cyclop:
159 | # The maximal code complexity to report.
160 | # Default: 10
161 | max-complexity: 30
162 | # The maximal average package complexity.
163 | # If it's higher than 0.0 (float) the check is enabled.
164 | # Default: 0.0
165 | package-average: 10.0
166 |
167 | depguard:
168 | # Rules to apply.
169 | #
170 | # Variables:
171 | # - File Variables
172 | # Use an exclamation mark `!` to negate a variable.
173 | # Example: `!$test` matches any file that is not a go test file.
174 | #
175 | # `$all` - matches all go files
176 | # `$test` - matches all go test files
177 | #
178 | # - Package Variables
179 | #
180 | # `$gostd` - matches all of go's standard library (Pulled from `GOROOT`)
181 | #
182 | # Default (applies if no custom rules are defined): Only allow $gostd in all files.
183 | rules:
184 | "deprecated":
185 | # List of file globs that will match this list of settings to compare against.
186 | # By default, if a path is relative, it is relative to the directory where the golangci-lint command is executed.
187 | # The placeholder '${base-path}' is substituted with a path relative to the mode defined with `run.relative-path-mode`.
188 | # The placeholder '${config-path}' is substituted with a path relative to the configuration file.
189 | # Default: $all
190 | files:
191 | - "$all"
192 | # List of packages that are not allowed.
193 | # Entries can be a variable (starting with $), a string prefix, or an exact match (if ending with $).
194 | # Default: []
195 | deny:
196 | - pkg: github.com/golang/protobuf
197 | desc: Use google.golang.org/protobuf instead, see https://developers.google.com/protocol-buffers/docs/reference/go/faq#modules
198 | - pkg: github.com/satori/go.uuid
199 | desc: Use github.com/google/uuid instead, satori's package is not maintained
200 | - pkg: github.com/gofrs/uuid$
201 | desc: Use github.com/gofrs/uuid/v5 or later, it was not a go module before v5
202 | "non-test files":
203 | files:
204 | - "!$test"
205 | deny:
206 | - pkg: math/rand$
207 | desc: Use math/rand/v2 instead, see https://go.dev/blog/randv2
208 | "non-main files":
209 | files:
210 | - "!**/main.go"
211 | deny:
212 | - pkg: log$
213 | desc: Use log/slog instead, see https://go.dev/blog/slog
214 |
215 | errcheck:
216 | # Report about not checking of errors in type assertions: `a := b.(MyStruct)`.
217 | # Such cases aren't reported by default.
218 | # Default: false
219 | check-type-assertions: true
220 |
221 | exhaustive:
222 | # Program elements to check for exhaustiveness.
223 | # Default: [ switch ]
224 | check:
225 | - switch
226 | - map
227 |
228 | exhaustruct:
229 | # List of regular expressions to exclude struct packages and their names from checks.
230 | # Regular expressions must match complete canonical struct package/name/structname.
231 | # Default: []
232 | exclude:
233 | # std libs
234 | - ^net/http.Client$
235 | - ^net/http.Cookie$
236 | - ^net/http.Request$
237 | - ^net/http.Response$
238 | - ^net/http.Server$
239 | - ^net/http.Transport$
240 | - ^net/url.URL$
241 | - ^os/exec.Cmd$
242 | - ^reflect.StructField$
243 | # public libs
244 | - ^github.com/Shopify/sarama.Config$
245 | - ^github.com/Shopify/sarama.ProducerMessage$
246 | - ^github.com/mitchellh/mapstructure.DecoderConfig$
247 | - ^github.com/prometheus/client_golang/.+Opts$
248 | - ^github.com/spf13/cobra.Command$
249 | - ^github.com/spf13/cobra.CompletionOptions$
250 | - ^github.com/stretchr/testify/mock.Mock$
251 | - ^github.com/testcontainers/testcontainers-go.+Request$
252 | - ^github.com/testcontainers/testcontainers-go.FromDockerfile$
253 | - ^golang.org/x/tools/go/analysis.Analyzer$
254 | - ^google.golang.org/protobuf/.+Options$
255 | - ^gopkg.in/yaml.v3.Node$
256 |
257 | funcorder:
258 | # Checks if the exported methods of a structure are placed before the non-exported ones.
259 | # Default: true
260 | struct-method: false
261 |
262 | funlen:
263 | # Checks the number of lines in a function.
264 | # If lower than 0, disable the check.
265 | # Default: 60
266 | lines: 100
267 | # Checks the number of statements in a function.
268 | # If lower than 0, disable the check.
269 | # Default: 40
270 | statements: 50
271 |
272 | gochecksumtype:
273 | # Presence of `default` case in switch statements satisfies exhaustiveness, if all members are not listed.
274 | # Default: true
275 | default-signifies-exhaustive: false
276 |
277 | gocognit:
278 | # Minimal code complexity to report.
279 | # Default: 30 (but we recommend 10-20)
280 | min-complexity: 20
281 |
282 | gocritic:
283 | # Settings passed to gocritic.
284 | # The settings key is the name of a supported gocritic checker.
285 | # The list of supported checkers can be found at https://go-critic.com/overview.
286 | settings:
287 | captLocal:
288 | # Whether to restrict checker to params only.
289 | # Default: true
290 | paramsOnly: false
291 | underef:
292 | # Whether to skip (*x).method() calls where x is a pointer receiver.
293 | # Default: true
294 | skipRecvDeref: false
295 |
296 | govet:
297 | # Enable all analyzers.
298 | # Default: false
299 | enable-all: true
300 | # Disable analyzers by name.
301 | # Run `GL_DEBUG=govet golangci-lint run --enable=govet` to see default, all available analyzers, and enabled analyzers.
302 | # Default: []
303 | disable:
304 | - fieldalignment # too strict
305 | # Settings per analyzer.
306 | settings:
307 | shadow:
308 | # Whether to be strict about shadowing; can be noisy.
309 | # Default: false
310 | strict: true
311 |
312 | inamedparam:
313 | # Skips check for interface methods with only a single parameter.
314 | # Default: false
315 | skip-single-param: true
316 |
317 | mnd:
318 | # List of function patterns to exclude from analysis.
319 | # Values always ignored: `time.Date`,
320 | # `strconv.FormatInt`, `strconv.FormatUint`, `strconv.FormatFloat`,
321 | # `strconv.ParseInt`, `strconv.ParseUint`, `strconv.ParseFloat`.
322 | # Default: []
323 | ignored-functions:
324 | - args.Error
325 | - flag.Arg
326 | - flag.Duration.*
327 | - flag.Float.*
328 | - flag.Int.*
329 | - flag.Uint.*
330 | - os.Chmod
331 | - os.Mkdir.*
332 | - os.OpenFile
333 | - os.WriteFile
334 | - prometheus.ExponentialBuckets.*
335 | - prometheus.LinearBuckets
336 |
337 | nakedret:
338 | # Make an issue if func has more lines of code than this setting, and it has naked returns.
339 | # Default: 30
340 | max-func-lines: 0
341 |
342 | nolintlint:
343 | # Exclude following linters from requiring an explanation.
344 | # Default: []
345 | allow-no-explanation: [ funlen, gocognit, golines ]
346 | # Enable to require an explanation of nonzero length after each nolint directive.
347 | # Default: false
348 | require-explanation: true
349 | # Enable to require nolint directives to mention the specific linter being suppressed.
350 | # Default: false
351 | require-specific: true
352 |
353 | perfsprint:
354 | # Optimizes into strings concatenation.
355 | # Default: true
356 | strconcat: false
357 |
358 | reassign:
359 | # Patterns for global variable names that are checked for reassignment.
360 | # See https://github.com/curioswitch/go-reassign#usage
361 | # Default: ["EOF", "Err.*"]
362 | patterns:
363 | - ".*"
364 |
365 | rowserrcheck:
366 | # database/sql is always checked.
367 | # Default: []
368 | packages:
369 | - github.com/jmoiron/sqlx
370 |
371 | sloglint:
372 | # Enforce not using global loggers.
373 | # Values:
374 | # - "": disabled
375 | # - "all": report all global loggers
376 | # - "default": report only the default slog logger
377 | # https://github.com/go-simpler/sloglint?tab=readme-ov-file#no-global
378 | # Default: ""
379 | # no-global: all
380 | # Enforce using methods that accept a context.
381 | # Values:
382 | # - "": disabled
383 | # - "all": report all contextless calls
384 | # - "scope": report only if a context exists in the scope of the outermost function
385 | # https://github.com/go-simpler/sloglint?tab=readme-ov-file#context-only
386 | # Default: ""
387 | context: scope
388 |
389 | staticcheck:
390 | # SAxxxx checks in https://staticcheck.dev/docs/configuration/options/#checks
391 | # Example (to disable some checks): [ "all", "-SA1000", "-SA1001"]
392 | # Default: ["all", "-ST1000", "-ST1003", "-ST1016", "-ST1020", "-ST1021", "-ST1022"]
393 | checks:
394 | - all
395 | # Incorrect or missing package comment.
396 | # https://staticcheck.dev/docs/checks/#ST1000
397 | - -ST1000
398 | # Use consistent method receiver names.
399 | # https://staticcheck.dev/docs/checks/#ST1016
400 | - -ST1016
401 | # Omit embedded fields from selector expression.
402 | # https://staticcheck.dev/docs/checks/#QF1008
403 | - -QF1008
404 |
405 | usetesting:
406 | # Enable/disable `os.TempDir()` detections.
407 | # Default: false
408 | os-temp-dir: true
409 |
410 | exclusions:
411 | # Log a warning if an exclusion rule is unused.
412 | # Default: false
413 | warn-unused: false
414 | # Predefined exclusion rules.
415 | # Default: []
416 | presets:
417 | - std-error-handling
418 | - common-false-positives
419 | # Excluding configuration per-path, per-linter, per-text and per-source.
420 | rules:
421 | - path: '_test\.go'
422 | linters:
423 | - bodyclose
424 | - dupl
425 | - errcheck
426 | - funlen
427 | - goconst
428 | - gosec
429 | - noctx
430 | - wrapcheck
431 |
--------------------------------------------------------------------------------
/internal/visualizer/visualizer.go:
--------------------------------------------------------------------------------
1 | // Package visualizer provides functionality for generating DOT format visualization of dependency graphs.
2 | package visualizer
3 |
4 | import (
5 | "fmt"
6 | "sort"
7 | "strconv"
8 | "strings"
9 |
10 | "github.com/cvsouth/go-package-analyzer/internal/analyzer"
11 | )
12 |
13 | // Constants for text formatting and color handling.
14 | const (
15 | fillColorOpacity = 0.05 // Opacity for package fill colors
16 | textWrapWidth = 25 // Maximum width for text wrapping
17 | hexColorLength = 6 // Standard hex color length (RRGGBB)
18 | )
19 |
20 | // Visualizer generates DOT representations of package dependency graphs.
21 | type Visualizer struct{}
22 |
23 | // New creates a new visualizer.
24 | func New() *Visualizer {
25 | return &Visualizer{}
26 | }
27 |
28 | // GenerateDOTContent creates DOT format content for Graphviz.
29 | func (v *Visualizer) GenerateDOTContent(
30 | graph *analyzer.DependencyGraph,
31 | ) string {
32 | var dot strings.Builder
33 |
34 | v.writeDOTHeader(&dot)
35 |
36 | // Prepare data for node and edge generation
37 | packagePaths := v.getSortedPackagePaths(graph)
38 | circularDependencies := v.detectCircularDependencies(graph)
39 | dependencyPaths := v.initializeDependencyPaths(graph)
40 |
41 | // Generate nodes and edges
42 | nodeLines := v.generateNodes(graph, packagePaths, dependencyPaths)
43 | normalEdges, circularEdges := v.generateEdges(graph, packagePaths, circularDependencies, dependencyPaths)
44 |
45 | // Write output
46 | v.writeNodes(&dot, nodeLines)
47 | v.writeEdges(&dot, normalEdges, circularEdges)
48 | v.writeLayerConstraints(&dot, graph)
49 |
50 | dot.WriteString("}\n")
51 | return dot.String()
52 | }
53 |
54 | // writeDOTHeader writes the DOT file header and configuration.
55 | func (v *Visualizer) writeDOTHeader(dot *strings.Builder) {
56 | dot.WriteString("digraph dependencies {\n")
57 | dot.WriteString(" bgcolor=\"transparent\";\n")
58 | dot.WriteString(" rankdir=TB;\n")
59 | dot.WriteString(" splines=ortho;\n")
60 | dot.WriteString(" nodesep=1.0;\n") // Increased from 0.8
61 | dot.WriteString(" ranksep=1.5;\n") // Increased from 1.2
62 | dot.WriteString(" concentrate=true;\n")
63 | dot.WriteString(" start=42;\n") // Fixed seed for deterministic layout
64 | dot.WriteString(" ordering=out;\n") // Consistent edge ordering
65 | dot.WriteString(" overlap=false;\n") // Prevent node overlap
66 | dot.WriteString(" sep=\"+30,30\";\n") // Increased separation
67 | dot.WriteString(" esep=\"+15,15\";\n") // Increased edge separation
68 | dot.WriteString(" dpi=96;\n") // Fixed DPI for consistent sizing
69 | dot.WriteString(" margin=\"1,1\";\n") // Increased margin to prevent cropping
70 | dot.WriteString(" pad=\"1,1\";\n") // Increased padding around the graph
71 | dot.WriteString(" packmode=\"graph\";\n") // Better packing to prevent overflow
72 | dot.WriteString(
73 | " node [shape=box, style=filled, fontname=\"JetBrains Mono\", fontsize=11, penwidth=2, margin=\"0.4,0.3\", width=0, height=0, fixedsize=false];\n",
74 | )
75 | dot.WriteString(" edge [fontsize=10, labelangle=0, labeldistance=1.5];\n")
76 | dot.WriteString(" \n")
77 | }
78 |
79 | // getSortedPackagePaths returns a sorted slice of package paths for deterministic processing.
80 | func (v *Visualizer) getSortedPackagePaths(graph *analyzer.DependencyGraph) []string {
81 | var packagePaths []string
82 | for pkgPath := range graph.Packages {
83 | packagePaths = append(packagePaths, pkgPath)
84 | }
85 | sort.Strings(packagePaths)
86 | return packagePaths
87 | }
88 |
89 | // initializeDependencyPaths sets up the dependency path tracking with entry point.
90 | func (v *Visualizer) initializeDependencyPaths(graph *analyzer.DependencyGraph) map[string]int {
91 | dependencyPaths := make(map[string]int)
92 | // Entry point gets violet (first color)
93 | entryDepPath := v.getDependencyPath(graph.EntryPackage, graph.ModuleName)
94 | dependencyPaths[entryDepPath] = 0
95 | return dependencyPaths
96 | }
97 |
98 | // generateNodes creates all node definitions for the DOT output.
99 | func (v *Visualizer) generateNodes(
100 | graph *analyzer.DependencyGraph,
101 | packagePaths []string,
102 | dependencyPaths map[string]int,
103 | ) []string {
104 | var nodeLines []string
105 |
106 | for _, pkgPath := range packagePaths {
107 | pkg := graph.Packages[pkgPath]
108 | nodeID := v.sanitizeNodeID(pkgPath)
109 |
110 | // Determine border color based on dependency path
111 | borderColor := v.getPackageColors(pkgPath, graph.ModuleName, dependencyPaths)
112 |
113 | // Create fill color as 5% opacity version of border color
114 | fillColor := v.hexToRGBA(borderColor, fillColorOpacity)
115 |
116 | // Create simple label with package name, file count, and path
117 | relativePath := v.getRelativePath(pkgPath, graph.ModuleName)
118 | wrappedPath := v.wrapText(relativePath, textWrapWidth) // Wrap path at 25 characters
119 | wrappedName := v.wrapText(pkg.Name, textWrapWidth) // Wrap package name at 25 characters
120 | label := fmt.Sprintf("%s\\n%d files\\n%s",
121 | v.escapeHTML(wrappedName),
122 | pkg.FileCount,
123 | v.escapeHTML(wrappedPath))
124 |
125 | nodeLine := fmt.Sprintf(" %s [label=\"%s\", fillcolor=\"%s\", color=\"%s\", fontcolor=\"white\"];",
126 | nodeID, label, fillColor, borderColor)
127 | nodeLines = append(nodeLines, nodeLine)
128 | }
129 |
130 | return nodeLines
131 | }
132 |
133 | // generateEdges creates all edge definitions, separating normal and circular dependencies.
134 | func (v *Visualizer) generateEdges(
135 | graph *analyzer.DependencyGraph,
136 | packagePaths []string,
137 | circularDependencies map[string]map[string]bool,
138 | dependencyPaths map[string]int,
139 | ) ([]string, []string) {
140 | var normalEdgeLines []string
141 | var circularEdgeLines []string
142 |
143 | for _, pkgPath := range packagePaths {
144 | pkg := graph.Packages[pkgPath]
145 | fromID := v.sanitizeNodeID(pkgPath)
146 | sourceBorderColor := v.getPackageColors(pkgPath, graph.ModuleName, dependencyPaths)
147 |
148 | // Sort dependencies for consistent edge ordering
149 | deps := v.getSortedDependencies(pkg, graph)
150 |
151 | for _, dep := range deps {
152 | toID := v.sanitizeNodeID(dep)
153 |
154 | if circularDependencies[pkgPath][dep] {
155 | edgeLine := v.createCircularEdge(fromID, toID, circularDependencies, pkgPath, dep)
156 | circularEdgeLines = append(circularEdgeLines, edgeLine)
157 | } else {
158 | edgeLine := v.createNormalEdge(fromID, toID, sourceBorderColor)
159 | normalEdgeLines = append(normalEdgeLines, edgeLine)
160 | }
161 | }
162 | }
163 |
164 | // Sort both edge lists for completely deterministic output
165 | sort.Strings(normalEdgeLines)
166 | sort.Strings(circularEdgeLines)
167 |
168 | return normalEdgeLines, circularEdgeLines
169 | }
170 |
171 | // getSortedDependencies returns sorted dependencies for a package.
172 | func (v *Visualizer) getSortedDependencies(pkg *analyzer.PackageInfo, graph *analyzer.DependencyGraph) []string {
173 | var deps []string
174 | for _, dep := range pkg.Dependencies {
175 | if _, exists := graph.Packages[dep]; exists {
176 | deps = append(deps, dep)
177 | }
178 | }
179 | sort.Strings(deps)
180 | return deps
181 | }
182 |
183 | // createCircularEdge creates a circular dependency edge with appropriate styling.
184 | func (v *Visualizer) createCircularEdge(
185 | fromID, toID string,
186 | circularDependencies map[string]map[string]bool,
187 | pkgPath, dep string,
188 | ) string {
189 | edgeDirection := ""
190 | // Check if this is a bidirectional dependency (both directions exist)
191 | if circularDependencies[dep] != nil && circularDependencies[dep][pkgPath] {
192 | edgeDirection = ", dir=both"
193 | }
194 | return fmt.Sprintf(" %s -> %s [color=\"red\", penwidth=1.5%s];", fromID, toID, edgeDirection)
195 | }
196 |
197 | // createNormalEdge creates a normal dependency edge.
198 | func (v *Visualizer) createNormalEdge(fromID, toID, sourceBorderColor string) string {
199 | return fmt.Sprintf(" %s -> %s [color=\"%s\", penwidth=1.5];", fromID, toID, sourceBorderColor)
200 | }
201 |
202 | // writeNodes writes all node definitions to the DOT output.
203 | func (v *Visualizer) writeNodes(dot *strings.Builder, nodeLines []string) {
204 | for _, line := range nodeLines {
205 | dot.WriteString(line + "\n")
206 | }
207 | dot.WriteString(" \n")
208 | }
209 |
210 | // writeEdges writes all edge definitions to the DOT output.
211 | func (v *Visualizer) writeEdges(dot *strings.Builder, normalEdges, circularEdges []string) {
212 | // Output normal edges first
213 | for _, line := range normalEdges {
214 | dot.WriteString(line + "\n")
215 | }
216 |
217 | // Output circular edges last (so they appear "on top")
218 | for _, line := range circularEdges {
219 | dot.WriteString(line + "\n")
220 | }
221 | }
222 |
223 | // writeLayerConstraints writes layer constraints and entry point ranking to the DOT output.
224 | func (v *Visualizer) writeLayerConstraints(dot *strings.Builder, graph *analyzer.DependencyGraph) {
225 | dot.WriteString(" \n")
226 |
227 | // First, set the entry package to be at the top with highest rank
228 | if graph.EntryPackage != "" {
229 | entryNodeID := v.sanitizeNodeID(graph.EntryPackage)
230 | fmt.Fprintf(dot, " { rank=source; %s; }\n", entryNodeID)
231 | }
232 |
233 | // Generate rank constraints for each layer
234 | v.generateLayerConstraints(dot, graph)
235 | }
236 |
237 | // generateLayerConstraints generates rank constraints for graph layers.
238 | func (v *Visualizer) generateLayerConstraints(dot *strings.Builder, graph *analyzer.DependencyGraph) {
239 | // Generate rank constraints for each layer (layers are indexed from 0 at top)
240 | // In Graphviz, rank=min is at the top, rank=max is at the bottom
241 | for layerIndex, layer := range graph.Layers {
242 | if len(layer) > 1 {
243 | v.processMultiPackageLayer(dot, layer, graph.EntryPackage)
244 | } else if len(layer) == 1 && layer[0] != graph.EntryPackage {
245 | v.processSinglePackageLayer(dot, layer[0], layerIndex, len(graph.Layers), graph)
246 | }
247 | }
248 | }
249 |
250 | // processMultiPackageLayer handles layers with multiple packages.
251 | func (v *Visualizer) processMultiPackageLayer(dot *strings.Builder, layer []string, entryPackage string) {
252 | // Sort packages within the layer for deterministic output
253 | sortedLayer := make([]string, len(layer))
254 | copy(sortedLayer, layer)
255 | sort.Strings(sortedLayer)
256 |
257 | var layerNodes []string
258 | for _, pkgPath := range sortedLayer {
259 | // Skip the entry package since it's already set to rank=source
260 | if pkgPath != entryPackage {
261 | layerNodes = append(layerNodes, v.sanitizeNodeID(pkgPath))
262 | }
263 | }
264 |
265 | if len(layerNodes) > 0 {
266 | layerLine := fmt.Sprintf(" { rank=same; %s; }", strings.Join(layerNodes, "; "))
267 | dot.WriteString(layerLine + "\n")
268 | }
269 | }
270 |
271 | // processSinglePackageLayer handles layers with a single package.
272 | func (v *Visualizer) processSinglePackageLayer(
273 | dot *strings.Builder,
274 | pkgPath string,
275 | layerIndex, totalLayers int,
276 | graph *analyzer.DependencyGraph,
277 | ) {
278 | nodeID := v.sanitizeNodeID(pkgPath)
279 |
280 | // For leaf packages (bottom layer), use rank=sink
281 | if layerIndex == totalLayers-1 && v.isLeafPackage(pkgPath, graph) {
282 | fmt.Fprintf(dot, " { rank=sink; %s; }\n", nodeID)
283 | }
284 | }
285 |
286 | // isLeafPackage checks if a package has no internal dependencies.
287 | func (v *Visualizer) isLeafPackage(pkgPath string, graph *analyzer.DependencyGraph) bool {
288 | pkg := graph.Packages[pkgPath]
289 | for _, dep := range pkg.Dependencies {
290 | if _, exists := graph.Packages[dep]; exists {
291 | return false
292 | }
293 | }
294 | return true
295 | }
296 |
297 | // detectCircularDependencies identifies packages that have circular dependencies.
298 | func (v *Visualizer) detectCircularDependencies(graph *analyzer.DependencyGraph) map[string]map[string]bool {
299 | circularEdges := make(map[string]map[string]bool)
300 |
301 | // Find all cycles using DFS
302 | cycles := v.findAllCycles(graph)
303 |
304 | // Mark all edges that are part of any cycle as circular
305 | for _, cycle := range cycles {
306 | for i := range cycle {
307 | from := cycle[i]
308 | to := cycle[(i+1)%len(cycle)]
309 |
310 | if circularEdges[from] == nil {
311 | circularEdges[from] = make(map[string]bool)
312 | }
313 | circularEdges[from][to] = true
314 | }
315 | }
316 |
317 | return circularEdges
318 | }
319 |
320 | // findAllCycles finds all cycles in the dependency graph using DFS.
321 | func (v *Visualizer) findAllCycles(graph *analyzer.DependencyGraph) [][]string {
322 | var cycles [][]string
323 | visited := make(map[string]bool)
324 | recStack := make(map[string]bool)
325 |
326 | // Try to find cycles starting from each unvisited node
327 | for pkgPath := range graph.Packages {
328 | if !visited[pkgPath] {
329 | path := []string{}
330 | v.dfsForCycles(graph, pkgPath, visited, recStack, path, &cycles)
331 | }
332 | }
333 |
334 | return cycles
335 | }
336 |
337 | // dfsForCycles performs DFS to find cycles.
338 | func (v *Visualizer) dfsForCycles(
339 | graph *analyzer.DependencyGraph,
340 | node string,
341 | visited, recStack map[string]bool,
342 | path []string,
343 | cycles *[][]string,
344 | ) {
345 | visited[node] = true
346 | recStack[node] = true
347 | path = append(path, node)
348 |
349 | if pkg, exists := graph.Packages[node]; exists {
350 | v.processDependenciesForCycles(pkg, graph, visited, recStack, path, cycles)
351 | }
352 |
353 | recStack[node] = false
354 | }
355 |
356 | // processDependenciesForCycles processes package dependencies for cycle detection.
357 | func (v *Visualizer) processDependenciesForCycles(
358 | pkg *analyzer.PackageInfo,
359 | graph *analyzer.DependencyGraph,
360 | visited, recStack map[string]bool,
361 | path []string,
362 | cycles *[][]string,
363 | ) {
364 | for _, dep := range pkg.Dependencies {
365 | if _, depExists := graph.Packages[dep]; !depExists {
366 | continue
367 | }
368 |
369 | if !visited[dep] {
370 | v.dfsForCycles(graph, dep, visited, recStack, path, cycles)
371 | } else if recStack[dep] {
372 | v.extractCycleFromPath(dep, path, cycles)
373 | }
374 | }
375 | }
376 |
377 | // extractCycleFromPath extracts a cycle from the current path.
378 | func (v *Visualizer) extractCycleFromPath(dep string, path []string, cycles *[][]string) {
379 | cycleStart := -1
380 | for i, pathNode := range path {
381 | if pathNode == dep {
382 | cycleStart = i
383 | break
384 | }
385 | }
386 | if cycleStart != -1 {
387 | cycle := make([]string, len(path)-cycleStart)
388 | copy(cycle, path[cycleStart:])
389 | *cycles = append(*cycles, cycle)
390 | }
391 | }
392 |
393 | // sanitizeNodeID creates a valid DOT node identifier.
394 | func (v *Visualizer) sanitizeNodeID(pkgPath string) string {
395 | // Replace problematic characters with underscores
396 | nodeID := strings.ReplaceAll(pkgPath, "/", "_")
397 | nodeID = strings.ReplaceAll(nodeID, "\\", "_") // Handle Windows backslashes
398 | nodeID = strings.ReplaceAll(nodeID, ".", "_")
399 | nodeID = strings.ReplaceAll(nodeID, "-", "_")
400 |
401 | // Ensure it starts with a letter or underscore
402 | if len(nodeID) > 0 && (nodeID[0] < 'a' || nodeID[0] > 'z') &&
403 | (nodeID[0] < 'A' || nodeID[0] > 'Z') && nodeID[0] != '_' {
404 | nodeID = "pkg_" + nodeID
405 | }
406 |
407 | return nodeID
408 | }
409 |
410 | // getRelativePath returns the path relative to the module (without the module namespace).
411 | func (v *Visualizer) getRelativePath(pkgPath, moduleName string) string {
412 | // Remove module name prefix to get relative path
413 | relPath := strings.TrimPrefix(pkgPath, moduleName)
414 | relPath = strings.TrimPrefix(relPath, "/")
415 | relPath = strings.TrimPrefix(relPath, "\\") // Handle Windows backslashes
416 |
417 | // Normalize path separators to forward slashes for display
418 | relPath = strings.ReplaceAll(relPath, "\\", "/")
419 |
420 | // If it's the root package, show a meaningful name
421 | if relPath == "" {
422 | return "/"
423 | }
424 |
425 | return relPath
426 | }
427 |
428 | // hexToRGBA converts a hex color to RGBA format with specified opacity.
429 | func (v *Visualizer) hexToRGBA(hexColor string, opacity float64) string {
430 | // Remove # if present
431 | hex := strings.TrimPrefix(hexColor, "#")
432 |
433 | // Parse hex values
434 | var r, g, b int64
435 | if len(hex) == hexColorLength {
436 | r, _ = strconv.ParseInt(hex[0:2], 16, 0)
437 | g, _ = strconv.ParseInt(hex[2:4], 16, 0)
438 | b, _ = strconv.ParseInt(hex[4:6], 16, 0)
439 | } else {
440 | // Default to black if parsing fails
441 | r, g, b = 0, 0, 0
442 | }
443 |
444 | return fmt.Sprintf("rgba(%d,%d,%d,%.2f)", r, g, b, opacity)
445 | }
446 |
447 | // getPackageColors returns fill and border colors for a package using dependency path coloring.
448 | func (v *Visualizer) getPackageColors(
449 | pkgPath, moduleName string,
450 | dependencyPaths map[string]int,
451 | ) string {
452 | // Color series: border colors for dependency paths
453 | colorSeries := []string{
454 | "#6fdc8c", // Bright Pastel Mint
455 | "#6ab7ff", // Bright Sky Blue (pastel-leaning complement to Blue)
456 | "#c086e8", // Soft Bright Lavender (complement to Purple)
457 | "#ffe066", // Pastel Lemon (bright but soft Yellow)
458 | "#ff944d", // Warm Apricot (complement to Deep Orange)
459 | "#4dd0b0", // Pastel Aqua Teal
460 | "#ff80a5", // Bright Baby Pink (pastel tint of Pink)
461 | "#a98274", // Muted Rosewood (soft pastel Brown complement)
462 | "#a8e063", // Light Lime Pastel
463 | "#8c9eff", // Periwinkle Blue (softened Navy Blue)
464 | "#ff8aa1", // Coral Pink (lighter and pastel complement to Coral)
465 | "#b39ddb", // Light Lavender Indigo
466 | "#ff80bf", // Light Magenta Pink
467 | }
468 |
469 | // Get dependency path for this package
470 | depPath := v.getDependencyPath(pkgPath, moduleName)
471 |
472 | // Get color index for this dependency path
473 | colorIndex, exists := dependencyPaths[depPath]
474 | if !exists {
475 | // Assign next color in series
476 | colorIndex = len(dependencyPaths)
477 | dependencyPaths[depPath] = colorIndex
478 | }
479 |
480 | // Wrap around if we exceed the color series
481 | colorIndex %= len(colorSeries)
482 |
483 | borderColor := colorSeries[colorIndex]
484 |
485 | return borderColor
486 | }
487 |
488 | // getDependencyPath extracts the dependency path from a package path.
489 | func (v *Visualizer) getDependencyPath(pkgPath, moduleName string) string {
490 | // Get the relative path from module
491 | relPath := strings.TrimPrefix(pkgPath, moduleName)
492 | relPath = strings.TrimPrefix(relPath, "/")
493 | relPath = strings.TrimPrefix(relPath, "\\") // Handle Windows backslashes
494 |
495 | // Normalize path separators to forward slashes
496 | relPath = strings.ReplaceAll(relPath, "\\", "/")
497 |
498 | // If it's the root package
499 | if relPath == "" {
500 | return "root"
501 | }
502 |
503 | // Split path into components
504 | parts := strings.Split(relPath, "/")
505 | if len(parts) == 0 {
506 | return "root"
507 | }
508 |
509 | rootFolder := parts[0]
510 |
511 | // Special case: if root folder is "services", include the service name
512 | if rootFolder == "services" && len(parts) > 1 {
513 | return "services/" + parts[1]
514 | }
515 |
516 | // For other folders, just use the root folder
517 | return rootFolder
518 | }
519 |
520 | // escapeHTML escapes HTML characters for use in DOT HTML-like labels.
521 | func (v *Visualizer) escapeHTML(text string) string {
522 | // First escape backslashes for DOT format (but preserve \n sequences)
523 | // We need to be careful not to double-escape line breaks
524 | text = strings.ReplaceAll(text, "\\", "\\\\")
525 | // But restore line breaks that got double-escaped
526 | text = strings.ReplaceAll(text, "\\\\n", "\\n")
527 | text = strings.ReplaceAll(text, "&", "&")
528 | text = strings.ReplaceAll(text, "<", "<")
529 | text = strings.ReplaceAll(text, ">", ">")
530 | text = strings.ReplaceAll(text, "\"", """)
531 | text = strings.ReplaceAll(text, "'", "'")
532 | return text
533 | }
534 |
535 | // wrapText wraps text at a specified width, preferring to break at word boundaries.
536 | func (v *Visualizer) wrapText(text string, maxWidth int) string {
537 | if len(text) <= maxWidth {
538 | return text
539 | }
540 |
541 | // Split text while preserving separators
542 | var tokens []string
543 | currentToken := ""
544 |
545 | for i, char := range text {
546 | currentToken += string(char)
547 |
548 | // Check if this is a separator and we have content before it
549 | if (char == '/' || char == '-' || char == '_' || char == '.') && len(currentToken) > 1 {
550 | tokens = append(tokens, currentToken)
551 | currentToken = ""
552 | } else if i == len(text)-1 {
553 | // Last character
554 | tokens = append(tokens, currentToken)
555 | }
556 | }
557 |
558 | if len(tokens) <= 1 {
559 | // Single token or no separators - just break at maxWidth
560 | var result strings.Builder
561 | for i := 0; i < len(text); i += maxWidth {
562 | end := i + maxWidth
563 | if end > len(text) {
564 | end = len(text)
565 | }
566 | if i > 0 {
567 | result.WriteString("\\n")
568 | }
569 | result.WriteString(text[i:end])
570 | }
571 | return result.String()
572 | }
573 |
574 | // Multiple tokens - try to fit tokens on lines
575 | return v.wrapTokens(tokens, maxWidth)
576 | }
577 |
578 | // wrapTokens wraps multiple tokens onto lines within maxWidth.
579 | func (v *Visualizer) wrapTokens(tokens []string, maxWidth int) string {
580 | var result strings.Builder
581 | currentLine := ""
582 |
583 | for _, token := range tokens {
584 | testLine := currentLine + token
585 |
586 | if len(testLine) <= maxWidth {
587 | currentLine = testLine
588 | } else {
589 | v.processTokenThatDoesntFit(&result, ¤tLine, token, maxWidth)
590 | }
591 | }
592 |
593 | // Add the last line
594 | if currentLine != "" {
595 | if result.Len() > 0 {
596 | result.WriteString("\\n")
597 | }
598 | result.WriteString(currentLine)
599 | }
600 |
601 | return result.String()
602 | }
603 |
604 | // processTokenThatDoesntFit handles tokens that don't fit on the current line.
605 | func (v *Visualizer) processTokenThatDoesntFit(
606 | result *strings.Builder, currentLine *string, token string, maxWidth int,
607 | ) {
608 | if *currentLine != "" {
609 | if result.Len() > 0 {
610 | result.WriteString("\\n")
611 | }
612 | result.WriteString(*currentLine)
613 | *currentLine = token
614 | } else {
615 | // Even single token is too long, force break it
616 | v.forceBreakLongToken(result, token, maxWidth)
617 | *currentLine = ""
618 | }
619 | }
620 |
621 | // forceBreakLongToken breaks a token that is longer than maxWidth.
622 | func (v *Visualizer) forceBreakLongToken(result *strings.Builder, token string, maxWidth int) {
623 | if result.Len() > 0 {
624 | result.WriteString("\\n")
625 | }
626 | for i := 0; i < len(token); i += maxWidth {
627 | end := i + maxWidth
628 | if end > len(token) {
629 | end = len(token)
630 | }
631 | if i > 0 {
632 | result.WriteString("\\n")
633 | }
634 | result.WriteString(token[i:end])
635 | }
636 | }
637 |
--------------------------------------------------------------------------------
/internal/scanner/scanner.go:
--------------------------------------------------------------------------------
1 | // Package scanner provides functionality to scan the filesystem for Go projects.
2 | package scanner
3 |
4 | import (
5 | "os"
6 | "path/filepath"
7 | "runtime"
8 | "strings"
9 | )
10 |
11 | // Operating system constants.
12 | const (
13 | osWindows = "windows"
14 | osDarwin = "darwin"
15 | osLinux = "linux"
16 | )
17 |
18 | // Search depth constant for go.mod file recursive search.
19 | const maxGoModSearchDepth = 3
20 |
21 | // DirectoryNode represents a directory in the filesystem tree.
22 | type DirectoryNode struct {
23 | Name string `json:"name"`
24 | Path string `json:"path"`
25 | IsGoProject bool `json:"isGoProject"`
26 | Children []*DirectoryNode `json:"children,omitempty"`
27 | IsExpanded bool `json:"isExpanded,omitempty"`
28 | }
29 |
30 | // ScanResult represents the result of a directory scan operation.
31 | type ScanResult struct {
32 | Success bool `json:"success"`
33 | Tree *DirectoryNode `json:"tree,omitempty"`
34 | Error string `json:"error,omitempty"`
35 | }
36 |
37 | // DirectoryListResult represents the result of listing a specific directory.
38 | type DirectoryListResult struct {
39 | Success bool `json:"success"`
40 | Directories []*DirectoryNode `json:"directories"`
41 | Error string `json:"error,omitempty"`
42 | }
43 |
44 | // Scanner handles filesystem scanning operations.
45 | type Scanner struct{}
46 |
47 | // New creates a new Scanner instance.
48 | func New() *Scanner {
49 | return &Scanner{}
50 | }
51 |
52 | // GetFilesystemRoots returns just the filesystem roots (/ for Unix, drives for Windows).
53 | func (s *Scanner) GetFilesystemRoots() (*ScanResult, error) {
54 | rootPaths := getFilesystemRoots()
55 |
56 | if len(rootPaths) == 0 {
57 | return &ScanResult{
58 | Success: false,
59 | Error: "No filesystem roots found",
60 | }, nil
61 | }
62 |
63 | // Create virtual root node
64 | root := &DirectoryNode{
65 | Name: "Filesystem",
66 | Path: "",
67 | Children: make([]*DirectoryNode, 0),
68 | }
69 |
70 | // Add each filesystem root as a child
71 | for _, rootPath := range rootPaths {
72 | var actualPath string
73 | var displayName string
74 |
75 | // For Unix systems, rootPaths contains directory names that need to be converted to absolute paths
76 | if (runtime.GOOS == osLinux || runtime.GOOS == osDarwin) && !strings.HasPrefix(rootPath, "/") {
77 | actualPath = "/" + rootPath
78 | displayName = rootPath
79 | } else {
80 | actualPath = rootPath
81 | displayName = rootPath
82 | }
83 |
84 | // Check if root is accessible - only include if it can be accessed and read
85 | if isDirectoryAccessible(actualPath) {
86 | isGo := isGoProject(actualPath)
87 |
88 | // If it's not a Go project, check if it has subdirectories or Go files
89 | // Skip root directories that are not Go projects, have no subdirectories, AND have no Go files
90 | if !isGo && !hasSubdirectories(actualPath) && !hasGoFiles(actualPath) {
91 | continue // Skip this root directory - it's a dead end with no useful content
92 | }
93 |
94 | child := &DirectoryNode{
95 | Name: displayName,
96 | Path: actualPath,
97 | IsGoProject: isGo,
98 | Children: nil, // Will be loaded on demand
99 | }
100 | root.Children = append(root.Children, child)
101 | }
102 | // Note: We silently skip inaccessible roots and dead-end directories
103 | }
104 |
105 | return &ScanResult{
106 | Success: true,
107 | Tree: root,
108 | }, nil
109 | }
110 |
111 | // processDirectoryEntries processes directory entries and returns valid directory nodes.
112 | func processDirectoryEntries(dirPath string, entries []os.DirEntry) []*DirectoryNode {
113 | directories := make([]*DirectoryNode, 0)
114 |
115 | for _, entry := range entries {
116 | if !entry.IsDir() {
117 | continue
118 | }
119 |
120 | childPath := filepath.Join(dirPath, entry.Name())
121 | childName := entry.Name()
122 |
123 | // Skip excluded directories
124 | if shouldExcludeDirectory(childPath, childName) {
125 | continue
126 | }
127 |
128 | // Only include child directories that are accessible
129 | if isDirectoryAccessible(childPath) {
130 | if shouldIncludeDirectory(childPath) {
131 | child := &DirectoryNode{
132 | Name: childName,
133 | Path: childPath,
134 | IsGoProject: isGoProject(childPath),
135 | Children: nil, // Will be loaded on demand when expanded
136 | }
137 | directories = append(directories, child)
138 | }
139 | }
140 | // Note: We silently skip inaccessible subdirectories and dead-end directories
141 | }
142 |
143 | return directories
144 | }
145 |
146 | // shouldIncludeDirectory determines if a directory should be included in the results.
147 | func shouldIncludeDirectory(childPath string) bool {
148 | isGo := isGoProject(childPath)
149 |
150 | // If it's a Go project, always include
151 | if isGo {
152 | return true
153 | }
154 |
155 | // If it's not a Go project, check if it has subdirectories or Go files
156 | // Skip directories that are not Go projects, have no subdirectories, AND have no Go files (dead ends)
157 | return hasSubdirectories(childPath) || hasGoFiles(childPath)
158 | }
159 |
160 | // validateDirectoryPath validates that the directory path exists and is accessible.
161 | func validateDirectoryPath(dirPath string) *DirectoryListResult {
162 | // Check if directory should be excluded (but allow if it's a filesystem root)
163 | if !isFilesystemRoot(dirPath) && shouldExcludeDirectory(dirPath, filepath.Base(dirPath)) {
164 | return &DirectoryListResult{
165 | Success: false,
166 | Error: "Directory is excluded from scanning",
167 | }
168 | }
169 | return nil
170 | }
171 |
172 | // ListDirectory returns the subdirectories of a specific directory path.
173 | func (s *Scanner) ListDirectory(dirPath string) (*DirectoryListResult, error) {
174 | // Validate and clean the path
175 | dirPath = filepath.Clean(dirPath)
176 |
177 | // Check if directory is accessible upfront
178 | if !isDirectoryAccessible(dirPath) {
179 | return handleInaccessibleDirectory(dirPath)
180 | }
181 |
182 | // Validate directory path
183 | if result := validateDirectoryPath(dirPath); result != nil {
184 | return result, nil
185 | }
186 |
187 | // Read directory contents (we know this will work because we checked accessibility above)
188 | entries, err := os.ReadDir(dirPath)
189 | if err != nil {
190 | // This shouldn't happen since we verified accessibility, but handle it just in case
191 | return &DirectoryListResult{
192 | Success: false,
193 | Error: "Cannot read directory contents: " + err.Error(),
194 | }, err
195 | }
196 |
197 | // Process entries and get valid directories
198 | directories := processDirectoryEntries(dirPath, entries)
199 |
200 | return &DirectoryListResult{
201 | Success: true,
202 | Directories: directories,
203 | }, nil
204 | }
205 |
206 | // isGoProject checks if a directory is a Go project by looking for go.mod file.
207 | // A directory is considered a Go project if:
208 | // 1. It contains a go.mod file directly in the directory
209 | // OR
210 | // 2. It contains a .git folder AND somewhere inside its recursive structure it contains a go.mod file.
211 | func isGoProject(dirPath string) bool {
212 | // First check if go.mod file exists directly in this directory
213 | goModPath := filepath.Join(dirPath, "go.mod")
214 | if _, err := os.Stat(goModPath); err == nil {
215 | return true
216 | }
217 |
218 | // Check if we can access the directory - if not, assume it's not a Go project
219 | if _, err := os.Stat(dirPath); err != nil {
220 | return false
221 | }
222 |
223 | // If no direct go.mod, check if there's a .git folder
224 | gitPath := filepath.Join(dirPath, ".git")
225 | if info, err := os.Stat(gitPath); err == nil && info.IsDir() {
226 | // .git exists, now recursively search for go.mod in subdirectories
227 | return hasGoModFileRecursive(dirPath, 0, maxGoModSearchDepth)
228 | }
229 |
230 | // No go.mod directly and no .git folder
231 | return false
232 | }
233 |
234 | // hasGoModFileRecursive recursively searches for go.mod files up to maxDepth levels.
235 | func hasGoModFileRecursive(dirPath string, currentDepth, maxDepth int) bool {
236 | if currentDepth >= maxDepth {
237 | return false
238 | }
239 |
240 | entries, err := os.ReadDir(dirPath)
241 | if err != nil {
242 | // If we can't read the directory (e.g., permission denied), return false
243 | return false
244 | }
245 |
246 | for _, entry := range entries {
247 | if !entry.IsDir() {
248 | continue
249 | }
250 |
251 | // Skip excluded directories to avoid scanning deep into dependencies
252 | childPath := filepath.Join(dirPath, entry.Name())
253 | if shouldExcludeDirectory(childPath, entry.Name()) {
254 | continue
255 | }
256 |
257 | // Check for go.mod in this subdirectory first
258 | goModPath := filepath.Join(childPath, "go.mod")
259 | if _, statErr := os.Stat(goModPath); statErr == nil {
260 | return true
261 | }
262 |
263 | // Recursively check deeper if not found - but only if we can access the directory
264 | if _, statErr := os.Stat(childPath); statErr == nil {
265 | if hasGoModFileRecursive(childPath, currentDepth+1, maxDepth) {
266 | return true
267 | }
268 | }
269 | // Note: We silently skip inaccessible directories rather than failing
270 | }
271 |
272 | return false
273 | }
274 |
275 | // getFilesystemRoots returns the filesystem roots based on the operating system.
276 | func getFilesystemRoots() []string {
277 | switch runtime.GOOS {
278 | case osWindows:
279 | return getWindowsRoots()
280 | case osDarwin, osLinux:
281 | return getUnixRoots()
282 | default:
283 | return []string{"/"}
284 | }
285 | }
286 |
287 | // isFilesystemRoot checks if a path is a filesystem root.
288 | func isFilesystemRoot(path string) bool {
289 | switch runtime.GOOS {
290 | case osWindows:
291 | // For Windows, check if it's a drive root like C:\, D:\, etc.
292 | roots := getWindowsRoots()
293 | for _, root := range roots {
294 | if path == root {
295 | return true
296 | }
297 | }
298 | case osLinux, osDarwin:
299 | // For Unix systems, only "/" is a true filesystem root
300 | // The directories returned by getUnixRoots() are just top-level directories to display
301 | if path == "/" {
302 | return true
303 | }
304 | }
305 |
306 | return false
307 | }
308 |
309 | // getWindowsRoots returns all available Windows drive letters.
310 | func getWindowsRoots() []string {
311 | var roots []string
312 |
313 | // Check all possible drive letters
314 | for drive := 'A'; drive <= 'Z'; drive++ {
315 | drivePath := string(drive) + ":\\"
316 | if info, err := os.Stat(drivePath); err == nil && info.IsDir() {
317 | roots = append(roots, drivePath)
318 | }
319 | }
320 |
321 | // If no drives found (shouldn't happen), fallback to C:
322 | if len(roots) == 0 {
323 | roots = []string{"C:\\"}
324 | }
325 |
326 | return roots
327 | }
328 |
329 | // getUnixRoots returns the non-excluded directories within "/" for Linux and macOS.
330 | func getUnixRoots() []string {
331 | var roots []string
332 | rootPath := "/"
333 |
334 | // Try to read the root directory
335 | entries, err := os.ReadDir(rootPath)
336 | if err != nil {
337 | // If we can't read /, fallback to just "/"
338 | return []string{"/"}
339 | }
340 |
341 | // Process each entry in the root directory
342 | for _, entry := range entries {
343 | if !entry.IsDir() {
344 | continue
345 | }
346 |
347 | entryName := entry.Name()
348 | entryPath := filepath.Join(rootPath, entryName)
349 |
350 | // Skip excluded directories - this will exclude system dirs like proc, sys, etc.
351 | if shouldExcludeDirectory(entryPath, entryName) {
352 | continue
353 | }
354 |
355 | // Check if the directory is both accessible and readable
356 | if isDirectoryAccessible(entryPath) {
357 | isGo := isGoProject(entryPath)
358 |
359 | // If it's not a Go project, check if it has subdirectories or Go files
360 | // Skip root directories that are not Go projects, have no subdirectories, AND have no Go files
361 | if !isGo && !hasSubdirectories(entryPath) && !hasGoFiles(entryPath) {
362 | continue // Skip this root directory - it's a dead end with no useful content
363 | }
364 |
365 | // Return just the directory name without the "/" prefix
366 | roots = append(roots, entryName)
367 | }
368 | // Note: We silently skip inaccessible directories and dead-end directories
369 | }
370 |
371 | // If no accessible directories found, fallback to "/"
372 | if len(roots) == 0 {
373 | roots = []string{"/"}
374 | }
375 |
376 | return roots
377 | }
378 |
379 | // shouldExcludeDirectory checks if a directory should be excluded from scanning.
380 | func shouldExcludeDirectory(fullPath, dirName string) bool {
381 | // Check system directories
382 | if isSystemDirectory(dirName) {
383 | return true
384 | }
385 |
386 | // Check Go-specific directories
387 | if isGoSpecificDirectory(dirName) {
388 | return true
389 | }
390 |
391 | // Check Go module paths
392 | if isGoModulePath(fullPath) {
393 | return true
394 | }
395 |
396 | // Check development tools in specific locations
397 | if isDevToolInSpecificLocation(fullPath, dirName) {
398 | return true
399 | }
400 |
401 | // Check OS-specific exclusions
402 | return shouldExcludeOSDirectory(fullPath, dirName)
403 | }
404 |
405 | // isSystemDirectory checks if a directory is a system directory.
406 | func isSystemDirectory(dirName string) bool {
407 | systemDirs := []string{
408 | "node_modules", ".git", ".svn", ".hg", "vendor",
409 | "bin", "obj", "tmp", "temp", "cache", ".cache",
410 | "log", "logs", ".logs", "dist", "build", "target",
411 | ".idea", ".vscode", ".vs", "__pycache__", ".pytest_cache",
412 | ".DS_Store",
413 | }
414 |
415 | for _, sysDir := range systemDirs {
416 | if strings.EqualFold(dirName, sysDir) {
417 | return true
418 | }
419 | }
420 | return false
421 | }
422 |
423 | // isGoSpecificDirectory checks if a directory is a Go-specific directory.
424 | func isGoSpecificDirectory(dirName string) bool {
425 | goDirs := []string{
426 | "pkg", // Go module cache
427 | "mod", // Module cache subdirectory
428 | "sum", // Module checksum cache
429 | "modcache", // Alternative module cache location
430 | "gocache", // Build cache
431 | }
432 |
433 | for _, goDir := range goDirs {
434 | if strings.EqualFold(dirName, goDir) {
435 | return true
436 | }
437 | }
438 | return false
439 | }
440 |
441 | // isGoModulePath checks if a path is a Go module path.
442 | func isGoModulePath(fullPath string) bool {
443 | return strings.Contains(fullPath, "/pkg/mod/") || strings.Contains(fullPath, "\\pkg\\mod\\")
444 | }
445 |
446 | // isDevToolInSpecificLocation checks if a directory is a development tool in a specific location.
447 | func isDevToolInSpecificLocation(fullPath, dirName string) bool {
448 | devToolDirs := []string{
449 | "Code", "Code - Insiders", "Visual Studio Code", "code-server",
450 | "google-chrome", "chrome", "chromium", "firefox", "mozilla",
451 | "brave", "edge", "opera", "discord", "slack", "teams", "zoom",
452 | "docker", "docker-desktop", "virtualbox", "vmware", "parallels",
453 | "spotify", "steam", "android-studio", "intellij", "pycharm",
454 | "webstorm", "goland", "cursor", "Cursor",
455 | }
456 |
457 | for _, toolDir := range devToolDirs {
458 | if strings.EqualFold(dirName, toolDir) {
459 | // Check if this is a development tool directory in a specific location
460 | switch runtime.GOOS {
461 | case osWindows:
462 | return isWindowsDevToolLocation(fullPath)
463 | case osLinux, osDarwin:
464 | return isUnixDevToolLocation(fullPath)
465 | }
466 | }
467 | }
468 | return false
469 | }
470 |
471 | // isWindowsDevToolLocation checks if a path is a Windows development tool location.
472 | func isWindowsDevToolLocation(fullPath string) bool {
473 | return strings.Contains(fullPath, "\\Program Files\\") ||
474 | strings.Contains(fullPath, "\\Program Files (x86)\\") ||
475 | strings.Contains(fullPath, "\\AppData\\") ||
476 | strings.Contains(fullPath, "\\Local Settings\\")
477 | }
478 |
479 | // isUnixDevToolLocation checks if a path is a Unix development tool location.
480 | func isUnixDevToolLocation(fullPath string) bool {
481 | return strings.HasPrefix(fullPath, "/usr/") ||
482 | strings.HasPrefix(fullPath, "/opt/") ||
483 | strings.Contains(fullPath, "/.config/")
484 | }
485 |
486 | // shouldExcludeOSDirectory checks OS-specific directory exclusions.
487 | func shouldExcludeOSDirectory(fullPath, dirName string) bool {
488 | switch runtime.GOOS {
489 | case osLinux:
490 | return shouldExcludeLinuxDirectory(fullPath, dirName)
491 | case osWindows:
492 | return shouldExcludeWindowsDirectory(fullPath, dirName)
493 | case osDarwin:
494 | return shouldExcludeMacDirectory(fullPath, dirName)
495 | }
496 | return false
497 | }
498 |
499 | // isLinuxSystemDirectory checks if a directory is a Linux system directory.
500 | func isLinuxSystemDirectory(fullPath, dirName string) bool {
501 | linuxSystemDirs := []string{
502 | "proc", "sys", "dev", "run", "boot", "lost+found",
503 | "mnt", "media", "snap", "swapfile",
504 | }
505 |
506 | // Exclude system directories at root level
507 | if strings.HasPrefix(fullPath, "/") && strings.Count(fullPath, "/") == 1 {
508 | for _, sysDir := range linuxSystemDirs {
509 | if strings.EqualFold(dirName, sysDir) {
510 | return true
511 | }
512 | }
513 | }
514 | return false
515 | }
516 |
517 | // isBrowserOrAppDirectory checks if a directory is a browser or application data directory.
518 | func isBrowserOrAppDirectory(fullPath, dirName string) bool {
519 | browserAndAppDirs := []string{
520 | ".mozilla", ".firefox", ".chrome", ".chromium", ".google-chrome",
521 | ".config/google-chrome", ".config/chromium", ".config/mozilla",
522 | ".thunderbird", ".steam", ".discord", ".slack", ".zoom", ".docker",
523 | ".android", ".gradle", ".npm", ".yarn", ".pnpm", ".cargo", ".rustup",
524 | ".gem", ".rbenv", ".pyenv", ".virtualenvs", ".conda", ".miniconda", ".anaconda",
525 | }
526 |
527 | // Check if this is a browser/app directory (full path or name match)
528 | for _, appDir := range browserAndAppDirs {
529 | if strings.Contains(fullPath, appDir) || strings.EqualFold(dirName, filepath.Base(appDir)) {
530 | return true
531 | }
532 | }
533 | return false
534 | }
535 |
536 | // isConfigDevToolDirectory checks if a directory is a development tool within .config.
537 | func isConfigDevToolDirectory(fullPath, dirName string) bool {
538 | if !strings.Contains(fullPath, "/.config/") {
539 | return false
540 | }
541 |
542 | configDevToolDirs := []string{
543 | "Code", "Code - Insiders", "Visual Studio Code", "code-server",
544 | "google-chrome", "chromium", "mozilla", "discord", "slack", "zoom",
545 | "docker", "android-studio", "cursor", "Cursor", "intellij",
546 | "pycharm", "webstorm", "goland",
547 | }
548 |
549 | for _, toolDir := range configDevToolDirs {
550 | if strings.EqualFold(dirName, toolDir) {
551 | return true
552 | }
553 | }
554 | return false
555 | }
556 |
557 | // isAllowedHiddenDirectory checks if a hidden directory is allowed.
558 | func isAllowedHiddenDirectory(dirName string) bool {
559 | if !strings.HasPrefix(dirName, ".") {
560 | return true // Not a hidden directory
561 | }
562 |
563 | allowedHiddenDirs := []string{
564 | ".config", ".local", ".ssh", ".gnupg", ".gitconfig", ".bashrc",
565 | ".zshrc", ".profile", ".vimrc", ".tmux", ".aws", ".kube", ".terraform",
566 | }
567 |
568 | for _, allowedDir := range allowedHiddenDirs {
569 | if strings.HasPrefix(dirName, allowedDir) {
570 | return true
571 | }
572 | }
573 | return false
574 | }
575 |
576 | func shouldExcludeLinuxDirectory(fullPath, dirName string) bool {
577 | // Check system directories
578 | if isLinuxSystemDirectory(fullPath, dirName) {
579 | return true
580 | }
581 |
582 | // Check browser and application data directories
583 | if isBrowserOrAppDirectory(fullPath, dirName) {
584 | return true
585 | }
586 |
587 | // Check development tools within .config
588 | if isConfigDevToolDirectory(fullPath, dirName) {
589 | return true
590 | }
591 |
592 | // Check hidden directories (exclude most, allow some)
593 | if !isAllowedHiddenDirectory(dirName) {
594 | return true
595 | }
596 |
597 | return false
598 | }
599 |
600 | // shouldExcludeWindowsDirectory checks for Windows-specific directory exclusions.
601 | func shouldExcludeWindowsDirectory(fullPath, dirName string) bool {
602 | windowsSystemDirs := []string{
603 | "Windows", "Program Files", "Program Files (x86)", "ProgramData",
604 | "System Volume Information", "$Recycle.Bin", "Recovery",
605 | "hiberfil.sys", "pagefile.sys", "swapfile.sys",
606 | "AppData", "Application Data", "Local Settings",
607 | }
608 |
609 | for _, sysDir := range windowsSystemDirs {
610 | if strings.EqualFold(dirName, sysDir) {
611 | return true
612 | }
613 | }
614 |
615 | // Exclude drive root system folders
616 | if len(fullPath) >= 3 && fullPath[1] == ':' && fullPath[2] == '\\' {
617 | if strings.Count(fullPath, "\\") == 1 {
618 | for _, sysDir := range windowsSystemDirs {
619 | if strings.EqualFold(dirName, sysDir) {
620 | return true
621 | }
622 | }
623 | }
624 | }
625 |
626 | return false
627 | }
628 |
629 | // shouldExcludeMacDirectory checks for macOS-specific directory exclusions.
630 | func shouldExcludeMacDirectory(fullPath, dirName string) bool {
631 | macSystemDirs := []string{
632 | "System", "Library", "Applications", "Volumes", "cores",
633 | "dev", "etc", "tmp", "usr", "bin", "sbin", "var",
634 | "private", "Network", ".DocumentRevisions-V100",
635 | ".Spotlight-V100", ".Trashes", ".fseventsd",
636 | }
637 |
638 | // Exclude system directories at root level
639 | if strings.HasPrefix(fullPath, "/") && strings.Count(fullPath, "/") == 1 {
640 | for _, sysDir := range macSystemDirs {
641 | if strings.EqualFold(dirName, sysDir) {
642 | return true
643 | }
644 | }
645 | }
646 |
647 | // Exclude hidden directories (but allow some common ones)
648 | if strings.HasPrefix(dirName, ".") &&
649 | !strings.HasPrefix(dirName, ".config") &&
650 | !strings.HasPrefix(dirName, ".local") {
651 | return true
652 | }
653 |
654 | return false
655 | }
656 |
657 | // ScanForGoProjects is a legacy method for backwards compatibility - now just returns filesystem roots.
658 | func (s *Scanner) ScanForGoProjects() (*ScanResult, error) {
659 | return s.GetFilesystemRoots()
660 | }
661 |
662 | // isDirectoryAccessible checks if a directory exists, is accessible, and can be read.
663 | func isDirectoryAccessible(dirPath string) bool {
664 | // First check if directory exists and is a directory
665 | info, err := os.Stat(dirPath)
666 | if err != nil {
667 | return false
668 | }
669 | if !info.IsDir() {
670 | return false
671 | }
672 |
673 | // Try to read the directory to ensure we have read permissions
674 | _, err = os.ReadDir(dirPath)
675 | return err == nil
676 | }
677 |
678 | // hasSubdirectories checks if a directory contains any subdirectories.
679 | func hasSubdirectories(dirPath string) bool {
680 | entries, err := os.ReadDir(dirPath)
681 | if err != nil {
682 | return false // If we can't read it, assume no subdirectories
683 | }
684 |
685 | for _, entry := range entries {
686 | if entry.IsDir() {
687 | return true
688 | }
689 | }
690 | return false
691 | }
692 |
693 | // hasGoFiles checks if a directory contains any .go files.
694 | func hasGoFiles(dirPath string) bool {
695 | entries, err := os.ReadDir(dirPath)
696 | if err != nil {
697 | return false // If we can't read it, assume no Go files
698 | }
699 |
700 | for _, entry := range entries {
701 | if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".go") {
702 | return true
703 | }
704 | }
705 | return false
706 | }
707 |
708 | // handleInaccessibleDirectory handles error cases when a directory is not accessible.
709 | func handleInaccessibleDirectory(dirPath string) (*DirectoryListResult, error) {
710 | // Check specific error types for better error messages
711 | if info, err := os.Stat(dirPath); err != nil {
712 | if os.IsNotExist(err) {
713 | return &DirectoryListResult{
714 | Success: false,
715 | Error: "Directory does not exist",
716 | }, nil
717 | } else if os.IsPermission(err) {
718 | return &DirectoryListResult{
719 | Success: false,
720 | Error: "Permission denied - cannot access directory",
721 | }, nil
722 | }
723 | return &DirectoryListResult{
724 | Success: false,
725 | Error: "Directory not accessible: " + err.Error(),
726 | }, nil
727 | } else if !info.IsDir() {
728 | return &DirectoryListResult{
729 | Success: false,
730 | Error: "Path is not a directory",
731 | }, nil
732 | }
733 | // Directory exists but can't be read
734 | return &DirectoryListResult{
735 | Success: false,
736 | Error: "Permission denied - cannot read directory contents",
737 | }, nil
738 | }
739 |
--------------------------------------------------------------------------------
/internal/scanner/scanner_test.go:
--------------------------------------------------------------------------------
1 | package scanner_test
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "os"
7 | "path/filepath"
8 | "runtime"
9 | "strings"
10 | "testing"
11 | "time"
12 |
13 | "github.com/stretchr/testify/assert"
14 | "github.com/stretchr/testify/require"
15 |
16 | "github.com/cvsouth/go-package-analyzer/internal/scanner"
17 | )
18 |
19 | // Test helper functions and utilities
20 |
21 | // createTempDirWithStructure creates a complex directory structure for testing.
22 | func createTempDirWithStructure(t *testing.T) string {
23 | t.Helper()
24 |
25 | tempDir := t.TempDir()
26 |
27 | // Create various subdirectories
28 | dirs := []string{
29 | "go_project_with_mod",
30 | "go_project_with_git",
31 | "go_project_with_git/subdir",
32 | "regular_dir",
33 | "regular_dir/subdir",
34 | "empty_dir",
35 | "node_modules", // Should be excluded
36 | ".git", // Should be excluded
37 | "vendor", // Should be excluded
38 | }
39 |
40 | for _, dir := range dirs {
41 | err := os.MkdirAll(filepath.Join(tempDir, dir), 0755)
42 | require.NoError(t, err)
43 | }
44 |
45 | // Create go.mod in go project
46 | goModContent := "module test\n\ngo 1.19\n"
47 | err := os.WriteFile(filepath.Join(tempDir, "go_project_with_mod", "go.mod"), []byte(goModContent), 0644)
48 | require.NoError(t, err)
49 |
50 | // Create .git folder and go.mod in subdirectory for git project
51 | gitDir := filepath.Join(tempDir, "go_project_with_git", ".git")
52 | err = os.MkdirAll(gitDir, 0755)
53 | require.NoError(t, err)
54 |
55 | err = os.WriteFile(filepath.Join(tempDir, "go_project_with_git", "subdir", "go.mod"), []byte(goModContent), 0644)
56 | require.NoError(t, err)
57 |
58 | // Create some Go files
59 | goFileContent := "package main\n\nfunc main() {}\n"
60 | err = os.WriteFile(filepath.Join(tempDir, "regular_dir", "main.go"), []byte(goFileContent), 0644)
61 | require.NoError(t, err)
62 |
63 | // Create regular files
64 | err = os.WriteFile(filepath.Join(tempDir, "regular_dir", "README.md"), []byte("# Test"), 0644)
65 | require.NoError(t, err)
66 |
67 | return tempDir
68 | }
69 |
70 | // createRestrictedDir creates a directory with restricted permissions (Unix only).
71 | func createRestrictedDir(t *testing.T) string {
72 | t.Helper()
73 |
74 | if runtime.GOOS == "windows" {
75 | t.Skip("Permission tests not applicable on Windows")
76 | }
77 |
78 | tempDir := t.TempDir()
79 |
80 | // Create a subdirectory and restrict its permissions
81 | restrictedDir := filepath.Join(tempDir, "restricted")
82 | err := os.MkdirAll(restrictedDir, 0755)
83 | require.NoError(t, err)
84 |
85 | // Remove read permissions
86 | err = os.Chmod(restrictedDir, 0000)
87 | require.NoError(t, err)
88 |
89 | return tempDir
90 | }
91 |
92 | // cleanupTempDir removes temporary directory and handles permission restoration.
93 | func cleanupTempDir(t *testing.T, tempDir string) {
94 | t.Helper()
95 |
96 | // Restore permissions before cleanup (Unix only)
97 | if runtime.GOOS != "windows" {
98 | filepath.Walk(tempDir, func(path string, info os.FileInfo, err error) error {
99 | if err == nil && info.IsDir() {
100 | os.Chmod(path, 0755)
101 | }
102 | return nil
103 | })
104 | }
105 |
106 | err := os.RemoveAll(tempDir)
107 | if err != nil {
108 | t.Logf("Failed to cleanup temp dir %s: %v", tempDir, err)
109 | }
110 | }
111 |
112 | // Test Data Structures
113 |
114 | func TestDirectoryNode_JSONMarshaling(t *testing.T) {
115 | tests := []struct {
116 | name string
117 | node *scanner.DirectoryNode
118 | }{
119 | {
120 | name: "empty node",
121 | node: &scanner.DirectoryNode{},
122 | },
123 | {
124 | name: "simple node",
125 | node: &scanner.DirectoryNode{
126 | Name: "test",
127 | Path: "/test",
128 | IsGoProject: true,
129 | IsExpanded: true,
130 | },
131 | },
132 | {
133 | name: "node with children",
134 | node: &scanner.DirectoryNode{
135 | Name: "parent",
136 | Path: "/parent",
137 | IsGoProject: false,
138 | Children: []*scanner.DirectoryNode{
139 | {
140 | Name: "child1",
141 | Path: "/parent/child1",
142 | IsGoProject: true,
143 | },
144 | {
145 | Name: "child2",
146 | Path: "/parent/child2",
147 | IsGoProject: false,
148 | },
149 | },
150 | },
151 | },
152 | {
153 | name: "node with unicode characters",
154 | node: &scanner.DirectoryNode{
155 | Name: "测试目录",
156 | Path: "/测试目录",
157 | IsGoProject: true,
158 | },
159 | },
160 | }
161 |
162 | for _, tt := range tests {
163 | t.Run(tt.name, func(t *testing.T) {
164 | // Test marshaling
165 | data, err := json.Marshal(tt.node)
166 | require.NoError(t, err)
167 | assert.NotEmpty(t, data)
168 |
169 | // Test unmarshaling
170 | var unmarshaled scanner.DirectoryNode
171 | err = json.Unmarshal(data, &unmarshaled)
172 | require.NoError(t, err)
173 |
174 | // Verify fields
175 | assert.Equal(t, tt.node.Name, unmarshaled.Name)
176 | assert.Equal(t, tt.node.Path, unmarshaled.Path)
177 | assert.Equal(t, tt.node.IsGoProject, unmarshaled.IsGoProject)
178 | assert.Equal(t, tt.node.IsExpanded, unmarshaled.IsExpanded)
179 | assert.Len(t, unmarshaled.Children, len(tt.node.Children))
180 | })
181 | }
182 | }
183 |
184 | func TestScanResult_JSONMarshaling(t *testing.T) {
185 | tests := []struct {
186 | name string
187 | result *scanner.ScanResult
188 | }{
189 | {
190 | name: "success result",
191 | result: &scanner.ScanResult{
192 | Success: true,
193 | Tree: &scanner.DirectoryNode{
194 | Name: "root",
195 | Path: "/",
196 | },
197 | },
198 | },
199 | {
200 | name: "error result",
201 | result: &scanner.ScanResult{
202 | Success: false,
203 | Error: "test error",
204 | },
205 | },
206 | {
207 | name: "empty result",
208 | result: &scanner.ScanResult{},
209 | },
210 | }
211 |
212 | for _, tt := range tests {
213 | t.Run(tt.name, func(t *testing.T) {
214 | data, err := json.Marshal(tt.result)
215 | require.NoError(t, err)
216 |
217 | var unmarshaled scanner.ScanResult
218 | err = json.Unmarshal(data, &unmarshaled)
219 | require.NoError(t, err)
220 |
221 | assert.Equal(t, tt.result.Success, unmarshaled.Success)
222 | assert.Equal(t, tt.result.Error, unmarshaled.Error)
223 | })
224 | }
225 | }
226 |
227 | func TestDirectoryListResult_JSONMarshaling(t *testing.T) {
228 | tests := []struct {
229 | name string
230 | result *scanner.DirectoryListResult
231 | }{
232 | {
233 | name: "success result with directories",
234 | result: &scanner.DirectoryListResult{
235 | Success: true,
236 | Directories: []*scanner.DirectoryNode{
237 | {Name: "dir1", Path: "/dir1"},
238 | {Name: "dir2", Path: "/dir2"},
239 | },
240 | },
241 | },
242 | {
243 | name: "error result",
244 | result: &scanner.DirectoryListResult{
245 | Success: false,
246 | Error: "test error",
247 | },
248 | },
249 | {
250 | name: "empty result",
251 | result: &scanner.DirectoryListResult{
252 | Success: true,
253 | Directories: []*scanner.DirectoryNode{},
254 | },
255 | },
256 | }
257 |
258 | for _, tt := range tests {
259 | t.Run(tt.name, func(t *testing.T) {
260 | data, err := json.Marshal(tt.result)
261 | require.NoError(t, err)
262 |
263 | var unmarshaled scanner.DirectoryListResult
264 | err = json.Unmarshal(data, &unmarshaled)
265 | require.NoError(t, err)
266 |
267 | assert.Equal(t, tt.result.Success, unmarshaled.Success)
268 | assert.Equal(t, tt.result.Error, unmarshaled.Error)
269 | assert.Len(t, unmarshaled.Directories, len(tt.result.Directories))
270 | })
271 | }
272 | }
273 |
274 | // Test Constructor
275 |
276 | func TestNew(t *testing.T) {
277 | scanner1 := scanner.New()
278 | scanner2 := scanner.New()
279 |
280 | // Verify non-nil return
281 | assert.NotNil(t, scanner1)
282 | assert.NotNil(t, scanner2)
283 |
284 | // Verify they are different instances (different pointers)
285 | assert.NotSame(t, scanner1, scanner2, "Should create different instances")
286 | }
287 |
288 | // Test GetFilesystemRoots
289 |
290 | func TestScanner_GetFilesystemRoots(t *testing.T) {
291 | s := scanner.New()
292 |
293 | result, err := s.GetFilesystemRoots()
294 | require.NoError(t, err)
295 | require.NotNil(t, result)
296 |
297 | // Should always succeed
298 | assert.True(t, result.Success)
299 | assert.Empty(t, result.Error)
300 | assert.NotNil(t, result.Tree)
301 |
302 | // Check root node properties
303 | assert.Equal(t, "Filesystem", result.Tree.Name)
304 | assert.Empty(t, result.Tree.Path)
305 | assert.NotNil(t, result.Tree.Children)
306 |
307 | // Should have at least one child (filesystem root)
308 | assert.NotEmpty(t, result.Tree.Children)
309 |
310 | // Verify OS-specific behavior
311 | switch runtime.GOOS {
312 | case "windows":
313 | // Windows should have drive letters
314 | for _, child := range result.Tree.Children {
315 | assert.True(t, strings.HasSuffix(child.Path, ":\\") || strings.HasSuffix(child.Path, ":/"))
316 | }
317 | case "linux", "darwin":
318 | // Unix systems should have root directory or subdirectories
319 | foundRoot := false
320 | for _, child := range result.Tree.Children {
321 | if strings.HasPrefix(child.Path, "/") {
322 | foundRoot = true
323 | break
324 | }
325 | }
326 | assert.True(t, foundRoot, "Should find at least one Unix-style path")
327 | }
328 | }
329 |
330 | // Test ListDirectory
331 |
332 | func TestScanner_ListDirectory_ValidDirectories(t *testing.T) {
333 | s := scanner.New()
334 | tempDir := createTempDirWithStructure(t)
335 | defer cleanupTempDir(t, tempDir)
336 |
337 | result, err := s.ListDirectory(tempDir)
338 | require.NoError(t, err)
339 | require.NotNil(t, result)
340 |
341 | assert.True(t, result.Success)
342 | assert.Empty(t, result.Error)
343 | assert.NotNil(t, result.Directories)
344 |
345 | // Find expected directories (excluding system directories)
346 | dirNames := make(map[string]bool)
347 | for _, dir := range result.Directories {
348 | dirNames[dir.Name] = true
349 | }
350 |
351 | // Should include Go projects
352 | assert.True(t, dirNames["go_project_with_mod"], "Should include directory with go.mod")
353 | assert.True(t, dirNames["go_project_with_git"], "Should include directory with .git and nested go.mod")
354 |
355 | // Should include directories with Go files
356 | assert.True(t, dirNames["regular_dir"], "Should include directory with .go files")
357 |
358 | // Should exclude system directories
359 | assert.False(t, dirNames["node_modules"], "Should exclude node_modules")
360 | assert.False(t, dirNames[".git"], "Should exclude .git")
361 | assert.False(t, dirNames["vendor"], "Should exclude vendor")
362 |
363 | // Should exclude empty directories (no Go content)
364 | assert.False(t, dirNames["empty_dir"], "Should exclude empty directories")
365 | }
366 |
367 | // setupGoProjectTestCase creates a test directory with specific Go project characteristics.
368 | func setupGoProjectTestCase(t *testing.T, baseDir, testName string, hasGoMod, hasGitFolder bool) string {
369 | t.Helper()
370 |
371 | testDir := filepath.Join(baseDir, "test_"+strings.ReplaceAll(testName, " ", "_"))
372 | err := os.MkdirAll(testDir, 0755)
373 | require.NoError(t, err)
374 |
375 | if hasGoMod {
376 | goModContent := "module test\n\ngo 1.19\n"
377 | err = os.WriteFile(filepath.Join(testDir, "go.mod"), []byte(goModContent), 0644)
378 | require.NoError(t, err)
379 | }
380 |
381 | if hasGitFolder {
382 | setupGitFolder(t, testDir, !hasGoMod)
383 | }
384 |
385 | return testDir
386 | }
387 |
388 | // setupGitFolder creates a .git directory and optionally a nested go.mod.
389 | func setupGitFolder(t *testing.T, testDir string, createNestedGoMod bool) {
390 | t.Helper()
391 |
392 | gitDir := filepath.Join(testDir, ".git")
393 | err := os.MkdirAll(gitDir, 0755)
394 | require.NoError(t, err)
395 |
396 | if createNestedGoMod {
397 | subDir := filepath.Join(testDir, "subdir")
398 | err = os.MkdirAll(subDir, 0755)
399 | require.NoError(t, err)
400 |
401 | goModContent := "module test\n\ngo 1.19\n"
402 | err = os.WriteFile(filepath.Join(subDir, "go.mod"), []byte(goModContent), 0644)
403 | require.NoError(t, err)
404 | }
405 | }
406 |
407 | // validateGoProjectDetection validates that the directory was correctly detected as a Go project.
408 | func validateGoProjectDetection(t *testing.T, s *scanner.Scanner, baseDir, testDir string, expectedGo bool) {
409 | t.Helper()
410 |
411 | result, err := s.ListDirectory(baseDir)
412 | require.NoError(t, err)
413 | require.True(t, result.Success)
414 |
415 | // Find our test directory
416 | var foundDir *scanner.DirectoryNode
417 | for _, dir := range result.Directories {
418 | if dir.Path == testDir {
419 | foundDir = dir
420 | break
421 | }
422 | }
423 |
424 | if expectedGo {
425 | require.NotNil(t, foundDir, "Go project directory should be included")
426 | assert.True(t, foundDir.IsGoProject, "Directory should be detected as Go project")
427 | } else if foundDir != nil {
428 | // Non-Go directories without content may be excluded
429 | assert.False(t, foundDir.IsGoProject, "Directory should not be detected as Go project")
430 | }
431 | }
432 |
433 | func TestScanner_ListDirectory_GoProjectDetection(t *testing.T) {
434 | s := scanner.New()
435 |
436 | // Create a base test directory in the current working directory to avoid system exclusions
437 | baseDir := t.TempDir()
438 | defer cleanupTempDir(t, baseDir)
439 |
440 | tests := []struct {
441 | name string
442 | hasGoMod bool
443 | hasGitFolder bool
444 | expectedGo bool
445 | }{
446 | {
447 | name: "directory with go.mod",
448 | hasGoMod: true,
449 | expectedGo: true,
450 | },
451 | {
452 | name: "directory with .git and nested go.mod",
453 | hasGitFolder: true,
454 | expectedGo: true,
455 | },
456 | {
457 | name: "directory without Go project markers",
458 | expectedGo: false,
459 | },
460 | }
461 |
462 | for _, tt := range tests {
463 | t.Run(tt.name, func(t *testing.T) {
464 | testDir := setupGoProjectTestCase(t, baseDir, tt.name, tt.hasGoMod, tt.hasGitFolder)
465 | validateGoProjectDetection(t, s, baseDir, testDir, tt.expectedGo)
466 | })
467 | }
468 | }
469 |
470 | func TestScanner_ListDirectory_ErrorCases(t *testing.T) {
471 | s := scanner.New()
472 |
473 | tests := []struct {
474 | name string
475 | dirPath string
476 | expectedError string
477 | }{
478 | {
479 | name: "non-existent directory",
480 | dirPath: "/this/directory/does/not/exist",
481 | expectedError: "Directory does not exist",
482 | },
483 | {
484 | name: "empty path",
485 | dirPath: "",
486 | expectedError: "", // Clean path might resolve to current directory
487 | },
488 | }
489 |
490 | for _, tt := range tests {
491 | t.Run(tt.name, func(t *testing.T) {
492 | result, err := s.ListDirectory(tt.dirPath)
493 | require.NoError(t, err) // Method shouldn't return error, but result should indicate failure
494 | require.NotNil(t, result)
495 |
496 | if tt.expectedError != "" {
497 | assert.False(t, result.Success)
498 | assert.Contains(t, result.Error, tt.expectedError)
499 | }
500 | })
501 | }
502 | }
503 |
504 | func TestScanner_ListDirectory_FileInsteadOfDirectory(t *testing.T) {
505 | s := scanner.New()
506 |
507 | // Create a temp file
508 | tempFile, err := os.CreateTemp(t.TempDir(), "scanner_test_file_*")
509 | require.NoError(t, err)
510 | defer os.Remove(tempFile.Name())
511 | tempFile.Close()
512 |
513 | result, err := s.ListDirectory(tempFile.Name())
514 | require.NoError(t, err)
515 | require.NotNil(t, result)
516 |
517 | assert.False(t, result.Success)
518 | assert.Contains(t, result.Error, "not a directory")
519 | }
520 |
521 | func TestScanner_ListDirectory_PermissionDenied(t *testing.T) {
522 | if runtime.GOOS == "windows" {
523 | t.Skip("Permission tests not reliable on Windows")
524 | }
525 |
526 | s := scanner.New()
527 | tempDir := createRestrictedDir(t)
528 | defer cleanupTempDir(t, tempDir)
529 |
530 | restrictedPath := filepath.Join(tempDir, "restricted")
531 | result, err := s.ListDirectory(restrictedPath)
532 | require.NoError(t, err)
533 | require.NotNil(t, result)
534 |
535 | assert.False(t, result.Success)
536 | assert.Contains(t, strings.ToLower(result.Error), "permission")
537 | }
538 |
539 | func TestScanner_ListDirectory_PathTraversalSafety(t *testing.T) {
540 | s := scanner.New()
541 |
542 | // Test various path traversal attempts
543 | paths := []string{
544 | "../../../",
545 | "..\\..\\..\\",
546 | "/../../etc",
547 | "./../..",
548 | }
549 |
550 | for _, path := range paths {
551 | t.Run("path_"+path, func(t *testing.T) {
552 | result, err := s.ListDirectory(path)
553 | require.NoError(t, err)
554 |
555 | // Should either succeed with cleaned path or fail gracefully
556 | // The important thing is it doesn't panic or cause security issues
557 | assert.NotNil(t, result)
558 | })
559 | }
560 | }
561 |
562 | func TestScanner_ListDirectory_UnicodeAndSpecialChars(t *testing.T) {
563 | s := scanner.New()
564 |
565 | // Create temp directory with unicode name
566 | tempDir := t.TempDir()
567 | defer cleanupTempDir(t, tempDir)
568 |
569 | // Create subdirectory with unicode name
570 | unicodeSubdir := filepath.Join(tempDir, "子目录")
571 | err := os.MkdirAll(unicodeSubdir, 0755)
572 | require.NoError(t, err)
573 |
574 | // Create go.mod to make it a Go project
575 | goModContent := "module test\n\ngo 1.19\n"
576 | err = os.WriteFile(filepath.Join(unicodeSubdir, "go.mod"), []byte(goModContent), 0644)
577 | require.NoError(t, err)
578 |
579 | result, err := s.ListDirectory(tempDir)
580 | require.NoError(t, err)
581 | require.NotNil(t, result)
582 |
583 | assert.True(t, result.Success)
584 | assert.NotEmpty(t, result.Directories)
585 |
586 | // Find unicode directory
587 | var foundUnicodeDir *scanner.DirectoryNode
588 | for _, dir := range result.Directories {
589 | if dir.Name == "子目录" {
590 | foundUnicodeDir = dir
591 | break
592 | }
593 | }
594 |
595 | require.NotNil(t, foundUnicodeDir, "Should find unicode directory")
596 | assert.True(t, foundUnicodeDir.IsGoProject, "Unicode directory should be detected as Go project")
597 | }
598 |
599 | // Test ScanForGoProjects (legacy method)
600 |
601 | func TestScanner_ScanForGoProjects(t *testing.T) {
602 | s := scanner.New()
603 |
604 | result1, err1 := s.ScanForGoProjects()
605 | require.NoError(t, err1)
606 |
607 | result2, err2 := s.GetFilesystemRoots()
608 | require.NoError(t, err2)
609 |
610 | // Legacy method should return same result as GetFilesystemRoots
611 | assert.Equal(t, result1.Success, result2.Success)
612 | assert.Equal(t, result1.Error, result2.Error)
613 |
614 | // Tree structure should be the same
615 | if result1.Tree != nil && result2.Tree != nil {
616 | assert.Equal(t, result1.Tree.Name, result2.Tree.Name)
617 | assert.Equal(t, result1.Tree.Path, result2.Tree.Path)
618 | assert.Len(t, result2.Tree.Children, len(result1.Tree.Children))
619 | }
620 | }
621 |
622 | // Integration Tests
623 |
624 | func TestScanner_Integration_ComplexDirectoryStructure(t *testing.T) {
625 | s := scanner.New()
626 |
627 | // Create a complex nested structure
628 | tempDir := t.TempDir()
629 | defer cleanupTempDir(t, tempDir)
630 |
631 | // Create various directory types
632 | structure := map[string][]string{
633 | "go_project": {"go.mod"},
634 | "git_go_project": {".git/", "src/go.mod"},
635 | "regular_with_go": {"main.go", "utils.go"},
636 | "regular_no_go": {"README.md", "config.json"},
637 | "empty": {},
638 | "node_modules": {"package.json"}, // Should be excluded
639 | ".hidden": {"file.txt"}, // Should be excluded
640 | "nested/deep/structure": {"go.mod"},
641 | }
642 |
643 | for dirPath, files := range structure {
644 | fullDirPath := filepath.Join(tempDir, dirPath)
645 | err := os.MkdirAll(fullDirPath, 0755)
646 | require.NoError(t, err)
647 |
648 | for _, file := range files {
649 | filePath := filepath.Join(fullDirPath, file)
650 | if strings.HasSuffix(file, "/") {
651 | // It's a directory
652 | err = os.MkdirAll(filePath, 0755)
653 | require.NoError(t, err)
654 | } else {
655 | // Ensure parent directory exists
656 | err = os.MkdirAll(filepath.Dir(filePath), 0755)
657 | require.NoError(t, err)
658 |
659 | var content string
660 | switch filepath.Ext(file) {
661 | case ".mod":
662 | content = "module test\n\ngo 1.19\n"
663 | case ".go":
664 | content = "package main\n\nfunc main() {}\n"
665 | default:
666 | content = "test content"
667 | }
668 |
669 | err = os.WriteFile(filePath, []byte(content), 0644)
670 | require.NoError(t, err)
671 | }
672 | }
673 | }
674 |
675 | result, err := s.ListDirectory(tempDir)
676 | require.NoError(t, err)
677 | require.True(t, result.Success)
678 |
679 | // Analyze results
680 | dirMap := make(map[string]*scanner.DirectoryNode)
681 | for _, dir := range result.Directories {
682 | dirMap[dir.Name] = dir
683 | }
684 |
685 | // Verify Go project detection
686 | assert.True(t, dirMap["go_project"].IsGoProject, "Direct go.mod should be detected")
687 | assert.True(t, dirMap["git_go_project"].IsGoProject, "Git repo with nested go.mod should be detected")
688 |
689 | // Verify non-Go projects with Go files are included
690 | if regularDir, exists := dirMap["regular_with_go"]; exists {
691 | assert.False(t, regularDir.IsGoProject, "Directory with only .go files should not be Go project")
692 | }
693 |
694 | // Verify exclusions
695 | assert.NotContains(t, dirMap, "node_modules", "node_modules should be excluded")
696 | assert.NotContains(t, dirMap, ".hidden", "Hidden directories should be excluded")
697 |
698 | // Verify nested structure is accessible
699 | if nestedDir, exists := dirMap["nested"]; exists {
700 | // Test listing the nested directory
701 | nestedResult, nestedErr := s.ListDirectory(nestedDir.Path)
702 | require.NoError(t, nestedErr)
703 | assert.True(t, nestedResult.Success)
704 | }
705 | }
706 |
707 | func TestScanner_Integration_CrossPlatformPaths(t *testing.T) {
708 | s := scanner.New()
709 |
710 | // Test that paths are handled correctly across platforms
711 | tempDir := t.TempDir()
712 | defer cleanupTempDir(t, tempDir)
713 |
714 | // Create subdirectory
715 | subDir := filepath.Join(tempDir, "subdir")
716 | err := os.MkdirAll(subDir, 0755)
717 | require.NoError(t, err)
718 |
719 | // Create go.mod
720 | goModContent := "module test\n\ngo 1.19\n"
721 | err = os.WriteFile(filepath.Join(subDir, "go.mod"), []byte(goModContent), 0644)
722 | require.NoError(t, err)
723 |
724 | result, err := s.ListDirectory(tempDir)
725 | require.NoError(t, err)
726 | require.True(t, result.Success)
727 | require.NotEmpty(t, result.Directories)
728 |
729 | subDirResult := result.Directories[0]
730 |
731 | // Verify path uses correct separators for the platform
732 | expectedPath := filepath.Join(tempDir, "subdir")
733 | assert.Equal(t, expectedPath, subDirResult.Path)
734 |
735 | // Verify path can be used for further operations
736 | nestedResult, err := s.ListDirectory(subDirResult.Path)
737 | require.NoError(t, err)
738 | assert.True(t, nestedResult.Success)
739 | }
740 |
741 | // Performance Tests
742 |
743 | func TestScanner_Performance_LargeDirectory(t *testing.T) {
744 | if testing.Short() {
745 | t.Skip("Skipping performance test in short mode")
746 | }
747 |
748 | s := scanner.New()
749 |
750 | // Create a directory with many subdirectories
751 | tempDir := t.TempDir()
752 | defer cleanupTempDir(t, tempDir)
753 |
754 | // Create 100 subdirectories (reasonable for performance test)
755 | for i := range 100 {
756 | subDir := filepath.Join(tempDir, fmt.Sprintf("dir_%03d", i))
757 | err := os.MkdirAll(subDir, 0755)
758 | require.NoError(t, err)
759 |
760 | // Every 10th directory gets a go.mod
761 | if i%10 == 0 {
762 | goModContent := fmt.Sprintf("module dir_%03d\n\ngo 1.19\n", i)
763 | err = os.WriteFile(filepath.Join(subDir, "go.mod"), []byte(goModContent), 0644)
764 | require.NoError(t, err)
765 | }
766 | }
767 |
768 | // Measure performance
769 | start := time.Now()
770 | result, err := s.ListDirectory(tempDir)
771 | duration := time.Since(start)
772 |
773 | require.NoError(t, err)
774 | require.True(t, result.Success)
775 |
776 | // Should complete within reasonable time (5 seconds for 100 dirs)
777 | assert.Less(t, duration, 5*time.Second, "Directory listing should complete within 5 seconds")
778 |
779 | // Should find the Go projects
780 | goProjectCount := 0
781 | for _, dir := range result.Directories {
782 | if dir.IsGoProject {
783 | goProjectCount++
784 | }
785 | }
786 | assert.Equal(t, 10, goProjectCount, "Should find exactly 10 Go projects")
787 | }
788 |
789 | // Error Recovery Tests
790 |
791 | func TestScanner_ErrorRecovery_MixedPermissions(t *testing.T) {
792 | if runtime.GOOS == "windows" {
793 | t.Skip("Permission tests not reliable on Windows")
794 | }
795 |
796 | s := scanner.New()
797 |
798 | tempDir := t.TempDir()
799 | defer cleanupTempDir(t, tempDir)
800 |
801 | // Create mixed accessible/inaccessible directories
802 | accessibleDir := filepath.Join(tempDir, "accessible")
803 | err := os.MkdirAll(accessibleDir, 0755)
804 | require.NoError(t, err)
805 |
806 | restrictedDir := filepath.Join(tempDir, "restricted")
807 | err = os.MkdirAll(restrictedDir, 0755)
808 | require.NoError(t, err)
809 |
810 | // Add go.mod to accessible directory
811 | goModContent := "module accessible\n\ngo 1.19\n"
812 | err = os.WriteFile(filepath.Join(accessibleDir, "go.mod"), []byte(goModContent), 0644)
813 | require.NoError(t, err)
814 |
815 | // Restrict permissions on one directory
816 | err = os.Chmod(restrictedDir, 0000)
817 | require.NoError(t, err)
818 |
819 | result, err := s.ListDirectory(tempDir)
820 | require.NoError(t, err)
821 | require.True(t, result.Success) // Should succeed despite restricted directory
822 |
823 | // Should still find accessible directory
824 | found := false
825 | for _, dir := range result.Directories {
826 | if dir.Name == "accessible" {
827 | found = true
828 | assert.True(t, dir.IsGoProject)
829 | break
830 | }
831 | }
832 | assert.True(t, found, "Should find accessible directory despite restricted directory")
833 | }
834 |
--------------------------------------------------------------------------------