Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,8 @@ jobs:
gofmt -s -d .
exit 1
fi

- name: Run golangci-lint
uses: golangci/golangci-lint-action@v6
with:
version: latest
46 changes: 27 additions & 19 deletions .golangci.yml
Original file line number Diff line number Diff line change
@@ -1,23 +1,31 @@
version: "2"
run:
timeout: 5m
tests: true

linters:
enable:
- errcheck # Check for unchecked errors
- govet # Vet examines Go source code
- ineffassign # Detect ineffectual assignments
- staticcheck # Advanced static analysis
- unused # Find unused variables, functions, constants
- gosimple # Simplify code
- typecheck # Parse and type-check Go code
- goimports # Format imports
- gocritic # Opinionated Go code review tool
- gofmt # Format code

issues:
exclude-rules:
# Allow underscores in test function names
- path: ".*_test\\.go"
linters:
- golint
- gocritic
exclusions:
generated: lax
presets:
- comments
- common-false-positives
- legacy
- std-error-handling
rules:
- linters:
- golint
path: .*_test\.go
paths:
- third_party$
- builtin$
- examples$
formatters:
enable:
- gofmt
- goimports
exclusions:
generated: lax
paths:
- third_party$
- builtin$
- examples$
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ A beautiful, interactive Terminal UI for analyzing Ruby gem dependencies and qui
[![CI](https://github.com/spaquet/gemtracker/actions/workflows/ci.yml/badge.svg)](https://github.com/spaquet/gemtracker/actions)
[![Latest Release](https://img.shields.io/github/v/release/spaquet/gemtracker)](https://github.com/spaquet/gemtracker/releases)
[![License](https://img.shields.io/github/license/spaquet/gemtracker)](LICENSE)
[![Go Report Card](https://goreportcard.com/badge/github.com/spaquet/gemtracker)](https://goreportcard.com/report/github.com/spaquet/gemtracker)
[![golangci-lint](https://img.shields.io/badge/lint-golangci--lint-brightgreen)](https://github.com/spaquet/gemtracker/actions/workflows/ci.yml)

![gemtracker screenshot](images/screen1.png)

Expand Down
28 changes: 18 additions & 10 deletions cmd/gemtracker/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,6 @@ func parseArgs() Args {
for i := 1; i < len(os.Args); i++ {
arg := os.Args[i]
i = parseArg(arg, i, &args)
if args.ShowVersion || args.ReportFormat != "" || args.ProjectPath != "" {
// Quick exits
}
}

return args
Expand Down Expand Up @@ -113,6 +110,13 @@ func parseArg(arg string, index int, args *Args) int {
}

func main() {
os.Exit(run())
}

// run executes the CLI and returns the process exit code. Keeping this
// separate from main lets deferred cleanup (telemetry, logger) always run
// before the process exits, since os.Exit in main would skip them.
func run() int {
// Initialize Sentry error tracking (optional, only if SENTRY_DSN is set)
if err := telemetry.InitSentry(version); err != nil {
// Log error but continue - Sentry is optional
Expand All @@ -125,7 +129,7 @@ func main() {

if args.ShowVersion {
printVersion()
os.Exit(0)
return 0
}

// Initialize logger (before TUI starts)
Expand All @@ -142,8 +146,10 @@ func main() {

// Check if we're in report mode
if args.ReportFormat != "" {
generateReport(args.ProjectPath, args.ReportFormat, args.OutputPath, args.NoCache, args.Verbose)
os.Exit(0)
if !generateReport(args.ProjectPath, args.ReportFormat, args.OutputPath, args.NoCache, args.Verbose) {
return 1
}
return 0
}

// Start the interactive TUI
Expand All @@ -153,20 +159,22 @@ func main() {
if _, err := p.Run(); err != nil {
telemetry.CaptureError(err)
fmt.Fprintf(os.Stderr, "Error running program: %v\n", err)
os.Exit(1)
return 1
}
return 0
}

// generateReport generates a gem dependency report in the specified format (text, csv, or json)
// and writes it to the specified output path or stdout if no path is provided.
// It exits the program with a non-zero status if report generation fails.
func generateReport(projectPath, format, outputPath string, noCache, verbose bool) {
// It returns false if report generation fails.
func generateReport(projectPath, format, outputPath string, noCache, verbose bool) bool {
reportGen := ui.NewReportGenerator(projectPath, noCache, verbose)
if err := reportGen.Generate(format, outputPath); err != nil {
telemetry.CaptureError(err)
fmt.Fprintf(os.Stderr, "Error generating report: %v\n", err)
os.Exit(1)
return false
}
return true
}

// printVersion outputs the gemtracker version string to stdout, including commit hash and build date
Expand Down
2 changes: 1 addition & 1 deletion cmd/gemtracker/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -312,5 +312,5 @@ func BenchmarkPrintVersion(b *testing.B) {
}

w.Close()
io.ReadAll(r)
_, _ = io.ReadAll(r)
}
4 changes: 3 additions & 1 deletion internal/cache/health_cache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,9 @@ func TestHealthCacheTTL(t *testing.T) {

// Ensure cache dir exists
cacheDir := filepath.Dir(cachePath)
os.MkdirAll(cacheDir, 0755)
if err := os.MkdirAll(cacheDir, 0755); err != nil {
t.Fatalf("Failed to create cache dir: %v", err)
}

// Write directly without using WriteHealth (which updates CachedAt)
data, _ := json.MarshalIndent(entry, "", " ")
Expand Down
2 changes: 1 addition & 1 deletion internal/gemfile/analyzer.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ func generateDetails(result *AnalysisResult) string {
status = "⚠"
}

sb.WriteString(fmt.Sprintf("%s %-30s v%s\n", status, gemStatus.Name, gemStatus.Version))
fmt.Fprintf(&sb, "%s %-30s v%s\n", status, gemStatus.Name, gemStatus.Version)
}

return sb.String()
Expand Down
7 changes: 4 additions & 3 deletions internal/gemfile/gemsize.go
Original file line number Diff line number Diff line change
Expand Up @@ -204,13 +204,14 @@ func removeANSICodes(s string) string {
result := ""
inEscape := false
for _, ch := range s {
if ch == '\x1b' {
switch {
case ch == '\x1b':
inEscape = true
} else if inEscape {
case inEscape:
if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') {
inEscape = false
}
} else {
default:
result += string(ch)
}
}
Expand Down
6 changes: 3 additions & 3 deletions internal/gemfile/health.go
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ func (hc *HealthChecker) fetchGitHubBatchGroup(pairs []RepoOwnerPair, token stri
queryBuilder.WriteString("query {")
for i, pair := range pairs {
alias := fmt.Sprintf("r%d", i)
queryBuilder.WriteString(fmt.Sprintf(
fmt.Fprintf(&queryBuilder,
`%s: repository(owner: "%s", name: "%s") {
pushedAt
stargazerCount
Expand All @@ -250,7 +250,7 @@ func (hc *HealthChecker) fetchGitHubBatchGroup(pairs []RepoOwnerPair, token stri
openIssues: issues(states: OPEN) { totalCount }
}`,
alias, pair.Owner, pair.Repo,
))
)
}
queryBuilder.WriteString("}")

Expand Down Expand Up @@ -305,7 +305,7 @@ func (hc *HealthChecker) fetchGitHubBatchGroup(pairs []RepoOwnerPair, token stri

// Find the corresponding pair by alias index
idx := 0
fmt.Sscanf(alias, "r%d", &idx)
_, _ = fmt.Sscanf(alias, "r%d", &idx)
if idx >= len(pairs) {
continue
}
Expand Down
15 changes: 6 additions & 9 deletions internal/gemfile/osv_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ func TestOSVClient_QueryBatch_Success(t *testing.T) {

w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
io.WriteString(w, mockResponse)
_, _ = io.WriteString(w, mockResponse)
}))
defer server.Close()

Expand Down Expand Up @@ -179,7 +179,7 @@ func TestOSVClient_QueryBatch_NoVulnerabilities(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
io.WriteString(w, mockResponse)
_, _ = io.WriteString(w, mockResponse)
}))
defer server.Close()

Expand Down Expand Up @@ -208,7 +208,7 @@ func TestOSVClient_QueryBatch_NoVulnerabilities(t *testing.T) {
func TestOSVClient_QueryBatch_APIError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
io.WriteString(w, "Internal server error")
_, _ = io.WriteString(w, "Internal server error")
}))
defer server.Close()

Expand All @@ -234,7 +234,7 @@ func TestOSVClient_QueryBatch_MalformedJSON(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
io.WriteString(w, "{invalid json}")
_, _ = io.WriteString(w, "{invalid json}")
}))
defer server.Close()

Expand Down Expand Up @@ -462,7 +462,7 @@ func TestOSVClient_QueryBatch_MultipleVulnerabilities(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
io.WriteString(w, mockResponse)
_, _ = io.WriteString(w, mockResponse)
}))
defer server.Close()

Expand Down Expand Up @@ -495,10 +495,7 @@ func TestOSVClient_QueryBatch_MultipleVulnerabilities(t *testing.T) {
func TestOSVClient_QueryBatch_ContextCancellation(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Simulate slow response
select {
case <-r.Context().Done():
return
}
<-r.Context().Done()
}))
defer server.Close()

Expand Down
4 changes: 2 additions & 2 deletions internal/gemfile/outdated.go
Original file line number Diff line number Diff line change
Expand Up @@ -504,10 +504,10 @@ func isVersionLess(v1, v2 string) bool {
var num1, num2 int

if i < len(v1Nums) {
fmt.Sscanf(v1Nums[i], "%d", &num1)
_, _ = fmt.Sscanf(v1Nums[i], "%d", &num1)
}
if i < len(v2Nums) {
fmt.Sscanf(v2Nums[i], "%d", &num2)
_, _ = fmt.Sscanf(v2Nums[i], "%d", &num2)
}

if num1 < num2 {
Expand Down
28 changes: 9 additions & 19 deletions internal/gemfile/outdated_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ func TestIsOutdated_WithMockServer(t *testing.T) {

w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
io.WriteString(w, response)
_, _ = io.WriteString(w, response)
}))
defer server.Close()

Expand Down Expand Up @@ -97,23 +97,15 @@ func TestIsOutdated_CacheBehavior(t *testing.T) {

w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
io.WriteString(w, response)
_, _ = io.WriteString(w, response)
}))
defer server.Close()

oc := NewOutdatedChecker()

// First call should make HTTP request
_, err := oc.getLatestVersion("test-gem")
if err != nil {
// This might fail because we're not using the mock server URL
// but the logic should still work
}

// Check that cache logic exists
if _, ok := oc.cache["test-gem"]; !ok && callCount > 0 {
// If we had successful HTTP calls, cache should be populated
}
// First call should make HTTP request; not using mock server URL so
// errors are expected and irrelevant to this test.
_, _ = oc.getLatestVersion("test-gem")
}

func TestGetHomepage_WithoutURL(t *testing.T) {
Expand Down Expand Up @@ -170,11 +162,9 @@ func TestGetDescription_NoCache(t *testing.T) {
// Since we're not making actual HTTP calls, this should return empty
desc := oc.GetDescription("unknown-gem")

// Should return empty string for uncached/unfetchable gem
if desc != "" {
// This might actually have a value if the HTTP call succeeds
// but for most test environments it should be empty
}
// Should return empty string for uncached/unfetchable gem, but the HTTP
// call may succeed in some test environments, so we only log the value.
_ = desc
}

func TestOutdatedChecker_VersionComparison(t *testing.T) {
Expand Down Expand Up @@ -243,7 +233,7 @@ func TestOutdatedChecker_Caching(t *testing.T) {

w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
io.WriteString(w, response)
_, _ = io.WriteString(w, response)
}))
defer server.Close()

Expand Down
18 changes: 3 additions & 15 deletions internal/gemfile/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,9 +128,10 @@ func processParserLine(line string, gf *Gemfile, state *parseState, gemLineRegex
return true
}
state.inSection = newSection
if newSection == "GEM" {
switch newSection {
case "GEM":
state.currentSource = "https://rubygems.org/"
} else if newSection == "PATH" {
case "PATH":
// PATH section source will be set by the remote line, default to "."
state.currentSource = "."
}
Expand Down Expand Up @@ -244,17 +245,6 @@ func getCurrentPlatform() string {
return goos + "-" + goarch
}

// matchesPlatform checks if a version string's platform suffix matches the given platform.
// Handles versions like "1.6.3-arm64-darwin", "1.6.3-x86_64-linux-musl", "1.6.3" (generic)
func matchesPlatform(version, platform string) bool {
parts := strings.Split(version, "-")
if len(parts) <= 1 {
return false // Generic version, no platform suffix
}
// Check if version starts with the platform prefix
return strings.HasPrefix(version, platform)
}

// parseGemOrGitLine parses gem spec lines (4-space indent) and dependency lines (6-space indent)
// from GIT/GEM sections of the Gemfile.lock. Returns the current or newly created Gem.
// When multiple platform-specific versions exist for a gem, it selects the one matching the current system.
Expand All @@ -272,8 +262,6 @@ func parseGemOrGitLine(line string, gf *Gemfile, currentGem *Gem, gemLineRegex,
if exists {
// Replace if current platform matches better, or if we don't have the current platform yet
currentPlatform := getCurrentPlatform()
existingIsCurrentPlatform := matchesPlatform(version, currentPlatform)
existingIsCurrentPlatform = existingIsCurrentPlatform || matchesPlatform(existingGem.Version, currentPlatform)

// Prefer current platform version; if existing is generic and new is specific to current platform, replace
// Also replace if existing doesn't match current platform but new does
Expand Down
Loading
Loading