Skip to content

Commit 079f7b3

Browse files
boyterCopilotclaudeCopilot
authored
add change-coupling report (--coupling / --coupling-for) (#733)
* trying things * tweaking coupling * clean up git display outputs * tweak output * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix: gofmt mcp.go coupling limit description Re-indent the mcp.WithNumber("limit") description line that was left under-indented, restoring gofmt compliance. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * tweak to ensure no leaks of cli to mcp * coupling --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent f4c46b5 commit 079f7b3

11 files changed

Lines changed: 1637 additions & 55 deletions

README.md

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,8 @@ Flags:
314314
--count-as string count extension as language [e.g. jsp:htm,chead:"C Header" maps extension jsp to html and chead to C Header]
315315
--count-as-pattern stringArray count files matching a path pattern as a new named category backed by a base language [repeatable; pattern is glob by default, prefix with re: for regex; e.g. *_spec.rb:"Ruby Spec":Ruby or re:\.test\.js$:"JavaScript Tests":JavaScript]
316316
--count-ignore set to allow .gitignore and .ignore files to be counted
317+
--coupling render the change-coupling report (file pairs that change together over recent git history)
318+
--coupling-for string blast-radius view: given a file path, show what tends to change with it over recent git history
317319
--currency-symbol string set currency symbol (default "$")
318320
--debug enable debug output
319321
--depth int commit window size for git history reports; 0 means entire history (large repos may be slow) (default 1000)
@@ -835,7 +837,7 @@ LOCOMO is a rough estimator with known limitations:
835837

836838
### Git Insight Reports
837839

838-
In addition to counting the working tree, `scc` can run four git-aware reports over recent commit history. Each is selected by a flag and rendered as `tabular` (default), `csv`, or `json` via `--format`. All four are derived from one in-process walk of the repository - there is no `exec("git")`, so the `git` binary does not need to be on `PATH`.
840+
In addition to counting the working tree, `scc` can run five git-aware reports over recent commit history. Each is selected by a flag and rendered as `tabular` (default), `csv`, or `json` via `--format`. All are derived from one in-process walk of the repository - there is no `exec("git")`, so the `git` binary does not need to be on `PATH`.
839841

840842
> **Note:** these reports are **slower** than a normal `scc` run. They walk the repository history (one diff per commit using pure-Go Myers diff via [go-git](https://github.com/go-git/go-git)) instead of just counting the current working tree. Runtime scales with `--depth` (the commit window size, default `1000`; `0` means entire history). On large repositories with deep history, expect runtimes measured in seconds to minutes rather than the millisecond-scale you get from a plain `scc` run. Use `--depth` to bound the window.
841843
@@ -844,6 +846,7 @@ When no report flag is set, `scc` behaves exactly as today, these flags are stri
844846
| Flag | Report | Answers |
845847
|---|---|---|
846848
| `--hotspots` | Hotspots | Which files are defect-prone - high complexity × high churn. |
849+
| `--coupling` / `--coupling-for FILE` | Change coupling | Which files change together - hidden dependencies and blast radius. |
847850
| `--by-author` | Author rollup | Bus factor - who last-touched the surviving code. |
848851
| `--by-author --timeline` | Author timeline | How each author's activity rises and falls over time. |
849852
| `--timeline` | Languages over time | How the language mix shifts - rewrites, migrations. |
@@ -857,7 +860,7 @@ Shared flags for these reports:
857860
| `-w, --wide` | - | 109-column variant of any report (extra columns where applicable). |
858861
| `--no-fold-authors` | off | Disable the name + email-domain identity folding fallback applied after `.mailmap`. |
859862

860-
`--hotspots` is mutually exclusive with `--by-author` / `--timeline`; combining them is an error. With `--by-author` set, `--timeline` switches from the author rollup to the author timeline. Alone, `--timeline` renders the languages timeline.
863+
Each report is standalone: `--hotspots`, `--coupling` (or `--coupling-for`), and `--by-author` / `--timeline` are mutually exclusive, and combining them is an error. `--coupling-for FILE` implies `--coupling`. With `--by-author` set, `--timeline` switches from the author rollup to the author timeline. Alone, `--timeline` renders the languages timeline.
861864

862865
#### Hotspots - `--hotspots`
863866

@@ -881,6 +884,46 @@ main.go Go 180 44 2,510 8 48.2
881884

882885
Tabular output shows the top files (≈20). `--wide` adds a hotspot bar and an added-lines code-vs-comment split (`+Code%`). `--format csv|json` emits every file with a positive score along with the full per-file detail and window metadata.
883886

887+
#### Change coupling - `--coupling` / `--coupling-for`
888+
889+
Which files tend to change together in the same commit I.E. *temporal* coupling.
890+
Surfaces the "if you edit A you probably need to edit B" relationships. `Shared Commits` is how
891+
many commits touched both files; `Coupling` is the share of commits touching *either* file that
892+
touched *both*, so 100% means they never move apart. Pairs are ranked strongest first,
893+
and only pairs sharing at least 2 commits are reported.
894+
895+
```text
896+
$ scc --coupling
897+
───────────────────────────────────────────────────────────────────────────────
898+
Change Coupling · last 1000 commits · 2019-07-24 → 2026-07-20
899+
───────────────────────────────────────────────────────────────────────────────
900+
File A File B Shared Commits Coupling
901+
───────────────────────────────────────────────────────────────────────────────
902+
languages.json processor/constants.go 196 67.6%
903+
LANGUAGES.md languages.json 166 61.3%
904+
LANGUAGES.md processor/constants.go 152 58.2%
905+
main.go processor/processor.go 65 33.9%
906+
───────────────────────────────────────────────────────────────────────────────
907+
top 15 of 492 pairs · sharing ≥2 commits
908+
───────────────────────────────────────────────────────────────────────────────
909+
```
910+
911+
`--coupling-for FILE` narrows to a single file's and what history says tends to move with it.
912+
913+
```text
914+
$ scc --coupling-for processor/workers.go
915+
───────────────────────────────────────────────────────────────────────────────
916+
Change Coupling · last 1000 commits · 2019-07-24 → 2026-07-20
917+
───────────────────────────────────────────────────────────────────────────────
918+
Related File Shared Commits Coupling
919+
───────────────────────────────────────────────────────────────────────────────
920+
processor/structs.go 27 25.2%
921+
processor/workers_test.go 17 17.7%
922+
processor/processor.go 34 15.7%
923+
main.go 23 13.7%
924+
───────────────────────────────────────────────────────────────────────────────
925+
```
926+
884927
#### Author rollup - `--by-author`
885928

886929
Bus factor and last-toucher attribution. Lines untouched in the window collect under the sentinel `(before window)` so percentages reconcile to 100%.
@@ -1591,7 +1634,7 @@ Add to your `claude_desktop_config.json`:
15911634

15921635
#### Exposed Tools
15931636

1594-
The MCP server exposes two tools:
1637+
The MCP server exposes three tools:
15951638

15961639
**`analyze`** - Count lines of code, comments, blanks and estimate complexity for a project directory or file.
15971640

@@ -1619,6 +1662,17 @@ Results are returned as JSON with per-language breakdown (files, lines, code, co
16191662

16201663
Results are returned as JSON with the history window walked (depth, commit count, date range) and a per-file list (file, language, complexity, commits, lines changed, authors, code/comment churn, and a normalised 0–100 score). Requires `path` to be inside a git repository.
16211664

1665+
**`coupling`** - Report change coupling from a git repository's history: files that historically change together. Has two modes, selected by whether `file` is set. With `file`, returns that file's blast radius - the other files that change together with it. Without `file`, returns the repo-wide all-pairs overview.
1666+
1667+
| Parameter | Type | Required | Description |
1668+
|---|---|---|---|
1669+
| `path` | string | no | Directory inside the git repository to analyze. Defaults to current directory. |
1670+
| `file` | string | no | Target file (as it appears at HEAD). Set it for the per-file blast-radius view; omit it for the repo-wide all-pairs report. |
1671+
| `depth` | number | no | Maximum number of recent commits to walk. Default: `1000`. Set to `0` for unlimited. |
1672+
| `limit` | number | no | Maximum rows to return, strongest first - coupled files in per-file mode, file pairs in all-pairs mode. Default: `50`. Set to `-1` for unlimited. |
1673+
1674+
Results are returned as JSON with the history window walked. With `file`, each partner carries its shared-commit count and the directional probabilities `couple` (given you changed the target, how often the partner follows) and `reverse` (the other direction). Without `file`, each pair carries its shared-commit count and symmetric coupling degree. Requires `path` to be inside a git repository.
1675+
16221676
### Adding/Modifying Languages
16231677

16241678
To add or modify a language you will need to edit the `languages.json` file in the root of the project, and then run `go generate` to build it into the application. You can then `go install` or `go build` as normal to produce the binary with your modifications.

config.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,8 @@ func registerFlags(flags *pflag.FlagSet, b *flagBindings) {
454454
flags.Float64Var(floatVar(&processor.LocomoTPS), "locomo-tps", 0, "LOCOMO output tokens per second (overrides preset)")
455455
flags.Float64Var(floatVar(&processor.LocomoCyclesOverride), "locomo-cycles", 0, "override estimated LLM iteration cycles (default: calculated from complexity)")
456456
flags.BoolVar(boolVar(&processor.Hotspots), "hotspots", false, "render the hotspots report (files ranked by complexity × change frequency over recent git history)")
457+
flags.BoolVar(boolVar(&processor.Coupling), "coupling", false, "render the change-coupling report (file pairs that change together over recent git history)")
458+
flags.StringVar(strVar(&processor.CouplingFor), "coupling-for", "", "blast-radius view: given a file path, show what tends to change with it over recent git history")
457459
flags.BoolVar(boolVar(&processor.ByAuthor), "by-author", false, "render the author rollup report (bus factor and last-toucher attribution over recent git history)")
458460
flags.IntVar(intVar(&processor.HistoryDepth), "depth", 1000, "commit window size for git history reports; 0 means entire history (large repos may be slow)")
459461
flags.BoolVar(boolVar(&processor.Timeline), "timeline", false, "render an over-time view of recent git history; with --by-author runs the author timeline, alone runs the languages timeline")

mcp.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,41 @@ Also returns the history window walked (depth, commit count, date range). Requir
110110

111111
mcpServer.AddTool(hotspotsTool, mcpHotspotsHandler)
112112

113+
couplingTool := mcp.NewTool("coupling",
114+
mcp.WithDescription(`Return change-coupling from a git repository's history — files that historically change together. Has two modes, selected by whether the 'file' argument is set.
115+
116+
WITH 'file' — the target file's "blast radius": the other files that change together with it. Use this before editing a file to discover what else you will likely need to read or change. For the target, returns each coupled file with:
117+
- file: path relative to the repo root
118+
- shared: number of commits that changed BOTH the target and this file
119+
- partnerCommits: this file's own commit count in the window
120+
- couple: P(this file changes | you changed the target), 0–100 — the blast-radius probability
121+
- reverse: P(target changes | this file changed), 0–100 — a large gap from couple marks a hub-style (asymmetric) link rather than a true peer coupling
122+
Partners are ranked by degree (highest first; base-rate corrected). Also returns the target's own commit count (targetCommits) and the history window walked.
123+
124+
WITHOUT 'file' — the repo-wide all-pairs overview: every file pair that changes together, strongest first. For each pair, returns:
125+
- fileA, fileB: the two coupled paths, relative to the repo root
126+
- shared: number of commits that changed BOTH files
127+
- commitsA, commitsB: each file's own commit count in the window
128+
- degree: symmetric coupling ratio shared/(commitsA+commitsB−shared), 0–100
129+
Pairs are ranked strongest first (most shared commits). Also returns the history window walked.
130+
131+
Requires path to be inside a git repository.`),
132+
mcp.WithString("file",
133+
mcp.Description("Optional target file path (relative to the repo, as it appears at HEAD). Set it for the per-file blast-radius view; omit it for the repo-wide all-pairs report."),
134+
),
135+
mcp.WithString("path",
136+
mcp.Description("Directory inside the git repository to analyze. Defaults to current directory."),
137+
),
138+
mcp.WithNumber("depth",
139+
mcp.Description("Maximum number of recent commits to walk. Defaults to 1000. Set to 0 for unlimited (slower on large repos)."),
140+
),
141+
mcp.WithNumber("limit",
142+
mcp.Description("Maximum rows to return, strongest first — coupled files in per-file mode, file pairs in all-pairs mode. Defaults to 50. Set to -1 for unlimited."),
143+
),
144+
)
145+
146+
mcpServer.AddTool(couplingTool, mcpCouplingHandler)
147+
113148
errLogger := log.New(os.Stderr, "scc-mcp: ", log.LstdFlags)
114149
if err := server.ServeStdio(mcpServer, server.WithErrorLogger(errLogger)); err != nil {
115150
_, _ = fmt.Fprintf(os.Stderr, "scc-mcp: server error: %v\n", err)
@@ -451,6 +486,64 @@ func mcpHotspotsHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.
451486
return mcp.NewToolResultText(out), nil
452487
}
453488

489+
func mcpCouplingHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
490+
args := request.GetArguments()
491+
492+
target, _ := args["file"].(string)
493+
494+
path := "."
495+
if p, ok := args["path"].(string); ok && p != "" {
496+
path = p
497+
}
498+
499+
absPath, err := filepath.Abs(path)
500+
if err != nil {
501+
return mcp.NewToolResultError(fmt.Sprintf("invalid path: %v", err)), nil
502+
}
503+
504+
if _, err := os.Stat(absPath); err != nil {
505+
return mcp.NewToolResultError(fmt.Sprintf("path cannot be accessed: %s: %v", absPath, err)), nil
506+
}
507+
508+
// Serialize access to processor globals so concurrent MCP requests don't
509+
// race on shared state.
510+
mcpMu.Lock()
511+
defer mcpMu.Unlock()
512+
513+
processor.HistoryDepth = 1000
514+
if d, ok := args["depth"].(float64); ok {
515+
if d < 0 {
516+
d = 0
517+
}
518+
processor.HistoryDepth = int(d)
519+
}
520+
521+
fileLimit := 50
522+
if l, ok := args["limit"].(float64); ok {
523+
if l < 0 {
524+
fileLimit = 0
525+
} else {
526+
fileLimit = int(l)
527+
}
528+
}
529+
530+
processor.ProcessConstants()
531+
processor.ConfigureLazy(true)
532+
533+
// file set → per-file blast radius; file omitted → repo-wide all-pairs.
534+
var out string
535+
if target != "" {
536+
out, err = processor.CouplingForJSONReport(absPath, target, fileLimit)
537+
} else {
538+
out, err = processor.CouplingJSONReport(absPath, fileLimit)
539+
}
540+
if err != nil {
541+
return mcp.NewToolResultError(fmt.Sprintf("coupling analysis failed: %v", err)), nil
542+
}
543+
544+
return mcp.NewToolResultText(out), nil
545+
}
546+
454547
func jsonMarshal(v any) ([]byte, error) {
455548
return json.MarshalIndent(v, "", " ")
456549
}

0 commit comments

Comments
 (0)