@assistkick/create 1.0.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 (178) hide show
  1. package/dist/bin/create.d.ts +2 -0
  2. package/dist/bin/create.js +25 -0
  3. package/dist/bin/create.js.map +1 -0
  4. package/dist/src/scaffolder.d.ts +22 -0
  5. package/dist/src/scaffolder.js +120 -0
  6. package/dist/src/scaffolder.js.map +1 -0
  7. package/package.json +24 -0
  8. package/templates/product-system/.env.example +8 -0
  9. package/templates/product-system/CLAUDE.md +45 -0
  10. package/templates/product-system/package.json +32 -0
  11. package/templates/product-system/packages/backend/package.json +37 -0
  12. package/templates/product-system/packages/backend/src/middleware/auth_middleware.test.ts +86 -0
  13. package/templates/product-system/packages/backend/src/middleware/auth_middleware.ts +35 -0
  14. package/templates/product-system/packages/backend/src/routes/auth.ts +463 -0
  15. package/templates/product-system/packages/backend/src/routes/coherence.ts +187 -0
  16. package/templates/product-system/packages/backend/src/routes/graph.ts +67 -0
  17. package/templates/product-system/packages/backend/src/routes/kanban.ts +201 -0
  18. package/templates/product-system/packages/backend/src/routes/pipeline.ts +41 -0
  19. package/templates/product-system/packages/backend/src/routes/projects.ts +122 -0
  20. package/templates/product-system/packages/backend/src/routes/users.ts +97 -0
  21. package/templates/product-system/packages/backend/src/server.ts +159 -0
  22. package/templates/product-system/packages/backend/src/services/auth_service.test.ts +115 -0
  23. package/templates/product-system/packages/backend/src/services/auth_service.ts +82 -0
  24. package/templates/product-system/packages/backend/src/services/coherence-review.ts +339 -0
  25. package/templates/product-system/packages/backend/src/services/email_service.ts +75 -0
  26. package/templates/product-system/packages/backend/src/services/init.ts +80 -0
  27. package/templates/product-system/packages/backend/src/services/invitation_service.test.ts +235 -0
  28. package/templates/product-system/packages/backend/src/services/invitation_service.ts +193 -0
  29. package/templates/product-system/packages/backend/src/services/password_reset_service.test.ts +151 -0
  30. package/templates/product-system/packages/backend/src/services/password_reset_service.ts +135 -0
  31. package/templates/product-system/packages/backend/src/services/project_service.test.ts +215 -0
  32. package/templates/product-system/packages/backend/src/services/project_service.ts +171 -0
  33. package/templates/product-system/packages/backend/src/services/pty_session_manager.test.ts +88 -0
  34. package/templates/product-system/packages/backend/src/services/pty_session_manager.ts +279 -0
  35. package/templates/product-system/packages/backend/src/services/terminal_ws_handler.ts +133 -0
  36. package/templates/product-system/packages/backend/src/services/user_management_service.test.ts +158 -0
  37. package/templates/product-system/packages/backend/src/services/user_management_service.ts +128 -0
  38. package/templates/product-system/packages/backend/tsconfig.json +22 -0
  39. package/templates/product-system/packages/frontend/index.html +13 -0
  40. package/templates/product-system/packages/frontend/package-lock.json +2666 -0
  41. package/templates/product-system/packages/frontend/package.json +30 -0
  42. package/templates/product-system/packages/frontend/public/favicon.svg +16 -0
  43. package/templates/product-system/packages/frontend/src/App.tsx +29 -0
  44. package/templates/product-system/packages/frontend/src/api/client.ts +386 -0
  45. package/templates/product-system/packages/frontend/src/api/client_projects.test.ts +104 -0
  46. package/templates/product-system/packages/frontend/src/api/client_refresh.test.ts +145 -0
  47. package/templates/product-system/packages/frontend/src/components/CoherenceView.tsx +414 -0
  48. package/templates/product-system/packages/frontend/src/components/GraphLegend.tsx +124 -0
  49. package/templates/product-system/packages/frontend/src/components/GraphSettings.tsx +112 -0
  50. package/templates/product-system/packages/frontend/src/components/GraphView.tsx +370 -0
  51. package/templates/product-system/packages/frontend/src/components/InviteUserDialog.tsx +85 -0
  52. package/templates/product-system/packages/frontend/src/components/KanbanView.tsx +470 -0
  53. package/templates/product-system/packages/frontend/src/components/LoginPage.tsx +116 -0
  54. package/templates/product-system/packages/frontend/src/components/ProjectSelector.tsx +187 -0
  55. package/templates/product-system/packages/frontend/src/components/QaIssueSheet.tsx +192 -0
  56. package/templates/product-system/packages/frontend/src/components/SidePanel.tsx +231 -0
  57. package/templates/product-system/packages/frontend/src/components/TerminalView.tsx +200 -0
  58. package/templates/product-system/packages/frontend/src/components/Toolbar.tsx +84 -0
  59. package/templates/product-system/packages/frontend/src/components/UsersView.tsx +249 -0
  60. package/templates/product-system/packages/frontend/src/constants/graph.ts +191 -0
  61. package/templates/product-system/packages/frontend/src/hooks/useAuth.tsx +54 -0
  62. package/templates/product-system/packages/frontend/src/hooks/useGraph.ts +27 -0
  63. package/templates/product-system/packages/frontend/src/hooks/useKanban.ts +21 -0
  64. package/templates/product-system/packages/frontend/src/hooks/useProjects.ts +86 -0
  65. package/templates/product-system/packages/frontend/src/hooks/useTheme.ts +26 -0
  66. package/templates/product-system/packages/frontend/src/hooks/useToast.tsx +62 -0
  67. package/templates/product-system/packages/frontend/src/hooks/use_projects_logic.test.ts +61 -0
  68. package/templates/product-system/packages/frontend/src/main.tsx +12 -0
  69. package/templates/product-system/packages/frontend/src/pages/accept_invitation_page.tsx +167 -0
  70. package/templates/product-system/packages/frontend/src/pages/forgot_password_page.tsx +100 -0
  71. package/templates/product-system/packages/frontend/src/pages/register_page.tsx +137 -0
  72. package/templates/product-system/packages/frontend/src/pages/reset_password_page.tsx +146 -0
  73. package/templates/product-system/packages/frontend/src/routes/ProtectedRoute.tsx +12 -0
  74. package/templates/product-system/packages/frontend/src/routes/accept_invitation.tsx +14 -0
  75. package/templates/product-system/packages/frontend/src/routes/dashboard.tsx +221 -0
  76. package/templates/product-system/packages/frontend/src/routes/forgot_password.tsx +13 -0
  77. package/templates/product-system/packages/frontend/src/routes/login.tsx +14 -0
  78. package/templates/product-system/packages/frontend/src/routes/register.tsx +14 -0
  79. package/templates/product-system/packages/frontend/src/routes/reset_password.tsx +13 -0
  80. package/templates/product-system/packages/frontend/src/styles/index.css +3358 -0
  81. package/templates/product-system/packages/frontend/src/utils/auth_validation.test.ts +51 -0
  82. package/templates/product-system/packages/frontend/src/utils/auth_validation.ts +19 -0
  83. package/templates/product-system/packages/frontend/src/utils/login_validation.test.ts +61 -0
  84. package/templates/product-system/packages/frontend/src/utils/login_validation.ts +24 -0
  85. package/templates/product-system/packages/frontend/src/utils/logout.test.ts +63 -0
  86. package/templates/product-system/packages/frontend/src/utils/node_sizing.test.ts +62 -0
  87. package/templates/product-system/packages/frontend/src/utils/node_sizing.ts +24 -0
  88. package/templates/product-system/packages/frontend/src/utils/task_status.test.ts +53 -0
  89. package/templates/product-system/packages/frontend/src/utils/task_status.ts +14 -0
  90. package/templates/product-system/packages/frontend/tsconfig.json +21 -0
  91. package/templates/product-system/packages/frontend/vite.config.ts +20 -0
  92. package/templates/product-system/packages/shared/.env.example +3 -0
  93. package/templates/product-system/packages/shared/README.md +1 -0
  94. package/templates/product-system/packages/shared/db/migrate.ts +32 -0
  95. package/templates/product-system/packages/shared/db/migrations/0000_dashing_gorgon.sql +128 -0
  96. package/templates/product-system/packages/shared/db/migrations/meta/0000_snapshot.json +819 -0
  97. package/templates/product-system/packages/shared/db/migrations/meta/_journal.json +13 -0
  98. package/templates/product-system/packages/shared/db/schema.ts +137 -0
  99. package/templates/product-system/packages/shared/drizzle.config.js +14 -0
  100. package/templates/product-system/packages/shared/lib/claude-service.ts +215 -0
  101. package/templates/product-system/packages/shared/lib/coherence.ts +278 -0
  102. package/templates/product-system/packages/shared/lib/completeness.ts +30 -0
  103. package/templates/product-system/packages/shared/lib/constants.ts +327 -0
  104. package/templates/product-system/packages/shared/lib/db.ts +81 -0
  105. package/templates/product-system/packages/shared/lib/git_workflow.ts +110 -0
  106. package/templates/product-system/packages/shared/lib/graph.ts +186 -0
  107. package/templates/product-system/packages/shared/lib/kanban.ts +161 -0
  108. package/templates/product-system/packages/shared/lib/markdown.ts +205 -0
  109. package/templates/product-system/packages/shared/lib/pipeline-state-store.ts +124 -0
  110. package/templates/product-system/packages/shared/lib/pipeline.ts +489 -0
  111. package/templates/product-system/packages/shared/lib/prompt_builder.ts +170 -0
  112. package/templates/product-system/packages/shared/lib/relevance_search.ts +159 -0
  113. package/templates/product-system/packages/shared/lib/session.ts +152 -0
  114. package/templates/product-system/packages/shared/lib/validator.ts +117 -0
  115. package/templates/product-system/packages/shared/lib/work_summary_parser.ts +130 -0
  116. package/templates/product-system/packages/shared/package.json +30 -0
  117. package/templates/product-system/packages/shared/scripts/assign-project.ts +52 -0
  118. package/templates/product-system/packages/shared/tools/add_edge.ts +61 -0
  119. package/templates/product-system/packages/shared/tools/add_node.ts +101 -0
  120. package/templates/product-system/packages/shared/tools/end_session.ts +87 -0
  121. package/templates/product-system/packages/shared/tools/get_gaps.ts +87 -0
  122. package/templates/product-system/packages/shared/tools/get_kanban.ts +125 -0
  123. package/templates/product-system/packages/shared/tools/get_node.ts +78 -0
  124. package/templates/product-system/packages/shared/tools/get_status.ts +98 -0
  125. package/templates/product-system/packages/shared/tools/migrate_to_turso.ts +385 -0
  126. package/templates/product-system/packages/shared/tools/move_card.ts +143 -0
  127. package/templates/product-system/packages/shared/tools/rebuild_index.ts +77 -0
  128. package/templates/product-system/packages/shared/tools/remove_edge.ts +59 -0
  129. package/templates/product-system/packages/shared/tools/remove_node.ts +96 -0
  130. package/templates/product-system/packages/shared/tools/resolve_question.ts +75 -0
  131. package/templates/product-system/packages/shared/tools/search_nodes.ts +106 -0
  132. package/templates/product-system/packages/shared/tools/start_session.ts +144 -0
  133. package/templates/product-system/packages/shared/tools/update_node.ts +133 -0
  134. package/templates/product-system/packages/shared/tsconfig.json +24 -0
  135. package/templates/product-system/pnpm-workspace.yaml +2 -0
  136. package/templates/product-system/smoke_test.ts +219 -0
  137. package/templates/product-system/tests/coherence_review.test.ts +562 -0
  138. package/templates/product-system/tests/db_sqlite_fallback.test.ts +75 -0
  139. package/templates/product-system/tests/edge_type_color_coding.test.ts +147 -0
  140. package/templates/product-system/tests/emit-tool-use-events.test.ts +85 -0
  141. package/templates/product-system/tests/feature_kind.test.ts +139 -0
  142. package/templates/product-system/tests/gap_indicators.test.ts +199 -0
  143. package/templates/product-system/tests/graceful_init.test.ts +142 -0
  144. package/templates/product-system/tests/graph_legend.test.ts +314 -0
  145. package/templates/product-system/tests/graph_settings_sheet.test.ts +804 -0
  146. package/templates/product-system/tests/hide_defined_filter.test.ts +205 -0
  147. package/templates/product-system/tests/kanban.test.ts +529 -0
  148. package/templates/product-system/tests/neighborhood_focus.test.ts +132 -0
  149. package/templates/product-system/tests/node_search.test.ts +340 -0
  150. package/templates/product-system/tests/node_sizing.test.ts +170 -0
  151. package/templates/product-system/tests/node_type_toggle_filters.test.ts +285 -0
  152. package/templates/product-system/tests/node_type_visual_encoding.test.ts +103 -0
  153. package/templates/product-system/tests/pipeline-state-store.test.ts +268 -0
  154. package/templates/product-system/tests/pipeline-unit.test.ts +593 -0
  155. package/templates/product-system/tests/pipeline.test.ts +195 -0
  156. package/templates/product-system/tests/pipeline_stats_all_cards.test.ts +193 -0
  157. package/templates/product-system/tests/play_all.test.ts +296 -0
  158. package/templates/product-system/tests/qa_issue_sheet.test.ts +464 -0
  159. package/templates/product-system/tests/relevance_search.test.ts +186 -0
  160. package/templates/product-system/tests/search_reorder.test.ts +88 -0
  161. package/templates/product-system/tests/serve_ui.test.ts +281 -0
  162. package/templates/product-system/tests/serve_ui_drizzle.test.ts +114 -0
  163. package/templates/product-system/tests/session_context_recall.test.ts +135 -0
  164. package/templates/product-system/tests/side_panel.test.ts +345 -0
  165. package/templates/product-system/tests/spec_completeness_label.test.ts +69 -0
  166. package/templates/product-system/tests/url_routing_test.ts +122 -0
  167. package/templates/product-system/tests/user_login.test.ts +150 -0
  168. package/templates/product-system/tests/user_registration.test.ts +205 -0
  169. package/templates/product-system/tests/web_terminal.test.ts +572 -0
  170. package/templates/product-system/tests/work_summary.test.ts +211 -0
  171. package/templates/product-system/tests/zoom_pan.test.ts +43 -0
  172. package/templates/product-system/tsconfig.json +24 -0
  173. package/templates/skills/product-bootstrap/SKILL.md +312 -0
  174. package/templates/skills/product-code-reviewer/SKILL.md +147 -0
  175. package/templates/skills/product-debugger/SKILL.md +206 -0
  176. package/templates/skills/product-debugger/references/agent-browser.md +1156 -0
  177. package/templates/skills/product-developer/SKILL.md +182 -0
  178. package/templates/skills/product-interview/SKILL.md +220 -0
