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: 2 additions & 0 deletions docs/mcp-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ Detailed context for a single file.
**Input:** `{ filePath: string }` (relative path)
**Returns:** path, loc, exports, imports (with symbols, isTypeOnly, weight), dependents (with symbols, isTypeOnly, weight), metrics (all FileMetrics including churn, complexity, blastRadius, deadExports, hasTests, testFile)

**Path normalization:** Backslashes are normalized to forward slashes. Common prefixes (`src/`, `lib/`, `app/`) are stripped once from the leading position before graph lookup. If the file is not found, the error includes up to 3 suggested similar paths.

**Use when:** Before modifying a file, understand its role, connections, and risk profile.
**Not for:** Symbol-level detail (use symbol_context).

Expand Down
7 changes: 4 additions & 3 deletions docs/metrics.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,16 @@ All metrics are computed per-file and stored in `FileMetrics`. Module-level aggr
|--------|------|-------|-------------|
| **cohesion** | number | 0-1 | `internalDeps / (internalDeps + externalDeps)`. 1=fully internal. |
| **escapeVelocity** | number | 0-1 | Readiness for extraction. High = few internal deps, many external consumers. |
| **verdict** | string | COHESIVE/MODERATE/JUNK_DRAWER | Cohesion >= 0.6 = COHESIVE, >= 0.4 = MODERATE, else JUNK_DRAWER. |
| **verdict** | string | LEAF/COHESIVE/MODERATE/JUNK_DRAWER | Single non-test file = LEAF (cohesion meaningless). Otherwise: cohesion >= 0.6 = COHESIVE, >= 0.4 = MODERATE, else JUNK_DRAWER. |

## Force Analysis

| Signal | Threshold | Meaning |
|--------|-----------|---------|
| **Tension file** | tension > 0.3 | File pulled by 2+ modules with roughly equal strength. Split candidate. |
| **Tension file** | tension > 0.3 | File pulled by 2+ modules with roughly equal strength. Split candidate. Type hubs (`types.ts`, `constants.ts`, `config.ts`) and entry points (`cli.ts`, `main.ts`, `app.ts`, `server.ts`) get suppressed split recommendations. |
| **Bridge file** | betweenness > 0.05, connects 2+ modules | Removing it disconnects parts of the graph. Critical path. |
| **Junk drawer** | module cohesion < 0.4 | Module with mostly external deps. Needs restructuring. |
| **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. |

## Complexity Scoring
Expand Down
1 change: 1 addition & 0 deletions specs/history.log
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@
2026-03-11 | shipped | fix-dead-export-false-positives | 2h→1.5h | 1d | Fix 33% false positive rate: merge duplicate imports, include same-file calls, call graph consumption. 8 regression tests.
2026-03-11 | shipped | fix-error-handling | 1h→0.5h | 1d | Consistent impact_analysis error handling, LOC off-by-one fix, empty file guard. 17 regression tests.
2026-03-11 | shipped | fix-metrics-test-files | 2h→1.5h | 1d | Exclude test files from coverage/coupling metrics, isTestFile detection, coupling formula fix. 19 regression tests.
2026-03-11 | shipped | feat-metric-quality | 3h→2h | 1d | LEAF verdict for single-file modules, tension suppression for type hubs/entry points, file_context path normalization. 20 regression tests.
378 changes: 378 additions & 0 deletions specs/shipped/2026-03-11-feat-metric-quality.md

Large diffs are not rendered by default.

209 changes: 208 additions & 1 deletion src/analyzer/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ describe("analyzeGraph", () => {
expect(result.forceAnalysis.moduleCohesion.length).toBeGreaterThan(0);

const verdicts = result.forceAnalysis.moduleCohesion.map((m) => m.verdict);
expect(verdicts.every((v) => ["COHESIVE", "MODERATE", "JUNK_DRAWER"].includes(v))).toBe(true);
expect(verdicts.every((v) => ["COHESIVE", "MODERATE", "JUNK_DRAWER", "LEAF"].includes(v))).toBe(true);
});

it("detects tension files pulled by multiple modules", () => {
Expand Down Expand Up @@ -544,3 +544,210 @@ describe("computeGroups", () => {
}
});
});

