├── 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 | ![GitHub go.mod Go version](https://img.shields.io/github/go-mod/go-version/cvsouth/go-package-analyzer) 4 | [![Tests](https://github.com/cvsouth/go-package-analyzer/actions/workflows/test.yml/badge.svg)](https://github.com/cvsouth/go-package-analyzer/actions/workflows/test.yml) 5 | [![Go Report Card](https://goreportcard.com/badge/github.com/cvsouth/go-package-analyzer)](https://goreportcard.com/report/github.com/cvsouth/go-package-analyzer) 6 | [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/cvsouth/go-package-analyzer/badge)](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 | ![screenshot](https://raw.githubusercontent.com/cvsouth/go-package-analyzer/refs/heads/main/screenshot.png) 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 |
17 | 18 |
19 | 20 | 21 |
22 |
23 | 26 |
27 |
28 | 29 | 30 | 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 | 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 | --------------------------------------------------------------------------------