Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ Architectural force analysis.
codebase-intelligence forces <path> [--cohesion <n>] [--tension <n>] [--escape <n>] [--json] [--force]
```

**Output:** module cohesion verdicts, tension files, bridge files, extraction candidates, summary.
**Output:** module cohesion verdicts, tension files, bridge files, extraction candidates, shallow modules, deep modules, seam candidates, locality risks, summary.

### dead-exports

Expand Down
2 changes: 1 addition & 1 deletion docs/mcp-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ Module-level architecture with cross-module dependencies.
Architectural force analysis — module health, misplaced files, bridge files.

**Input:** `{ cohesionThreshold?: number, tensionThreshold?: number, escapeThreshold?: number }`
**Returns:** moduleCohesion (with verdicts), tensionFiles (with pull details + recommendations), bridgeFiles (with connections), extractionCandidates (with recommendations), summary
**Returns:** moduleCohesion (with verdicts), tensionFiles (with pull details + recommendations), bridgeFiles (with connections), extractionCandidates (with recommendations), shallowModules, deepModules, seamCandidates, localityRisks, summary

**Use when:** "What's architecturally wrong?" "Which modules are coupled?" "What files should be moved?"
**Not for:** File-level metrics (use find_hotspots).
Expand Down
4 changes: 4 additions & 0 deletions docs/metrics.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ All metrics are computed per-file and stored in `FileMetrics`. Module-level aggr
| **Leaf module** | 1 non-test file | Single-file module. Cohesion is degenerate (no internal deps possible). Not a problem — use `find_hotspots(metric='coupling')` for high-coupling concerns. |
| **Junk drawer** | module cohesion < 0.4, 2+ non-test files | Module with mostly external deps. Needs restructuring. |
| **Extraction candidate** | escapeVelocity >= 0.5 | Module with 0 internal deps, consumed by many others. Extract to package. |
| **Shallow module** | many exports per file, low LOC per export, low cohesion | Public surface is wide relative to hidden behavior. Complexity likely leaks to callers. |
| **Deep module** | few exports, high LOC per export, reused across modules | Small interface hiding larger useful behavior. Good leverage for callers. |
| **Seam candidate** | exported file/module used by 2+ external modules | Natural place to stabilize an interface and vary implementation behind it. |
| **Locality risk** | tension + blast radius, or bridge + blast radius | Changes to one concept are likely to spread across multiple files/modules. |

## Complexity Scoring

Expand Down
55 changes: 55 additions & 0 deletions src/analyzer/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,57 @@ describe("analyzeGraph", () => {
expect(result.forceAnalysis.summary).toContain("healthy");
});

it("detects shallow modules from wide public surface and low locality", () => {
const files = [
makeFile("src/shallow/api.ts", {
loc: 18,
exports: [
{ name: "one", type: "function", loc: 2, isDefault: false, complexity: 1 },
{ name: "two", type: "function", loc: 2, isDefault: false, complexity: 1 },
{ name: "three", type: "function", loc: 2, isDefault: false, complexity: 1 },
{ name: "four", type: "function", loc: 2, isDefault: false, complexity: 1 },
],
imports: [imp("src/shared/base.ts")],
}),
makeFile("src/shallow/helpers.ts", {
loc: 18,
exports: [
{ name: "five", type: "function", loc: 2, isDefault: false, complexity: 1 },
{ name: "six", type: "function", loc: 2, isDefault: false, complexity: 1 },
],
imports: [imp("src/shared/base.ts")],
}),
makeFile("src/shared/base.ts"),
];
const built = buildGraph(files);
const result = analyzeGraph(built, files);

expect(result.forceAnalysis.shallowModules).toEqual(
expect.arrayContaining([
expect.objectContaining({ module: "src/shallow/", exports: 6 }),
]),
);
});

it("detects locality risks for files with tension and broad blast radius", () => {
const files = [
makeFile("src/shared/utils.ts", {
imports: [imp("src/a/service.ts"), imp("src/b/service.ts")],
}),
makeFile("src/a/service.ts", { imports: [imp("src/shared/utils.ts")] }),
makeFile("src/b/service.ts", { imports: [imp("src/shared/utils.ts")] }),
makeFile("src/feature/consumer.ts", { imports: [imp("src/shared/utils.ts")] }),
];
const built = buildGraph(files);
const result = analyzeGraph(built, files);

expect(result.forceAnalysis.localityRisks).toEqual(
expect.arrayContaining([
expect.objectContaining({ file: "src/shared/utils.ts" }),
]),
);
});

it("handles circular dependencies in stats", () => {
const files = [
makeFile("a.ts", { imports: [imp("b.ts")] }),
Expand Down Expand Up @@ -244,6 +295,10 @@ describe("analyzeGraph", () => {
expect(result.forceAnalysis).toHaveProperty("tensionFiles");
expect(result.forceAnalysis).toHaveProperty("bridgeFiles");
expect(result.forceAnalysis).toHaveProperty("extractionCandidates");
expect(result.forceAnalysis).toHaveProperty("shallowModules");
expect(result.forceAnalysis).toHaveProperty("deepModules");
expect(result.forceAnalysis).toHaveProperty("seamCandidates");
expect(result.forceAnalysis).toHaveProperty("localityRisks");
expect(result.forceAnalysis).toHaveProperty("summary");
});
});
Expand Down
162 changes: 160 additions & 2 deletions src/analyzer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ import type {
TensionFile,
BridgeFile,
ExtractionCandidate,
ShallowModule,
DeepModule,
SeamCandidate,
LocalityRisk,
CodebaseGraph,
GraphNode,
SymbolMetrics,
Expand Down Expand Up @@ -123,7 +127,14 @@ export function analyzeGraph(built: BuiltGraph, parsedFiles?: ParsedFile[]): Cod
const groups = computeGroups(fileNodes, fileMetrics);

// Centrifuge force analysis
const forceAnalysis = computeForceAnalysis(graph, fileNodes, fileMetrics, moduleMetrics, betweennessScores);
const forceAnalysis = computeForceAnalysis(
graph,
fileNodes,
fileMetrics,
moduleMetrics,
betweennessScores,
parsedByPath,
);

// Update tension in fileMetrics from force analysis
for (const tf of forceAnalysis.tensionFiles) {
Expand Down Expand Up @@ -339,12 +350,46 @@ function isEntryPointFile(fileId: string): boolean {
return false;
}

function getModuleExportStats(
files: GraphNode[],
parsedByPath: Map<string, ParsedFile>,
): { exportCount: number; loc: number } {
let exportCount = 0;
let loc = 0;

for (const file of files) {
if (isTestFilePath(file.id)) continue;
loc += file.loc;
exportCount += parsedByPath.get(file.id)?.exports.length ?? 0;
}

return { exportCount, loc };
}

function dependentModuleCountForModule(modulePath: string, graph: Graph): number {
const dependents = new Set<string>();

graph.forEachNode((nodeId: string, attrs: Record<string, unknown>) => {
if (attrs.type !== "file") return;
if ((attrs.module as string) !== modulePath) return;

for (const neighbor of graph.inNeighbors(nodeId)) {
if (graph.getNodeAttribute(neighbor, "type") !== "file") continue;
const neighborModule = graph.getNodeAttribute(neighbor, "module") as string;
if (neighborModule !== modulePath) dependents.add(neighborModule);
}
});

return dependents.size;
}

function computeForceAnalysis(
graph: Graph,
fileNodes: GraphNode[],
fileMetrics: Map<string, FileMetrics>,
moduleMetrics: Map<string, ModuleMetrics>,
betweennessScores: Map<string, number>
betweennessScores: Map<string, number>,
parsedByPath: Map<string, ParsedFile>,
): ForceAnalysis {
// Group files by module for non-test file counting
const moduleFiles = new Map<string, GraphNode[]>();
Expand Down Expand Up @@ -471,6 +516,109 @@ function computeForceAnalysis(
});
}

const shallowModules: ShallowModule[] = [];
const deepModules: DeepModule[] = [];
const seamCandidates: SeamCandidate[] = [];
const localityRisks: LocalityRisk[] = [];

for (const mod of moduleMetrics.values()) {
const files = moduleFiles.get(mod.path) ?? [];
const nonTestFiles = files.filter((file) => !isTestFilePath(file.id));
if (nonTestFiles.length === 0) continue;

const { exportCount, loc } = getModuleExportStats(nonTestFiles, parsedByPath);
const exportsPerFile = exportCount / nonTestFiles.length;
const locPerExport = exportCount > 0 ? loc / exportCount : loc;
const dependedByModules = dependentModuleCountForModule(mod.path, graph);

if (
exportCount >= nonTestFiles.length * 2
&& locPerExport <= 20
&& mod.cohesion <= 0.5
&& dependedByModules <= 1
) {
shallowModules.push({
module: mod.path,
files: nonTestFiles.length,
exports: exportCount,
exportsPerFile: Math.round(exportsPerFile * 100) / 100,
cohesion: mod.cohesion,
locPerExport: Math.round(locPerExport * 100) / 100,
evidence: `${exportCount} exports across ${nonTestFiles.length} file(s), ${locPerExport.toFixed(1)} LOC/export, cohesion ${mod.cohesion.toFixed(2)}`,
});
}

if (
exportCount > 0
&& exportCount <= nonTestFiles.length
&& locPerExport >= 25
&& dependedByModules >= 1
&& mod.cohesion >= 0.5
) {
deepModules.push({
module: mod.path,
files: nonTestFiles.length,
exports: exportCount,
exportsPerFile: Math.round(exportsPerFile * 100) / 100,
locPerExport: Math.round(locPerExport * 100) / 100,
dependedByModules,
evidence: `${exportCount} exports hide ${loc} LOC across ${nonTestFiles.length} file(s); reused by ${dependedByModules} module(s)`,
});
}

if (exportCount > 0 && dependedByModules >= 2) {
seamCandidates.push({
target: mod.path,
scope: "module",
exposedSymbols: exportCount,
fanIn: dependedByModules,
dependentModules: dependedByModules,
evidence: `${exportCount} exported symbol(s) used across ${dependedByModules} dependent module(s)`,
});
}
}

for (const file of fileNodes) {
const parsed = parsedByPath.get(file.id);
const exposedSymbols = parsed?.exports.length ?? 0;
const metrics = fileMetrics.get(file.id);
if (!metrics) continue;

const tensionInfo = tensionFiles.find((item) => item.file === file.id);
const pulledByModuleCount = tensionInfo?.pulledBy.length ?? 0;
const kind: LocalityRisk["kind"] | null = tensionInfo && metrics.blastRadius >= 2
? "ripple-zone"
: metrics.isBridge && metrics.blastRadius >= 2
? "bridge-blast"
: pulledByModuleCount >= 2 && metrics.blastRadius >= 1
? "concept-spread"
: null;

if (kind) {
localityRisks.push({
file: file.id,
kind,
tension: metrics.tension,
blastRadius: metrics.blastRadius,
isBridge: metrics.isBridge,
pulledByModuleCount,
evidence: `blast radius ${metrics.blastRadius}, tension ${metrics.tension.toFixed(2)}, bridge=${String(metrics.isBridge)}`,
});
}

const dependentModules = tensionInfo?.pulledBy.length ?? 0;
if (exposedSymbols > 0 && dependentModules >= 2 && metrics.fanIn >= 2) {
seamCandidates.push({
target: file.id,
scope: "file",
exposedSymbols,
fanIn: metrics.fanIn,
dependentModules,
evidence: `${exposedSymbols} exported symbol(s), fan-in ${metrics.fanIn}, pulled by ${dependentModules} module(s)`,
});
}
}

// Summary
const junkDrawers = moduleCohesion.filter((m) => m.verdict === "JUNK_DRAWER");
const summaryParts: string[] = [];
Expand All @@ -483,6 +631,12 @@ function computeForceAnalysis(
if (extractionCandidates.length > 0) {
summaryParts.push(`${extractionCandidates.map((e) => e.target).join(", ")} ready for extraction`);
}
if (shallowModules.length > 0) {
summaryParts.push(`${shallowModules.length} shallow module candidate(s)`);
}
if (localityRisks.length > 0) {
summaryParts.push(`${localityRisks.length} locality risk(s)`);
}
if (summaryParts.length === 0) {
summaryParts.push("Codebase architecture looks healthy. No major force imbalances detected.");
}
Expand All @@ -492,6 +646,10 @@ function computeForceAnalysis(
tensionFiles: tensionFiles.sort((a, b) => b.tension - a.tension),
bridgeFiles: bridgeFiles.sort((a, b) => b.betweenness - a.betweenness),
extractionCandidates: extractionCandidates.sort((a, b) => b.escapeVelocity - a.escapeVelocity),
shallowModules: shallowModules.sort((a, b) => b.exportsPerFile - a.exportsPerFile),
deepModules: deepModules.sort((a, b) => b.locPerExport - a.locPerExport),
seamCandidates: seamCandidates.sort((a, b) => b.dependentModules - a.dependentModules || b.fanIn - a.fanIn),
localityRisks: localityRisks.sort((a, b) => b.blastRadius - a.blastRadius || b.tension - a.tension),
summary: summaryParts.join(". ") + ".",
};
}
Expand Down
36 changes: 36 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -628,6 +628,42 @@ program
output(` ${e.recommendation}`);
}
}

if (result.shallowModules.length > 0) {
output(``);
output(`Shallow Modules (${result.shallowModules.length}):`);
for (const m of result.shallowModules) {
output(` ${m.module} (${m.exports} exports, cohesion: ${m.cohesion.toFixed(2)})`);
output(` ${m.evidence}`);
}
}

if (result.deepModules.length > 0) {
output(``);
output(`Deep Modules (${result.deepModules.length}):`);
for (const m of result.deepModules) {
output(` ${m.module} (${m.exports} exports, depended by: ${m.dependedByModules})`);
output(` ${m.evidence}`);
}
}

if (result.seamCandidates.length > 0) {
output(``);
output(`Seam Candidates (${result.seamCandidates.length}):`);
for (const seam of result.seamCandidates) {
output(` ${seam.target} [${seam.scope}] (dependents: ${seam.dependentModules}, fan-in: ${seam.fanIn})`);
output(` ${seam.evidence}`);
}
}

if (result.localityRisks.length > 0) {
output(``);
output(`Locality Risks (${result.localityRisks.length}):`);
for (const risk of result.localityRisks) {
output(` ${risk.file} [${risk.kind}] (blast radius: ${risk.blastRadius}, tension: ${risk.tension.toFixed(2)})`);
output(` ${risk.evidence}`);
}
}
});

// ── Subcommand: dead-exports ───────────────────────────────
Expand Down
8 changes: 8 additions & 0 deletions src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,10 @@ export interface ForcesResult {
tensionFiles: Array<{ file: string; tension: number; pulledBy: Array<{ module: string; strength: number; symbols: string[] }>; recommendation: string }>;
bridgeFiles: Array<{ file: string; betweenness: number; connects: string[]; role: string }>;
extractionCandidates: Array<{ target: string; escapeVelocity: number; recommendation: string }>;
shallowModules: Array<{ module: string; files: number; exports: number; exportsPerFile: number; cohesion: number; locPerExport: number; evidence: string }>;
deepModules: Array<{ module: string; files: number; exports: number; exportsPerFile: number; locPerExport: number; dependedByModules: number; evidence: string }>;
seamCandidates: Array<{ target: string; scope: string; exposedSymbols: number; fanIn: number; dependentModules: number; evidence: string }>;
localityRisks: Array<{ file: string; kind: string; tension: number; blastRadius: number; isBridge: boolean; pulledByModuleCount: number; evidence: string }>;
summary: string;
}

Expand Down Expand Up @@ -568,6 +572,10 @@ export function computeForces(
tensionFiles,
bridgeFiles: graph.forceAnalysis.bridgeFiles,
extractionCandidates,
shallowModules: graph.forceAnalysis.shallowModules,
deepModules: graph.forceAnalysis.deepModules,
seamCandidates: graph.forceAnalysis.seamCandidates,
localityRisks: graph.forceAnalysis.localityRisks,
summary: graph.forceAnalysis.summary,
};
}
Expand Down
Loading
Loading