tenbo-dashboard 0.10.2 → 0.11.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 +6 -0
- package/bin/tenbo-dashboard.mjs +7 -0
- package/package.json +7 -4
- package/scripts/impact.ts +25 -0
- package/scripts/index.ts +41 -0
- package/scripts/reminder.ts +24 -0
- package/scripts/sync.ts +66 -5
- package/src/api/lib/contextResolver.ts +183 -10
- package/src/api/lib/health/collectAll.ts +26 -54
- package/src/api/lib/health/coupling.ts +3 -0
- package/src/api/lib/health/deadCode.ts +1 -0
- package/src/api/lib/health/importGraph.ts +3 -65
- package/src/api/lib/health/structuralGraph.ts +87 -0
- package/src/api/lib/health/types.ts +7 -2
- package/src/api/lib/impact.ts +203 -0
- package/src/api/lib/itemProjection.ts +5 -3
- package/src/api/lib/notes.ts +12 -0
- package/src/api/lib/roadmapStore.ts +1 -13
- package/src/api/lib/sourceIndex/build.ts +161 -0
- package/src/api/lib/sourceIndex/query.ts +80 -0
- package/src/api/lib/sourceIndex/store.ts +128 -0
- package/src/api/lib/sourceIndex/types.ts +46 -0
- package/src/api/lib/tenboFs.ts +20 -1
- package/vite.config.ts +0 -1
- package/scripts/bin-output.test.ts +0 -153
- package/scripts/commit-ready.test.ts +0 -47
- package/scripts/compute-metrics.test.ts +0 -35
- package/scripts/context.test.ts +0 -77
- package/scripts/hook.test.ts +0 -144
- package/scripts/item.test.ts +0 -234
- package/scripts/items.test.ts +0 -132
- package/scripts/next-id.test.ts +0 -128
- package/scripts/next.test.ts +0 -67
- package/scripts/validate-cli.test.ts +0 -152
- package/scripts/work-queue.test.ts +0 -65
- package/src/api/lib/contextResolver.test.ts +0 -161
- package/src/api/lib/health/agingTodos.test.ts +0 -32
- package/src/api/lib/health/archCompliance.test.ts +0 -26
- package/src/api/lib/health/collectAll.test.ts +0 -110
- package/src/api/lib/health/config.test.ts +0 -45
- package/src/api/lib/health/coupling.test.ts +0 -40
- package/src/api/lib/health/deadCode.test.ts +0 -27
- package/src/api/lib/health/docDrift.test.ts +0 -72
- package/src/api/lib/health/hotspotFiles.test.ts +0 -49
- package/src/api/lib/health/importGraph.test.ts +0 -73
- package/src/api/lib/health/layerFiles.test.ts +0 -46
- package/src/api/lib/health/redundancy.test.ts +0 -24
- package/src/api/lib/health/testCoverage.test.ts +0 -33
- package/src/api/lib/health/types.test.ts +0 -36
- package/src/api/lib/metrics.test.ts +0 -81
- package/src/api/lib/metricsRefresh.test.ts +0 -54
- package/src/api/lib/parseCache.test.ts +0 -96
- package/src/api/lib/phases.test.ts +0 -90
- package/src/api/lib/priority.test.ts +0 -54
- package/src/api/lib/roadmapStore.test.ts +0 -163
- package/src/api/lib/tenboFs.layerContent.test.ts +0 -36
- package/src/api/lib/tenboFs.v2.test.ts +0 -86
- package/src/api/lib/tenboFs.workspaceContent.test.ts +0 -42
- package/src/api/lib/validator.docUpdate.test.ts +0 -99
- package/src/api/lib/validator.duplicateIds.test.ts +0 -89
- package/src/api/lib/validator.goalRef.test.ts +0 -76
- package/src/api/lib/validator.phases.test.ts +0 -98
- package/src/api/lib/validator.preflightViolations.test.ts +0 -67
- package/src/api/lib/validator.relationships.test.ts +0 -132
- package/src/api/lib/validator.specLinks.test.ts +0 -100
- package/src/api/lib/validator.v2.test.ts +0 -55
- package/src/api/lib/validator.verification.test.ts +0 -52
- package/src/api/routes/state.test.ts +0 -148
- package/src/router/useHashRoute.test.ts +0 -69
- package/src/test-setup.ts +0 -1
- package/src/ui/FindingModal/FindingModal.test.tsx +0 -36
- package/src/ui/HealthPage/HealthPage.test.tsx +0 -71
- package/src/ui/HealthPage/severity.test.ts +0 -46
- package/src/ui/LayerDetailPage/LayerDetailPage.test.tsx +0 -30
- package/src/ui/LayerPage/isTemplateOnly.test.ts +0 -45
package/README.md
CHANGED
|
@@ -33,6 +33,10 @@ Project overview, principles, and glossary alongside per-layer narratives, inten
|
|
|
33
33
|
|
|
34
34
|
Surfaces the things that would otherwise become tech debt nobody mentions: oversized layers (hotspots), code-map references that no longer match real files (doc drift), unreferenced files, coupling violations, dead code. Each finding carries a severity, the file it points at, and a suggested fix.
|
|
35
35
|
|
|
36
|
+
Some health findings include generated graph evidence such as importers, exports,
|
|
37
|
+
and layer ownership. This evidence is rebuilt from source and `.tenbo/` metadata;
|
|
38
|
+
it is not separate project memory.
|
|
39
|
+
|
|
36
40
|
## CLI tools
|
|
37
41
|
|
|
38
42
|
The same package ships commands the skill uses behind the scenes — also runnable directly:
|
|
@@ -46,6 +50,8 @@ npx tenbo-dashboard item link-commit sk-030 7fc09a5
|
|
|
46
50
|
npx tenbo-dashboard items --status done --verification pending_live --json
|
|
47
51
|
npx tenbo-dashboard next --json
|
|
48
52
|
npx tenbo-dashboard context feature --query "help me build X" --json
|
|
53
|
+
npx tenbo-dashboard impact --json # Map git changes to affected layers/docs/items
|
|
54
|
+
npx tenbo-dashboard index --json # Rebuild the derived source index
|
|
49
55
|
npx tenbo-dashboard validate # Schema + consistency checks
|
|
50
56
|
npx tenbo-dashboard init-check # Strict completeness check after fresh init
|
|
51
57
|
npx tenbo-dashboard metrics --all # Recompute scope metrics + health findings
|
package/bin/tenbo-dashboard.mjs
CHANGED
|
@@ -49,6 +49,9 @@ const commands = {
|
|
|
49
49
|
next: 'scripts/next.ts',
|
|
50
50
|
'work-queue': 'scripts/work-queue.ts',
|
|
51
51
|
context: 'scripts/context.ts',
|
|
52
|
+
impact: 'scripts/impact.ts',
|
|
53
|
+
index: 'scripts/index.ts',
|
|
54
|
+
reminder: 'scripts/reminder.ts',
|
|
52
55
|
hook: 'scripts/hook.ts',
|
|
53
56
|
};
|
|
54
57
|
|
|
@@ -97,6 +100,10 @@ ${topLevelItemHelpLines().join('\n')}
|
|
|
97
100
|
[--type <type>] [--json]
|
|
98
101
|
tenbo-dashboard context feature Fetch agent planning context for a natural-language request
|
|
99
102
|
--query "<request>" [--json]
|
|
103
|
+
tenbo-dashboard impact Map git changes to affected layers, docs, items,
|
|
104
|
+
and verification checks [--since <ref>] [--json]
|
|
105
|
+
tenbo-dashboard index Build or refresh the derived source index [--if-stale] [--json]
|
|
106
|
+
tenbo-dashboard reminder print Print advisory session reminder text [--json]
|
|
100
107
|
tenbo-dashboard validate Run validation rules
|
|
101
108
|
tenbo-dashboard init-check Strict completeness check for fresh init (errors on missing skeletons, file_count:0, etc)
|
|
102
109
|
tenbo-dashboard next-id <prefix> Allocate next roadmap item ID
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tenbo-dashboard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Local-first architecture dashboard and CLI tools for .tenbo/ repos",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -30,6 +30,9 @@
|
|
|
30
30
|
"bin/",
|
|
31
31
|
"src/",
|
|
32
32
|
"scripts/",
|
|
33
|
+
"!**/*.test.ts",
|
|
34
|
+
"!**/*.test.tsx",
|
|
35
|
+
"!src/test-setup.ts",
|
|
33
36
|
"index.html",
|
|
34
37
|
"vite.config.ts",
|
|
35
38
|
"tsconfig.json"
|
|
@@ -47,7 +50,7 @@
|
|
|
47
50
|
"@dnd-kit/sortable": "^8.0.0",
|
|
48
51
|
"@dnd-kit/utilities": "^3.2.2",
|
|
49
52
|
"@parcel/watcher": "^2.5.6",
|
|
50
|
-
"@vitejs/plugin-react": "^4.
|
|
53
|
+
"@vitejs/plugin-react": "^4.7.0",
|
|
51
54
|
"@xyflow/react": "^12.10.2",
|
|
52
55
|
"chokidar": "^3.6.0",
|
|
53
56
|
"dagre": "^0.8.5",
|
|
@@ -59,9 +62,9 @@
|
|
|
59
62
|
"react-markdown": "^9.0.1",
|
|
60
63
|
"remark-gfm": "^4.0.0",
|
|
61
64
|
"ts-morph": "^25",
|
|
62
|
-
"tsx": "^4.
|
|
65
|
+
"tsx": "^4.22.4",
|
|
63
66
|
"typescript": "^5.5.4",
|
|
64
|
-
"vite": "^
|
|
67
|
+
"vite": "^6.4.3",
|
|
65
68
|
"yaml": "^2.5.1"
|
|
66
69
|
},
|
|
67
70
|
"devDependencies": {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { resolveImpact } from '../src/api/lib/impact';
|
|
2
|
+
import { hasFlag, readOption } from './cliArgs';
|
|
3
|
+
import { handleCliError, isMain, misuse, repoRootFromCwd, runMain, serialize, type CliResult } from './cliResult';
|
|
4
|
+
|
|
5
|
+
export function runImpactCli(repoRoot: string, args: string[]): CliResult {
|
|
6
|
+
const json = hasFlag(args, '--json');
|
|
7
|
+
const since = readOption(args, '--since');
|
|
8
|
+
if (args.includes('--since') && (!since || since.startsWith('--'))) {
|
|
9
|
+
return misuse('Usage: tenbo impact [--since <ref>] [--json]', json);
|
|
10
|
+
}
|
|
11
|
+
try {
|
|
12
|
+
const payload = resolveImpact(repoRoot, { since });
|
|
13
|
+
return serialize(
|
|
14
|
+
payload,
|
|
15
|
+
json,
|
|
16
|
+
`${payload.changed_files.length} changed file(s), ${payload.affected_layers.length} affected layer(s)\n`,
|
|
17
|
+
);
|
|
18
|
+
} catch (err) {
|
|
19
|
+
return handleCliError(err, json);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (isMain(import.meta.url)) {
|
|
24
|
+
runMain(runImpactCli(repoRootFromCwd(), process.argv.slice(2)));
|
|
25
|
+
}
|
package/scripts/index.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { buildSourceIndex } from '../src/api/lib/sourceIndex/build';
|
|
2
|
+
import { checkSourceIndexFreshness, readSourceIndex, writeSourceIndex } from '../src/api/lib/sourceIndex/store';
|
|
3
|
+
import { hasFlag } from './cliArgs';
|
|
4
|
+
import { handleCliError, isMain, repoRootFromCwd, runMain, serialize, type CliResult } from './cliResult';
|
|
5
|
+
|
|
6
|
+
export function runIndexCli(repoRoot: string, args: string[]): CliResult {
|
|
7
|
+
const json = hasFlag(args, '--json');
|
|
8
|
+
const ifStale = hasFlag(args, '--if-stale');
|
|
9
|
+
try {
|
|
10
|
+
const existing = readSourceIndex(repoRoot);
|
|
11
|
+
const before = checkSourceIndexFreshness(repoRoot, existing);
|
|
12
|
+
if (ifStale && before.status === 'fresh' && existing) {
|
|
13
|
+
return serialize({
|
|
14
|
+
ok: true,
|
|
15
|
+
mode: 'reuse',
|
|
16
|
+
freshness: before,
|
|
17
|
+
files: existing.files.length,
|
|
18
|
+
layers: existing.layers.length,
|
|
19
|
+
warnings: existing.warnings,
|
|
20
|
+
}, json, `source index fresh (${existing.files.length} file(s))\n`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const index = buildSourceIndex(repoRoot);
|
|
24
|
+
writeSourceIndex(repoRoot, index);
|
|
25
|
+
const freshness = checkSourceIndexFreshness(repoRoot, index);
|
|
26
|
+
return serialize({
|
|
27
|
+
ok: true,
|
|
28
|
+
mode: 'rebuild',
|
|
29
|
+
freshness,
|
|
30
|
+
files: index.files.length,
|
|
31
|
+
layers: index.layers.length,
|
|
32
|
+
warnings: index.warnings,
|
|
33
|
+
}, json, `source index rebuilt (${index.files.length} file(s))\n`);
|
|
34
|
+
} catch (err) {
|
|
35
|
+
return handleCliError(err, json);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (isMain(import.meta.url)) {
|
|
40
|
+
runMain(runIndexCli(repoRootFromCwd(), process.argv.slice(2)));
|
|
41
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { hasFlag } from './cliArgs';
|
|
2
|
+
import { isMain, misuse, runMain, serialize, type CliResult } from './cliResult';
|
|
3
|
+
|
|
4
|
+
export function renderSessionReminder(): string {
|
|
5
|
+
return 'Tenbo context reminder (advisory, does not block): if this repo has .tenbo/, prefer `npx tenbo-dashboard context feature --query "<request>" --json` before broad source search.';
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function runReminderCli(args: string[]): CliResult {
|
|
9
|
+
const json = hasFlag(args, '--json');
|
|
10
|
+
const command = args.find((arg) => !arg.startsWith('--'));
|
|
11
|
+
if (command !== 'print') {
|
|
12
|
+
return misuse('Usage: tenbo reminder print [--json]', json);
|
|
13
|
+
}
|
|
14
|
+
const payload = {
|
|
15
|
+
ok: true,
|
|
16
|
+
mode: 'print',
|
|
17
|
+
reminder: renderSessionReminder(),
|
|
18
|
+
};
|
|
19
|
+
return serialize(payload, json, `${payload.reminder}\n`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (isMain(import.meta.url)) {
|
|
23
|
+
runMain(runReminderCli(process.argv.slice(2)));
|
|
24
|
+
}
|
package/scripts/sync.ts
CHANGED
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import fs from 'node:fs';
|
|
16
16
|
import path from 'node:path';
|
|
17
|
+
import { spawnSync } from 'node:child_process';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
17
19
|
import { findRepoRoot } from '../src/api/lib/repoRoot';
|
|
18
20
|
import { runComputeMetrics } from './compute-metrics';
|
|
19
21
|
import { runInitCheck } from './init-check';
|
|
@@ -47,6 +49,58 @@ interface SyncArgs {
|
|
|
47
49
|
scope?: string; // single-scope refresh; omit to refresh all
|
|
48
50
|
}
|
|
49
51
|
|
|
52
|
+
export interface IndexRefreshResult {
|
|
53
|
+
exitCode: number;
|
|
54
|
+
stdout: string;
|
|
55
|
+
stderr: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const DEFAULT_INDEX_BUDGET_MS = 3000;
|
|
59
|
+
|
|
60
|
+
function dashboardBinPath(): string {
|
|
61
|
+
const here = new URL(import.meta.url);
|
|
62
|
+
if (here.protocol === 'file:') return fileURLToPath(new URL('../bin/tenbo-dashboard.mjs', here));
|
|
63
|
+
return path.resolve(process.cwd(), 'bin/tenbo-dashboard.mjs');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type SpawnSyncFn = typeof spawnSync;
|
|
67
|
+
|
|
68
|
+
export function runIndexRefreshForSync(
|
|
69
|
+
repoRoot: string,
|
|
70
|
+
budgetMs = DEFAULT_INDEX_BUDGET_MS,
|
|
71
|
+
spawn: SpawnSyncFn = spawnSync,
|
|
72
|
+
): IndexRefreshResult {
|
|
73
|
+
const binPath = dashboardBinPath();
|
|
74
|
+
const child = spawn(process.execPath, [binPath, 'index', '--if-stale', '--json'], {
|
|
75
|
+
cwd: repoRoot,
|
|
76
|
+
encoding: 'utf8',
|
|
77
|
+
timeout: budgetMs,
|
|
78
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
if (child.error) {
|
|
82
|
+
const code = (child.error as NodeJS.ErrnoException).code;
|
|
83
|
+
if (code === 'ETIMEDOUT') {
|
|
84
|
+
return {
|
|
85
|
+
exitCode: 0,
|
|
86
|
+
stdout: child.stdout ?? '',
|
|
87
|
+
stderr: `sync: source index refresh timed out after ${budgetMs}ms; continuing without fresh index\n`,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
exitCode: 1,
|
|
92
|
+
stdout: child.stdout ?? '',
|
|
93
|
+
stderr: child.stderr || `${child.error.message}\n`,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
exitCode: child.status ?? (child.signal ? 1 : 0),
|
|
99
|
+
stdout: child.stdout ?? '',
|
|
100
|
+
stderr: child.stderr ?? '',
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
50
104
|
export async function runSync(args: SyncArgs): Promise<number> {
|
|
51
105
|
const { repoRoot, scope } = args;
|
|
52
106
|
|
|
@@ -59,7 +113,14 @@ export async function runSync(args: SyncArgs): Promise<number> {
|
|
|
59
113
|
for (const f of m?.findings ?? []) priorFindingKeys.add(findingKey(id, f));
|
|
60
114
|
}
|
|
61
115
|
|
|
62
|
-
// 1. metrics
|
|
116
|
+
// 1. source index (fail-open; metrics can still refresh without graph findings)
|
|
117
|
+
const indexResult = runIndexRefreshForSync(repoRoot);
|
|
118
|
+
if (indexResult.stderr) process.stderr.write(indexResult.stderr);
|
|
119
|
+
if (indexResult.exitCode !== 0) {
|
|
120
|
+
process.stderr.write('sync: source index refresh failed-open; continuing without fresh index\n');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 2. metrics
|
|
63
124
|
const metricsCode = await runComputeMetrics({
|
|
64
125
|
repoRoot,
|
|
65
126
|
all: !scope,
|
|
@@ -70,7 +131,7 @@ export async function runSync(args: SyncArgs): Promise<number> {
|
|
|
70
131
|
return 1;
|
|
71
132
|
}
|
|
72
133
|
|
|
73
|
-
//
|
|
134
|
+
// 3. init-check
|
|
74
135
|
const { defects } = runInitCheck(repoRoot);
|
|
75
136
|
if (defects.length > 0) {
|
|
76
137
|
process.stderr.write(`sync: init-check FAILED — ${defects.length} init defect(s):\n`);
|
|
@@ -81,7 +142,7 @@ export async function runSync(args: SyncArgs): Promise<number> {
|
|
|
81
142
|
return 1;
|
|
82
143
|
}
|
|
83
144
|
|
|
84
|
-
//
|
|
145
|
+
// 4. validate (re-read state after metrics may have changed scope dirs)
|
|
85
146
|
const stateAfter = readState(repoRoot);
|
|
86
147
|
const result = validate(stateAfter);
|
|
87
148
|
if (result.errors.length > 0) {
|
|
@@ -90,7 +151,7 @@ export async function runSync(args: SyncArgs): Promise<number> {
|
|
|
90
151
|
return 1;
|
|
91
152
|
}
|
|
92
153
|
|
|
93
|
-
//
|
|
154
|
+
// 5. Diff findings: surface NEW critical/warning findings introduced this run.
|
|
94
155
|
const newFindings: NewFinding[] = [];
|
|
95
156
|
for (const id of targetScopes) {
|
|
96
157
|
const m = readMetrics(path.join(repoRoot, '.tenbo', 'scopes', id, 'metrics.json'));
|
|
@@ -107,7 +168,7 @@ export async function runSync(args: SyncArgs): Promise<number> {
|
|
|
107
168
|
}
|
|
108
169
|
}
|
|
109
170
|
|
|
110
|
-
//
|
|
171
|
+
// 6. Summary line + (optional) new-finding lines.
|
|
111
172
|
const scopeLabel = scope ? `scope "${scope}"` : `${targetScopes.length} scope(s)`;
|
|
112
173
|
if (newFindings.length === 0) {
|
|
113
174
|
process.stdout.write(`sync: ${scopeLabel} fresh. No new errors, no new warning/critical findings.\n`);
|
|
@@ -2,13 +2,21 @@ import { existsSync, statSync } from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { readLayerContent, readState } from './tenboFs';
|
|
4
4
|
import { comparePriority } from './priority';
|
|
5
|
+
import { checkSourceIndexFreshness, readSourceIndex } from './sourceIndex/store';
|
|
6
|
+
import { querySourceIndex, type SourceEvidenceFile } from './sourceIndex/query';
|
|
5
7
|
import type { Item, Layer, Scope, Status } from '../../types';
|
|
6
8
|
|
|
7
9
|
export type ContextIntent = 'session' | 'feature' | 'item';
|
|
8
10
|
export type ContextConfidence = 'high' | 'medium' | 'low';
|
|
9
11
|
|
|
10
12
|
export interface ContextWarning {
|
|
11
|
-
kind:
|
|
13
|
+
kind:
|
|
14
|
+
| 'missing_agent_context'
|
|
15
|
+
| 'stale_agent_context'
|
|
16
|
+
| 'missing_source_index'
|
|
17
|
+
| 'stale_source_index'
|
|
18
|
+
| 'corrupt_source_index'
|
|
19
|
+
| 'incompatible_source_index';
|
|
12
20
|
message: string;
|
|
13
21
|
path: string;
|
|
14
22
|
age_days?: number;
|
|
@@ -36,6 +44,27 @@ export interface ContextItemEntry {
|
|
|
36
44
|
score: number;
|
|
37
45
|
}
|
|
38
46
|
|
|
47
|
+
export type ContextReadPlanKind =
|
|
48
|
+
| 'product'
|
|
49
|
+
| 'layer-intent'
|
|
50
|
+
| 'layer-code-map'
|
|
51
|
+
| 'roadmap-item'
|
|
52
|
+
| 'source-file'
|
|
53
|
+
| 'spec';
|
|
54
|
+
|
|
55
|
+
export interface ContextReadPlanEntry {
|
|
56
|
+
path: string;
|
|
57
|
+
kind: ContextReadPlanKind;
|
|
58
|
+
reason: string;
|
|
59
|
+
priority: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface ContextVerificationEntry {
|
|
63
|
+
command: string;
|
|
64
|
+
purpose: string;
|
|
65
|
+
when: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
39
68
|
export interface ContextLayerEntry {
|
|
40
69
|
scope: string;
|
|
41
70
|
id: string;
|
|
@@ -76,6 +105,8 @@ export interface FeatureContextBundle {
|
|
|
76
105
|
context: {
|
|
77
106
|
layer_docs: string[];
|
|
78
107
|
files_to_read: string[];
|
|
108
|
+
read_plan: ContextReadPlanEntry[];
|
|
109
|
+
verification_plan: ContextVerificationEntry[];
|
|
79
110
|
};
|
|
80
111
|
warnings: ContextWarning[];
|
|
81
112
|
}
|
|
@@ -285,6 +316,83 @@ function unique<T>(values: T[]): T[] {
|
|
|
285
316
|
return Array.from(new Set(values));
|
|
286
317
|
}
|
|
287
318
|
|
|
319
|
+
function pushReadPlan(
|
|
320
|
+
entries: ContextReadPlanEntry[],
|
|
321
|
+
seen: Set<string>,
|
|
322
|
+
entry: ContextReadPlanEntry,
|
|
323
|
+
) {
|
|
324
|
+
if (seen.has(entry.path)) return;
|
|
325
|
+
seen.add(entry.path);
|
|
326
|
+
entries.push(entry);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function buildReadPlan(args: {
|
|
330
|
+
layerDocs: string[];
|
|
331
|
+
activeItems: ScoredItemEntry[];
|
|
332
|
+
matchingItems: ScoredItemEntry[];
|
|
333
|
+
sourceEvidence: SourceEvidenceFile[];
|
|
334
|
+
filesToRead: string[];
|
|
335
|
+
}): ContextReadPlanEntry[] {
|
|
336
|
+
const entries: ContextReadPlanEntry[] = [];
|
|
337
|
+
const seen = new Set<string>();
|
|
338
|
+
pushReadPlan(entries, seen, {
|
|
339
|
+
path: '.tenbo/overview.md',
|
|
340
|
+
kind: 'product',
|
|
341
|
+
reason: 'Product goals and non-goals frame the request',
|
|
342
|
+
priority: 1,
|
|
343
|
+
});
|
|
344
|
+
for (const doc of args.layerDocs) {
|
|
345
|
+
pushReadPlan(entries, seen, {
|
|
346
|
+
path: doc,
|
|
347
|
+
kind: doc.endsWith('/intent.md') ? 'layer-intent' : 'layer-code-map',
|
|
348
|
+
reason: 'Selected layer context for the request',
|
|
349
|
+
priority: entries.length + 1,
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
for (const source of args.sourceEvidence) {
|
|
353
|
+
pushReadPlan(entries, seen, {
|
|
354
|
+
path: source.path,
|
|
355
|
+
kind: 'source-file',
|
|
356
|
+
reason: source.reason,
|
|
357
|
+
priority: entries.length + 1,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
for (const entry of [...args.activeItems, ...args.matchingItems]) {
|
|
361
|
+
for (const link of entry.item.links ?? []) {
|
|
362
|
+
pushReadPlan(entries, seen, {
|
|
363
|
+
path: link,
|
|
364
|
+
kind: 'spec',
|
|
365
|
+
reason: `Linked plan/spec for ${entry.item.id}`,
|
|
366
|
+
priority: entries.length + 1,
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
for (const file of args.filesToRead) {
|
|
371
|
+
pushReadPlan(entries, seen, {
|
|
372
|
+
path: file,
|
|
373
|
+
kind: file.startsWith('.tenbo/') ? 'roadmap-item' : 'source-file',
|
|
374
|
+
reason: 'Referenced by active or matching roadmap context',
|
|
375
|
+
priority: entries.length + 1,
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
return entries.slice(0, 12).map((entry, index) => ({ ...entry, priority: index + 1 }));
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function buildVerificationPlan(): ContextVerificationEntry[] {
|
|
382
|
+
return [
|
|
383
|
+
{
|
|
384
|
+
command: 'node tenbo-dashboard/bin/tenbo-dashboard.mjs validate --json',
|
|
385
|
+
purpose: 'Check Tenbo roadmap and documentation structure',
|
|
386
|
+
when: 'after changing .tenbo files',
|
|
387
|
+
},
|
|
388
|
+
{
|
|
389
|
+
command: 'cd tenbo-dashboard && npm test -- --run',
|
|
390
|
+
purpose: 'Run dashboard and CLI unit tests',
|
|
391
|
+
when: 'after changing dashboard code',
|
|
392
|
+
},
|
|
393
|
+
];
|
|
394
|
+
}
|
|
395
|
+
|
|
288
396
|
function confidenceForScore(score: number): ContextConfidence {
|
|
289
397
|
if (score >= 3) return 'high';
|
|
290
398
|
if (score >= 2) return 'medium';
|
|
@@ -308,6 +416,24 @@ function scopeCandidates(scopes: Scope[], scoredLayers: ScoredLayer[], matchingI
|
|
|
308
416
|
}).sort((a, b) => b.score - a.score || a.id.localeCompare(b.id));
|
|
309
417
|
}
|
|
310
418
|
|
|
419
|
+
function withSourceScopeScores(candidates: ContextScopeEntry[], sourceScores: Map<string, number>): ContextScopeEntry[] {
|
|
420
|
+
return candidates
|
|
421
|
+
.map((candidate) => ({
|
|
422
|
+
...candidate,
|
|
423
|
+
score: candidate.score + (sourceScores.get(candidate.id) ?? 0),
|
|
424
|
+
}))
|
|
425
|
+
.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id));
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function withSourceLayerScores(scoredLayers: ScoredLayer[], sourceScores: Map<string, number>): ScoredLayer[] {
|
|
429
|
+
return scoredLayers
|
|
430
|
+
.map((entry) => ({
|
|
431
|
+
...entry,
|
|
432
|
+
score: entry.score + (sourceScores.get(`${entry.scope.id}:${entry.layer.id}`) ?? 0),
|
|
433
|
+
}))
|
|
434
|
+
.sort((a, b) => b.score - a.score || a.scope.id.localeCompare(b.scope.id) || a.layer.id.localeCompare(b.layer.id));
|
|
435
|
+
}
|
|
436
|
+
|
|
311
437
|
function roadmapEntriesForScope(scope: Scope, statuses: readonly Status[]): ScoredItemEntry[] {
|
|
312
438
|
return scope.items
|
|
313
439
|
.filter((item) => statuses.includes(item.status))
|
|
@@ -333,24 +459,55 @@ function agentContextWarnings(repoRoot: string, now: Date): ContextWarning[] {
|
|
|
333
459
|
return [];
|
|
334
460
|
}
|
|
335
461
|
|
|
462
|
+
function sourceIndexWarning(freshness: ReturnType<typeof checkSourceIndexFreshness>): ContextWarning | null {
|
|
463
|
+
if (freshness.status === 'fresh') return null;
|
|
464
|
+
const kindByStatus: Record<string, ContextWarning['kind']> = {
|
|
465
|
+
missing: 'missing_source_index',
|
|
466
|
+
stale: 'stale_source_index',
|
|
467
|
+
corrupt: 'corrupt_source_index',
|
|
468
|
+
incompatible: 'incompatible_source_index',
|
|
469
|
+
};
|
|
470
|
+
const kind = kindByStatus[freshness.status];
|
|
471
|
+
if (!kind) return null;
|
|
472
|
+
return {
|
|
473
|
+
kind,
|
|
474
|
+
path: '.tenbo/cache/source-index.json',
|
|
475
|
+
message: freshness.message,
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
336
479
|
export function resolveFeatureContext(repoRoot: string, query: string, opts: ResolveOptions = {}): FeatureContextBundle {
|
|
337
480
|
const now = opts.now ?? new Date();
|
|
338
481
|
const state = readState(repoRoot);
|
|
339
482
|
const queryTokens = tokenize(query);
|
|
340
483
|
const goals = parseGoals(state.workspaceContent.overviewMd);
|
|
341
484
|
const nonGoals = parseNonGoals(state.workspaceContent.overviewMd);
|
|
485
|
+
const sourceIndex = readSourceIndex(repoRoot);
|
|
486
|
+
const sourceFreshness = checkSourceIndexFreshness(repoRoot, sourceIndex);
|
|
487
|
+
const sourceQuery = sourceFreshness.status === 'fresh' && sourceIndex
|
|
488
|
+
? querySourceIndex(sourceIndex, query)
|
|
489
|
+
: null;
|
|
342
490
|
const matchingItems = scoreItems(queryTokens, state.scopes);
|
|
343
|
-
const scoredLayers =
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
491
|
+
const scoredLayers = withSourceLayerScores(
|
|
492
|
+
scoreLayers(repoRoot, queryTokens, state.scopes, matchingItems),
|
|
493
|
+
sourceQuery?.layerScores ?? new Map(),
|
|
494
|
+
);
|
|
495
|
+
const candidates = withSourceScopeScores(
|
|
496
|
+
scopeCandidates(state.scopes, scoredLayers, matchingItems),
|
|
497
|
+
sourceQuery?.scopeScores ?? new Map(),
|
|
498
|
+
);
|
|
499
|
+
const topScope = candidates[0];
|
|
500
|
+
const confidence = confidenceForScore(topScope?.score ?? 0);
|
|
347
501
|
const selectedScope = confidence === 'low'
|
|
348
502
|
? null
|
|
349
|
-
: state.scopes.find((scope) => scope.id ===
|
|
503
|
+
: state.scopes.find((scope) => scope.id === topScope.id) ?? null;
|
|
504
|
+
const scopedLayers = selectedScope
|
|
505
|
+
? scoredLayers.filter((entry) => entry.scope.id === selectedScope.id && entry.score > 0)
|
|
506
|
+
: [];
|
|
507
|
+
const topLayer = scopedLayers[0];
|
|
350
508
|
const selectedLayers = selectedScope
|
|
351
|
-
?
|
|
352
|
-
.filter((entry) => entry.
|
|
353
|
-
.filter((entry) => entry.score === topLayer.score)
|
|
509
|
+
? scopedLayers
|
|
510
|
+
.filter((entry) => entry.score === topLayer?.score)
|
|
354
511
|
.slice(0, 3)
|
|
355
512
|
.map((entry) => entry.layer.id)
|
|
356
513
|
: [];
|
|
@@ -380,6 +537,17 @@ export function resolveFeatureContext(repoRoot: string, query: string, opts: Res
|
|
|
380
537
|
...activeItems.flatMap((entry) => entry.item.files_to_read ?? []),
|
|
381
538
|
...matchingItems.flatMap((entry) => entry.item.files_to_read ?? []),
|
|
382
539
|
]);
|
|
540
|
+
const sourceEvidence = (sourceQuery?.files ?? [])
|
|
541
|
+
.filter((entry) => !selectedScope || entry.scope === selectedScope.id)
|
|
542
|
+
.filter((entry) => selectedLayers.length === 0 || entry.layers.some((layer) => selectedLayers.includes(layer)));
|
|
543
|
+
const readPlan = buildReadPlan({
|
|
544
|
+
layerDocs,
|
|
545
|
+
activeItems,
|
|
546
|
+
matchingItems,
|
|
547
|
+
sourceEvidence,
|
|
548
|
+
filesToRead,
|
|
549
|
+
});
|
|
550
|
+
const sourceWarning = sourceIndexWarning(sourceFreshness);
|
|
383
551
|
|
|
384
552
|
return {
|
|
385
553
|
ok: true,
|
|
@@ -409,7 +577,12 @@ export function resolveFeatureContext(repoRoot: string, query: string, opts: Res
|
|
|
409
577
|
context: {
|
|
410
578
|
layer_docs: layerDocs,
|
|
411
579
|
files_to_read: filesToRead,
|
|
580
|
+
read_plan: readPlan,
|
|
581
|
+
verification_plan: buildVerificationPlan(),
|
|
412
582
|
},
|
|
413
|
-
warnings:
|
|
583
|
+
warnings: [
|
|
584
|
+
...agentContextWarnings(repoRoot, now),
|
|
585
|
+
...(sourceWarning ? [sourceWarning] : []),
|
|
586
|
+
],
|
|
414
587
|
};
|
|
415
588
|
}
|
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
import type { Item, Scope } from '../../../types';
|
|
2
2
|
import type { Finding, Signal } from './types';
|
|
3
3
|
import type { HealthConfig } from './config';
|
|
4
|
-
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
5
|
-
import path from 'node:path';
|
|
6
4
|
import { resolveLayerFiles } from './layerFiles';
|
|
7
5
|
import { analyzeHotspotFiles } from './hotspotFiles';
|
|
8
6
|
import { analyzeAgingTodos } from './agingTodos';
|
|
9
7
|
import { analyzeDocDrift } from './docDrift';
|
|
10
8
|
import { analyzeTestCoverage } from './testCoverage';
|
|
11
9
|
import { analyzeArchCompliance } from './archCompliance';
|
|
12
|
-
import {
|
|
10
|
+
import { graphFromSourceIndex } from './structuralGraph';
|
|
13
11
|
import type { ImportGraph } from './importGraph';
|
|
14
12
|
import { analyzeDeadCode } from './deadCode';
|
|
15
13
|
import { analyzeCoupling } from './coupling';
|
|
16
14
|
import { analyzeRedundancy } from './redundancy';
|
|
17
15
|
import { analyzeAgingSuperseded } from './agingSuperseded';
|
|
16
|
+
import { checkSourceIndexFreshness, readSourceIndex } from '../sourceIndex/store';
|
|
17
|
+
import type { GraphEvidence } from './types';
|
|
18
18
|
|
|
19
19
|
interface AnalyzerArgs {
|
|
20
20
|
repoRoot: string;
|
|
@@ -39,58 +39,15 @@ const ANALYZERS: Record<Signal, AnalyzerFn | null> = {
|
|
|
39
39
|
'redundancy': null, // Phase 4
|
|
40
40
|
};
|
|
41
41
|
|
|
42
|
-
const SOURCE_EXT_RE = /\.tsx?$/;
|
|
43
|
-
const IGNORED_SOURCE_DIRS = new Set([
|
|
44
|
-
'.git',
|
|
45
|
-
'.tenbo',
|
|
46
|
-
'node_modules',
|
|
47
|
-
'dist',
|
|
48
|
-
'build',
|
|
49
|
-
'coverage',
|
|
50
|
-
'.next',
|
|
51
|
-
'.turbo',
|
|
52
|
-
'out',
|
|
53
|
-
]);
|
|
54
|
-
|
|
55
|
-
function collectScopeSourceFiles(repoRoot: string, scopePath: string, seedFiles: string[]): string[] {
|
|
56
|
-
const sourceFiles = new Set(seedFiles.filter(f => SOURCE_EXT_RE.test(f)));
|
|
57
|
-
const root = path.resolve(repoRoot, scopePath || '.');
|
|
58
|
-
if (!existsSync(root)) return Array.from(sourceFiles).sort();
|
|
59
|
-
|
|
60
|
-
function walk(dir: string) {
|
|
61
|
-
let entries: string[];
|
|
62
|
-
try { entries = readdirSync(dir); } catch { return; }
|
|
63
|
-
for (const entry of entries.sort()) {
|
|
64
|
-
if (IGNORED_SOURCE_DIRS.has(entry)) continue;
|
|
65
|
-
const full = path.join(dir, entry);
|
|
66
|
-
let st;
|
|
67
|
-
try { st = statSync(full); } catch { continue; }
|
|
68
|
-
if (st.isDirectory()) {
|
|
69
|
-
walk(full);
|
|
70
|
-
} else if (SOURCE_EXT_RE.test(entry) && !entry.endsWith('.d.ts')) {
|
|
71
|
-
sourceFiles.add(path.relative(repoRoot, full).split(path.sep).join('/'));
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
walk(root);
|
|
76
|
-
return Array.from(sourceFiles).sort();
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function tsConfigForScope(repoRoot: string, scopePath: string): string | undefined {
|
|
80
|
-
const scoped = path.join(repoRoot, scopePath || '.', 'tsconfig.json');
|
|
81
|
-
if (existsSync(scoped)) return scoped;
|
|
82
|
-
const root = path.join(repoRoot, 'tsconfig.json');
|
|
83
|
-
return existsSync(root) ? root : undefined;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
42
|
export async function collectAll(repoRoot: string, scope: Scope, config: HealthConfig, allItems?: Map<string, Item>): Promise<Finding[]> {
|
|
87
43
|
const filesByLayer = resolveLayerFiles(repoRoot, scope);
|
|
88
|
-
const allTsFiles = collectScopeSourceFiles(repoRoot, scope.path, Object.values(filesByLayer).flat());
|
|
89
44
|
let graph: ImportGraph | undefined;
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
45
|
+
let graphEvidence: GraphEvidence | undefined;
|
|
46
|
+
const sourceIndex = readSourceIndex(repoRoot);
|
|
47
|
+
const indexFreshness = checkSourceIndexFreshness(repoRoot, sourceIndex);
|
|
48
|
+
if (indexFreshness.status === 'fresh' && sourceIndex) {
|
|
49
|
+
graph = graphFromSourceIndex(sourceIndex, scope.id);
|
|
50
|
+
graphEvidence = { mode: 'fresh-index', index_status: indexFreshness.status };
|
|
94
51
|
}
|
|
95
52
|
const out: Finding[] = [];
|
|
96
53
|
for (const layer of scope.layers) {
|
|
@@ -100,7 +57,8 @@ export async function collectAll(repoRoot: string, scope: Scope, config: HealthC
|
|
|
100
57
|
if (!fn) continue;
|
|
101
58
|
if (opted.has(signal)) continue;
|
|
102
59
|
try {
|
|
103
|
-
|
|
60
|
+
const findings = fn({ repoRoot, scopeId: scope.id, scopePath: scope.path, layerId: layer.id, files, config, graph });
|
|
61
|
+
out.push(...decorateGraphEvidence(findings, graphEvidence));
|
|
104
62
|
} catch (e) {
|
|
105
63
|
// Analyzer failures don't break the whole report. Log and continue.
|
|
106
64
|
console.error(`[health] analyzer ${signal} failed for layer ${layer.id}:`, e);
|
|
@@ -112,7 +70,7 @@ export async function collectAll(repoRoot: string, scope: Scope, config: HealthC
|
|
|
112
70
|
try {
|
|
113
71
|
const couplingFindings = analyzeCoupling(filesByLayer, graph)
|
|
114
72
|
.filter(f => !(config.ignore.layer_signals[f.layer] ?? []).includes('coupling'));
|
|
115
|
-
out.push(...couplingFindings);
|
|
73
|
+
out.push(...decorateGraphEvidence(couplingFindings, graphEvidence));
|
|
116
74
|
} catch (e) {
|
|
117
75
|
console.error('[health] analyzer coupling failed:', e);
|
|
118
76
|
}
|
|
@@ -135,3 +93,17 @@ export async function collectAll(repoRoot: string, scope: Scope, config: HealthC
|
|
|
135
93
|
|
|
136
94
|
return out;
|
|
137
95
|
}
|
|
96
|
+
|
|
97
|
+
function decorateGraphEvidence(findings: Finding[], evidence: GraphEvidence | undefined): Finding[] {
|
|
98
|
+
if (!evidence) return findings;
|
|
99
|
+
return findings.map((finding) => {
|
|
100
|
+
if (finding.signal !== 'dead-code' && finding.signal !== 'coupling') return finding;
|
|
101
|
+
return {
|
|
102
|
+
...finding,
|
|
103
|
+
details: {
|
|
104
|
+
...finding.details,
|
|
105
|
+
graph_evidence: evidence,
|
|
106
|
+
},
|
|
107
|
+
} as Finding;
|
|
108
|
+
});
|
|
109
|
+
}
|