@@ -0,0 +1,562 @@
1
+ /**
2
+ * Tests for coherence review logic — prompt building, proposal parsing, sorting,
3
+ * conflict detection, dismissal matching, scope expansion, and UI renderer logic.
4
+ * Uses node:test built-in runner.
5
+ */
6
+ import { describe, it } from 'node:test';
7
+ import assert from 'node:assert/strict';
8
+
9
+ // --- Test data ---
10
+
11
+ const SAMPLE_GRAPH = {
12
+ version: '1.0',
13
+ project: { name: 'Test', description: '' },
14
+ nodes: [
15
+ { id: 'feat_001', type: 'feature', name: 'Node CRUD', status: 'defined', completeness: 1, created_at: '2025-01-01T00:00:00Z', updated_at: '2025-01-15T00:00:00Z' },
16
+ { id: 'feat_002', type: 'feature', name: 'Edge Management', status: 'defined', completeness: 1, created_at: '2025-01-01T00:00:00Z', updated_at: '2025-01-15T00:00:00Z' },
17
+ { id: 'comp_001', type: 'component', name: 'Graph Engine', status: 'defined', completeness: 1, created_at: '2025-01-01T00:00:00Z', updated_at: '2025-01-15T00:00:00Z' },
18
+ { id: 'feat_003', type: 'feature', name: 'New Feature', status: 'draft', completeness: 0.3, created_at: '2025-02-01T00:00:00Z', updated_at: '2025-02-10T00:00:00Z' },
19
+ { id: 'dec_001', type: 'decision', name: 'Use Turso', status: 'defined', completeness: 1, created_at: '2025-01-05T00:00:00Z', updated_at: '2025-01-05T00:00:00Z' },
20
+ ],
21
+ edges: [
22
+ { from: 'feat_001', relation: 'implemented_with', to: 'comp_001' },
23
+ { from: 'feat_002', relation: 'implemented_with', to: 'comp_001' },
24
+ { from: 'feat_003', relation: 'depends_on', to: 'feat_001' },
25
+ ],
26
+ };
27
+
28
+ const EMPTY_COHERENCE = {
29
+ last_review_timestamp: null,
30
+ review_status: 'idle',
31
+ partial_run: false,
32
+ proposals: [],
33
+ };
34
+
35
+ const COHERENCE_WITH_DISMISSED = {
36
+ last_review_timestamp: '2025-01-20T00:00:00Z',
37
+ review_status: 'idle',
38
+ partial_run: false,
39
+ proposals: [
40
+ {
41
+ id: 'abc123',
42
+ type: 'add_edge',
43
+ from_id: 'feat_001',
44
+ to_id: 'feat_002',
45
+ relation: 'depends_on',
46
+ reasoning: 'Both relate to graph ops',
47
+ confidence: 'medium',
48
+ status: 'dismissed',
49
+ created_at: '2025-01-20T00:00:00Z',
50
+ resolved_at: '2025-01-20T01:00:00Z',
51
+ },
52
+ ],
53
+ };
54
+
55
+ // --- Scope expansion tests ---
56
+
57
+ const expandScope = (scopeNodeIds, graph) => {
58
+ const expanded = new Set(scopeNodeIds);
59
+ for (const nodeId of scopeNodeIds) {
60
+ for (const edge of graph.edges) {
61
+ if (edge.from === nodeId) expanded.add(edge.to);
62
+ if (edge.to === nodeId) expanded.add(edge.from);
63
+ }
64
+ }
65
+ return [...expanded];
66
+ };
67
+
68
+ describe('Scope Expansion', () => {
69
+ it('includes 1-hop neighbors of scope nodes', () => {
70
+ const result = expandScope(['feat_003'], SAMPLE_GRAPH);
71
+ // feat_003 depends_on feat_001, so feat_001 should be included
72
+ assert.ok(result.includes('feat_003'));
73
+ assert.ok(result.includes('feat_001'));
74
+ assert.equal(result.length, 2);
75
+ });
76
+
77
+ it('includes all neighbors in both edge directions', () => {
78
+ const result = expandScope(['comp_001'], SAMPLE_GRAPH);
79
+ // comp_001 is target of feat_001 and feat_002 implemented_with edges
80
+ assert.ok(result.includes('comp_001'));
81
+ assert.ok(result.includes('feat_001'));
82
+ assert.ok(result.includes('feat_002'));
83
+ assert.equal(result.length, 3);
84
+ });
85
+
86
+ it('returns the same nodes if no edges connect', () => {
87
+ const result = expandScope(['dec_001'], SAMPLE_GRAPH);
88
+ assert.deepEqual(result, ['dec_001']);
89
+ });
90
+
91
+ it('deduplicates nodes', () => {
92
+ const result = expandScope(['feat_001', 'feat_002'], SAMPLE_GRAPH);
93
+ // Both connect to comp_001, should appear once
94
+ const unique = new Set(result);
95
+ assert.equal(result.length, unique.size);
96
+ });
97
+ });
98
+
99
+ // --- Prompt building tests ---
100
+
101
+ const buildCoherencePrompt = (graph, coherenceData) => {
102
+ const lastReview = coherenceData.last_review_timestamp;
103
+ const getDismissalKey = (p) => {
104
+ switch (p.type) {
105
+ case 'add_edge':
106
+ case 'remove_edge':
107
+ return `${p.type}:${p.from_id}:${p.relation}:${p.to_id}`;
108
+ case 'add_node':
109
+ return `${p.type}:${p.proposed_node_type}:${p.proposed_node_name}`;
110
+ case 'deprecate_node':
111
+ return `${p.type}:${p.target_node_id}`;
112
+ case 'update_description':
113
+ return `${p.type}:${p.target_node_id}`;
114
+ default:
115
+ return `${p.type}:${p.id}`;
116
+ }
117
+ };
118
+
119
+ const dismissedKeys = new Set(
120
+ coherenceData.proposals
121
+ .filter(p => p.status === 'dismissed')
122
+ .map(p => getDismissalKey(p))
123
+ );
124
+ const dismissedList = [...dismissedKeys];
125
+
126
+ const changedNodes = lastReview
127
+ ? graph.nodes.filter(n => n.updated_at > lastReview || n.created_at > lastReview)
128
+ : graph.nodes;
129
+ const changedIds = changedNodes.map(n => n.id);
130
+ const scopeNodeIds = lastReview ? expandScope(changedIds, graph) : changedIds;
131
+
132
+ const nodesSummary = graph.nodes.map(n =>
133
+ ` ${n.id} [${n.type}] "${n.name}" status=${n.status} completeness=${n.completeness}`
134
+ ).join('\n');
135
+
136
+ const edgesSummary = graph.edges.map(e =>
137
+ ` ${e.from} --${e.relation}--> ${e.to}`
138
+ ).join('\n');
139
+
140
+ let prompt = `You are a graph coherence reviewer`;
141
+ prompt += `\n\n## All Nodes\n${nodesSummary}\n\n`;
142
+ prompt += `## All Edges\n${edgesSummary}\n\n`;
143
+ prompt += `## Scope\n`;
144
+ prompt += scopeNodeIds.length > 0
145
+ ? `Focus on these nodes: ${scopeNodeIds.join(', ')}\n\n`
146
+ : `No previous review — analyze all nodes.\n\n`;
147
+
148
+ if (dismissedList.length > 0) {
149
+ prompt += `## Previously Dismissed\n`;
150
+ prompt += dismissedList.map(d => ` - ${d}`).join('\n') + '\n\n';
151
+ }
152
+
153
+ prompt += `## Five Proposal Types\n`;
154
+ prompt += `add_edge, add_node, remove_edge, deprecate_node, update_description\n`;
155
+
156
+ return prompt;
157
+ };
158
+
159
+ describe('Coherence Prompt Building', () => {
160
+ it('includes all nodes and edges in the prompt', () => {
161
+ const prompt = buildCoherencePrompt(SAMPLE_GRAPH, EMPTY_COHERENCE);
162
+ assert.ok(prompt.includes('feat_001'));
163
+ assert.ok(prompt.includes('feat_002'));
164
+ assert.ok(prompt.includes('comp_001'));
165
+ assert.ok(prompt.includes('feat_003'));
166
+ assert.ok(prompt.includes('feat_001 --implemented_with--> comp_001'));
167
+ });
168
+
169
+ it('scopes to all nodes when no last review timestamp', () => {
170
+ const prompt = buildCoherencePrompt(SAMPLE_GRAPH, EMPTY_COHERENCE);
171
+ assert.ok(prompt.includes('feat_001'));
172
+ assert.ok(prompt.includes('feat_002'));
173
+ assert.ok(prompt.includes('comp_001'));
174
+ assert.ok(prompt.includes('feat_003'));
175
+ assert.ok(prompt.includes('dec_001'));
176
+ });
177
+
178
+ it('scopes to recently modified nodes with 1-hop expansion', () => {
179
+ const prompt = buildCoherencePrompt(SAMPLE_GRAPH, COHERENCE_WITH_DISMISSED);
180
+ // feat_003 created after 2025-01-20, its 1-hop neighbor feat_001 should be in scope
181
+ assert.ok(prompt.includes('feat_003'));
182
+ assert.ok(prompt.includes('feat_001'));
183
+ });
184
+
185
+ it('includes dismissed proposals as exact-tuple keys', () => {
186
+ const prompt = buildCoherencePrompt(SAMPLE_GRAPH, COHERENCE_WITH_DISMISSED);
187
+ assert.ok(prompt.includes('Previously Dismissed'));
188
+ assert.ok(prompt.includes('add_edge:feat_001:depends_on:feat_002'));
189
+ });
190
+
191
+ it('omits dismissed section when no dismissed proposals exist', () => {
192
+ const prompt = buildCoherencePrompt(SAMPLE_GRAPH, EMPTY_COHERENCE);
193
+ assert.ok(!prompt.includes('Previously Dismissed'));
194
+ });
195
+
196
+ it('mentions all five proposal types', () => {
197
+ const prompt = buildCoherencePrompt(SAMPLE_GRAPH, EMPTY_COHERENCE);
198
+ assert.ok(prompt.includes('add_edge'));
199
+ assert.ok(prompt.includes('add_node'));
200
+ assert.ok(prompt.includes('remove_edge'));
201
+ assert.ok(prompt.includes('deprecate_node'));
202
+ assert.ok(prompt.includes('update_description'));
203
+ });
204
+ });
205
+
206
+ // --- JSON parsing tests ---
207
+
208
+ const parseClaudeOutput = (output) => {
209
+ let jsonStr = output.trim();
210
+ const fenceMatch = jsonStr.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
211
+ if (fenceMatch) {
212
+ jsonStr = fenceMatch[1].trim();
213
+ }
214
+ const arrayMatch = jsonStr.match(/\[[\s\S]*\]/);
215
+ if (arrayMatch) {
216
+ jsonStr = arrayMatch[0];
217
+ }
218
+ return JSON.parse(jsonStr);
219
+ };
220
+
221
+ describe('Claude Output Parsing', () => {
222
+ it('parses plain JSON array', () => {
223
+ const output = '[{"type":"add_edge","from_id":"feat_001","to_id":"feat_003","relation":"depends_on","confidence":"high","reasoning":"test"}]';
224
+ const result = parseClaudeOutput(output);
225
+ assert.equal(result.length, 1);
226
+ assert.equal(result[0].type, 'add_edge');
227
+ assert.equal(result[0].confidence, 'high');
228
+ });
229
+
230
+ it('parses JSON wrapped in markdown code fences', () => {
231
+ const output = '```json\n[{"type":"add_node","proposed_node_type":"design_pattern","proposed_node_name":"Test Pattern","confidence":"medium","reasoning":"shared"}]\n```';
232
+ const result = parseClaudeOutput(output);
233
+ assert.equal(result.length, 1);
234
+ assert.equal(result[0].type, 'add_node');
235
+ });
236
+
237
+ it('parses empty array', () => {
238
+ const result = parseClaudeOutput('[]');
239
+ assert.equal(result.length, 0);
240
+ });
241
+
242
+ it('parses JSON with surrounding text', () => {
243
+ const output = 'Here are my proposals:\n\n[{"type":"remove_edge","from_id":"a","to_id":"b","relation":"relates_to","confidence":"low","reasoning":"x"}]\n\nDone.';
244
+ const result = parseClaudeOutput(output);
245
+ assert.equal(result.length, 1);
246
+ assert.equal(result[0].type, 'remove_edge');
247
+ });
248
+
249
+ it('parses all five proposal types', () => {
250
+ const output = JSON.stringify([
251
+ { type: 'add_edge', from_id: 'a', to_id: 'b', relation: 'r', confidence: 'high', reasoning: 'x' },
252
+ { type: 'add_node', proposed_node_type: 't', proposed_node_name: 'n', confidence: 'medium', reasoning: 'y' },
253
+ { type: 'remove_edge', from_id: 'c', to_id: 'd', relation: 's', confidence: 'high', reasoning: 'z' },
254
+ { type: 'deprecate_node', target_node_id: 'e', confidence: 'low', reasoning: 'w' },
255
+ { type: 'update_description', target_node_id: 'f', proposed_description: 'new desc', confidence: 'medium', reasoning: 'v' },
256
+ ]);
257
+ const result = parseClaudeOutput(output);
258
+ assert.equal(result.length, 5);
259
+ assert.deepEqual(result.map(r => r.type), ['add_edge', 'add_node', 'remove_edge', 'deprecate_node', 'update_description']);
260
+ });
261
+ });
262
+
263
+ // --- Proposal type validation ---
264
+
265
+ describe('Proposal Data Structure', () => {
266
+ it('EMPTY_COHERENCE has correct shape', () => {
267
+ assert.equal(EMPTY_COHERENCE.last_review_timestamp, null);
268
+ assert.equal(EMPTY_COHERENCE.review_status, 'idle');
269
+ assert.equal(EMPTY_COHERENCE.partial_run, false);
270
+ assert.ok(Array.isArray(EMPTY_COHERENCE.proposals));
271
+ assert.equal(EMPTY_COHERENCE.proposals.length, 0);
272
+ });
273
+
274
+ it('proposals have required fields', () => {
275
+ const proposal = COHERENCE_WITH_DISMISSED.proposals[0];
276
+ assert.ok(proposal.id);
277
+ assert.ok(proposal.type);
278
+ assert.ok(proposal.status);
279
+ assert.ok(proposal.created_at);
280
+ assert.ok(proposal.confidence);
281
+ assert.ok(['add_edge', 'add_node', 'remove_edge', 'deprecate_node', 'update_description'].includes(proposal.type));
282
+ assert.ok(['pending', 'approved', 'dismissed'].includes(proposal.status));
283
+ });
284
+
285
+ it('add_edge proposals have from_id, to_id, and relation', () => {
286
+ const proposal = COHERENCE_WITH_DISMISSED.proposals[0];
287
+ assert.equal(proposal.type, 'add_edge');
288
+ assert.ok(proposal.from_id);
289
+ assert.ok(proposal.to_id);
290
+ assert.ok(proposal.relation);
291
+ });
292
+ });
293
+
294
+ // --- Sorting tests ---
295
+
296
+ const PROPOSAL_TYPE_ORDER = ['deprecate_node', 'remove_edge', 'update_description', 'add_edge', 'add_node'];
297
+
298
+ const sortProposals = (proposals) => {
299
+ return [...proposals].sort((a, b) => {
300
+ const aIdx = PROPOSAL_TYPE_ORDER.indexOf(a.type);
301
+ const bIdx = PROPOSAL_TYPE_ORDER.indexOf(b.type);
302
+ if (aIdx !== bIdx) return aIdx - bIdx;
303
+ if (a.batch_id && b.batch_id && a.batch_id === b.batch_id) return 0;
304
+ if (a.batch_id && !b.batch_id) return -1;
305
+ if (!a.batch_id && b.batch_id) return 1;
306
+ return 0;
307
+ });
308
+ };
309
+
310
+ describe('Proposal Sorting', () => {
311
+ it('sorts by type priority: deprecate first, add last', () => {
312
+ const proposals = [
313
+ { type: 'add_edge', id: '1' },
314
+ { type: 'deprecate_node', id: '2' },
315
+ { type: 'remove_edge', id: '3' },
316
+ { type: 'update_description', id: '4' },
317
+ { type: 'add_node', id: '5' },
318
+ ];
319
+ const sorted = sortProposals(proposals);
320
+ assert.deepEqual(sorted.map(p => p.type), [
321
+ 'deprecate_node', 'remove_edge', 'update_description', 'add_edge', 'add_node',
322
+ ]);
323
+ });
324
+
325
+ it('keeps batch members together within same type', () => {
326
+ const proposals = [
327
+ { type: 'add_edge', id: '1', batch_id: 'b1' },
328
+ { type: 'add_edge', id: '2' },
329
+ { type: 'add_edge', id: '3', batch_id: 'b1' },
330
+ ];
331
+ const sorted = sortProposals(proposals);
332
+ // Batched items come before unbatched
333
+ assert.equal(sorted[0].batch_id, 'b1');
334
+ assert.equal(sorted[1].batch_id, 'b1');
335
+ assert.equal(sorted[2].batch_id, undefined);
336
+ });
337
+ });
338
+
339
+ // --- Dismissal key tests ---
340
+
341
+ const getDismissalKey = (proposal) => {
342
+ switch (proposal.type) {
343
+ case 'add_edge':
344
+ case 'remove_edge':
345
+ return `${proposal.type}:${proposal.from_id}:${proposal.relation}:${proposal.to_id}`;
346
+ case 'add_node':
347
+ return `${proposal.type}:${proposal.proposed_node_type}:${proposal.proposed_node_name}`;
348
+ case 'deprecate_node':
349
+ return `${proposal.type}:${proposal.target_node_id}`;
350
+ case 'update_description':
351
+ return `${proposal.type}:${proposal.target_node_id}`;
352
+ default:
353
+ return `${proposal.type}:${proposal.id}`;
354
+ }
355
+ };
356
+
357
+ describe('Dismissal Key Generation', () => {
358
+ it('generates correct key for add_edge', () => {
359
+ const key = getDismissalKey({ type: 'add_edge', from_id: 'a', relation: 'r', to_id: 'b' });
360
+ assert.equal(key, 'add_edge:a:r:b');
361
+ });
362
+
363
+ it('generates correct key for remove_edge', () => {
364
+ const key = getDismissalKey({ type: 'remove_edge', from_id: 'x', relation: 'y', to_id: 'z' });
365
+ assert.equal(key, 'remove_edge:x:y:z');
366
+ });
367
+
368
+ it('generates correct key for add_node', () => {
369
+ const key = getDismissalKey({ type: 'add_node', proposed_node_type: 'decision', proposed_node_name: 'Test' });
370
+ assert.equal(key, 'add_node:decision:Test');
371
+ });
372
+
373
+ it('generates correct key for deprecate_node', () => {
374
+ const key = getDismissalKey({ type: 'deprecate_node', target_node_id: 'feat_099' });
375
+ assert.equal(key, 'deprecate_node:feat_099');
376
+ });
377
+
378
+ it('generates correct key for update_description', () => {
379
+ const key = getDismissalKey({ type: 'update_description', target_node_id: 'comp_001' });
380
+ assert.equal(key, 'update_description:comp_001');
381
+ });
382
+ });
383
+
384
+ // --- Conflict detection tests ---
385
+
386
+ const detectConflicts = (proposals) => {
387
+ const conflicts = new Map();
388
+ const pending = proposals.filter(p => p.status === 'pending');
389
+
390
+ const deprecatedNodeIds = new Set(
391
+ pending.filter(p => p.type === 'deprecate_node').map(p => p.target_node_id)
392
+ );
393
+
394
+ for (const p of pending) {
395
+ const pConflicts = new Set();
396
+
397
+ if (p.type === 'add_edge') {
398
+ if (deprecatedNodeIds.has(p.from_id) || deprecatedNodeIds.has(p.to_id)) {
399
+ const deprecator = pending.find(d =>
400
+ d.type === 'deprecate_node' &&
401
+ (d.target_node_id === p.from_id || d.target_node_id === p.to_id)
402
+ );
403
+ if (deprecator) pConflicts.add(deprecator.id);
404
+ }
405
+ }
406
+
407
+ if (p.type === 'remove_edge') {
408
+ const addDup = pending.find(a =>
409
+ a.type === 'add_edge' &&
410
+ a.from_id === p.from_id && a.relation === p.relation && a.to_id === p.to_id
411
+ );
412
+ if (addDup) pConflicts.add(addDup.id);
413
+ }
414
+
415
+ if (p.type === 'update_description' && deprecatedNodeIds.has(p.target_node_id)) {
416
+ const deprecator = pending.find(d =>
417
+ d.type === 'deprecate_node' && d.target_node_id === p.target_node_id
418
+ );
419
+ if (deprecator) pConflicts.add(deprecator.id);
420
+ }
421
+
422
+ if (pConflicts.size > 0) {
423
+ conflicts.set(p.id, pConflicts);
424
+ for (const cId of pConflicts) {
425
+ if (!conflicts.has(cId)) conflicts.set(cId, new Set());
426
+ conflicts.get(cId).add(p.id);
427
+ }
428
+ }
429
+ }
430
+
431
+ return conflicts;
432
+ };
433
+
434
+ describe('Conflict Detection', () => {
435
+ it('detects conflict between add_edge and deprecate_node', () => {
436
+ const proposals = [
437
+ { id: '1', type: 'add_edge', from_id: 'a', to_id: 'b', relation: 'r', status: 'pending' },
438
+ { id: '2', type: 'deprecate_node', target_node_id: 'b', status: 'pending' },
439
+ ];
440
+ const conflicts = detectConflicts(proposals);
441
+ assert.ok(conflicts.has('1'));
442
+ assert.ok(conflicts.get('1').has('2'));
443
+ assert.ok(conflicts.has('2'));
444
+ assert.ok(conflicts.get('2').has('1'));
445
+ });
446
+
447
+ it('detects conflict between remove_edge and add_edge for same tuple', () => {
448
+ const proposals = [
449
+ { id: '1', type: 'remove_edge', from_id: 'a', to_id: 'b', relation: 'r', status: 'pending' },
450
+ { id: '2', type: 'add_edge', from_id: 'a', to_id: 'b', relation: 'r', status: 'pending' },
451
+ ];
452
+ const conflicts = detectConflicts(proposals);
453
+ assert.ok(conflicts.has('1'));
454
+ assert.ok(conflicts.get('1').has('2'));
455
+ });
456
+
457
+ it('detects conflict between update_description and deprecate_node for same node', () => {
458
+ const proposals = [
459
+ { id: '1', type: 'update_description', target_node_id: 'x', status: 'pending' },
460
+ { id: '2', type: 'deprecate_node', target_node_id: 'x', status: 'pending' },
461
+ ];
462
+ const conflicts = detectConflicts(proposals);
463
+ assert.ok(conflicts.has('1'));
464
+ assert.ok(conflicts.get('1').has('2'));
465
+ });
466
+
467
+ it('returns empty map when no conflicts', () => {
468
+ const proposals = [
469
+ { id: '1', type: 'add_edge', from_id: 'a', to_id: 'b', relation: 'r', status: 'pending' },
470
+ { id: '2', type: 'add_edge', from_id: 'c', to_id: 'd', relation: 's', status: 'pending' },
471
+ ];
472
+ const conflicts = detectConflicts(proposals);
473
+ assert.equal(conflicts.size, 0);
474
+ });
475
+
476
+ it('ignores resolved proposals', () => {
477
+ const proposals = [
478
+ { id: '1', type: 'add_edge', from_id: 'a', to_id: 'b', relation: 'r', status: 'approved' },
479
+ { id: '2', type: 'deprecate_node', target_node_id: 'b', status: 'pending' },
480
+ ];
481
+ const conflicts = detectConflicts(proposals);
482
+ assert.equal(conflicts.size, 0);
483
+ });
484
+ });
485
+
486
+ // --- CoherenceRenderer logic tests ---
487
+
488
+ describe('CoherenceRenderer logic', () => {
489
+ it('escapeHtml escapes special characters', () => {
490
+ const escapeHtml = (str) => {
491
+ if (!str) return '';
492
+ return str
493
+ .replace(/&/g, '&')
494
+ .replace(/</g, '&lt;')
495
+ .replace(/>/g, '&gt;')
496
+ .replace(/"/g, '&quot;');
497
+ };
498
+
499
+ assert.equal(escapeHtml('<script>alert("xss")</script>'), '&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;');
500
+ assert.equal(escapeHtml(''), '');
501
+ assert.equal(escapeHtml(null), '');
502
+ assert.equal(escapeHtml('normal text'), 'normal text');
503
+ assert.equal(escapeHtml('a & b'), 'a &amp; b');
504
+ });
505
+
506
+ it('filters pending proposals correctly', () => {
507
+ const proposals = [
508
+ { id: '1', status: 'pending' },
509
+ { id: '2', status: 'approved' },
510
+ { id: '3', status: 'dismissed' },
511
+ { id: '4', status: 'pending' },
512
+ ];
513
+ const pending = proposals.filter(p => p.status === 'pending');
514
+ const resolved = proposals.filter(p => p.status !== 'pending');
515
+
516
+ assert.equal(pending.length, 2);
517
+ assert.equal(resolved.length, 2);
518
+ assert.deepEqual(pending.map(p => p.id), ['1', '4']);
519
+ });
520
+
521
+ it('finds nodes by id from graph data', () => {
522
+ const findNode = (nodeId) => SAMPLE_GRAPH.nodes.find(n => n.id === nodeId) || null;
523
+
524
+ assert.equal(findNode('feat_001').name, 'Node CRUD');
525
+ assert.equal(findNode('comp_001').name, 'Graph Engine');
526
+ assert.equal(findNode('nonexistent'), null);
527
+ });
528
+
529
+ it('groups proposals by batch correctly', () => {
530
+ const proposals = [
531
+ { id: '1', batch_id: 'b1', type: 'add_edge' },
532
+ { id: '2', batch_id: 'b1', type: 'add_edge' },
533
+ { id: '3', type: 'remove_edge' },
534
+ { id: '4', batch_id: 'b2', type: 'add_node' },
535
+ ];
536
+
537
+ const batched = new Map();
538
+ const unbatched = [];
539
+ for (const p of proposals) {
540
+ if (p.batch_id) {
541
+ if (!batched.has(p.batch_id)) batched.set(p.batch_id, []);
542
+ batched.get(p.batch_id).push(p);
543
+ } else {
544
+ unbatched.push(p);
545
+ }
546
+ }
547
+
548
+ // Only batches with 2+ items are real batches
549
+ const realBatches = new Map();
550
+ for (const [batchId, items] of batched) {
551
+ if (items.length >= 2) {
552
+ realBatches.set(batchId, items);
553
+ } else {
554
+ unbatched.push(...items);
555
+ }
556
+ }
557
+
558
+ assert.equal(realBatches.size, 1); // b1 has 2 items
559
+ assert.ok(realBatches.has('b1'));
560
+ assert.equal(unbatched.length, 2); // feat_003 and b2's single item
561
+ });
562
+ });
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Tests for the local SQLite fallback in db.ts (feat_095).
3
+ * Verifies the URL construction logic and connection selection.
4
+ */
5
+ import { describe, it } from 'node:test';
6
+ import assert from 'node:assert/strict';
7
+ import { join } from 'node:path';
8
+
9
+ describe('Local SQLite URL format', () => {
10
+ it('builds a file: URL from a data directory path', () => {
11
+ const projectRoot = '/home/user/myproject/product-system';
12
+ const dataDir = join(projectRoot, 'data');
13
+ const connectionUrl = `file:${join(dataDir, 'local.db')}`;
14
+
15
+ assert.ok(connectionUrl.startsWith('file:'));
16
+ assert.ok(connectionUrl.includes('data'));
17
+ assert.ok(connectionUrl.endsWith('local.db'));
18
+ });
19
+
20
+ it('uses Turso when both credentials are present', () => {
21
+ const url = 'libsql://test.turso.io';
22
+ const authToken = 'tok-abc';
23
+ assert.equal(Boolean(url && authToken), true);
24
+ });
25
+
26
+ it('falls back to local SQLite when both credentials are absent', () => {
27
+ const url = undefined;
28
+ const authToken = undefined;
29
+ assert.equal(Boolean(url && authToken), false);
30
+ });
31
+
32
+ it('falls back when only URL is set', () => {
33
+ const url = 'libsql://test.turso.io';
34
+ const authToken = undefined;
35
+ assert.equal(Boolean(url && authToken), false);
36
+ });
37
+
38
+ it('falls back when only token is set', () => {
39
+ const url = undefined;
40
+ const authToken = 'tok-abc';
41
+ assert.equal(Boolean(url && authToken), false);
42
+ });
43
+ });
44
+
45
+ describe('Local SQLite connection config', () => {
46
+ it('authToken is undefined for local SQLite', () => {
47
+ const tursoUrl = undefined;
48
+ const tursoAuthToken = undefined;
49
+
50
+ let connectionAuthToken: string | undefined;
51
+
52
+ if (tursoUrl && tursoAuthToken) {
53
+ connectionAuthToken = tursoAuthToken;
54
+ } else {
55
+ connectionAuthToken = undefined;
56
+ }
57
+
58
+ assert.equal(connectionAuthToken, undefined);
59
+ });
60
+
61
+ it('authToken is set for Turso connection', () => {
62
+ const tursoUrl = 'libsql://test.turso.io';
63
+ const tursoAuthToken = 'tok-abc';
64
+
65
+ let connectionAuthToken: string | undefined;
66
+
67
+ if (tursoUrl && tursoAuthToken) {
68
+ connectionAuthToken = tursoAuthToken;
69
+ } else {
70
+ connectionAuthToken = undefined;
71
+ }
72
+
73
+ assert.equal(connectionAuthToken, 'tok-abc');
74
+ });
75
+ });