processor/history_coupling.go GO 983 lines View on github.com → Search inside
1// SPDX-License-Identifier: MIT23package processor45import (6	"encoding/csv"7	"errors"8	"fmt"9	"os"10	"path"11	"path/filepath"12	"sort"13	"strings"1415	"github.com/go-git/go-git/v5/plumbing"16	"github.com/go-git/go-git/v5/plumbing/object"17	jsoniter "github.com/json-iterator/go"18)1920// couplingOverviewTopN caps the rows in the tabular all-pairs (--coupling)21// overview. This is deliberately capped where the other history reports are not:22// the all-pairs view is a repo-wide glance, and a flat dump of every pair is23// unreadable. The per-file --coupling-for view and the CSV/JSON output stay24// uncapped — the full pair set is one --format away.25const couplingOverviewTopN = 152627// CouplingMinShared is the floor on co-change count for a pair to appear in28// any output. A pair that changed together only once is almost always a29// coincidence, not coupling, so the noise is dropped at the source. Raw counts30// below this are still accumulated; they're just not reported.31const CouplingMinShared = 23233// CouplingMaxFilesPerCommit is the default size cap: commits touching more than34// this many files are excluded from PAIR counting (each file still counts35// toward its own commit total). A commit touching hundreds of files is a sweep36// — initial import, vendored dump, gofmt, a license-header change — that37// carries no logical-coupling signal yet costs O(k²) pairs. 0 disables the cap.38const CouplingMaxFilesPerCommit = 303940// CouplingCount is the raw, unopinionated co-change record for one unordered41// file pair: how often each file changed across the window, and how often they42// changed in the same commit. scc emits these integers; any ratio a consumer43// wants — symmetric degree, or the directional P(B changes | A changed) that44// answers "blast radius" — is a division the consumer chooses, not scc.45type CouplingCount struct {46	A        string // lexicographically smaller surviving path47	B        string // lexicographically larger surviving path48	Shared   int    // commits in which BOTH changed49	CommitsA int    // commits in which A changed (window total)50	CommitsB int    // commits in which B changed (window total)5152	// HEAD complexity of each file (cyclomatic, or cognitive when the Cognitive53	// global is on), populated at Finalise. Only consumed by the weighted54	// ranking; zero for data/generated files that carry no complexity signal.55	ComplexityA int6456	ComplexityB int6457}5859// WeightedScore ranks a pair by co-change volume × the pair's smaller file60// complexity — the same shape --hotspots uses (complexity × commits), applied61// to a pair. Two effects fall out of it:62//63//   - min-complexity means a pair only scores when BOTH files are complex, so a64//     complex file coupled to a zero-complexity data/generated file (JSON, HTML,65//     Markdown) drops away — the churn-noise the raw view surfaces at the top.66//   - Shared (raw volume), not Degree, is the co-change term. Degree rewards a67//     thin 2-shared-commit pair that always moved together with a coincidental68//     100%; multiplied by high test-file complexity that buries the real work.69//     Volume keeps the heavyweight couplings on top and matches --hotspots.70func (c CouplingCount) WeightedScore() float64 {71	return float64(c.Shared) * float64(min(c.ComplexityA, c.ComplexityB))72}7374// lessCouplingRaw is the deterministic raw ordering: strongest absolute75// co-change first, then degree, then path. Shared by the raw sort and used as76// the tiebreak under weighted ranking.77func lessCouplingRaw(a, b CouplingCount) bool {78	if a.Shared != b.Shared {79		return a.Shared > b.Shared80	}81	if da, db := a.Degree(), b.Degree(); da != db {82		return da > db83	}84	if a.A != b.A {85		return a.A < b.A86	}87	return a.B < b.B88}8990// headComplexity returns a file's HEAD complexity used for weighting: cognitive91// when the Cognitive global is on (matching --hotspots), cyclomatic otherwise.92// A path absent from HEAD contributes zero, so it can never lift a pair's score.93func headComplexity(head HeadSnapshot, path string) int64 {94	hf, ok := head.Files[path]95	if !ok {96		return 097	}98	if Cognitive {99		return hf.Cognitive100	}101	return hf.Complexity102}103104// Degree is the symmetric coupling ratio shared/(a+b−shared) as a 0–100105// percentage — the standard temporal-coupling "degree". It is a convenience106// for the human-facing table only; the raw counts sit beside it so the number107// is never a black box. Returns 0 when the union is empty.108func (c CouplingCount) Degree() float64 {109	union := c.CommitsA + c.CommitsB - c.Shared110	if union <= 0 {111		return 0112	}113	return float64(c.Shared) / float64(union) * 100.0114}115116// couplingObserver accumulates temporal (change) coupling from the commit117// stream: which files keep changing together in the same commit. It implements118// only CommitObserver — coupling needs neither the start-tree baseline nor the119// mailmap, so it is the cheapest observer the history engine carries.120type couplingObserver struct {121	maxFilesPerCommit int122123	fileCommits map[string]int    // path -> commits touching it124	pairShared  map[pairKey]int   // unordered pair -> co-change count125	alias       map[string]string // renamed-from path -> renamed-to path126127	// Resolved-and-filtered state, materialised at Finalise. fc and ps have had128	// renames folded; head is the survivor set. Both the pair-list view and the129	// file-oriented "blast radius" query read from these.130	fc   map[string]int131	ps   map[pairKey]int132	head HeadSnapshot133134	window     HistoryWindow135	pairs      []CouplingCount // materialised at Finalise, strongest first136	totalPairs int             // pairs meeting the floor (for the footer)137	skipped    int             // commits dropped from pair counting by the cap138}139140type pairKey struct{ a, b string }141142func newCouplingObserver() *couplingObserver {143	return &couplingObserver{144		maxFilesPerCommit: CouplingMaxFilesPerCommit,145		fileCommits:       map[string]int{},146		pairShared:        map[pairKey]int{},147		alias:             map[string]string{},148	}149}150151func (o *couplingObserver) Observe(_ CommitInfo, changes []FileChange) {152	// Record renames so paths counted under an old name before the rename can be153	// folded into the new name at Finalise. Cheap to note here, resolved once at154	// the end rather than migrated eagerly per commit.155	for _, fc := range changes {156		if fc.FromPath != "" && fc.FromPath != fc.Path {157			o.alias[fc.FromPath] = fc.Path158		}159	}160161	// Distinct paths only — a rename can surface the same logical file twice.162	// FileChange already excludes deletes, binaries, submodules, ignored and163	// unclassifiable paths, so every Path here is a real counted source file.164	paths := make([]string, 0, len(changes))165	seen := make(map[string]struct{}, len(changes))166	for _, fc := range changes {167		if _, dup := seen[fc.Path]; dup {168			continue169		}170		seen[fc.Path] = struct{}{}171		paths = append(paths, fc.Path)172		o.fileCommits[fc.Path]++ // every file's own total — the ratio denominator173	}174175	if len(paths) < 2 {176		return // nothing can couple in a single-file commit177	}178	if o.maxFilesPerCommit > 0 && len(paths) > o.maxFilesPerCommit {179		o.skipped++180		return // totals already counted; skip the O(k²) pair explosion181	}182183	sort.Strings(paths) // canonical order so the pair key is stable: a < b184	for i := 0; i < len(paths); i++ {185		for j := i + 1; j < len(paths); j++ {186			o.pairShared[pairKey{paths[i], paths[j]}]++187		}188	}189}190191func (o *couplingObserver) Finalise(window HistoryWindow, head HeadSnapshot) {192	o.window = window193194	// Fold rename history: collapse every path to its final name, so a file that195	// lived under an old path before a rename shares one set of counts with its196	// current name. Done once here — O(total) — rather than migrated per commit.197	fileCommits := make(map[string]int, len(o.fileCommits))198	for path, n := range o.fileCommits {199		fileCommits[o.resolve(path)] += n200	}201	pairShared := make(map[pairKey]int, len(o.pairShared))202	for k, shared := range o.pairShared {203		a, b := o.resolve(k.a), o.resolve(k.b)204		if a == b {205			continue // both sides renamed to the same file — no longer a pair206		}207		if a > b {208			a, b = b, a209		}210		pairShared[pairKey{a, b}] += shared211	}212213	o.fc = fileCommits214	o.ps = pairShared215	o.head = head216217	pairs := make([]CouplingCount, 0, len(pairShared))218	for k, shared := range pairShared {219		if shared < CouplingMinShared {220			continue221		}222		// Keep only pairs whose BOTH files still exist in HEAD — same convention223		// as the rest of the engine, which never reports files that are gone.224		if _, ok := head.Files[k.a]; !ok {225			continue226		}227		if _, ok := head.Files[k.b]; !ok {228			continue229		}230		pairs = append(pairs, CouplingCount{231			A: k.a, B: k.b, Shared: shared,232			CommitsA:    fileCommits[k.a],233			CommitsB:    fileCommits[k.b],234			ComplexityA: headComplexity(head, k.a),235			ComplexityB: headComplexity(head, k.b),236		})237	}238239	if CouplingWeighted {240		// Weighted: co-change volume × min-complexity first, so pairs of complex241		// files outrank high-churn data/generated pairs. Ties fall back to the raw242		// ordering (shared, degree, path) for determinism.243		sort.Slice(pairs, func(i, j int) bool {244			wi, wj := pairs[i].WeightedScore(), pairs[j].WeightedScore()245			if wi != wj {246				return wi > wj247			}248			return lessCouplingRaw(pairs[i], pairs[j])249		})250	} else {251		// Strongest absolute co-change first, then strongest degree, then path so252		// the order is deterministic. Volume first surfaces the heavyweight253		// couplings; the Degree column lets the reader tell genuine coupling from254		// two busy files that merely co-change by chance.255		sort.Slice(pairs, func(i, j int) bool {256			return lessCouplingRaw(pairs[i], pairs[j])257		})258	}259260	o.pairs = pairs261	o.totalPairs = len(pairs)262}263264// resolve follows the rename chain from path to its final name. Guards against265// a pathological cycle in the alias map by capping the walk.266func (o *couplingObserver) resolve(path string) string {267	for i := 0; i < 64; i++ {268		next, ok := o.alias[path]269		if !ok || next == path {270			return path271		}272		path = next273	}274	return path275}276277// CouplingPartner is one file that co-changes with a chosen target file.278//279// Couple answers "if I change the target, how likely am I to touch this too".280// On its own it is confounded by the partner's base rate: a file that changes281// in most commits scores a near-perfect Couple against ANY target, purely282// because it is always there. Such a hub shows HIGH Couple and LOW Reverse —283// e.g. a target touched 3 times, always alongside a file touched 158 times,284// gives Couple 100% / Reverse 1.9%.285//286// Degree is the base-rate-corrected view and is what rows are ranked by; the287// two directional numbers are kept as supporting detail.288type CouplingPartner struct {289	Path          string290	Shared        int // commits changing BOTH target and this partner291	PartnerCommit int // partner's window commit total292	TargetCommit  int // target's window commit total293294	// HEAD complexity of the partner and the target, populated by partnersFor.295	// Only consumed by the weighted ranking.296	PartnerComplexity int64297	TargetComplexity  int64298}299300// WeightedScore mirrors the pairwise weighting for the blast-radius view:301// co-change volume × the smaller of the target's and partner's complexity, so a302// file's complex, frequently-co-changing neighbours outrank the trivial ones.303func (p CouplingPartner) WeightedScore() float64 {304	return float64(p.Shared) * float64(min(p.TargetComplexity, p.PartnerComplexity))305}306307// Couple is P(partner changes | target changed) = Shared / TargetCommit, the308// directional blast-radius probability: edit the target, expect to edit this.309func (p CouplingPartner) Couple() float64 {310	if p.TargetCommit <= 0 {311		return 0312	}313	return float64(p.Shared) / float64(p.TargetCommit) * 100.0314}315316// Reverse is P(target changes | partner changed) = Shared / PartnerCommit. A317// large gap between Reverse and Couple marks an asymmetric (hub-style) link318// rather than a true peer coupling.319func (p CouplingPartner) Reverse() float64 {320	if p.PartnerCommit <= 0 {321		return 0322	}323	return float64(p.Shared) / float64(p.PartnerCommit) * 100.0324}325326// Degree is the symmetric coupling ratio Shared/(target+partner−Shared) as a327// 0–100 percentage — the same measure the pairwise --coupling report ranks by,328// so the two views agree on what "strongly coupled" means.329//330// Unlike Couple it is not fooled by a busy partner: a hub present in every one331// of the target's commits still scores low here, because its own large commit332// total sits in the denominator.333func (p CouplingPartner) Degree() float64 {334	union := p.TargetCommit + p.PartnerCommit - p.Shared335	if union <= 0 {336		return 0337	}338	return float64(p.Shared) / float64(union) * 100.0339}340341// partnersFor returns every surviving file coupled to target, ranked by Degree342// descending. Returns nil when the target never changed in the window.343//344// Ranking is by Degree, not Couple: Couple alone puts every busy file at 100%345// (it was present for all of a rarely-touched target's commits by base rate346// alone), which buries the genuine peer couplings under hubs.347//348// target must be a current (HEAD) path: Finalise has already folded every349// pre-rename name into its final one, so the counts keyed here are complete,350// but an old path that no longer exists in HEAD will not match. Callers351// validate against HEAD via resolveCouplingTarget before the walk.352func (o *couplingObserver) partnersFor(target string) []CouplingPartner {353	tc := o.fc[target]354	if tc == 0 {355		return nil356	}357	out := make([]CouplingPartner, 0)358	for k, shared := range o.ps {359		if shared < CouplingMinShared {360			continue361		}362		var partner string363		switch target {364		case k.a:365			partner = k.b366		case k.b:367			partner = k.a368		default:369			continue370		}371		if _, ok := o.head.Files[partner]; !ok {372			continue373		}374		out = append(out, CouplingPartner{375			Path:              partner,376			Shared:            shared,377			PartnerCommit:     o.fc[partner],378			TargetCommit:      tc,379			PartnerComplexity: headComplexity(o.head, partner),380			TargetComplexity:  headComplexity(o.head, target),381		})382	}383	// Degree first (base-rate corrected), then raw co-change volume, then path so384	// the order is deterministic. Under weighted ranking, volume × min-complexity385	// leads and the raw ordering is the tiebreak.386	sort.Slice(out, func(i, j int) bool {387		if CouplingWeighted {388			wi, wj := out[i].WeightedScore(), out[j].WeightedScore()389			if wi != wj {390				return wi > wj391			}392		}393		di, dj := out[i].Degree(), out[j].Degree()394		if di != dj {395			return di > dj396		}397		if out[i].Shared != out[j].Shared {398			return out[i].Shared > out[j].Shared399		}400		return out[i].Path < out[j].Path401	})402	return out403}404405// CouplingForJSONReport walks history and returns the directional coupling for406// a single target file as JSON — the MCP entry point. limit > 0 caps the407// partner list (ranked by Degree, highest first); limit <= 0 returns every partner.408//409// target accepts the same forms as --coupling-for and is validated against HEAD410// before the walk, so a caller passing a bad path gets an immediate error rather411// than paying for a full traversal first.412func CouplingForJSONReport(repoPath, target string, limit int) (string, error) {413	resolved, err := resolveCouplingTarget(repoPath, target)414	if err != nil {415		return "", err416	}417	observer := newCouplingObserver()418	if _, err := runHistory(repoPath, observer); err != nil {419		return "", err420	}421	return renderCouplingForJSONLimited(observer, resolved, limit)422}423424// CouplingJSONReport walks the git history at repoPath and returns the coupling425// report as a JSON string — the programmatic entry point for the MCP server,426// which needs the rendered data rather than stdout side effects. A limit > 0427// caps the pair list (strongest first); limit <= 0 returns every pair.428func CouplingJSONReport(repoPath string, limit int) (string, error) {429	observer := newCouplingObserver()430	if _, err := runHistory(repoPath, observer); err != nil {431		return "", err432	}433	return renderCouplingJSONLimited(observer, limit)434}435436// couplingSuggestionLimit caps the "did you mean" candidates offered when a437// --coupling-for path misses.438const couplingSuggestionLimit = 5439440// errStopTreeWalk halts a tree walk once enough suggestions are collected.441// object.Tree.Files().ForEach surfaces whatever the callback returns, so it is442// compared back at the call site and discarded.443var errStopTreeWalk = errors.New("stop tree walk")444445// couplingTargetCandidates returns the repo-relative forms to try for a446// user-supplied --coupling-for path, most likely first. Git keys every path447// from the repository root with forward slashes and no "./" prefix, so the448// string a user naturally types ("./processor/x.go", an absolute path, or a449// path relative to a subdirectory they are standing in) rarely matches as-is.450func couplingTargetCandidates(repoRoot, target string) []string {451	var out []string452	seen := map[string]struct{}{}453	add := func(p string) {454		if p == "" {455			return456		}457		p = path.Clean(filepath.ToSlash(p))458		if p == "." || p == ".." || strings.HasPrefix(p, "../") {459			return // escapes the repository, or names no file460		}461		if _, dup := seen[p]; dup {462			return463		}464		seen[p] = struct{}{}465		out = append(out, p)466	}467468	if filepath.IsAbs(target) {469		// Absolute → relative to the repository root.470		if rel, err := filepath.Rel(repoRoot, target); err == nil {471			add(rel)472		}473		return out474	}475476	// As typed, cleaned. Handles both "processor/x.go" and "./processor/x.go".477	add(filepath.ToSlash(target))478479	// Relative to the working directory, for running scc from a subdirectory:480	// `cd processor && scc --coupling-for constants.go .` means processor/constants.go.481	if cwd, err := os.Getwd(); err == nil {482		if rel, err := filepath.Rel(repoRoot, filepath.Join(cwd, target)); err == nil {483			add(rel)484		}485	}486	return out487}488489// couplingTargetMiss builds the error for a target path that matched nothing in490// HEAD, offering same-basename files as suggestions. The message is context491// neutral — it names the path, not any CLI flag — so it reads correctly whether492// surfaced through the CLI (which re-wraps it with the flag name at its call493// site) or an MCP client (which passed a `file` argument and has never seen the494// flag). Only ever runs on the failure path, so walking the tree here costs495// nothing in the happy case.496func couplingTargetMiss(tree *object.Tree, target string) error {497	base := path.Base(path.Clean(filepath.ToSlash(target)))498	var matches []string499	err := tree.Files().ForEach(func(f *object.File) error {500		if path.Base(f.Name) != base {501			return nil502		}503		matches = append(matches, f.Name)504		if len(matches) >= couplingSuggestionLimit {505			return errStopTreeWalk506		}507		return nil508	})509	if err != nil && !errors.Is(err, errStopTreeWalk) {510		return fmt.Errorf("target %q is not in HEAD (deleted, ignored, or path typo)", target)511	}512	if len(matches) > 0 {513		return fmt.Errorf("target %q is not in HEAD; did you mean:\n  %s",514			target, strings.Join(matches, "\n  "))515	}516	return fmt.Errorf("target %q is not in HEAD (deleted, ignored, or path typo)", target)517}518519// resolveCouplingTarget maps a user-supplied --coupling-for path onto the520// git-style path the history engine keys on, verifying it against HEAD *before*521// the caller pays for a full history walk. A typo previously cost a complete522// walk (seconds to minutes) before reporting the miss.523//524// A repository with no HEAD (freshly initialised, no commits) is not an error525// here: the path is normalised and the walk reports the empty window as usual.526func resolveCouplingTarget(repoPath, target string) (string, error) {527	fallback := path.Clean(filepath.ToSlash(target))528529	repo, err := openRepository(repoPath)530	if err != nil {531		return "", fmt.Errorf("open git repository: %w", err)532	}533	ref, err := repo.Head()534	if err != nil {535		if errors.Is(err, plumbing.ErrReferenceNotFound) {536			// See runHistory: unresolvable and unborn HEADs are the same537			// error. The walk warns about it, so stay quiet here and just538			// let it report the empty window.539			return fallback, nil540		}541		return "", fmt.Errorf("read HEAD: %w", err)542	}543	commit, err := repo.CommitObject(ref.Hash())544	if err != nil {545		return "", fmt.Errorf("read HEAD commit: %w", err)546	}547	tree, err := commit.Tree()548	if err != nil {549		return "", fmt.Errorf("read HEAD tree: %w", err)550	}551552	repoRoot := repoPath553	if wt, err := repo.Worktree(); err == nil {554		repoRoot = wt.Filesystem.Root()555	}556557	for _, candidate := range couplingTargetCandidates(repoRoot, target) {558		if _, err := tree.File(candidate); err == nil {559			return candidate, nil560		}561	}562	return "", couplingTargetMiss(tree, target)563}564565// runCouplingReport is the dispatch entry point called from Process() when566// --coupling is set. Walks history and writes the chosen format to stdout or567// FileOutput.568func runCouplingReport(repoPath string) error {569	// Resolve and validate the target before the walk — a bad path should fail570	// in milliseconds, not after a full history traversal.571	target := ""572	if CouplingFor != "" {573		resolved, err := resolveCouplingTarget(repoPath, CouplingFor)574		if err != nil {575			// resolveCouplingTarget's errors are context-neutral (they name the576			// path, not the flag). On the CLI we know the invocation came from577			// --coupling-for, so re-attach the flag name for the user.578			return fmt.Errorf("--coupling-for: %w", err)579		}580		target = resolved581	}582583	observer := newCouplingObserver()584	if _, err := runHistory(repoPath, observer); err != nil {585		return err586	}587	var out string588	var err error589	if target != "" {590		out, err = renderCouplingFor(observer, target)591	} else {592		out, err = renderCoupling(observer)593	}594	if err != nil {595		return err596	}597	if FileOutput == "" {598		fmt.Print(out)599	} else {600		if err := os.WriteFile(FileOutput, []byte(out), 0644); err != nil {601			return err602		}603		fmt.Println("results written to " + FileOutput)604	}605	return nil606}607608func renderCoupling(o *couplingObserver) (string, error) {609	switch strings.ToLower(Format) {610	case "", "tabular", "wide":611		return renderCouplingTabular(o), nil612	case "csv":613		return renderCouplingCSV(o)614	case "json":615		return renderCouplingJSON(o)616	default:617		return "", fmt.Errorf("unsupported --format %q for --coupling (supported: tabular, csv, json)", Format)618	}619}620621// %-27s %-26s %15s %8s622// 27 + 1 + 26 + 1 + 15 + 1 + 8 = 79623// Mirrors the --coupling-for view: plain "Shared Commits" / "Coupling" headers,624// no jargon and no explanatory footer. The per-file A/B commit counts drop from625// the table (they remain in the CSV / JSON output); dropping them also frees the626// width the two file paths need.627var tabularCouplingFormatHead = "%-27s %-26s %15s %8s\n"628var tabularCouplingFormatBody = "%-27s %-26s %15d %7.1f%%\n"629var tabularCouplingWeightedBody = "%-27s %-26s %15d %8.1f\n"630631// Wide tabular: same columns, both file paths widened to fill the 109-col rule.632// 42+1+41+1+15+1+8 = 109.633var tabularWideCouplingFormatHead = "%-42s %-41s %15s %8s\n"634var tabularWideCouplingFormatBody = "%-42s %-41s %15d %7.1f%%\n"635var tabularWideCouplingWeightedBody = "%-42s %-41s %15d %8.1f\n"636637func renderCouplingTabular(o *couplingObserver) string {638	wide := More || strings.EqualFold(Format, "wide")639	brk := tabularBreakFor(wide)640641	var sb strings.Builder642	sb.WriteString(historyHeader("Change Coupling", o.window, wide))643644	// Weighted mode ranks by degree × complexity and reports a normalised 0–100645	// Score in place of the raw Coupling %; the top (max-scoring) pair sits first646	// after the weighted sort, so it is the normalisation denominator.647	weighted := CouplingWeighted648	lastLabel := "Coupling"649	var maxScore float64650	if weighted {651		lastLabel = "Score"652		if len(o.pairs) > 0 {653			maxScore = o.pairs[0].WeightedScore()654		}655	}656657	headFmt := tabularCouplingFormatHead658	bodyFmt := tabularCouplingFormatBody659	aTrim, aWidth, bTrim, bWidth := 26, 27, 25, 26660	if wide {661		headFmt = tabularWideCouplingFormatHead662		bodyFmt = tabularWideCouplingFormatBody663		aTrim, aWidth, bTrim, bWidth = 41, 42, 40, 41664	}665	if weighted {666		bodyFmt = tabularCouplingWeightedBody667		if wide {668			bodyFmt = tabularWideCouplingWeightedBody669		}670	}671672	_, _ = fmt.Fprintf(&sb, headFmt,673		"File A", "File B", "Shared Commits", lastLabel)674	sb.WriteString(brk)675676	// The all-pairs view is a repo-wide overview, not a per-file answer: a flat677	// dump of every pair is unreadable, so the tabular form shows only the678	// strongest couplings. The full set is always available via --format csv/json679	// (e.g. to build a coupling graph). --coupling-for is the per-file drill-down.680	limit := min(len(o.pairs), couplingOverviewTopN)681	for _, p := range o.pairs[:limit] {682		aCol := unicodeAwareRightPad(unicodeAwareTrim(p.A, aTrim), aWidth)683		bCol := unicodeAwareRightPad(unicodeAwareTrim(p.B, bTrim), bWidth)684		if weighted {685			score := 0.0686			if maxScore > 0 {687				score = p.WeightedScore() / maxScore * 100.0688			}689			_, _ = fmt.Fprintf(&sb, bodyFmt, aCol, bCol, p.Shared, score)690		} else {691			_, _ = fmt.Fprintf(&sb, bodyFmt, aCol, bCol, p.Shared, p.Degree())692		}693	}694695	sb.WriteString(brk)696	if limit > 0 {697		var footer string698		suffix := ""699		if weighted {700			suffix = " · weighted by complexity"701		}702		if len(o.pairs) > limit {703			footer = fmt.Sprintf("top %d of %d pairs · sharing ≥%d commits%s", limit, len(o.pairs), CouplingMinShared, suffix)704		} else {705			footer = fmt.Sprintf("%d pairs · sharing ≥%d commits%s", len(o.pairs), CouplingMinShared, suffix)706		}707		sb.WriteString(footer)708		sb.WriteByte('\n')709		sb.WriteString(brk)710	} else {711		footer := "no file pairs met the coupling threshold"712		sb.WriteString(footer)713		sb.WriteByte('\n')714		sb.WriteString(brk)715	}716	return sb.String()717}718719func renderCouplingCSV(o *couplingObserver) (string, error) {720	var sb strings.Builder721	sb.WriteString(formatWindowComment(o.window))722	sb.WriteByte('\n')723724	w := csv.NewWriter(&sb)725	_ = w.Write([]string{"FileA", "FileB", "Shared", "CommitsA", "CommitsB", "Degree"})726	for _, p := range o.pairs {727		_ = w.Write([]string{728			p.A,729			p.B,730			fmt.Sprintf("%d", p.Shared),731			fmt.Sprintf("%d", p.CommitsA),732			fmt.Sprintf("%d", p.CommitsB),733			fmt.Sprintf("%.1f", p.Degree()),734		})735	}736	w.Flush()737	if err := w.Error(); err != nil {738		return "", err739	}740	return sb.String(), nil741}742743type couplingJSONPair struct {744	FileA    string  `json:"fileA"`745	FileB    string  `json:"fileB"`746	Shared   int     `json:"shared"`747	CommitsA int     `json:"commitsA"`748	CommitsB int     `json:"commitsB"`749	Degree   float64 `json:"degree"`750}751752type couplingJSONDoc struct {753	Report string             `json:"report"`754	Window hotspotsJSONWindow `json:"window"`755	Pairs  []couplingJSONPair `json:"pairs"`756}757758func renderCouplingJSON(o *couplingObserver) (string, error) {759	return renderCouplingJSONLimited(o, 0)760}761762func renderCouplingJSONLimited(o *couplingObserver, limit int) (string, error) {763	doc := couplingJSONDoc{764		Report: "coupling",765		Window: hotspotsJSONWindow{766			Depth:   o.window.Depth,767			Commits: o.window.Commits,768			From:    formatWindowDate(o.window.From),769			To:      formatWindowDate(o.window.To),770		},771		Pairs: make([]couplingJSONPair, 0, len(o.pairs)),772	}773	for _, p := range o.pairs {774		if limit > 0 && len(doc.Pairs) >= limit {775			break776		}777		doc.Pairs = append(doc.Pairs, couplingJSONPair{778			FileA:    p.A,779			FileB:    p.B,780			Shared:   p.Shared,781			CommitsA: p.CommitsA,782			CommitsB: p.CommitsB,783			Degree:   round1(p.Degree()),784		})785	}786	b, err := jsoniter.Marshal(doc)787	if err != nil {788		return "", err789	}790	return string(b), nil791}792793// --- file-oriented "blast radius" view ---------------------------------------794795func renderCouplingFor(o *couplingObserver, target string) (string, error) {796	switch strings.ToLower(Format) {797	case "", "tabular", "wide":798		return renderCouplingForTabular(o, target), nil799	case "csv":800		return renderCouplingForCSV(o, target)801	case "json":802		return renderCouplingForJSON(o, target)803	default:804		return "", fmt.Errorf("unsupported --format %q for --coupling-for (supported: tabular, csv, json)", Format)805	}806}807808// %-51s %16s %10s809// 51 + 1 + 16 + 1 + 10 = 79, matching the tabular break rule. The middle column810// is widened to spell out "Shared Commits" rather than a bare "Shared".811// The human view keeps three columns: the related file, how many commits812// touched both, and the symmetric coupling score it is ranked by. The813// directional Couple / Reverse ratios stay in the CSV and JSON output for tools814// that want them — on screen they were the source of the base-rate confusion.815var tabularCouplingForFormatHead = "%-51s %16s %10s\n"816var tabularCouplingForFormatBody = "%-51s %16d %9.1f%%\n"817var tabularCouplingForWeightedBody = "%-51s %16d %10.1f\n"818819// Wide tabular: same columns, Related File widened to fill the 109-col rule.820// 81 + 1 + 16 + 1 + 10 = 109.821var tabularWideCouplingForFormatHead = "%-81s %16s %10s\n"822var tabularWideCouplingForFormatBody = "%-81s %16d %9.1f%%\n"823var tabularWideCouplingForWeightedBody = "%-81s %16d %10.1f\n"824825func renderCouplingForTabular(o *couplingObserver, target string) string {826	wide := More || strings.EqualFold(Format, "wide")827	brk := tabularBreakFor(wide)828829	var sb strings.Builder830	// The columns speak for themselves, so no descriptive sentence sits between831	// the banner and the table. The target file name is carried by the thin-target832	// warning when it matters, and always by the CSV / JSON output.833	sb.WriteString(historyHeader("Change Coupling", o.window, wide))834835	partners := o.partnersFor(target)836837	if _, alive := o.head.Files[target]; !alive {838		sb.WriteString(fmt.Sprintf("%s is not in HEAD (deleted, ignored, or path typo)\n", target))839		sb.WriteString(brk)840		return sb.String()841	}842843	// A low target commit count makes the ratios coarse, but the Shared Commits844	// column already shows that directly, so the tabular view carries no extra845	// warning sentence. historyHeader already ends with a break, so the table846	// head follows straight on.847	// Weighted mode reports a normalised 0–100 Score (degree × min-complexity)848	// in place of the raw Coupling %; partnersFor has already ranked it first, so849	// the leading partner is the normalisation denominator.850	weighted := CouplingWeighted851	lastLabel := "Coupling"852	var maxScore float64853	if weighted {854		lastLabel = "Score"855		if len(partners) > 0 {856			maxScore = partners[0].WeightedScore()857		}858	}859860	headFmt, bodyFmt := tabularCouplingForFormatHead, tabularCouplingForFormatBody861	nameTrim, nameWidth := 50, 51862	if wide {863		headFmt, bodyFmt = tabularWideCouplingForFormatHead, tabularWideCouplingForFormatBody864		nameTrim, nameWidth = 80, 81865	}866	if weighted {867		bodyFmt = tabularCouplingForWeightedBody868		if wide {869			bodyFmt = tabularWideCouplingForWeightedBody870		}871	}872873	_, _ = fmt.Fprintf(&sb, headFmt, "Related File", "Shared Commits", lastLabel)874	sb.WriteString(brk)875876	for _, p := range partners {877		nameCol := unicodeAwareRightPad(unicodeAwareTrim(p.Path, nameTrim), nameWidth)878		if weighted {879			score := 0.0880			if maxScore > 0 {881				score = p.WeightedScore() / maxScore * 100.0882			}883			_, _ = fmt.Fprintf(&sb, bodyFmt, nameCol, p.Shared, score)884		} else {885			_, _ = fmt.Fprintf(&sb, bodyFmt, nameCol, p.Shared, p.Degree())886		}887	}888889	sb.WriteString(brk)890	if len(partners) > 0 {891		suffix := ""892		if weighted {893			suffix = " · weighted by complexity"894		}895		_, _ = fmt.Fprintf(&sb, "%d coupled files · pairs sharing ≥%d commits%s\n",896			len(partners), CouplingMinShared, suffix)897	} else {898		sb.WriteString("no file shares enough commits with this target to couple\n")899	}900	sb.WriteString(brk)901	return sb.String()902}903904func renderCouplingForCSV(o *couplingObserver, target string) (string, error) {905	var sb strings.Builder906	sb.WriteString(formatWindowComment(o.window))907	sb.WriteByte('\n')908909	w := csv.NewWriter(&sb)910	_ = w.Write([]string{"Target", "Partner", "Shared", "TargetCommits", "PartnerCommits", "Degree", "Couple", "Reverse"})911	for _, p := range o.partnersFor(target) {912		_ = w.Write([]string{913			target,914			p.Path,915			fmt.Sprintf("%d", p.Shared),916			fmt.Sprintf("%d", p.TargetCommit),917			fmt.Sprintf("%d", p.PartnerCommit),918			fmt.Sprintf("%.1f", p.Degree()),919			fmt.Sprintf("%.1f", p.Couple()),920			fmt.Sprintf("%.1f", p.Reverse()),921		})922	}923	w.Flush()924	if err := w.Error(); err != nil {925		return "", err926	}927	return sb.String(), nil928}929930type couplingForJSONPartner struct {931	File           string  `json:"file"`932	Shared         int     `json:"shared"`933	PartnerCommits int     `json:"partnerCommits"`934	Degree         float64 `json:"degree"`935	Couple         float64 `json:"couple"`936	Reverse        float64 `json:"reverse"`937}938939type couplingForJSONDoc struct {940	Report        string                   `json:"report"`941	Target        string                   `json:"target"`942	TargetCommits int                      `json:"targetCommits"`943	Window        hotspotsJSONWindow       `json:"window"`944	Partners      []couplingForJSONPartner `json:"partners"`945}946947func renderCouplingForJSON(o *couplingObserver, target string) (string, error) {948	return renderCouplingForJSONLimited(o, target, 0)949}950951func renderCouplingForJSONLimited(o *couplingObserver, target string, limit int) (string, error) {952	doc := couplingForJSONDoc{953		Report:        "coupling-for",954		Target:        target,955		TargetCommits: o.fc[target],956		Window: hotspotsJSONWindow{957			Depth:   o.window.Depth,958			Commits: o.window.Commits,959			From:    formatWindowDate(o.window.From),960			To:      formatWindowDate(o.window.To),961		},962		Partners: make([]couplingForJSONPartner, 0),963	}964	for _, p := range o.partnersFor(target) {965		if limit > 0 && len(doc.Partners) >= limit {966			break967		}968		doc.Partners = append(doc.Partners, couplingForJSONPartner{969			File:           p.Path,970			Shared:         p.Shared,971			PartnerCommits: p.PartnerCommit,972			Degree:         round1(p.Degree()),973			Couple:         round1(p.Couple()),974			Reverse:        round1(p.Reverse()),975		})976	}977	b, err := jsoniter.Marshal(doc)978	if err != nil {979		return "", err980	}981	return string(b), nil982}

