From 1ae97d50a2b5b9657e65aa0c20039c5d409d164c Mon Sep 17 00:00:00 2001 From: Marcus Burghardt Date: Mon, 13 Apr 2026 11:16:41 +0200 Subject: [PATCH] fix: relativize Score.File paths in JSON output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Analyze function populates Score.File from gocyclo's Stat.Pos.Filename, which contains absolute paths derived from filepath.WalkDir with an absolute module root. This causes the recommended_actions section (and potentially scores, worst_crap, worst_gaze_crap) to embed machine-specific absolute paths in JSON output, making baselines non-portable and leaking filesystem structure. Add a relativization pass after computeScores() returns — converting all Score.File values from absolute to relative using filepath.Rel against moduleDir. This is placed after coverage lookups (which need exact paths) but before buildSummary (which propagates paths to all summary sections), fixing all downstream consumers in one place. Assisted-by: OpenCode (claude-opus-4-6) Signed-off-by: Marcus Burghardt --- internal/crap/analyze.go | 9 +++++++++ internal/crap/crap_test.go | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/internal/crap/analyze.go b/internal/crap/analyze.go index 620bd2b..2b8b46b 100644 --- a/internal/crap/analyze.go +++ b/internal/crap/analyze.go @@ -134,6 +134,15 @@ func Analyze(patterns []string, moduleDir string, opts Options) (*Report, error) // Step 5: Join complexity with coverage and compute CRAP. scores := computeScores(complexityStats, coverMap, opts) + // Step 5b: Relativize file paths for portable JSON output. + // computeScores uses absolute paths for coverage lookups, but the + // final report should contain paths relative to the module root. + for i := range scores { + if rel, err := filepath.Rel(moduleDir, scores[i].File); err == nil { + scores[i].File = rel + } + } + // Step 6: Build summary. summary := buildSummary(scores, opts) diff --git a/internal/crap/crap_test.go b/internal/crap/crap_test.go index 036f743..2848d85 100644 --- a/internal/crap/crap_test.go +++ b/internal/crap/crap_test.go @@ -1494,3 +1494,39 @@ func TestWriteText_RemediationBreakdown(t *testing.T) { t.Errorf("expected '[decompose]' label on worst offender, got:\n%s", out) } } + +func TestAnalyze_RelativizesFilePaths(t *testing.T) { + modRoot := moduleRoot(t) + + // Build a minimal coverage profile so Analyze can run. + profileContent := "mode: set\n" + + "github.com/unbound-force/gaze/internal/crap/crap.go:152.55,156.2 2 1\n" + + profileFile := filepath.Join(t.TempDir(), "cover.out") + if err := os.WriteFile(profileFile, []byte(profileContent), 0o644); err != nil { + t.Fatalf("writing cover profile: %v", err) + } + + opts := DefaultOptions() + opts.CoverProfile = profileFile + + report, err := Analyze([]string{"./internal/crap"}, modRoot, opts) + if err != nil { + t.Fatalf("Analyze failed: %v", err) + } + + // All Score.File paths must be relative (no leading /). + for _, s := range report.Scores { + if filepath.IsAbs(s.File) { + t.Errorf("Score.File is absolute, want relative: %q (function %s)", s.File, s.Function) + } + } + + // RecommendedActions (if any) must also have relative paths. + for _, a := range report.Summary.RecommendedActions { + if filepath.IsAbs(a.File) { + t.Errorf("RecommendedAction.File is absolute, want relative: %q (function %s)", + a.File, a.Function) + } + } +}