From e4fe60a8b20843caf433a36eb08fa43013b65bd9 Mon Sep 17 00:00:00 2001 From: bntvllnt <32437578+bntvllnt@users.noreply.github.com> Date: Thu, 30 Apr 2026 00:52:33 +0200 Subject: [PATCH 1/2] feat(architecture): extend force analysis signals --- docs/cli-reference.md | 2 +- docs/mcp-tools.md | 2 +- docs/metrics.md | 4 + src/analyzer/index.test.ts | 55 +++++++++++++ src/analyzer/index.ts | 162 ++++++++++++++++++++++++++++++++++++- src/cli.ts | 36 +++++++++ src/core/index.ts | 8 ++ src/types/index.ts | 47 +++++++++++ 8 files changed, 312 insertions(+), 4 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 0d018cd..764e21b 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -84,7 +84,7 @@ Architectural force analysis. codebase-intelligence forces [--cohesion ] [--tension ] [--escape ] [--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 diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index 4315ffd..279497d 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -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). diff --git a/docs/metrics.md b/docs/metrics.md index 2fe1584..6a58c33 100644 --- a/docs/metrics.md +++ b/docs/metrics.md @@ -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 diff --git a/src/analyzer/index.test.ts b/src/analyzer/index.test.ts index 5b3628c..c9bb617 100644 --- a/src/analyzer/index.test.ts +++ b/src/analyzer/index.test.ts @@ -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")] }), @@ -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"); }); }); diff --git a/src/analyzer/index.ts b/src/analyzer/index.ts index 38f5ad4..d44d22c 100644 --- a/src/analyzer/index.ts +++ b/src/analyzer/index.ts @@ -11,6 +11,10 @@ import type { TensionFile, BridgeFile, ExtractionCandidate, + ShallowModule, + DeepModule, + SeamCandidate, + LocalityRisk, CodebaseGraph, GraphNode, SymbolMetrics, @@ -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) { @@ -339,12 +350,46 @@ function isEntryPointFile(fileId: string): boolean { return false; } +function getModuleExportStats( + files: GraphNode[], + parsedByPath: Map, +): { 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(); + + graph.forEachNode((nodeId: string, attrs: Record) => { + 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, moduleMetrics: Map, - betweennessScores: Map + betweennessScores: Map, + parsedByPath: Map, ): ForceAnalysis { // Group files by module for non-test file counting const moduleFiles = new Map(); @@ -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[] = []; @@ -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."); } @@ -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(". ") + ".", }; } diff --git a/src/cli.ts b/src/cli.ts index e87191d..347c902 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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 ─────────────────────────────── diff --git a/src/core/index.ts b/src/core/index.ts index 4d0177f..51eb577 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -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; } @@ -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, }; } diff --git a/src/types/index.ts b/src/types/index.ts index 887e462..cc94223 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -138,11 +138,58 @@ export interface ExtractionCandidate { recommendation: string; } +export interface ShallowModule { + module: string; + files: number; + exports: number; + exportsPerFile: number; + cohesion: number; + locPerExport: number; + evidence: string; +} + +export interface DeepModule { + module: string; + files: number; + exports: number; + exportsPerFile: number; + locPerExport: number; + dependedByModules: number; + evidence: string; +} + +export type SeamScope = "file" | "module"; + +export interface SeamCandidate { + target: string; + scope: SeamScope; + exposedSymbols: number; + fanIn: number; + dependentModules: number; + evidence: string; +} + +export type LocalityRiskKind = "ripple-zone" | "bridge-blast" | "concept-spread"; + +export interface LocalityRisk { + file: string; + kind: LocalityRiskKind; + tension: number; + blastRadius: number; + isBridge: boolean; + pulledByModuleCount: number; + evidence: string; +} + export interface ForceAnalysis { moduleCohesion: Array; tensionFiles: TensionFile[]; bridgeFiles: BridgeFile[]; extractionCandidates: ExtractionCandidate[]; + shallowModules: ShallowModule[]; + deepModules: DeepModule[]; + seamCandidates: SeamCandidate[]; + localityRisks: LocalityRisk[]; summary: string; } From 87dc3be684fc9a7b8b4f7a73c4c934332cf4580f Mon Sep 17 00:00:00 2001 From: bntvllnt <32437578+bntvllnt@users.noreply.github.com> Date: Thu, 30 Apr 2026 01:03:15 +0200 Subject: [PATCH 2/2] test(architecture): cover forces via cli and mcp --- tests/cli-commands.test.ts | 58 +++++++++++++++++++++++++++++++++++--- tests/mcp-tools.test.ts | 23 ++++++++++++++- 2 files changed, 76 insertions(+), 5 deletions(-) diff --git a/tests/cli-commands.test.ts b/tests/cli-commands.test.ts index 3f3284f..a8e6136 100644 --- a/tests/cli-commands.test.ts +++ b/tests/cli-commands.test.ts @@ -1,5 +1,6 @@ +import { execFileSync } from "child_process"; import { describe, it, expect } from "vitest"; -import { getFixturePipeline } from "./helpers/pipeline.js"; +import { getFixturePipeline, getFixtureSrcPath } from "./helpers/pipeline.js"; import { computeOverview, computeFileContext, @@ -390,6 +391,10 @@ describe("CLI core commands (integration)", () => { expect(Array.isArray(result.tensionFiles)).toBe(true); expect(Array.isArray(result.bridgeFiles)).toBe(true); expect(Array.isArray(result.extractionCandidates)).toBe(true); + expect(Array.isArray(result.shallowModules)).toBe(true); + expect(Array.isArray(result.deepModules)).toBe(true); + expect(Array.isArray(result.seamCandidates)).toBe(true); + expect(Array.isArray(result.localityRisks)).toBe(true); expect(typeof result.summary).toBe("string"); }); @@ -409,6 +414,54 @@ describe("CLI core commands (integration)", () => { expect(strict.tensionFiles.length).toBeGreaterThanOrEqual(lenient.tensionFiles.length); }); + + it("returns evidence on derived architecture signals", () => { + const { codebaseGraph } = getFixturePipeline(); + const result = computeForces(codebaseGraph); + + for (const item of result.shallowModules) expect(item.evidence.length).toBeGreaterThan(0); + for (const item of result.deepModules) expect(item.evidence.length).toBeGreaterThan(0); + for (const item of result.seamCandidates) expect(item.evidence.length).toBeGreaterThan(0); + for (const item of result.localityRisks) expect(item.evidence.length).toBeGreaterThan(0); + }); + }); + + describe("forces CLI command", () => { + it("returns the new architecture signals in JSON output", () => { + const stdout = execFileSync( + "pnpm", + ["exec", "tsx", "src/cli.ts", "forces", getFixtureSrcPath(), "--json", "--force"], + { cwd: process.cwd(), encoding: "utf8" }, + ); + const parsed = JSON.parse(stdout) as Record; + + expect(parsed).toHaveProperty("moduleCohesion"); + expect(parsed).toHaveProperty("tensionFiles"); + expect(parsed).toHaveProperty("bridgeFiles"); + expect(parsed).toHaveProperty("extractionCandidates"); + expect(parsed).toHaveProperty("shallowModules"); + expect(parsed).toHaveProperty("deepModules"); + expect(parsed).toHaveProperty("seamCandidates"); + expect(parsed).toHaveProperty("localityRisks"); + expect(parsed).toHaveProperty("summary"); + }); + + it("prints the new architecture sections in human output when data exists", () => { + const stdout = execFileSync( + "pnpm", + ["exec", "tsx", "src/cli.ts", "forces", getFixtureSrcPath(), "--force"], + { cwd: process.cwd(), encoding: "utf8" }, + ); + const { codebaseGraph } = getFixturePipeline(); + const result = computeForces(codebaseGraph); + + expect(stdout).toContain("Force Analysis"); + expect(stdout).toContain("Module Cohesion"); + if (result.shallowModules.length > 0) expect(stdout).toContain("Shallow Modules"); + if (result.deepModules.length > 0) expect(stdout).toContain("Deep Modules"); + if (result.seamCandidates.length > 0) expect(stdout).toContain("Seam Candidates"); + if (result.localityRisks.length > 0) expect(stdout).toContain("Locality Risks"); + }); }); describe("computeDeadExports", () => { @@ -463,7 +516,6 @@ describe("CLI core commands (integration)", () => { it("returns context for a known symbol", () => { const { codebaseGraph } = getFixturePipeline(); const firstSymbol = [...codebaseGraph.symbolMetrics.values()][0]; - if (!firstSymbol) return; const result = computeSymbolContext(codebaseGraph, firstSymbol.name); @@ -538,7 +590,6 @@ describe("CLI core commands (integration)", () => { it("returns impact levels for a known symbol", () => { const { codebaseGraph } = getFixturePipeline(); const firstSymbol = [...codebaseGraph.symbolMetrics.values()][0]; - if (!firstSymbol) return; const result = impactAnalysis(codebaseGraph, firstSymbol.name); @@ -559,7 +610,6 @@ describe("CLI core commands (integration)", () => { it("finds references for a known symbol", () => { const { codebaseGraph } = getFixturePipeline(); const firstSymbol = [...codebaseGraph.symbolMetrics.values()][0]; - if (!firstSymbol) return; const result = renameSymbol(codebaseGraph, firstSymbol.name, "newName", true); diff --git a/tests/mcp-tools.test.ts b/tests/mcp-tools.test.ts index 02fb35e..b6a76a6 100644 --- a/tests/mcp-tools.test.ts +++ b/tests/mcp-tools.test.ts @@ -187,12 +187,16 @@ describe("Tool 5: get_module_structure", () => { }); describe("Tool 6: analyze_forces", () => { - it("returns cohesion, tension, bridges, extraction candidates", async () => { + it("returns cohesion, tension, bridges, extraction candidates, and architecture signals", async () => { const r = await callTool("analyze_forces"); expect(r).toHaveProperty("moduleCohesion"); expect(r).toHaveProperty("tensionFiles"); expect(r).toHaveProperty("bridgeFiles"); expect(r).toHaveProperty("extractionCandidates"); + expect(r).toHaveProperty("shallowModules"); + expect(r).toHaveProperty("deepModules"); + expect(r).toHaveProperty("seamCandidates"); + expect(r).toHaveProperty("localityRisks"); expect(r).toHaveProperty("summary"); expect(r).toHaveProperty("nextSteps"); const cohesion = r.moduleCohesion as Array>; @@ -223,6 +227,23 @@ describe("Tool 6: analyze_forces", () => { } } }); + + it("returns evidence-bearing derived signals when present", async () => { + const r = await callTool("analyze_forces"); + + for (const item of r.shallowModules as Array<{ evidence: string }>) { + expect(item.evidence.length).toBeGreaterThan(0); + } + for (const item of r.deepModules as Array<{ evidence: string }>) { + expect(item.evidence.length).toBeGreaterThan(0); + } + for (const item of r.seamCandidates as Array<{ evidence: string }>) { + expect(item.evidence.length).toBeGreaterThan(0); + } + for (const item of r.localityRisks as Array<{ evidence: string }>) { + expect(item.evidence.length).toBeGreaterThan(0); + } + }); }); describe("Tool 7: find_dead_exports", () => {