processor/report_test.go GO 972 lines View on github.com → Search inside
1// SPDX-License-Identifier: MIT23package processor45import (6	"bytes"7	"html/template"8	"io"9	"os"10	"path/filepath"11	"strings"12	"testing"13	"time"14)1516func writeTestFile(path, content string) error {17	return os.WriteFile(path, []byte(content), 0o644)18}1920// TestCollectReportDataOnFixtureRepo runs the orchestrator against a small21// git fixture repo and asserts every section is populated. It exercises the22// happy path of the spec: GitAvailable=true, all four git pointers non-nil,23// per-file table and cost results present.24func TestCollectReportDataOnFixtureRepo(t *testing.T) {25	ProcessConstants()2627	dir := makeFixtureRepo(t, []map[string]string{28		{"a.go": "package a\n\nfunc A() {}\n"},29		{"a.go": "package a\n\nfunc A() {}\n\nfunc B() {}\n", "b.go": "package a\n\nfunc C() {}\n"},30		{"a.go": "package a\n\nfunc A() {}\n\nfunc B() {}\n\nfunc D() {}\n"},31	})3233	data, err := CollectReportData(dir)34	if err != nil {35		t.Fatalf("CollectReportData: %v", err)36	}3738	if !data.GitAvailable {39		t.Errorf("expected GitAvailable=true for fixture repo, got false")40	}41	if data.RepoName == "" {42		t.Errorf("expected RepoName to be derived from path, got empty")43	}44	if data.SccVersion == "" {45		t.Errorf("expected SccVersion to be set")46	}47	if data.GeneratedAt.IsZero() {48		t.Errorf("expected GeneratedAt to be set")49	}5051	if data.Totals.Files == 0 {52		t.Errorf("expected Totals.Files > 0, got 0")53	}54	if len(data.Summary) == 0 {55		t.Errorf("expected Summary to contain at least one language")56	}57	if len(data.Files) == 0 {58		t.Errorf("expected Files slice to be populated")59	}6061	if data.ULOC == nil {62		t.Errorf("expected ULOC section, got nil")63	} else if data.ULOC.Global == 0 {64		t.Errorf("expected ULOC.Global > 0, got 0")65	}6667	if data.LineLength == nil {68		t.Errorf("expected LineLength section, got nil")69	} else {70		if len(data.LineLength.Buckets) == 0 {71			t.Errorf("expected LineLength.Buckets populated")72		}73		if data.LineLength.Max == 0 {74			t.Errorf("expected LineLength.Max > 0")75		}76	}7778	if data.Hotspots == nil {79		t.Errorf("expected Hotspots section to be populated when git available")80	}81	if data.Authors == nil {82		t.Errorf("expected Authors section to be populated when git available")83	}84	if data.LanguageTimeline == nil {85		t.Errorf("expected LanguageTimeline section to be populated when git available")86	}87	if data.AuthorTimeline == nil {88		t.Errorf("expected AuthorTimeline section to be populated when git available")89	}9091	if data.Cocomo == nil {92		t.Errorf("expected Cocomo result, got nil")93	}94	if data.Locomo == nil {95		t.Errorf("expected Locomo result, got nil")96	}97}9899// TestCollectReportDataOnNonGitDir verifies the git-less path: detect=false,100// all four git pointers nil, but the language/file/cost sections still101// populated.102func TestCollectReportDataOnNonGitDir(t *testing.T) {103	ProcessConstants()104105	dir := t.TempDir()106	if err := writeTestFile(filepath.Join(dir, "main.go"), "package main\n\nfunc main() {}\n"); err != nil {107		t.Fatalf("write file: %v", err)108	}109110	data, err := CollectReportData(dir)111	if err != nil {112		t.Fatalf("CollectReportData: %v", err)113	}114115	if data.GitAvailable {116		t.Errorf("expected GitAvailable=false for non-git tempdir, got true")117	}118	if data.Hotspots != nil {119		t.Errorf("expected Hotspots=nil when git unavailable, got %+v", data.Hotspots)120	}121	if data.Authors != nil {122		t.Errorf("expected Authors=nil when git unavailable, got %+v", data.Authors)123	}124	if data.LanguageTimeline != nil {125		t.Errorf("expected LanguageTimeline=nil when git unavailable")126	}127	if data.AuthorTimeline != nil {128		t.Errorf("expected AuthorTimeline=nil when git unavailable")129	}130131	if data.Totals.Files == 0 {132		t.Errorf("expected Totals.Files > 0 even without git, got 0")133	}134	if data.Cocomo == nil {135		t.Errorf("expected Cocomo result even without git, got nil")136	}137}138139// TestCollectReportDataRestoresFlags asserts that the package-level flag140// vars mutated inside CollectReportData are restored to their on-entry141// values, even when the report mode flipped them on. Critical for142// in-process re-entrancy.143func TestCollectReportDataRestoresFlags(t *testing.T) {144	ProcessConstants()145146	prevUloc, prevMaxMean, prevFiles := UlocMode, MaxMean, Files147	UlocMode, MaxMean, Files = false, false, false148	t.Cleanup(func() {149		UlocMode = prevUloc150		MaxMean = prevMaxMean151		Files = prevFiles152	})153154	dir := t.TempDir()155	if err := writeTestFile(filepath.Join(dir, "main.go"), "package main\n\nfunc main() {}\n"); err != nil {156		t.Fatalf("write file: %v", err)157	}158159	if _, err := CollectReportData(dir); err != nil {160		t.Fatalf("CollectReportData: %v", err)161	}162163	if UlocMode {164		t.Errorf("expected UlocMode restored to false, got true")165	}166	if MaxMean {167		t.Errorf("expected MaxMean restored to false, got true")168	}169	if Files {170		t.Errorf("expected Files restored to false, got true")171	}172}173174// TestCollectReportDataHonoursReportSkip verifies --report-skip nilling out175// the matching *Result pointers. Uses a tempdir (no git) so the only176// sections under test are ULOC, line-length, and cost.177func TestCollectReportDataHonoursReportSkip(t *testing.T) {178	ProcessConstants()179180	prevSkip := ReportSkipNames181	ReportSkipNames = map[string]bool{182		"uloc":       true,183		"linelength": true,184		"cocomo":     true,185		"locomo":     true,186	}187	t.Cleanup(func() { ReportSkipNames = prevSkip })188189	dir := t.TempDir()190	if err := writeTestFile(filepath.Join(dir, "main.go"), "package main\n\nfunc main() {}\n"); err != nil {191		t.Fatalf("write file: %v", err)192	}193194	data, err := CollectReportData(dir)195	if err != nil {196		t.Fatalf("CollectReportData: %v", err)197	}198199	if data.ULOC != nil {200		t.Errorf("expected ULOC=nil under --report-skip uloc, got %+v", data.ULOC)201	}202	if data.LineLength != nil {203		t.Errorf("expected LineLength=nil under --report-skip linelength, got %+v", data.LineLength)204	}205	if data.Cocomo != nil {206		t.Errorf("expected Cocomo=nil under --report-skip cocomo, got %+v", data.Cocomo)207	}208	if data.Locomo != nil {209		t.Errorf("expected Locomo=nil under --report-skip locomo, got %+v", data.Locomo)210	}211}212213// TestRenderReportEmbedsShareCardMeta renders a full report and asserts the214// OpenGraph / Twitter Card meta tags from spec 04 are present with the215// embedded data: URL share card. Locks in the unfurl contract.216func TestRenderReportEmbedsShareCardMeta(t *testing.T) {217	ProcessConstants()218219	dir := makeFixtureRepo(t, []map[string]string{220		{"a.go": "package a\n\nfunc A() {}\n"},221		{"a.go": "package a\n\nfunc A() {}\n\nfunc B() {}\n"},222	})223224	data, err := CollectReportData(dir)225	if err != nil {226		t.Fatalf("CollectReportData: %v", err)227	}228229	out := filepath.Join(t.TempDir(), "report.html")230	if err := RenderReport(data, out); err != nil {231		t.Fatalf("RenderReport: %v", err)232	}233234	body, err := os.ReadFile(out)235	if err != nil {236		t.Fatalf("read rendered report: %v", err)237	}238	html := string(body)239240	wants := []string{241		`<meta property="og:type" content="website">`,242		`<meta property="og:title" content="scc analysed `,243		`<meta property="og:description" content="`,244		`<meta property="og:image" content="data:image/svg`,245		`<meta name="twitter:card" content="summary_large_image">`,246		`<meta name="twitter:title" content="scc analysed `,247		`<meta name="twitter:description" content="`,248		`<meta name="twitter:image" content="data:image/svg`,249	}250	for _, w := range wants {251		if !strings.Contains(html, w) {252			t.Errorf("rendered report missing %q", w)253		}254	}255256	// The description should follow the spec-04 headline shape: files · SLOC.257	if !strings.Contains(html, " files · ") || !strings.Contains(html, " SLOC") {258		t.Errorf("rendered report description doesn't match headline shape; got HTML:\n%s", html)259	}260}261262// TestRenderReportSkipCardDropsImageTags asserts that --report-skip card263// suppresses both image meta tags but leaves the text ones intact.264func TestRenderReportSkipCardDropsImageTags(t *testing.T) {265	ProcessConstants()266267	prevSkip := ReportSkipNames268	ReportSkipNames = map[string]bool{"card": true}269	t.Cleanup(func() { ReportSkipNames = prevSkip })270271	dir := t.TempDir()272	if err := writeTestFile(filepath.Join(dir, "main.go"), "package main\n\nfunc main() {}\n"); err != nil {273		t.Fatalf("write file: %v", err)274	}275276	data, err := CollectReportData(dir)277	if err != nil {278		t.Fatalf("CollectReportData: %v", err)279	}280281	out := filepath.Join(t.TempDir(), "report.html")282	if err := RenderReport(data, out); err != nil {283		t.Fatalf("RenderReport: %v", err)284	}285286	body, err := os.ReadFile(out)287	if err != nil {288		t.Fatalf("read rendered report: %v", err)289	}290	html := string(body)291292	if strings.Contains(html, `property="og:image"`) {293		t.Errorf("expected no og:image when card is skipped")294	}295	if strings.Contains(html, `name="twitter:image"`) {296		t.Errorf("expected no twitter:image when card is skipped")297	}298	// Text tags must still be present.299	for _, w := range []string{`property="og:title"`, `name="twitter:description"`} {300		if !strings.Contains(html, w) {301			t.Errorf("expected text meta tag %q to remain when card is skipped", w)302		}303	}304}305306// TestCocomoPrettyCost locks in the headline cost format ("$1,234,567" style).307func TestCocomoPrettyCost(t *testing.T) {308	r := CocomoResult{CurrencySymbol: "$", EstimatedCost: 1234567.89}309	got := r.PrettyCost()310	if got != "$1,234,567" {311		t.Errorf("PrettyCost = %q, want %q", got, "$1,234,567")312	}313}314315// withReportSkipReset snapshots ReportSkipNames around a test body and316// restores it afterward. Spec 05 tests mutate the global heavily and we want317// the test order not to matter.318func withReportSkipReset(t *testing.T) {319	t.Helper()320	prev := ReportSkipNames321	t.Cleanup(func() { ReportSkipNames = prev })322}323324// TestParseReportSkipKnownNames exercises the happy path: every recognised325// name is parsed, lower-cased, and the warning channel stays silent.326func TestParseReportSkipKnownNames(t *testing.T) {327	withReportSkipReset(t)328329	var warn bytes.Buffer330	parseReportSkipTo("Cocomo, Locomo, hotspots, AUTHORS, timeline, files, uloc, linelength, card", &warn)331332	for _, name := range []string{"cocomo", "locomo", "hotspots", "authors", "timeline", "files", "uloc", "linelength", "card"} {333		if !ReportSkipped(name) {334			t.Errorf("ReportSkipped(%q) = false, want true after parseReportSkip", name)335		}336	}337	if got := warn.String(); got != "" {338		t.Errorf("expected no warning output for recognised names, got %q", got)339	}340}341342// TestParseReportSkipUnknownNameWarns covers spec 05's "unknown names emit a343// warning on stderr, continue" rule. The unknown name is still recorded so344// future template helpers can surface "ignored" hints.345func TestParseReportSkipUnknownNameWarns(t *testing.T) {346	withReportSkipReset(t)347348	var warn bytes.Buffer349	parseReportSkipTo("cocomo, bogus, also-bogus", &warn)350351	if !ReportSkipped("cocomo") {352		t.Errorf("recognised name should be marked skipped")353	}354	out := warn.String()355	if !strings.Contains(out, "unknown section \"bogus\"") {356		t.Errorf("expected warning to name the bogus section, got %q", out)357	}358	if !strings.Contains(out, "unknown section \"also-bogus\"") {359		t.Errorf("expected a warning per unknown section, got %q", out)360	}361}362363// TestParseReportSkipEmptyClearsMap asserts that parsing an empty string364// resets the map. Important because the var is package-level and a previous365// in-process run could otherwise leak state.366func TestParseReportSkipEmptyClearsMap(t *testing.T) {367	withReportSkipReset(t)368	ReportSkipNames = map[string]bool{"cocomo": true}369370	var warn bytes.Buffer371	parseReportSkipTo("", &warn)372373	if len(ReportSkipNames) != 0 {374		t.Errorf("expected ReportSkipNames cleared by empty input, got %v", ReportSkipNames)375	}376	if warn.Len() != 0 {377		t.Errorf("expected no warning for empty input, got %q", warn.String())378	}379}380381// TestReportSkippedCaseInsensitive locks in the spec 05 contract that382// callers can pass either case.383func TestReportSkippedCaseInsensitive(t *testing.T) {384	withReportSkipReset(t)385	ReportSkipNames = map[string]bool{"cocomo": true}386387	for _, name := range []string{"cocomo", "Cocomo", "COCOMO"} {388		if !ReportSkipped(name) {389			t.Errorf("ReportSkipped(%q) = false, want true", name)390		}391	}392	if ReportSkipped("hotspots") {393		t.Errorf("ReportSkipped(\"hotspots\") = true, want false")394	}395}396397// TestSkippedTemplateHelper renders a tiny template that uses the `skipped`398// helper to gate a block. Locks in the spec 05 contract that the helper is399// registered and reads ReportSkipNames.400func TestSkippedTemplateHelper(t *testing.T) {401	withReportSkipReset(t)402	ReportSkipNames = map[string]bool{"cocomo": true}403404	tmpl, err := template.New("t").Funcs(reportFuncs).Parse(405		`{{ if not (skipped "cocomo") }}cocomo-on{{ else }}cocomo-off{{ end }};{{ if not (skipped "hotspots") }}hotspots-on{{ end }}`,406	)407	if err != nil {408		t.Fatalf("parse: %v", err)409	}410	var out bytes.Buffer411	if err := tmpl.Execute(&out, nil); err != nil {412		t.Fatalf("execute: %v", err)413	}414	got := out.String()415	want := "cocomo-off;hotspots-on"416	if got != want {417		t.Errorf("template output = %q, want %q", got, want)418	}419}420421// TestDetectRepoNameHonoursReportTitle covers step 1 of the spec 05422// auto-detection chain — an explicit --report-title wins over everything.423func TestDetectRepoNameHonoursReportTitle(t *testing.T) {424	prev := ReportTitle425	ReportTitle = "My Custom Repo"426	t.Cleanup(func() { ReportTitle = prev })427428	if got := detectRepoName(t.TempDir()); got != "My Custom Repo" {429		t.Errorf("detectRepoName = %q, want %q", got, "My Custom Repo")430	}431}432433// TestDetectRepoNameFallsBackToBasename covers step 3 of the chain: with no434// override and no git remote, the basename of the analysed path is used.435func TestDetectRepoNameFallsBackToBasename(t *testing.T) {436	prev := ReportTitle437	ReportTitle = ""438	t.Cleanup(func() { ReportTitle = prev })439440	dir := filepath.Join(t.TempDir(), "myproj")441	if err := os.Mkdir(dir, 0o755); err != nil {442		t.Fatalf("mkdir: %v", err)443	}444445	if got := detectRepoName(dir); got != "myproj" {446		t.Errorf("detectRepoName = %q, want %q", got, "myproj")447	}448}449450// TestNoCocomoEquivalentToReportSkipCocomo verifies the spec 05 row that451// --no-cocomo and --report-skip cocomo produce the same outcome (Cocomo452// section nil). Uses a non-git tempdir to keep the run fast.453func TestNoCocomoEquivalentToReportSkipCocomo(t *testing.T) {454	ProcessConstants()455	withReportSkipReset(t)456457	dir := t.TempDir()458	if err := writeTestFile(filepath.Join(dir, "main.go"), "package main\n\nfunc main() {}\n"); err != nil {459		t.Fatalf("write file: %v", err)460	}461462	// Path 1: --no-cocomo (the Cocomo flag is "skip" when true).463	prevCocomo := Cocomo464	Cocomo = true465	t.Cleanup(func() { Cocomo = prevCocomo })466467	ReportSkipNames = map[string]bool{}468	dataA, err := CollectReportData(dir)469	if err != nil {470		t.Fatalf("CollectReportData (no-cocomo): %v", err)471	}472	if dataA.Cocomo != nil {473		t.Errorf("--no-cocomo: expected Cocomo=nil, got %+v", dataA.Cocomo)474	}475476	// Path 2: --report-skip cocomo, with the Cocomo skip flag off.477	Cocomo = false478	ReportSkipNames = map[string]bool{"cocomo": true}479	dataB, err := CollectReportData(dir)480	if err != nil {481		t.Fatalf("CollectReportData (skip cocomo): %v", err)482	}483	if dataB.Cocomo != nil {484		t.Errorf("--report-skip cocomo: expected Cocomo=nil, got %+v", dataB.Cocomo)485	}486}487488// TestReportImplicitlyEnablesULOCAndLineLength verifies that --report flips489// the ULOC and line-length analysis modes on, and --report-skip suppresses490// them — the spec 05 rows for `--uloc` / `-m` / `--character`.491func TestReportImplicitlyEnablesULOCAndLineLength(t *testing.T) {492	ProcessConstants()493	withReportSkipReset(t)494495	prevUloc, prevMaxMean, prevFiles := UlocMode, MaxMean, Files496	UlocMode, MaxMean, Files = false, false, false497	t.Cleanup(func() {498		UlocMode = prevUloc499		MaxMean = prevMaxMean500		Files = prevFiles501	})502503	dir := t.TempDir()504	if err := writeTestFile(filepath.Join(dir, "main.go"), "package main\n\nfunc main() {}\n"); err != nil {505		t.Fatalf("write file: %v", err)506	}507508	ReportSkipNames = map[string]bool{}509	data, err := CollectReportData(dir)510	if err != nil {511		t.Fatalf("CollectReportData: %v", err)512	}513	if data.ULOC == nil {514		t.Errorf("expected ULOC populated when --report runs without --report-skip uloc")515	}516	if data.LineLength == nil {517		t.Errorf("expected LineLength populated when --report runs without --report-skip linelength")518	}519	if len(data.Files) == 0 {520		t.Errorf("expected per-file table populated when --report runs without --report-skip files")521	}522}523524// TestReportSkipRecognisedListMatchesSpec locks in the exact set of names525// spec 05 enumerates as recognised so a future code change can't silently526// add or drop one without updating the spec.527func TestReportSkipRecognisedListMatchesSpec(t *testing.T) {528	want := []string{"cocomo", "locomo", "hotspots", "authors", "timeline", "files", "uloc", "linelength", "card"}529	if len(reportSkipRecognised) != len(want) {530		t.Errorf("reportSkipRecognised size = %d, want %d", len(reportSkipRecognised), len(want))531	}532	for _, name := range want {533		if !reportSkipRecognised[name] {534			t.Errorf("reportSkipRecognised missing %q", name)535		}536	}537}538539// TestCommaFmt locks in thousands-separator rendering for the headline540// numbers (`{{ comma .Totals.Code }}`). Covers boundaries the template hits:541// zero, < 1k, the 4- and 7-digit thresholds, and a negative.542func TestCommaFmt(t *testing.T) {543	cases := []struct {544		in   int64545		want string546	}{547		{0, "0"},548		{1, "1"},549		{12, "12"},550		{123, "123"},551		{1234, "1,234"},552		{12345, "12,345"},553		{123456, "123,456"},554		{1234567, "1,234,567"},555		{1000000000, "1,000,000,000"},556	}557	for _, tc := range cases {558		if got := commaFmt(tc.in); got != tc.want {559			t.Errorf("commaFmt(%d) = %q, want %q", tc.in, got, tc.want)560		}561	}562563	// The `comma` template helper wraps commaFmt with a negative-sign guard.564	helper := reportFuncs["comma"].(func(int64) string)565	if got := helper(-1234); got != "-1,234" {566		t.Errorf("comma(-1234) = %q, want %q", got, "-1,234")567	}568	if got := helper(0); got != "0" {569		t.Errorf("comma(0) = %q, want %q", got, "0")570	}571}572573// TestPctHelper locks in the "{{ pct n d }}" format and the zero-denominator574// guard the template relies on (e.g. when a section is skipped).575func TestPctHelper(t *testing.T) {576	pct := reportFuncs["pct"].(func(int64, int64) string)577	cases := []struct {578		num, denom int64579		want       string580	}{581		{0, 0, "0.0%"},582		{1, 0, "0.0%"},583		{0, 100, "0.0%"},584		{1, 4, "25.0%"},585		{1, 3, "33.3%"},586		{2, 3, "66.7%"},587		{100, 100, "100.0%"},588	}589	for _, tc := range cases {590		if got := pct(tc.num, tc.denom); got != tc.want {591			t.Errorf("pct(%d, %d) = %q, want %q", tc.num, tc.denom, got, tc.want)592		}593	}594}595596// TestHumanBytes locks in SI-style byte rendering used by the share card and597// per-file table.598func TestHumanBytes(t *testing.T) {599	cases := []struct {600		in   int64601		want string602	}{603		{0, "0B"},604		{1, "1B"},605		{999, "999B"},606		{1000, "1.0K"},607		{1500, "1.5K"},608		{12000, "12.0K"},609		{100000, "100K"},610		{1500000, "1.5M"},611		{1500000000, "1.5G"},612		{1500000000000, "1.5T"},613		{1500000000000000, "1.5P"},614	}615	for _, tc := range cases {616		if got := humanBytes(tc.in); got != tc.want {617			t.Errorf("humanBytes(%d) = %q, want %q", tc.in, got, tc.want)618		}619	}620}621622// TestDonutArcsMath asserts the geometry of the donut: arc dasharrays sum to623// the canonical circumference, offsets are negative cumulative segments, and624// colours flow through langColor.625func TestDonutArcsMath(t *testing.T) {626	if got := donutArcs(nil); got != nil {627		t.Errorf("donutArcs(nil) = %+v, want nil", got)628	}629	if got := donutArcs([]LanguageSummary{{Name: "Go", Code: 0}}); got != nil {630		t.Errorf("donutArcs(zero-total) = %+v, want nil", got)631	}632633	arcs := donutArcs([]LanguageSummary{634		{Name: "Go", Code: 75},635		{Name: "JavaScript", Code: 25},636	})637	if len(arcs) != 2 {638		t.Fatalf("donutArcs returned %d arcs, want 2", len(arcs))639	}640	if arcs[0].Color != "#00ADD8" {641		t.Errorf("arc[0].Color = %q, want #00ADD8 (Go)", arcs[0].Color)642	}643	if arcs[1].Color != "#f1e05a" {644		t.Errorf("arc[1].Color = %q, want #f1e05a (JavaScript)", arcs[1].Color)645	}646	if arcs[0].Dashoffset != 0 {647		t.Errorf("arc[0].Dashoffset = %f, want 0", arcs[0].Dashoffset)648	}649	// Second arc's offset is -1*first-arc-segment = -75.650	if arcs[1].Dashoffset != -75 {651		t.Errorf("arc[1].Dashoffset = %f, want -75", arcs[1].Dashoffset)652	}653	if arcs[0].Dasharray != "75.000 25.000" {654		t.Errorf("arc[0].Dasharray = %q, want %q", arcs[0].Dasharray, "75.000 25.000")655	}656	if arcs[1].Dasharray != "25.000 75.000" {657		t.Errorf("arc[1].Dasharray = %q, want %q", arcs[1].Dasharray, "25.000 75.000")658	}659}660661// TestSparklinePath covers the SVG-path generator for the timeline tables:662// empty input is "", a single point is a degenerate "M", two-point series663// renders an "M…L…" pair, fill mode closes the path back to the baseline,664// and a flat series doesn't divide by zero.665func TestSparklinePath(t *testing.T) {666	if got := sparklinePath(nil, 100, 20); got != "" {667		t.Errorf("sparklinePath(nil) = %q, want \"\"", got)668	}669	if got := sparklinePath([]int{1, 2, 3}, 0, 20); got != "" {670		t.Errorf("sparklinePath with w=0 should be empty, got %q", got)671	}672673	// Single point: x=0, span=1 → y=h (baseline).674	if got := sparklinePath([]int{5}, 100, 20); got != "M0.0,20.0" {675		t.Errorf("sparklinePath([5]) = %q, want %q", got, "M0.0,20.0")676	}677678	// Two-point ascending series spans the full box.679	got := sparklinePath([]int{0, 10}, 100, 20)680	if got != "M0.0,20.0 L100.0,0.0" {681		t.Errorf("sparklinePath([0,10]) = %q, want %q", got, "M0.0,20.0 L100.0,0.0")682	}683684	// Flat series should not divide by zero — span is forced to 1, so every685	// point lands on the baseline.686	flat := sparklinePath([]int{4, 4, 4}, 100, 20)687	if !strings.Contains(flat, "M0.0,20.0") || !strings.Contains(flat, "L100.0,20.0") {688		t.Errorf("sparklinePath(flat) = %q, want degenerate baseline path", flat)689	}690691	// Fill mode closes the path back to the baseline and back to x=0.692	fill := sparklineFill([]int{0, 10}, 100, 20)693	if !strings.HasSuffix(fill, " L100.0,20.0 L0.0,20.0 Z") {694		t.Errorf("sparklineFill should close to baseline, got %q", fill)695	}696}697698// TestDataURLCard locks in the og:image data: URL prefix and the space699// escaping the unfurl scrapers require.700func TestDataURLCard(t *testing.T) {701	got := dataURLCard(template.HTML("  <svg>hello world</svg>  "))702	want := "data:image/svg+xml;utf8,%3Csvg%3Ehello%20world%3C%2Fsvg%3E"703	if got != want {704		t.Errorf("dataURLCard = %q, want %q", got, want)705	}706707	// Empty input still yields the prefix so the template can embed it708	// unconditionally; the og:image gate is in the template, not here.709	if got := dataURLCard(""); got != "data:image/svg+xml;utf8," {710		t.Errorf("dataURLCard(\"\") = %q, want bare prefix", got)711	}712}713714// goldenReportFixture returns a hand-curated ReportData with every715// section populated and every timestamp / duration / floating-point value716// fixed. Used by TestRenderReport_Golden to catch unintended template717// changes. Keep this deterministic — never call time.Now(), never inject718// random IDs, never rely on map iteration order.719func goldenReportFixture() ReportData {720	generated := time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC)721	lastCommit := time.Date(2026, 1, 14, 18, 0, 0, 0, time.UTC)722723	return ReportData{724		RepoName:     "golden-fixture",725		SccVersion:   "3.8.0-test",726		GeneratedAt:  generated,727		Duration:     1500 * time.Millisecond,728		GitAvailable: true,729		Summary: []LanguageSummary{730			{Name: "Go", Count: 12, Code: 8000, Comment: 600, Blank: 1200, Complexity: 420, ULOC: 5400},731			{Name: "JavaScript", Count: 6, Code: 2400, Comment: 180, Blank: 360, Complexity: 90, ULOC: 1800},732			{Name: "Markdown", Count: 3, Code: 240, Comment: 0, Blank: 60, Complexity: 0, ULOC: 220},733		},734		Totals: Totals{735			Files: 21, Lines: 13040, Code: 10640, Comment: 780, Blank: 1620, Complexity: 510, Bytes: 524288,736		},737		ULOC: &ULOCResult{738			Global: 7420, TotalLines: 13040, Dryness: 0.569,739			PerLanguage: []ULOCLanguage{740				{Language: "Go", ULOC: 5400},741				{Language: "JavaScript", ULOC: 1800},742				{Language: "Markdown", ULOC: 220},743			},744		},745		LineLength: &LineLengthResult{746			Mean: 38.2, Max: 220, TotalLines: 10640,747			Buckets: []LineLengthBucket{748				{Start: 0, End: 20, Label: "0–20", Count: 4200},749				{Start: 20, End: 40, Label: "20–40", Count: 3800},750				{Start: 40, End: 60, Label: "40–60", Count: 1600},751				{Start: 60, End: 80, Label: "60–80", Count: 700},752				{Start: 80, End: 100, Label: "80–100", Count: 220},753				{Start: 100, End: 120, Label: "100–120", Count: 100},754				{Start: 120, End: 0, Label: "120+", Count: 20},755			},756			Outliers: []LineLengthOutlier{757				{File: "internal/wide.go", Language: "Go", LineLength: 220},758				{File: "web/styles.js", Language: "JavaScript", LineLength: 180},759			},760		},761		Hotspots: &HotspotsResult{762			Available: true,763			TotalRaw:  3,764			Records: []HotspotRow{765				{File: "internal/server.go", Language: "Go", Complexity: 240, Commits: 64, LinesChanged: 1200, Authors: 4, Score: 92.5},766				{File: "internal/cache.go", Language: "Go", Complexity: 120, Commits: 28, LinesChanged: 420, Authors: 2, Score: 41.2},767				{File: "web/app.js", Language: "JavaScript", Complexity: 60, Commits: 14, LinesChanged: 180, Authors: 2, Score: 17.8},768			},769		},770		Authors: &AuthorsResult{771			BusFactor:    1,772			BusAuthors:   []string{"Alice"},773			BusCovered:   0.62,774			InWindowCode: 9800,775			Rows: []AuthorRow{776				{Name: "Alice", Email: "alice@example.com", Code: 6200, Files: 14, OwnsPercent: 62.4, InWindowPercent: 64.0, LastCommit: lastCommit},777				{Name: "Bob", Email: "bob@example.com", Code: 2400, Files: 8, OwnsPercent: 24.1, InWindowPercent: 22.0, LastCommit: lastCommit.Add(-48 * time.Hour)},778				{Name: "(before window)", Sentinel: true},779			},780		},781		LanguageTimeline: &LangTimelineResult{782			Buckets: 4,783			Rows: []LangTimelineRow{784				{Language: "Go", StartingLines: 4000, CodeNow: 8000, Change: 4000, SharePercent: 75.2, Trajectory: []int64{4000, 5200, 6800, 8000}},785				{Language: "JavaScript", StartingLines: 2200, CodeNow: 2400, Change: 200, SharePercent: 22.6, Trajectory: []int64{2200, 2300, 2350, 2400}},786				{Language: "Markdown", StartingLines: 200, CodeNow: 240, Change: 40, SharePercent: 2.2, Trajectory: []int64{200, 210, 220, 240}},787			},788		},789		AuthorTimeline: &AuthorTimelineResult{790			Buckets: 4,791			Rows: []AuthorTimelineRow{792				{Name: "Alice", Email: "alice@example.com", TotalCommits: 48, CodeDelta: 3800, Series: []AuthorTimelineBucket{793					{Commits: 8, CodeDelta: 500},794					{Commits: 14, CodeDelta: 1200},795					{Commits: 12, CodeDelta: 900},796					{Commits: 14, CodeDelta: 1200},797				}},798				{Name: "Bob", Email: "bob@example.com", TotalCommits: 22, CodeDelta: 400, Series: []AuthorTimelineBucket{799					{Commits: 6, CodeDelta: 200},800					{Commits: 6, CodeDelta: 100},801					{Commits: 5, CodeDelta: 50},802					{Commits: 5, CodeDelta: 50},803				}},804			},805		},806		Cocomo: &CocomoResult{807			ProjectType: "organic", SumCode: 10640,808			EstimatedEffort: 26.4, EstimatedCost: 297500, ScheduleMonths: 8.4, PeopleRequired: 3.1,809			AverageWage: 56286, Overhead: 2.4, EAF: 1.0, CurrencySymbol: "$",810		},811		Locomo: &LocomoResult{812			InputTokens: 320000, OutputTokens: 96000, Cost: 18.25,813			GenerationSeconds: 1800, ReviewHours: 12, AverageComplexityMult: 1.4,814			IterationFactor: 1.2, Preset: "medium",815		},816		Files: []*FileJob{817			{Location: "internal/server.go", Language: "Go", Lines: 3200, Code: 2600, Comment: 200, Blank: 400, Complexity: 240, Bytes: 96000, Uloc: 2200},818			{Location: "internal/cache.go", Language: "Go", Lines: 1400, Code: 1100, Comment: 120, Blank: 180, Complexity: 120, Bytes: 42000, Uloc: 900},819			{Location: "web/app.js", Language: "JavaScript", Lines: 2000, Code: 1600, Comment: 80, Blank: 320, Complexity: 60, Bytes: 60000, Uloc: 1200},820			{Location: "README.md", Language: "Markdown", Lines: 120, Code: 100, Comment: 0, Blank: 20, Complexity: 0, Bytes: 4096, Uloc: 90},821		},822	}823}824825// TestRenderReport_Golden snapshots the full HTML report output against826// testdata/golden-report.html. Run with UPDATE_GOLDEN=1 to refresh after827// an intentional template change. The fixture is hand-curated and828// deterministic — no time.Now, no random colour assignment, no map829// iteration in the input — so a stable template produces stable bytes.830func TestRenderReport_Golden(t *testing.T) {831	ProcessConstants()832	withReportSkipReset(t)833	ReportSkipNames = map[string]bool{}834835	d := goldenReportFixture()836837	var buf bytes.Buffer838	if err := renderReportTo(&buf, d); err != nil {839		t.Fatalf("renderReportTo: %v", err)840	}841	got := buf.Bytes()842843	goldenPath := filepath.Join("testdata", "golden-report.html")844	if os.Getenv("UPDATE_GOLDEN") == "1" {845		if err := os.MkdirAll(filepath.Dir(goldenPath), 0o755); err != nil {846			t.Fatalf("mkdir testdata: %v", err)847		}848		if err := os.WriteFile(goldenPath, got, 0o644); err != nil {849			t.Fatalf("write golden: %v", err)850		}851		return852	}853854	want, err := os.ReadFile(goldenPath)855	if err != nil {856		t.Fatalf("read golden (run UPDATE_GOLDEN=1 to create): %v", err)857	}858	if !bytes.Equal(got, want) {859		t.Fatalf("golden mismatch (run UPDATE_GOLDEN=1 to refresh)\n--- want (%d bytes)\n--- got  (%d bytes)\nfirst diff at byte %d",860			len(want), len(got), firstByteDiff(want, got))861	}862}863864// firstByteDiff returns the index of the first byte at which a and b865// differ, or -1 if they're equal. Used by the golden test's failure866// message so a reviewer can jump straight to the divergence.867func firstByteDiff(a, b []byte) int {868	n := min(len(b), len(a))869	for i := range n {870		if a[i] != b[i] {871			return i872		}873	}874	if len(a) != len(b) {875		return n876	}877	return -1878}879880// TestConfirmReportOverwriteExplicitPathSilent verifies the "explicit881// path = consent" branch: when the user typed --report=foo.html, we882// should not stat, not prompt, not produce a warning. The function must883// succeed even with a nil reader and writer.884func TestConfirmReportOverwriteExplicitPathSilent(t *testing.T) {885	if err := confirmReportOverwrite("anywhere.html", false, true, nil, nil); err != nil {886		t.Errorf("explicit path: expected nil, got %v", err)887	}888	if err := confirmReportOverwrite("anywhere.html", false, false, nil, nil); err != nil {889		t.Errorf("explicit path non-TTY: expected nil, got %v", err)890	}891}892893// TestConfirmReportOverwriteMissingFileProceeds covers the bare-flag894// case where the file doesn't exist yet — no prompt, no error.895func TestConfirmReportOverwriteMissingFileProceeds(t *testing.T) {896	missing := filepath.Join(t.TempDir(), "scc-report.html")897	var out bytes.Buffer898	if err := confirmReportOverwrite(missing, true, true, strings.NewReader(""), &out); err != nil {899		t.Errorf("missing file: expected nil, got %v", err)900	}901	if out.Len() != 0 {902		t.Errorf("missing file: expected no prompt, got %q", out.String())903	}904}905906// TestConfirmReportOverwritePromptAccepts walks the happy interactive907// path: file exists, stdin attached, user types "y" — proceed.908func TestConfirmReportOverwritePromptAccepts(t *testing.T) {909	path := filepath.Join(t.TempDir(), "scc-report.html")910	if err := os.WriteFile(path, []byte("<html></html>"), 0o644); err != nil {911		t.Fatalf("seed file: %v", err)912	}913	for _, ans := range []string{"y\n", "Y\n", "yes\n", "YES\n", "  y \n"} {914		var out bytes.Buffer915		err := confirmReportOverwrite(path, true, true, strings.NewReader(ans), &out)916		if err != nil {917			t.Errorf("answer %q: expected nil, got %v", ans, err)918		}919		if !strings.Contains(out.String(), "Overwrite?") {920			t.Errorf("answer %q: expected prompt in stderr, got %q", ans, out.String())921		}922	}923}924925// TestConfirmReportOverwritePromptRejects covers the "default to no"926// rule the prompt advertises in its `[y/N]` suffix. Anything that927// isn't an affirmative aborts.928func TestConfirmReportOverwritePromptRejects(t *testing.T) {929	path := filepath.Join(t.TempDir(), "scc-report.html")930	if err := os.WriteFile(path, []byte("<html></html>"), 0o644); err != nil {931		t.Fatalf("seed file: %v", err)932	}933	for _, ans := range []string{"n\n", "N\n", "no\n", "\n", "garbage\n"} {934		var out bytes.Buffer935		err := confirmReportOverwrite(path, true, true, strings.NewReader(ans), &out)936		if err == nil {937			t.Errorf("answer %q: expected abort, got nil", ans)938			continue939		}940		if !strings.Contains(err.Error(), "not overwritten") {941			t.Errorf("answer %q: expected 'not overwritten' in error, got %v", ans, err)942		}943	}944}945946// TestConfirmReportOverwriteNonTTYRefuses locks in the CI / piped-stdin947// guard: with the default name and an existing file we refuse to948// silently clobber, and the error tells the user how to opt in.949func TestConfirmReportOverwriteNonTTYRefuses(t *testing.T) {950	path := filepath.Join(t.TempDir(), "scc-report.html")951	if err := os.WriteFile(path, []byte("<html></html>"), 0o644); err != nil {952		t.Fatalf("seed file: %v", err)953	}954	err := confirmReportOverwrite(path, true, false, strings.NewReader(""), io.Discard)955	if err == nil {956		t.Fatalf("non-TTY: expected error, got nil")957	}958	if !strings.Contains(err.Error(), "--report="+path) {959		t.Errorf("non-TTY error should point at explicit-path form, got %v", err)960	}961}962963// TestDefaultReportNameMatchesNoOptDefVal locks in the contract between964// main.go's NoOptDefVal wiring and runReport's "did the user supply a965// path" check. If these drift, the prompt logic silently breaks.966func TestDefaultReportNameMatchesNoOptDefVal(t *testing.T) {967	if DefaultReportName != "scc-report.html" {968		t.Errorf("DefaultReportName = %q, want %q (main.go wires this as NoOptDefVal)",969			DefaultReportName, "scc-report.html")970	}971}

