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 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}
Same data, no extra tab — call code_get_file + code_get_findings over MCP from Claude/Cursor/Copilot.