function isAutoExcluded(a, b) {
var na = a.split('/').pop(), nb = b.split('/').pop();
var da = a.substring(0, a.lastIndexOf('/') + 1);
var db = b.substring(0, b.lastIndexOf('/') + 1);
var lockFiles = ['Cargo.lock', 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', 'composer.lock', 'Gemfile.lock', 'poetry.lock'];
var manifestFiles = ['Cargo.toml', 'package.json', 'yarn.lock', 'pnpm-lock.yaml', 'composer.json', 'Gemfile', 'pyproject.toml'];
if (lockFiles.indexOf(na) >= 0 || lockFiles.indexOf(nb) >= 0) return 'lock file';
var projFiles = ['.csproj', '.sln', '.fsproj', '.vbproj'];
if (projFiles.some(function(ext) { return na.endsWith(ext) || nb.endsWith(ext); })) return 'project file';
if (na === 'pom.xml' || nb === 'pom.xml' || na === 'build.gradle' || nb === 'build.gradle') return 'build file';
var indexFiles = ['mod.rs', 'lib.rs', 'index.ts', 'index.js', 'index.tsx', 'index.jsx', '__init__.py'];
if (da === db && (indexFiles.indexOf(na) >= 0 || indexFiles.indexOf(nb) >= 0)) return 'module index';
function stripTestSuffix(name) {
return name
.replace(/\.spec\.(ts|js|tsx|jsx|mjs)$/, '.$1')
.replace(/\.test\.(ts|js|tsx|jsx|mjs|py)$/, '.$1')
.replace(/_test\.go$/, '.go')
.replace(/Tests?\.(java|cs|fs)$/, '.$1')
.replace(/Tests?\.(cs|fs)$/, '.$1');
}
if (stripTestSuffix(na) !== na && stripTestSuffix(na) === nb) return 'test file';
if (stripTestSuffix(nb) !== nb && stripTestSuffix(nb) === na) return 'test file';
if (da === db && na.endsWith('.cs') && nb.endsWith('.cs')) {
var aBase = na.slice(0, -3), bBase = nb.slice(0, -3);
if (aBase === 'I' + bBase || bBase === 'I' + aBase) return 'interface/impl';
}
if (na.endsWith('.java') && nb.endsWith('.java')) {
var aj = na.slice(0, -5), bj = nb.slice(0, -5);
if (aj + 'Impl' === bj || bj + 'Impl' === aj) return 'interface/impl';
if (aj + 'Interface' === bj || bj + 'Interface' === aj) return 'interface/impl';
}
return null;
}
function buildCouplingTab() {
var pairs = (R.coupling_pairs || []).slice().sort(function(a, b) {
return b.coupling_pct - a.coupling_pct;
});
var container = el('div');
if (pairs.length === 0) {
var noTempData = el('div', { className: 'no-data' });
noTempData.append(txt('No temporal coupling data available.'));
container.append(noTempData);
} else {
container.append(buildTabInfo(
'Temporal Coupling — Files that change together',
'Temporal coupling measures how often two files are modified in the same commit. A high percentage means the files are implicitly linked — changing one almost always requires changing the other. This can indicate hidden dependencies, duplicated logic, or missing abstractions. Consider extracting shared interfaces or merging tightly coupled files.',
[
{ color: 'var(--c-good-lo)', label: '<30% — Normal co-change' },
{ color: 'var(--c-warn)', label: '30–60% — Worth investigating' },
{ color: 'var(--c-danger)', label: '>60% — Strongly coupled, refactor candidate' }
]
));
var dismissed = {};
var showAutoExcluded = false;
var controls = el('div', { className: 'cp-controls' });
var toggleAutoBtn = el('button');
toggleAutoBtn.append(txt('Show auto-excluded'));
var statusSpan = el('span');
var resetBtn = el('button');
resetBtn.append(txt('Reset dismissed'));
controls.append(toggleAutoBtn, statusSpan, resetBtn);
container.append(controls);
var card = el('div', { className: 'view-card' });
var tableWrap = el('div', { style: { overflowX: 'auto' } });
var COL_TIPS = {
'Co-changes': 'Number of commits where both files were modified together.',
'Coupling %': 'Co-changes ÷ min(commits A, commits B). Answers: “Of the less-frequently-changed file’s commits, what share also touched the other file?” 100 % means the two files always move together.',
'Cross-boundary': 'The files live in different top-level modules or directories. Cross-boundary coupling is riskier because it signals hidden dependencies between components that should be independent.'
};
function renderTable() {
tableWrap.replaceChildren();
var table = el('table');
var thead = el('thead');
var hRow = el('tr');
['File A', 'File B', 'Co-changes', 'Coupling %', 'Cross-boundary', '', ''].forEach(function(h) {
hRow.append(thWithTip(h, COL_TIPS[h] || null));
});
thead.append(hRow);
table.append(thead);
var tbody = el('tbody');
var hiddenCount = 0;
var autoCount = 0;
pairs.slice(0, 100).forEach(function(p, idx) {
var excludeReason = isAutoExcluded(p.file_a, p.file_b);
if (excludeReason) autoCount++;
if (dismissed[idx]) { hiddenCount++; return; }
if (excludeReason && !showAutoExcluded) { hiddenCount++; return; }
var row = el('tr');
if (excludeReason) row.className = 'cp-auto-excluded';
var aCell = el('td');
var aParts = fileParts(p.file_a);
var aDir = el('span', { className: 'file-dir' });
aDir.append(txt(aParts.dir));
var aName = el('span', { className: 'file-name' });
aName.append(txt(aParts.name));
aCell.append(aDir, aName);
var bCell = el('td');
var bParts = fileParts(p.file_b);
var bDir = el('span', { className: 'file-dir' });
bDir.append(txt(bParts.dir));
var bName = el('span', { className: 'file-name' });
bName.append(txt(bParts.name));
bCell.append(bDir, bName);
if (excludeReason) {
var tag = el('span', { className: 'cp-auto-tag' });
tag.append(txt(excludeReason));
bCell.append(tag);
}
var coCell = el('td');
coCell.append(txt(String(p.co_changes)));
var pctCell = el('td');
var pctSpan = el('span', { style: { fontWeight: '700', color: p.coupling_pct > 70 ? 'var(--c-danger)' : p.coupling_pct > 40 ? 'var(--c-warn)' : 'var(--c-good)' } });
pctSpan.append(txt(fmt(p.coupling_pct, 1) + '%'));
pctCell.append(pctSpan);
var cbCell = el('td');
if (p.cross_boundary) {
var cbBadge = el('span', { style: { color: 'var(--c-warn)', fontWeight: '600', fontSize: '0.75rem' } });
cbBadge.append(txt('⚠ cross-boundary'));
cbCell.append(cbBadge);
}
if (p.is_test_pair) {
var tpBadge = el('span', { title: 'Expected coupling — production file and its test file naturally change together.', style: { marginLeft: '4px', cursor: 'default' } });
tpBadge.append(txt('🧪'));
cbCell.append(tpBadge);
}
var barCell = el('td', { className: 'inline-bar' });
barCell.append(inlineBar(p.coupling_pct, p.coupling_pct > 70 ? 'var(--c-danger)' : p.coupling_pct > 40 ? 'var(--c-warn)' : 'var(--c-good)'));
var dismissCell = el('td');
var dismissBtn = el('button', { className: 'cp-dismiss' });
dismissBtn.append(txt('×'));
dismissBtn.addEventListener('click', (function(i) {
return function() { dismissed[i] = true; renderTable(); };
})(idx));
dismissCell.append(dismissBtn);
row.append(aCell, bCell, coCell, pctCell, cbCell, barCell, dismissCell);
tbody.append(row);
});
table.append(tbody);
tableWrap.append(table);
statusSpan.replaceChildren();
var parts = [];
if (autoCount > 0) parts.push(autoCount + ' auto-excluded');
var dismissedCount = Object.keys(dismissed).length;
if (dismissedCount > 0) parts.push(dismissedCount + ' dismissed');
if (parts.length > 0) {
statusSpan.append(txt(parts.join(', ') + ' — ' + hiddenCount + ' hidden'));
}
resetBtn.style.display = dismissedCount > 0 ? '' : 'none';
toggleAutoBtn.className = showAutoExcluded ? 'active' : '';
toggleAutoBtn.replaceChildren();
toggleAutoBtn.append(txt(showAutoExcluded ? 'Hide auto-excluded' : 'Show auto-excluded (' + autoCount + ')'));
}
toggleAutoBtn.addEventListener('click', function() {
showAutoExcluded = !showAutoExcluded;
renderTable();
});
resetBtn.addEventListener('click', function() {
dismissed = {};
renderTable();
});
renderTable();
card.append(tableWrap);
container.append(card);
}
var instabilityCard = el('div', { className: 'view-card' });
var instHeader = el('div', { className: 'tab-info-title' });
instHeader.append(txt('Instability by File'));
instabilityCard.append(instHeader);
var instDesc = el('div', { style: { fontSize: '13px', color: 'var(--text-muted)', margin: '6px 0 12px' } });
instDesc.append(txt('Instability = Ce ÷ (Ca + Ce). 0 = maximally stable (depended upon, changes carefully). 1 = maximally unstable (depends on others, safe to change freely).'));
instabilityCard.append(instDesc);
var perFileCoupling = R.per_file_coupling;
if (!perFileCoupling || perFileCoupling.length === 0) {
var noInstData = el('div', { className: 'no-data' });
noInstData.append(txt('No static import data available.'));
instabilityCard.append(noInstData);
} else {
var instTableWrap = el('div', { style: { overflowX: 'auto' } });
var instTable = el('table');
var instThead = el('thead');
var instHRow = el('tr');
[
{ label: 'File', tip: null },
{ label: 'Ca', tip: 'Afferent coupling: number of files that import this file. High Ca = many dependents, risky to change.' },
{ label: 'Ce', tip: 'Efferent coupling: number of files this file imports. High Ce = many dependencies.' },
{ label: 'Instability', tip: 'Ce / (Ca + Ce). 0 = stable (depended upon). 1 = unstable (depends on others).' }
].forEach(function(col) {
instHRow.append(thWithTip(col.label, col.tip));
});
instThead.append(instHRow);
instTable.append(instThead);
var instTbody = el('tbody');
perFileCoupling.slice().sort(function(a, b) { return b.instability - a.instability; }).slice(0, 50).forEach(function(f) {
var fRow = el('tr');
var fileCell = el('td');
var fParts = fileParts(f.path);
var dirSpan = el('span', { className: 'file-dir' });
dirSpan.append(txt(fParts.dir));
var nameSpan = el('span', { className: 'file-name' });
nameSpan.append(txt(fParts.name));
fileCell.append(dirSpan, nameSpan);
linkFileCell(fileCell, f.path, 'Graph');
var caCell = el('td');
caCell.append(txt(String(f.ca)));
var ceCell = el('td');
ceCell.append(txt(String(f.ce)));
var instColor = f.instability <= 0.3
? 'var(--c-good)'
: f.instability <= 0.7
? 'var(--c-warn)'
: 'var(--c-danger)';
var instCell = el('td', { style: { display: 'flex', alignItems: 'center', gap: '8px' } });
var instVal = el('span', { style: { fontWeight: '700', color: instColor, minWidth: '3.5ch' } });
instVal.append(txt(fmt(f.instability, 2)));
instCell.append(instVal, inlineBar(f.instability * 100, instColor));
fRow.append(fileCell, caCell, ceCell, instCell);
instTbody.append(fRow);
});
instTable.append(instTbody);
instTableWrap.append(instTable);
instabilityCard.append(instTableWrap);
}
container.append(instabilityCard);
return container;
}