tenbo-dashboard 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/README.md +32 -0
  2. package/bin/tenbo-dashboard.mjs +67 -0
  3. package/index.html +12 -0
  4. package/package.json +75 -0
  5. package/scripts/compute-metrics.test.ts +35 -0
  6. package/scripts/compute-metrics.ts +46 -0
  7. package/scripts/next-id.test.ts +128 -0
  8. package/scripts/next-id.ts +183 -0
  9. package/scripts/validate-cli.test.ts +152 -0
  10. package/scripts/validate-cli.ts +181 -0
  11. package/src/App.tsx +152 -0
  12. package/src/ErrorBoundary.tsx +25 -0
  13. package/src/api/client.ts +41 -0
  14. package/src/api/lib/frontmatterScan.ts +37 -0
  15. package/src/api/lib/health/agingTodos.test.ts +32 -0
  16. package/src/api/lib/health/agingTodos.ts +97 -0
  17. package/src/api/lib/health/archCompliance.test.ts +26 -0
  18. package/src/api/lib/health/archCompliance.ts +53 -0
  19. package/src/api/lib/health/collectAll.test.ts +49 -0
  20. package/src/api/lib/health/collectAll.ts +78 -0
  21. package/src/api/lib/health/config.test.ts +45 -0
  22. package/src/api/lib/health/config.ts +54 -0
  23. package/src/api/lib/health/coupling.test.ts +40 -0
  24. package/src/api/lib/health/coupling.ts +66 -0
  25. package/src/api/lib/health/deadCode.test.ts +23 -0
  26. package/src/api/lib/health/deadCode.ts +76 -0
  27. package/src/api/lib/health/docDrift.test.ts +54 -0
  28. package/src/api/lib/health/docDrift.ts +99 -0
  29. package/src/api/lib/health/hotspotFiles.test.ts +49 -0
  30. package/src/api/lib/health/hotspotFiles.ts +70 -0
  31. package/src/api/lib/health/importGraph.test.ts +29 -0
  32. package/src/api/lib/health/importGraph.ts +51 -0
  33. package/src/api/lib/health/layerFiles.test.ts +46 -0
  34. package/src/api/lib/health/layerFiles.ts +40 -0
  35. package/src/api/lib/health/redundancy.test.ts +24 -0
  36. package/src/api/lib/health/redundancy.ts +162 -0
  37. package/src/api/lib/health/testCoverage.test.ts +33 -0
  38. package/src/api/lib/health/testCoverage.ts +55 -0
  39. package/src/api/lib/health/types.test.ts +29 -0
  40. package/src/api/lib/health/types.ts +85 -0
  41. package/src/api/lib/http.ts +34 -0
  42. package/src/api/lib/metrics.test.ts +81 -0
  43. package/src/api/lib/metrics.ts +78 -0
  44. package/src/api/lib/metricsRefresh.test.ts +54 -0
  45. package/src/api/lib/metricsRefresh.ts +147 -0
  46. package/src/api/lib/phases.test.ts +76 -0
  47. package/src/api/lib/phases.ts +48 -0
  48. package/src/api/lib/relationships.ts +65 -0
  49. package/src/api/lib/repoRoot.ts +12 -0
  50. package/src/api/lib/tenboFs.layerContent.test.ts +36 -0
  51. package/src/api/lib/tenboFs.ts +237 -0
  52. package/src/api/lib/tenboFs.v2.test.ts +86 -0
  53. package/src/api/lib/tenboFs.workspaceContent.test.ts +42 -0
  54. package/src/api/lib/validator.docUpdate.test.ts +99 -0
  55. package/src/api/lib/validator.phases.test.ts +98 -0
  56. package/src/api/lib/validator.relationships.test.ts +126 -0
  57. package/src/api/lib/validator.specLinks.test.ts +96 -0
  58. package/src/api/lib/validator.ts +311 -0
  59. package/src/api/lib/validator.v2.test.ts +55 -0
  60. package/src/api/lib/yamlOrdered.ts +60 -0
  61. package/src/api/plugin.ts +31 -0
  62. package/src/api/routes/items.ts +25 -0
  63. package/src/api/routes/layerContent.ts +22 -0
  64. package/src/api/routes/layerDocs.ts +22 -0
  65. package/src/api/routes/open.ts +18 -0
  66. package/src/api/routes/related.ts +11 -0
  67. package/src/api/routes/reorder.ts +15 -0
  68. package/src/api/routes/state.ts +23 -0
  69. package/src/api/routes/watch.ts +31 -0
  70. package/src/css-modules.d.ts +4 -0
  71. package/src/hooks/useApiPatch.ts +9 -0
  72. package/src/hooks/useLayerContent.ts +25 -0
  73. package/src/hooks/usePrompt.ts +23 -0
  74. package/src/hooks/useTenboState.ts +35 -0
  75. package/src/main.tsx +13 -0
  76. package/src/prompts/populateLayer.ts +12 -0
  77. package/src/router/routes.ts +98 -0
  78. package/src/router/useHashRoute.test.ts +67 -0
  79. package/src/router/useHashRoute.ts +15 -0
  80. package/src/styles.css +228 -0
  81. package/src/test-setup.ts +1 -0
  82. package/src/types.ts +202 -0
  83. package/src/ui/DotGrid.module.css +7 -0
  84. package/src/ui/DotGrid.tsx +87 -0
  85. package/src/ui/EmptyState.module.css +11 -0
  86. package/src/ui/EmptyState.tsx +10 -0
  87. package/src/ui/FindingModal/EvidenceSection.tsx +51 -0
  88. package/src/ui/FindingModal/FindingModal.module.css +31 -0
  89. package/src/ui/FindingModal/FindingModal.test.tsx +36 -0
  90. package/src/ui/FindingModal/HeadlineSection.tsx +17 -0
  91. package/src/ui/FindingModal/RelatedFindingsSection.tsx +29 -0
  92. package/src/ui/FindingModal/SuggestionSection.tsx +13 -0
  93. package/src/ui/FindingModal/index.tsx +36 -0
  94. package/src/ui/HealthPage/Digest.tsx +29 -0
  95. package/src/ui/HealthPage/FindingRow.tsx +21 -0
  96. package/src/ui/HealthPage/HealthPage.module.css +45 -0
  97. package/src/ui/HealthPage/HealthPage.test.tsx +49 -0
  98. package/src/ui/HealthPage/HealthPage.tsx +66 -0
  99. package/src/ui/HealthPage/LayerCard.tsx +36 -0
  100. package/src/ui/HealthPage/SeverityIcon.tsx +29 -0
  101. package/src/ui/HealthPage/SeverityLegend.tsx +27 -0
  102. package/src/ui/HealthPage/severity.test.ts +46 -0
  103. package/src/ui/HealthPage/severity.ts +21 -0
  104. package/src/ui/ItemCard.module.css +131 -0
  105. package/src/ui/ItemCard.tsx +117 -0
  106. package/src/ui/ItemModal/DescriptionField.tsx +22 -0
  107. package/src/ui/ItemModal/EnrichmentSections.tsx +49 -0
  108. package/src/ui/ItemModal/ItemModal.module.css +200 -0
  109. package/src/ui/ItemModal/LinksSection.tsx +26 -0
  110. package/src/ui/ItemModal/NotesSection.tsx +18 -0
  111. package/src/ui/ItemModal/PhasesSection.tsx +79 -0
  112. package/src/ui/ItemModal/RelatedSection.tsx +26 -0
  113. package/src/ui/ItemModal/RelationshipsSection.tsx +69 -0
  114. package/src/ui/ItemModal/StatusLayerControls.tsx +43 -0
  115. package/src/ui/ItemModal/TitleField.tsx +21 -0
  116. package/src/ui/ItemModal/index.tsx +117 -0
  117. package/src/ui/KanbanColumn.module.css +20 -0
  118. package/src/ui/KanbanColumn.tsx +36 -0
  119. package/src/ui/LayerDetailPage/LayerDetailPage.module.css +11 -0
  120. package/src/ui/LayerDetailPage/LayerDetailPage.test.tsx +30 -0
  121. package/src/ui/LayerDetailPage/LayerDetailPage.tsx +59 -0
  122. package/src/ui/LayerDetailPage/SignalSection.tsx +33 -0
  123. package/src/ui/LayerDrawer.module.css +60 -0
  124. package/src/ui/LayerDrawer.tsx +69 -0
  125. package/src/ui/LayerKanban.module.css +45 -0
  126. package/src/ui/LayerKanban.tsx +65 -0
  127. package/src/ui/LayerPage/FillThisInButton.tsx +11 -0
  128. package/src/ui/LayerPage/LayerPage.module.css +52 -0
  129. package/src/ui/LayerPage/LayerPage.tsx +76 -0
  130. package/src/ui/LayerPage/MetricsBadges.tsx +18 -0
  131. package/src/ui/LayerPage/isTemplateOnly.test.ts +45 -0
  132. package/src/ui/LayerPage/isTemplateOnly.ts +19 -0
  133. package/src/ui/LayerPage/tabs/CodeMapTab.tsx +19 -0
  134. package/src/ui/LayerPage/tabs/DeepDivesTab.tsx +35 -0
  135. package/src/ui/LayerPage/tabs/IntentTab.tsx +19 -0
  136. package/src/ui/LayerPage/tabs/PlainEnglishTab.tsx +19 -0
  137. package/src/ui/Markdown.module.css +115 -0
  138. package/src/ui/Markdown.tsx +27 -0
  139. package/src/ui/RoadmapPage/RoadmapPage.module.css +93 -0
  140. package/src/ui/RoadmapPage/RoadmapPage.tsx +255 -0
  141. package/src/ui/ScopePage/ScopePage.module.css +72 -0
  142. package/src/ui/ScopePage/ScopePage.tsx +131 -0
  143. package/src/ui/SummaryStrip.module.css +18 -0
  144. package/src/ui/SummaryStrip.tsx +16 -0
  145. package/src/ui/TaskList.module.css +136 -0
  146. package/src/ui/TaskList.tsx +91 -0
  147. package/src/ui/TopBar.module.css +78 -0
  148. package/src/ui/TopBar.tsx +48 -0
  149. package/src/ui/WorkspacePage/WorkspacePage.module.css +24 -0
  150. package/src/ui/WorkspacePage/WorkspacePage.tsx +36 -0
  151. package/src/ui/WorkspacePage/tabs/GlossaryTab.tsx +6 -0
  152. package/src/ui/WorkspacePage/tabs/OverviewTab.tsx +28 -0
  153. package/src/ui/WorkspacePage/tabs/PrinciplesTab.tsx +6 -0
  154. package/tsconfig.json +18 -0
  155. package/vite.config.ts +9 -0
