processor/history_coupling.go GO 981 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	git "github.com/go-git/go-git/v5"16	"github.com/go-git/go-git/v5/plumbing"17	"github.com/go-git/go-git/v5/plumbing/object"18	jsoniter "github.com/json-iterator/go"19)2021// couplingOverviewTopN caps the rows in the tabular all-pairs (--coupling)22// overview. This is deliberately capped where the other history reports are not:23// the all-pairs view is a repo-wide glance, and a flat dump of every pair is24// unreadable. The per-file --coupling-for view and the CSV/JSON output stay25// uncapped — the full pair set is one --format away.26const couplingOverviewTopN = 152728// CouplingMinShared is the floor on co-change count for a pair to appear in29// any output. A pair that changed together only once is almost always a30// coincidence, not coupling, so the noise is dropped at the source. Raw counts31// below this are still accumulated; they're just not reported.32const CouplingMinShared = 23334// CouplingMaxFilesPerCommit is the default size cap: commits touching more than35// this many files are excluded from PAIR counting (each file still counts36// toward its own commit total). A commit touching hundreds of files is a sweep37// — initial import, vendored dump, gofmt, a license-header change — that38// carries no logical-coupling signal yet costs O(k²) pairs. 0 disables the cap.39const CouplingMaxFilesPerCommit = 304041// CouplingCount is the raw, unopinionated co-change record for one unordered42// file pair: how often each file changed across the window, and how often they43// changed in the same commit. scc emits these integers; any ratio a consumer44// wants — symmetric degree, or the directional P(B changes | A changed) that45// answers "blast radius" — is a division the consumer chooses, not scc.46type CouplingCount struct {47	A        string // lexicographically smaller surviving path48	B        string // lexicographically larger surviving path49	Shared   int    // commits in which BOTH changed50	CommitsA int    // commits in which A changed (window total)51	CommitsB int    // commits in which B changed (window total)5253	// HEAD complexity of each file (cyclomatic, or cognitive when the Cognitive54	// global is on), populated at Finalise. Only consumed by the weighted55	// ranking; zero for data/generated files that carry no complexity signal.56	ComplexityA int6457	ComplexityB int6458}5960// WeightedScore ranks a pair by co-change volume × the pair's smaller file61// complexity — the same shape --hotspots uses (complexity × commits), applied62// to a pair. Two effects fall out of it:63//64//   - min-complexity means a pair only scores when BOTH files are complex, so a65//     complex file coupled to a zero-complexity data/generated file (JSON, HTML,66//     Markdown) drops away — the churn-noise the raw view surfaces at the top.67//   - Shared (raw volume), not Degree, is the co-change term. Degree rewards a68//     thin 2-shared-commit pair that always moved together with a coincidental69//     100%; multiplied by high test-file complexity that buries the real work.70//     Volume keeps the heavyweight couplings on top and matches --hotspots.71func (c CouplingCount) WeightedScore() float64 {72	return float64(c.Shared) * float64(min(c.ComplexityA, c.ComplexityB))73}7475// lessCouplingRaw is the deterministic raw ordering: strongest absolute76// co-change first, then degree, then path. Shared by the raw sort and used as77// the tiebreak under weighted ranking.78func lessCouplingRaw(a, b CouplingCount) bool {79	if a.Shared != b.Shared {80		return a.Shared > b.Shared81	}82	if da, db := a.Degree(), b.Degree(); da != db {83		return da > db84	}85	if a.A != b.A {86		return a.A < b.A87	}88	return a.B < b.B89}9091// headComplexity returns a file's HEAD complexity used for weighting: cognitive92// when the Cognitive global is on (matching --hotspots), cyclomatic otherwise.93// A path absent from HEAD contributes zero, so it can never lift a pair's score.94func headComplexity(head HeadSnapshot, path string) int64 {95	hf, ok := head.Files[path]96	if !ok {97		return 098	}99	if Cognitive {100		return hf.Cognitive101	}102	return hf.Complexity103}104105// Degree is the symmetric coupling ratio shared/(a+b−shared) as a 0–100106// percentage — the standard temporal-coupling "degree". It is a convenience107// for the human-facing table only; the raw counts sit beside it so the number108// is never a black box. Returns 0 when the union is empty.109func (c CouplingCount) Degree() float64 {110	union := c.CommitsA + c.CommitsB - c.Shared111	if union <= 0 {112		return 0113	}114	return float64(c.Shared) / float64(union) * 100.0115}116117// couplingObserver accumulates temporal (change) coupling from the commit118// stream: which files keep changing together in the same commit. It implements119// only CommitObserver — coupling needs neither the start-tree baseline nor the120// mailmap, so it is the cheapest observer the history engine carries.121type couplingObserver struct {122	maxFilesPerCommit int123124	fileCommits map[string]int    // path -> commits touching it125	pairShared  map[pairKey]int   // unordered pair -> co-change count126	alias       map[string]string // renamed-from path -> renamed-to path127128	// Resolved-and-filtered state, materialised at Finalise. fc and ps have had129	// renames folded; head is the survivor set. Both the pair-list view and the130	// file-oriented "blast radius" query read from these.131	fc   map[string]int132	ps   map[pairKey]int133	head HeadSnapshot134135	window     HistoryWindow136	pairs      []CouplingCount // materialised at Finalise, strongest first137	totalPairs int             // pairs meeting the floor (for the footer)138	skipped    int             // commits dropped from pair counting by the cap139}140141type pairKey struct{ a, b string }142143func newCouplingObserver() *couplingObserver {144	return &couplingObserver{145		maxFilesPerCommit: CouplingMaxFilesPerCommit,146		fileCommits:       map[string]int{},147		pairShared:        map[pairKey]int{},148		alias:             map[string]string{},149	}150}151152func (o *couplingObserver) Observe(_ CommitInfo, changes []FileChange) {153	// Record renames so paths counted under an old name before the rename can be154	// folded into the new name at Finalise. Cheap to note here, resolved once at155	// the end rather than migrated eagerly per commit.156	for _, fc := range changes {157		if fc.FromPath != "" && fc.FromPath != fc.Path {158			o.alias[fc.FromPath] = fc.Path159		}160	}161162	// Distinct paths only — a rename can surface the same logical file twice.163	// FileChange already excludes deletes, binaries, submodules, ignored and164	// unclassifiable paths, so every Path here is a real counted source file.165	paths := make([]string, 0, len(changes))166	seen := make(map[string]struct{}, len(changes))167	for _, fc := range changes {168		if _, dup := seen[fc.Path]; dup {169			continue170		}171		seen[fc.Path] = struct{}{}172		paths = append(paths, fc.Path)173		o.fileCommits[fc.Path]++ // every file's own total — the ratio denominator174	}175176	if len(paths) < 2 {177		return // nothing can couple in a single-file commit178	}179	if o.maxFilesPerCommit > 0 && len(paths) > o.maxFilesPerCommit {180		o.skipped++181		return // totals already counted; skip the O(k²) pair explosion182	}183184	sort.Strings(paths) // canonical order so the pair key is stable: a < b185	for i := 0; i < len(paths); i++ {186		for j := i + 1; j < len(paths); j++ {187			o.pairShared[pairKey{paths[i], paths[j]}]++188		}189	}190}191192func (o *couplingObserver) Finalise(window HistoryWindow, head HeadSnapshot) {193	o.window = window194195	// Fold rename history: collapse every path to its final name, so a file that196	// lived under an old path before a rename shares one set of counts with its197	// current name. Done once here — O(total) — rather than migrated per commit.198	fileCommits := make(map[string]int, len(o.fileCommits))199	for path, n := range o.fileCommits {200		fileCommits[o.resolve(path)] += n201	}202	pairShared := make(map[pairKey]int, len(o.pairShared))203	for k, shared := range o.pairShared {204		a, b := o.resolve(k.a), o.resolve(k.b)205		if a == b {206			continue // both sides renamed to the same file — no longer a pair207		}208		if a > b {209			a, b = b, a210		}211		pairShared[pairKey{a, b}] += shared212	}213214	o.fc = fileCommits215	o.ps = pairShared216	o.head = head217218	pairs := make([]CouplingCount, 0, len(pairShared))219	for k, shared := range pairShared {220		if shared < CouplingMinShared {221			continue222		}223		// Keep only pairs whose BOTH files still exist in HEAD — same convention224		// as the rest of the engine, which never reports files that are gone.225		if _, ok := head.Files[k.a]; !ok {226			continue227		}228		if _, ok := head.Files[k.b]; !ok {229			continue230		}231		pairs = append(pairs, CouplingCount{232			A: k.a, B: k.b, Shared: shared,233			CommitsA:    fileCommits[k.a],234			CommitsB:    fileCommits[k.b],235			ComplexityA: headComplexity(head, k.a),236			ComplexityB: headComplexity(head, k.b),237		})238	}239240	if CouplingWeighted {241		// Weighted: co-change volume × min-complexity first, so pairs of complex242		// files outrank high-churn data/generated pairs. Ties fall back to the raw243		// ordering (shared, degree, path) for determinism.244		sort.Slice(pairs, func(i, j int) bool {245			wi, wj := pairs[i].WeightedScore(), pairs[j].WeightedScore()246			if wi != wj {247				return wi > wj248			}249			return lessCouplingRaw(pairs[i], pairs[j])250		})251	} else {252		// Strongest absolute co-change first, then strongest degree, then path so253		// the order is deterministic. Volume first surfaces the heavyweight254		// couplings; the Degree column lets the reader tell genuine coupling from255		// two busy files that merely co-change by chance.256		sort.Slice(pairs, func(i, j int) bool {257			return lessCouplingRaw(pairs[i], pairs[j])258		})259	}260261	o.pairs = pairs262	o.totalPairs = len(pairs)263}264265// resolve follows the rename chain from path to its final name. Guards against266// a pathological cycle in the alias map by capping the walk.267func (o *couplingObserver) resolve(path string) string {268	for i := 0; i < 64; i++ {269		next, ok := o.alias[path]270		if !ok || next == path {271			return path272		}273		path = next274	}275	return path276}277278// CouplingPartner is one file that co-changes with a chosen target file.279//280// Couple answers "if I change the target, how likely am I to touch this too".281// On its own it is confounded by the partner's base rate: a file that changes282// in most commits scores a near-perfect Couple against ANY target, purely283// because it is always there. Such a hub shows HIGH Couple and LOW Reverse —284// e.g. a target touched 3 times, always alongside a file touched 158 times,285// gives Couple 100% / Reverse 1.9%.286//287// Degree is the base-rate-corrected view and is what rows are ranked by; the288// two directional numbers are kept as supporting detail.289type CouplingPartner struct {290	Path          string291	Shared        int // commits changing BOTH target and this partner292	PartnerCommit int // partner's window commit total293	TargetCommit  int // target's window commit total294295	// HEAD complexity of the partner and the target, populated by partnersFor.296	// Only consumed by the weighted ranking.297	PartnerComplexity int64298	TargetComplexity  int64299}300301// WeightedScore mirrors the pairwise weighting for the blast-radius view:302// co-change volume × the smaller of the target's and partner's complexity, so a303// file's complex, frequently-co-changing neighbours outrank the trivial ones.304func (p CouplingPartner) WeightedScore() float64 {305	return float64(p.Shared) * float64(min(p.TargetComplexity, p.PartnerComplexity))306}307308// Couple is P(partner changes | target changed) = Shared / TargetCommit, the309// directional blast-radius probability: edit the target, expect to edit this.310func (p CouplingPartner) Couple() float64 {311	if p.TargetCommit <= 0 {312		return 0313	}314	return float64(p.Shared) / float64(p.TargetCommit) * 100.0315}316317// Reverse is P(target changes | partner changed) = Shared / PartnerCommit. A318// large gap between Reverse and Couple marks an asymmetric (hub-style) link319// rather than a true peer coupling.320func (p CouplingPartner) Reverse() float64 {321	if p.PartnerCommit <= 0 {322		return 0323	}324	return float64(p.Shared) / float64(p.PartnerCommit) * 100.0325}326327// Degree is the symmetric coupling ratio Shared/(target+partner−Shared) as a328// 0–100 percentage — the same measure the pairwise --coupling report ranks by,329// so the two views agree on what "strongly coupled" means.330//331// Unlike Couple it is not fooled by a busy partner: a hub present in every one332// of the target's commits still scores low here, because its own large commit333// total sits in the denominator.334func (p CouplingPartner) Degree() float64 {335	union := p.TargetCommit + p.PartnerCommit - p.Shared336	if union <= 0 {337		return 0338	}339	return float64(p.Shared) / float64(union) * 100.0340}341342// partnersFor returns every surviving file coupled to target, ranked by Degree343// descending. Returns nil when the target never changed in the window.344//345// Ranking is by Degree, not Couple: Couple alone puts every busy file at 100%346// (it was present for all of a rarely-touched target's commits by base rate347// alone), which buries the genuine peer couplings under hubs.348//349// target must be a current (HEAD) path: Finalise has already folded every350// pre-rename name into its final one, so the counts keyed here are complete,351// but an old path that no longer exists in HEAD will not match. Callers352// validate against HEAD via resolveCouplingTarget before the walk.353func (o *couplingObserver) partnersFor(target string) []CouplingPartner {354	tc := o.fc[target]355	if tc == 0 {356		return nil357	}358	out := make([]CouplingPartner, 0)359	for k, shared := range o.ps {360		if shared < CouplingMinShared {361			continue362		}363		var partner string364		switch target {365		case k.a:366			partner = k.b367		case k.b:368			partner = k.a369		default:370			continue371		}372		if _, ok := o.head.Files[partner]; !ok {373			continue374		}375		out = append(out, CouplingPartner{376			Path:              partner,377			Shared:            shared,378			PartnerCommit:     o.fc[partner],379			TargetCommit:      tc,380			PartnerComplexity: headComplexity(o.head, partner),381			TargetComplexity:  headComplexity(o.head, target),382		})383	}384	// Degree first (base-rate corrected), then raw co-change volume, then path so385	// the order is deterministic. Under weighted ranking, volume × min-complexity386	// leads and the raw ordering is the tiebreak.387	sort.Slice(out, func(i, j int) bool {388		if CouplingWeighted {389			wi, wj := out[i].WeightedScore(), out[j].WeightedScore()390			if wi != wj {391				return wi > wj392			}393		}394		di, dj := out[i].Degree(), out[j].Degree()395		if di != dj {396			return di > dj397		}398		if out[i].Shared != out[j].Shared {399			return out[i].Shared > out[j].Shared400		}401		return out[i].Path < out[j].Path402	})403	return out404}405406// CouplingForJSONReport walks history and returns the directional coupling for407// a single target file as JSON — the MCP entry point. limit > 0 caps the408// partner list (ranked by Degree, highest first); limit <= 0 returns every partner.409//410// target accepts the same forms as --coupling-for and is validated against HEAD411// before the walk, so a caller passing a bad path gets an immediate error rather412// than paying for a full traversal first.413func CouplingForJSONReport(repoPath, target string, limit int) (string, error) {414	resolved, err := resolveCouplingTarget(repoPath, target)415	if err != nil {416		return "", err417	}418	observer := newCouplingObserver()419	if _, err := runHistory(repoPath, observer); err != nil {420		return "", err421	}422	return renderCouplingForJSONLimited(observer, resolved, limit)423}424425// CouplingJSONReport walks the git history at repoPath and returns the coupling426// report as a JSON string — the programmatic entry point for the MCP server,427// which needs the rendered data rather than stdout side effects. A limit > 0428// caps the pair list (strongest first); limit <= 0 returns every pair.429func CouplingJSONReport(repoPath string, limit int) (string, error) {430	observer := newCouplingObserver()431	if _, err := runHistory(repoPath, observer); err != nil {432		return "", err433	}434	return renderCouplingJSONLimited(observer, limit)435}436437// couplingSuggestionLimit caps the "did you mean" candidates offered when a438// --coupling-for path misses.439const couplingSuggestionLimit = 5440441// errStopTreeWalk halts a tree walk once enough suggestions are collected.442// object.Tree.Files().ForEach surfaces whatever the callback returns, so it is443// compared back at the call site and discarded.444var errStopTreeWalk = errors.New("stop tree walk")445446// couplingTargetCandidates returns the repo-relative forms to try for a447// user-supplied --coupling-for path, most likely first. Git keys every path448// from the repository root with forward slashes and no "./" prefix, so the449// string a user naturally types ("./processor/x.go", an absolute path, or a450// path relative to a subdirectory they are standing in) rarely matches as-is.451func couplingTargetCandidates(repoRoot, target string) []string {452	var out []string453	seen := map[string]struct{}{}454	add := func(p string) {455		if p == "" {456			return457		}458		p = path.Clean(filepath.ToSlash(p))459		if p == "." || p == ".." || strings.HasPrefix(p, "../") {460			return // escapes the repository, or names no file461		}462		if _, dup := seen[p]; dup {463			return464		}465		seen[p] = struct{}{}466		out = append(out, p)467	}468469	if filepath.IsAbs(target) {470		// Absolute → relative to the repository root.471		if rel, err := filepath.Rel(repoRoot, target); err == nil {472			add(rel)473		}474		return out475	}476477	// As typed, cleaned. Handles both "processor/x.go" and "./processor/x.go".478	add(filepath.ToSlash(target))479480	// Relative to the working directory, for running scc from a subdirectory:481	// `cd processor && scc --coupling-for constants.go .` means processor/constants.go.482	if cwd, err := os.Getwd(); err == nil {483		if rel, err := filepath.Rel(repoRoot, filepath.Join(cwd, target)); err == nil {484			add(rel)485		}486	}487	return out488}489490// couplingTargetMiss builds the error for a target path that matched nothing in491// HEAD, offering same-basename files as suggestions. The message is context492// neutral — it names the path, not any CLI flag — so it reads correctly whether493// surfaced through the CLI (which re-wraps it with the flag name at its call494// site) or an MCP client (which passed a `file` argument and has never seen the495// flag). Only ever runs on the failure path, so walking the tree here costs496// nothing in the happy case.497func couplingTargetMiss(tree *object.Tree, target string) error {498	base := path.Base(path.Clean(filepath.ToSlash(target)))499	var matches []string500	err := tree.Files().ForEach(func(f *object.File) error {501		if path.Base(f.Name) != base {502			return nil503		}504		matches = append(matches, f.Name)505		if len(matches) >= couplingSuggestionLimit {506			return errStopTreeWalk507		}508		return nil509	})510	if err != nil && !errors.Is(err, errStopTreeWalk) {511		return fmt.Errorf("target %q is not in HEAD (deleted, ignored, or path typo)", target)512	}513	if len(matches) > 0 {514		return fmt.Errorf("target %q is not in HEAD; did you mean:\n  %s",515			target, strings.Join(matches, "\n  "))516	}517	return fmt.Errorf("target %q is not in HEAD (deleted, ignored, or path typo)", target)518}519520// resolveCouplingTarget maps a user-supplied --coupling-for path onto the521// git-style path the history engine keys on, verifying it against HEAD *before*522// the caller pays for a full history walk. A typo previously cost a complete523// walk (seconds to minutes) before reporting the miss.524//525// A repository with no HEAD (freshly initialised, no commits) is not an error526// here: the path is normalised and the walk reports the empty window as usual.527func resolveCouplingTarget(repoPath, target string) (string, error) {528	fallback := path.Clean(filepath.ToSlash(target))529530	repo, err := git.PlainOpenWithOptions(repoPath, &git.PlainOpenOptions{DetectDotGit: true})531	if err != nil {532		return "", fmt.Errorf("open git repository: %w", err)533	}534	ref, err := repo.Head()535	if err != nil {536		if errors.Is(err, plumbing.ErrReferenceNotFound) {537			return fallback, nil // empty repo — let the walk report the empty window538		}539		return "", fmt.Errorf("read HEAD: %w", err)540	}541	commit, err := repo.CommitObject(ref.Hash())542	if err != nil {543		return "", fmt.Errorf("read HEAD commit: %w", err)544	}545	tree, err := commit.Tree()546	if err != nil {547		return "", fmt.Errorf("read HEAD tree: %w", err)548	}549550	repoRoot := repoPath551	if wt, err := repo.Worktree(); err == nil {552		repoRoot = wt.Filesystem.Root()553	}554555	for _, candidate := range couplingTargetCandidates(repoRoot, target) {556		if _, err := tree.File(candidate); err == nil {557			return candidate, nil558		}559	}560	return "", couplingTargetMiss(tree, target)561}562563// runCouplingReport is the dispatch entry point called from Process() when564// --coupling is set. Walks history and writes the chosen format to stdout or565// FileOutput.566func runCouplingReport(repoPath string) error {567	// Resolve and validate the target before the walk — a bad path should fail568	// in milliseconds, not after a full history traversal.569	target := ""570	if CouplingFor != "" {571		resolved, err := resolveCouplingTarget(repoPath, CouplingFor)572		if err != nil {573			// resolveCouplingTarget's errors are context-neutral (they name the574			// path, not the flag). On the CLI we know the invocation came from575			// --coupling-for, so re-attach the flag name for the user.576			return fmt.Errorf("--coupling-for: %w", err)577		}578		target = resolved579	}580581	observer := newCouplingObserver()582	if _, err := runHistory(repoPath, observer); err != nil {583		return err584	}585	var out string586	var err error587	if target != "" {588		out, err = renderCouplingFor(observer, target)589	} else {590		out, err = renderCoupling(observer)591	}592	if err != nil {593		return err594	}595	if FileOutput == "" {596		fmt.Print(out)597	} else {598		if err := os.WriteFile(FileOutput, []byte(out), 0644); err != nil {599			return err600		}601		fmt.Println("results written to " + FileOutput)602	}603	return nil604}605606func renderCoupling(o *couplingObserver) (string, error) {607	switch strings.ToLower(Format) {608	case "", "tabular", "wide":609		return renderCouplingTabular(o), nil610	case "csv":611		return renderCouplingCSV(o)612	case "json":613		return renderCouplingJSON(o)614	default:615		return "", fmt.Errorf("unsupported --format %q for --coupling (supported: tabular, csv, json)", Format)616	}617}618619// %-27s %-26s %15s %8s620// 27 + 1 + 26 + 1 + 15 + 1 + 8 = 79621// Mirrors the --coupling-for view: plain "Shared Commits" / "Coupling" headers,622// no jargon and no explanatory footer. The per-file A/B commit counts drop from623// the table (they remain in the CSV / JSON output); dropping them also frees the624// width the two file paths need.625var tabularCouplingFormatHead = "%-27s %-26s %15s %8s\n"626var tabularCouplingFormatBody = "%-27s %-26s %15d %7.1f%%\n"627var tabularCouplingWeightedBody = "%-27s %-26s %15d %8.1f\n"628629// Wide tabular: same columns, both file paths widened to fill the 109-col rule.630// 42+1+41+1+15+1+8 = 109.631var tabularWideCouplingFormatHead = "%-42s %-41s %15s %8s\n"632var tabularWideCouplingFormatBody = "%-42s %-41s %15d %7.1f%%\n"633var tabularWideCouplingWeightedBody = "%-42s %-41s %15d %8.1f\n"634635func renderCouplingTabular(o *couplingObserver) string {636	wide := More || strings.EqualFold(Format, "wide")637	brk := tabularBreakFor(wide)638639	var sb strings.Builder640	sb.WriteString(historyHeader("Change Coupling", o.window, wide))641642	// Weighted mode ranks by degree × complexity and reports a normalised 0–100643	// Score in place of the raw Coupling %; the top (max-scoring) pair sits first644	// after the weighted sort, so it is the normalisation denominator.645	weighted := CouplingWeighted646	lastLabel := "Coupling"647	var maxScore float64648	if weighted {649		lastLabel = "Score"650		if len(o.pairs) > 0 {651			maxScore = o.pairs[0].WeightedScore()652		}653	}654655	headFmt := tabularCouplingFormatHead656	bodyFmt := tabularCouplingFormatBody657	aTrim, aWidth, bTrim, bWidth := 26, 27, 25, 26658	if wide {659		headFmt = tabularWideCouplingFormatHead660		bodyFmt = tabularWideCouplingFormatBody661		aTrim, aWidth, bTrim, bWidth = 41, 42, 40, 41662	}663	if weighted {664		bodyFmt = tabularCouplingWeightedBody665		if wide {666			bodyFmt = tabularWideCouplingWeightedBody667		}668	}669670	_, _ = fmt.Fprintf(&sb, headFmt,671		"File A", "File B", "Shared Commits", lastLabel)672	sb.WriteString(brk)673674	// The all-pairs view is a repo-wide overview, not a per-file answer: a flat675	// dump of every pair is unreadable, so the tabular form shows only the676	// strongest couplings. The full set is always available via --format csv/json677	// (e.g. to build a coupling graph). --coupling-for is the per-file drill-down.678	limit := min(len(o.pairs), couplingOverviewTopN)679	for _, p := range o.pairs[:limit] {680		aCol := unicodeAwareRightPad(unicodeAwareTrim(p.A, aTrim), aWidth)681		bCol := unicodeAwareRightPad(unicodeAwareTrim(p.B, bTrim), bWidth)682		if weighted {683			score := 0.0684			if maxScore > 0 {685				score = p.WeightedScore() / maxScore * 100.0686			}687			_, _ = fmt.Fprintf(&sb, bodyFmt, aCol, bCol, p.Shared, score)688		} else {689			_, _ = fmt.Fprintf(&sb, bodyFmt, aCol, bCol, p.Shared, p.Degree())690		}691	}692693	sb.WriteString(brk)694	if limit > 0 {695		var footer string696		suffix := ""697		if weighted {698			suffix = " · weighted by complexity"699		}700		if len(o.pairs) > limit {701			footer = fmt.Sprintf("top %d of %d pairs · sharing ≥%d commits%s", limit, len(o.pairs), CouplingMinShared, suffix)702		} else {703			footer = fmt.Sprintf("%d pairs · sharing ≥%d commits%s", len(o.pairs), CouplingMinShared, suffix)704		}705		sb.WriteString(footer)706		sb.WriteByte('\n')707		sb.WriteString(brk)708	} else {709		footer := "no file pairs met the coupling threshold"710		sb.WriteString(footer)711		sb.WriteByte('\n')712		sb.WriteString(brk)713	}714	return sb.String()715}716717func renderCouplingCSV(o *couplingObserver) (string, error) {718	var sb strings.Builder719	sb.WriteString(formatWindowComment(o.window))720	sb.WriteByte('\n')721722	w := csv.NewWriter(&sb)723	_ = w.Write([]string{"FileA", "FileB", "Shared", "CommitsA", "CommitsB", "Degree"})724	for _, p := range o.pairs {725		_ = w.Write([]string{726			p.A,727			p.B,728			fmt.Sprintf("%d", p.Shared),729			fmt.Sprintf("%d", p.CommitsA),730			fmt.Sprintf("%d", p.CommitsB),731			fmt.Sprintf("%.1f", p.Degree()),732		})733	}734	w.Flush()735	if err := w.Error(); err != nil {736		return "", err737	}738	return sb.String(), nil739}740741type couplingJSONPair struct {742	FileA    string  `json:"fileA"`743	FileB    string  `json:"fileB"`744	Shared   int     `json:"shared"`745	CommitsA int     `json:"commitsA"`746	CommitsB int     `json:"commitsB"`747	Degree   float64 `json:"degree"`748}749750type couplingJSONDoc struct {751	Report string             `json:"report"`752	Window hotspotsJSONWindow `json:"window"`753	Pairs  []couplingJSONPair `json:"pairs"`754}755756func renderCouplingJSON(o *couplingObserver) (string, error) {757	return renderCouplingJSONLimited(o, 0)758}759760func renderCouplingJSONLimited(o *couplingObserver, limit int) (string, error) {761	doc := couplingJSONDoc{762		Report: "coupling",763		Window: hotspotsJSONWindow{764			Depth:   o.window.Depth,765			Commits: o.window.Commits,766			From:    formatWindowDate(o.window.From),767			To:      formatWindowDate(o.window.To),768		},769		Pairs: make([]couplingJSONPair, 0, len(o.pairs)),770	}771	for _, p := range o.pairs {772		if limit > 0 && len(doc.Pairs) >= limit {773			break774		}775		doc.Pairs = append(doc.Pairs, couplingJSONPair{776			FileA:    p.A,777			FileB:    p.B,778			Shared:   p.Shared,779			CommitsA: p.CommitsA,780			CommitsB: p.CommitsB,781			Degree:   round1(p.Degree()),782		})783	}784	b, err := jsoniter.Marshal(doc)785	if err != nil {786		return "", err787	}788	return string(b), nil789}790791// --- file-oriented "blast radius" view ---------------------------------------792793func renderCouplingFor(o *couplingObserver, target string) (string, error) {794	switch strings.ToLower(Format) {795	case "", "tabular", "wide":796		return renderCouplingForTabular(o, target), nil797	case "csv":798		return renderCouplingForCSV(o, target)799	case "json":800		return renderCouplingForJSON(o, target)801	default:802		return "", fmt.Errorf("unsupported --format %q for --coupling-for (supported: tabular, csv, json)", Format)803	}804}805806// %-51s %16s %10s807// 51 + 1 + 16 + 1 + 10 = 79, matching the tabular break rule. The middle column808// is widened to spell out "Shared Commits" rather than a bare "Shared".809// The human view keeps three columns: the related file, how many commits810// touched both, and the symmetric coupling score it is ranked by. The811// directional Couple / Reverse ratios stay in the CSV and JSON output for tools812// that want them — on screen they were the source of the base-rate confusion.813var tabularCouplingForFormatHead = "%-51s %16s %10s\n"814var tabularCouplingForFormatBody = "%-51s %16d %9.1f%%\n"815var tabularCouplingForWeightedBody = "%-51s %16d %10.1f\n"816817// Wide tabular: same columns, Related File widened to fill the 109-col rule.818// 81 + 1 + 16 + 1 + 10 = 109.819var tabularWideCouplingForFormatHead = "%-81s %16s %10s\n"820var tabularWideCouplingForFormatBody = "%-81s %16d %9.1f%%\n"821var tabularWideCouplingForWeightedBody = "%-81s %16d %10.1f\n"822823func renderCouplingForTabular(o *couplingObserver, target string) string {824	wide := More || strings.EqualFold(Format, "wide")825	brk := tabularBreakFor(wide)826827	var sb strings.Builder828	// The columns speak for themselves, so no descriptive sentence sits between829	// the banner and the table. The target file name is carried by the thin-target830	// warning when it matters, and always by the CSV / JSON output.831	sb.WriteString(historyHeader("Change Coupling", o.window, wide))832833	partners := o.partnersFor(target)834835	if _, alive := o.head.Files[target]; !alive {836		sb.WriteString(fmt.Sprintf("%s is not in HEAD (deleted, ignored, or path typo)\n", target))837		sb.WriteString(brk)838		return sb.String()839	}840841	// A low target commit count makes the ratios coarse, but the Shared Commits842	// column already shows that directly, so the tabular view carries no extra843	// warning sentence. historyHeader already ends with a break, so the table844	// head follows straight on.845	// Weighted mode reports a normalised 0–100 Score (degree × min-complexity)846	// in place of the raw Coupling %; partnersFor has already ranked it first, so847	// the leading partner is the normalisation denominator.848	weighted := CouplingWeighted849	lastLabel := "Coupling"850	var maxScore float64851	if weighted {852		lastLabel = "Score"853		if len(partners) > 0 {854			maxScore = partners[0].WeightedScore()855		}856	}857858	headFmt, bodyFmt := tabularCouplingForFormatHead, tabularCouplingForFormatBody859	nameTrim, nameWidth := 50, 51860	if wide {861		headFmt, bodyFmt = tabularWideCouplingForFormatHead, tabularWideCouplingForFormatBody862		nameTrim, nameWidth = 80, 81863	}864	if weighted {865		bodyFmt = tabularCouplingForWeightedBody866		if wide {867			bodyFmt = tabularWideCouplingForWeightedBody868		}869	}870871	_, _ = fmt.Fprintf(&sb, headFmt, "Related File", "Shared Commits", lastLabel)872	sb.WriteString(brk)873874	for _, p := range partners {875		nameCol := unicodeAwareRightPad(unicodeAwareTrim(p.Path, nameTrim), nameWidth)876		if weighted {877			score := 0.0878			if maxScore > 0 {879				score = p.WeightedScore() / maxScore * 100.0880			}881			_, _ = fmt.Fprintf(&sb, bodyFmt, nameCol, p.Shared, score)882		} else {883			_, _ = fmt.Fprintf(&sb, bodyFmt, nameCol, p.Shared, p.Degree())884		}885	}886887	sb.WriteString(brk)888	if len(partners) > 0 {889		suffix := ""890		if weighted {891			suffix = " · weighted by complexity"892		}893		_, _ = fmt.Fprintf(&sb, "%d coupled files · pairs sharing ≥%d commits%s\n",894			len(partners), CouplingMinShared, suffix)895	} else {896		sb.WriteString("no file shares enough commits with this target to couple\n")897	}898	sb.WriteString(brk)899	return sb.String()900}901902func renderCouplingForCSV(o *couplingObserver, target string) (string, error) {903	var sb strings.Builder904	sb.WriteString(formatWindowComment(o.window))905	sb.WriteByte('\n')906907	w := csv.NewWriter(&sb)908	_ = w.Write([]string{"Target", "Partner", "Shared", "TargetCommits", "PartnerCommits", "Degree", "Couple", "Reverse"})909	for _, p := range o.partnersFor(target) {910		_ = w.Write([]string{911			target,912			p.Path,913			fmt.Sprintf("%d", p.Shared),914			fmt.Sprintf("%d", p.TargetCommit),915			fmt.Sprintf("%d", p.PartnerCommit),916			fmt.Sprintf("%.1f", p.Degree()),917			fmt.Sprintf("%.1f", p.Couple()),918			fmt.Sprintf("%.1f", p.Reverse()),919		})920	}921	w.Flush()922	if err := w.Error(); err != nil {923		return "", err924	}925	return sb.String(), nil926}927928type couplingForJSONPartner struct {929	File           string  `json:"file"`930	Shared         int     `json:"shared"`931	PartnerCommits int     `json:"partnerCommits"`932	Degree         float64 `json:"degree"`933	Couple         float64 `json:"couple"`934	Reverse        float64 `json:"reverse"`935}936937type couplingForJSONDoc struct {938	Report        string                   `json:"report"`939	Target        string                   `json:"target"`940	TargetCommits int                      `json:"targetCommits"`941	Window        hotspotsJSONWindow       `json:"window"`942	Partners      []couplingForJSONPartner `json:"partners"`943}944945func renderCouplingForJSON(o *couplingObserver, target string) (string, error) {946	return renderCouplingForJSONLimited(o, target, 0)947}948949func renderCouplingForJSONLimited(o *couplingObserver, target string, limit int) (string, error) {950	doc := couplingForJSONDoc{951		Report:        "coupling-for",952		Target:        target,953		TargetCommits: o.fc[target],954		Window: hotspotsJSONWindow{955			Depth:   o.window.Depth,956			Commits: o.window.Commits,957			From:    formatWindowDate(o.window.From),958			To:      formatWindowDate(o.window.To),959		},960		Partners: make([]couplingForJSONPartner, 0),961	}962	for _, p := range o.partnersFor(target) {963		if limit > 0 && len(doc.Partners) >= limit {964			break965		}966		doc.Partners = append(doc.Partners, couplingForJSONPartner{967			File:           p.Path,968			Shared:         p.Shared,969			PartnerCommits: p.PartnerCommit,970			Degree:         round1(p.Degree()),971			Couple:         round1(p.Couple()),972			Reverse:        round1(p.Reverse()),973		})974	}975	b, err := jsoniter.Marshal(doc)976	if err != nil {977		return "", err978	}979	return string(b), nil980}

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.