processor/report_render.go GO 645 lines View on github.com → Search inside
1// SPDX-License-Identifier: MIT23package processor45import (6	"bufio"7	"bytes"8	_ "embed"9	"fmt"10	"html/template"11	"io"12	"math"13	"net/url"14	"os"15	"sort"16	"strings"17	"sync"1819	"github.com/mattn/go-isatty"20)2122//go:embed report_template.gohtml23var reportTemplateSrc string2425//go:embed report_card.gosvg26var reportCardSrc string2728var (29	reportTmplOnce sync.Once30	reportTmpl     *template.Template31)3233// reportTemplate parses the embedded HTML report and share-card templates on34// first use so a normal scc invocation pays nothing for the report path. The35// returned root template has both "report" and "card" defined.36func reportTemplate() *template.Template {37	reportTmplOnce.Do(func() {38		root := template.New("report").Funcs(reportFuncs)39		reportTmpl = template.Must(root.Parse(reportTemplateSrc))40		template.Must(reportTmpl.New("card").Parse(reportCardSrc))41	})42	return reportTmpl43}4445// parseReportSkip turns the raw --report-skip value into the lower-cased46// lookup map CollectReportData consults via ReportSkipped. Empty input clears47// the map. Whitespace and case in each item are normalised. Unknown names48// emit a warning on stderr (per spec 05) and are still added to the map so49// that future template helpers can surface "you asked for X but it's not a50// real section" hints without re-parsing.51func parseReportSkip(raw string) {52	parseReportSkipTo(raw, os.Stderr)53}5455// parseReportSkipTo is the testable seam for parseReportSkip — the warning56// destination is plumbed through so unit tests can capture stderr output57// without resorting to os.Stderr redirection.58func parseReportSkipTo(raw string, warnW io.Writer) {59	ReportSkipNames = map[string]bool{}60	if strings.TrimSpace(raw) == "" {61		return62	}63	for part := range strings.SplitSeq(raw, ",") {64		name := strings.ToLower(strings.TrimSpace(part))65		if name == "" {66			continue67		}68		if !reportSkipRecognised[name] {69			_, _ = fmt.Fprintf(warnW, "warning: --report-skip: unknown section %q (recognised: cocomo, locomo, hotspots, coupling, authors, timeline, files, uloc, linelength, card)\n", name)70		}71		ReportSkipNames[name] = true72	}73}7475// runReport is the dispatcher entry point invoked from Process() when76// ReportOut is set. It first prompts the user before clobbering an77// existing default-named file (so a bare `--report` is non-destructive),78// then collects data and renders the HTML output.79func runReport(paths []string) error {80	path := "."81	if len(paths) > 0 {82		path = paths[0]83	}84	usedDefault := ReportOut == DefaultReportName85	stdinIsTTY := isatty.IsTerminal(os.Stdin.Fd()) || isatty.IsCygwinTerminal(os.Stdin.Fd())86	if err := confirmReportOverwrite(ReportOut, usedDefault, stdinIsTTY, os.Stdin, os.Stderr); err != nil {87		return err88	}89	data, err := CollectReportData(path)90	if err != nil {91		return err92	}93	return RenderReport(data, ReportOut)94}9596// confirmReportOverwrite asks the user before clobbering a default-named97// scc-report.html in the current directory. The contract is:98//99//   - explicit path (`--report=foo.html`): treat the name as deliberate100//     consent and overwrite silently — usedDefaultName is false.101//   - bare `--report` and the file doesn't exist: proceed.102//   - bare `--report`, file exists, TTY attached: prompt "Overwrite? [y/N]"103//     and return nil only on an affirmative answer.104//   - bare `--report`, file exists, no TTY (CI, piped stdin): refuse and105//     point the user at the explicit-path form so the intent is auditable106//     in scripts.107//108// The io.Reader / io.Writer seam keeps the function unit-testable without109// poking at os.Stdin / os.Stderr.110func confirmReportOverwrite(outPath string, usedDefaultName, stdinIsTTY bool, in io.Reader, out io.Writer) error {111	if !usedDefaultName {112		return nil113	}114	if _, err := os.Stat(outPath); os.IsNotExist(err) {115		return nil116	} else if err != nil {117		// Some other stat error (permission?) — let the subsequent118		// os.Create surface the underlying problem with a clearer119		// "create report file: ..." wrapper instead of a stat error120		// nobody asked for.121		return nil122	}123	if !stdinIsTTY {124		return fmt.Errorf("%s already exists; rerun with --report=%s to overwrite explicitly", outPath, outPath)125	}126	_, _ = fmt.Fprintf(out, "%s already exists. Overwrite? [y/N]: ", outPath)127	line, err := bufio.NewReader(in).ReadString('\n')128	if err != nil && line == "" {129		return fmt.Errorf("aborted: %w", err)130	}131	ans := strings.ToLower(strings.TrimSpace(line))132	if ans == "y" || ans == "yes" {133		return nil134	}135	return fmt.Errorf("aborted: %s not overwritten", outPath)136}137138// RenderReport renders the share card first (so the result can be embedded139// as og:image in the main template) and then writes the page to outPath.140func RenderReport(d ReportData, outPath string) error {141	f, err := os.Create(outPath)142	if err != nil {143		return fmt.Errorf("create report file: %w", err)144	}145	defer f.Close()146147	if err := renderReportTo(f, d); err != nil {148		return err149	}150151	fmt.Fprintf(os.Stderr, "Report written to %s\n", outPath)152	return nil153}154155// renderReportTo is the io.Writer-shaped seam used by the golden test in156// spec 06. It renders the share card (so the og:image data URL is157// available) and then writes the full HTML page in one pass. Callers that158// just want bytes can use a bytes.Buffer.159func renderReportTo(w io.Writer, d ReportData) error {160	if !ReportSkipped("card") {161		var cardBuf bytes.Buffer162		if err := reportTemplate().ExecuteTemplate(&cardBuf, "card", d); err != nil {163			return fmt.Errorf("render share card: %w", err)164		}165		d.CardSVG = template.HTML(cardBuf.String())166	}167	if err := reportTemplate().ExecuteTemplate(w, "report", d); err != nil {168		return fmt.Errorf("render report: %w", err)169	}170	return nil171}172173// reportLangColors is the GitHub-style language colour palette consulted by174// the langColor template helper. Anything not in the map renders with the175// neutral fallback. Keep it short — exotic languages can fall back safely.176var reportLangColors = map[string]string{177	"Go":         "#00ADD8",178	"JavaScript": "#f1e05a",179	"TypeScript": "#3178c6",180	"Python":     "#3572A5",181	"Java":       "#b07219",182	"C":          "#555555",183	"C++":        "#f34b7d",184	"C#":         "#178600",185	"Ruby":       "#701516",186	"Rust":       "#dea584",187	"PHP":        "#4F5D95",188	"Swift":      "#F05138",189	"Kotlin":     "#A97BFF",190	"Shell":      "#89e051",191	"Bash":       "#89e051",192	"HTML":       "#e34c26",193	"CSS":        "#563d7c",194	"SCSS":       "#c6538c",195	"Markdown":   "#083fa1",196	"YAML":       "#cb171e",197	"JSON":       "#292929",198	"TOML":       "#9c4221",199	"XML":        "#0060ac",200	"SQL":        "#e38c00",201	"Dockerfile": "#384d54",202	"Makefile":   "#427819",203	"Perl":       "#0298c3",204	"Lua":        "#000080",205	"R":          "#198CE7",206	"Scala":      "#c22d40",207	"Haskell":    "#5e5086",208	"Elixir":     "#6e4a7e",209	"Erlang":     "#B83998",210	"Clojure":    "#db5855",211	"Vue":        "#41b883",212	"Svelte":     "#ff3e00",213	"Plain Text": "#999999",214	"Zig":        "#ec915c",215}216217// DonutArc is the geometry for a single arc segment in a donut chart. Used218// by donutArcs and consumed via the dasharray/dashoffset SVG attributes on a219// <circle>. Spec calls for this even though the sample mockup uses a flat220// composition bar — included for future templates / share-card variants.221type DonutArc struct {222	Color      string223	Dasharray  string224	Dashoffset float64225}226227// Bar is one bar in a histogram. Used by bucketBars for the line-length228// chart. X is the SVG x-coordinate, W and H are width/height in user units.229type Bar struct {230	X     int231	W     int232	H     int233	Y     int234	Count int64235	Label string236}237238// reportFuncs is the template func map registered against both the page239// template and the share card. Everything in here is pure (no I/O, no240// globals beyond the colour map) so templates remain deterministic.241var reportFuncs = template.FuncMap{242	"comma": func(n int64) string {243		// Manual implementation avoids pulling text/message's MatchString244		// allocations for what is a hot template helper.245		if n < 0 {246			return "-" + commaFmt(-n)247		}248		return commaFmt(n)249	},250	"commaInt": func(n int) string {251		return commaFmt(int64(n))252	},253	"pct": func(num, denom int64) string {254		if denom == 0 {255			return "0.0%"256		}257		return fmt.Sprintf("%.1f%%", float64(num)/float64(denom)*100)258	},259	"pctFloat": func(v float64) string {260		return fmt.Sprintf("%.1f%%", v*100)261	},262	"pctRaw": func(num, denom int64) float64 {263		if denom == 0 {264			return 0265		}266		return float64(num) / float64(denom) * 100267	},268	"bytes":           humanBytes,269	"langColor":       langColor,270	"donutArcs":       donutArcs,271	"sparklinePath":   sparklinePath,272	"sparklineFill":   sparklineFill,273	"sparklinePath64": sparklinePath64,274	"sparklineFill64": sparklineFill64,275	"bucketBars":      bucketBars,276	"histoBars":       histoBars,277	"authorActivity":  authorActivity,278	"durationSeconds": func(d any) float64 {279		switch v := d.(type) {280		case float64:281			return v282		default:283			// time.Duration formatted via Seconds() method.284			if ds, ok := d.(interface{ Seconds() float64 }); ok {285				return ds.Seconds()286			}287			_ = v288			return 0289		}290	},291	"divFloat": func(a, b float64) float64 {292		if b == 0 {293			return 0294		}295		return a / b296	},297	"mulFloat":    func(a, b float64) float64 { return a * b },298	"intCast":     func(v float64) int64 { return int64(v) },299	"dataURLCard": dataURLCard,300	"safeHTML":    func(s string) template.HTML { return template.HTML(s) },301	"skipped":     func(section string) bool { return ReportSkipped(section) },302	"add":         func(a, b int) int { return a + b },303	"sub":         func(a, b int) int { return a - b },304	"mul":         func(a, b int) int { return a * b },305	"div": func(a, b int) int {306		if b == 0 {307			return 0308		}309		return a / b310	},311	"int64":     func(n int) int64 { return int64(n) },312	"fromInt64": func(n int64) int { return int(n) },313	"fmtTime": func(layout string, t any) string {314		switch v := t.(type) {315		case string:316			return v317		default:318			return fmt.Sprintf("%v", t)319		}320	},321	"firstN": func(n int, items any) any {322		// Generic slice truncation via reflection-free type switch on the323		// concrete slice types the template uses.324		switch s := items.(type) {325		case []LanguageSummary:326			if len(s) > n {327				return s[:n]328			}329			return s330		case []HotspotRow:331			if len(s) > n {332				return s[:n]333			}334			return s335		case []CouplingPairRow:336			if len(s) > n {337				return s[:n]338			}339			return s340		case []AuthorRow:341			if len(s) > n {342				return s[:n]343			}344			return s345		case []LangTimelineRow:346			if len(s) > n {347				return s[:n]348			}349			return s350		case []AuthorTimelineRow:351			if len(s) > n {352				return s[:n]353			}354			return s355		case []*FileJob:356			if len(s) > n {357				return s[:n]358			}359			return s360		case []LineLengthOutlier:361			if len(s) > n {362				return s[:n]363			}364			return s365		}366		return items367	},368	"sliceLen": func(items any) int {369		switch s := items.(type) {370		case []LanguageSummary:371			return len(s)372		case []HotspotRow:373			return len(s)374		case []CouplingPairRow:375			return len(s)376		case []AuthorRow:377			return len(s)378		case []LangTimelineRow:379			return len(s)380		case []AuthorTimelineRow:381			return len(s)382		case []*FileJob:383			return len(s)384		case []LineLengthOutlier:385			return len(s)386		case []LineLengthBucket:387			return len(s)388		}389		return 0390	},391	"deltaSign": func(n int64) string {392		if n > 0 {393			return "+"394		}395		return ""396	},397	"deltaColor": func(n int64) string {398		if n > 0 {399			return "var(--good)"400		}401		if n < 0 {402			return "var(--danger)"403		}404		return "var(--fg-muted)"405	},406	// filesSorted returns the file list sorted by line count descending so the407	// "Notable files" table is deterministic regardless of walker order.408	"filesSorted": func(files []*FileJob) []*FileJob {409		out := make([]*FileJob, len(files))410		copy(out, files)411		sort.Slice(out, func(i, j int) bool {412			if out[i].Lines != out[j].Lines {413				return out[i].Lines > out[j].Lines414			}415			return out[i].Location < out[j].Location416		})417		return out418	},419}420421func commaFmt(n int64) string {422	s := fmt.Sprintf("%d", n)423	if len(s) <= 3 {424		return s425	}426	var b strings.Builder427	pre := len(s) % 3428	if pre > 0 {429		b.WriteString(s[:pre])430		if len(s) > pre {431			b.WriteByte(',')432		}433	}434	for i := pre; i < len(s); i += 3 {435		b.WriteString(s[i : i+3])436		if i+3 < len(s) {437			b.WriteByte(',')438		}439	}440	return b.String()441}442443// humanBytes renders a byte count as "612K", "4.2M", etc. Uses SI units444// (1000-based) to match the existing tabular formatter default. Output is445// kept compact for table cells and the share card.446func humanBytes(n int64) string {447	const unit = 1000448	if n < unit {449		return fmt.Sprintf("%dB", n)450	}451	div, exp := int64(unit), 0452	for x := n / unit; x >= unit; x /= unit {453		div *= unit454		exp++455	}456	suffixes := []string{"K", "M", "G", "T", "P"}457	if exp >= len(suffixes) {458		exp = len(suffixes) - 1459	}460	v := float64(n) / float64(div)461	if v >= 100 {462		return fmt.Sprintf("%.0f%s", v, suffixes[exp])463	}464	if v >= 10 {465		return fmt.Sprintf("%.1f%s", v, suffixes[exp])466	}467	return fmt.Sprintf("%.1f%s", v, suffixes[exp])468}469470func langColor(name string) string {471	if c, ok := reportLangColors[name]; ok {472		return c473	}474	return "#999999"475}476477// donutArcs converts a sorted-by-code LanguageSummary slice into the geometry478// the donut SVG needs. circumference 2πr with r=1 keeps arithmetic simple —479// callers scale via the SVG circle's stroke-dasharray with the canonical r.480func donutArcs(summary []LanguageSummary) []DonutArc {481	var total int64482	for _, s := range summary {483		total += s.Code484	}485	if total == 0 {486		return nil487	}488	const circ = 100.0489	offset := 0.0490	arcs := make([]DonutArc, 0, len(summary))491	for _, s := range summary {492		frac := float64(s.Code) / float64(total)493		seg := frac * circ494		arcs = append(arcs, DonutArc{495			Color:      langColor(s.Name),496			Dasharray:  fmt.Sprintf("%.3f %.3f", seg, circ-seg),497			Dashoffset: -offset,498		})499		offset += seg500	}501	return arcs502}503504// sparklinePath turns a series into an SVG `d=` attribute scaled into a505// w×h box. Linear interpolation between data points. Empty input returns "".506func sparklinePath(values []int, w, h int) string {507	return sparklinePathInternal(intsTo64(values), w, h, false)508}509510func sparklineFill(values []int, w, h int) string {511	return sparklinePathInternal(intsTo64(values), w, h, true)512}513514func sparklinePath64(values []int64, w, h int) string {515	return sparklinePathInternal(values, w, h, false)516}517518func sparklineFill64(values []int64, w, h int) string {519	return sparklinePathInternal(values, w, h, true)520}521522func intsTo64(in []int) []int64 {523	out := make([]int64, len(in))524	for i, v := range in {525		out[i] = int64(v)526	}527	return out528}529530func sparklinePathInternal(values []int64, w, h int, closed bool) string {531	if len(values) == 0 || w <= 0 || h <= 0 {532		return ""533	}534	minV, maxV := values[0], values[0]535	for _, v := range values {536		if v < minV {537			minV = v538		}539		if v > maxV {540			maxV = v541		}542	}543	span := float64(maxV - minV)544	if span == 0 {545		span = 1546	}547	stepX := float64(w)548	if len(values) > 1 {549		stepX = float64(w) / float64(len(values)-1)550	}551	var b strings.Builder552	for i, v := range values {553		x := float64(i) * stepX554		// Invert Y so larger values are higher.555		y := float64(h) - (float64(v-minV)/span)*float64(h)556		if i == 0 {557			fmt.Fprintf(&b, "M%.1f,%.1f", x, y)558		} else {559			fmt.Fprintf(&b, " L%.1f,%.1f", x, y)560		}561	}562	if closed {563		// Close the path to the baseline so the area fill renders cleanly.564		fmt.Fprintf(&b, " L%.1f,%.1f L%.1f,%.1f Z", float64(w), float64(h), 0.0, float64(h))565	}566	return b.String()567}568569// bucketBars converts a slice of bucket counts into Bar geometry sized into570// a maxH-tall plot. Bars are uniformly spaced; the caller positions the SVG571// at whatever x-origin it wants by adding to Bar.X.572func bucketBars(buckets []int64, maxH int) []Bar {573	if len(buckets) == 0 || maxH <= 0 {574		return nil575	}576	var max int64577	for _, b := range buckets {578		if b > max {579			max = b580		}581	}582	if max == 0 {583		max = 1584	}585	const barW = 80586	const gap = 10587	bars := make([]Bar, len(buckets))588	for i, count := range buckets {589		h := int(math.Round(float64(count) / float64(max) * float64(maxH)))590		if h < 1 && count > 0 {591			h = 1592		}593		bars[i] = Bar{594			X:     i*(barW+gap) + 10,595			W:     barW,596			H:     h,597			Y:     maxH - h,598			Count: count,599		}600	}601	return bars602}603604// histoBars projects the line-length histogram buckets directly into Bar605// geometry. Saves the template from having to extract the Count field into606// a parallel slice. baseX shifts each bar's X origin so the caller can place607// the histogram beside an axis.608func histoBars(buckets []LineLengthBucket, plotH int) []Bar {609	counts := make([]int64, len(buckets))610	for i, b := range buckets {611		counts[i] = b.Count612	}613	bars := bucketBars(counts, plotH)614	for i := range bars {615		bars[i].Label = buckets[i].Label616		bars[i].Count = buckets[i].Count617	}618	return bars619}620621// authorActivity flattens an author's per-bucket Series into a list of code622// deltas suitable for sparklinePath64. Used by the timeline template.623func authorActivity(series []AuthorTimelineBucket) []int64 {624	out := make([]int64, len(series))625	for i, b := range series {626		out[i] = b.CodeDelta627	}628	return out629}630631// dataURLCard URL-encodes the rendered card SVG into a data: URL suitable632// for og:image. Uses a minimal escape set so the URL stays compact and633// human-readable when viewing source.634func dataURLCard(card template.HTML) string {635	s := string(card)636	// Strip leading whitespace / newlines so the resulting URL is compact.637	s = strings.TrimSpace(s)638	// QueryEscape encodes too aggressively for our purposes (e.g. spaces -> +)639	// but it's safe for og:image consumers, so we use it then patch the few640	// edge cases.641	encoded := url.QueryEscape(s)642	encoded = strings.ReplaceAll(encoded, "+", "%20")643	return "data:image/svg+xml;utf8," + encoded644}

Code quality findings 8

Blank identifier discarding results; verify intentional ignoring of return values
warning correctness blank-identifier-discard
_, _ = fmt.Fprintf(warnW, "warning: --report-skip: unknown section %q (recognised: cocomo, locomo, hotspots, coupling, authors, timeline, files, uloc, linelength, card)\n", name)
Blank identifier discarding results; verify intentional ignoring of return values
warning correctness blank-identifier-discard
_ = v
Type switch without default case; unhandled types will silently do nothing. Add a default case for safety
info correctness unchecked-type-switch
switch s := items.(type) {
Range over slice copies each element by value; use index or pointer receiver for large structs to avoid copies
info performance copy-large-struct
for i, v := range in {
Range over slice copies each element by value; use index or pointer receiver for large structs to avoid copies
info performance copy-large-struct
for i, v := range values {
Range over slice copies each element by value; use index or pointer receiver for large structs to avoid copies
info performance copy-large-struct
for i, count := range buckets {
Range over slice copies each element by value; use index or pointer receiver for large structs to avoid copies
info performance copy-large-struct
for i, b := range buckets {
Range over slice copies each element by value; use index or pointer receiver for large structs to avoid copies
info performance copy-large-struct
for i, b := range series {

Get this view in your editor

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