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.
Files changed (75) hide show
  1. package/README.md +6 -0
  2. package/bin/tenbo-dashboard.mjs +7 -0
  3. package/package.json +7 -4
  4. package/scripts/impact.ts +25 -0
  5. package/scripts/index.ts +41 -0
  6. package/scripts/reminder.ts +24 -0
  7. package/scripts/sync.ts +66 -5
  8. package/src/api/lib/contextResolver.ts +183 -10
  9. package/src/api/lib/health/collectAll.ts +26 -54
  10. package/src/api/lib/health/coupling.ts +3 -0
  11. package/src/api/lib/health/deadCode.ts +1 -0
  12. package/src/api/lib/health/importGraph.ts +3 -65
  13. package/src/api/lib/health/structuralGraph.ts +87 -0
  14. package/src/api/lib/health/types.ts +7 -2
  15. package/src/api/lib/impact.ts +203 -0
  16. package/src/api/lib/itemProjection.ts +5 -3
  17. package/src/api/lib/notes.ts +12 -0
  18. package/src/api/lib/roadmapStore.ts +1 -13
  19. package/src/api/lib/sourceIndex/build.ts +161 -0
  20. package/src/api/lib/sourceIndex/query.ts +80 -0
  21. package/src/api/lib/sourceIndex/store.ts +128 -0
  22. package/src/api/lib/sourceIndex/types.ts +46 -0
  23. package/src/api/lib/tenboFs.ts +20 -1
  24. package/vite.config.ts +0 -1
  25. package/scripts/bin-output.test.ts +0 -153
  26. package/scripts/commit-ready.test.ts +0 -47
  27. package/scripts/compute-metrics.test.ts +0 -35
  28. package/scripts/context.test.ts +0 -77
  29. package/scripts/hook.test.ts +0 -144
  30. package/scripts/item.test.ts +0 -234
  31. package/scripts/items.test.ts +0 -132
  32. package/scripts/next-id.test.ts +0 -128
  33. package/scripts/next.test.ts +0 -67
  34. package/scripts/validate-cli.test.ts +0 -152
  35. package/scripts/work-queue.test.ts +0 -65
  36. package/src/api/lib/contextResolver.test.ts +0 -161
  37. package/src/api/lib/health/agingTodos.test.ts +0 -32
  38. package/src/api/lib/health/archCompliance.test.ts +0 -26
  39. package/src/api/lib/health/collectAll.test.ts +0 -110
  40. package/src/api/lib/health/config.test.ts +0 -45
  41. package/src/api/lib/health/coupling.test.ts +0 -40
  42. package/src/api/lib/health/deadCode.test.ts +0 -27
  43. package/src/api/lib/health/docDrift.test.ts +0 -72
  44. package/src/api/lib/health/hotspotFiles.test.ts +0 -49
  45. package/src/api/lib/health/importGraph.test.ts +0 -73
  46. package/src/api/lib/health/layerFiles.test.ts +0 -46
  47. package/src/api/lib/health/redundancy.test.ts +0 -24
  48. package/src/api/lib/health/testCoverage.test.ts +0 -33
  49. package/src/api/lib/health/types.test.ts +0 -36
  50. package/src/api/lib/metrics.test.ts +0 -81
  51. package/src/api/lib/metricsRefresh.test.ts +0 -54
  52. package/src/api/lib/parseCache.test.ts +0 -96
  53. package/src/api/lib/phases.test.ts +0 -90
  54. package/src/api/lib/priority.test.ts +0 -54
  55. package/src/api/lib/roadmapStore.test.ts +0 -163
  56. package/src/api/lib/tenboFs.layerContent.test.ts +0 -36
  57. package/src/api/lib/tenboFs.v2.test.ts +0 -86
  58. package/src/api/lib/tenboFs.workspaceContent.test.ts +0 -42
  59. package/src/api/lib/validator.docUpdate.test.ts +0 -99
  60. package/src/api/lib/validator.duplicateIds.test.ts +0 -89
  61. package/src/api/lib/validator.goalRef.test.ts +0 -76
  62. package/src/api/lib/validator.phases.test.ts +0 -98
  63. package/src/api/lib/validator.preflightViolations.test.ts +0 -67
  64. package/src/api/lib/validator.relationships.test.ts +0 -132
  65. package/src/api/lib/validator.specLinks.test.ts +0 -100
  66. package/src/api/lib/validator.v2.test.ts +0 -55
  67. package/src/api/lib/validator.verification.test.ts +0 -52
  68. package/src/api/routes/state.test.ts +0 -148
  69. package/src/router/useHashRoute.test.ts +0 -69
  70. package/src/test-setup.ts +0 -1
  71. package/src/ui/FindingModal/FindingModal.test.tsx +0 -36
  72. package/src/ui/HealthPage/HealthPage.test.tsx +0 -71
  73. package/src/ui/HealthPage/severity.test.ts +0 -46
  74. package/src/ui/LayerDetailPage/LayerDetailPage.test.tsx +0 -30
  75. 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
@@ -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.10.2",
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.3.1",
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.19.0",
65
+ "tsx": "^4.22.4",
63
66
  "typescript": "^5.5.4",
64
- "vite": "^5.4.3",
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
+ }
@@ -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
- // 2. init-check
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
- // 3. validate (re-read state after metrics may have changed scope dirs)
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
- // 4. Diff findings: surface NEW critical/warning findings introduced this run.
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
- // 5. Summary line + (optional) new-finding lines.
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: 'missing_agent_context' | 'stale_agent_context';
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 = scoreLayers(repoRoot, queryTokens, state.scopes, matchingItems);
344
- const candidates = scopeCandidates(state.scopes, scoredLayers, matchingItems);
345
- const topLayer = scoredLayers[0];
346
- const confidence = confidenceForScore(topLayer?.score ?? 0);
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 === topLayer.scope.id) ?? null;
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
- ? scoredLayers
352
- .filter((entry) => entry.scope.id === selectedScope.id && entry.score > 0)
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: agentContextWarnings(repoRoot, now),
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 { buildImportGraph } from './importGraph';
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
- try {
91
- graph = buildImportGraph(repoRoot, allTsFiles, { tsConfigFilePath: tsConfigForScope(repoRoot, scope.path) });
92
- } catch {
93
- // No tsconfig or build error — skip import-graph dependent analyzers
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
- out.push(...fn({ repoRoot, scopeId: scope.id, scopePath: scope.path, layerId: layer.id, files, config, graph }));
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
+ }