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,97 @@
|
|
|
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
|
+
interface TodoMatch { line: number; text: string }
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Find TODO|FIXME markers in single-line and block comments. Crude but
|
|
11
|
+
* sufficient: we strip string literals before scanning to avoid false positives.
|
|
12
|
+
*/
|
|
13
|
+
export function parseTodoLines(source: string): TodoMatch[] {
|
|
14
|
+
const stripped = source.replace(/(["'`])(?:\\.|(?!\1).)*\1/g, (m) => ' '.repeat(m.length));
|
|
15
|
+
const lines = stripped.split('\n');
|
|
16
|
+
const out: TodoMatch[] = [];
|
|
17
|
+
for (let i = 0; i < lines.length; i++) {
|
|
18
|
+
const m = /(?:\/\/|\/\*|\*)\s*(TODO|FIXME)\b[^\n]*/i.exec(lines[i]);
|
|
19
|
+
if (!m) continue;
|
|
20
|
+
out.push({ line: i + 1, text: m[0].replace(/^[/*\s]+/, '') });
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface BlameInfo { author: string; commitHash: string; ageDays: number }
|
|
26
|
+
|
|
27
|
+
function gitBlameLine(repoRoot: string, relPath: string, line: number): BlameInfo | null {
|
|
28
|
+
try {
|
|
29
|
+
const out = execSync(`git blame --line-porcelain -L ${line},${line} -- "${relPath}"`, {
|
|
30
|
+
cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
31
|
+
});
|
|
32
|
+
const commitHash = out.split('\n')[0]?.split(' ')[0] ?? '';
|
|
33
|
+
const authorMatch = /^author (.+)$/m.exec(out);
|
|
34
|
+
const timeMatch = /^author-time (\d+)$/m.exec(out);
|
|
35
|
+
if (!commitHash || !timeMatch) return null;
|
|
36
|
+
const ts = Number(timeMatch[1]) * 1000;
|
|
37
|
+
const ageDays = Math.floor((Date.now() - ts) / (24 * 3600 * 1000));
|
|
38
|
+
return { author: authorMatch?.[1] ?? 'unknown', commitHash, ageDays };
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function severityForAge(ageDays: number, thresholds: HealthConfig['thresholds']['todo_age_months']): Severity | null {
|
|
45
|
+
const months = ageDays / 30;
|
|
46
|
+
if (months >= thresholds.critical) return 'critical';
|
|
47
|
+
if (months >= thresholds.warning) return 'warning';
|
|
48
|
+
if (months >= thresholds.info) return 'info';
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function analyzeAgingTodos(
|
|
53
|
+
repoRoot: string,
|
|
54
|
+
layerId: string,
|
|
55
|
+
files: string[],
|
|
56
|
+
config: HealthConfig,
|
|
57
|
+
): Finding[] {
|
|
58
|
+
const findings: Finding[] = [];
|
|
59
|
+
for (const rel of files) {
|
|
60
|
+
let src: string;
|
|
61
|
+
try { src = readFileSync(path.resolve(repoRoot, rel), 'utf8'); } catch { continue; }
|
|
62
|
+
const todos = parseTodoLines(src);
|
|
63
|
+
if (todos.length === 0) continue;
|
|
64
|
+
const lines = src.split('\n');
|
|
65
|
+
for (const t of todos) {
|
|
66
|
+
const blame = gitBlameLine(repoRoot, rel, t.line);
|
|
67
|
+
if (!blame) continue;
|
|
68
|
+
const severity = severityForAge(blame.ageDays, config.thresholds.todo_age_months);
|
|
69
|
+
if (!severity) continue;
|
|
70
|
+
const ctx = lines.slice(Math.max(0, t.line - 2), t.line + 1).join('\n');
|
|
71
|
+
findings.push({
|
|
72
|
+
id: `${layerId}.aging-todos.${rel.replace(/\W/g, '_')}_${t.line}`,
|
|
73
|
+
signal: 'aging-todos',
|
|
74
|
+
severity,
|
|
75
|
+
confidence: 'high',
|
|
76
|
+
layer: layerId,
|
|
77
|
+
target: rel,
|
|
78
|
+
headline: `${path.basename(rel)}:${t.line} — ${blame.ageDays}d old`,
|
|
79
|
+
suggestion: {
|
|
80
|
+
summary: 'Resolve or remove this TODO',
|
|
81
|
+
rationale: `Created ${blame.ageDays} days ago by ${blame.author} (${blame.commitHash.slice(0, 7)}). Aging markers tend to outlive their relevance.`,
|
|
82
|
+
action_kind: 'resolve-todo',
|
|
83
|
+
},
|
|
84
|
+
details: {
|
|
85
|
+
kind: 'aging-todos',
|
|
86
|
+
line: t.line,
|
|
87
|
+
age_days: blame.ageDays,
|
|
88
|
+
commit_hash: blame.commitHash,
|
|
89
|
+
author: blame.author,
|
|
90
|
+
text: t.text,
|
|
91
|
+
context: ctx,
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return findings;
|
|
97
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { analyzeArchCompliance } from './archCompliance';
|
|
3
|
+
|
|
4
|
+
describe('analyzeArchCompliance', () => {
|
|
5
|
+
it('flags files placed outside the conventional subfolders', () => {
|
|
6
|
+
const findings = analyzeArchCompliance('lyr', [
|
|
7
|
+
'apps/editor/src/domains/x/application/RunFoo.ts', // ok
|
|
8
|
+
'apps/editor/src/domains/x/services/Bar.ts', // ok
|
|
9
|
+
'apps/editor/src/domains/x/RandomThing.ts', // FLAG
|
|
10
|
+
'apps/editor/src/domains/x/utility.ts', // FLAG
|
|
11
|
+
]);
|
|
12
|
+
expect(findings).toHaveLength(2);
|
|
13
|
+
expect(findings[0].signal).toBe('architecture-compliance');
|
|
14
|
+
expect(findings[0].severity).toBe('warning');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('does not flag files inside conventional subfolders', () => {
|
|
18
|
+
const findings = analyzeArchCompliance('lyr', [
|
|
19
|
+
'apps/editor/src/domains/x/application/foo.ts',
|
|
20
|
+
'apps/editor/src/domains/x/domain/bar.ts',
|
|
21
|
+
'apps/editor/src/domains/x/infrastructure/baz.ts',
|
|
22
|
+
'apps/editor/src/domains/x/ui/q.tsx',
|
|
23
|
+
]);
|
|
24
|
+
expect(findings).toEqual([]);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import type { Finding } from './types';
|
|
3
|
+
|
|
4
|
+
const CONVENTIONAL_FOLDERS = ['application', 'domain', 'infrastructure', 'services', 'ui', 'components', 'hooks', '__tests__'];
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Editor uses DDD-ish layout under each domain. We expect each file to live
|
|
8
|
+
* in one of CONVENTIONAL_FOLDERS. Files at the domain root, or in folders not
|
|
9
|
+
* in the list, get flagged.
|
|
10
|
+
*/
|
|
11
|
+
export function analyzeArchCompliance(
|
|
12
|
+
layerId: string,
|
|
13
|
+
files: string[],
|
|
14
|
+
): Finding[] {
|
|
15
|
+
const findings: Finding[] = [];
|
|
16
|
+
for (const rel of files) {
|
|
17
|
+
const m = /\/domains\/[^/]+\/([^/]+)\//.exec(rel);
|
|
18
|
+
if (m) {
|
|
19
|
+
if (CONVENTIONAL_FOLDERS.includes(m[1])) continue;
|
|
20
|
+
findings.push(makeFinding(layerId, rel, m[1], 'unconventional-subfolder'));
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
const rootM = /\/domains\/[^/]+\/[^/]+$/.exec(rel);
|
|
24
|
+
if (rootM) {
|
|
25
|
+
findings.push(makeFinding(layerId, rel, '(root)', 'file-at-domain-root'));
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return findings;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function makeFinding(layerId: string, rel: string, actual: string, rule: string): Finding {
|
|
32
|
+
const filename = path.basename(rel);
|
|
33
|
+
return {
|
|
34
|
+
id: `${layerId}.architecture-compliance.${filename.replace(/\W/g, '_')}`,
|
|
35
|
+
signal: 'architecture-compliance',
|
|
36
|
+
severity: 'warning',
|
|
37
|
+
confidence: 'medium',
|
|
38
|
+
layer: layerId,
|
|
39
|
+
target: rel,
|
|
40
|
+
headline: `${filename} is in a non-standard location ('${actual}')`,
|
|
41
|
+
suggestion: {
|
|
42
|
+
summary: `Move ${filename} to one of: application/, domain/, infrastructure/, services/, ui/`,
|
|
43
|
+
rationale: 'Editor domains follow a conventional folder layout. Files outside it are harder to locate during navigation and code review.',
|
|
44
|
+
action_kind: 'move-file',
|
|
45
|
+
},
|
|
46
|
+
details: {
|
|
47
|
+
kind: 'architecture-compliance',
|
|
48
|
+
expected_path_pattern: 'apps/editor/src/domains/<domain>/{application,domain,infrastructure,services,ui}/...',
|
|
49
|
+
actual_path: rel,
|
|
50
|
+
rule,
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { collectAll } from './collectAll';
|
|
3
|
+
import { DEFAULT_HEALTH_CONFIG } from './config';
|
|
4
|
+
import { findRepoRoot } from '../repoRoot';
|
|
5
|
+
import type { Scope } from '../../../types';
|
|
6
|
+
|
|
7
|
+
describe('collectAll', () => {
|
|
8
|
+
const repoRoot = findRepoRoot(process.cwd());
|
|
9
|
+
|
|
10
|
+
it('returns findings array with the layer id present in each finding', async () => {
|
|
11
|
+
if (!repoRoot) throw new Error('Could not find repo root');
|
|
12
|
+
const scope: Scope = {
|
|
13
|
+
id: 'editor', path: 'apps/editor', description: '',
|
|
14
|
+
layers: [{
|
|
15
|
+
id: 'visual-canvas', name: 'Visual Canvas', description: '',
|
|
16
|
+
files: ['src/domains/canvas/**'],
|
|
17
|
+
dependencies: { inbound: [], outbound: [], external: [] },
|
|
18
|
+
}],
|
|
19
|
+
items: [],
|
|
20
|
+
};
|
|
21
|
+
const findings = await collectAll(repoRoot, scope, DEFAULT_HEALTH_CONFIG);
|
|
22
|
+
expect(Array.isArray(findings)).toBe(true);
|
|
23
|
+
for (const f of findings) {
|
|
24
|
+
expect(f.layer).toBe('visual-canvas');
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('skips signals that are opted-out via config.ignore.layer_signals', async () => {
|
|
29
|
+
if (!repoRoot) throw new Error('Could not find repo root');
|
|
30
|
+
const scope: Scope = {
|
|
31
|
+
id: 'editor', path: 'apps/editor', description: '',
|
|
32
|
+
layers: [{
|
|
33
|
+
id: 'visual-canvas', name: 'Visual Canvas', description: '',
|
|
34
|
+
files: ['src/domains/canvas/**'],
|
|
35
|
+
dependencies: { inbound: [], outbound: [], external: [] },
|
|
36
|
+
}],
|
|
37
|
+
items: [],
|
|
38
|
+
};
|
|
39
|
+
const cfg = {
|
|
40
|
+
...DEFAULT_HEALTH_CONFIG,
|
|
41
|
+
ignore: {
|
|
42
|
+
...DEFAULT_HEALTH_CONFIG.ignore,
|
|
43
|
+
layer_signals: { 'visual-canvas': ['hotspot-files', 'aging-todos', 'doc-drift', 'test-coverage', 'architecture-compliance', 'dead-code', 'redundancy'] as const },
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
const findings = await collectAll(repoRoot, scope, cfg as any);
|
|
47
|
+
expect(findings).toEqual([]);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { Scope } from '../../../types';
|
|
2
|
+
import type { Finding, Signal } from './types';
|
|
3
|
+
import type { HealthConfig } from './config';
|
|
4
|
+
import { resolveLayerFiles } from './layerFiles';
|
|
5
|
+
import { analyzeHotspotFiles } from './hotspotFiles';
|
|
6
|
+
import { analyzeAgingTodos } from './agingTodos';
|
|
7
|
+
import { analyzeDocDrift } from './docDrift';
|
|
8
|
+
import { analyzeTestCoverage } from './testCoverage';
|
|
9
|
+
import { analyzeArchCompliance } from './archCompliance';
|
|
10
|
+
import { buildImportGraph } from './importGraph';
|
|
11
|
+
import type { ImportGraph } from './importGraph';
|
|
12
|
+
import { analyzeDeadCode } from './deadCode';
|
|
13
|
+
import { analyzeCoupling } from './coupling';
|
|
14
|
+
import { analyzeRedundancy } from './redundancy';
|
|
15
|
+
|
|
16
|
+
interface AnalyzerArgs {
|
|
17
|
+
repoRoot: string;
|
|
18
|
+
scopeId: string;
|
|
19
|
+
layerId: string;
|
|
20
|
+
files: string[];
|
|
21
|
+
config: HealthConfig;
|
|
22
|
+
graph?: ImportGraph;
|
|
23
|
+
}
|
|
24
|
+
type AnalyzerFn = (args: AnalyzerArgs) => Finding[];
|
|
25
|
+
|
|
26
|
+
const ANALYZERS: Record<Signal, AnalyzerFn | null> = {
|
|
27
|
+
'hotspot-files': ({ repoRoot, layerId, files, config }) => analyzeHotspotFiles(repoRoot, layerId, files, config),
|
|
28
|
+
'aging-todos': ({ repoRoot, layerId, files, config }) => analyzeAgingTodos(repoRoot, layerId, files, config),
|
|
29
|
+
'doc-drift': ({ repoRoot, scopeId, layerId, files }) => analyzeDocDrift(repoRoot, scopeId, layerId, files),
|
|
30
|
+
'test-coverage': ({ repoRoot, layerId, files }) => analyzeTestCoverage(repoRoot, layerId, files),
|
|
31
|
+
'architecture-compliance': ({ layerId, files }) => analyzeArchCompliance(layerId, files),
|
|
32
|
+
'dead-code': ({ repoRoot, layerId, files, graph }) => graph ? analyzeDeadCode(repoRoot, layerId, files, graph) : [],
|
|
33
|
+
'coupling': null, // dispatched once per scope after the layer loop
|
|
34
|
+
'redundancy': null, // Phase 4
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export async function collectAll(repoRoot: string, scope: Scope, config: HealthConfig): Promise<Finding[]> {
|
|
38
|
+
const filesByLayer = resolveLayerFiles(repoRoot, scope);
|
|
39
|
+
const allTsFiles = Object.values(filesByLayer).flat().filter(f => /\.tsx?$/.test(f));
|
|
40
|
+
let graph: ImportGraph | undefined;
|
|
41
|
+
try {
|
|
42
|
+
graph = buildImportGraph(repoRoot, allTsFiles);
|
|
43
|
+
} catch {
|
|
44
|
+
// No tsconfig or build error — skip import-graph dependent analyzers
|
|
45
|
+
}
|
|
46
|
+
const out: Finding[] = [];
|
|
47
|
+
for (const layer of scope.layers) {
|
|
48
|
+
const opted = new Set<Signal>(config.ignore.layer_signals[layer.id] ?? []);
|
|
49
|
+
const files = filesByLayer[layer.id] ?? [];
|
|
50
|
+
for (const [signal, fn] of Object.entries(ANALYZERS) as [Signal, AnalyzerFn | null][]) {
|
|
51
|
+
if (!fn) continue;
|
|
52
|
+
if (opted.has(signal)) continue;
|
|
53
|
+
try {
|
|
54
|
+
out.push(...fn({ repoRoot, scopeId: scope.id, layerId: layer.id, files, config, graph }));
|
|
55
|
+
} catch (e) {
|
|
56
|
+
// Analyzer failures don't break the whole report. Log and continue.
|
|
57
|
+
console.error(`[health] analyzer ${signal} failed for layer ${layer.id}:`, e);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// Coupling is a scope-wide analyzer — run once after per-layer loop
|
|
62
|
+
if (graph) {
|
|
63
|
+
try {
|
|
64
|
+
const couplingFindings = analyzeCoupling(filesByLayer, graph)
|
|
65
|
+
.filter(f => !(config.ignore.layer_signals[f.layer] ?? []).includes('coupling'));
|
|
66
|
+
out.push(...couplingFindings);
|
|
67
|
+
} catch (e) {
|
|
68
|
+
console.error('[health] analyzer coupling failed:', e);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// Redundancy is scope-wide and async (jscpd) — run after per-layer + coupling
|
|
72
|
+
const redundancyFindings = await analyzeRedundancy(repoRoot, filesByLayer, config).catch(e => {
|
|
73
|
+
console.error('[health] redundancy failed:', e);
|
|
74
|
+
return [] as Finding[];
|
|
75
|
+
});
|
|
76
|
+
out.push(...redundancyFindings.filter(f => !(config.ignore.layer_signals[f.layer] ?? []).includes('redundancy')));
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { loadHealthConfig, DEFAULT_HEALTH_CONFIG } from './config';
|
|
3
|
+
import { writeFileSync, mkdtempSync, mkdirSync, rmSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
function makeRepo(): string {
|
|
8
|
+
const root = mkdtempSync(path.join(tmpdir(), 'tenbo-health-'));
|
|
9
|
+
mkdirSync(path.join(root, '.tenbo'), { recursive: true });
|
|
10
|
+
return root;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('loadHealthConfig', () => {
|
|
14
|
+
it('returns defaults when no config file exists', () => {
|
|
15
|
+
const root = makeRepo();
|
|
16
|
+
const cfg = loadHealthConfig(root);
|
|
17
|
+
expect(cfg).toEqual(DEFAULT_HEALTH_CONFIG);
|
|
18
|
+
rmSync(root, { recursive: true, force: true });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('merges file values over defaults', () => {
|
|
22
|
+
const root = makeRepo();
|
|
23
|
+
writeFileSync(path.join(root, '.tenbo/health.config.yaml'), `
|
|
24
|
+
thresholds:
|
|
25
|
+
hotspot_loc:
|
|
26
|
+
critical: 2000
|
|
27
|
+
`);
|
|
28
|
+
const cfg = loadHealthConfig(root);
|
|
29
|
+
expect(cfg.thresholds.hotspot_loc.critical).toBe(2000);
|
|
30
|
+
expect(cfg.thresholds.hotspot_loc.warning).toBe(DEFAULT_HEALTH_CONFIG.thresholds.hotspot_loc.warning);
|
|
31
|
+
rmSync(root, { recursive: true, force: true });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('preserves signal_weights override order', () => {
|
|
35
|
+
const root = makeRepo();
|
|
36
|
+
writeFileSync(path.join(root, '.tenbo/health.config.yaml'), `
|
|
37
|
+
signal_weights:
|
|
38
|
+
- aging-todos
|
|
39
|
+
- dead-code
|
|
40
|
+
`);
|
|
41
|
+
const cfg = loadHealthConfig(root);
|
|
42
|
+
expect(cfg.signal_weights[0]).toBe('aging-todos');
|
|
43
|
+
rmSync(root, { recursive: true, force: true });
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { parse as parseYaml } from 'yaml';
|
|
4
|
+
import { SIGNAL_WEIGHTS_DEFAULT, type Signal } from './types';
|
|
5
|
+
|
|
6
|
+
export interface HealthConfig {
|
|
7
|
+
thresholds: {
|
|
8
|
+
hotspot_loc: { critical: number; warning: number; info: number };
|
|
9
|
+
hotspot_function_loc: number;
|
|
10
|
+
todo_age_months: { critical: number; warning: number; info: number };
|
|
11
|
+
redundancy_min_lines: number;
|
|
12
|
+
};
|
|
13
|
+
ignore: {
|
|
14
|
+
/** Repo-relative file paths exempted from hotspot-files signal. */
|
|
15
|
+
hotspot_files: string[];
|
|
16
|
+
/** Map of layer id -> array of signal ids to skip for that layer. */
|
|
17
|
+
layer_signals: Record<string, Signal[]>;
|
|
18
|
+
};
|
|
19
|
+
signal_weights: Signal[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const DEFAULT_HEALTH_CONFIG: HealthConfig = {
|
|
23
|
+
thresholds: {
|
|
24
|
+
hotspot_loc: { critical: 1000, warning: 500, info: 300 },
|
|
25
|
+
hotspot_function_loc: 150,
|
|
26
|
+
todo_age_months: { critical: 12, warning: 6, info: 3 },
|
|
27
|
+
redundancy_min_lines: 30,
|
|
28
|
+
},
|
|
29
|
+
ignore: {
|
|
30
|
+
hotspot_files: [],
|
|
31
|
+
layer_signals: {},
|
|
32
|
+
},
|
|
33
|
+
signal_weights: SIGNAL_WEIGHTS_DEFAULT,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export function loadHealthConfig(repoRoot: string): HealthConfig {
|
|
37
|
+
const p = path.join(repoRoot, '.tenbo', 'health.config.yaml');
|
|
38
|
+
if (!existsSync(p)) return DEFAULT_HEALTH_CONFIG;
|
|
39
|
+
const raw = parseYaml(readFileSync(p, 'utf8')) as Partial<HealthConfig> | null;
|
|
40
|
+
if (!raw) return DEFAULT_HEALTH_CONFIG;
|
|
41
|
+
return {
|
|
42
|
+
thresholds: {
|
|
43
|
+
hotspot_loc: { ...DEFAULT_HEALTH_CONFIG.thresholds.hotspot_loc, ...(raw.thresholds?.hotspot_loc ?? {}) },
|
|
44
|
+
hotspot_function_loc: raw.thresholds?.hotspot_function_loc ?? DEFAULT_HEALTH_CONFIG.thresholds.hotspot_function_loc,
|
|
45
|
+
todo_age_months: { ...DEFAULT_HEALTH_CONFIG.thresholds.todo_age_months, ...(raw.thresholds?.todo_age_months ?? {}) },
|
|
46
|
+
redundancy_min_lines: raw.thresholds?.redundancy_min_lines ?? DEFAULT_HEALTH_CONFIG.thresholds.redundancy_min_lines,
|
|
47
|
+
},
|
|
48
|
+
ignore: {
|
|
49
|
+
hotspot_files: raw.ignore?.hotspot_files ?? DEFAULT_HEALTH_CONFIG.ignore.hotspot_files,
|
|
50
|
+
layer_signals: raw.ignore?.layer_signals ?? DEFAULT_HEALTH_CONFIG.ignore.layer_signals,
|
|
51
|
+
},
|
|
52
|
+
signal_weights: raw.signal_weights ?? SIGNAL_WEIGHTS_DEFAULT,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { analyzeCoupling } from './coupling';
|
|
3
|
+
import type { ImportGraph } from './importGraph';
|
|
4
|
+
|
|
5
|
+
const stubGraph = (edges: Record<string, string[]>): ImportGraph => ({
|
|
6
|
+
importsFrom: (f) => edges[f] ?? [],
|
|
7
|
+
importedBy: () => [],
|
|
8
|
+
allFiles: () => Object.keys(edges),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
describe('analyzeCoupling', () => {
|
|
12
|
+
it("flags imports that reach into another layer's internals", () => {
|
|
13
|
+
const filesByLayer = {
|
|
14
|
+
A: ['apps/editor/src/A/foo.ts'],
|
|
15
|
+
B: ['apps/editor/src/B/index.ts', 'apps/editor/src/B/internal.ts'],
|
|
16
|
+
};
|
|
17
|
+
const graph = stubGraph({
|
|
18
|
+
'apps/editor/src/A/foo.ts': ['apps/editor/src/B/internal.ts'],
|
|
19
|
+
});
|
|
20
|
+
const findings = analyzeCoupling(filesByLayer, graph);
|
|
21
|
+
expect(findings).toHaveLength(1);
|
|
22
|
+
expect(findings[0].layer).toBe('A');
|
|
23
|
+
expect(findings[0].details.kind).toBe('coupling');
|
|
24
|
+
if (findings[0].details.kind === 'coupling') {
|
|
25
|
+
expect(findings[0].details.crosses_public_api).toBe(false);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("does not flag imports that go through layer's index.ts (public API)", () => {
|
|
30
|
+
const filesByLayer = {
|
|
31
|
+
A: ['apps/editor/src/A/foo.ts'],
|
|
32
|
+
B: ['apps/editor/src/B/index.ts'],
|
|
33
|
+
};
|
|
34
|
+
const graph = stubGraph({
|
|
35
|
+
'apps/editor/src/A/foo.ts': ['apps/editor/src/B/index.ts'],
|
|
36
|
+
});
|
|
37
|
+
const findings = analyzeCoupling(filesByLayer, graph);
|
|
38
|
+
expect(findings).toEqual([]);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import type { Finding, Severity } from './types';
|
|
3
|
+
import type { ImportGraph } from './importGraph';
|
|
4
|
+
|
|
5
|
+
function isPublicApiPath(file: string): boolean {
|
|
6
|
+
const base = path.basename(file);
|
|
7
|
+
return base === 'index.ts' || base === 'index.tsx';
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function severityFor(count: number): Severity {
|
|
11
|
+
if (count >= 5) return 'critical';
|
|
12
|
+
if (count >= 2) return 'warning';
|
|
13
|
+
return 'info';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function analyzeCoupling(
|
|
17
|
+
filesByLayer: Record<string, string[]>,
|
|
18
|
+
graph: ImportGraph,
|
|
19
|
+
): Finding[] {
|
|
20
|
+
const layerOf = new Map<string, string>();
|
|
21
|
+
for (const [layerId, files] of Object.entries(filesByLayer)) {
|
|
22
|
+
for (const f of files) layerOf.set(f, layerId);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type Reach = { source: string; targetLayer: string; targetFile: string; sourceFiles: string[] };
|
|
26
|
+
const reaches = new Map<string, Reach>();
|
|
27
|
+
|
|
28
|
+
for (const [sourceFile, sourceLayer] of layerOf.entries()) {
|
|
29
|
+
for (const targetFile of graph.importsFrom(sourceFile)) {
|
|
30
|
+
const targetLayer = layerOf.get(targetFile);
|
|
31
|
+
if (!targetLayer || targetLayer === sourceLayer) continue;
|
|
32
|
+
if (isPublicApiPath(targetFile)) continue;
|
|
33
|
+
const key = `${sourceLayer}->${targetFile}`;
|
|
34
|
+
const cur = reaches.get(key) ?? { source: sourceLayer, targetLayer, targetFile, sourceFiles: [] };
|
|
35
|
+
cur.sourceFiles.push(sourceFile);
|
|
36
|
+
reaches.set(key, cur);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const findings: Finding[] = [];
|
|
41
|
+
for (const r of reaches.values()) {
|
|
42
|
+
const severity = severityFor(r.sourceFiles.length);
|
|
43
|
+
findings.push({
|
|
44
|
+
id: `${r.source}.coupling.${r.targetFile.replace(/\W/g, '_')}`,
|
|
45
|
+
signal: 'coupling',
|
|
46
|
+
severity,
|
|
47
|
+
confidence: 'high',
|
|
48
|
+
layer: r.source,
|
|
49
|
+
target: r.targetFile,
|
|
50
|
+
headline: `${r.sourceFiles.length} import${r.sourceFiles.length === 1 ? '' : 's'} reach into ${r.targetLayer}/${path.basename(r.targetFile)}`,
|
|
51
|
+
suggestion: {
|
|
52
|
+
summary: `Extract a shared module or expose ${path.basename(r.targetFile)} via ${r.targetLayer}/index.ts`,
|
|
53
|
+
rationale: `Layer ${r.source} reaches into layer ${r.targetLayer}'s internals (${r.targetFile}). Reaching past the public API tightens coupling.`,
|
|
54
|
+
action_kind: 'extract-shared',
|
|
55
|
+
},
|
|
56
|
+
details: {
|
|
57
|
+
kind: 'coupling',
|
|
58
|
+
source_file: r.sourceFiles[0],
|
|
59
|
+
target_file: r.targetFile,
|
|
60
|
+
import_lines: [],
|
|
61
|
+
crosses_public_api: false,
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
return findings;
|
|
66
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { analyzeDeadCode } from './deadCode';
|
|
3
|
+
import { buildImportGraph } from './importGraph';
|
|
4
|
+
import { writeFileSync, mkdtempSync, mkdirSync, rmSync } from 'node:fs';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
describe('analyzeDeadCode', () => {
|
|
9
|
+
it('flags a file with zero importers', () => {
|
|
10
|
+
const root = mkdtempSync(path.join(tmpdir(), 'dead-'));
|
|
11
|
+
writeFileSync(path.join(root, 'tsconfig.json'), JSON.stringify({ compilerOptions: { module: 'esnext', target: 'es2020' }, include: ['**/*.ts'] }));
|
|
12
|
+
mkdirSync(path.join(root, 'src'), { recursive: true });
|
|
13
|
+
writeFileSync(path.join(root, 'src/used.ts'), `export const x = 1;`);
|
|
14
|
+
writeFileSync(path.join(root, 'src/unused.ts'), `export const y = 1;`);
|
|
15
|
+
writeFileSync(path.join(root, 'src/entry.ts'), `import { x } from './used';\nexport const z = x;`);
|
|
16
|
+
const graph = buildImportGraph(root, ['src/entry.ts', 'src/used.ts', 'src/unused.ts']);
|
|
17
|
+
|
|
18
|
+
const findings = analyzeDeadCode(root, 'lyr', ['src/used.ts', 'src/unused.ts'], graph);
|
|
19
|
+
expect(findings.find(f => f.target === 'src/unused.ts')).toBeDefined();
|
|
20
|
+
expect(findings.find(f => f.target === 'src/used.ts')).toBeUndefined();
|
|
21
|
+
rmSync(root, { recursive: true, force: true });
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import type { Finding } from './types';
|
|
4
|
+
import type { ImportGraph } from './importGraph';
|
|
5
|
+
|
|
6
|
+
const ENTRY_PATTERNS = [
|
|
7
|
+
/\/main\.tsx?$/, /\/index\.tsx?$/, /\.test\.tsx?$/, /\.config\.[mc]?[jt]s$/, /vite\.config/,
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
function isLikelyEntry(file: string): boolean {
|
|
11
|
+
return ENTRY_PATTERNS.some(re => re.test(file));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function lastImportedCommit(repoRoot: string, file: string): string | null {
|
|
15
|
+
try {
|
|
16
|
+
const out = execSync(`git log -1 --pretty=format:%H -- "${file}"`, {
|
|
17
|
+
cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
18
|
+
});
|
|
19
|
+
return out.trim() || null;
|
|
20
|
+
} catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function gitAgeDays(repoRoot: string, file: string): number {
|
|
26
|
+
try {
|
|
27
|
+
const out = execSync(`git log -1 --pretty=format:%at -- "${file}"`, {
|
|
28
|
+
cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
29
|
+
});
|
|
30
|
+
const ts = Number(out.trim()) * 1000;
|
|
31
|
+
if (!ts) return 0;
|
|
32
|
+
return Math.floor((Date.now() - ts) / (24 * 3600 * 1000));
|
|
33
|
+
} catch {
|
|
34
|
+
return 0;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function analyzeDeadCode(
|
|
39
|
+
repoRoot: string,
|
|
40
|
+
layerId: string,
|
|
41
|
+
files: string[],
|
|
42
|
+
graph: ImportGraph,
|
|
43
|
+
): Finding[] {
|
|
44
|
+
const findings: Finding[] = [];
|
|
45
|
+
for (const rel of files) {
|
|
46
|
+
// Only analyze TypeScript/TSX source files
|
|
47
|
+
if (!/\.tsx?$/.test(rel)) continue;
|
|
48
|
+
if (rel.endsWith('.test.ts') || rel.endsWith('.test.tsx')) continue;
|
|
49
|
+
if (graph.importedBy(rel).length > 0) continue;
|
|
50
|
+
const filename = path.basename(rel);
|
|
51
|
+
const isEntry = isLikelyEntry(rel);
|
|
52
|
+
findings.push({
|
|
53
|
+
id: `${layerId}.dead-code.${filename.replace(/\W/g, '_')}`,
|
|
54
|
+
signal: 'dead-code',
|
|
55
|
+
severity: isEntry ? 'info' : 'critical',
|
|
56
|
+
confidence: isEntry ? 'low' : 'high',
|
|
57
|
+
layer: layerId,
|
|
58
|
+
target: rel,
|
|
59
|
+
headline: `${filename} has no in-repo importers`,
|
|
60
|
+
suggestion: {
|
|
61
|
+
summary: isEntry ? `Confirm ${filename} is intentionally an entry point` : `Delete ${filename}`,
|
|
62
|
+
rationale: isEntry
|
|
63
|
+
? 'Looks like an entry point (main, index, config, test). Verify before deleting.'
|
|
64
|
+
: 'No file in the repo imports this. Likely safe to remove.',
|
|
65
|
+
action_kind: 'delete-file',
|
|
66
|
+
},
|
|
67
|
+
details: {
|
|
68
|
+
kind: 'dead-code',
|
|
69
|
+
exports: [],
|
|
70
|
+
last_imported_commit: lastImportedCommit(repoRoot, rel),
|
|
71
|
+
git_age_days: gitAgeDays(repoRoot, rel),
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
return findings;
|
|
76
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { analyzeDocDrift, extractFileRefs } from './docDrift';
|
|
3
|
+
import { writeFileSync, mkdtempSync, mkdirSync, rmSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
describe('extractFileRefs', () => {
|
|
8
|
+
it('pulls inline-code-fenced paths from markdown', () => {
|
|
9
|
+
const md = `Look at \`apps/editor/src/foo.ts\` and \`bar/baz.tsx\`.`;
|
|
10
|
+
expect(extractFileRefs(md)).toEqual(['apps/editor/src/foo.ts', 'bar/baz.tsx']);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('ignores prose with no file-like tokens', () => {
|
|
14
|
+
expect(extractFileRefs('See the README. No paths here.')).toEqual([]);
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe('analyzeDocDrift', () => {
|
|
19
|
+
it('emits missing-ref finding when code-map.md references a file that does not exist', () => {
|
|
20
|
+
const root = mkdtempSync(path.join(tmpdir(), 'drift-'));
|
|
21
|
+
mkdirSync(path.join(root, '.tenbo/scopes/editor/layers/lyr'), { recursive: true });
|
|
22
|
+
writeFileSync(
|
|
23
|
+
path.join(root, '.tenbo/scopes/editor/layers/lyr/code-map.md'),
|
|
24
|
+
'See `apps/editor/src/missing.ts`.\n',
|
|
25
|
+
);
|
|
26
|
+
const findings = analyzeDocDrift(root, 'editor', 'lyr', []);
|
|
27
|
+
const broken = findings.find(f =>
|
|
28
|
+
f.details.kind === 'doc-drift' && f.details.drift_type === 'missing-ref'
|
|
29
|
+
);
|
|
30
|
+
expect(broken).toBeDefined();
|
|
31
|
+
rmSync(root, { recursive: true, force: true });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('emits unreferenced-file info finding when a layer file is not mentioned in code-map.md', () => {
|
|
35
|
+
const root = mkdtempSync(path.join(tmpdir(), 'drift-'));
|
|
36
|
+
mkdirSync(path.join(root, 'apps/editor/src'), { recursive: true });
|
|
37
|
+
writeFileSync(path.join(root, 'apps/editor/src/known.ts'), 'export const x = 1;\n');
|
|
38
|
+
writeFileSync(path.join(root, 'apps/editor/src/orphan.ts'), 'export const y = 1;\n');
|
|
39
|
+
mkdirSync(path.join(root, '.tenbo/scopes/editor/layers/lyr'), { recursive: true });
|
|
40
|
+
writeFileSync(
|
|
41
|
+
path.join(root, '.tenbo/scopes/editor/layers/lyr/code-map.md'),
|
|
42
|
+
'See `apps/editor/src/known.ts`.\n',
|
|
43
|
+
);
|
|
44
|
+
const findings = analyzeDocDrift(root, 'editor', 'lyr', [
|
|
45
|
+
'apps/editor/src/known.ts',
|
|
46
|
+
'apps/editor/src/orphan.ts',
|
|
47
|
+
]);
|
|
48
|
+
const orphan = findings.find(f =>
|
|
49
|
+
f.details.kind === 'doc-drift' && f.details.drift_type === 'unreferenced-file'
|
|
50
|
+
);
|
|
51
|
+
expect(orphan).toBeDefined();
|
|
52
|
+
rmSync(root, { recursive: true, force: true });
|
|
53
|
+
});
|
|
54
|
+
});
|