@@ -0,0 +1,126 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { validate } from './validator';
3
+ import { childrenOf } from './relationships';
4
+ import type { TenboState, Item } from '../../types';
5
+
6
+ function stateWithItems(items: Item[]): TenboState {
7
+ return {
8
+ scopes: [{
9
+ id: 'e',
10
+ path: '.',
11
+ description: 'editor scope description here for tests',
12
+ layers: [{ id: 'l', name: 'L', description: 'a small layer for testing now', files: ['x'] } as any],
13
+ items,
14
+ }],
15
+ crossCutting: [],
16
+ narratives: { 'e/l': '# L' },
17
+ } as any;
18
+ }
19
+
20
+ const baseItem = (over: Partial<Item> = {}): Item => ({
21
+ id: 'ed-001',
22
+ title: 't',
23
+ layer: 'l',
24
+ status: 'later',
25
+ description: 'a roadmap item used for relationship tests now',
26
+ ...over,
27
+ });
28
+
29
+ const errMsgsFor = (state: TenboState, id: string) =>
30
+ validate(state).errors.filter((e) => e.itemId === id).map((e) => e.message);
31
+ const warnMsgsFor = (state: TenboState, id: string) =>
32
+ validate(state).warnings.filter((e) => e.itemId === id).map((e) => e.message);
33
+
34
+ describe('validator — relationships', () => {
35
+ it('accepts well-formed spawned_from + related with no errors or warnings', () => {
36
+ const state = stateWithItems([
37
+ baseItem({ id: 'ed-001' }),
38
+ baseItem({ id: 'ed-002' }),
39
+ baseItem({ id: 'ed-003', spawned_from: 'ed-001', related: ['ed-002'] }),
40
+ ]);
41
+ expect(errMsgsFor(state, 'ed-003')).toEqual([]);
42
+ expect(warnMsgsFor(state, 'ed-003')).toEqual([]);
43
+ });
44
+
45
+ it('warns when spawned_from references an unknown id', () => {
46
+ const state = stateWithItems([baseItem({ id: 'ed-001', spawned_from: 'ed-999' })]);
47
+ expect(warnMsgsFor(state, 'ed-001')).toContain(
48
+ "ed-001 spawned_from references unknown id 'ed-999'",
49
+ );
50
+ expect(errMsgsFor(state, 'ed-001')).toEqual([]);
51
+ });
52
+
53
+ it('warns when related references an unknown id', () => {
54
+ const state = stateWithItems([baseItem({ id: 'ed-001', related: ['ed-999'] })]);
55
+ expect(warnMsgsFor(state, 'ed-001')).toContain(
56
+ "ed-001 related references unknown id 'ed-999'",
57
+ );
58
+ expect(errMsgsFor(state, 'ed-001')).toEqual([]);
59
+ });
60
+
61
+ it('errors on self-reference in spawned_from', () => {
62
+ const state = stateWithItems([baseItem({ id: 'ed-001', spawned_from: 'ed-001' })]);
63
+ expect(errMsgsFor(state, 'ed-001').some((m) => /spawned_from cannot reference itself/.test(m))).toBe(true);
64
+ });
65
+
66
+ it('errors on self-reference in related', () => {
67
+ const state = stateWithItems([baseItem({ id: 'ed-001', related: ['ed-001'] })]);
68
+ expect(errMsgsFor(state, 'ed-001').some((m) => /related cannot reference itself/.test(m))).toBe(true);
69
+ });
70
+
71
+ it('warns on duplicate id in related', () => {
72
+ const state = stateWithItems([
73
+ baseItem({ id: 'ed-001' }),
74
+ baseItem({ id: 'ed-002' }),
75
+ baseItem({ id: 'ed-003', related: ['ed-001', 'ed-001'] }),
76
+ ]);
77
+ expect(warnMsgsFor(state, 'ed-003').some((m) => /duplicate id 'ed-001'/.test(m))).toBe(true);
78
+ });
79
+
80
+ it('warns when same id appears in both spawned_from and related (redundant)', () => {
81
+ const state = stateWithItems([
82
+ baseItem({ id: 'ed-001' }),
83
+ baseItem({ id: 'ed-002', spawned_from: 'ed-001', related: ['ed-001'] }),
84
+ ]);
85
+ expect(warnMsgsFor(state, 'ed-002').some((m) => /both spawned_from and related/.test(m))).toBe(true);
86
+ });
87
+
88
+ it('errors on invalid id format in spawned_from', () => {
89
+ const state = stateWithItems([baseItem({ id: 'ed-001', spawned_from: 'NotAnId' })]);
90
+ expect(errMsgsFor(state, 'ed-001').some((m) => /invalid id format/.test(m))).toBe(true);
91
+ });
92
+
93
+ it('errors on invalid id format in related', () => {
94
+ const state = stateWithItems([baseItem({ id: 'ed-001', related: ['BAD'] })]);
95
+ expect(errMsgsFor(state, 'ed-001').some((m) => /invalid id format/.test(m))).toBe(true);
96
+ });
97
+
98
+ it('resolves cross-scope refs against the cross-cutting roadmap', () => {
99
+ const state: TenboState = {
100
+ ...stateWithItems([baseItem({ id: 'ed-001', spawned_from: 'x-001' })]),
101
+ crossCuttingRoadmap: [
102
+ { id: 'x-001', title: 'cross', status: 'now', description: 'a cross-cutting item', layers: [] } as any,
103
+ ],
104
+ };
105
+ expect(warnMsgsFor(state, 'ed-001')).toEqual([]);
106
+ });
107
+ });
108
+
109
+ describe('childrenOf helper', () => {
110
+ it('returns items whose spawned_from matches the given parent id', () => {
111
+ const items: Item[] = [
112
+ baseItem({ id: 'ed-001' }),
113
+ baseItem({ id: 'ed-002', spawned_from: 'ed-001' }),
114
+ baseItem({ id: 'ed-003', spawned_from: 'ed-001' }),
115
+ baseItem({ id: 'ed-004', spawned_from: 'ed-002' }),
116
+ baseItem({ id: 'ed-005' }),
117
+ ];
118
+ const children = childrenOf(items, 'ed-001');
119
+ expect(children.map((c) => c.id)).toEqual(['ed-002', 'ed-003']);
120
+ });
121
+
122
+ it('returns empty array when no children match', () => {
123
+ const items: Item[] = [baseItem({ id: 'ed-001' }), baseItem({ id: 'ed-002' })];
124
+ expect(childrenOf(items, 'ed-001')).toEqual([]);
125
+ });
126
+ });
@@ -0,0 +1,96 @@
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[], specFiles?: Set<string>): TenboState {
6
+ const s: TenboState = {
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
+ if (specFiles) s.specFiles = specFiles;
18
+ return s;
19
+ }
20
+
21
+ const baseItem = (over: Partial<Item> = {}): Item => ({
22
+ id: 'ed-001',
23
+ title: 't',
24
+ layer: 'l',
25
+ status: 'later',
26
+ description: 'a roadmap item used for spec link tests now',
27
+ ...over,
28
+ });
29
+
30
+ const warnMsgsFor = (state: TenboState, id: string) =>
31
+ validate(state).warnings.filter((w) => w.itemId === id).map((w) => w.message);
32
+
33
+ describe('validator — spec links (Phase 6)', () => {
34
+ it('warns when a .tenbo/specs/ link points to a missing file', () => {
35
+ const state = stateWithItems(
36
+ [baseItem({ id: 'ed-001', links: ['.tenbo/specs/ed-001-foo.md'] })],
37
+ new Set<string>(), // empty — file doesn't exist
38
+ );
39
+ expect(warnMsgsFor(state, 'ed-001')).toContain(
40
+ 'ed-001 links to .tenbo/specs/ed-001-foo.md which does not exist',
41
+ );
42
+ });
43
+
44
+ it('warns when an item is done but its spec is not archived', () => {
45
+ const state = stateWithItems(
46
+ [baseItem({ id: 'ed-002', status: 'done', links: ['.tenbo/specs/ed-002-foo.md'] })],
47
+ new Set(['.tenbo/specs/ed-002-foo.md']),
48
+ );
49
+ expect(warnMsgsFor(state, 'ed-002')).toContain(
50
+ "ed-002 is done but its spec hasn't been archived",
51
+ );
52
+ });
53
+
54
+ it('warns when an item is not done but its spec is archived', () => {
55
+ const state = stateWithItems(
56
+ [baseItem({ id: 'ed-003', status: 'now', links: ['.tenbo/specs/archive/ed-003-foo.md'] })],
57
+ new Set(['.tenbo/specs/archive/ed-003-foo.md']),
58
+ );
59
+ expect(warnMsgsFor(state, 'ed-003')).toContain(
60
+ 'ed-003 is now but its spec is archived',
61
+ );
62
+ });
63
+
64
+ it('warns when a link points at the legacy roadmap/ path', () => {
65
+ const state = stateWithItems(
66
+ [baseItem({ id: 'ed-004', links: ['roadmap/ed-004-foo.md'] })],
67
+ );
68
+ expect(warnMsgsFor(state, 'ed-004')).toContain(
69
+ 'ed-004 links to old spec path; should be .tenbo/specs/ed-004-foo.md',
70
+ );
71
+ });
72
+
73
+ it('does not warn for a healthy active spec link', () => {
74
+ const state = stateWithItems(
75
+ [baseItem({ id: 'ed-005', status: 'now', links: ['.tenbo/specs/ed-005-foo.md'], done_when: ['ok'] })],
76
+ new Set(['.tenbo/specs/ed-005-foo.md']),
77
+ );
78
+ expect(warnMsgsFor(state, 'ed-005')).toEqual([]);
79
+ });
80
+
81
+ it('does not warn for a healthy archived spec link on a done item', () => {
82
+ const state = stateWithItems(
83
+ [baseItem({ id: 'ed-006', status: 'done', links: ['.tenbo/specs/archive/ed-006-foo.md'] })],
84
+ new Set(['.tenbo/specs/archive/ed-006-foo.md']),
85
+ );
86
+ expect(warnMsgsFor(state, 'ed-006')).toEqual([]);
87
+ });
88
+
89
+ it('does not warn on done items whose links do not point into .tenbo/specs/', () => {
90
+ // E.g. items linking to external docs or that never had a spec.
91
+ const state = stateWithItems(
92
+ [baseItem({ id: 'ed-007', status: 'done', links: ['docs/something.md'] })],
93
+ );
94
+ expect(warnMsgsFor(state, 'ed-007')).toEqual([]);
95
+ });
96
+ });
@@ -0,0 +1,311 @@
1
+ import type { TenboState, ValidateResult, ValidateIssue, Item } from '../../types';
2
+ import { VALID_PHASE_STATUSES, isIsoDate } from './phases';
3
+
4
+ const FORBIDDEN_JARGON = [
5
+ 'module', 'API', 'endpoint', 'interface', 'abstraction', 'service',
6
+ 'controller', 'DTO', 'ORM', 'repository pattern', 'middleware',
7
+ 'schema', 'polyfill', 'shim', 'AST', 'RPC', 'webhook', 'queue',
8
+ 'worker', 'daemon',
9
+ ];
10
+
11
+ const VALID_STATUSES = new Set(['now', 'next', 'later', 'done']);
12
+ const VALID_PRIORITIES = new Set(['p0', 'p1', 'p2', 'p3']);
13
+ const ITEM_ID_RE = /^[a-z]{1,5}-\d{3,}$/;
14
+ const DOC_UPDATE_REQUIRED_TYPES = new Set(['feature', 'refactor', 'bug']);
15
+ const DOC_UPDATE_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
16
+ const DOC_UPDATE_SKIPPED_RE = /^skipped\s*[—:-]\s*\S/;
17
+
18
+ export function validate(state: TenboState): ValidateResult {
19
+ const errors: ValidateIssue[] = [];
20
+ const warnings: ValidateIssue[] = [];
21
+
22
+ // Collect every known roadmap-item id across scopes + cross-cutting roadmap.
23
+ // Used by the relationship validator (`spawned_from`, `related`) to flag
24
+ // references that don't resolve to anything in the workspace.
25
+ const allItemIds = new Set<string>();
26
+ for (const scope of state.scopes) {
27
+ for (const item of scope.items) allItemIds.add(item.id);
28
+ }
29
+ for (const item of state.crossCuttingRoadmap ?? []) allItemIds.add(item.id);
30
+
31
+ const validateRelationships = (item: Item, scopeId?: string) => {
32
+ if (item.spawned_from !== undefined) {
33
+ const sf = item.spawned_from;
34
+ if (typeof sf !== 'string' || !ITEM_ID_RE.test(sf)) {
35
+ errors.push({ level: 'error', message: `item "${item.id}" spawned_from "${sf}" has invalid id format`, scope: scopeId, itemId: item.id });
36
+ } else if (sf === item.id) {
37
+ errors.push({ level: 'error', message: `item "${item.id}" spawned_from cannot reference itself`, scope: scopeId, itemId: item.id });
38
+ } else if (!allItemIds.has(sf)) {
39
+ warnings.push({ level: 'warning', message: `${item.id} spawned_from references unknown id '${sf}'`, scope: scopeId, itemId: item.id });
40
+ }
41
+ }
42
+ if (item.related !== undefined) {
43
+ if (!Array.isArray(item.related)) {
44
+ errors.push({ level: 'error', message: `item "${item.id}" related must be a list`, scope: scopeId, itemId: item.id });
45
+ } else {
46
+ const seenRel = new Set<string>();
47
+ for (const ref of item.related) {
48
+ if (typeof ref !== 'string' || !ITEM_ID_RE.test(ref)) {
49
+ errors.push({ level: 'error', message: `item "${item.id}" related entry "${ref}" has invalid id format`, scope: scopeId, itemId: item.id });
50
+ continue;
51
+ }
52
+ if (ref === item.id) {
53
+ errors.push({ level: 'error', message: `item "${item.id}" related cannot reference itself`, scope: scopeId, itemId: item.id });
54
+ continue;
55
+ }
56
+ if (seenRel.has(ref)) {
57
+ warnings.push({ level: 'warning', message: `item "${item.id}" related has duplicate id '${ref}'`, scope: scopeId, itemId: item.id });
58
+ }
59
+ seenRel.add(ref);
60
+ if (item.spawned_from && ref === item.spawned_from) {
61
+ warnings.push({ level: 'warning', message: `item "${item.id}" lists '${ref}' in both spawned_from and related (redundant)`, scope: scopeId, itemId: item.id });
62
+ }
63
+ if (!allItemIds.has(ref)) {
64
+ warnings.push({ level: 'warning', message: `${item.id} related references unknown id '${ref}'`, scope: scopeId, itemId: item.id });
65
+ }
66
+ }
67
+ }
68
+ }
69
+ };
70
+
71
+ // Spec-link lifecycle validation (Phase 6 of x-001).
72
+ // Specs migrated from `roadmap/<id>-slug.md` to `.tenbo/specs/<id>-slug.md`,
73
+ // and archive when the item flips to `done`. These warnings flag drift
74
+ // between item status and spec location, plus links pointing at the old
75
+ // roadmap/ path. All warnings (not errors) so cleanup can land incrementally.
76
+ const validateSpecLinks = (item: Item, scopeId?: string) => {
77
+ const links = item.links ?? [];
78
+ for (const link of links) {
79
+ if (typeof link !== 'string') continue;
80
+ // Old roadmap/ path detection.
81
+ if (/^roadmap\/[a-z]{1,5}-\d{3,}-.*\.md$/i.test(link)) {
82
+ warnings.push({
83
+ level: 'warning',
84
+ message: `${item.id} links to old spec path; should be .tenbo/specs/${link.slice('roadmap/'.length)}`,
85
+ scope: scopeId,
86
+ itemId: item.id,
87
+ });
88
+ continue;
89
+ }
90
+ const inSpecs = link.startsWith('.tenbo/specs/');
91
+ if (!inSpecs) continue;
92
+ const inArchive = link.startsWith('.tenbo/specs/archive/');
93
+ // File-existence check (only when state.specFiles is populated).
94
+ if (state.specFiles && !state.specFiles.has(link)) {
95
+ warnings.push({
96
+ level: 'warning',
97
+ message: `${item.id} links to ${link} which does not exist`,
98
+ scope: scopeId,
99
+ itemId: item.id,
100
+ });
101
+ }
102
+ // Lifecycle: status vs archive.
103
+ if (item.status === 'done' && !inArchive) {
104
+ warnings.push({
105
+ level: 'warning',
106
+ message: `${item.id} is done but its spec hasn't been archived`,
107
+ scope: scopeId,
108
+ itemId: item.id,
109
+ });
110
+ } else if (item.status && item.status !== 'done' && inArchive) {
111
+ warnings.push({
112
+ level: 'warning',
113
+ message: `${item.id} is ${item.status} but its spec is archived`,
114
+ scope: scopeId,
115
+ itemId: item.id,
116
+ });
117
+ }
118
+ }
119
+ };
120
+
121
+ for (const scope of state.scopes) {
122
+ const layerIds = new Set(scope.layers.map(l => l.id));
123
+
124
+ // Layer rules
125
+ for (const layer of scope.layers) {
126
+ if (!layer.id || !layer.name || !layer.description || !layer.files?.length) {
127
+ errors.push({ level: 'error', message: `layer "${layer.id}" is missing required fields`, scope: scope.id, layerId: layer.id });
128
+ }
129
+ if (layer.parent && !layerIds.has(layer.parent)) {
130
+ errors.push({ level: 'error', message: `layer "${layer.id}" parent "${layer.parent}" does not exist`, scope: scope.id, layerId: layer.id });
131
+ }
132
+ if (!state.narratives[`${scope.id}/${layer.id}`]) {
133
+ errors.push({ level: 'error', message: `layer "${layer.id}" is missing its narrative file`, scope: scope.id, layerId: layer.id });
134
+ }
135
+ const wc = (layer.description ?? '').trim().split(/\s+/).length;
136
+ if (wc < 5 || wc > 30) {
137
+ warnings.push({ level: 'warning', message: `layer "${layer.id}" description is ${wc < 5 ? 'too short' : 'too long'} (${wc} words)`, scope: scope.id, layerId: layer.id });
138
+ }
139
+ for (const word of FORBIDDEN_JARGON) {
140
+ const re = new RegExp(`\\b${word}\\b`, 'i');
141
+ if (re.test(layer.description ?? '')) {
142
+ warnings.push({ level: 'warning', message: `layer "${layer.id}" description uses "${word}"`, scope: scope.id, layerId: layer.id });
143
+ }
144
+ }
145
+ // v2: intent.md empty check
146
+ const docs = state.layerDocs?.[`${scope.id}/${layer.id}`];
147
+ if (docs?.hasIntent && docs.intentEmpty) {
148
+ errors.push({ level: 'error', message: `layer "${layer.id}" intent.md is empty`, scope: scope.id, layerId: layer.id });
149
+ }
150
+ // v2: dependencies resolution
151
+ const outbound = layer.dependencies?.outbound ?? [];
152
+ for (const dep of outbound) {
153
+ if (!layerIds.has(dep)) {
154
+ errors.push({ level: 'error', message: `layer "${layer.id}" dependencies.outbound references "${dep}" which does not exist`, scope: scope.id, layerId: layer.id });
155
+ }
156
+ }
157
+ const inbound = layer.dependencies?.inbound ?? [];
158
+ for (const dep of inbound) {
159
+ if (!layerIds.has(dep)) {
160
+ errors.push({ level: 'error', message: `layer "${layer.id}" dependencies.inbound references "${dep}" which does not exist`, scope: scope.id, layerId: layer.id });
161
+ }
162
+ }
163
+ if (state.layerDocs && !state.layerDocs[`${scope.id}/${layer.id}`]?.hasCodeMap) {
164
+ warnings.push({ level: 'warning', message: `layer "${layer.id}" has no code-map.md`, scope: scope.id, layerId: layer.id });
165
+ }
166
+ }
167
+
168
+ // Item rules
169
+ const seenIds = new Set<string>();
170
+ for (const item of scope.items) {
171
+ if (!/^[a-z]{1,5}-\d{3,}$/.test(item.id)) {
172
+ errors.push({ level: 'error', message: `item "${item.id}" has invalid id format (expected <prefix>-NNN, e.g. ed-001 or x-001)`, scope: scope.id, itemId: item.id });
173
+ }
174
+ if (seenIds.has(item.id)) {
175
+ errors.push({ level: 'error', message: `duplicate item id "${item.id}"`, scope: scope.id, itemId: item.id });
176
+ }
177
+ seenIds.add(item.id);
178
+ // Status is optional when phases are present (it's derived via roll-up).
179
+ // Only validate when explicitly set.
180
+ const hasPhases = Array.isArray(item.phases) && item.phases.length > 0;
181
+ if (item.status !== undefined && item.status !== null) {
182
+ if (!VALID_STATUSES.has(item.status)) {
183
+ errors.push({ level: 'error', message: `item "${item.id}" has invalid status "${item.status}"`, scope: scope.id, itemId: item.id });
184
+ }
185
+ } else if (!hasPhases) {
186
+ errors.push({ level: 'error', message: `item "${item.id}" is missing required status (or phases)`, scope: scope.id, itemId: item.id });
187
+ }
188
+ if (item.priority !== undefined && !VALID_PRIORITIES.has(item.priority)) {
189
+ errors.push({ level: 'error', message: `item "${item.id}" has invalid priority "${item.priority}" (expected p0, p1, p2, or p3)`, scope: scope.id, itemId: item.id });
190
+ }
191
+ if (item.layer && !layerIds.has(item.layer)) {
192
+ errors.push({ level: 'error', message: `item "${item.id}" references layer "${item.layer}" which does not exist`, scope: scope.id, itemId: item.id });
193
+ }
194
+ if (item.layers?.length) {
195
+ for (const ref of item.layers) {
196
+ const [sId, lId] = ref.split('.');
197
+ const targetScope = state.scopes.find(s => s.id === sId);
198
+ if (!targetScope || !targetScope.layers.some(l => l.id === lId)) {
199
+ errors.push({ level: 'error', message: `cross-scope ref "${ref}" on item "${item.id}" doesn't resolve`, itemId: item.id });
200
+ }
201
+ }
202
+ }
203
+ if (item.status === 'now' && !item.done_when?.length) {
204
+ warnings.push({ level: 'warning', message: `item "${item.id}" has status:now but no done_when criteria`, scope: scope.id, itemId: item.id });
205
+ }
206
+ for (const word of FORBIDDEN_JARGON) {
207
+ const re = new RegExp(`\\b${word}\\b`, 'i');
208
+ if (re.test(item.description ?? '')) {
209
+ warnings.push({ level: 'warning', message: `item "${item.id}" description uses "${word}"`, scope: scope.id, itemId: item.id });
210
+ }
211
+ }
212
+
213
+ // Relationship validation (Phase 5 of x-001).
214
+ validateRelationships(item, scope.id);
215
+
216
+ // Spec-link lifecycle validation (Phase 6 of x-001).
217
+ validateSpecLinks(item, scope.id);
218
+
219
+ // Doc-update gate (Phase 3 of x-003). Warn on done items whose type
220
+ // implies architecture surface impact but doc_update is missing or
221
+ // malformed. Items without `type:` are exempt — the gate only fires
222
+ // when the item explicitly opted in to a typed shape (via Behavior 13
223
+ // audit, or hand-set). This keeps existing un-typed done items quiet
224
+ // while catching every new shipped feature/refactor/bug going forward.
225
+ if (item.status === 'done' && item.type && DOC_UPDATE_REQUIRED_TYPES.has(item.type)) {
226
+ const du = item.doc_update;
227
+ if (du === undefined || du === null || (typeof du === 'string' && du.trim() === '')) {
228
+ warnings.push({
229
+ level: 'warning',
230
+ message: `${item.id} is done (type=${item.type}) but doc_update is not stamped — confirm the layer's intent.md/code-map.md was updated, then set doc_update: <YYYY-MM-DD> or doc_update: skipped — <reason>`,
231
+ scope: scope.id,
232
+ itemId: item.id,
233
+ });
234
+ } else if (typeof du !== 'string') {
235
+ errors.push({
236
+ level: 'error',
237
+ message: `${item.id} doc_update must be a string (got ${typeof du})`,
238
+ scope: scope.id,
239
+ itemId: item.id,
240
+ });
241
+ } else if (!DOC_UPDATE_DATE_RE.test(du.trim()) && !DOC_UPDATE_SKIPPED_RE.test(du.trim())) {
242
+ warnings.push({
243
+ level: 'warning',
244
+ message: `${item.id} doc_update "${du}" is malformed — expected YYYY-MM-DD or 'skipped — <reason>'`,
245
+ scope: scope.id,
246
+ itemId: item.id,
247
+ });
248
+ }
249
+ }
250
+
251
+ // Phase schema validation (Phase 3 of x-001).
252
+ if (item.phases !== undefined) {
253
+ if (!Array.isArray(item.phases)) {
254
+ errors.push({ level: 'error', message: `item "${item.id}" phases must be a list`, scope: scope.id, itemId: item.id });
255
+ } else {
256
+ // If both `status:` and `phases:` are present, item.status will be derived;
257
+ // emit a warning so authors drop the explicit status.
258
+ if (item.phases.length > 0 && item.status !== undefined && item.status !== null) {
259
+ warnings.push({ level: 'warning', message: `item "${item.id}" has both status and phases — status will be derived from phases, drop the explicit status`, scope: scope.id, itemId: item.id });
260
+ }
261
+ const seenPhaseIds = new Set<number>();
262
+ item.phases.forEach((ph, idx) => {
263
+ const expectedId = idx + 1;
264
+ if (typeof ph.id !== 'number' || !Number.isInteger(ph.id)) {
265
+ errors.push({ level: 'error', message: `item "${item.id}" phase at position ${expectedId} is missing an integer id`, scope: scope.id, itemId: item.id });
266
+ } else {
267
+ if (ph.id !== expectedId) {
268
+ errors.push({ level: 'error', message: `item "${item.id}" phase at position ${expectedId} has id ${ph.id} (ids must be 1..N matching position)`, scope: scope.id, itemId: item.id });
269
+ }
270
+ if (seenPhaseIds.has(ph.id)) {
271
+ errors.push({ level: 'error', message: `item "${item.id}" has duplicate phase id ${ph.id}`, scope: scope.id, itemId: item.id });
272
+ }
273
+ seenPhaseIds.add(ph.id);
274
+ }
275
+ if (!ph.title || typeof ph.title !== 'string') {
276
+ errors.push({ level: 'error', message: `item "${item.id}" phase ${ph.id ?? expectedId} is missing a title`, scope: scope.id, itemId: item.id });
277
+ }
278
+ if (!ph.status || !VALID_PHASE_STATUSES.has(ph.status)) {
279
+ errors.push({ level: 'error', message: `item "${item.id}" phase ${ph.id ?? expectedId} has invalid status "${ph.status}"`, scope: scope.id, itemId: item.id });
280
+ }
281
+ if (ph.completed_at !== undefined) {
282
+ if (typeof ph.completed_at !== 'string' || !isIsoDate(ph.completed_at)) {
283
+ errors.push({ level: 'error', message: `item "${item.id}" phase ${ph.id ?? expectedId} completed_at "${ph.completed_at}" is not YYYY-MM-DD`, scope: scope.id, itemId: item.id });
284
+ }
285
+ }
286
+ if (ph.status === 'done' && !ph.completed_at) {
287
+ warnings.push({ level: 'warning', message: `item "${item.id}" phase ${ph.id ?? expectedId} is done but has no completed_at`, scope: scope.id, itemId: item.id });
288
+ }
289
+ if (ph.status !== 'done' && ph.completed_at) {
290
+ warnings.push({ level: 'warning', message: `item "${item.id}" phase ${ph.id ?? expectedId} has completed_at but status is "${ph.status}"`, scope: scope.id, itemId: item.id });
291
+ }
292
+ });
293
+ }
294
+ }
295
+ }
296
+ }
297
+
298
+ const allRefs = new Set(state.scopes.flatMap(s => s.layers.map(l => `${s.id}:${l.id}`)));
299
+ for (const item of state.crossCuttingRoadmap ?? []) {
300
+ const refs = [...(item.layers ?? []), ...(item.affects ?? [])];
301
+ for (const ref of refs) {
302
+ if (!allRefs.has(ref)) {
303
+ errors.push({ level: 'error', message: `cross-cutting item "${item.id}" references "${ref}" which does not resolve`, itemId: item.id });
304
+ }
305
+ }
306
+ validateRelationships(item);
307
+ validateSpecLinks(item);
308
+ }
309
+
310
+ return { errors, warnings };
311
+ }
@@ -0,0 +1,55 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { validate } from './validator';
3
+ import type { TenboState } from '../../types';
4
+
5
+ function baseState(): TenboState {
6
+ return {
7
+ scopes: [{
8
+ id: 'e', path: '.', description: 'editor scope description here',
9
+ layers: [{ id: 'l', name: 'L', description: 'a small layer for testing now', files: ['x'] } as any],
10
+ items: [],
11
+ }],
12
+ crossCutting: [],
13
+ narratives: { 'e/l': '# L' },
14
+ } as any;
15
+ }
16
+
17
+ describe('validator v2 errors', () => {
18
+ it('errors when intent.md is empty but exists', () => {
19
+ const s = baseState();
20
+ s.layerDocs = { 'e/l': { hasIntent: true, hasCodeMap: false, intentMtime: 0, codeMapMtime: null, intentEmpty: true } as any };
21
+ const r = validate(s);
22
+ expect(r.errors.some(e => /intent\.md.*empty/i.test(e.message))).toBe(true);
23
+ });
24
+
25
+ it('errors when dependencies edge points to nonexistent layer', () => {
26
+ const s = baseState();
27
+ (s.scopes[0].layers[0] as any).dependencies = { outbound: ['ghost'] };
28
+ const r = validate(s);
29
+ expect(r.errors.some(e => /dependencies.*ghost/i.test(e.message))).toBe(true);
30
+ });
31
+
32
+ it('errors when cross-cutting roadmap item references nonexistent scope', () => {
33
+ const s = baseState();
34
+ s.crossCuttingRoadmap = [{ id: 'x-001', title: 't', status: 'later', description: 'a workspace level item description', layers: ['ghost:l'] } as any];
35
+ const r = validate(s);
36
+ expect(r.errors.some(e => /ghost:l.*resolve/i.test(e.message))).toBe(true);
37
+ });
38
+ });
39
+
40
+ describe('validator v2 warnings', () => {
41
+ it('warns when layer has no code-map.md', () => {
42
+ const s = baseState();
43
+ s.layerDocs = { 'e/l': { hasIntent: false, hasCodeMap: false, intentMtime: null, codeMapMtime: null, intentEmpty: false } as any };
44
+ const r = validate(s);
45
+ expect(r.warnings.some(w => /code-map\.md/i.test(w.message))).toBe(true);
46
+ });
47
+
48
+ it('warns when status:now item missing done_when', () => {
49
+ const s = baseState();
50
+ s.scopes[0].items.push({ id: 'ed-001', title: 't', layer: 'l', status: 'now', description: 'a now item without done criteria' } as any);
51
+ const r = validate(s);
52
+ expect(r.warnings.some(w => /ed-001.*done_when/i.test(w.message))).toBe(true);
53
+ });
54
+
55
+ });
@@ -0,0 +1,60 @@
1
+ import { Document, parseDocument, isMap, isSeq, YAMLMap, YAMLSeq } from 'yaml';
2
+
3
+ export function parseYaml(text: string): Document {
4
+ return parseDocument(text, { keepSourceTokens: true });
5
+ }
6
+
7
+ export function stringifyYaml(doc: Document): string {
8
+ return doc.toString({ lineWidth: 0, blockQuote: 'literal' });
9
+ }
10
+
11
+ function getSeq(doc: Document, key: string): YAMLSeq {
12
+ const root = doc.contents;
13
+ if (!isMap(root)) throw new Error('YAML root is not a map');
14
+ const node = root.get(key, true);
15
+ if (!isSeq(node)) throw new Error(`${key} is not a sequence`);
16
+ return node;
17
+ }
18
+
19
+ function findById(seq: YAMLSeq, id: string): YAMLMap | null {
20
+ for (const item of seq.items) {
21
+ if (isMap(item) && item.get('id') === id) return item;
22
+ }
23
+ return null;
24
+ }
25
+
26
+ export function patchSeqItem(
27
+ doc: Document,
28
+ seqKey: string,
29
+ id: string,
30
+ patch: Record<string, unknown>
31
+ ): void {
32
+ const seq = getSeq(doc, seqKey);
33
+ const node = findById(seq, id);
34
+ if (!node) throw new Error(`item ${id} not found in ${seqKey}`);
35
+ for (const [k, v] of Object.entries(patch)) {
36
+ if (v === undefined || v === null) {
37
+ node.delete(k);
38
+ } else {
39
+ node.set(k, v);
40
+ }
41
+ }
42
+ }
43
+
44
+ export function reorderSeqItems(doc: Document, seqKey: string, idsInOrder: string[]): void {
45
+ const seq = getSeq(doc, seqKey);
46
+ const byId = new Map<string, YAMLMap>();
47
+ for (const item of seq.items) {
48
+ if (isMap(item)) {
49
+ const id = item.get('id') as string | undefined;
50
+ if (id) byId.set(id, item);
51
+ }
52
+ }
53
+ const reordered: YAMLMap[] = [];
54
+ for (const id of idsInOrder) {
55
+ const node = byId.get(id);
56
+ if (!node) throw new Error(`item ${id} missing during reorder`);
57
+ reordered.push(node);
58
+ }
59
+ seq.items = reordered;
60
+ }