describe("LEAF verdict for single-file modules", () => {
it("AC-1: single non-test file module gets LEAF verdict", () => {
const files = [
makeFile("src/search/index.ts"),
makeFile("src/parser/a.ts", { imports: [imp("src/parser/b.ts")] }),
makeFile("src/parser/b.ts"),
];
const built = buildGraph(files);
const result = analyzeGraph(built, files);

const searchMod = result.forceAnalysis.moduleCohesion.find((m) => m.path === "src/search/");
expect(searchMod).toBeDefined();
expect(searchMod?.verdict).toBe("LEAF");
});

it("AC-E2: 2-file module with 0 internal deps gets JUNK_DRAWER, not LEAF", () => {
const files = [
makeFile("src/grab/a.ts", { imports: [imp("src/other/x.ts")] }),
makeFile("src/grab/b.ts", { imports: [imp("src/other/y.ts")] }),
makeFile("src/other/x.ts"),
makeFile("src/other/y.ts"),
];
const built = buildGraph(files);
const result = analyzeGraph(built, files);

const grabMod = result.forceAnalysis.moduleCohesion.find((m) => m.path === "src/grab/");
expect(grabMod).toBeDefined();
expect(grabMod?.verdict).toBe("JUNK_DRAWER");
});

it("EC1: 1-file 0-dep module gets LEAF", () => {
const files = [
makeFile("src/lonely/index.ts"),
];
const built = buildGraph(files);
const result = analyzeGraph(built, files);

const mod = result.forceAnalysis.moduleCohesion.find((m) => m.path === "src/lonely/");
expect(mod).toBeDefined();
expect(mod?.verdict).toBe("LEAF");
});

it("EC2: 1-file module with outgoing deps still gets LEAF", () => {
const files = [
makeFile("src/single/index.ts", { imports: [imp("src/other/a.ts"), imp("src/other/b.ts")] }),
makeFile("src/other/a.ts"),
makeFile("src/other/b.ts"),
];
const built = buildGraph(files);
const result = analyzeGraph(built, files);

const singleMod = result.forceAnalysis.moduleCohesion.find((m) => m.path === "src/single/");
expect(singleMod).toBeDefined();
expect(singleMod?.verdict).toBe("LEAF");
});

it("AC-10/EC6: module with 1 prod file + 1 test file gets LEAF", () => {
const files = [
makeFile("src/community/index.ts"),
makeFile("src/community/index.test.ts", { isTestFile: true }),
];
const built = buildGraph(files);
const result = analyzeGraph(built, files);

const mod = result.forceAnalysis.moduleCohesion.find((m) => m.path === "src/community/");
expect(mod).toBeDefined();
expect(mod?.verdict).toBe("LEAF");
});

it("EC7: module with 2 prod files + 1 test file is NOT LEAF", () => {
const files = [
makeFile("src/mod/a.ts", { imports: [imp("src/other/x.ts")] }),
makeFile("src/mod/b.ts", { imports: [imp("src/other/y.ts")] }),
makeFile("src/mod/a.test.ts", { isTestFile: true }),
makeFile("src/other/x.ts"),
makeFile("src/other/y.ts"),
];
const built = buildGraph(files);
const result = analyzeGraph(built, files);

const mod = result.forceAnalysis.moduleCohesion.find((m) => m.path === "src/mod/");
expect(mod).toBeDefined();
expect(mod?.verdict).not.toBe("LEAF");
});

it("AC-6: summary does not count LEAF modules as junk-drawer", () => {
const files = [
makeFile("src/search/index.ts"),
makeFile("src/grab/a.ts", { imports: [imp("src/other/x.ts")] }),
makeFile("src/grab/b.ts", { imports: [imp("src/other/y.ts")] }),
makeFile("src/other/x.ts"),
makeFile("src/other/y.ts"),
];
const built = buildGraph(files);
const result = analyzeGraph(built, files);

// search/ is LEAF and should NOT be counted in junk-drawer summary
expect(result.forceAnalysis.summary).not.toContain("src/search/");
// grab/ is JUNK_DRAWER and should be in summary
expect(result.forceAnalysis.summary).toContain("src/grab/");
});
});

describe("tension suppression for type hubs and entry points", () => {
it("AC-2: type hub file gets suppressed split recommendation", () => {
// types/index.ts is pulled by two different modules
const files = [
makeFile("src/types/index.ts", {
imports: [imp("src/a/x.ts"), imp("src/b/y.ts")],
exports: [
{ name: "MyType", type: "interface", loc: 1, isDefault: false, complexity: 1 },
],
}),
makeFile("src/a/x.ts"),
makeFile("src/b/y.ts"),
];
const built = buildGraph(files);
const result = analyzeGraph(built, files);

const typesFile = result.forceAnalysis.tensionFiles.find(
(t) => t.file === "src/types/index.ts"
);
if (typesFile) {
expect(typesFile.recommendation).toContain("not recommended");
expect(typesFile.recommendation).not.toContain("Split into");
}
});

it("AC-3: entry point file gets suppressed split recommendation", () => {
// cli.ts is pulled by two different modules
const files = [
makeFile("cli.ts", {
imports: [imp("src/a/x.ts"), imp("src/b/y.ts")],
}),
makeFile("src/a/x.ts"),
makeFile("src/b/y.ts"),
];
const built = buildGraph(files);
const result = analyzeGraph(built, files);

const cliFile = result.forceAnalysis.tensionFiles.find(
(t) => t.file === "cli.ts"
);
if (cliFile) {
expect(cliFile.recommendation).toContain("not recommended");
expect(cliFile.recommendation).not.toContain("Split into");
}
});

it("EC4: types.ts in nested module gets suppressed split rec", () => {
const files = [
makeFile("src/core/types.ts", {
imports: [imp("src/a/x.ts"), imp("src/b/y.ts")],
}),
makeFile("src/a/x.ts"),
makeFile("src/b/y.ts"),
];
const built = buildGraph(files);
const result = analyzeGraph(built, files);

const typesFile = result.forceAnalysis.tensionFiles.find(
(t) => t.file === "src/core/types.ts"
);
if (typesFile) {
expect(typesFile.recommendation).toContain("not recommended");
}
});

it("EC5: entry point at root (main.ts, server.ts) gets suppressed", () => {
const files = [
makeFile("main.ts", {
imports: [imp("src/a/x.ts"), imp("src/b/y.ts")],
}),
makeFile("src/a/x.ts"),
makeFile("src/b/y.ts"),
];
const built = buildGraph(files);
const result = analyzeGraph(built, files);

const mainFile = result.forceAnalysis.tensionFiles.find(
(t) => t.file === "main.ts"
);
if (mainFile) {
expect(mainFile.recommendation).toContain("not recommended");
}
});

it("regular file with tension still gets split recommendation", () => {
const files = [
makeFile("utils.ts", {
imports: [imp("src/a/x.ts"), imp("src/b/y.ts")],
}),
makeFile("src/a/x.ts"),
makeFile("src/b/y.ts"),
];
const built = buildGraph(files);
const result = analyzeGraph(built, files);

const utilsFile = result.forceAnalysis.tensionFiles.find(
(t) => t.file === "utils.ts"
);
if (utilsFile) {
expect(utilsFile.recommendation).toContain("Split into");
}
});
});
56 changes: 49 additions & 7 deletions src/analyzer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,17 +317,51 @@ function computeModuleMetrics(
return moduleMetrics;
}

