Blank identifier discarding results; verify intentional ignoring of return values
_, _ = fmt.Fprintf(&sb, headFmt,
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}
Same data, no extra tab — call code_get_file + code_get_findings over MCP from Claude/Cursor/Copilot.