tenbo-dashboard 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -0
- package/bin/tenbo-dashboard.mjs +67 -0
- package/index.html +12 -0
- package/package.json +75 -0
- package/scripts/compute-metrics.test.ts +35 -0
- package/scripts/compute-metrics.ts +46 -0
- package/scripts/next-id.test.ts +128 -0
- package/scripts/next-id.ts +183 -0
- package/scripts/validate-cli.test.ts +152 -0
- package/scripts/validate-cli.ts +181 -0
- package/src/App.tsx +152 -0
- package/src/ErrorBoundary.tsx +25 -0
- package/src/api/client.ts +41 -0
- package/src/api/lib/frontmatterScan.ts +37 -0
- package/src/api/lib/health/agingTodos.test.ts +32 -0
- package/src/api/lib/health/agingTodos.ts +97 -0
- package/src/api/lib/health/archCompliance.test.ts +26 -0
- package/src/api/lib/health/archCompliance.ts +53 -0
- package/src/api/lib/health/collectAll.test.ts +49 -0
- package/src/api/lib/health/collectAll.ts +78 -0
- package/src/api/lib/health/config.test.ts +45 -0
- package/src/api/lib/health/config.ts +54 -0
- package/src/api/lib/health/coupling.test.ts +40 -0
- package/src/api/lib/health/coupling.ts +66 -0
- package/src/api/lib/health/deadCode.test.ts +23 -0
- package/src/api/lib/health/deadCode.ts +76 -0
- package/src/api/lib/health/docDrift.test.ts +54 -0
- package/src/api/lib/health/docDrift.ts +99 -0
- package/src/api/lib/health/hotspotFiles.test.ts +49 -0
- package/src/api/lib/health/hotspotFiles.ts +70 -0
- package/src/api/lib/health/importGraph.test.ts +29 -0
- package/src/api/lib/health/importGraph.ts +51 -0
- package/src/api/lib/health/layerFiles.test.ts +46 -0
- package/src/api/lib/health/layerFiles.ts +40 -0
- package/src/api/lib/health/redundancy.test.ts +24 -0
- package/src/api/lib/health/redundancy.ts +162 -0
- package/src/api/lib/health/testCoverage.test.ts +33 -0
- package/src/api/lib/health/testCoverage.ts +55 -0
- package/src/api/lib/health/types.test.ts +29 -0
- package/src/api/lib/health/types.ts +85 -0
- package/src/api/lib/http.ts +34 -0
- package/src/api/lib/metrics.test.ts +81 -0
- package/src/api/lib/metrics.ts +78 -0
- package/src/api/lib/metricsRefresh.test.ts +54 -0
- package/src/api/lib/metricsRefresh.ts +147 -0
- package/src/api/lib/phases.test.ts +76 -0
- package/src/api/lib/phases.ts +48 -0
- package/src/api/lib/relationships.ts +65 -0
- package/src/api/lib/repoRoot.ts +12 -0
- package/src/api/lib/tenboFs.layerContent.test.ts +36 -0
- package/src/api/lib/tenboFs.ts +237 -0
- package/src/api/lib/tenboFs.v2.test.ts +86 -0
- package/src/api/lib/tenboFs.workspaceContent.test.ts +42 -0
- package/src/api/lib/validator.docUpdate.test.ts +99 -0
- package/src/api/lib/validator.phases.test.ts +98 -0
- package/src/api/lib/validator.relationships.test.ts +126 -0
- package/src/api/lib/validator.specLinks.test.ts +96 -0
- package/src/api/lib/validator.ts +311 -0
- package/src/api/lib/validator.v2.test.ts +55 -0
- package/src/api/lib/yamlOrdered.ts +60 -0
- package/src/api/plugin.ts +31 -0
- package/src/api/routes/items.ts +25 -0
- package/src/api/routes/layerContent.ts +22 -0
- package/src/api/routes/layerDocs.ts +22 -0
- package/src/api/routes/open.ts +18 -0
- package/src/api/routes/related.ts +11 -0
- package/src/api/routes/reorder.ts +15 -0
- package/src/api/routes/state.ts +23 -0
- package/src/api/routes/watch.ts +31 -0
- package/src/css-modules.d.ts +4 -0
- package/src/hooks/useApiPatch.ts +9 -0
- package/src/hooks/useLayerContent.ts +25 -0
- package/src/hooks/usePrompt.ts +23 -0
- package/src/hooks/useTenboState.ts +35 -0
- package/src/main.tsx +13 -0
- package/src/prompts/populateLayer.ts +12 -0
- package/src/router/routes.ts +98 -0
- package/src/router/useHashRoute.test.ts +67 -0
- package/src/router/useHashRoute.ts +15 -0
- package/src/styles.css +228 -0
- package/src/test-setup.ts +1 -0
- package/src/types.ts +202 -0
- package/src/ui/DotGrid.module.css +7 -0
- package/src/ui/DotGrid.tsx +87 -0
- package/src/ui/EmptyState.module.css +11 -0
- package/src/ui/EmptyState.tsx +10 -0
- package/src/ui/FindingModal/EvidenceSection.tsx +51 -0
- package/src/ui/FindingModal/FindingModal.module.css +31 -0
- package/src/ui/FindingModal/FindingModal.test.tsx +36 -0
- package/src/ui/FindingModal/HeadlineSection.tsx +17 -0
- package/src/ui/FindingModal/RelatedFindingsSection.tsx +29 -0
- package/src/ui/FindingModal/SuggestionSection.tsx +13 -0
- package/src/ui/FindingModal/index.tsx +36 -0
- package/src/ui/HealthPage/Digest.tsx +29 -0
- package/src/ui/HealthPage/FindingRow.tsx +21 -0
- package/src/ui/HealthPage/HealthPage.module.css +45 -0
- package/src/ui/HealthPage/HealthPage.test.tsx +49 -0
- package/src/ui/HealthPage/HealthPage.tsx +66 -0
- package/src/ui/HealthPage/LayerCard.tsx +36 -0
- package/src/ui/HealthPage/SeverityIcon.tsx +29 -0
- package/src/ui/HealthPage/SeverityLegend.tsx +27 -0
- package/src/ui/HealthPage/severity.test.ts +46 -0
- package/src/ui/HealthPage/severity.ts +21 -0
- package/src/ui/ItemCard.module.css +131 -0
- package/src/ui/ItemCard.tsx +117 -0
- package/src/ui/ItemModal/DescriptionField.tsx +22 -0
- package/src/ui/ItemModal/EnrichmentSections.tsx +49 -0
- package/src/ui/ItemModal/ItemModal.module.css +200 -0
- package/src/ui/ItemModal/LinksSection.tsx +26 -0
- package/src/ui/ItemModal/NotesSection.tsx +18 -0
- package/src/ui/ItemModal/PhasesSection.tsx +79 -0
- package/src/ui/ItemModal/RelatedSection.tsx +26 -0
- package/src/ui/ItemModal/RelationshipsSection.tsx +69 -0
- package/src/ui/ItemModal/StatusLayerControls.tsx +43 -0
- package/src/ui/ItemModal/TitleField.tsx +21 -0
- package/src/ui/ItemModal/index.tsx +117 -0
- package/src/ui/KanbanColumn.module.css +20 -0
- package/src/ui/KanbanColumn.tsx +36 -0
- package/src/ui/LayerDetailPage/LayerDetailPage.module.css +11 -0
- package/src/ui/LayerDetailPage/LayerDetailPage.test.tsx +30 -0
- package/src/ui/LayerDetailPage/LayerDetailPage.tsx +59 -0
- package/src/ui/LayerDetailPage/SignalSection.tsx +33 -0
- package/src/ui/LayerDrawer.module.css +60 -0
- package/src/ui/LayerDrawer.tsx +69 -0
- package/src/ui/LayerKanban.module.css +45 -0
- package/src/ui/LayerKanban.tsx +65 -0
- package/src/ui/LayerPage/FillThisInButton.tsx +11 -0
- package/src/ui/LayerPage/LayerPage.module.css +52 -0
- package/src/ui/LayerPage/LayerPage.tsx +76 -0
- package/src/ui/LayerPage/MetricsBadges.tsx +18 -0
- package/src/ui/LayerPage/isTemplateOnly.test.ts +45 -0
- package/src/ui/LayerPage/isTemplateOnly.ts +19 -0
- package/src/ui/LayerPage/tabs/CodeMapTab.tsx +19 -0
- package/src/ui/LayerPage/tabs/DeepDivesTab.tsx +35 -0
- package/src/ui/LayerPage/tabs/IntentTab.tsx +19 -0
- package/src/ui/LayerPage/tabs/PlainEnglishTab.tsx +19 -0
- package/src/ui/Markdown.module.css +115 -0
- package/src/ui/Markdown.tsx +27 -0
- package/src/ui/RoadmapPage/RoadmapPage.module.css +93 -0
- package/src/ui/RoadmapPage/RoadmapPage.tsx +255 -0
- package/src/ui/ScopePage/ScopePage.module.css +72 -0
- package/src/ui/ScopePage/ScopePage.tsx +131 -0
- package/src/ui/SummaryStrip.module.css +18 -0
- package/src/ui/SummaryStrip.tsx +16 -0
- package/src/ui/TaskList.module.css +136 -0
- package/src/ui/TaskList.tsx +91 -0
- package/src/ui/TopBar.module.css +78 -0
- package/src/ui/TopBar.tsx +48 -0
- package/src/ui/WorkspacePage/WorkspacePage.module.css +24 -0
- package/src/ui/WorkspacePage/WorkspacePage.tsx +36 -0
- package/src/ui/WorkspacePage/tabs/GlossaryTab.tsx +6 -0
- package/src/ui/WorkspacePage/tabs/OverviewTab.tsx +28 -0
- package/src/ui/WorkspacePage/tabs/PrinciplesTab.tsx +6 -0
- package/tsconfig.json +18 -0
- package/vite.config.ts +9 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import type { Finding } from './types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Return file-like tokens from inline-code spans in a markdown document.
|
|
7
|
+
* We require a `/` and a `.ext` to avoid matching prose words.
|
|
8
|
+
*/
|
|
9
|
+
export function extractFileRefs(md: string): string[] {
|
|
10
|
+
const out: string[] = [];
|
|
11
|
+
const re = /`([^`\n]+)`/g;
|
|
12
|
+
let m: RegExpExecArray | null;
|
|
13
|
+
while ((m = re.exec(md))) {
|
|
14
|
+
const candidate = m[1].trim();
|
|
15
|
+
if (/^[\w./@-]+\.[a-zA-Z]{1,5}$/.test(candidate) && candidate.includes('/')) out.push(candidate);
|
|
16
|
+
}
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function analyzeDocDrift(
|
|
21
|
+
repoRoot: string,
|
|
22
|
+
scopeId: string,
|
|
23
|
+
layerId: string,
|
|
24
|
+
layerFiles: string[],
|
|
25
|
+
): Finding[] {
|
|
26
|
+
const docPath = path.join(repoRoot, '.tenbo/scopes', scopeId, 'layers', layerId, 'code-map.md');
|
|
27
|
+
if (!existsSync(docPath)) return [];
|
|
28
|
+
const md = readFileSync(docPath, 'utf8');
|
|
29
|
+
const docMtime = statSync(docPath).mtimeMs;
|
|
30
|
+
const refs = extractFileRefs(md);
|
|
31
|
+
const refSet = new Set(refs);
|
|
32
|
+
const findings: Finding[] = [];
|
|
33
|
+
|
|
34
|
+
// 1. missing-ref: file in code-map.md doesn't exist on disk
|
|
35
|
+
const broken: string[] = [];
|
|
36
|
+
for (const ref of refs) {
|
|
37
|
+
if (!existsSync(path.join(repoRoot, ref))) broken.push(ref);
|
|
38
|
+
}
|
|
39
|
+
if (broken.length > 0) {
|
|
40
|
+
const severity = broken.length >= 3 ? 'critical' : 'warning';
|
|
41
|
+
findings.push({
|
|
42
|
+
id: `${layerId}.doc-drift.missing-ref`,
|
|
43
|
+
signal: 'doc-drift',
|
|
44
|
+
severity,
|
|
45
|
+
confidence: 'high',
|
|
46
|
+
layer: layerId,
|
|
47
|
+
target: path.relative(repoRoot, docPath).split(path.sep).join('/'),
|
|
48
|
+
headline: `code-map.md references ${broken.length} non-existent file${broken.length === 1 ? '' : 's'}`,
|
|
49
|
+
suggestion: {
|
|
50
|
+
summary: 'Update code-map.md to remove or correct broken references',
|
|
51
|
+
rationale: `${broken.length} reference${broken.length === 1 ? '' : 's'} point to files that no longer exist.`,
|
|
52
|
+
action_kind: 'update-doc',
|
|
53
|
+
},
|
|
54
|
+
details: {
|
|
55
|
+
kind: 'doc-drift',
|
|
56
|
+
drift_type: 'missing-ref',
|
|
57
|
+
doc_path: path.relative(repoRoot, docPath).split(path.sep).join('/'),
|
|
58
|
+
doc_mtime_iso: new Date(docMtime).toISOString(),
|
|
59
|
+
code_mtime_iso: null,
|
|
60
|
+
affected_files: broken,
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 2. unreferenced-file: layer file not mentioned anywhere in code-map.md
|
|
66
|
+
const orphan: string[] = [];
|
|
67
|
+
for (const f of layerFiles) {
|
|
68
|
+
if (refSet.has(f)) continue;
|
|
69
|
+
const base = path.basename(f);
|
|
70
|
+
if (md.includes(base)) continue;
|
|
71
|
+
orphan.push(f);
|
|
72
|
+
}
|
|
73
|
+
if (orphan.length > 0) {
|
|
74
|
+
findings.push({
|
|
75
|
+
id: `${layerId}.doc-drift.unreferenced-file`,
|
|
76
|
+
signal: 'doc-drift',
|
|
77
|
+
severity: 'info',
|
|
78
|
+
confidence: 'medium',
|
|
79
|
+
layer: layerId,
|
|
80
|
+
target: path.relative(repoRoot, docPath).split(path.sep).join('/'),
|
|
81
|
+
headline: `${orphan.length} file${orphan.length === 1 ? '' : 's'} in this layer not mentioned in code-map.md`,
|
|
82
|
+
suggestion: {
|
|
83
|
+
summary: 'Add new files to code-map.md or confirm they belong in this layer',
|
|
84
|
+
rationale: `These files exist in the layer's source paths but are not referenced anywhere in code-map.md.`,
|
|
85
|
+
action_kind: 'update-doc',
|
|
86
|
+
},
|
|
87
|
+
details: {
|
|
88
|
+
kind: 'doc-drift',
|
|
89
|
+
drift_type: 'unreferenced-file',
|
|
90
|
+
doc_path: path.relative(repoRoot, docPath).split(path.sep).join('/'),
|
|
91
|
+
doc_mtime_iso: new Date(docMtime).toISOString(),
|
|
92
|
+
code_mtime_iso: null,
|
|
93
|
+
affected_files: orphan,
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return findings;
|
|
99
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { analyzeHotspotFiles } from './hotspotFiles';
|
|
3
|
+
import { DEFAULT_HEALTH_CONFIG } from './config';
|
|
4
|
+
import { writeFileSync, mkdtempSync, mkdirSync, rmSync } from 'node:fs';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
function makeFile(root: string, rel: string, lines: number): string {
|
|
9
|
+
const full = path.join(root, rel);
|
|
10
|
+
mkdirSync(path.dirname(full), { recursive: true });
|
|
11
|
+
writeFileSync(full, Array.from({ length: lines }, (_, i) => `// line ${i}`).join('\n'));
|
|
12
|
+
return rel;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('analyzeHotspotFiles', () => {
|
|
16
|
+
it('flags files exceeding LOC thresholds', () => {
|
|
17
|
+
const root = mkdtempSync(path.join(tmpdir(), 'hotspot-'));
|
|
18
|
+
const small = makeFile(root, 'small.ts', 100);
|
|
19
|
+
const medium = makeFile(root, 'medium.ts', 350);
|
|
20
|
+
const large = makeFile(root, 'large.ts', 700);
|
|
21
|
+
const huge = makeFile(root, 'huge.ts', 1500);
|
|
22
|
+
|
|
23
|
+
const findings = analyzeHotspotFiles(root, 'lyr', [small, medium, large, huge], DEFAULT_HEALTH_CONFIG);
|
|
24
|
+
|
|
25
|
+
expect(findings.find(f => f.target === 'small.ts')).toBeUndefined();
|
|
26
|
+
expect(findings.find(f => f.target === 'medium.ts')?.severity).toBe('info');
|
|
27
|
+
expect(findings.find(f => f.target === 'large.ts')?.severity).toBe('warning');
|
|
28
|
+
expect(findings.find(f => f.target === 'huge.ts')?.severity).toBe('critical');
|
|
29
|
+
rmSync(root, { recursive: true, force: true });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('respects ignore.hotspot_files', () => {
|
|
33
|
+
const root = mkdtempSync(path.join(tmpdir(), 'hotspot-'));
|
|
34
|
+
const huge = makeFile(root, 'huge.ts', 1500);
|
|
35
|
+
const cfg = { ...DEFAULT_HEALTH_CONFIG, ignore: { ...DEFAULT_HEALTH_CONFIG.ignore, hotspot_files: ['huge.ts'] } };
|
|
36
|
+
const findings = analyzeHotspotFiles(root, 'lyr', [huge], cfg);
|
|
37
|
+
expect(findings).toEqual([]);
|
|
38
|
+
rmSync(root, { recursive: true, force: true });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('produces a structured suggestion with action_kind=split-file', () => {
|
|
42
|
+
const root = mkdtempSync(path.join(tmpdir(), 'hotspot-'));
|
|
43
|
+
const huge = makeFile(root, 'huge.ts', 1500);
|
|
44
|
+
const findings = analyzeHotspotFiles(root, 'lyr', [huge], DEFAULT_HEALTH_CONFIG);
|
|
45
|
+
expect(findings[0].suggestion.action_kind).toBe('split-file');
|
|
46
|
+
expect(findings[0].details.kind).toBe('hotspot-files');
|
|
47
|
+
rmSync(root, { recursive: true, force: true });
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { execSync } from 'node:child_process';
|
|
4
|
+
import type { Finding, Severity } from './types';
|
|
5
|
+
import type { HealthConfig } from './config';
|
|
6
|
+
|
|
7
|
+
function lineCount(absPath: string): number {
|
|
8
|
+
const content = readFileSync(absPath, 'utf8');
|
|
9
|
+
if (content.length === 0) return 0;
|
|
10
|
+
return content.split('\n').length - (content.endsWith('\n') ? 1 : 0);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function severityFor(loc: number, t: HealthConfig['thresholds']['hotspot_loc']): Severity | null {
|
|
14
|
+
if (loc >= t.critical) return 'critical';
|
|
15
|
+
if (loc >= t.warning) return 'warning';
|
|
16
|
+
if (loc >= t.info) return 'info';
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function commits30d(repoRoot: string, relPath: string): number {
|
|
21
|
+
try {
|
|
22
|
+
const out = execSync(`git log --since="30 days ago" --pretty=format:%H -- "${relPath}"`, {
|
|
23
|
+
cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
24
|
+
});
|
|
25
|
+
return out.trim() === '' ? 0 : out.trim().split('\n').length;
|
|
26
|
+
} catch {
|
|
27
|
+
return 0;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function analyzeHotspotFiles(
|
|
32
|
+
repoRoot: string,
|
|
33
|
+
layerId: string,
|
|
34
|
+
files: string[], // repo-relative
|
|
35
|
+
config: HealthConfig,
|
|
36
|
+
): Finding[] {
|
|
37
|
+
const findings: Finding[] = [];
|
|
38
|
+
const ignored = new Set(config.ignore.hotspot_files);
|
|
39
|
+
for (const rel of files) {
|
|
40
|
+
if (ignored.has(rel)) continue;
|
|
41
|
+
const abs = path.resolve(repoRoot, rel);
|
|
42
|
+
let loc: number;
|
|
43
|
+
try { loc = lineCount(abs); } catch { continue; }
|
|
44
|
+
const severity = severityFor(loc, config.thresholds.hotspot_loc);
|
|
45
|
+
if (!severity) continue;
|
|
46
|
+
const filename = path.basename(rel);
|
|
47
|
+
findings.push({
|
|
48
|
+
id: `${layerId}.hotspot-files.${filename.replace(/\W/g, '_')}`,
|
|
49
|
+
signal: 'hotspot-files',
|
|
50
|
+
severity,
|
|
51
|
+
confidence: 'high',
|
|
52
|
+
layer: layerId,
|
|
53
|
+
target: rel,
|
|
54
|
+
headline: `${filename} (${loc.toLocaleString()} LOC)`,
|
|
55
|
+
suggestion: {
|
|
56
|
+
summary: `Split ${filename} into smaller files`,
|
|
57
|
+
rationale: `${loc.toLocaleString()} LOC exceeds ${severity} threshold of ${config.thresholds.hotspot_loc[severity].toLocaleString()}.`,
|
|
58
|
+
action_kind: 'split-file',
|
|
59
|
+
},
|
|
60
|
+
details: {
|
|
61
|
+
kind: 'hotspot-files',
|
|
62
|
+
loc,
|
|
63
|
+
top_functions: [],
|
|
64
|
+
commits_30d: commits30d(repoRoot, rel),
|
|
65
|
+
split_candidates: [],
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
return findings;
|
|
70
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { buildImportGraph } from './importGraph';
|
|
3
|
+
import { writeFileSync, mkdtempSync, mkdirSync, rmSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
function makeTSConfig(root: string) {
|
|
8
|
+
writeFileSync(path.join(root, 'tsconfig.json'), JSON.stringify({
|
|
9
|
+
compilerOptions: { module: 'esnext', target: 'es2020', moduleResolution: 'bundler', allowJs: false, strict: false },
|
|
10
|
+
include: ['**/*.ts'],
|
|
11
|
+
}));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('buildImportGraph', () => {
|
|
15
|
+
it('records import edges between files', () => {
|
|
16
|
+
const root = mkdtempSync(path.join(tmpdir(), 'graph-'));
|
|
17
|
+
makeTSConfig(root);
|
|
18
|
+
mkdirSync(path.join(root, 'src'), { recursive: true });
|
|
19
|
+
writeFileSync(path.join(root, 'src/a.ts'), `import { x } from './b';\nexport const a = x;`);
|
|
20
|
+
writeFileSync(path.join(root, 'src/b.ts'), `export const x = 1;`);
|
|
21
|
+
writeFileSync(path.join(root, 'src/c.ts'), `export const c = 2;`);
|
|
22
|
+
|
|
23
|
+
const graph = buildImportGraph(root, ['src/a.ts', 'src/b.ts', 'src/c.ts']);
|
|
24
|
+
expect(graph.importsFrom('src/a.ts')).toContain('src/b.ts');
|
|
25
|
+
expect(graph.importedBy('src/b.ts')).toContain('src/a.ts');
|
|
26
|
+
expect(graph.importedBy('src/c.ts')).toEqual([]);
|
|
27
|
+
rmSync(root, { recursive: true, force: true });
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Project } from 'ts-morph';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export interface ImportGraph {
|
|
5
|
+
importsFrom(file: string): string[];
|
|
6
|
+
importedBy(file: string): string[];
|
|
7
|
+
allFiles(): string[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Build an import graph for the given repo-relative TS/TSX files.
|
|
12
|
+
* Resolution uses the repo's tsconfig if found at <repoRoot>/tsconfig.json,
|
|
13
|
+
* otherwise a default config.
|
|
14
|
+
*/
|
|
15
|
+
export function buildImportGraph(repoRoot: string, files: string[]): ImportGraph {
|
|
16
|
+
const tsconfigPath = path.join(repoRoot, 'tsconfig.json');
|
|
17
|
+
const project = new Project({
|
|
18
|
+
tsConfigFilePath: tsconfigPath,
|
|
19
|
+
skipAddingFilesFromTsConfig: true,
|
|
20
|
+
skipFileDependencyResolution: false,
|
|
21
|
+
});
|
|
22
|
+
const absFiles = files.map(f => path.resolve(repoRoot, f));
|
|
23
|
+
project.addSourceFilesAtPaths(absFiles);
|
|
24
|
+
|
|
25
|
+
const fwd = new Map<string, Set<string>>();
|
|
26
|
+
const bwd = new Map<string, Set<string>>();
|
|
27
|
+
const ensure = (m: Map<string, Set<string>>, k: string) => {
|
|
28
|
+
let s = m.get(k);
|
|
29
|
+
if (!s) { s = new Set(); m.set(k, s); }
|
|
30
|
+
return s;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
for (const sf of project.getSourceFiles()) {
|
|
34
|
+
const fromAbs = sf.getFilePath();
|
|
35
|
+
const fromRel = path.relative(repoRoot, fromAbs).split(path.sep).join('/');
|
|
36
|
+
if (!files.includes(fromRel)) continue;
|
|
37
|
+
for (const decl of sf.getImportDeclarations()) {
|
|
38
|
+
const target = decl.getModuleSpecifierSourceFile();
|
|
39
|
+
if (!target) continue; // external module
|
|
40
|
+
const toAbs = target.getFilePath();
|
|
41
|
+
const toRel = path.relative(repoRoot, toAbs).split(path.sep).join('/');
|
|
42
|
+
ensure(fwd, fromRel).add(toRel);
|
|
43
|
+
ensure(bwd, toRel).add(fromRel);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
importsFrom: (f) => Array.from(fwd.get(f) ?? []),
|
|
48
|
+
importedBy: (f) => Array.from(bwd.get(f) ?? []),
|
|
49
|
+
allFiles: () => files,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { resolveLayerFiles } from './layerFiles';
|
|
3
|
+
import { findRepoRoot } from '../repoRoot';
|
|
4
|
+
import type { Scope } from '../../../types';
|
|
5
|
+
|
|
6
|
+
describe('resolveLayerFiles', () => {
|
|
7
|
+
const repoRoot = findRepoRoot(process.cwd());
|
|
8
|
+
|
|
9
|
+
it('returns repo-relative paths bucketed by layer', () => {
|
|
10
|
+
if (!repoRoot) throw new Error('Could not find repo root');
|
|
11
|
+
// Use a real fixture: this repo's editor scope.
|
|
12
|
+
const scope: Scope = {
|
|
13
|
+
id: 'editor',
|
|
14
|
+
path: 'apps/editor',
|
|
15
|
+
description: '',
|
|
16
|
+
layers: [{
|
|
17
|
+
id: 'visual-canvas',
|
|
18
|
+
name: 'Visual Canvas',
|
|
19
|
+
description: '',
|
|
20
|
+
files: ['src/domains/canvas/**'],
|
|
21
|
+
dependencies: { inbound: [], outbound: [], external: [] },
|
|
22
|
+
}],
|
|
23
|
+
items: [],
|
|
24
|
+
};
|
|
25
|
+
const result = resolveLayerFiles(repoRoot, scope);
|
|
26
|
+
expect(result['visual-canvas']).toBeDefined();
|
|
27
|
+
expect(result['visual-canvas'].length).toBeGreaterThan(0);
|
|
28
|
+
// Paths are repo-relative
|
|
29
|
+
expect(result['visual-canvas'][0]).toMatch(/^apps\/editor\/src\/domains\/canvas\//);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('returns empty array for layer with no matching files', () => {
|
|
33
|
+
if (!repoRoot) throw new Error('Could not find repo root');
|
|
34
|
+
const scope: Scope = {
|
|
35
|
+
id: 'editor', path: 'apps/editor', description: '',
|
|
36
|
+
layers: [{
|
|
37
|
+
id: 'nonexistent', name: '', description: '',
|
|
38
|
+
files: ['src/domains/does-not-exist/**'],
|
|
39
|
+
dependencies: { inbound: [], outbound: [], external: [] },
|
|
40
|
+
}],
|
|
41
|
+
items: [],
|
|
42
|
+
};
|
|
43
|
+
const result = resolveLayerFiles(repoRoot, scope);
|
|
44
|
+
expect(result['nonexistent']).toEqual([]);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import type { Scope } from '../../../types';
|
|
4
|
+
import { globMatches } from '../metrics';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Returns a map of layerId -> array of repo-relative file paths.
|
|
8
|
+
*/
|
|
9
|
+
export function resolveLayerFiles(repoRoot: string, scope: Scope): Record<string, string[]> {
|
|
10
|
+
const out: Record<string, string[]> = {};
|
|
11
|
+
for (const layer of scope.layers) {
|
|
12
|
+
out[layer.id] = matchLayerFiles(repoRoot, scope.path, layer.files ?? []);
|
|
13
|
+
}
|
|
14
|
+
return out;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function matchLayerFiles(repoRoot: string, scopePath: string, globs: string[]): string[] {
|
|
18
|
+
const root = path.resolve(repoRoot, scopePath);
|
|
19
|
+
if (!existsSync(root)) return [];
|
|
20
|
+
const matches: string[] = [];
|
|
21
|
+
function walk(dir: string) {
|
|
22
|
+
let entries: string[];
|
|
23
|
+
try { entries = readdirSync(dir); } catch { return; }
|
|
24
|
+
for (const entry of entries.sort()) {
|
|
25
|
+
const full = path.join(dir, entry);
|
|
26
|
+
let st;
|
|
27
|
+
try { st = statSync(full); } catch { continue; }
|
|
28
|
+
if (st.isDirectory()) walk(full);
|
|
29
|
+
else {
|
|
30
|
+
const relToScope = path.relative(root, full).split(path.sep).join('/');
|
|
31
|
+
if (globs.some(g => globMatches(g, relToScope))) {
|
|
32
|
+
const relToRepo = path.relative(repoRoot, full).split(path.sep).join('/');
|
|
33
|
+
matches.push(relToRepo);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
walk(root);
|
|
39
|
+
return matches;
|
|
40
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { analyzeRedundancy } from './redundancy';
|
|
3
|
+
import { DEFAULT_HEALTH_CONFIG } from './config';
|
|
4
|
+
import { writeFileSync, mkdtempSync, mkdirSync, rmSync } from 'node:fs';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
describe('analyzeRedundancy', () => {
|
|
9
|
+
it('flags duplicated blocks across files', async () => {
|
|
10
|
+
const root = mkdtempSync(path.join(tmpdir(), 'dup-'));
|
|
11
|
+
mkdirSync(path.join(root, 'src/A'), { recursive: true });
|
|
12
|
+
mkdirSync(path.join(root, 'src/B'), { recursive: true });
|
|
13
|
+
const block = Array.from({ length: 40 }, (_, i) => ` console.log("dup line ${i}");`).join('\n');
|
|
14
|
+
const wrap = (b: string) => `export function f() {\n${b}\n}\n`;
|
|
15
|
+
writeFileSync(path.join(root, 'src/A/foo.ts'), wrap(block));
|
|
16
|
+
writeFileSync(path.join(root, 'src/B/bar.ts'), wrap(block));
|
|
17
|
+
|
|
18
|
+
const filesByLayer = { A: ['src/A/foo.ts'], B: ['src/B/bar.ts'] };
|
|
19
|
+
const findings = await analyzeRedundancy(root, filesByLayer, DEFAULT_HEALTH_CONFIG);
|
|
20
|
+
expect(findings.length).toBeGreaterThan(0);
|
|
21
|
+
expect(findings[0].signal).toBe('redundancy');
|
|
22
|
+
rmSync(root, { recursive: true, force: true });
|
|
23
|
+
}, 30000);
|
|
24
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// FALLBACK: jscpd v4's ESM bundle has a broken `colors/safe` import in Node 22
|
|
2
|
+
// (missing `.js` extension), so the programmatic `detectClones` API cannot be
|
|
3
|
+
// imported directly. Instead we shell out to the jscpd CLI (CommonJS entry
|
|
4
|
+
// point — no ESM problem), parse its JSON report from a temp output dir, and
|
|
5
|
+
// clean up after ourselves.
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { execFileSync } from 'node:child_process';
|
|
8
|
+
import { existsSync, mkdtempSync, rmSync, readFileSync } from 'node:fs';
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
import type { HealthConfig } from './config';
|
|
12
|
+
import type { Finding, Severity } from './types';
|
|
13
|
+
|
|
14
|
+
// jscpd's exports map blocks require.resolve('jscpd/package.json') and
|
|
15
|
+
// require.resolve('jscpd/bin/jscpd'). Instead, walk up from this file's
|
|
16
|
+
// directory to find the nearest node_modules/.bin/jscpd shell wrapper.
|
|
17
|
+
const _thisDir = path.dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
|
|
19
|
+
function jscpdBin(): string {
|
|
20
|
+
let dir = _thisDir;
|
|
21
|
+
for (let i = 0; i < 8; i++) {
|
|
22
|
+
const candidate = path.join(dir, 'node_modules', '.bin', 'jscpd');
|
|
23
|
+
if (existsSync(candidate)) return candidate;
|
|
24
|
+
const parent = path.dirname(dir);
|
|
25
|
+
if (parent === dir) break;
|
|
26
|
+
dir = parent;
|
|
27
|
+
}
|
|
28
|
+
return 'jscpd'; // fallback: rely on PATH
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface JscpdFile {
|
|
32
|
+
name: string; // may be absolute or relative to cwd
|
|
33
|
+
start: number;
|
|
34
|
+
end: number;
|
|
35
|
+
startLoc?: { line: number };
|
|
36
|
+
endLoc?: { line: number };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface JscpdClone {
|
|
40
|
+
firstFile: JscpdFile;
|
|
41
|
+
secondFile: JscpdFile;
|
|
42
|
+
lines?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function layerOf(
|
|
46
|
+
filesByLayer: Record<string, string[]>,
|
|
47
|
+
filePath: string,
|
|
48
|
+
repoRoot: string,
|
|
49
|
+
): string | undefined {
|
|
50
|
+
// jscpd returns paths relative to cwd (repoRoot) or absolute — normalize both.
|
|
51
|
+
const rel = path.isAbsolute(filePath)
|
|
52
|
+
? path.relative(repoRoot, filePath)
|
|
53
|
+
: filePath;
|
|
54
|
+
for (const [layerId, files] of Object.entries(filesByLayer)) {
|
|
55
|
+
if (files.includes(rel)) return layerId;
|
|
56
|
+
}
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function severityFor(copyCount: number, sameLayer: boolean): Severity {
|
|
61
|
+
if (copyCount >= 3) return 'critical';
|
|
62
|
+
if (!sameLayer) return 'warning';
|
|
63
|
+
return 'info';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function resolveFilePath(filePath: string, repoRoot: string): string {
|
|
67
|
+
if (path.isAbsolute(filePath)) return path.relative(repoRoot, filePath);
|
|
68
|
+
// jscpd may return paths relative to cwd where the command ran (repoRoot)
|
|
69
|
+
return filePath;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function analyzeRedundancy(
|
|
73
|
+
repoRoot: string,
|
|
74
|
+
filesByLayer: Record<string, string[]>,
|
|
75
|
+
config: HealthConfig,
|
|
76
|
+
): Promise<Finding[]> {
|
|
77
|
+
const allFiles = Object.values(filesByLayer).flat().filter(f => /\.tsx?$/.test(f));
|
|
78
|
+
if (allFiles.length === 0) return [];
|
|
79
|
+
|
|
80
|
+
const absPaths = allFiles.map(f =>
|
|
81
|
+
path.isAbsolute(f) ? f : path.join(repoRoot, f),
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const outDir = mkdtempSync(path.join(tmpdir(), 'jscpd-'));
|
|
85
|
+
try {
|
|
86
|
+
try {
|
|
87
|
+
// jscpd .bin wrapper is a shell script; execute via sh to be safe.
|
|
88
|
+
execFileSync(
|
|
89
|
+
jscpdBin(),
|
|
90
|
+
[
|
|
91
|
+
'--reporters', 'json',
|
|
92
|
+
'--output', outDir,
|
|
93
|
+
'--min-lines', String(config.thresholds.redundancy_min_lines),
|
|
94
|
+
'--silent',
|
|
95
|
+
...absPaths,
|
|
96
|
+
],
|
|
97
|
+
{ cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] },
|
|
98
|
+
);
|
|
99
|
+
} catch {
|
|
100
|
+
// jscpd exits with code 1 when duplications exceed the threshold.
|
|
101
|
+
// The report file is still written — only bail below if it's missing.
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
let report: { duplicates?: JscpdClone[] };
|
|
105
|
+
try {
|
|
106
|
+
const raw = readFileSync(path.join(outDir, 'jscpd-report.json'), 'utf8');
|
|
107
|
+
report = JSON.parse(raw) as { duplicates?: JscpdClone[] };
|
|
108
|
+
} catch {
|
|
109
|
+
// No report file — no supported files found or jscpd failed entirely.
|
|
110
|
+
return [];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const clones = report.duplicates ?? [];
|
|
114
|
+
const findings: Finding[] = [];
|
|
115
|
+
|
|
116
|
+
for (const clone of clones) {
|
|
117
|
+
const pathA = resolveFilePath(clone.firstFile.name, repoRoot);
|
|
118
|
+
const pathB = resolveFilePath(clone.secondFile.name, repoRoot);
|
|
119
|
+
|
|
120
|
+
const layerA = layerOf(filesByLayer, pathA, repoRoot);
|
|
121
|
+
const layerB = layerOf(filesByLayer, pathB, repoRoot);
|
|
122
|
+
const layer = layerA ?? layerB ?? 'unknown';
|
|
123
|
+
|
|
124
|
+
const startA = clone.firstFile.startLoc?.line ?? clone.firstFile.start;
|
|
125
|
+
const endA = clone.firstFile.endLoc?.line ?? clone.firstFile.end;
|
|
126
|
+
const startB = clone.secondFile.startLoc?.line ?? clone.secondFile.start;
|
|
127
|
+
const endB = clone.secondFile.endLoc?.line ?? clone.secondFile.end;
|
|
128
|
+
const lineCount = clone.lines ?? (endA - startA + 1);
|
|
129
|
+
|
|
130
|
+
const sameLayer = layerA !== undefined && layerA === layerB;
|
|
131
|
+
const severity = severityFor(2, sameLayer);
|
|
132
|
+
|
|
133
|
+
const idKey = `${pathA.replace(/\W/g, '_')}_L${startA}`;
|
|
134
|
+
findings.push({
|
|
135
|
+
id: `${layer}.redundancy.${idKey}`,
|
|
136
|
+
signal: 'redundancy',
|
|
137
|
+
severity,
|
|
138
|
+
confidence: 'high',
|
|
139
|
+
layer,
|
|
140
|
+
target: pathA,
|
|
141
|
+
headline: `${path.basename(pathA)} duplicates ${lineCount} lines into ${path.basename(pathB)}`,
|
|
142
|
+
suggestion: {
|
|
143
|
+
summary: `Extract the duplicated ${lineCount}-line block into a shared module`,
|
|
144
|
+
rationale: `Identical code in ${path.basename(pathA)} (L${startA}-${endA}) and ${path.basename(pathB)} (L${startB}-${endB}) creates maintenance risk — one fix must land in both places.`,
|
|
145
|
+
action_kind: 'extract-shared',
|
|
146
|
+
},
|
|
147
|
+
details: {
|
|
148
|
+
kind: 'redundancy',
|
|
149
|
+
copies: [
|
|
150
|
+
{ path: pathA, lines: [startA, endA] },
|
|
151
|
+
{ path: pathB, lines: [startB, endB] },
|
|
152
|
+
],
|
|
153
|
+
similarity_pct: 100,
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return findings;
|
|
159
|
+
} finally {
|
|
160
|
+
rmSync(outDir, { recursive: true, force: true });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { analyzeTestCoverage } from './testCoverage';
|
|
3
|
+
import { writeFileSync, mkdtempSync, mkdirSync, rmSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
describe('analyzeTestCoverage', () => {
|
|
8
|
+
it('flags use case files without sibling tests', () => {
|
|
9
|
+
const root = mkdtempSync(path.join(tmpdir(), 'cov-'));
|
|
10
|
+
mkdirSync(path.join(root, 'src/domains/x/application'), { recursive: true });
|
|
11
|
+
const a = 'src/domains/x/application/RunFooUseCase.ts';
|
|
12
|
+
const b = 'src/domains/x/application/RunBarUseCase.ts';
|
|
13
|
+
writeFileSync(path.join(root, a), 'export class RunFooUseCase {}');
|
|
14
|
+
writeFileSync(path.join(root, b), 'export class RunBarUseCase {}');
|
|
15
|
+
writeFileSync(path.join(root, 'src/domains/x/application/RunFooUseCase.test.ts'), 'test("x", () => {});');
|
|
16
|
+
|
|
17
|
+
const findings = analyzeTestCoverage(root, 'lyr', [a, b]);
|
|
18
|
+
expect(findings).toHaveLength(1);
|
|
19
|
+
expect(findings[0].target).toBe(b);
|
|
20
|
+
expect(findings[0].severity).toBe('warning');
|
|
21
|
+
rmSync(root, { recursive: true, force: true });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('flags non-use-case infrastructure files at info severity when missing tests', () => {
|
|
25
|
+
const root = mkdtempSync(path.join(tmpdir(), 'cov-'));
|
|
26
|
+
mkdirSync(path.join(root, 'src/domains/x/infrastructure'), { recursive: true });
|
|
27
|
+
const a = 'src/domains/x/infrastructure/SupabaseRepo.ts';
|
|
28
|
+
writeFileSync(path.join(root, a), 'export class SupabaseRepo {}');
|
|
29
|
+
const findings = analyzeTestCoverage(root, 'lyr', [a]);
|
|
30
|
+
expect(findings.find(f => f.target === a)?.severity).toBe('info');
|
|
31
|
+
rmSync(root, { recursive: true, force: true });
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import type { Finding, Severity } from './types';
|
|
4
|
+
|
|
5
|
+
const USE_CASE_RE = /\/application\/[^/]*UseCase\.ts$/;
|
|
6
|
+
const INFRA_RE = /\/infrastructure\/[^/]+\.ts$/;
|
|
7
|
+
const TEST_SIBLING_PATTERNS = ['.test.ts', '.test.tsx'];
|
|
8
|
+
|
|
9
|
+
function siblingTestPath(repoRoot: string, file: string): string | null {
|
|
10
|
+
const dir = path.dirname(file);
|
|
11
|
+
const base = path.basename(file).replace(/\.tsx?$/, '');
|
|
12
|
+
for (const suffix of TEST_SIBLING_PATTERNS) {
|
|
13
|
+
const candidate = path.join(dir, base + suffix);
|
|
14
|
+
if (existsSync(path.resolve(repoRoot, candidate))) return candidate;
|
|
15
|
+
const inFolder = path.join(dir, '__tests__', base + suffix);
|
|
16
|
+
if (existsSync(path.resolve(repoRoot, inFolder))) return inFolder;
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function analyzeTestCoverage(
|
|
22
|
+
repoRoot: string,
|
|
23
|
+
layerId: string,
|
|
24
|
+
files: string[],
|
|
25
|
+
): Finding[] {
|
|
26
|
+
const findings: Finding[] = [];
|
|
27
|
+
for (const rel of files) {
|
|
28
|
+
if (rel.endsWith('.test.ts') || rel.endsWith('.test.tsx')) continue;
|
|
29
|
+
const isUseCase = USE_CASE_RE.test(rel);
|
|
30
|
+
const isInfra = INFRA_RE.test(rel);
|
|
31
|
+
if (!isUseCase && !isInfra) continue;
|
|
32
|
+
if (siblingTestPath(repoRoot, rel)) continue;
|
|
33
|
+
const severity: Severity = isUseCase ? 'warning' : 'info';
|
|
34
|
+
const filename = path.basename(rel);
|
|
35
|
+
const suggestedTestPath = rel.replace(/\.tsx?$/, '.test.ts');
|
|
36
|
+
findings.push({
|
|
37
|
+
id: `${layerId}.test-coverage.${filename.replace(/\W/g, '_')}`,
|
|
38
|
+
signal: 'test-coverage',
|
|
39
|
+
severity,
|
|
40
|
+
confidence: 'high',
|
|
41
|
+
layer: layerId,
|
|
42
|
+
target: rel,
|
|
43
|
+
headline: `${filename} has no test`,
|
|
44
|
+
suggestion: {
|
|
45
|
+
summary: `Add test at ${suggestedTestPath}`,
|
|
46
|
+
rationale: isUseCase
|
|
47
|
+
? 'Application-layer use cases are core behavior and should have unit tests.'
|
|
48
|
+
: 'Infrastructure adapters benefit from a focused test against their interface.',
|
|
49
|
+
action_kind: 'add-test',
|
|
50
|
+
},
|
|
51
|
+
details: { kind: 'test-coverage', suggested_test_path: suggestedTestPath },
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return findings;
|
|
55
|
+
}
|