function isTestFilePath(fileId: string): boolean {
return fileId.includes(".test.") || fileId.includes(".spec.") || fileId.includes("__tests__/");
}

function isTypeHubFile(fileId: string): boolean {
const basename = path.basename(fileId);
const dir = path.dirname(fileId);
const dirBasename = path.basename(dir);
if (basename === "types.ts" || basename === "constants.ts" || basename === "config.ts") return true;
if (basename === "index.ts" && dirBasename === "types") return true;
return false;
}

function isEntryPointFile(fileId: string): boolean {
const basename = path.basename(fileId);
const entryNames = ["cli.ts", "main.ts", "app.ts", "server.ts"];
if (entryNames.includes(basename)) return true;
const dir = path.dirname(fileId);
if (basename === "index.ts" && (dir === "." || dir === "")) return true;
return false;
}

function computeForceAnalysis(
graph: Graph,
fileNodes: GraphNode[],
fileMetrics: Map<string, FileMetrics>,
moduleMetrics: Map<string, ModuleMetrics>,
betweennessScores: Map<string, number>
): ForceAnalysis {
// Group files by module for non-test file counting
const moduleFiles = new Map<string, GraphNode[]>();
for (const node of fileNodes) {
const existing = moduleFiles.get(node.module) ?? [];
existing.push(node);
moduleFiles.set(node.module, existing);
}

// Module cohesion verdicts
type CohesionVerdict = "COHESIVE" | "MODERATE" | "JUNK_DRAWER";
type CohesionVerdict = "COHESIVE" | "MODERATE" | "JUNK_DRAWER" | "LEAF";
const moduleCohesion = [...moduleMetrics.values()].map((m) => {
const verdict: CohesionVerdict = m.cohesion >= 0.6 ? "COHESIVE" : m.cohesion >= 0.4 ? "MODERATE" : "JUNK_DRAWER";
const files = moduleFiles.get(m.path) ?? [];
const nonTestFileCount = files.filter((f) => !isTestFilePath(f.id)).length;
const verdict: CohesionVerdict = nonTestFileCount <= 1
? "LEAF"
: m.cohesion >= 0.6 ? "COHESIVE" : m.cohesion >= 0.4 ? "MODERATE" : "JUNK_DRAWER";
return { ...m, verdict };
});

Expand Down Expand Up @@ -375,16 +409,24 @@ function computeForceAnalysis(
const tension = maxEntropy > 0 ? Math.round((entropy / maxEntropy) * 100) / 100 : 0;

if (tension > 0.3) {
const topModules = pulls
.sort((a, b) => b.strength - a.strength)
.slice(0, 2)
.map((p) => path.basename(p.module.replace(/\/$/, "")));
let recommendation: string;
if (isTypeHubFile(file.id)) {
recommendation = "Type hub — split not recommended (design-intentional shared types)";
} else if (isEntryPointFile(file.id)) {
recommendation = "Entry point — split not recommended (CLI/app entry point)";
} else {
const topModules = pulls
.sort((a, b) => b.strength - a.strength)
.slice(0, 2)
.map((p) => path.basename(p.module.replace(/\/$/, "")));
recommendation = `Split into ${topModules.map((m) => `${m}-${path.basename(file.id)}`).join(" and ")}`;
}

tensionFiles.push({
file: file.id,
tension,
pulledBy: pulls.sort((a, b) => b.strength - a.strength),
recommendation: `Split into ${topModules.map((m) => `${m}-${path.basename(file.id)}`).join(" and ")}`,
recommendation,
});
}
}
Expand Down
Loading
Loading