processor/report_render.go GO 638 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, 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 []AuthorRow:336			if len(s) > n {337				return s[:n]338			}339			return s340		case []LangTimelineRow:341			if len(s) > n {342				return s[:n]343			}344			return s345		case []AuthorTimelineRow:346			if len(s) > n {347				return s[:n]348			}349			return s350		case []*FileJob:351			if len(s) > n {352				return s[:n]353			}354			return s355		case []LineLengthOutlier:356			if len(s) > n {357				return s[:n]358			}359			return s360		}361		return items362	},363	"sliceLen": func(items any) int {364		switch s := items.(type) {365		case []LanguageSummary:366			return len(s)367		case []HotspotRow:368			return len(s)369		case []AuthorRow:370			return len(s)371		case []LangTimelineRow:372			return len(s)373		case []AuthorTimelineRow:374			return len(s)375		case []*FileJob:376			return len(s)377		case []LineLengthOutlier:378			return len(s)379		case []LineLengthBucket:380			return len(s)381		}382		return 0383	},384	"deltaSign": func(n int64) string {385		if n > 0 {386			return "+"387		}388		return ""389	},390	"deltaColor": func(n int64) string {391		if n > 0 {392			return "var(--good)"393		}394		if n < 0 {395			return "var(--danger)"396		}397		return "var(--fg-muted)"398	},399	// filesSorted returns the file list sorted by line count descending so the400	// "Notable files" table is deterministic regardless of walker order.401	"filesSorted": func(files []*FileJob) []*FileJob {402		out := make([]*FileJob, len(files))403		copy(out, files)404		sort.Slice(out, func(i, j int) bool {405			if out[i].Lines != out[j].Lines {406				return out[i].Lines > out[j].Lines407			}408			return out[i].Location < out[j].Location409		})410		return out411	},412}413414func commaFmt(n int64) string {415	s := fmt.Sprintf("%d", n)416	if len(s) <= 3 {417		return s418	}419	var b strings.Builder420	pre := len(s) % 3421	if pre > 0 {422		b.WriteString(s[:pre])423		if len(s) > pre {424			b.WriteByte(',')425		}426	}427	for i := pre; i < len(s); i += 3 {428		b.WriteString(s[i : i+3])429		if i+3 < len(s) {430			b.WriteByte(',')431		}432	}433	return b.String()434}435436// humanBytes renders a byte count as "612K", "4.2M", etc. Uses SI units437// (1000-based) to match the existing tabular formatter default. Output is438// kept compact for table cells and the share card.439func humanBytes(n int64) string {440	const unit = 1000441	if n < unit {442		return fmt.Sprintf("%dB", n)443	}444	div, exp := int64(unit), 0445	for x := n / unit; x >= unit; x /= unit {446		div *= unit447		exp++448	}449	suffixes := []string{"K", "M", "G", "T", "P"}450	if exp >= len(suffixes) {451		exp = len(suffixes) - 1452	}453	v := float64(n) / float64(div)454	if v >= 100 {455		return fmt.Sprintf("%.0f%s", v, suffixes[exp])456	}457	if v >= 10 {458		return fmt.Sprintf("%.1f%s", v, suffixes[exp])459	}460	return fmt.Sprintf("%.1f%s", v, suffixes[exp])461}462463func langColor(name string) string {464	if c, ok := reportLangColors[name]; ok {465		return c466	}467	return "#999999"468}469470// donutArcs converts a sorted-by-code LanguageSummary slice into the geometry471// the donut SVG needs. circumference 2πr with r=1 keeps arithmetic simple —472// callers scale via the SVG circle's stroke-dasharray with the canonical r.473func donutArcs(summary []LanguageSummary) []DonutArc {474	var total int64475	for _, s := range summary {476		total += s.Code477	}478	if total == 0 {479		return nil480	}481	const circ = 100.0482	offset := 0.0483	arcs := make([]DonutArc, 0, len(summary))484	for _, s := range summary {485		frac := float64(s.Code) / float64(total)486		seg := frac * circ487		arcs = append(arcs, DonutArc{488			Color:      langColor(s.Name),489			Dasharray:  fmt.Sprintf("%.3f %.3f", seg, circ-seg),490			Dashoffset: -offset,491		})492		offset += seg493	}494	return arcs495}496497// sparklinePath turns a series into an SVG `d=` attribute scaled into a498// w×h box. Linear interpolation between data points. Empty input returns "".499func sparklinePath(values []int, w, h int) string {500	return sparklinePathInternal(intsTo64(values), w, h, false)501}502503func sparklineFill(values []int, w, h int) string {504	return sparklinePathInternal(intsTo64(values), w, h, true)505}506507func sparklinePath64(values []int64, w, h int) string {508	return sparklinePathInternal(values, w, h, false)509}510511func sparklineFill64(values []int64, w, h int) string {512	return sparklinePathInternal(values, w, h, true)513}514515func intsTo64(in []int) []int64 {516	out := make([]int64, len(in))517	for i, v := range in {518		out[i] = int64(v)519	}520	return out521}522523func sparklinePathInternal(values []int64, w, h int, closed bool) string {524	if len(values) == 0 || w <= 0 || h <= 0 {525		return ""526	}527	minV, maxV := values[0], values[0]528	for _, v := range values {529		if v < minV {530			minV = v531		}532		if v > maxV {533			maxV = v534		}535	}536	span := float64(maxV - minV)537	if span == 0 {538		span = 1539	}540	stepX := float64(w)541	if len(values) > 1 {542		stepX = float64(w) / float64(len(values)-1)543	}544	var b strings.Builder545	for i, v := range values {546		x := float64(i) * stepX547		// Invert Y so larger values are higher.548		y := float64(h) - (float64(v-minV)/span)*float64(h)549		if i == 0 {550			fmt.Fprintf(&b, "M%.1f,%.1f", x, y)551		} else {552			fmt.Fprintf(&b, " L%.1f,%.1f", x, y)553		}554	}555	if closed {556		// Close the path to the baseline so the area fill renders cleanly.557		fmt.Fprintf(&b, " L%.1f,%.1f L%.1f,%.1f Z", float64(w), float64(h), 0.0, float64(h))558	}559	return b.String()560}561562// bucketBars converts a slice of bucket counts into Bar geometry sized into563// a maxH-tall plot. Bars are uniformly spaced; the caller positions the SVG564// at whatever x-origin it wants by adding to Bar.X.565func bucketBars(buckets []int64, maxH int) []Bar {566	if len(buckets) == 0 || maxH <= 0 {567		return nil568	}569	var max int64570	for _, b := range buckets {571		if b > max {572			max = b573		}574	}575	if max == 0 {576		max = 1577	}578	const barW = 80579	const gap = 10580	bars := make([]Bar, len(buckets))581	for i, count := range buckets {582		h := int(math.Round(float64(count) / float64(max) * float64(maxH)))583		if h < 1 && count > 0 {584			h = 1585		}586		bars[i] = Bar{587			X:     i*(barW+gap) + 10,588			W:     barW,589			H:     h,590			Y:     maxH - h,591			Count: count,592		}593	}594	return bars595}596597// histoBars projects the line-length histogram buckets directly into Bar598// geometry. Saves the template from having to extract the Count field into599// a parallel slice. baseX shifts each bar's X origin so the caller can place600// the histogram beside an axis.601func histoBars(buckets []LineLengthBucket, plotH int) []Bar {602	counts := make([]int64, len(buckets))603	for i, b := range buckets {604		counts[i] = b.Count605	}606	bars := bucketBars(counts, plotH)607	for i := range bars {608		bars[i].Label = buckets[i].Label609		bars[i].Count = buckets[i].Count610	}611	return bars612}613614// authorActivity flattens an author's per-bucket Series into a list of code615// deltas suitable for sparklinePath64. Used by the timeline template.616func authorActivity(series []AuthorTimelineBucket) []int64 {617	out := make([]int64, len(series))618	for i, b := range series {619		out[i] = b.CodeDelta620	}621	return out622}623624// dataURLCard URL-encodes the rendered card SVG into a data: URL suitable625// for og:image. Uses a minimal escape set so the URL stays compact and626// human-readable when viewing source.627func dataURLCard(card template.HTML) string {628	s := string(card)629	// Strip leading whitespace / newlines so the resulting URL is compact.630	s = strings.TrimSpace(s)631	// QueryEscape encodes too aggressively for our purposes (e.g. spaces -> +)632	// but it's safe for og:image consumers, so we use it then patch the few633	// edge cases.634	encoded := url.QueryEscape(s)635	encoded = strings.ReplaceAll(encoded, "+", "%20")636	return "data:image/svg+xml;utf8," + encoded637}

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, 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.