tenbo-dashboard 0.4.1 → 0.6.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/bin/tenbo-dashboard.mjs +3 -0
- package/package.json +1 -1
- package/scripts/archive.ts +88 -0
- package/src/api/lib/health/agingSuperseded.ts +76 -0
- package/src/api/lib/health/collectAll.ts +17 -4
- package/src/api/lib/health/docDrift.test.ts +18 -0
- package/src/api/lib/health/docDrift.ts +4 -1
- package/src/api/lib/health/layerFiles.test.ts +5 -5
- package/src/api/lib/health/types.ts +5 -1
- package/src/api/lib/tenboFs.ts +85 -2
- package/src/api/lib/validator.goalRef.test.ts +76 -0
- package/src/api/lib/validator.relationships.test.ts +7 -1
- package/src/api/lib/validator.specLinks.test.ts +5 -1
- package/src/api/lib/validator.ts +118 -0
- package/src/api/plugin.ts +2 -0
- package/src/api/routes/archive.ts +60 -0
- package/src/api/routes/watch.ts +2 -0
- package/src/hooks/useApiPatch.ts +3 -2
- package/src/router/routes.ts +1 -1
- package/src/router/useHashRoute.test.ts +5 -3
- package/src/types.ts +45 -0
- package/src/ui/ArchivedContext.ts +12 -0
- package/src/ui/ItemCard.module.css +18 -0
- package/src/ui/ItemCard.tsx +13 -7
- package/src/ui/LayerDetailPage/LayerDetailPage.tsx +1 -0
- package/src/ui/RoadmapPage/RoadmapPage.module.css +22 -0
- package/src/ui/RoadmapPage/RoadmapPage.tsx +47 -9
- package/src/ui/SummaryStrip.tsx +1 -1
- package/src/ui/WorkspacePage/WorkspacePage.tsx +4 -1
- package/src/ui/WorkspacePage/tabs/DecisionsTab.module.css +176 -0
- package/src/ui/WorkspacePage/tabs/DecisionsTab.tsx +194 -0
package/bin/tenbo-dashboard.mjs
CHANGED
|
@@ -39,6 +39,7 @@ const commands = {
|
|
|
39
39
|
metrics: 'scripts/compute-metrics.ts',
|
|
40
40
|
'init-check': 'scripts/init-check.ts',
|
|
41
41
|
sync: 'scripts/sync.ts',
|
|
42
|
+
archive: 'scripts/archive.ts',
|
|
42
43
|
};
|
|
43
44
|
|
|
44
45
|
// Documented aliases for the bare-launch behavior. Users (and agents) reading
|
|
@@ -79,6 +80,8 @@ Usage:
|
|
|
79
80
|
tenbo-dashboard init-check Strict completeness check for fresh init (errors on missing skeletons, file_count:0, etc)
|
|
80
81
|
tenbo-dashboard next-id <prefix> Allocate next roadmap item ID
|
|
81
82
|
tenbo-dashboard metrics --all Compute scope metrics
|
|
83
|
+
tenbo-dashboard archive Move old done/dropped items to roadmap-archive.yaml
|
|
84
|
+
[--scope <id>] [--days 30] [--max 20]
|
|
82
85
|
tenbo-dashboard --version Print package version
|
|
83
86
|
tenbo-dashboard help Show this help
|
|
84
87
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* archive.ts — CLI to move done/dropped roadmap items to roadmap-archive.yaml.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* npx tenbo-dashboard archive [--scope <id>] [--days 30] [--max 20]
|
|
6
|
+
*
|
|
7
|
+
* --days (default 30): only archive done/dropped items whose doc_update is older than N days.
|
|
8
|
+
* --max (default 20): only trigger if done+dropped count exceeds this threshold per scope.
|
|
9
|
+
* --scope: limit to one scope (default: all scopes).
|
|
10
|
+
*/
|
|
11
|
+
import { findRepoRoot } from '../src/api/lib/repoRoot';
|
|
12
|
+
import { readState, archiveItems } from '../src/api/lib/tenboFs';
|
|
13
|
+
import type { Item } from '../src/types';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
|
|
16
|
+
function parseArgs(argv: string[]): { scope?: string; days: number; max: number } {
|
|
17
|
+
let scope: string | undefined;
|
|
18
|
+
let days = 30;
|
|
19
|
+
let max = 20;
|
|
20
|
+
for (let i = 0; i < argv.length; i++) {
|
|
21
|
+
if (argv[i] === '--scope' && argv[i + 1]) {
|
|
22
|
+
scope = argv[++i];
|
|
23
|
+
} else if (argv[i] === '--days' && argv[i + 1]) {
|
|
24
|
+
days = parseInt(argv[++i], 10);
|
|
25
|
+
} else if (argv[i] === '--max' && argv[i + 1]) {
|
|
26
|
+
max = parseInt(argv[++i], 10);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return { scope, days, max };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isOlderThanDays(docUpdate: string | undefined, days: number): boolean {
|
|
33
|
+
if (!docUpdate) return false;
|
|
34
|
+
const match = docUpdate.match(/^\d{4}-\d{2}-\d{2}$/);
|
|
35
|
+
if (!match) return false;
|
|
36
|
+
const itemDate = new Date(docUpdate);
|
|
37
|
+
const cutoff = new Date();
|
|
38
|
+
cutoff.setDate(cutoff.getDate() - days);
|
|
39
|
+
return itemDate <= cutoff;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function getArchivableIds(items: Item[], days: number, maxThreshold: number): string[] {
|
|
43
|
+
const doneDropped = items.filter(i => i.status === 'done' || i.status === 'dropped');
|
|
44
|
+
if (doneDropped.length <= maxThreshold) return [];
|
|
45
|
+
return doneDropped
|
|
46
|
+
.filter(i => isOlderThanDays(i.doc_update, days))
|
|
47
|
+
.map(i => i.id);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isMain(): boolean {
|
|
51
|
+
try {
|
|
52
|
+
const invoked = process.argv[1] ? path.resolve(process.argv[1]) : '';
|
|
53
|
+
const here = path.resolve(new URL(import.meta.url).pathname);
|
|
54
|
+
return invoked === here;
|
|
55
|
+
} catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (isMain()) {
|
|
61
|
+
const args = parseArgs(process.argv.slice(2));
|
|
62
|
+
const cwd = process.cwd();
|
|
63
|
+
const repoRoot = findRepoRoot(cwd) ?? path.resolve(cwd, '..', '..');
|
|
64
|
+
|
|
65
|
+
const state = readState(repoRoot);
|
|
66
|
+
const scopesToProcess = args.scope
|
|
67
|
+
? state.scopes.filter(s => s.id === args.scope)
|
|
68
|
+
: state.scopes;
|
|
69
|
+
|
|
70
|
+
if (args.scope && scopesToProcess.length === 0) {
|
|
71
|
+
process.stderr.write(`Error: scope '${args.scope}' not found.\n`);
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let totalArchived = 0;
|
|
76
|
+
for (const scope of scopesToProcess) {
|
|
77
|
+
const ids = getArchivableIds(scope.items, args.days, args.max);
|
|
78
|
+
if (ids.length > 0) {
|
|
79
|
+
archiveItems(repoRoot, scope.id, ids);
|
|
80
|
+
console.log(`Archived ${ids.length} items from scope '${scope.id}' to roadmap-archive.yaml`);
|
|
81
|
+
totalArchived += ids.length;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (totalArchived === 0) {
|
|
86
|
+
console.log('No items eligible for archiving.');
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { Item, Scope } from '../../../types';
|
|
2
|
+
import type { Finding } from './types';
|
|
3
|
+
|
|
4
|
+
const THIRTY_DAYS_MS = 30 * 24 * 3600 * 1000;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Surfaces `later` items that are >30 days old and reference at least one
|
|
8
|
+
* done/dropped item (via spawned_from, superseded_by, or related).
|
|
9
|
+
* These are likely stale — either they should be dropped, completed, or
|
|
10
|
+
* explicitly re-justified with an updated note.
|
|
11
|
+
*/
|
|
12
|
+
export function analyzeAgingSuperseded(
|
|
13
|
+
scope: Scope,
|
|
14
|
+
allItems: Map<string, Item>,
|
|
15
|
+
now: number = Date.now(),
|
|
16
|
+
): Finding[] {
|
|
17
|
+
const findings: Finding[] = [];
|
|
18
|
+
|
|
19
|
+
for (const item of scope.items) {
|
|
20
|
+
if (item.status !== 'later') continue;
|
|
21
|
+
|
|
22
|
+
const ageDays = itemAgeDays(item, now);
|
|
23
|
+
if (ageDays < 30) continue;
|
|
24
|
+
|
|
25
|
+
const refs = collectReferences(item);
|
|
26
|
+
if (refs.length === 0) continue;
|
|
27
|
+
|
|
28
|
+
const referencedDoneOrDropped = refs
|
|
29
|
+
.map(id => allItems.get(id))
|
|
30
|
+
.filter((ref): ref is Item => ref !== undefined && (ref.status === 'done' || ref.status === 'dropped'))
|
|
31
|
+
.map(ref => ({ id: ref.id, status: ref.status }));
|
|
32
|
+
|
|
33
|
+
if (referencedDoneOrDropped.length === 0) continue;
|
|
34
|
+
|
|
35
|
+
findings.push({
|
|
36
|
+
id: `${item.layer ?? scope.id}.aging-superseded.${item.id}`,
|
|
37
|
+
signal: 'aging-superseded',
|
|
38
|
+
severity: ageDays > 90 ? 'warning' : 'info',
|
|
39
|
+
confidence: 'medium',
|
|
40
|
+
layer: item.layer ?? scope.layers[0]?.id ?? scope.id,
|
|
41
|
+
target: item.id,
|
|
42
|
+
headline: `"${item.title}" is ${ageDays}d old in later, references ${referencedDoneOrDropped.length} done/dropped item(s)`,
|
|
43
|
+
suggestion: {
|
|
44
|
+
summary: 'Mark dropped, mark done, or update with a note explaining the delay',
|
|
45
|
+
rationale: `This item has been in "later" for ${ageDays} days and references items that have already shipped or been dropped. It may be stale.`,
|
|
46
|
+
action_kind: 'triage-item',
|
|
47
|
+
},
|
|
48
|
+
details: {
|
|
49
|
+
kind: 'aging-superseded',
|
|
50
|
+
item_id: item.id,
|
|
51
|
+
item_title: item.title,
|
|
52
|
+
age_days: ageDays,
|
|
53
|
+
referenced_items: referencedDoneOrDropped,
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return findings;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function collectReferences(item: Item): string[] {
|
|
62
|
+
const refs: string[] = [];
|
|
63
|
+
if (item.spawned_from) refs.push(item.spawned_from);
|
|
64
|
+
if (item.superseded_by) refs.push(item.superseded_by);
|
|
65
|
+
if (item.related) refs.push(...item.related);
|
|
66
|
+
return refs;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function itemAgeDays(item: Item, now: number): number {
|
|
70
|
+
const docUpdate = item.doc_update;
|
|
71
|
+
if (docUpdate && /^\d{4}-\d{2}-\d{2}$/.test(docUpdate)) {
|
|
72
|
+
const ts = new Date(docUpdate).getTime();
|
|
73
|
+
return Math.floor((now - ts) / (24 * 3600 * 1000));
|
|
74
|
+
}
|
|
75
|
+
return THIRTY_DAYS_MS / (24 * 3600 * 1000) + 1; // assume old enough if no date
|
|
76
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Scope } from '../../../types';
|
|
1
|
+
import type { Item, Scope } from '../../../types';
|
|
2
2
|
import type { Finding, Signal } from './types';
|
|
3
3
|
import type { HealthConfig } from './config';
|
|
4
4
|
import { resolveLayerFiles } from './layerFiles';
|
|
@@ -12,10 +12,12 @@ import type { ImportGraph } from './importGraph';
|
|
|
12
12
|
import { analyzeDeadCode } from './deadCode';
|
|
13
13
|
import { analyzeCoupling } from './coupling';
|
|
14
14
|
import { analyzeRedundancy } from './redundancy';
|
|
15
|
+
import { analyzeAgingSuperseded } from './agingSuperseded';
|
|
15
16
|
|
|
16
17
|
interface AnalyzerArgs {
|
|
17
18
|
repoRoot: string;
|
|
18
19
|
scopeId: string;
|
|
20
|
+
scopePath?: string;
|
|
19
21
|
layerId: string;
|
|
20
22
|
files: string[];
|
|
21
23
|
config: HealthConfig;
|
|
@@ -26,7 +28,8 @@ type AnalyzerFn = (args: AnalyzerArgs) => Finding[];
|
|
|
26
28
|
const ANALYZERS: Record<Signal, AnalyzerFn | null> = {
|
|
27
29
|
'hotspot-files': ({ repoRoot, layerId, files, config }) => analyzeHotspotFiles(repoRoot, layerId, files, config),
|
|
28
30
|
'aging-todos': ({ repoRoot, layerId, files, config }) => analyzeAgingTodos(repoRoot, layerId, files, config),
|
|
29
|
-
'
|
|
31
|
+
'aging-superseded': null, // scope-wide item analyzer, dispatched after the layer loop
|
|
32
|
+
'doc-drift': ({ repoRoot, scopeId, layerId, files, scopePath }) => analyzeDocDrift(repoRoot, scopeId, layerId, files, scopePath),
|
|
30
33
|
'test-coverage': ({ repoRoot, layerId, files }) => analyzeTestCoverage(repoRoot, layerId, files),
|
|
31
34
|
'architecture-compliance': ({ layerId, files }) => analyzeArchCompliance(layerId, files),
|
|
32
35
|
'dead-code': ({ repoRoot, layerId, files, graph }) => graph ? analyzeDeadCode(repoRoot, layerId, files, graph) : [],
|
|
@@ -34,7 +37,7 @@ const ANALYZERS: Record<Signal, AnalyzerFn | null> = {
|
|
|
34
37
|
'redundancy': null, // Phase 4
|
|
35
38
|
};
|
|
36
39
|
|
|
37
|
-
export async function collectAll(repoRoot: string, scope: Scope, config: HealthConfig): Promise<Finding[]> {
|
|
40
|
+
export async function collectAll(repoRoot: string, scope: Scope, config: HealthConfig, allItems?: Map<string, Item>): Promise<Finding[]> {
|
|
38
41
|
const filesByLayer = resolveLayerFiles(repoRoot, scope);
|
|
39
42
|
const allTsFiles = Object.values(filesByLayer).flat().filter(f => /\.tsx?$/.test(f));
|
|
40
43
|
let graph: ImportGraph | undefined;
|
|
@@ -51,7 +54,7 @@ export async function collectAll(repoRoot: string, scope: Scope, config: HealthC
|
|
|
51
54
|
if (!fn) continue;
|
|
52
55
|
if (opted.has(signal)) continue;
|
|
53
56
|
try {
|
|
54
|
-
out.push(...fn({ repoRoot, scopeId: scope.id, layerId: layer.id, files, config, graph }));
|
|
57
|
+
out.push(...fn({ repoRoot, scopeId: scope.id, scopePath: scope.path, layerId: layer.id, files, config, graph }));
|
|
55
58
|
} catch (e) {
|
|
56
59
|
// Analyzer failures don't break the whole report. Log and continue.
|
|
57
60
|
console.error(`[health] analyzer ${signal} failed for layer ${layer.id}:`, e);
|
|
@@ -74,5 +77,15 @@ export async function collectAll(repoRoot: string, scope: Scope, config: HealthC
|
|
|
74
77
|
return [] as Finding[];
|
|
75
78
|
});
|
|
76
79
|
out.push(...redundancyFindings.filter(f => !(config.ignore.layer_signals[f.layer] ?? []).includes('redundancy')));
|
|
80
|
+
|
|
81
|
+
// Aging-superseded is item-based (not file-based) — run once per scope
|
|
82
|
+
try {
|
|
83
|
+
const itemMap = allItems ?? new Map(scope.items.map(i => [i.id, i]));
|
|
84
|
+
const agingFindings = analyzeAgingSuperseded(scope, itemMap);
|
|
85
|
+
out.push(...agingFindings.filter(f => !(config.ignore.layer_signals[f.layer] ?? []).includes('aging-superseded')));
|
|
86
|
+
} catch (e) {
|
|
87
|
+
console.error('[health] aging-superseded failed:', e);
|
|
88
|
+
}
|
|
89
|
+
|
|
77
90
|
return out;
|
|
78
91
|
}
|
|
@@ -31,6 +31,24 @@ describe('analyzeDocDrift', () => {
|
|
|
31
31
|
rmSync(root, { recursive: true, force: true });
|
|
32
32
|
});
|
|
33
33
|
|
|
34
|
+
it('does NOT emit missing-ref when file exists under the scope path', () => {
|
|
35
|
+
const root = mkdtempSync(path.join(tmpdir(), 'drift-'));
|
|
36
|
+
const scopePath = 'tenbo-dashboard';
|
|
37
|
+
mkdirSync(path.join(root, scopePath, 'src'), { recursive: true });
|
|
38
|
+
writeFileSync(path.join(root, scopePath, 'src/main.tsx'), 'export default 1;\n');
|
|
39
|
+
mkdirSync(path.join(root, '.tenbo/scopes/dashboard/layers/lyr'), { recursive: true });
|
|
40
|
+
writeFileSync(
|
|
41
|
+
path.join(root, '.tenbo/scopes/dashboard/layers/lyr/code-map.md'),
|
|
42
|
+
'Entry point: `src/main.tsx`.\n',
|
|
43
|
+
);
|
|
44
|
+
const findings = analyzeDocDrift(root, 'dashboard', 'lyr', [], scopePath);
|
|
45
|
+
const broken = findings.find(f =>
|
|
46
|
+
f.details.kind === 'doc-drift' && f.details.drift_type === 'missing-ref'
|
|
47
|
+
);
|
|
48
|
+
expect(broken).toBeUndefined();
|
|
49
|
+
rmSync(root, { recursive: true, force: true });
|
|
50
|
+
});
|
|
51
|
+
|
|
34
52
|
it('emits unreferenced-file info finding when a layer file is not mentioned in code-map.md', () => {
|
|
35
53
|
const root = mkdtempSync(path.join(tmpdir(), 'drift-'));
|
|
36
54
|
mkdirSync(path.join(root, 'apps/editor/src'), { recursive: true });
|
|
@@ -22,6 +22,7 @@ export function analyzeDocDrift(
|
|
|
22
22
|
scopeId: string,
|
|
23
23
|
layerId: string,
|
|
24
24
|
layerFiles: string[],
|
|
25
|
+
scopePath?: string,
|
|
25
26
|
): Finding[] {
|
|
26
27
|
const docPath = path.join(repoRoot, '.tenbo/scopes', scopeId, 'layers', layerId, 'code-map.md');
|
|
27
28
|
if (!existsSync(docPath)) return [];
|
|
@@ -34,7 +35,9 @@ export function analyzeDocDrift(
|
|
|
34
35
|
// 1. missing-ref: file in code-map.md doesn't exist on disk
|
|
35
36
|
const broken: string[] = [];
|
|
36
37
|
for (const ref of refs) {
|
|
37
|
-
|
|
38
|
+
const existsAtRoot = existsSync(path.join(repoRoot, ref));
|
|
39
|
+
const existsAtScope = scopePath ? existsSync(path.join(repoRoot, scopePath, ref)) : false;
|
|
40
|
+
if (!existsAtRoot && !existsAtScope) broken.push(ref);
|
|
38
41
|
}
|
|
39
42
|
if (broken.length > 0) {
|
|
40
43
|
const severity = broken.length >= 3 ? 'critical' : 'warning';
|
|
@@ -8,16 +8,16 @@ describe('resolveLayerFiles', () => {
|
|
|
8
8
|
|
|
9
9
|
it('returns repo-relative paths bucketed by layer', () => {
|
|
10
10
|
if (!repoRoot) throw new Error('Could not find repo root');
|
|
11
|
-
// Use a real fixture: this repo's
|
|
11
|
+
// Use a real fixture: this repo's health directory which has known .ts files.
|
|
12
12
|
const scope: Scope = {
|
|
13
|
-
id: '
|
|
14
|
-
path: '
|
|
13
|
+
id: 'dashboard',
|
|
14
|
+
path: 'tenbo-dashboard/src/api/lib/health',
|
|
15
15
|
description: '',
|
|
16
16
|
layers: [{
|
|
17
17
|
id: 'visual-canvas',
|
|
18
18
|
name: 'Visual Canvas',
|
|
19
19
|
description: '',
|
|
20
|
-
files: ['
|
|
20
|
+
files: ['*.ts'],
|
|
21
21
|
dependencies: { inbound: [], outbound: [], external: [] },
|
|
22
22
|
}],
|
|
23
23
|
items: [],
|
|
@@ -26,7 +26,7 @@ describe('resolveLayerFiles', () => {
|
|
|
26
26
|
expect(result['visual-canvas']).toBeDefined();
|
|
27
27
|
expect(result['visual-canvas'].length).toBeGreaterThan(0);
|
|
28
28
|
// Paths are repo-relative
|
|
29
|
-
expect(result['visual-canvas'][0]).toMatch(/^
|
|
29
|
+
expect(result['visual-canvas'][0]).toMatch(/^tenbo-dashboard\/src\/api\/lib\/health\//);
|
|
30
30
|
});
|
|
31
31
|
|
|
32
32
|
it('returns empty array for layer with no matching files', () => {
|
|
@@ -9,6 +9,7 @@ export type Signal =
|
|
|
9
9
|
| 'doc-drift'
|
|
10
10
|
| 'test-coverage'
|
|
11
11
|
| 'aging-todos'
|
|
12
|
+
| 'aging-superseded'
|
|
12
13
|
| 'architecture-compliance'
|
|
13
14
|
| 'redundancy';
|
|
14
15
|
|
|
@@ -20,7 +21,8 @@ export type ActionKind =
|
|
|
20
21
|
| 'move-file'
|
|
21
22
|
| 'update-doc'
|
|
22
23
|
| 'add-test'
|
|
23
|
-
| 'resolve-todo'
|
|
24
|
+
| 'resolve-todo'
|
|
25
|
+
| 'triage-item';
|
|
24
26
|
|
|
25
27
|
export interface Suggestion {
|
|
26
28
|
summary: string; // imperative one-liner
|
|
@@ -36,6 +38,7 @@ export type FindingDetails =
|
|
|
36
38
|
| { kind: 'doc-drift'; drift_type: 'missing-ref' | 'unreferenced-file' | 'stale-section'; doc_path: string; doc_mtime_iso: string | null; code_mtime_iso: string | null; affected_files: string[] }
|
|
37
39
|
| { kind: 'test-coverage'; suggested_test_path: string }
|
|
38
40
|
| { kind: 'aging-todos'; line: number; age_days: number; commit_hash: string; author: string; text: string; context: string }
|
|
41
|
+
| { kind: 'aging-superseded'; item_id: string; item_title: string; age_days: number; referenced_items: { id: string; status: string }[] }
|
|
39
42
|
| { kind: 'architecture-compliance'; expected_path_pattern: string; actual_path: string; rule: string }
|
|
40
43
|
| { kind: 'redundancy'; copies: { path: string; lines: [number, number] }[]; similarity_pct: number };
|
|
41
44
|
|
|
@@ -66,6 +69,7 @@ export const SIGNAL_WEIGHTS_DEFAULT: Signal[] = [
|
|
|
66
69
|
'doc-drift',
|
|
67
70
|
'test-coverage',
|
|
68
71
|
'aging-todos',
|
|
72
|
+
'aging-superseded',
|
|
69
73
|
];
|
|
70
74
|
|
|
71
75
|
export const CONFIDENCE_RANK: Record<Confidence, number> = {
|
package/src/api/lib/tenboFs.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync, readdirSync, existsSync, renameSync, statSync, type Stats } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { parse as parseSimple } from 'yaml';
|
|
3
|
+
import { parse as parseSimple, stringify as yamlStringify } from 'yaml';
|
|
4
4
|
import { parseYaml, stringifyYaml, patchSeqItem, reorderSeqItems } from './yamlOrdered';
|
|
5
5
|
import { getCached, invalidate as invalidateCache } from './parseCache';
|
|
6
|
-
import type { TenboState, Scope, Item, Layer, CrossCutting, LayerDocs, ScopeMetrics, WorkspaceContent, LayerContent } from '../../types';
|
|
6
|
+
import type { TenboState, Scope, Item, Layer, CrossCutting, LayerDocs, ScopeMetrics, WorkspaceContent, LayerContent, DecisionRecord } from '../../types';
|
|
7
7
|
|
|
8
8
|
function tenboDir(repoRoot: string) { return path.join(repoRoot, '.tenbo'); }
|
|
9
9
|
|
|
@@ -70,6 +70,13 @@ function readRoadmap(repoRoot: string, scopeId: string): Item[] {
|
|
|
70
70
|
return data?.items ?? [];
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
export function readRoadmapArchive(repoRoot: string, scopeId: string): Item[] {
|
|
74
|
+
const file = path.join(tenboDir(repoRoot), 'scopes', scopeId, 'roadmap-archive.yaml');
|
|
75
|
+
if (!existsSync(file)) return [];
|
|
76
|
+
const data = getCached(file, 'roadmap-archive-yaml', (text) => parseSimple(text) as any);
|
|
77
|
+
return data?.items ?? [];
|
|
78
|
+
}
|
|
79
|
+
|
|
73
80
|
function readNarratives(repoRoot: string, scopeId: string): Record<string, string> {
|
|
74
81
|
const dir = path.join(tenboDir(repoRoot), 'scopes', scopeId, 'layers');
|
|
75
82
|
if (!existsSync(dir)) return {};
|
|
@@ -186,6 +193,43 @@ export function readSpecFiles(repoRoot: string): Set<string> | undefined {
|
|
|
186
193
|
return out;
|
|
187
194
|
}
|
|
188
195
|
|
|
196
|
+
/**
|
|
197
|
+
* Read project-level decision records from `.tenbo/decisions/*.md`.
|
|
198
|
+
* Returns undefined if the directory does not exist (graceful no-op).
|
|
199
|
+
* Frontmatter is parsed leniently — malformed entries are still returned
|
|
200
|
+
* with whatever fields could be parsed; the validator surfaces issues.
|
|
201
|
+
*/
|
|
202
|
+
export function readDecisions(repoRoot: string): Record<string, DecisionRecord> | undefined {
|
|
203
|
+
const dir = path.join(tenboDir(repoRoot), 'decisions');
|
|
204
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory()) return undefined;
|
|
205
|
+
const out: Record<string, DecisionRecord> = {};
|
|
206
|
+
for (const entry of readdirSync(dir)) {
|
|
207
|
+
if (!entry.endsWith('.md')) continue;
|
|
208
|
+
const full = path.join(dir, entry);
|
|
209
|
+
if (!statSync(full).isFile()) continue;
|
|
210
|
+
const text = readFileSync(full, 'utf8');
|
|
211
|
+
const slug = entry.replace(/\.md$/, '');
|
|
212
|
+
const fmMatch = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
|
213
|
+
let frontmatter: Record<string, unknown> = {};
|
|
214
|
+
let body = text;
|
|
215
|
+
if (fmMatch) {
|
|
216
|
+
try {
|
|
217
|
+
frontmatter = (parseSimple(fmMatch[1]) as Record<string, unknown>) ?? {};
|
|
218
|
+
} catch {
|
|
219
|
+
frontmatter = {};
|
|
220
|
+
}
|
|
221
|
+
body = fmMatch[2] ?? '';
|
|
222
|
+
}
|
|
223
|
+
out[slug] = {
|
|
224
|
+
path: `.tenbo/decisions/${entry}`,
|
|
225
|
+
slug,
|
|
226
|
+
frontmatter: frontmatter as DecisionRecord['frontmatter'],
|
|
227
|
+
body,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
return out;
|
|
231
|
+
}
|
|
232
|
+
|
|
189
233
|
export function readState(repoRoot: string): TenboState {
|
|
190
234
|
const ws = readWorkspace(repoRoot);
|
|
191
235
|
const scopes: Scope[] = ws.scopeRefs.map(ref => ({
|
|
@@ -194,6 +238,7 @@ export function readState(repoRoot: string): TenboState {
|
|
|
194
238
|
description: ref.description,
|
|
195
239
|
layers: readArchitecture(repoRoot, ref.id),
|
|
196
240
|
items: readRoadmap(repoRoot, ref.id),
|
|
241
|
+
archivedItems: readRoadmapArchive(repoRoot, ref.id),
|
|
197
242
|
}));
|
|
198
243
|
const narratives = scopes.reduce<Record<string, string>>((acc, s) => {
|
|
199
244
|
return Object.assign(acc, readNarratives(repoRoot, s.id));
|
|
@@ -214,6 +259,8 @@ export function readState(repoRoot: string): TenboState {
|
|
|
214
259
|
if (Object.keys(metrics).length > 0) state.metrics = metrics;
|
|
215
260
|
const specFiles = readSpecFiles(repoRoot);
|
|
216
261
|
if (specFiles) state.specFiles = specFiles;
|
|
262
|
+
const decisions = readDecisions(repoRoot);
|
|
263
|
+
if (decisions) state.decisions = decisions;
|
|
217
264
|
return state;
|
|
218
265
|
}
|
|
219
266
|
|
|
@@ -242,3 +289,39 @@ export function reorderItems(repoRoot: string, scopeId: string, idsInOrder: stri
|
|
|
242
289
|
reorderSeqItems(doc, 'items', idsInOrder);
|
|
243
290
|
atomicWrite(file, stringifyYaml(doc));
|
|
244
291
|
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Move items from roadmap.yaml to roadmap-archive.yaml for the given scope.
|
|
295
|
+
* Creates the archive file if it doesn't exist. Uses atomic writes for both files.
|
|
296
|
+
*/
|
|
297
|
+
export function archiveItems(repoRoot: string, scopeId: string, itemIds: string[]): void {
|
|
298
|
+
if (itemIds.length === 0) return;
|
|
299
|
+
|
|
300
|
+
const roadmapFile = path.join(tenboDir(repoRoot), 'scopes', scopeId, 'roadmap.yaml');
|
|
301
|
+
const archiveFile = path.join(tenboDir(repoRoot), 'scopes', scopeId, 'roadmap-archive.yaml');
|
|
302
|
+
|
|
303
|
+
// Read roadmap and find matching items
|
|
304
|
+
const roadmapText = readFileSync(roadmapFile, 'utf8');
|
|
305
|
+
const roadmapData = parseSimple(roadmapText) as any;
|
|
306
|
+
const allItems: Item[] = roadmapData?.items ?? [];
|
|
307
|
+
|
|
308
|
+
const idsToArchive = new Set(itemIds);
|
|
309
|
+
const itemsToArchive = allItems.filter(i => idsToArchive.has(i.id));
|
|
310
|
+
const remainingItems = allItems.filter(i => !idsToArchive.has(i.id));
|
|
311
|
+
|
|
312
|
+
if (itemsToArchive.length === 0) return;
|
|
313
|
+
|
|
314
|
+
// Read existing archive
|
|
315
|
+
let archiveItems: Item[] = [];
|
|
316
|
+
if (existsSync(archiveFile)) {
|
|
317
|
+
const archiveData = parseSimple(readFileSync(archiveFile, 'utf8')) as any;
|
|
318
|
+
archiveItems = archiveData?.items ?? [];
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Append archived items
|
|
322
|
+
archiveItems.push(...itemsToArchive);
|
|
323
|
+
|
|
324
|
+
// Write both files atomically
|
|
325
|
+
atomicWrite(roadmapFile, yamlStringify({ items: remainingItems }, { lineWidth: 0, blockQuote: 'literal' }));
|
|
326
|
+
atomicWrite(archiveFile, yamlStringify({ items: archiveItems }, { lineWidth: 0, blockQuote: 'literal' }));
|
|
327
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { validate } from './validator';
|
|
3
|
+
import type { TenboState, Item } from '../../types';
|
|
4
|
+
|
|
5
|
+
function stateWithItems(items: Item[]): TenboState {
|
|
6
|
+
return {
|
|
7
|
+
scopes: [{
|
|
8
|
+
id: 'e',
|
|
9
|
+
path: '.',
|
|
10
|
+
description: 'editor scope description here for tests',
|
|
11
|
+
layers: [{ id: 'l', name: 'L', description: 'a small layer for testing now', files: ['x'] } as any],
|
|
12
|
+
items,
|
|
13
|
+
}],
|
|
14
|
+
crossCutting: [],
|
|
15
|
+
narratives: { 'e/l': '# L' },
|
|
16
|
+
} as any;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const baseItem = (over: Partial<Item> = {}): Item => ({
|
|
20
|
+
id: 'ed-001',
|
|
21
|
+
title: 't',
|
|
22
|
+
layer: 'l',
|
|
23
|
+
status: 'now',
|
|
24
|
+
description: 'a roadmap item used for goal_ref tests now',
|
|
25
|
+
done_when: ['it works'],
|
|
26
|
+
...over,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const warningsFor = (state: TenboState, id: string) =>
|
|
30
|
+
validate(state).warnings.filter((w) => w.itemId === id).map((w) => w.message);
|
|
31
|
+
|
|
32
|
+
describe('validator — goal_ref shape (sk-025)', () => {
|
|
33
|
+
it('warns when goal_ref is missing on an active item', () => {
|
|
34
|
+
const state = stateWithItems([baseItem()]);
|
|
35
|
+
const msgs = warningsFor(state, 'ed-001');
|
|
36
|
+
expect(msgs.some((m) => m.includes('has no goal_ref'))).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('does not warn when goal_ref is missing on a done or dropped item', () => {
|
|
40
|
+
for (const status of ['done', 'dropped'] as const) {
|
|
41
|
+
const state = stateWithItems([baseItem({ status })]);
|
|
42
|
+
const msgs = warningsFor(state, 'ed-001');
|
|
43
|
+
expect(msgs.some((m) => m.includes('has no goal_ref'))).toBe(false);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('does not warn when goal_ref is a non-empty string array', () => {
|
|
48
|
+
const state = stateWithItems([baseItem({ goal_ref: ['g1', 'g3'] })]);
|
|
49
|
+
const msgs = warningsFor(state, 'ed-001');
|
|
50
|
+
expect(msgs.some((m) => m.includes('goal_ref'))).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('does not warn when goal_ref is the literal "exploratory"', () => {
|
|
54
|
+
const state = stateWithItems([baseItem({ goal_ref: 'exploratory' })]);
|
|
55
|
+
const msgs = warningsFor(state, 'ed-001');
|
|
56
|
+
expect(msgs.some((m) => m.includes('goal_ref'))).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('warns when goal_ref is an empty array', () => {
|
|
60
|
+
const state = stateWithItems([baseItem({ goal_ref: [] })]);
|
|
61
|
+
const msgs = warningsFor(state, 'ed-001');
|
|
62
|
+
expect(msgs.some((m) => m.includes('empty array'))).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('warns when goal_ref is a malformed value (string other than "exploratory")', () => {
|
|
66
|
+
const state = stateWithItems([baseItem({ goal_ref: 'g1' as any })]);
|
|
67
|
+
const msgs = warningsFor(state, 'ed-001');
|
|
68
|
+
expect(msgs.some((m) => m.includes('must be a string array or the literal "exploratory"'))).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('warns when goal_ref array contains a non-string entry', () => {
|
|
72
|
+
const state = stateWithItems([baseItem({ goal_ref: [1 as any, 'g2'] as any })]);
|
|
73
|
+
const msgs = warningsFor(state, 'ed-001');
|
|
74
|
+
expect(msgs.some((m) => m.includes('must be a string array'))).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -29,7 +29,13 @@ const baseItem = (over: Partial<Item> = {}): Item => ({
|
|
|
29
29
|
const errMsgsFor = (state: TenboState, id: string) =>
|
|
30
30
|
validate(state).errors.filter((e) => e.itemId === id).map((e) => e.message);
|
|
31
31
|
const warnMsgsFor = (state: TenboState, id: string) =>
|
|
32
|
-
validate(state).warnings
|
|
32
|
+
validate(state).warnings
|
|
33
|
+
.filter((e) => e.itemId === id)
|
|
34
|
+
.map((e) => e.message)
|
|
35
|
+
// Exclude goal_ref warnings — sk-025 added a missing-goal_ref warning that
|
|
36
|
+
// fires on every active item without one. Tests in this file predate
|
|
37
|
+
// goal_ref and assert "no warnings" for unrelated relationship cases.
|
|
38
|
+
.filter((m) => !m.includes('goal_ref'));
|
|
33
39
|
|
|
34
40
|
describe('validator — relationships', () => {
|
|
35
41
|
it('accepts well-formed spawned_from + related with no errors or warnings', () => {
|
|
@@ -28,7 +28,11 @@ const baseItem = (over: Partial<Item> = {}): Item => ({
|
|
|
28
28
|
});
|
|
29
29
|
|
|
30
30
|
const warnMsgsFor = (state: TenboState, id: string) =>
|
|
31
|
-
validate(state).warnings
|
|
31
|
+
validate(state).warnings
|
|
32
|
+
.filter((w) => w.itemId === id)
|
|
33
|
+
.map((w) => w.message)
|
|
34
|
+
// Exclude goal_ref warnings (sk-025) — orthogonal to spec-link tests.
|
|
35
|
+
.filter((m) => !m.includes('goal_ref'));
|
|
32
36
|
|
|
33
37
|
describe('validator — spec links (Phase 6)', () => {
|
|
34
38
|
it('warns when a .tenbo/specs/ link points to a missing file', () => {
|