Every file your code imports is a dependency you can read. Every file it keeps getting edited alongside is a dependency too, and no parser will find it. I built both graphs for five JavaScript codebases and counted the edges only git has.

A dependency nobody wrote down

The five earlier posts in this series all read the same artifact. Fitness functions, the Modularity Maturity Index, propagation cost and decoupling level are all computed from what the code says about itself. Architecture debt added version history, but used it to price groups of files rather than to map them. None of them can see a dependency that was never written down.

Harald Gall, Karin Hajek and Mehdi Jazayeri put that gap into words in 1998. They were working through twenty releases of a ten-million-line telecom switching system. Their complaint about code metrics is still the clearest statement of the problem:

Some dependencies are not written down either in documentation or in the code. The software engineer just “knows” that to make a change of a certain type, he or she has to change a certain set of modules.

Gall, Hajek & Jazayeri, ICSM 1998

They called what they found logical coupling, as opposed to the syntactic coupling a compiler sees. They read it off release histories: which modules kept changing in the same release. The idea has been renamed several times since. Thomas Zimmermann and colleagues called it evolutionary coupling in 2004. CodeScene calls it temporal coupling, and the microservice literature calls it logical coupling again. The measurement underneath has barely changed. Take the commits, find the files that keep arriving together, and treat that as evidence of a dependency.

Zimmermann's team built a tool called ROSE. It suggested further edits from mined co-change rules across eight large open-source archives. Its accuracy was modest: about 30 percent precision and 34 percent recall for a single suggestion. But when it made any suggestion at all, a correct location was among its top three more than 70 percent of the time. One of their stated aims was the point of this whole idea, that such a tool should “show up item coupling that is undetectable by program analysis”. Their favourite example is a C function in _Qdmodule.c that always changed with a Python function in qdsupport.py. As they put it, detecting that would require cross-language program analysis. Git needed no analysis at all.

So the claim is old and the mechanism is simple. What I wanted was the size of the effect. If I build the co-change graph and the import graph over the same files, how much of the first is missing from the second?

Two graphs over the same files

I ran this on the same five JavaScript repositories as the last three posts, at the same pinned commits, so the numbers stay comparable. Those are webpack, eslint, prettier, rollup and vite. Both graphs use the same node set, which is the source files that exist at that commit.

The import graph is the one from the earlier posts. It resolves relative require, import and export … from specifiers to files and ignores external packages.

The co-change graph comes from git log --name-only. Two files share an edge when they appear in the same non-merge commit. Two thresholds decide which of those edges count as real. A pair needs at least five co-changes, the threshold the microservice work uses. It also needs a History Coupling Probability of at least 0.5: when one of them changes, the other changes too at least half the time. Support alone would just reward files that change constantly. I also drop any commit touching more than 25 source files. A single sweeping rename would otherwise mint thousands of pairs. Both choices get tested later.

$ node analyze.mjs
webpack: 633 files, 2675 import edges, 177 co-change edges, 63.8% invisible, lift 41.8x
eslint: 388 files, 665 import edges, 13 co-change edges, 84.6% invisible, lift 20.3x
prettier: 527 files, 1654 import edges, 86 co-change edges, 76.7% invisible, lift 25.1x
rollup: 269 files, 1721 import edges, 227 co-change edges, 63.4% invisible, lift 11.5x
vite: 130 files, 651 import edges, 20 co-change edges, 40% invisible, lift 17.8x

Four files, six couplings, no imports

Before the totals, one group that shows the whole shape of the result. webpack generates the code that loads chunks at runtime, and it has a separate module per target environment: ReadFile and Require for node, Jsonp for the browser, ImportScripts for web workers. All four are chunk-loading runtime modules. Every one of the six possible pairs among them co-changes strongly, and not one of them imports another.

9 imports all fourof them shareRuntimeModule.jsTemplate.js …262220191918ReadFilelib/node/Requirelib/node/Jsonplib/web/ImportScriptslib/webworker/ co-changes (count), no import either way imports
webpack's four chunk-loading runtime modules. Every pair co-changes; the numbers are how many commits they shared. No pair imports the other, and all four sit two hops apart in the import graph through the same shared parents.

The strongest pair, ReadFile and Require, landed together 26 times with an HCP of 0.96. When one changed, the other almost always changed in the same commit. Nothing in the source says so. What binds them is that all four implement the same contract against the same runtime globals, so a change to how chunk loading works is a change to four files at once.

This is the same pattern the previous post priced as a modularity violation. Here I want the map rather than the bill: if a codebase is full of groups like this, how much of its real dependency structure is the import graph missing?

Two thirds of the coupling has no import behind it

Across the five repositories there are 523 pairs that clear both thresholds. 342 of them, just over 65 percent, have no import in either direction.

