Skip to content

Commit 608094f

Browse files
authored
feat: metric quality improvements — LEAF verdict, tension suppression, path normalization (#13)
## Summary Fixes #9 (items 1-3; items 4-5 deferred to separate issues) - **LEAF verdict**: Single-file modules labeled LEAF instead of JUNK_DRAWER (cohesion meaningless with 1 file) - **Tension suppression**: Type hub files (high fan-in, 0 fan-out, all type exports) and entry points (0 fan-in, high fan-out) excluded from split recommendations - **Path normalization**: `file_context` now strips common prefixes (`src/`, `lib/`, `app/`) and suggests correct path in error messages ## Test plan - [x] Regression tests for all ACs - [x] All existing tests pass (updated verdict assertions) - [x] Quality gates: lint, typecheck, build, test
1 parent b6546d3 commit 608094f

9 files changed

Lines changed: 768 additions & 15 deletions

File tree

docs/mcp-tools.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ Detailed context for a single file.
1919
**Input:** `{ filePath: string }` (relative path)
2020
**Returns:** path, loc, exports, imports (with symbols, isTypeOnly, weight), dependents (with symbols, isTypeOnly, weight), metrics (all FileMetrics including churn, complexity, blastRadius, deadExports, hasTests, testFile)
2121

22+
**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.
23+
2224
**Use when:** Before modifying a file, understand its role, connections, and risk profile.
2325
**Not for:** Symbol-level detail (use symbol_context).
2426

docs/metrics.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,16 @@ All metrics are computed per-file and stored in `FileMetrics`. Module-level aggr
2727
|--------|------|-------|-------------|
2828
| **cohesion** | number | 0-1 | `internalDeps / (internalDeps + externalDeps)`. 1=fully internal. |
2929
| **escapeVelocity** | number | 0-1 | Readiness for extraction. High = few internal deps, many external consumers. |
30-
| **verdict** | string | COHESIVE/MODERATE/JUNK_DRAWER | Cohesion >= 0.6 = COHESIVE, >= 0.4 = MODERATE, else JUNK_DRAWER. |
30+
| **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. |
3131

3232
## Force Analysis
3333

3434
| Signal | Threshold | Meaning |
3535
|--------|-----------|---------|
36-
| **Tension file** | tension > 0.3 | File pulled by 2+ modules with roughly equal strength. Split candidate. |
36+
| **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. |
3737
| **Bridge file** | betweenness > 0.05, connects 2+ modules | Removing it disconnects parts of the graph. Critical path. |
38-
| **Junk drawer** | module cohesion < 0.4 | Module with mostly external deps. Needs restructuring. |
38+
| **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. |
39+
| **Junk drawer** | module cohesion < 0.4, 2+ non-test files | Module with mostly external deps. Needs restructuring. |
3940
| **Extraction candidate** | escapeVelocity >= 0.5 | Module with 0 internal deps, consumed by many others. Extract to package. |
4041

4142
## Complexity Scoring

specs/history.log

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@
33
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.
44
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.
55
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.
6+
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.

specs/shipped/2026-03-11-feat-metric-quality.md

Lines changed: 378 additions & 0 deletions
Large diffs are not rendered by default.

src/analyzer/index.test.ts

Lines changed: 208 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ describe("analyzeGraph", () => {
177177
expect(result.forceAnalysis.moduleCohesion.length).toBeGreaterThan(0);
178178

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

183183
it("detects tension files pulled by multiple modules", () => {
@@ -544,3 +544,210 @@ describe("computeGroups", () => {
544544
}
545545
});
546546
});
547+
548+
describe("LEAF verdict for single-file modules", () => {
549+
it("AC-1: single non-test file module gets LEAF verdict", () => {
550+
const files = [
551+
makeFile("src/search/index.ts"),
552+
makeFile("src/parser/a.ts", { imports: [imp("src/parser/b.ts")] }),
553+
makeFile("src/parser/b.ts"),
554+
];
555+
const built = buildGraph(files);
556+
const result = analyzeGraph(built, files);
557+
558+
const searchMod = result.forceAnalysis.moduleCohesion.find((m) => m.path === "src/search/");
559+
expect(searchMod).toBeDefined();
560+
expect(searchMod?.verdict).toBe("LEAF");
561+
});
562+
563+
it("AC-E2: 2-file module with 0 internal deps gets JUNK_DRAWER, not LEAF", () => {
564+
const files = [
565+
makeFile("src/grab/a.ts", { imports: [imp("src/other/x.ts")] }),
566+
makeFile("src/grab/b.ts", { imports: [imp("src/other/y.ts")] }),
567+
makeFile("src/other/x.ts"),
568+
makeFile("src/other/y.ts"),
569+
];
570+
const built = buildGraph(files);
571+
const result = analyzeGraph(built, files);
572+
573+
const grabMod = result.forceAnalysis.moduleCohesion.find((m) => m.path === "src/grab/");
574+
expect(grabMod).toBeDefined();
575+
expect(grabMod?.verdict).toBe("JUNK_DRAWER");
576+
});
577+
578+
it("EC1: 1-file 0-dep module gets LEAF", () => {
579+
const files = [
580+
makeFile("src/lonely/index.ts"),
581+
];
582+
const built = buildGraph(files);
583+
const result = analyzeGraph(built, files);
584+
585+
const mod = result.forceAnalysis.moduleCohesion.find((m) => m.path === "src/lonely/");
586+
expect(mod).toBeDefined();
587+
expect(mod?.verdict).toBe("LEAF");
588+
});
589+
590+
it("EC2: 1-file module with outgoing deps still gets LEAF", () => {
591+
const files = [
592+
makeFile("src/single/index.ts", { imports: [imp("src/other/a.ts"), imp("src/other/b.ts")] }),
593+
makeFile("src/other/a.ts"),
594+
makeFile("src/other/b.ts"),
595+
];
596+
const built = buildGraph(files);
597+
const result = analyzeGraph(built, files);
598+
599+
const singleMod = result.forceAnalysis.moduleCohesion.find((m) => m.path === "src/single/");
600+
expect(singleMod).toBeDefined();
601+
expect(singleMod?.verdict).toBe("LEAF");
602+
});
603+
604+
it("AC-10/EC6: module with 1 prod file + 1 test file gets LEAF", () => {
605+
const files = [
606+
makeFile("src/community/index.ts"),
607+
makeFile("src/community/index.test.ts", { isTestFile: true }),
608+
];
609+
const built = buildGraph(files);
610+
const result = analyzeGraph(built, files);
611+
612+
const mod = result.forceAnalysis.moduleCohesion.find((m) => m.path === "src/community/");
613+
expect(mod).toBeDefined();
614+
expect(mod?.verdict).toBe("LEAF");
615+
});
616+
617+
it("EC7: module with 2 prod files + 1 test file is NOT LEAF", () => {
618+
const files = [
619+
makeFile("src/mod/a.ts", { imports: [imp("src/other/x.ts")] }),
620+
makeFile("src/mod/b.ts", { imports: [imp("src/other/y.ts")] }),
621+
makeFile("src/mod/a.test.ts", { isTestFile: true }),
622+
makeFile("src/other/x.ts"),
623+
makeFile("src/other/y.ts"),
624+
];
625+
const built = buildGraph(files);
626+
const result = analyzeGraph(built, files);
627+
628+
const mod = result.forceAnalysis.moduleCohesion.find((m) => m.path === "src/mod/");
629+
expect(mod).toBeDefined();
630+
expect(mod?.verdict).not.toBe("LEAF");
631+
});
632+
633+
it("AC-6: summary does not count LEAF modules as junk-drawer", () => {
634+
const files = [
635+
makeFile("src/search/index.ts"),
636+
makeFile("src/grab/a.ts", { imports: [imp("src/other/x.ts")] }),
637+
makeFile("src/grab/b.ts", { imports: [imp("src/other/y.ts")] }),
638+
makeFile("src/other/x.ts"),
639+
makeFile("src/other/y.ts"),
640+
];
641+
const built = buildGraph(files);
642+
const result = analyzeGraph(built, files);
643+
644+
// search/ is LEAF and should NOT be counted in junk-drawer summary
645+
expect(result.forceAnalysis.summary).not.toContain("src/search/");
646+
// grab/ is JUNK_DRAWER and should be in summary
647+
expect(result.forceAnalysis.summary).toContain("src/grab/");
648+
});
649+
});
650+
651+
describe("tension suppression for type hubs and entry points", () => {
652+
it("AC-2: type hub file gets suppressed split recommendation", () => {
653+
// types/index.ts is pulled by two different modules
654+
const files = [
655+
makeFile("src/types/index.ts", {
656+
imports: [imp("src/a/x.ts"), imp("src/b/y.ts")],
657+
exports: [
658+
{ name: "MyType", type: "interface", loc: 1, isDefault: false, complexity: 1 },
659+
],
660+
}),
661+
makeFile("src/a/x.ts"),
662+
makeFile("src/b/y.ts"),
663+
];
664+
const built = buildGraph(files);
665+
const result = analyzeGraph(built, files);
666+
667+
const typesFile = result.forceAnalysis.tensionFiles.find(
668+
(t) => t.file === "src/types/index.ts"
669+
);
670+
if (typesFile) {
671+
expect(typesFile.recommendation).toContain("not recommended");
672+
expect(typesFile.recommendation).not.toContain("Split into");
673+
}
674+
});
675+
676+
it("AC-3: entry point file gets suppressed split recommendation", () => {
677+
// cli.ts is pulled by two different modules
678+
const files = [
679+
makeFile("cli.ts", {
680+
imports: [imp("src/a/x.ts"), imp("src/b/y.ts")],
681+
}),
682+
makeFile("src/a/x.ts"),
683+
makeFile("src/b/y.ts"),
684+
];
685+
const built = buildGraph(files);
686+
const result = analyzeGraph(built, files);
687+
688+
const cliFile = result.forceAnalysis.tensionFiles.find(
689+
(t) => t.file === "cli.ts"
690+
);
691+
if (cliFile) {
692+
expect(cliFile.recommendation).toContain("not recommended");
693+
expect(cliFile.recommendation).not.toContain("Split into");
694+
}
695+
});
696+
697+
it("EC4: types.ts in nested module gets suppressed split rec", () => {
698+
const files = [
699+
makeFile("src/core/types.ts", {
700+
imports: [imp("src/a/x.ts"), imp("src/b/y.ts")],
701+
}),
702+
makeFile("src/a/x.ts"),
703+
makeFile("src/b/y.ts"),
704+
];
705+
const built = buildGraph(files);
706+
const result = analyzeGraph(built, files);
707+
708+
const typesFile = result.forceAnalysis.tensionFiles.find(
709+
(t) => t.file === "src/core/types.ts"
710+
);
711+
if (typesFile) {
712+
expect(typesFile.recommendation).toContain("not recommended");
713+
}
714+
});
715+
716+
it("EC5: entry point at root (main.ts, server.ts) gets suppressed", () => {
717+
const files = [
718+
makeFile("main.ts", {
719+
imports: [imp("src/a/x.ts"), imp("src/b/y.ts")],
720+
}),
721+
makeFile("src/a/x.ts"),
722+
makeFile("src/b/y.ts"),
723+
];
724+
const built = buildGraph(files);
725+
const result = analyzeGraph(built, files);
726+
727+
const mainFile = result.forceAnalysis.tensionFiles.find(
728+
(t) => t.file === "main.ts"
729+
);
730+
if (mainFile) {
731+
expect(mainFile.recommendation).toContain("not recommended");
732+
}
733+
});
734+
735+
it("regular file with tension still gets split recommendation", () => {
736+
const files = [
737+
makeFile("utils.ts", {
738+
imports: [imp("src/a/x.ts"), imp("src/b/y.ts")],
739+
}),
740+
makeFile("src/a/x.ts"),
741+
makeFile("src/b/y.ts"),
742+
];
743+
const built = buildGraph(files);
744+
const result = analyzeGraph(built, files);
745+
746+
const utilsFile = result.forceAnalysis.tensionFiles.find(
747+
(t) => t.file === "utils.ts"
748+
);
749+
if (utilsFile) {
750+
expect(utilsFile.recommendation).toContain("Split into");
751+
}
752+
});
753+
});

src/analyzer/index.ts

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -317,17 +317,51 @@ function computeModuleMetrics(
317317
return moduleMetrics;
318318
}
319319

320+
function isTestFilePath(fileId: string): boolean {
321+
return fileId.includes(".test.") || fileId.includes(".spec.") || fileId.includes("__tests__/");
322+
}
323+
324+
function isTypeHubFile(fileId: string): boolean {
325+
const basename = path.basename(fileId);
326+
const dir = path.dirname(fileId);
327+
const dirBasename = path.basename(dir);
328+
if (basename === "types.ts" || basename === "constants.ts" || basename === "config.ts") return true;
329+
if (basename === "index.ts" && dirBasename === "types") return true;
330+
return false;
331+
}
332+
333+
function isEntryPointFile(fileId: string): boolean {
334+
const basename = path.basename(fileId);
335+
const entryNames = ["cli.ts", "main.ts", "app.ts", "server.ts"];
336+
if (entryNames.includes(basename)) return true;
337+
const dir = path.dirname(fileId);
338+
if (basename === "index.ts" && (dir === "." || dir === "")) return true;
339+
return false;
340+
}
341+
320342
function computeForceAnalysis(
321343
graph: Graph,
322344
fileNodes: GraphNode[],
323345
fileMetrics: Map<string, FileMetrics>,
324346
moduleMetrics: Map<string, ModuleMetrics>,
325347
betweennessScores: Map<string, number>
326348
): ForceAnalysis {
349+
// Group files by module for non-test file counting
350+
const moduleFiles = new Map<string, GraphNode[]>();
351+
for (const node of fileNodes) {
352+
const existing = moduleFiles.get(node.module) ?? [];
353+
existing.push(node);
354+
moduleFiles.set(node.module, existing);
355+
}
356+
327357
// Module cohesion verdicts
328-
type CohesionVerdict = "COHESIVE" | "MODERATE" | "JUNK_DRAWER";
358+
type CohesionVerdict = "COHESIVE" | "MODERATE" | "JUNK_DRAWER" | "LEAF";
329359
const moduleCohesion = [...moduleMetrics.values()].map((m) => {
330-
const verdict: CohesionVerdict = m.cohesion >= 0.6 ? "COHESIVE" : m.cohesion >= 0.4 ? "MODERATE" : "JUNK_DRAWER";
360+
const files = moduleFiles.get(m.path) ?? [];
361+
const nonTestFileCount = files.filter((f) => !isTestFilePath(f.id)).length;
362+
const verdict: CohesionVerdict = nonTestFileCount <= 1
363+
? "LEAF"
364+
: m.cohesion >= 0.6 ? "COHESIVE" : m.cohesion >= 0.4 ? "MODERATE" : "JUNK_DRAWER";
331365
return { ...m, verdict };
332366
});
333367

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

377411
if (tension > 0.3) {
378-
const topModules = pulls
379-
.sort((a, b) => b.strength - a.strength)
380-
.slice(0, 2)
381-
.map((p) => path.basename(p.module.replace(/\/$/, "")));
412+
let recommendation: string;
413+
if (isTypeHubFile(file.id)) {
414+
recommendation = "Type hub — split not recommended (design-intentional shared types)";
415+
} else if (isEntryPointFile(file.id)) {
416+
recommendation = "Entry point — split not recommended (CLI/app entry point)";
417+
} else {
418+
const topModules = pulls
419+
.sort((a, b) => b.strength - a.strength)
420+
.slice(0, 2)
421+
.map((p) => path.basename(p.module.replace(/\/$/, "")));
422+
recommendation = `Split into ${topModules.map((m) => `${m}-${path.basename(file.id)}`).join(" and ")}`;
423+
}
382424

383425
tensionFiles.push({
384426
file: file.id,
385427
tension,
386428
pulledBy: pulls.sort((a, b) => b.strength - a.strength),
387-
recommendation: `Split into ${topModules.map((m) => `${m}-${path.basename(file.id)}`).join(" and ")}`,
429+
recommendation,
388430
});
389431
}
390432
}

0 commit comments

Comments
 (0)