Code quality findings 16

Can cause issues on Windows consider filepath.Join instead
info correctness path-join-windows
if err := writeTestFile(filepath.Join(dir, "main.go"), "package main\n\nfunc main() {}\n"); err != nil {
Can cause issues on Windows consider filepath.Join instead
info correctness path-join-windows
if err := writeTestFile(filepath.Join(dir, "main.go"), "package main\n\nfunc main() {}\n"); err != nil {
Can cause issues on Windows consider filepath.Join instead
info correctness path-join-windows
if err := writeTestFile(filepath.Join(dir, "main.go"), "package main\n\nfunc main() {}\n"); err != nil {
Can cause issues on Windows consider filepath.Join instead
info correctness path-join-windows
out := filepath.Join(t.TempDir(), "report.html")
Can cause issues on Windows consider filepath.Join instead
info correctness path-join-windows
if err := writeTestFile(filepath.Join(dir, "main.go"), "package main\n\nfunc main() {}\n"); err != nil {
Can cause issues on Windows consider filepath.Join instead
info correctness path-join-windows
out := filepath.Join(t.TempDir(), "report.html")
Can cause issues on Windows consider filepath.Join instead
info correctness path-join-windows
dir := filepath.Join(t.TempDir(), "myproj")
Can cause issues on Windows consider filepath.Join instead
info correctness path-join-windows
if err := writeTestFile(filepath.Join(dir, "main.go"), "package main\n\nfunc main() {}\n"); err != nil {
Can cause issues on Windows consider filepath.Join instead
info correctness path-join-windows
if err := writeTestFile(filepath.Join(dir, "main.go"), "package main\n\nfunc main() {}\n"); err != nil {
Can cause issues on Windows consider filepath.Join instead
info correctness path-join-windows
goldenPath := filepath.Join("testdata", "golden-report.html")
Can cause issues on Windows consider filepath.Join instead
info correctness path-join-windows
missing := filepath.Join(t.TempDir(), "scc-report.html")
Can cause issues on Windows consider filepath.Join instead
info correctness path-join-windows
path := filepath.Join(t.TempDir(), "scc-report.html")
String to byte slice conversion inside loop allocates a new slice each iteration; convert once before the loop
info correctness string-to-byte-in-loop
if err := os.WriteFile(path, []byte("<html></html>"), 0o644); err != nil {
Can cause issues on Windows consider filepath.Join instead
info correctness path-join-windows
path := filepath.Join(t.TempDir(), "scc-report.html")
String to byte slice conversion inside loop allocates a new slice each iteration; convert once before the loop
info correctness string-to-byte-in-loop
if err := os.WriteFile(path, []byte("<html></html>"), 0o644); err != nil {
Can cause issues on Windows consider filepath.Join instead
info correctness path-join-windows
path := filepath.Join(t.TempDir(), "scc-report.html")

Get this view in your editor

Same data, no extra tab — call code_get_file + code_get_findings over MCP from Claude/Cursor/Copilot.