one file imports the other no import either way webpack63.8%113/177eslint84.6%11/13prettier76.7%66/86rollup63.4%144/227vite40%8/20
Strong co-change pairs per repository, split by whether an import connects them. Bar length is the pair count, so webpack and rollup dominate.

Read on its own, that number invites a conclusion I do not think is true: that the import graph is not much use. The opposite check says otherwise. There are far more pairs of files that do not import each other than pairs that do, so the fair question is what an import edge does to the odds. In webpack, 2.4 percent of import edges become strong co-change pairs, against 0.06 percent of non-importing pairs. An import makes strong co-change about 42 times more likely. Across the five repos that multiplier runs from 12 to 42.

Both readings are correct, and they answer different questions. The import graph is a good predictor of what will change together. It is also a small minority of what actually does.

repo source files commits mined import edges co-change pairs no import odds vs chance
webpack6337,3792,67517763.8%41.8×
eslint3883,9106651384.6%20.3×
prettier5274,1071,6548676.7%25.1×
rollup2692,3341,72122763.4%11.5×
vite1303,8926512040.0%17.8×

eslint is worth a note. It produces only 13 strong pairs from 3,910 commits, the thinnest result in the set, because its commits are unusually narrow: one rule, one file. A codebase with genuinely independent parts produces very little change coupling, which is the outcome the metric is supposed to reward. Percentages over 13 pairs are still fragile, and I read eslint's 84.6 percent as a direction rather than a number.

But almost none of it is far away

I expected the unimported pairs to be scattered across the system, connected by nothing but a shared idea. That is not what they look like. For each of the 342 pairs I measured the shortest path between the two files through the import graph, ignoring direction.

shortest import path between the two files, in hops234-56+unreachablewebpack106n=113eslint101n=11prettier4321n=66rollup143n=144vite71n=8
For every co-change pair with no import between them, the shortest import path that does connect the two files. The two-hop band is 90 percent of the total, and not one pair in the five repositories is unreachable.

309 of the 342 pairs sit exactly two hops apart, and every single one is reachable. There is no hidden parallel universe here. What the co-change graph adds are shortcuts across a graph I already had. webpack's four runtime modules are two hops apart because they all import RuntimeModule, Template and five other files. rollup's Chunk.ts and finalisers/amd.ts are two hops apart for a different reason. Chunk imports the finaliser index and calls finalisers[format](…), so the real dependency runs through a lookup table.

That reframing makes the signal easier to act on. "These two files have nothing to do with each other but keep changing together" would be a mystery. "These two files hang off the same parent and change together far more often than their siblings do" is a question with an answer, and the parent is usually where the answer lives.

Distance also separates the cases worth attention. 27 percent of the unimported pairs cross a directory boundary. Sibling files in one folder co-changing is close to expected. A pair that spans two parts of the tree is the one to look at.

The shape that keeps appearing

Reading the strongest unimported pairs in each repository, the same structure turns up in four of the five. Every time, it is a family of parallel implementations of one interface.

webpack has the four chunk-loading runtime modules. rollup has its output finalisers, where iife.ts and umd.ts shared 33 commits at an HCP of 0.85, and amd.ts and umd.ts another 29. prettier has two such families at once: the parser adapters, where meriyah.js and typescript.js shared 19 commits, and the language plugins, where all 21 unimported pairs among seven language-*/index.js files are present, the strongest at an HCP of 0.93.

The prettier language plugins are the most extreme case, and they sit four hops apart rather than two. Each is a seven-line file exporting a printer, its languages, its options and its parsers. They are structurally identical and structurally unrelated. The plugin registry loads them through a dynamic import() that my resolver does not follow. So a change to the plugin contract is a change to seven files, and neither the import graph nor a directory listing groups them. Only the commits do.

Once you have seen the shape, the interpretation is not complicated. A set of files implementing one contract will change together whenever the contract changes. That is not a defect, and extracting a shared base class is often the wrong fix. It is simply a real dependency, and it is worth knowing it exists before someone edits three of the four and ships.

The two graphs disagree about which files matter

If change coupling were just a noisy view of the import graph, the files each one considers central would broadly agree. I took the ten highest-degree files in each graph per repository and compared the lists.

repo busiest by imports busiest by co-change shared in top 10
webpackRuntimeGlobals.jsRequireEnsurePlugin.js1
eslintrules/index.jsrules/max-depth.js3
prettierdocument/index.jslanguage-yaml/index.js0
rollupast/nodes/shared/Node.tsChunk.ts1
vitenode/utils.tsmiddlewares/time.ts2

