Skip to content

Commit 6fc5287

Browse files
authored
feat: extend analyze_forces architecture signals (#31)
## Summary - extend `analyze_forces` / `forces` with shallow, deep, seam, and locality signals - keep existing output intact while adding new sections for MCP and CLI consumers - add analyzer tests and docs for the new architecture signals ## Changes - add `shallowModules`, `deepModules`, `seamCandidates`, `localityRisks` to force analysis types - compute conservative heuristics in `src/analyzer/index.ts` - expose the new fields through `src/core/index.ts` and CLI `forces` - update `docs/mcp-tools.md`, `docs/cli-reference.md`, and `docs/metrics.md` ## Validation - `pnpm lint` - `pnpm typecheck` - `pnpm build` - `pnpm test -- src/analyzer/index.test.ts`
1 parent e244038 commit 6fc5287

10 files changed

Lines changed: 388 additions & 9 deletions

File tree

‎docs/cli-reference.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ Architectural force analysis.
8484
codebase-intelligence forces <path> [--cohesion <n>] [--tension <n>] [--escape <n>] [--json] [--force]
8585
```
8686

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

8989
### dead-exports
9090

‎docs/mcp-tools.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ Module-level architecture with cross-module dependencies.
6060
Architectural force analysis — module health, misplaced files, bridge files.
6161

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

6565
**Use when:** "What's architecturally wrong?" "Which modules are coupled?" "What files should be moved?"
6666
**Not for:** File-level metrics (use find_hotspots).

‎docs/metrics.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ All metrics are computed per-file and stored in `FileMetrics`. Module-level aggr
3838
| **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. |
3939
| **Junk drawer** | module cohesion < 0.4, 2+ non-test files | Module with mostly external deps. Needs restructuring. |
4040
| **Extraction candidate** | escapeVelocity >= 0.5 | Module with 0 internal deps, consumed by many others. Extract to package. |
41+
| **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. |
42+
| **Deep module** | few exports, high LOC per export, reused across modules | Small interface hiding larger useful behavior. Good leverage for callers. |
43+
| **Seam candidate** | exported file/module used by 2+ external modules | Natural place to stabilize an interface and vary implementation behind it. |
44+
| **Locality risk** | tension + blast radius, or bridge + blast radius | Changes to one concept are likely to spread across multiple files/modules. |
4145

4246
## Complexity Scoring
4347

‎src/analyzer/index.test.ts‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,57 @@ describe("analyzeGraph", () => {
208208
expect(result.forceAnalysis.summary).toContain("healthy");
209209
});
210210

211+
it("detects shallow modules from wide public surface and low locality", () => {
212+
const files = [
213+
makeFile("src/shallow/api.ts", {
214+
loc: 18,
215+
exports: [
216+
{ name: "one", type: "function", loc: 2, isDefault: false, complexity: 1 },
217+
{ name: "two", type: "function", loc: 2, isDefault: false, complexity: 1 },
218+
{ name: "three", type: "function", loc: 2, isDefault: false, complexity: 1 },
219+
{ name: "four", type: "function", loc: 2, isDefault: false, complexity: 1 },
220+
],
221+
imports: [imp("src/shared/base.ts")],
222+
}),
223+
makeFile("src/shallow/helpers.ts", {
224+
loc: 18,
225+
exports: [
226+
{ name: "five", type: "function", loc: 2, isDefault: false, complexity: 1 },
227+
{ name: "six", type: "function", loc: 2, isDefault: false, complexity: 1 },
228+
],
229+
imports: [imp("src/shared/base.ts")],
230+
}),
231+
makeFile("src/shared/base.ts"),
232+
];
233+
const built = buildGraph(files);
234+
const result = analyzeGraph(built, files);
235+
236+
expect(result.forceAnalysis.shallowModules).toEqual(
237+
expect.arrayContaining([
238+
expect.objectContaining({ module: "src/shallow/", exports: 6 }),
239+
]),
240+
);
241+
});
242+
243+
it("detects locality risks for files with tension and broad blast radius", () => {
244+
const files = [
245+
makeFile("src/shared/utils.ts", {
246+
imports: [imp("src/a/service.ts"), imp("src/b/service.ts")],
247+
}),
248+
makeFile("src/a/service.ts", { imports: [imp("src/shared/utils.ts")] }),
249+
makeFile("src/b/service.ts", { imports: [imp("src/shared/utils.ts")] }),
250+
makeFile("src/feature/consumer.ts", { imports: [imp("src/shared/utils.ts")] }),
251+
];
252+
const built = buildGraph(files);
253+
const result = analyzeGraph(built, files);
254+
255+
expect(result.forceAnalysis.localityRisks).toEqual(
256+
expect.arrayContaining([
257+
expect.objectContaining({ file: "src/shared/utils.ts" }),
258+
]),
259+
);
260+
});
261+
211262
it("handles circular dependencies in stats", () => {
212263
const files = [
213264
makeFile("a.ts", { imports: [imp("b.ts")] }),
@@ -244,6 +295,10 @@ describe("analyzeGraph", () => {
244295
expect(result.forceAnalysis).toHaveProperty("tensionFiles");
245296
expect(result.forceAnalysis).toHaveProperty("bridgeFiles");
246297
expect(result.forceAnalysis).toHaveProperty("extractionCandidates");
298+
expect(result.forceAnalysis).toHaveProperty("shallowModules");
299+
expect(result.forceAnalysis).toHaveProperty("deepModules");
300+
expect(result.forceAnalysis).toHaveProperty("seamCandidates");
301+
expect(result.forceAnalysis).toHaveProperty("localityRisks");
247302
expect(result.forceAnalysis).toHaveProperty("summary");
248303
});
249304
});

‎src/analyzer/index.ts‎

Lines changed: 160 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ import type {
1111
TensionFile,
1212
BridgeFile,
1313
ExtractionCandidate,
14+
ShallowModule,
15+
DeepModule,
16+
SeamCandidate,
17+
LocalityRisk,
1418
CodebaseGraph,
1519
GraphNode,
1620
SymbolMetrics,
@@ -123,7 +127,14 @@ export function analyzeGraph(built: BuiltGraph, parsedFiles?: ParsedFile[]): Cod
123127
const groups = computeGroups(fileNodes, fileMetrics);
124128

125129
// Centrifuge force analysis
126-
const forceAnalysis = computeForceAnalysis(graph, fileNodes, fileMetrics, moduleMetrics, betweennessScores);
130+
const forceAnalysis = computeForceAnalysis(
131+
graph,
132+
fileNodes,
133+
fileMetrics,
134+
moduleMetrics,
135+
betweennessScores,
136+
parsedByPath,
137+
);
127138

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

353+
function getModuleExportStats(
354+
files: GraphNode[],
355+
parsedByPath: Map<string, ParsedFile>,
356+
): { exportCount: number; loc: number } {
357+
let exportCount = 0;
358+
let loc = 0;
359+
360+
for (const file of files) {
361+
if (isTestFilePath(file.id)) continue;
362+
loc += file.loc;
363+
exportCount += parsedByPath.get(file.id)?.exports.length ?? 0;
364+
}
365+
366+
return { exportCount, loc };
367+
}
368+
369+
function dependentModuleCountForModule(modulePath: string, graph: Graph): number {
370+
const dependents = new Set<string>();
371+
372+
graph.forEachNode((nodeId: string, attrs: Record<string, unknown>) => {
373+
if (attrs.type !== "file") return;
374+
if ((attrs.module as string) !== modulePath) return;
375+
376+
for (const neighbor of graph.inNeighbors(nodeId)) {
377+
if (graph.getNodeAttribute(neighbor, "type") !== "file") continue;
378+
const neighborModule = graph.getNodeAttribute(neighbor, "module") as string;
379+
if (neighborModule !== modulePath) dependents.add(neighborModule);
380+
}
381+
});
382+
383+
return dependents.size;
384+
}
385+
342386
function computeForceAnalysis(
343387
graph: Graph,
344388
fileNodes: GraphNode[],
345389
fileMetrics: Map<string, FileMetrics>,
346390
moduleMetrics: Map<string, ModuleMetrics>,
347-
betweennessScores: Map<string, number>
391+
betweennessScores: Map<string, number>,
392+
parsedByPath: Map<string, ParsedFile>,
348393
): ForceAnalysis {
349394
// Group files by module for non-test file counting
350395
const moduleFiles = new Map<string, GraphNode[]>();
@@ -471,6 +516,109 @@ function computeForceAnalysis(
471516
});
472517
}
473518

519+
const shallowModules: ShallowModule[] = [];
520+
const deepModules: DeepModule[] = [];
521+
const seamCandidates: SeamCandidate[] = [];
522+
const localityRisks: LocalityRisk[] = [];
523+
524+
for (const mod of moduleMetrics.values()) {
525+
const files = moduleFiles.get(mod.path) ?? [];
526+
const nonTestFiles = files.filter((file) => !isTestFilePath(file.id));
527+
if (nonTestFiles.length === 0) continue;
528+
529+
const { exportCount, loc } = getModuleExportStats(nonTestFiles, parsedByPath);
530+
const exportsPerFile = exportCount / nonTestFiles.length;
531+
const locPerExport = exportCount > 0 ? loc / exportCount : loc;
532+
const dependedByModules = dependentModuleCountForModule(mod.path, graph);
533+
534+
if (
535+
exportCount >= nonTestFiles.length * 2
536+
&& locPerExport <= 20
537+
&& mod.cohesion <= 0.5
538+
&& dependedByModules <= 1
539+
) {
540+
shallowModules.push({
541+
module: mod.path,
542+
files: nonTestFiles.length,
543+
exports: exportCount,
544+
exportsPerFile: Math.round(exportsPerFile * 100) / 100,
545+
cohesion: mod.cohesion,
546+
locPerExport: Math.round(locPerExport * 100) / 100,
547+
evidence: `${exportCount} exports across ${nonTestFiles.length} file(s), ${locPerExport.toFixed(1)} LOC/export, cohesion ${mod.cohesion.toFixed(2)}`,
548+
});
549+
}
550+
551+
if (
552+
exportCount > 0
553+
&& exportCount <= nonTestFiles.length
554+
&& locPerExport >= 25
555+
&& dependedByModules >= 1
556+
&& mod.cohesion >= 0.5
557+
) {
558+
deepModules.push({
559+
module: mod.path,
560+
files: nonTestFiles.length,
561+
exports: exportCount,
562+
exportsPerFile: Math.round(exportsPerFile * 100) / 100,
563+
locPerExport: Math.round(locPerExport * 100) / 100,
564+
dependedByModules,
565+
evidence: `${exportCount} exports hide ${loc} LOC across ${nonTestFiles.length} file(s); reused by ${dependedByModules} module(s)`,
566+
});
567+
}
568+
569+
if (exportCount > 0 && dependedByModules >= 2) {
570+
seamCandidates.push({
571+
target: mod.path,
572+
scope: "module",
573+
exposedSymbols: exportCount,
574+
fanIn: dependedByModules,
575+
dependentModules: dependedByModules,
576+
evidence: `${exportCount} exported symbol(s) used across ${dependedByModules} dependent module(s)`,
577+
});
578+
}
579+
}
580+
581+
for (const file of fileNodes) {
582+
const parsed = parsedByPath.get(file.id);
583+
const exposedSymbols = parsed?.exports.length ?? 0;
584+
const metrics = fileMetrics.get(file.id);
585+
if (!metrics) continue;
586+
587+
const tensionInfo = tensionFiles.find((item) => item.file === file.id);
588+
const pulledByModuleCount = tensionInfo?.pulledBy.length ?? 0;
589+
const kind: LocalityRisk["kind"] | null = tensionInfo && metrics.blastRadius >= 2
590+
? "ripple-zone"
591+
: metrics.isBridge && metrics.blastRadius >= 2
592+
? "bridge-blast"
593+
: pulledByModuleCount >= 2 && metrics.blastRadius >= 1
594+
? "concept-spread"
595+
: null;
596+
597+
if (kind) {
598+
localityRisks.push({
599+
file: file.id,
600+
kind,
601+
tension: metrics.tension,
602+
blastRadius: metrics.blastRadius,
603+
isBridge: metrics.isBridge,
604+
pulledByModuleCount,
605+
evidence: `blast radius ${metrics.blastRadius}, tension ${metrics.tension.toFixed(2)}, bridge=${String(metrics.isBridge)}`,
606+
});
607+
}
608+
609+
const dependentModules = tensionInfo?.pulledBy.length ?? 0;
610+
if (exposedSymbols > 0 && dependentModules >= 2 && metrics.fanIn >= 2) {
611+
seamCandidates.push({
612+
target: file.id,
613+
scope: "file",
614+
exposedSymbols,
615+
fanIn: metrics.fanIn,
616+
dependentModules,
617+
evidence: `${exposedSymbols} exported symbol(s), fan-in ${metrics.fanIn}, pulled by ${dependentModules} module(s)`,
618+
});
619+
}
620+
}
621+
474622
// Summary
475623
const junkDrawers = moduleCohesion.filter((m) => m.verdict === "JUNK_DRAWER");
476624
const summaryParts: string[] = [];
@@ -483,6 +631,12 @@ function computeForceAnalysis(
483631
if (extractionCandidates.length > 0) {
484632
summaryParts.push(`${extractionCandidates.map((e) => e.target).join(", ")} ready for extraction`);
485633
}
634+
if (shallowModules.length > 0) {
635+
summaryParts.push(`${shallowModules.length} shallow module candidate(s)`);
636+
}
637+
if (localityRisks.length > 0) {
638+
summaryParts.push(`${localityRisks.length} locality risk(s)`);
639+
}
486640
if (summaryParts.length === 0) {
487641
summaryParts.push("Codebase architecture looks healthy. No major force imbalances detected.");
488642
}
@@ -492,6 +646,10 @@ function computeForceAnalysis(
492646
tensionFiles: tensionFiles.sort((a, b) => b.tension - a.tension),
493647
bridgeFiles: bridgeFiles.sort((a, b) => b.betweenness - a.betweenness),
494648
extractionCandidates: extractionCandidates.sort((a, b) => b.escapeVelocity - a.escapeVelocity),
649+
shallowModules: shallowModules.sort((a, b) => b.exportsPerFile - a.exportsPerFile),
650+
deepModules: deepModules.sort((a, b) => b.locPerExport - a.locPerExport),
651+
seamCandidates: seamCandidates.sort((a, b) => b.dependentModules - a.dependentModules || b.fanIn - a.fanIn),
652+
localityRisks: localityRisks.sort((a, b) => b.blastRadius - a.blastRadius || b.tension - a.tension),
495653
summary: summaryParts.join(". ") + ".",
496654
};
497655
}

‎src/cli.ts‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -628,6 +628,42 @@ program
628628
output(` ${e.recommendation}`);
629629
}
630630
}
631+
632+
if (result.shallowModules.length > 0) {
633+
output(``);
634+
output(`Shallow Modules (${result.shallowModules.length}):`);
635+
for (const m of result.shallowModules) {
636+
output(` ${m.module} (${m.exports} exports, cohesion: ${m.cohesion.toFixed(2)})`);
637+
output(` ${m.evidence}`);
638+
}
639+
}
640+
641+
if (result.deepModules.length > 0) {
642+
output(``);
643+
output(`Deep Modules (${result.deepModules.length}):`);
644+
for (const m of result.deepModules) {
645+
output(` ${m.module} (${m.exports} exports, depended by: ${m.dependedByModules})`);
646+
output(` ${m.evidence}`);
647+
}
648+
}
649+
650+
if (result.seamCandidates.length > 0) {
651+
output(``);
652+
output(`Seam Candidates (${result.seamCandidates.length}):`);
653+
for (const seam of result.seamCandidates) {
654+
output(` ${seam.target} [${seam.scope}] (dependents: ${seam.dependentModules}, fan-in: ${seam.fanIn})`);
655+
output(` ${seam.evidence}`);
656+
}
657+
}
658+
659+
if (result.localityRisks.length > 0) {
660+
output(``);
661+
output(`Locality Risks (${result.localityRisks.length}):`);
662+
for (const risk of result.localityRisks) {
663+
output(` ${risk.file} [${risk.kind}] (blast radius: ${risk.blastRadius}, tension: ${risk.tension.toFixed(2)})`);
664+
output(` ${risk.evidence}`);
665+
}
666+
}
631667
});
632668

633669
// ── Subcommand: dead-exports ───────────────────────────────

‎src/core/index.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -538,6 +538,10 @@ export interface ForcesResult {
538538
tensionFiles: Array<{ file: string; tension: number; pulledBy: Array<{ module: string; strength: number; symbols: string[] }>; recommendation: string }>;
539539
bridgeFiles: Array<{ file: string; betweenness: number; connects: string[]; role: string }>;
540540
extractionCandidates: Array<{ target: string; escapeVelocity: number; recommendation: string }>;
541+
shallowModules: Array<{ module: string; files: number; exports: number; exportsPerFile: number; cohesion: number; locPerExport: number; evidence: string }>;
542+
deepModules: Array<{ module: string; files: number; exports: number; exportsPerFile: number; locPerExport: number; dependedByModules: number; evidence: string }>;
543+
seamCandidates: Array<{ target: string; scope: string; exposedSymbols: number; fanIn: number; dependentModules: number; evidence: string }>;
544+
localityRisks: Array<{ file: string; kind: string; tension: number; blastRadius: number; isBridge: boolean; pulledByModuleCount: number; evidence: string }>;
541545
summary: string;
542546
}
543547

@@ -568,6 +572,10 @@ export function computeForces(
568572
tensionFiles,
569573
bridgeFiles: graph.forceAnalysis.bridgeFiles,
570574
extractionCandidates,
575+
shallowModules: graph.forceAnalysis.shallowModules,
576+
deepModules: graph.forceAnalysis.deepModules,
577+
seamCandidates: graph.forceAnalysis.seamCandidates,
578+
localityRisks: graph.forceAnalysis.localityRisks,
571579
summary: graph.forceAnalysis.summary,
572580
};
573581
}

0 commit comments

Comments
 (0)