The Bipartite Graph
Part I built the BindingsManifest to close the traceability loop. Part II exposed it through bidirectional queries. But the manifest is more than a traceability tool. It is a bipartite graph — two sets of nodes (Features and Files) connected by edges (bindings). Every architecture question is a query on this graph.
external.ts is red because it is claimed by all three features — its fan-out is 3 (and in reality, 15). accent-derive.ts connects only to ACCENT — it is exclusive. theme-state.ts connects only to THEME. This visual already tells you something: ACCENT and THEME are coupled through external.ts (which is expected for a ports file), but otherwise independent.
The bipartite graph is not an abstraction. It is the literal structure of requirements-bindings.json — Record<featureId, Record<acName, SymbolTarget[]>> — flattened to Feature -> File edges. Every analysis below is a query on this structure. No special tooling needed. Just the manifest.
SRP Violation Detector
The Single Responsibility Principle says a module should have one reason to change. In the manifest, a file's "reasons to change" are the features that claim it. If a file appears in 5 or more features' binding entries, it has 5 business reasons to change — a measurable SRP signal.
function detectSrpViolations(manifest: BindingsManifest, threshold = 3): string[] {
const fileFanOut = new Map<string, Set<string>>();
for (const [featureId, acs] of Object.entries(manifest)) {
for (const targets of Object.values(acs)) {
for (const { file } of targets) {
if (!fileFanOut.has(file)) fileFanOut.set(file, new Set());
fileFanOut.get(file)!.add(featureId);
}
}
}
return [...fileFanOut.entries()]
.filter(([, features]) => features.size > threshold)
.map(([file, features]) => `${file}: ${features.size} features (${[...features].join(', ')})`);
}function detectSrpViolations(manifest: BindingsManifest, threshold = 3): string[] {
const fileFanOut = new Map<string, Set<string>>();
for (const [featureId, acs] of Object.entries(manifest)) {
for (const targets of Object.values(acs)) {
for (const { file } of targets) {
if (!fileFanOut.has(file)) fileFanOut.set(file, new Set());
fileFanOut.get(file)!.add(featureId);
}
}
}
return [...fileFanOut.entries()]
.filter(([, features]) => features.size > threshold)
.map(([file, features]) => `${file}: ${features.size} features (${[...features].join(', ')})`);
}This is not a heuristic. It is a direct measurement. external.ts touching 15 features is expected — it is a ports file, a shared infrastructure boundary. But if spa-nav-state.ts touches 5 features, that state machine is doing too much. The fix is clear: extract the shared concern into its own module with its own feature.
The work trace hotspots command already provides this data. The SRP detector is just hotspots with a threshold.
Feature Coupling Matrix
Two features are coupled if they share source files. The more files they share, the stronger the coupling. A coupling matrix answers: "Can I modify Feature A without affecting Feature B?"
ACCENT THEME SPY NAV BUILD
ACCENT - 1 1 0 0
THEME 1 - 1 0 0
SPY 1 1 - 2 0
NAV 0 0 2 - 0
BUILD 0 0 0 0 - ACCENT THEME SPY NAV BUILD
ACCENT - 1 1 0 0
THEME 1 - 1 0 0
SPY 1 1 - 2 0
NAV 0 0 2 - 0
BUILD 0 0 0 0 -Reading: ACCENT and THEME share 1 file (external.ts — expected). SPY and NAV share 2 files — they probably share navigation-related state machines. BUILD shares nothing with anyone — it is fully isolated.
The matrix is a single pass over the manifest: for each file, enumerate its features, and increment the pairwise count. The result tells you where coupling lives — not where you think it lives, but where the test code proves it lives.
High coupling between features that should be independent is a design smell. Low coupling between features that share UI concerns is a sign of good boundaries. The matrix does not judge — it measures. The developer decides what the number means.
Cohesion Analyzer
Cohesion is the complement of coupling: are a feature's files together or scattered? If ACCENT claims src/lib/accent-palette-state.ts, src/lib/accent-preview-state.ts, and src/lib/accent-derive.ts — all three share the accent- prefix and live in the same directory. Cohesion is high.
If a hypothetical feature claimed src/lib/search-score.ts, scripts/lib/mermaid-renderer.ts, and src/lib/theme-state.ts — three unrelated files in different modules — cohesion would be low. That feature is probably doing three unrelated things under one name.
A simple cohesion metric: what fraction of a feature's files share a common path prefix? ACCENT has 3/3 files starting with accent- — cohesion 100%. A scattered feature might have 1/5 — cohesion 20%.
This is not a replacement for architectural judgement. But it surfaces features that look suspicious, mechanically, without reading a single line of code.
API Surface and Dead Export Detection
The manifest records which symbols are imported by tests — not just files. This is the feature's public API surface: the set of exports that tests actually use.
A module might export 12 functions. If tests across all features only import 8 of them, the other 4 are dead exports — candidates for making private, inlining, or removing. The scanner did not set out to detect dead exports. But the SymbolTarget[] data makes it trivial:
function findDeadExports(manifest: BindingsManifest): Map<string, string[]> {
const usedSymbols = new Map<string, Set<string>>();
for (const acs of Object.values(manifest)) {
for (const targets of Object.values(acs)) {
for (const { file, symbol } of targets) {
if (!usedSymbols.has(file)) usedSymbols.set(file, new Set());
usedSymbols.get(file)!.add(symbol);
}
}
}
// Compare with actual exports from each file...
return usedSymbols;
}function findDeadExports(manifest: BindingsManifest): Map<string, string[]> {
const usedSymbols = new Map<string, Set<string>>();
for (const acs of Object.values(manifest)) {
for (const targets of Object.values(acs)) {
for (const { file, symbol } of targets) {
if (!usedSymbols.has(file)) usedSymbols.set(file, new Set());
usedSymbols.get(file)!.add(symbol);
}
}
}
// Compare with actual exports from each file...
return usedSymbols;
}The manifest tells you which exports are load-bearing (used by tests that verify features) and which are orphaned. Dead export detection as a side effect of requirement tracing.
Encapsulation Breach Detector
A subtler violation: the tests for Feature A import a symbol from a file that Feature B claims as its own. Feature A's tests reach into Feature B's implementation details.
Feature ACCENT tests import deriveAccentPalette from src/lib/accent-derive.ts → OK (ACCENT's own file)
Feature THEME tests import deriveAccentPalette from src/lib/accent-derive.ts → BREACH (THEME reaching into ACCENT's implementation)Feature ACCENT tests import deriveAccentPalette from src/lib/accent-derive.ts → OK (ACCENT's own file)
Feature THEME tests import deriveAccentPalette from src/lib/accent-derive.ts → BREACH (THEME reaching into ACCENT's implementation)The manifest makes this visible: for each test file, look up the feature it belongs to (@FeatureTest(F)), then check whether the imported symbols belong to files claimed exclusively by another feature. If they do, Feature A has an undeclared dependency on Feature B's internals.
This does not mean the code is wrong — sometimes cross-feature imports are intentional. But the breach detector surfaces them explicitly, so you can decide whether they should be promoted to a shared abstraction or left as acknowledged coupling.
Change Amplification and Feature Isolation
Two complementary metrics fall directly from the graph:
Change amplification of a file: |features_touching(file)|. How many features break if this file changes? High amplification files are the ones to stabilise first — extract interfaces, add versioning, or split responsibilities.
Feature isolation score: exclusive_files / total_files. How much of a feature's code is exclusively its own versus shared with other features?
| Feature | Total files | Exclusive files | Isolation |
|---|---|---|---|
| ACCENT | 3 | 2 | 67% |
| THEME | 2 | 1 | 50% |
| BUILD | 12 | 11 | 92% |
| SPY | 2 | 1 | 50% |
BUILD at 92% isolation means it is almost entirely self-contained — extracting it into a separate package would be straightforward. ACCENT at 67% means one of its three files is shared. SPY at 50% means half its code is shared with other features.
A feature with 0% isolation is entirely entangled — every file it touches is also touched by other features. Refactoring it means refactoring everything. The isolation score surfaces these entanglements before you attempt the refactor.
Dependency Direction Analysis
The Feature class has a priority field: Critical, High, Medium, Low. The manifest connects features to files. The question: do critical features depend on files claimed by low-priority features?
If NAV (Critical) and COPY (Medium) share a source file, and someone refactors that file for COPY, they might break NAV — a critical feature broken by a medium-priority change. The manifest makes this risk visible:
function findRiskyDeps(features: FeatureMeta[], fileToFeatures: Map<string, Set<string>>): string[] {
const risks: string[] = [];
for (const [file, featureIds] of fileToFeatures) {
const priorities = featureIds.map(id => features.find(f => f.id === id)?.priority);
if (priorities.includes('critical') && priorities.includes('low')) {
risks.push(`${file}: shared by critical and low-priority features`);
}
}
return risks;
}function findRiskyDeps(features: FeatureMeta[], fileToFeatures: Map<string, Set<string>>): string[] {
const risks: string[] = [];
for (const [file, featureIds] of fileToFeatures) {
const priorities = featureIds.map(id => features.find(f => f.id === id)?.priority);
if (priorities.includes('critical') && priorities.includes('low')) {
risks.push(`${file}: shared by critical and low-priority features`);
}
}
return risks;
}The fix is not always to decouple — sometimes the dependency is legitimate. But the analysis surfaces it so the team can make a conscious choice rather than discovering it during an incident.
Module Extraction Feasibility
"If I wanted to extract the ACCENT feature into a standalone npm package, which files would come with it?"
The manifest answers directly: deriveFilesFromManifest(manifest, 'ACCENT') returns 3 files. But are any of them shared? If external.ts is among them, and 14 other features also claim external.ts, you cannot just copy it — you need to keep the port interface as a shared dependency.
The extraction analysis is:
- List the feature's files from the manifest
- For each file, check
fileToFeatures.get(file).size - Files with size 1 (exclusive) can move freely
- Files with size > 1 (shared) need to stay or be split
The result: "ACCENT can be extracted with 2 files, but external.ts must remain as a shared interface." This is a 5-line query on the manifest, not a week of architectural analysis.
Drift Over Time
The manifest is a JSON file checked into git. Comparing it across commits reveals feature sprawl:
- A feature that claimed 3 files six months ago now claims 8 — it grew. Why? Was it intentional or accidental?
- A file that was exclusive to one feature now appears in three features' entries — coupling increased.
- A new feature appeared with 15 ACs and 1 source file — every AC points to the same god module.
# How much did the manifest change in the last 10 commits?
git diff HEAD~10..HEAD -- requirements-bindings.json | wc -l
# Which features grew the most?
diff <(git show HEAD~10:requirements-bindings.json | jq 'to_entries[] | {key, count: (.value | keys | length)}') \
<(jq 'to_entries[] | {key, count: (.value | keys | length)}' requirements-bindings.json)# How much did the manifest change in the last 10 commits?
git diff HEAD~10..HEAD -- requirements-bindings.json | wc -l
# Which features grew the most?
diff <(git show HEAD~10:requirements-bindings.json | jq 'to_entries[] | {key, count: (.value | keys | length)}') \
<(jq 'to_entries[] | {key, count: (.value | keys | length)}' requirements-bindings.json)The manifest is a time series of architecture. Each commit captures the system's structure at that moment. Drift detection is diff on two snapshots.
Software Architecture, Quantified
The insight that unifies all of the above: the BindingsManifest transforms architecture questions — traditionally answered by "look at the code and form an intuition" — into queries on a graph.
| Question | Traditional answer | Manifest answer |
|---|---|---|
| Is this module doing too much? | "It feels big" | fileToFeatures.get(file).size > 3 |
| Are these features coupled? | "They seem related" | sharedFiles(A, B) > 0 |
| Is this feature cohesive? | "The files look related" | commonPrefixRatio(feature) > 0.8 |
| Can I extract this module? | "Maybe, let me investigate" | exclusiveFiles(feature) / totalFiles(feature) |
| Is this change safe? | "I think so" | work trace impact --diff |
| Is this code dead? | "Nobody uses it" | orphanSourceFiles.length > 0 |
SOLID is no longer a principle you invoke in code reviews — it is a metric you measure. SRP is file fan-out > threshold. Cohesion is common prefix ratio. Coupling is shared file count. These are not perfect proxies — no metric is — but they are concrete, reproducible, and derived from the same data that already serves traceability.
The manifest was built to answer "does every requirement have a test?" It turns out it also answers "is the architecture any good?" The best tools are the ones that solve problems you did not design them for.
Next: Part IV — Hexagonal Architecture and Coverage Hardening explains how extracting 232 sync IO calls into hexagonal ports made every factory testable — and why clean import graphs are a prerequisite for everything in this series.