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,152 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { computeDiff, renderOutput, issueKey } from './validate-cli';
|
|
3
|
+
import type { ValidateIssue } from '../src/types';
|
|
4
|
+
|
|
5
|
+
const warn = (msg: string, scope = 'editor', itemId?: string): ValidateIssue => ({
|
|
6
|
+
level: 'warning',
|
|
7
|
+
message: msg,
|
|
8
|
+
scope,
|
|
9
|
+
itemId,
|
|
10
|
+
});
|
|
11
|
+
const err = (msg: string, scope = 'editor', itemId?: string): ValidateIssue => ({
|
|
12
|
+
level: 'error',
|
|
13
|
+
message: msg,
|
|
14
|
+
scope,
|
|
15
|
+
itemId,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const snap = (errors: ValidateIssue[] = [], warnings: ValidateIssue[] = []) => ({
|
|
19
|
+
generated_at: '2026-04-28T00:00:00Z',
|
|
20
|
+
errors,
|
|
21
|
+
warnings,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('issueKey', () => {
|
|
25
|
+
it('hashes message + scope + itemId/layerId stably', () => {
|
|
26
|
+
const a = warn('x', 'editor', 'ed-001');
|
|
27
|
+
const b = warn('x', 'editor', 'ed-001');
|
|
28
|
+
expect(issueKey(a)).toBe(issueKey(b));
|
|
29
|
+
});
|
|
30
|
+
it('distinguishes different itemIds with the same message', () => {
|
|
31
|
+
expect(issueKey(warn('x', 'editor', 'ed-001'))).not.toBe(issueKey(warn('x', 'editor', 'ed-002')));
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('computeDiff', () => {
|
|
36
|
+
it('treats every finding as new when there is no previous snapshot', () => {
|
|
37
|
+
const cur = snap([], [warn('a', 'editor', 'ed-001'), warn('b', 'editor', 'ed-002')]);
|
|
38
|
+
const d = computeDiff(null, cur);
|
|
39
|
+
expect(d.newWarnings).toHaveLength(2);
|
|
40
|
+
expect(d.preExistingWarnings).toHaveLength(0);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('treats identical findings as pre-existing', () => {
|
|
44
|
+
const w = [warn('a', 'editor', 'ed-001'), warn('b', 'editor', 'ed-002')];
|
|
45
|
+
const d = computeDiff(snap([], w), snap([], w));
|
|
46
|
+
expect(d.newWarnings).toHaveLength(0);
|
|
47
|
+
expect(d.preExistingWarnings).toHaveLength(2);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('isolates one new warning against an otherwise-identical prior run', () => {
|
|
51
|
+
const prevW = [warn('a', 'editor', 'ed-001'), warn('b', 'editor', 'ed-002')];
|
|
52
|
+
const newW = [...prevW, warn('c', 'editor', 'ed-003')];
|
|
53
|
+
const d = computeDiff(snap([], prevW), snap([], newW));
|
|
54
|
+
expect(d.newWarnings).toHaveLength(1);
|
|
55
|
+
expect(d.newWarnings[0].message).toBe('c');
|
|
56
|
+
expect(d.preExistingWarnings).toHaveLength(2);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('separates new vs pre-existing errors the same way', () => {
|
|
60
|
+
const prevE = [err('e1', 'editor', 'ed-010')];
|
|
61
|
+
const newE = [...prevE, err('e2', 'editor', 'ed-011')];
|
|
62
|
+
const d = computeDiff(snap(prevE), snap(newE));
|
|
63
|
+
expect(d.newErrors.map((e) => e.message)).toEqual(['e2']);
|
|
64
|
+
expect(d.preExistingErrors.map((e) => e.message)).toEqual(['e1']);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('renderOutput — default mode', () => {
|
|
69
|
+
it('first run with no prev snapshot lists every finding as new', () => {
|
|
70
|
+
const s = snap([], [warn('a', 'editor', 'ed-001')]);
|
|
71
|
+
const d = computeDiff(null, s);
|
|
72
|
+
const r = renderOutput(s, d, {});
|
|
73
|
+
expect(r.exitCode).toBe(0);
|
|
74
|
+
expect(r.stdout).toContain('1 new warning');
|
|
75
|
+
expect(r.stdout).toContain('a');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('second run with identical findings emits the one-liner', () => {
|
|
79
|
+
const w = [warn('a', 'editor', 'ed-001'), warn('b', 'editor', 'ed-002')];
|
|
80
|
+
const s = snap([], w);
|
|
81
|
+
const d = computeDiff(snap([], w), s);
|
|
82
|
+
const r = renderOutput(s, d, {});
|
|
83
|
+
expect(r.exitCode).toBe(0);
|
|
84
|
+
expect(r.stdout).toMatch(/^tenbo validation passed \(0 errors, 2 warnings — all pre-existing\)/);
|
|
85
|
+
expect(r.stderr).toBe('');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('reports a single new warning while folding pre-existing into the summary', () => {
|
|
89
|
+
const prev = [warn('a', 'editor', 'ed-001'), warn('b', 'editor', 'ed-002')];
|
|
90
|
+
const cur = [...prev, warn('c', 'editor', 'ed-003')];
|
|
91
|
+
const s = snap([], cur);
|
|
92
|
+
const d = computeDiff(snap([], prev), s);
|
|
93
|
+
const r = renderOutput(s, d, {});
|
|
94
|
+
expect(r.exitCode).toBe(0);
|
|
95
|
+
expect(r.stdout).toContain('1 new warning');
|
|
96
|
+
expect(r.stdout).toContain('c');
|
|
97
|
+
expect(r.stdout).toContain('2 pre-existing warnings');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('exits 1 and prints new errors to stderr when a new error appears', () => {
|
|
101
|
+
const cur = snap([err('boom', 'editor', 'ed-001')], []);
|
|
102
|
+
const d = computeDiff(null, cur);
|
|
103
|
+
const r = renderOutput(cur, d, {});
|
|
104
|
+
expect(r.exitCode).toBe(1);
|
|
105
|
+
expect(r.stderr).toContain('1 new error');
|
|
106
|
+
expect(r.stderr).toContain('boom');
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('renderOutput — verbose mode', () => {
|
|
111
|
+
it('lists all errors and warnings regardless of diff', () => {
|
|
112
|
+
const w = [warn('a', 'editor', 'ed-001'), warn('b', 'editor', 'ed-002')];
|
|
113
|
+
const s = snap([], w);
|
|
114
|
+
const d = computeDiff(snap([], w), s); // all pre-existing
|
|
115
|
+
const r = renderOutput(s, d, { verbose: true });
|
|
116
|
+
expect(r.exitCode).toBe(0);
|
|
117
|
+
const combined = r.stdout + r.stderr;
|
|
118
|
+
expect(combined).toContain('a');
|
|
119
|
+
expect(combined).toContain('b');
|
|
120
|
+
expect(combined).toContain('tenbo validation warnings (2)');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('prints all errors with the FAILED header in verbose mode', () => {
|
|
124
|
+
const errs = [err('e1', 'editor', 'ed-010'), err('e2', 'editor', 'ed-011')];
|
|
125
|
+
const s = snap(errs, []);
|
|
126
|
+
const d = computeDiff(null, s);
|
|
127
|
+
const r = renderOutput(s, d, { verbose: true });
|
|
128
|
+
expect(r.exitCode).toBe(1);
|
|
129
|
+
expect(r.stderr).toContain('tenbo validation FAILED');
|
|
130
|
+
expect(r.stderr).toContain('e1');
|
|
131
|
+
expect(r.stderr).toContain('e2');
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe('renderOutput — json mode', () => {
|
|
136
|
+
it('emits the snapshot as JSON to stdout', () => {
|
|
137
|
+
const s = snap([], [warn('a', 'editor', 'ed-001')]);
|
|
138
|
+
const d = computeDiff(null, s);
|
|
139
|
+
const r = renderOutput(s, d, { json: true });
|
|
140
|
+
expect(r.exitCode).toBe(0);
|
|
141
|
+
const parsed = JSON.parse(r.stdout);
|
|
142
|
+
expect(parsed.warnings).toHaveLength(1);
|
|
143
|
+
expect(parsed.errors).toHaveLength(0);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('exit code reflects errors even in JSON mode', () => {
|
|
147
|
+
const s = snap([err('boom')], []);
|
|
148
|
+
const d = computeDiff(null, s);
|
|
149
|
+
const r = renderOutput(s, d, { json: true });
|
|
150
|
+
expect(r.exitCode).toBe(1);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* validate-cli.ts — tenbo validator CLI with diff-based output.
|
|
3
|
+
*
|
|
4
|
+
* Default output: summarizes NEW errors / warnings vs the previous run's
|
|
5
|
+
* `.tenbo/.validation-status.json` snapshot. Pre-existing findings are rolled
|
|
6
|
+
* into a single summary line so the signal is the diff, not the absolute set.
|
|
7
|
+
* --verbose: full list of every finding (the historical behavior).
|
|
8
|
+
* --json: full snapshot to stdout for CI consumers.
|
|
9
|
+
*
|
|
10
|
+
* The `.validation-status.json` schema is unchanged; only presentation changes.
|
|
11
|
+
*/
|
|
12
|
+
import { findRepoRoot } from '../src/api/lib/repoRoot';
|
|
13
|
+
import { readState } from '../src/api/lib/tenboFs';
|
|
14
|
+
import { validate } from '../src/api/lib/validator';
|
|
15
|
+
import type { ValidateIssue } from '../src/types';
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
|
|
19
|
+
interface Snapshot {
|
|
20
|
+
generated_at: string;
|
|
21
|
+
errors: ValidateIssue[];
|
|
22
|
+
warnings: ValidateIssue[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface DiffResult {
|
|
26
|
+
newErrors: ValidateIssue[];
|
|
27
|
+
preExistingErrors: ValidateIssue[];
|
|
28
|
+
newWarnings: ValidateIssue[];
|
|
29
|
+
preExistingWarnings: ValidateIssue[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Stable hash for an issue: message + scope + (itemId|layerId). */
|
|
33
|
+
export function issueKey(i: ValidateIssue): string {
|
|
34
|
+
const target = (i.itemId ?? '') + '::' + (i.layerId ?? '');
|
|
35
|
+
return `${i.message}||${i.scope ?? ''}||${target}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function computeDiff(prev: Snapshot | null, current: Snapshot): DiffResult {
|
|
39
|
+
const prevErr = new Set((prev?.errors ?? []).map(issueKey));
|
|
40
|
+
const prevWarn = new Set((prev?.warnings ?? []).map(issueKey));
|
|
41
|
+
const newErrors: ValidateIssue[] = [];
|
|
42
|
+
const preExistingErrors: ValidateIssue[] = [];
|
|
43
|
+
const newWarnings: ValidateIssue[] = [];
|
|
44
|
+
const preExistingWarnings: ValidateIssue[] = [];
|
|
45
|
+
for (const e of current.errors) (prevErr.has(issueKey(e)) ? preExistingErrors : newErrors).push(e);
|
|
46
|
+
for (const w of current.warnings) (prevWarn.has(issueKey(w)) ? preExistingWarnings : newWarnings).push(w);
|
|
47
|
+
return { newErrors, preExistingErrors, newWarnings, preExistingWarnings };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface RenderOptions {
|
|
51
|
+
verbose?: boolean;
|
|
52
|
+
json?: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface RenderResult {
|
|
56
|
+
stdout: string;
|
|
57
|
+
stderr: string;
|
|
58
|
+
exitCode: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Pure renderer — testable without spawning a process. */
|
|
62
|
+
export function renderOutput(snapshot: Snapshot, diff: DiffResult, opts: RenderOptions): RenderResult {
|
|
63
|
+
const out: string[] = [];
|
|
64
|
+
const err: string[] = [];
|
|
65
|
+
|
|
66
|
+
if (opts.json) {
|
|
67
|
+
out.push(JSON.stringify(snapshot, null, 2));
|
|
68
|
+
return {
|
|
69
|
+
stdout: out.join('\n') + '\n',
|
|
70
|
+
stderr: '',
|
|
71
|
+
exitCode: snapshot.errors.length ? 1 : 0,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (opts.verbose) {
|
|
76
|
+
if (snapshot.errors.length) {
|
|
77
|
+
err.push('tenbo validation FAILED:');
|
|
78
|
+
for (const e of snapshot.errors) err.push(` ❌ ${e.message}`);
|
|
79
|
+
} else {
|
|
80
|
+
out.push('tenbo validation: no errors.');
|
|
81
|
+
}
|
|
82
|
+
if (snapshot.warnings.length) {
|
|
83
|
+
const lines: string[] = [];
|
|
84
|
+
lines.push(`tenbo validation warnings (${snapshot.warnings.length}):`);
|
|
85
|
+
for (const w of snapshot.warnings) lines.push(` ⚠️ ${w.message}`);
|
|
86
|
+
(snapshot.errors.length ? err : out).push(lines.join('\n'));
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
stdout: out.join('\n') + (out.length ? '\n' : ''),
|
|
90
|
+
stderr: err.join('\n') + (err.length ? '\n' : ''),
|
|
91
|
+
exitCode: snapshot.errors.length ? 1 : 0,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Default: diff-based summary.
|
|
96
|
+
const { newErrors, preExistingErrors, newWarnings, preExistingWarnings } = diff;
|
|
97
|
+
const totalErrors = newErrors.length + preExistingErrors.length;
|
|
98
|
+
const totalWarnings = newWarnings.length + preExistingWarnings.length;
|
|
99
|
+
|
|
100
|
+
if (newErrors.length === 0 && newWarnings.length === 0) {
|
|
101
|
+
out.push(
|
|
102
|
+
`tenbo validation passed (${totalErrors} errors, ${totalWarnings} warnings — all pre-existing). Run with --verbose to list.`,
|
|
103
|
+
);
|
|
104
|
+
return { stdout: out.join('\n') + '\n', stderr: '', exitCode: 0 };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (newErrors.length > 0) {
|
|
108
|
+
err.push(`tenbo validation FAILED — ${newErrors.length} new error(s):`);
|
|
109
|
+
for (const e of newErrors) err.push(` ❌ ${e.message}`);
|
|
110
|
+
err.push('');
|
|
111
|
+
err.push(
|
|
112
|
+
`Summary: ${newErrors.length} new errors, ${preExistingErrors.length} pre-existing errors, ${newWarnings.length} new warnings, ${preExistingWarnings.length} pre-existing warnings. Run with --verbose to list everything.`,
|
|
113
|
+
);
|
|
114
|
+
return { stdout: '', stderr: err.join('\n') + '\n', exitCode: 1 };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// newErrors === 0, newWarnings > 0
|
|
118
|
+
out.push(`tenbo validation passed with ${newWarnings.length} new warning(s):`);
|
|
119
|
+
for (const w of newWarnings) out.push(` ⚠️ ${w.message}`);
|
|
120
|
+
out.push('');
|
|
121
|
+
out.push(
|
|
122
|
+
`Summary: 0 new errors, ${preExistingErrors.length} pre-existing errors, ${newWarnings.length} new warnings, ${preExistingWarnings.length} pre-existing warnings. Run with --verbose to list everything.`,
|
|
123
|
+
);
|
|
124
|
+
return { stdout: out.join('\n') + '\n', stderr: '', exitCode: 0 };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function readPrevSnapshot(statusPath: string): Snapshot | null {
|
|
128
|
+
if (!fs.existsSync(statusPath)) return null;
|
|
129
|
+
try {
|
|
130
|
+
const raw = JSON.parse(fs.readFileSync(statusPath, 'utf8'));
|
|
131
|
+
return {
|
|
132
|
+
generated_at: raw.generated_at ?? '',
|
|
133
|
+
errors: Array.isArray(raw.errors) ? raw.errors : [],
|
|
134
|
+
warnings: Array.isArray(raw.warnings) ? raw.warnings : [],
|
|
135
|
+
};
|
|
136
|
+
} catch {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function isMain(): boolean {
|
|
142
|
+
try {
|
|
143
|
+
const invoked = process.argv[1] ? path.resolve(process.argv[1]) : '';
|
|
144
|
+
const here = path.resolve(new URL(import.meta.url).pathname);
|
|
145
|
+
return invoked === here;
|
|
146
|
+
} catch {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (isMain()) {
|
|
152
|
+
const args = process.argv.slice(2);
|
|
153
|
+
const verbose = args.includes('--verbose');
|
|
154
|
+
const json = args.includes('--json');
|
|
155
|
+
|
|
156
|
+
const cwd = process.cwd();
|
|
157
|
+
const repoRoot = findRepoRoot(cwd) ?? path.resolve(cwd, '..', '..');
|
|
158
|
+
const statusPath = path.join(repoRoot, '.tenbo', '.validation-status.json');
|
|
159
|
+
const prev = readPrevSnapshot(statusPath);
|
|
160
|
+
|
|
161
|
+
const state = readState(repoRoot);
|
|
162
|
+
const result = validate(state);
|
|
163
|
+
const snapshot: Snapshot = {
|
|
164
|
+
generated_at: new Date().toISOString(),
|
|
165
|
+
errors: result.errors,
|
|
166
|
+
warnings: result.warnings,
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
fs.writeFileSync(statusPath, JSON.stringify(snapshot, null, 2) + '\n');
|
|
170
|
+
|
|
171
|
+
const diff = computeDiff(prev, snapshot);
|
|
172
|
+
const rendered = renderOutput(snapshot, diff, { verbose, json });
|
|
173
|
+
if (rendered.stdout) process.stdout.write(rendered.stdout);
|
|
174
|
+
if (rendered.stderr) process.stderr.write(rendered.stderr);
|
|
175
|
+
if (!json && !verbose) {
|
|
176
|
+
// Add a trailing pointer to where the snapshot was written, to stderr so it
|
|
177
|
+
// doesn't pollute callers parsing stdout.
|
|
178
|
+
process.stderr.write(`(snapshot at ${path.relative(repoRoot, statusPath)})\n`);
|
|
179
|
+
}
|
|
180
|
+
process.exit(rendered.exitCode);
|
|
181
|
+
}
|
package/src/App.tsx
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { TopBar } from './ui/TopBar';
|
|
3
|
+
import { EmptyState } from './ui/EmptyState';
|
|
4
|
+
import { LayerDrawer } from './ui/LayerDrawer';
|
|
5
|
+
import { ItemModal } from './ui/ItemModal';
|
|
6
|
+
import { useTenboState } from './hooks/useTenboState';
|
|
7
|
+
import { useApiPatch } from './hooks/useApiPatch';
|
|
8
|
+
import { tenboApi } from './api/client';
|
|
9
|
+
import { useHashRoute } from './router/useHashRoute';
|
|
10
|
+
import { modeFromRoute, type Mode } from './router/routes';
|
|
11
|
+
import { LayerPage } from './ui/LayerPage/LayerPage';
|
|
12
|
+
import { WorkspacePage } from './ui/WorkspacePage/WorkspacePage';
|
|
13
|
+
import { ScopePage } from './ui/ScopePage/ScopePage';
|
|
14
|
+
import { RoadmapPage } from './ui/RoadmapPage/RoadmapPage';
|
|
15
|
+
import { HealthPage } from './ui/HealthPage/HealthPage';
|
|
16
|
+
import { LayerDetailPage } from './ui/LayerDetailPage/LayerDetailPage';
|
|
17
|
+
import { FindingModal } from './ui/FindingModal';
|
|
18
|
+
import { DotGrid } from './ui/DotGrid';
|
|
19
|
+
import type { Item, Layer, RelatedDoc } from './types';
|
|
20
|
+
import type { Finding } from './api/lib/health/types';
|
|
21
|
+
|
|
22
|
+
type Overlay =
|
|
23
|
+
| { kind: 'drawer'; scopeId: string; layer: Layer }
|
|
24
|
+
| { kind: 'modal'; scopeId: string; item: Item }
|
|
25
|
+
| null;
|
|
26
|
+
|
|
27
|
+
export default function App() {
|
|
28
|
+
const { state, loadError, reload, generation } = useTenboState();
|
|
29
|
+
const patch = useApiPatch();
|
|
30
|
+
const { route, navigate } = useHashRoute();
|
|
31
|
+
|
|
32
|
+
const [overlay, setOverlay] = useState<Overlay>(null);
|
|
33
|
+
const [related, setRelated] = useState<RelatedDoc[]>([]);
|
|
34
|
+
const [openFinding, setOpenFinding] = useState<Finding | null>(null);
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
tenboApi.getRelated().then(setRelated).catch(() => {});
|
|
38
|
+
}, [state]);
|
|
39
|
+
|
|
40
|
+
// Keep overlay item in sync with refreshed state
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (!state || overlay?.kind !== 'modal') return;
|
|
43
|
+
const scope = state.scopes.find(s => s.id === overlay.scopeId);
|
|
44
|
+
const fresh = scope?.items.find(i => i.id === overlay.item.id)
|
|
45
|
+
?? state.crossCuttingRoadmap?.find(i => i.id === overlay.item.id);
|
|
46
|
+
if (fresh && fresh !== overlay.item) {
|
|
47
|
+
setOverlay({ kind: 'modal', scopeId: overlay.scopeId, item: fresh });
|
|
48
|
+
}
|
|
49
|
+
}, [state]);
|
|
50
|
+
|
|
51
|
+
if (loadError && loadError.includes('404')) return <EmptyState message="No .tenbo/ found at the repo root." />;
|
|
52
|
+
if (loadError) return <div style={{ padding: 24, color: 'var(--error)' }}>Error: {loadError} <button onClick={reload}>retry</button></div>;
|
|
53
|
+
if (!state) return <div style={{ padding: 24 }}>Loading…</div>;
|
|
54
|
+
|
|
55
|
+
const mode = modeFromRoute(route);
|
|
56
|
+
const onSelectMode = (m: Mode) => {
|
|
57
|
+
if (m === mode) return;
|
|
58
|
+
if (m === 'roadmap') navigate({ kind: 'roadmap' });
|
|
59
|
+
else if (m === 'health') navigate({ kind: 'health' });
|
|
60
|
+
else navigate({ kind: 'docs-project', tab: 'overview' });
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const handlePatch = async (scopeId: string, itemId: string, p: Partial<Item>) => {
|
|
64
|
+
await patch(scopeId, itemId, p);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const body = (() => {
|
|
68
|
+
if (route.kind === 'docs-project') return <WorkspacePage tab={route.tab} navigate={navigate} state={state} />;
|
|
69
|
+
if (route.kind === 'docs-layer') return <LayerPage scopeId={route.scopeId} layerId={route.layerId} tab={route.tab} navigate={navigate} state={state} generation={generation} />;
|
|
70
|
+
if (route.kind === 'docs-scope') return <ScopePage scopeId={route.scopeId} state={state} navigate={navigate} />;
|
|
71
|
+
if (route.kind === 'item') return <WorkspacePage tab="overview" navigate={navigate} state={state} />;
|
|
72
|
+
if (route.kind === 'health') return (
|
|
73
|
+
<HealthPage
|
|
74
|
+
state={state}
|
|
75
|
+
onSelectLayer={(scopeId, layerId) => navigate({ kind: 'health-layer', scopeId, layerId })}
|
|
76
|
+
onSelectFinding={setOpenFinding}
|
|
77
|
+
/>
|
|
78
|
+
);
|
|
79
|
+
if (route.kind === 'health-layer') return (
|
|
80
|
+
<LayerDetailPage
|
|
81
|
+
state={state}
|
|
82
|
+
scopeId={route.scopeId}
|
|
83
|
+
layerId={route.layerId}
|
|
84
|
+
onBack={() => navigate({ kind: 'health' })}
|
|
85
|
+
onSelectFinding={setOpenFinding}
|
|
86
|
+
/>
|
|
87
|
+
);
|
|
88
|
+
const scopeFilter = route.kind === 'roadmap' ? route.scope : undefined;
|
|
89
|
+
const layerFilter = route.kind === 'roadmap' ? route.layer : undefined;
|
|
90
|
+
return (
|
|
91
|
+
<RoadmapPage
|
|
92
|
+
state={state}
|
|
93
|
+
scopeFilter={scopeFilter}
|
|
94
|
+
layerFilter={layerFilter}
|
|
95
|
+
navigate={navigate}
|
|
96
|
+
onCardClick={(scopeId, item) => setOverlay({ kind: 'modal', scopeId, item })}
|
|
97
|
+
onPatch={handlePatch}
|
|
98
|
+
/>
|
|
99
|
+
);
|
|
100
|
+
})();
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<>
|
|
104
|
+
<DotGrid />
|
|
105
|
+
<TopBar
|
|
106
|
+
mode={mode}
|
|
107
|
+
onSelectMode={onSelectMode}
|
|
108
|
+
onReload={reload}
|
|
109
|
+
/>
|
|
110
|
+
{body}
|
|
111
|
+
{overlay?.kind === 'drawer' && (
|
|
112
|
+
<LayerDrawer
|
|
113
|
+
scopeId={overlay.scopeId}
|
|
114
|
+
layer={overlay.layer}
|
|
115
|
+
narrative={state.narratives[`${overlay.scopeId}/${overlay.layer.id}`] ?? null}
|
|
116
|
+
onClose={() => setOverlay(null)}
|
|
117
|
+
/>
|
|
118
|
+
)}
|
|
119
|
+
{overlay?.kind === 'modal' && (
|
|
120
|
+
<ItemModal
|
|
121
|
+
item={overlay.item}
|
|
122
|
+
scopeId={overlay.scopeId}
|
|
123
|
+
layers={state.scopes.find(s => s.id === overlay.scopeId)?.layers ?? []}
|
|
124
|
+
relatedDocs={related}
|
|
125
|
+
state={state}
|
|
126
|
+
onClose={() => setOverlay(null)}
|
|
127
|
+
onPatch={async (p) => { await patch(overlay.scopeId, overlay.item.id, p); }}
|
|
128
|
+
onOpenFile={(path) => { tenboApi.openFile(path).catch(() => {}); }}
|
|
129
|
+
onPromoteRelated={async (path) => {
|
|
130
|
+
const cur = overlay.item.links ?? [];
|
|
131
|
+
if (!cur.includes(path)) {
|
|
132
|
+
await patch(overlay.scopeId, overlay.item.id, { links: [...cur, path] });
|
|
133
|
+
}
|
|
134
|
+
}}
|
|
135
|
+
onSelectItem={(sId, it) => {
|
|
136
|
+
// Cross-cutting items don't carry a scope — fall back to current scope
|
|
137
|
+
// so the modal still has a sensible scope for layer dropdowns.
|
|
138
|
+
setOverlay({ kind: 'modal', scopeId: sId ?? overlay.scopeId, item: it });
|
|
139
|
+
}}
|
|
140
|
+
navigate={navigate}
|
|
141
|
+
/>
|
|
142
|
+
)}
|
|
143
|
+
<FindingModal
|
|
144
|
+
finding={openFinding}
|
|
145
|
+
allFindings={Object.values(state.metrics ?? {}).flatMap(m => m.findings ?? [])}
|
|
146
|
+
onClose={() => setOpenFinding(null)}
|
|
147
|
+
onOpenFile={(path) => { tenboApi.openFile(path).catch(() => {}); }}
|
|
148
|
+
onSelectFinding={setOpenFinding}
|
|
149
|
+
/>
|
|
150
|
+
</>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Component, ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
interface State { error: Error | null }
|
|
4
|
+
|
|
5
|
+
export class ErrorBoundary extends Component<{ children: ReactNode }, State> {
|
|
6
|
+
state: State = { error: null };
|
|
7
|
+
static getDerivedStateFromError(error: Error) { return { error }; }
|
|
8
|
+
componentDidCatch(error: Error, info: unknown) {
|
|
9
|
+
console.error('UI crashed:', error, info);
|
|
10
|
+
}
|
|
11
|
+
render() {
|
|
12
|
+
if (this.state.error) {
|
|
13
|
+
return (
|
|
14
|
+
<div style={{ padding: 24, fontFamily: 'system-ui' }}>
|
|
15
|
+
<h2 style={{ color: 'var(--error)' }}>Something went wrong</h2>
|
|
16
|
+
<pre style={{ background: 'var(--bg-2)', padding: 12, borderRadius: 4, overflow: 'auto', fontSize: 12 }}>
|
|
17
|
+
{this.state.error.message}
|
|
18
|
+
</pre>
|
|
19
|
+
<button className="outlined-button" onClick={() => location.reload()}>Reload</button>
|
|
20
|
+
</div>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
return this.props.children;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { TenboState, RelatedDoc, Item, LayerDoc } from '../types';
|
|
2
|
+
|
|
3
|
+
async function http<T>(url: string, init?: RequestInit): Promise<T> {
|
|
4
|
+
const r = await fetch(url, init);
|
|
5
|
+
if (!r.ok) throw new Error(`${init?.method ?? 'GET'} ${url} -> ${r.status}`);
|
|
6
|
+
return r.json() as Promise<T>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const tenboApi = {
|
|
10
|
+
getState(): Promise<TenboState> {
|
|
11
|
+
return http('/api/state');
|
|
12
|
+
},
|
|
13
|
+
getRelated(): Promise<RelatedDoc[]> {
|
|
14
|
+
return http('/api/related');
|
|
15
|
+
},
|
|
16
|
+
patchItem(scopeId: string, itemId: string, patch: Partial<Item>): Promise<{ ok: true }> {
|
|
17
|
+
return http(`/api/items/${itemId}`, {
|
|
18
|
+
method: 'PATCH',
|
|
19
|
+
headers: { 'Content-Type': 'application/json' },
|
|
20
|
+
body: JSON.stringify({ scope: scopeId, patch }),
|
|
21
|
+
});
|
|
22
|
+
},
|
|
23
|
+
reorder(scopeId: string, ids: string[]): Promise<{ ok: true }> {
|
|
24
|
+
return http('/api/items/reorder', {
|
|
25
|
+
method: 'POST',
|
|
26
|
+
headers: { 'Content-Type': 'application/json' },
|
|
27
|
+
body: JSON.stringify({ scope: scopeId, ids }),
|
|
28
|
+
});
|
|
29
|
+
},
|
|
30
|
+
getLayerDocs(scopeId: string, layerId: string): Promise<LayerDoc[]> {
|
|
31
|
+
const params = new URLSearchParams({ scope: scopeId, layer: layerId });
|
|
32
|
+
return http(`/api/layer-docs?${params}`);
|
|
33
|
+
},
|
|
34
|
+
openFile(path: string): Promise<{ ok: true }> {
|
|
35
|
+
return http('/api/open', {
|
|
36
|
+
method: 'POST',
|
|
37
|
+
headers: { 'Content-Type': 'application/json' },
|
|
38
|
+
body: JSON.stringify({ path }),
|
|
39
|
+
});
|
|
40
|
+
},
|
|
41
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, statSync, existsSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import matter from 'gray-matter';
|
|
4
|
+
import type { RelatedDoc } from '../../types';
|
|
5
|
+
|
|
6
|
+
const DEFAULT_DIRS = ['docs/superpowers/plans', 'docs/superpowers/specs'];
|
|
7
|
+
|
|
8
|
+
function walk(dir: string, out: string[] = []): string[] {
|
|
9
|
+
if (!existsSync(dir)) return out;
|
|
10
|
+
for (const entry of readdirSync(dir)) {
|
|
11
|
+
const full = path.join(dir, entry);
|
|
12
|
+
const st = statSync(full);
|
|
13
|
+
if (st.isDirectory()) walk(full, out);
|
|
14
|
+
else if (st.isFile() && full.endsWith('.md')) out.push(full);
|
|
15
|
+
}
|
|
16
|
+
return out;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function scanForRelated(repoRoot: string, extraDirs: string[] = []): RelatedDoc[] {
|
|
20
|
+
const dirs = [...DEFAULT_DIRS, ...extraDirs].map(d => path.join(repoRoot, d));
|
|
21
|
+
const files = dirs.flatMap(d => walk(d));
|
|
22
|
+
const out: RelatedDoc[] = [];
|
|
23
|
+
for (const file of files) {
|
|
24
|
+
const text = readFileSync(file, 'utf8');
|
|
25
|
+
const parsed = matter(text);
|
|
26
|
+
const itemId = parsed.data?.tenbo_item;
|
|
27
|
+
if (typeof itemId === 'string') {
|
|
28
|
+
const titleMatch = parsed.content.match(/^#\s+(.+)$/m);
|
|
29
|
+
out.push({
|
|
30
|
+
path: path.relative(repoRoot, file),
|
|
31
|
+
itemId,
|
|
32
|
+
title: titleMatch?.[1]?.trim(),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { analyzeAgingTodos, parseTodoLines } from './agingTodos';
|
|
3
|
+
import { DEFAULT_HEALTH_CONFIG } from './config';
|
|
4
|
+
|
|
5
|
+
describe('parseTodoLines', () => {
|
|
6
|
+
it('extracts TODO and FIXME with line numbers', () => {
|
|
7
|
+
const src = [
|
|
8
|
+
'function foo() {',
|
|
9
|
+
' // TODO: fix this',
|
|
10
|
+
' /* FIXME(poyi): broken */',
|
|
11
|
+
' return 1;',
|
|
12
|
+
'}',
|
|
13
|
+
].join('\n');
|
|
14
|
+
const matches = parseTodoLines(src);
|
|
15
|
+
expect(matches).toHaveLength(2);
|
|
16
|
+
expect(matches[0]).toEqual({ line: 2, text: 'TODO: fix this' });
|
|
17
|
+
expect(matches[1].line).toBe(3);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('ignores TODO inside string literals', () => {
|
|
21
|
+
const src = `const s = "TODO this is a string";`;
|
|
22
|
+
expect(parseTodoLines(src)).toEqual([]);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('analyzeAgingTodos', () => {
|
|
27
|
+
// Smoke test: just ensure it returns an array on real-repo files without throwing.
|
|
28
|
+
it('does not throw on real files', () => {
|
|
29
|
+
const findings = analyzeAgingTodos(process.cwd(), 'lyr', [], DEFAULT_HEALTH_CONFIG);
|
|
30
|
+
expect(Array.isArray(findings)).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
});
|