That is 7 shared files out of 50. The import graph nominates the files everything depends on, which tend to be stable utilities and base classes: RuntimeGlobals, a document builder, an AST node base. The co-change graph nominates the files that maintenance actually flows through. They are different questions, and a codebase where the answers coincide is one where the hardest-working files are also the most depended-on ones.

Does it grow over time?

The microservice work on logical coupling reports that it increases as a system ages. That claim comes from a registered report by Dario Amoroso d'Aragona and colleagues, which is a study design rather than a result, describing an earlier validation over 145 open-source microservice projects where coupling “tends to increase over time”. I wanted to know whether the same holds for a monolithic codebase.

So I rebuilt both graphs once per calendar year. The import graph comes from that year's tree; the co-change graph comes from a trailing 24-month window, because a cumulative window can only ever grow and would manufacture the trend I was testing for.

0%25%50%75%100%2014201620182020202220242026share of co-change pairs with no import between them, trailing 24-month windowyears with fewer than 20 co-change pairs are left outwebpackprettierrollupvite
The unimported share year by year, from a trailing 24-month commit window against the import graph as it stood. Years producing fewer than 20 strong pairs are omitted as too noisy to plot.

It does not replicate here. webpack moves the other way, from 86 percent in 2017 to 52 percent in 2026. rollup swings from 68 percent in 2019 to 23 percent the following year. Only prettier trends upward, from 58 to 74 percent between 2019 and 2023.

I do not read this as contradicting the microservice finding. A monolith can absorb its own coupling. When webpack's plugin families acquire a shared base module, pairs that were invisible become imports, and the share falls without any coupling disappearing. Services split across repositories have no such path: the coupling stays logical because it cannot become structural. So the trend may say more about how a system is packaged than about how it ages.

Where my numbers bend

The honest list, in the order I would attack my own results.

The thresholds move the answer. Sweeping support across 3, 5 and 10 and confidence across 0.3, 0.5 and 0.7 keeps webpack between 63 and 71 percent and rollup between 47 and 69, but prettier runs from 66 to 95 percent and vite from 0 to 43. The finding is stable for the two repos with hundreds of pairs and soft for the ones with dozens.

The commit-size cap moves it more. This is the confounder the microservice literature calls apparent coupling. Drop the cap entirely and webpack reports 2,331 pairs at 89.5 percent invisible, most of them manufactured by a handful of sweeping commits. Tighten it to 8 files and the same repo reports 58 pairs at 53 percent. My cap of 25 is a judgement call sitting between those, and anyone reproducing this should report theirs.

My import resolver is a regex, not a bundler. It does not follow dynamic import(), which is exactly why prettier's language plugins measure as four hops apart rather than two. Every unresolved specifier inflates the invisible share.

Co-change is not causation. Two files can land in the same commit because a release script touched both, or because one person prefers large commits. The HCP threshold filters much of this, since a file that changes with everything has a low conditional probability with each partner. It does not filter all of it.

One bug deserves its own paragraph, because it nearly became a result. My resolver checked candidate paths against the filesystem, and existsSync happily says yes to a directory. On a case-insensitive volume webpack's require("./Stats") resolved to the lib/stats/ folder, so the real edge to Stats.js was silently dropped. Every missing import edge turns a visible pair into an invisible one, which is exactly the number I was reporting. Resolving against the analysed file set instead removed the last genuinely unreachable pair from the whole corpus and moved a handful of figures in the previous post, which I have corrected.

What I would do with it

Change coupling tells you where to ask questions rather than what to fix. CodeScene's own documentation is blunt about this: coupling “in itself is neither good nor bad; it all depends on what modules are coupled and for what reason they co-evolve”.

Pairs that cross a directory boundary and share no import are the first place to look, because both signals say the files belong to different parts of the system and history says otherwise. Families of parallel implementations are worth documenting rather than refactoring, since the coupling is the contract working as intended and the risk is a partial edit. And a file that ranks high in the co-change graph but low in the import graph is carrying maintenance that its structure does not explain, which is the case I would want a name for before it grows.

What I did not find is the thing the framing promised. There is no second dependency graph hiding in git. There is one graph, and history draws a set of shortcuts across it that the source never mentions. Two thirds of the strongest couplings take one of those shortcuts, and nine in ten of them land two hops from where they started.

Files that change together are coupled whether or not they import each other. Across five JavaScript codebases, 65 percent of the strongest co-change pairs had no import between them, and 90 percent of those were still only two hops apart. An import makes co-change 12 to 42 times more likely, which makes the import graph a good predictor of coupling and a poor inventory of it.

I'm Maxat, a software engineer testing architecture research against real repositories. The mining scripts, pinned commits and raw results behind every number live in this site's repository. The whole method is a few hundred dependency-free lines you can point at your own git history.

References