Code quality findings 18

Blank identifier discarding results; verify intentional ignoring of return values
warning correctness blank-identifier-discard
_, _ = fmt.Fprintf(&sb, headFmt,
Blank identifier discarding results; verify intentional ignoring of return values
warning correctness blank-identifier-discard
_, _ = fmt.Fprintf(&sb, bodyFmt, aCol, bCol, p.Shared, score)
Blank identifier discarding results; verify intentional ignoring of return values
warning correctness blank-identifier-discard
_, _ = fmt.Fprintf(&sb, bodyFmt, aCol, bCol, p.Shared, p.Degree())
Blank identifier discarding results; verify intentional ignoring of return values
warning correctness blank-identifier-discard
_ = w.Write([]string{"FileA", "FileB", "Shared", "CommitsA", "CommitsB", "Degree"})
Blank identifier discarding results; verify intentional ignoring of return values
warning correctness blank-identifier-discard
_, _ = fmt.Fprintf(&sb, headFmt, "Related File", "Shared Commits", lastLabel)
Blank identifier discarding results; verify intentional ignoring of return values
warning correctness blank-identifier-discard
_, _ = fmt.Fprintf(&sb, bodyFmt, nameCol, p.Shared, score)
Blank identifier discarding results; verify intentional ignoring of return values
warning correctness blank-identifier-discard
_, _ = fmt.Fprintf(&sb, bodyFmt, nameCol, p.Shared, p.Degree())
Blank identifier discarding results; verify intentional ignoring of return values
warning correctness blank-identifier-discard
_, _ = fmt.Fprintf(&sb, "%d coupled files · pairs sharing ≥%d commits%s\n",
Blank identifier discarding results; verify intentional ignoring of return values
warning correctness blank-identifier-discard
_ = w.Write([]string{"Target", "Partner", "Shared", "TargetCommits", "PartnerCommits", "Degree", "Couple", "Reverse"})
Blank identifier discarding results; verify intentional ignoring of return values
warning correctness blank-identifier-discard
_ = w.Write([]string{
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 path, n := range o.fileCommits {
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 k, shared := range o.pairShared {
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 k, shared := range pairShared {
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 k, shared := range o.ps {
Can cause issues on Windows consider filepath.Join instead
info correctness path-join-windows
if rel, err := filepath.Rel(repoRoot, filepath.Join(cwd, target)); err == nil {
Unstructured output; use a structured logging library (e.g., slog, zap, zerolog, logrus)
info correctness fmt-println
fmt.Println("results written to " + FileOutput)
Multiple appends without pre-allocation; use make() with capacity when size is known
info performance append-without-prealloc
doc.Pairs = append(doc.Pairs, couplingJSONPair{
Multiple appends without pre-allocation; use make() with capacity when size is known
info performance append-without-prealloc
doc.Partners = append(doc.Partners, couplingForJSONPartner{

Get this view in your editor

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