opencode-conductor-cdd-plugin 1.0.0-beta.18 → 1.0.0-beta.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/prompts/cdd/setup.json +2 -2
- package/dist/prompts/cdd/setup.test.js +40 -118
- package/dist/prompts/cdd/setup.test.ts +40 -143
- package/dist/utils/codebaseAnalysis.d.ts +61 -0
- package/dist/utils/codebaseAnalysis.js +429 -0
- package/dist/utils/codebaseAnalysis.test.d.ts +1 -0
- package/dist/utils/codebaseAnalysis.test.js +556 -0
- package/dist/utils/documentGeneration.d.ts +97 -0
- package/dist/utils/documentGeneration.js +301 -0
- package/dist/utils/documentGeneration.test.d.ts +1 -0
- package/dist/utils/documentGeneration.test.js +380 -0
- package/dist/utils/interactiveMenu.d.ts +56 -0
- package/dist/utils/interactiveMenu.js +144 -0
- package/dist/utils/interactiveMenu.test.d.ts +1 -0
- package/dist/utils/interactiveMenu.test.js +231 -0
- package/dist/utils/interactiveSetup.d.ts +43 -0
- package/dist/utils/interactiveSetup.js +131 -0
- package/dist/utils/interactiveSetup.test.d.ts +1 -0
- package/dist/utils/interactiveSetup.test.js +124 -0
- package/dist/utils/projectMaturity.d.ts +53 -0
- package/dist/utils/projectMaturity.js +179 -0
- package/dist/utils/projectMaturity.test.d.ts +1 -0
- package/dist/utils/projectMaturity.test.js +298 -0
- package/dist/utils/questionGenerator.d.ts +51 -0
- package/dist/utils/questionGenerator.js +535 -0
- package/dist/utils/questionGenerator.test.d.ts +1 -0
- package/dist/utils/questionGenerator.test.js +328 -0
- package/dist/utils/setupIntegration.d.ts +72 -0
- package/dist/utils/setupIntegration.js +179 -0
- package/dist/utils/setupIntegration.test.d.ts +1 -0
- package/dist/utils/setupIntegration.test.js +344 -0
- package/package.json +1 -1
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { validateSelection, parseSelection } from './interactiveMenu.js';
|
|
4
|
+
// Constants
|
|
5
|
+
const MAX_QUESTIONS = 5;
|
|
6
|
+
const MAX_REVISION_ATTEMPTS = 3;
|
|
7
|
+
const STATE_FILE_PATH = 'conductor-cdd/setup_state.json';
|
|
8
|
+
const CHECKPOINT_MAP = {
|
|
9
|
+
product: '2.1_product_guide',
|
|
10
|
+
guidelines: '2.2_product_guidelines',
|
|
11
|
+
'tech-stack': '2.3_tech_stack',
|
|
12
|
+
styleguides: '2.4_code_styleguides',
|
|
13
|
+
workflow: '2.5_workflow',
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Present questions sequentially to the user
|
|
17
|
+
* Stops after maxQuestions or when option E is selected
|
|
18
|
+
*/
|
|
19
|
+
export async function presentQuestionsSequentially(questions, responder, options = {}) {
|
|
20
|
+
const { maxQuestions = MAX_QUESTIONS, customInputPrompt } = options;
|
|
21
|
+
const session = {
|
|
22
|
+
section: questions[0]?.section || 'product',
|
|
23
|
+
questionsAsked: [],
|
|
24
|
+
answers: [],
|
|
25
|
+
autoGenerateRequested: false,
|
|
26
|
+
startTime: new Date().toISOString(),
|
|
27
|
+
endTime: '',
|
|
28
|
+
};
|
|
29
|
+
for (let i = 0; i < Math.min(questions.length, maxQuestions); i++) {
|
|
30
|
+
const question = questions[i];
|
|
31
|
+
session.questionsAsked.push(question);
|
|
32
|
+
// Get user response
|
|
33
|
+
let selections;
|
|
34
|
+
let isValid = false;
|
|
35
|
+
// Keep asking until valid response
|
|
36
|
+
while (!isValid) {
|
|
37
|
+
selections = await responder(question, i + 1);
|
|
38
|
+
isValid = validateSelection(selections.join(','), question.type);
|
|
39
|
+
}
|
|
40
|
+
const parsedSelections = parseSelection(selections.join(','), question.type);
|
|
41
|
+
// Map selections to actual option text
|
|
42
|
+
const selectedOptions = parsedSelections.map(letter => {
|
|
43
|
+
const index = letter.charCodeAt(0) - 'A'.charCodeAt(0);
|
|
44
|
+
return question.options[index];
|
|
45
|
+
});
|
|
46
|
+
const answer = {
|
|
47
|
+
questionId: question.id,
|
|
48
|
+
selections: parsedSelections,
|
|
49
|
+
selectedOptions,
|
|
50
|
+
timestamp: new Date().toISOString(),
|
|
51
|
+
};
|
|
52
|
+
// Handle option D (custom input)
|
|
53
|
+
if (parsedSelections.includes('D') && customInputPrompt) {
|
|
54
|
+
answer.customText = await customInputPrompt(question);
|
|
55
|
+
}
|
|
56
|
+
session.answers.push(answer);
|
|
57
|
+
// Handle option E (auto-generate) - stop asking questions
|
|
58
|
+
if (parsedSelections.includes('E')) {
|
|
59
|
+
session.autoGenerateRequested = true;
|
|
60
|
+
session.autoGenerateAtQuestion = i;
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
session.endTime = new Date().toISOString();
|
|
65
|
+
return session;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Draft document content from user's selected answers
|
|
69
|
+
* CRITICAL: Only use selected options, ignore questions and unselected options
|
|
70
|
+
*/
|
|
71
|
+
export function draftDocumentFromAnswers(session, analysis) {
|
|
72
|
+
const sections = [];
|
|
73
|
+
let inferredFromContext = session.autoGenerateRequested;
|
|
74
|
+
// Build content from selected answers
|
|
75
|
+
for (const answer of session.answers) {
|
|
76
|
+
if (answer.customText) {
|
|
77
|
+
// Option D: Use custom text directly
|
|
78
|
+
sections.push(answer.customText);
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
// Options A, B, C: Use selected option text
|
|
82
|
+
for (const option of answer.selectedOptions) {
|
|
83
|
+
// Skip option D (Custom) and option E (Auto-generate)
|
|
84
|
+
if (!option.includes('custom') && !option.includes('Auto-generate')) {
|
|
85
|
+
sections.push(option);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// If auto-generate was requested, infer additional content from context
|
|
91
|
+
if (session.autoGenerateRequested) {
|
|
92
|
+
sections.push(...inferAdditionalContent(session, analysis));
|
|
93
|
+
}
|
|
94
|
+
// Generate document structure based on section type
|
|
95
|
+
const content = formatDocumentContent(session.section, sections, analysis);
|
|
96
|
+
return {
|
|
97
|
+
content,
|
|
98
|
+
inferredFromContext,
|
|
99
|
+
sectionsIncluded: sections,
|
|
100
|
+
wordCount: content.split(/\s+/).length,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Infer additional content when auto-generate is requested
|
|
105
|
+
*/
|
|
106
|
+
function inferAdditionalContent(session, analysis) {
|
|
107
|
+
const inferred = [];
|
|
108
|
+
// Use codebase analysis - check both languages and frameworks
|
|
109
|
+
const hasCodebaseData = Object.keys(analysis.languages).length > 0 ||
|
|
110
|
+
analysis.frameworks.frontend.length > 0 ||
|
|
111
|
+
analysis.frameworks.backend.length > 0;
|
|
112
|
+
if (hasCodebaseData) {
|
|
113
|
+
// Infer from detected technologies
|
|
114
|
+
const languages = Object.keys(analysis.languages);
|
|
115
|
+
if (languages.length > 0) {
|
|
116
|
+
inferred.push(`Technologies: ${languages.join(', ')}`);
|
|
117
|
+
}
|
|
118
|
+
if (analysis.frameworks.frontend.length > 0) {
|
|
119
|
+
inferred.push(`Frontend: ${analysis.frameworks.frontend.join(', ')}`);
|
|
120
|
+
}
|
|
121
|
+
if (analysis.frameworks.backend.length > 0) {
|
|
122
|
+
inferred.push(`Backend: ${analysis.frameworks.backend.join(', ')}`);
|
|
123
|
+
}
|
|
124
|
+
if (analysis.databases.length > 0) {
|
|
125
|
+
inferred.push(`Databases: ${analysis.databases.join(', ')}`);
|
|
126
|
+
}
|
|
127
|
+
if (analysis.architecture.length > 0 && !analysis.architecture.includes('unknown')) {
|
|
128
|
+
inferred.push(`Architecture: ${analysis.architecture.join(', ')}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
// Infer from previous answers in session
|
|
132
|
+
for (const answer of session.answers) {
|
|
133
|
+
for (const option of answer.selectedOptions) {
|
|
134
|
+
if (!option.includes('Auto-generate') && !option.includes('custom')) {
|
|
135
|
+
// Use selected options as context for inference
|
|
136
|
+
inferred.push(option);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return inferred;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Format document content based on section type
|
|
144
|
+
*/
|
|
145
|
+
function formatDocumentContent(section, contentSections, analysis) {
|
|
146
|
+
const lines = [];
|
|
147
|
+
// Ensure minimum content for greenfield projects
|
|
148
|
+
const hasContent = contentSections.length > 0;
|
|
149
|
+
if (!hasContent) {
|
|
150
|
+
contentSections.push('To be defined during project setup.');
|
|
151
|
+
}
|
|
152
|
+
// Section-specific formatting
|
|
153
|
+
switch (section) {
|
|
154
|
+
case 'product':
|
|
155
|
+
lines.push('# Product Guide', '');
|
|
156
|
+
lines.push('## Overview', '');
|
|
157
|
+
lines.push(...contentSections);
|
|
158
|
+
if (analysis.projectGoal) {
|
|
159
|
+
lines.push('', `## Project Goal`, '', analysis.projectGoal);
|
|
160
|
+
}
|
|
161
|
+
break;
|
|
162
|
+
case 'guidelines':
|
|
163
|
+
lines.push('# Product Guidelines', '');
|
|
164
|
+
lines.push('## Guidelines', '');
|
|
165
|
+
lines.push(...contentSections);
|
|
166
|
+
break;
|
|
167
|
+
case 'tech-stack':
|
|
168
|
+
lines.push('# Tech Stack', '');
|
|
169
|
+
lines.push('## Technologies', '');
|
|
170
|
+
lines.push(...contentSections);
|
|
171
|
+
break;
|
|
172
|
+
case 'styleguides':
|
|
173
|
+
lines.push('# Code Style Guides', '');
|
|
174
|
+
lines.push('## Style Guidelines', '');
|
|
175
|
+
lines.push(...contentSections);
|
|
176
|
+
break;
|
|
177
|
+
case 'workflow':
|
|
178
|
+
lines.push('# Workflow', '');
|
|
179
|
+
lines.push('## Development Workflow', '');
|
|
180
|
+
lines.push(...contentSections);
|
|
181
|
+
break;
|
|
182
|
+
default:
|
|
183
|
+
lines.push('# Document', '');
|
|
184
|
+
lines.push(...contentSections);
|
|
185
|
+
}
|
|
186
|
+
return lines.join('\n');
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Save document to file and update state
|
|
190
|
+
*/
|
|
191
|
+
export async function saveDocumentState(state) {
|
|
192
|
+
try {
|
|
193
|
+
// Ensure directory exists
|
|
194
|
+
const dir = path.dirname(state.filePath);
|
|
195
|
+
if (!fs.existsSync(dir)) {
|
|
196
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
197
|
+
}
|
|
198
|
+
// Save document to file
|
|
199
|
+
fs.writeFileSync(state.filePath, state.content, 'utf-8');
|
|
200
|
+
// Compute state file path from outputPath (e.g., /path/to/conductor-cdd/product.md → /path/to/conductor-cdd/setup_state.json)
|
|
201
|
+
const stateFilePath = path.join(path.dirname(state.filePath), 'setup_state.json');
|
|
202
|
+
// Update state file
|
|
203
|
+
let setupState = {};
|
|
204
|
+
if (fs.existsSync(stateFilePath)) {
|
|
205
|
+
const existingState = fs.readFileSync(stateFilePath, 'utf-8');
|
|
206
|
+
setupState = JSON.parse(existingState);
|
|
207
|
+
}
|
|
208
|
+
const previousCheckpoints = setupState.last_successful_step
|
|
209
|
+
? [setupState.last_successful_step]
|
|
210
|
+
: [];
|
|
211
|
+
setupState.last_successful_step = state.checkpoint;
|
|
212
|
+
setupState[`${state.section}_timestamp`] = state.timestamp || new Date().toISOString();
|
|
213
|
+
fs.writeFileSync(stateFilePath, JSON.stringify(setupState, null, 2), 'utf-8');
|
|
214
|
+
return {
|
|
215
|
+
success: true,
|
|
216
|
+
stateFile: stateFilePath,
|
|
217
|
+
checkpoint: state.checkpoint,
|
|
218
|
+
filePath: state.filePath,
|
|
219
|
+
previousCheckpoints,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
catch (error) {
|
|
223
|
+
return {
|
|
224
|
+
success: false,
|
|
225
|
+
error: error instanceof Error ? error.message : 'Unknown error',
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Orchestrate full document generation workflow
|
|
231
|
+
*/
|
|
232
|
+
export async function generateDocument(options) {
|
|
233
|
+
const { section, questions, analysis, responder, approvalFlow, outputPath, maxRevisions = MAX_REVISION_ATTEMPTS, customInputPrompt } = options;
|
|
234
|
+
try {
|
|
235
|
+
// Validate questions array
|
|
236
|
+
if (!questions || questions.length === 0) {
|
|
237
|
+
return {
|
|
238
|
+
success: false,
|
|
239
|
+
error: `No questions generated for section: ${section}`,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
// Step 1: Present questions sequentially
|
|
243
|
+
const session = await presentQuestionsSequentially(questions, responder, {
|
|
244
|
+
customInputPrompt,
|
|
245
|
+
});
|
|
246
|
+
// Step 2: Draft document from answers
|
|
247
|
+
let draft = draftDocumentFromAnswers(session, analysis);
|
|
248
|
+
let revisionCount = 0;
|
|
249
|
+
let approved = false;
|
|
250
|
+
let finalContent = draft.content;
|
|
251
|
+
// Step 3: Approval/revision loop
|
|
252
|
+
while (!approved && revisionCount < maxRevisions) {
|
|
253
|
+
const approvalResult = await approvalFlow(draft);
|
|
254
|
+
if (approvalResult.approved) {
|
|
255
|
+
approved = true;
|
|
256
|
+
finalContent = approvalResult.finalContent || draft.content;
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
// Revise based on guidance
|
|
260
|
+
revisionCount++;
|
|
261
|
+
if (revisionCount >= maxRevisions) {
|
|
262
|
+
return {
|
|
263
|
+
success: false,
|
|
264
|
+
error: `Max revision attempts (${maxRevisions}) exceeded`,
|
|
265
|
+
revisionCount,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
// Re-draft with revision guidance
|
|
269
|
+
// In real implementation, this would incorporate the revision guidance
|
|
270
|
+
draft = draftDocumentFromAnswers(session, analysis);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
// Step 4: Save document and update state
|
|
274
|
+
const checkpoint = CHECKPOINT_MAP[section];
|
|
275
|
+
const saveResult = await saveDocumentState({
|
|
276
|
+
section,
|
|
277
|
+
checkpoint,
|
|
278
|
+
content: finalContent,
|
|
279
|
+
filePath: outputPath,
|
|
280
|
+
timestamp: new Date().toISOString(),
|
|
281
|
+
});
|
|
282
|
+
if (!saveResult.success) {
|
|
283
|
+
return {
|
|
284
|
+
success: false,
|
|
285
|
+
error: `Failed to save document: ${saveResult.error}`,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
return {
|
|
289
|
+
success: true,
|
|
290
|
+
checkpoint,
|
|
291
|
+
autoGenerated: session.autoGenerateRequested,
|
|
292
|
+
revisionCount,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
catch (error) {
|
|
296
|
+
return {
|
|
297
|
+
success: false,
|
|
298
|
+
error: error instanceof Error ? error.message : 'Unknown error',
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { generateDocument, presentQuestionsSequentially, draftDocumentFromAnswers, saveDocumentState, } from './documentGeneration.js';
|
|
3
|
+
describe('documentGeneration', () => {
|
|
4
|
+
const mockAnalysis = {
|
|
5
|
+
languages: {
|
|
6
|
+
'TypeScript': 60,
|
|
7
|
+
'JavaScript': 40,
|
|
8
|
+
},
|
|
9
|
+
frameworks: {
|
|
10
|
+
frontend: ['React', 'Next.js'],
|
|
11
|
+
backend: ['Express'],
|
|
12
|
+
},
|
|
13
|
+
databases: ['PostgreSQL'],
|
|
14
|
+
manifests: [{
|
|
15
|
+
type: 'package.json',
|
|
16
|
+
path: '/package.json',
|
|
17
|
+
metadata: { name: 'test-project', version: '1.0.0' },
|
|
18
|
+
dependencies: { 'react': '^18.0.0' },
|
|
19
|
+
}],
|
|
20
|
+
architecture: ['MVC'],
|
|
21
|
+
projectGoal: 'E-commerce platform',
|
|
22
|
+
};
|
|
23
|
+
const mockQuestions = [
|
|
24
|
+
{
|
|
25
|
+
id: 'product-1',
|
|
26
|
+
text: 'What is the primary purpose?',
|
|
27
|
+
type: 'exclusive',
|
|
28
|
+
section: 'product',
|
|
29
|
+
options: ['E-commerce', 'Social media', 'Dashboard', 'Custom', 'Auto-generate'],
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
id: 'product-2',
|
|
33
|
+
text: 'Who are the target users?',
|
|
34
|
+
type: 'exclusive',
|
|
35
|
+
section: 'product',
|
|
36
|
+
options: ['Developers', 'End users', 'Businesses', 'Custom', 'Auto-generate'],
|
|
37
|
+
},
|
|
38
|
+
];
|
|
39
|
+
describe('presentQuestionsSequentially', () => {
|
|
40
|
+
it('should present questions one at a time up to max 5', async () => {
|
|
41
|
+
const questions = Array.from({ length: 7 }, (_, i) => ({
|
|
42
|
+
id: `q-${i}`,
|
|
43
|
+
text: `Question ${i}`,
|
|
44
|
+
type: 'exclusive',
|
|
45
|
+
section: 'product',
|
|
46
|
+
options: ['A', 'B', 'C', 'D', 'E'],
|
|
47
|
+
}));
|
|
48
|
+
const mockResponder = vi.fn()
|
|
49
|
+
.mockResolvedValueOnce(['A'])
|
|
50
|
+
.mockResolvedValueOnce(['B'])
|
|
51
|
+
.mockResolvedValueOnce(['C'])
|
|
52
|
+
.mockResolvedValueOnce(['A'])
|
|
53
|
+
.mockResolvedValueOnce(['B']);
|
|
54
|
+
const session = await presentQuestionsSequentially(questions, mockResponder, { maxQuestions: 5 });
|
|
55
|
+
expect(session.questionsAsked).toHaveLength(5);
|
|
56
|
+
expect(session.answers).toHaveLength(5);
|
|
57
|
+
expect(mockResponder).toHaveBeenCalledTimes(5);
|
|
58
|
+
});
|
|
59
|
+
it('should stop early if user selects option E (auto-generate)', async () => {
|
|
60
|
+
const mockResponder = vi.fn()
|
|
61
|
+
.mockResolvedValueOnce(['A'])
|
|
62
|
+
.mockResolvedValueOnce(['E']); // Option E selected
|
|
63
|
+
const session = await presentQuestionsSequentially(mockQuestions, mockResponder);
|
|
64
|
+
expect(session.questionsAsked).toHaveLength(2);
|
|
65
|
+
expect(session.answers).toHaveLength(2);
|
|
66
|
+
expect(session.autoGenerateRequested).toBe(true);
|
|
67
|
+
expect(session.autoGenerateAtQuestion).toBe(1); // 0-based index
|
|
68
|
+
});
|
|
69
|
+
it('should handle option D (custom input) by prompting for text', async () => {
|
|
70
|
+
const mockResponder = vi.fn()
|
|
71
|
+
.mockResolvedValueOnce(['D']); // Option D selected
|
|
72
|
+
const mockCustomInputPrompt = vi.fn()
|
|
73
|
+
.mockResolvedValueOnce('Custom purpose text');
|
|
74
|
+
const session = await presentQuestionsSequentially(mockQuestions.slice(0, 1), mockResponder, { customInputPrompt: mockCustomInputPrompt });
|
|
75
|
+
expect(session.answers[0].selections).toEqual(['D']);
|
|
76
|
+
expect(session.answers[0].customText).toBe('Custom purpose text');
|
|
77
|
+
expect(mockCustomInputPrompt).toHaveBeenCalledTimes(1);
|
|
78
|
+
});
|
|
79
|
+
it('should handle additive questions with multiple selections', async () => {
|
|
80
|
+
const additiveQuestion = {
|
|
81
|
+
id: 'features-1',
|
|
82
|
+
text: 'What features are needed?',
|
|
83
|
+
type: 'additive',
|
|
84
|
+
section: 'product',
|
|
85
|
+
options: ['Auth', 'Dashboard', 'API', 'Custom', 'Auto-generate'],
|
|
86
|
+
};
|
|
87
|
+
const mockResponder = vi.fn()
|
|
88
|
+
.mockResolvedValueOnce(['A', 'B', 'C']); // Multiple selections
|
|
89
|
+
const session = await presentQuestionsSequentially([additiveQuestion], mockResponder);
|
|
90
|
+
expect(session.answers[0].selections).toEqual(['A', 'B', 'C']);
|
|
91
|
+
expect(session.answers[0].selectedOptions).toEqual(['Auth', 'Dashboard', 'API']);
|
|
92
|
+
});
|
|
93
|
+
it('should reject invalid selections and re-prompt', async () => {
|
|
94
|
+
const mockResponder = vi.fn()
|
|
95
|
+
.mockResolvedValueOnce(['F']) // Invalid
|
|
96
|
+
.mockResolvedValueOnce(['A']); // Valid
|
|
97
|
+
const session = await presentQuestionsSequentially(mockQuestions.slice(0, 1), mockResponder);
|
|
98
|
+
expect(mockResponder).toHaveBeenCalledTimes(2);
|
|
99
|
+
expect(session.answers[0].selections).toEqual(['A']);
|
|
100
|
+
});
|
|
101
|
+
it('should track question history and timestamps', async () => {
|
|
102
|
+
const mockResponder = vi.fn()
|
|
103
|
+
.mockResolvedValueOnce(['A'])
|
|
104
|
+
.mockResolvedValueOnce(['B']);
|
|
105
|
+
const session = await presentQuestionsSequentially(mockQuestions, mockResponder);
|
|
106
|
+
expect(session.questionsAsked).toHaveLength(2);
|
|
107
|
+
expect(session.answers[0].timestamp).toBeDefined();
|
|
108
|
+
expect(session.answers[1].timestamp).toBeDefined();
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
describe('draftDocumentFromAnswers', () => {
|
|
112
|
+
it('should generate document content from selected answers only', () => {
|
|
113
|
+
const session = {
|
|
114
|
+
section: 'product',
|
|
115
|
+
questionsAsked: mockQuestions.slice(0, 2),
|
|
116
|
+
answers: [
|
|
117
|
+
{
|
|
118
|
+
questionId: 'product-1',
|
|
119
|
+
selections: ['A'],
|
|
120
|
+
selectedOptions: ['E-commerce'],
|
|
121
|
+
timestamp: new Date().toISOString(),
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
questionId: 'product-2',
|
|
125
|
+
selections: ['B'],
|
|
126
|
+
selectedOptions: ['End users'],
|
|
127
|
+
timestamp: new Date().toISOString(),
|
|
128
|
+
},
|
|
129
|
+
],
|
|
130
|
+
autoGenerateRequested: false,
|
|
131
|
+
startTime: new Date().toISOString(),
|
|
132
|
+
endTime: new Date().toISOString(),
|
|
133
|
+
};
|
|
134
|
+
const draft = draftDocumentFromAnswers(session, mockAnalysis);
|
|
135
|
+
expect(draft.content).toContain('E-commerce');
|
|
136
|
+
expect(draft.content).toContain('End users');
|
|
137
|
+
expect(draft.content).not.toContain('Social media'); // Unselected option
|
|
138
|
+
expect(draft.content).not.toContain('Developers'); // Unselected option
|
|
139
|
+
});
|
|
140
|
+
it('should ignore unselected options from questions', () => {
|
|
141
|
+
const session = {
|
|
142
|
+
section: 'product',
|
|
143
|
+
questionsAsked: mockQuestions.slice(0, 1),
|
|
144
|
+
answers: [
|
|
145
|
+
{
|
|
146
|
+
questionId: 'product-1',
|
|
147
|
+
selections: ['A'],
|
|
148
|
+
selectedOptions: ['E-commerce'],
|
|
149
|
+
timestamp: new Date().toISOString(),
|
|
150
|
+
},
|
|
151
|
+
],
|
|
152
|
+
autoGenerateRequested: false,
|
|
153
|
+
startTime: new Date().toISOString(),
|
|
154
|
+
endTime: new Date().toISOString(),
|
|
155
|
+
};
|
|
156
|
+
const draft = draftDocumentFromAnswers(session, mockAnalysis);
|
|
157
|
+
// Should NOT contain unselected options B, C
|
|
158
|
+
expect(draft.content).not.toContain('Social media');
|
|
159
|
+
expect(draft.content).not.toContain('Dashboard');
|
|
160
|
+
// Should NOT contain the option letters
|
|
161
|
+
expect(draft.content).not.toMatch(/\bA\)/);
|
|
162
|
+
expect(draft.content).not.toMatch(/\bB\)/);
|
|
163
|
+
expect(draft.content).not.toMatch(/\bC\)/);
|
|
164
|
+
});
|
|
165
|
+
it('should incorporate custom text from option D', () => {
|
|
166
|
+
const session = {
|
|
167
|
+
section: 'product',
|
|
168
|
+
questionsAsked: mockQuestions.slice(0, 1),
|
|
169
|
+
answers: [
|
|
170
|
+
{
|
|
171
|
+
questionId: 'product-1',
|
|
172
|
+
selections: ['D'],
|
|
173
|
+
selectedOptions: ['Custom'],
|
|
174
|
+
customText: 'A platform for managing IoT devices',
|
|
175
|
+
timestamp: new Date().toISOString(),
|
|
176
|
+
},
|
|
177
|
+
],
|
|
178
|
+
autoGenerateRequested: false,
|
|
179
|
+
startTime: new Date().toISOString(),
|
|
180
|
+
endTime: new Date().toISOString(),
|
|
181
|
+
};
|
|
182
|
+
const draft = draftDocumentFromAnswers(session, mockAnalysis);
|
|
183
|
+
expect(draft.content).toContain('A platform for managing IoT devices');
|
|
184
|
+
});
|
|
185
|
+
it('should infer additional content when auto-generate was requested', () => {
|
|
186
|
+
const session = {
|
|
187
|
+
section: 'product',
|
|
188
|
+
questionsAsked: mockQuestions.slice(0, 1),
|
|
189
|
+
answers: [
|
|
190
|
+
{
|
|
191
|
+
questionId: 'product-1',
|
|
192
|
+
selections: ['A'],
|
|
193
|
+
selectedOptions: ['E-commerce'],
|
|
194
|
+
timestamp: new Date().toISOString(),
|
|
195
|
+
},
|
|
196
|
+
],
|
|
197
|
+
autoGenerateRequested: true,
|
|
198
|
+
autoGenerateAtQuestion: 0,
|
|
199
|
+
startTime: new Date().toISOString(),
|
|
200
|
+
endTime: new Date().toISOString(),
|
|
201
|
+
};
|
|
202
|
+
const draft = draftDocumentFromAnswers(session, mockAnalysis);
|
|
203
|
+
expect(draft.content).toContain('E-commerce');
|
|
204
|
+
// Should infer additional sections based on context
|
|
205
|
+
expect(draft.content.length).toBeGreaterThan(100);
|
|
206
|
+
expect(draft.inferredFromContext).toBe(true);
|
|
207
|
+
});
|
|
208
|
+
it('should use codebase analysis to enhance brownfield documents', () => {
|
|
209
|
+
const session = {
|
|
210
|
+
section: 'tech-stack',
|
|
211
|
+
questionsAsked: [],
|
|
212
|
+
answers: [],
|
|
213
|
+
autoGenerateRequested: true,
|
|
214
|
+
startTime: new Date().toISOString(),
|
|
215
|
+
endTime: new Date().toISOString(),
|
|
216
|
+
};
|
|
217
|
+
const draft = draftDocumentFromAnswers(session, mockAnalysis);
|
|
218
|
+
expect(draft.content).toContain('TypeScript');
|
|
219
|
+
expect(draft.content).toContain('React');
|
|
220
|
+
expect(draft.content).toContain('Next.js');
|
|
221
|
+
expect(draft.content).toContain('PostgreSQL');
|
|
222
|
+
});
|
|
223
|
+
it('should generate comprehensive content for greenfield projects', () => {
|
|
224
|
+
const greenfieldAnalysis = {
|
|
225
|
+
languages: {},
|
|
226
|
+
frameworks: { frontend: [], backend: [] },
|
|
227
|
+
databases: [],
|
|
228
|
+
manifests: [],
|
|
229
|
+
architecture: [],
|
|
230
|
+
};
|
|
231
|
+
const session = {
|
|
232
|
+
section: 'product',
|
|
233
|
+
questionsAsked: mockQuestions.slice(0, 1),
|
|
234
|
+
answers: [
|
|
235
|
+
{
|
|
236
|
+
questionId: 'product-1',
|
|
237
|
+
selections: ['A'],
|
|
238
|
+
selectedOptions: ['E-commerce'],
|
|
239
|
+
timestamp: new Date().toISOString(),
|
|
240
|
+
},
|
|
241
|
+
],
|
|
242
|
+
autoGenerateRequested: false,
|
|
243
|
+
startTime: new Date().toISOString(),
|
|
244
|
+
endTime: new Date().toISOString(),
|
|
245
|
+
};
|
|
246
|
+
const draft = draftDocumentFromAnswers(session, greenfieldAnalysis);
|
|
247
|
+
expect(draft.content).toContain('E-commerce');
|
|
248
|
+
expect(draft.content.length).toBeGreaterThan(30);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
describe('saveDocumentState', () => {
|
|
252
|
+
it('should create state file with correct checkpoint structure', async () => {
|
|
253
|
+
const result = await saveDocumentState({
|
|
254
|
+
section: 'product',
|
|
255
|
+
checkpoint: '2.1_product_guide',
|
|
256
|
+
content: '# Product Guide\n\nContent here',
|
|
257
|
+
filePath: 'conductor-cdd/product.md',
|
|
258
|
+
});
|
|
259
|
+
expect(result.success).toBe(true);
|
|
260
|
+
expect(result.stateFile).toContain('setup_state.json');
|
|
261
|
+
expect(result.checkpoint).toBe('2.1_product_guide');
|
|
262
|
+
});
|
|
263
|
+
it('should update existing state file without overwriting other checkpoints', async () => {
|
|
264
|
+
// First save
|
|
265
|
+
await saveDocumentState({
|
|
266
|
+
section: 'product',
|
|
267
|
+
checkpoint: '2.1_product_guide',
|
|
268
|
+
content: 'Content 1',
|
|
269
|
+
filePath: 'conductor-cdd/product.md',
|
|
270
|
+
});
|
|
271
|
+
// Second save
|
|
272
|
+
const result = await saveDocumentState({
|
|
273
|
+
section: 'guidelines',
|
|
274
|
+
checkpoint: '2.2_product_guidelines',
|
|
275
|
+
content: 'Content 2',
|
|
276
|
+
filePath: 'conductor-cdd/guidelines.md',
|
|
277
|
+
});
|
|
278
|
+
expect(result.success).toBe(true);
|
|
279
|
+
expect(result.previousCheckpoints).toContain('2.1_product_guide');
|
|
280
|
+
});
|
|
281
|
+
it('should save document to correct file path', async () => {
|
|
282
|
+
const result = await saveDocumentState({
|
|
283
|
+
section: 'product',
|
|
284
|
+
checkpoint: '2.1_product_guide',
|
|
285
|
+
content: '# Product Guide\n\nTest content',
|
|
286
|
+
filePath: 'conductor-cdd/product.md',
|
|
287
|
+
});
|
|
288
|
+
expect(result.success).toBe(true);
|
|
289
|
+
expect(result.filePath).toContain('product.md');
|
|
290
|
+
});
|
|
291
|
+
it('should handle file system errors gracefully', async () => {
|
|
292
|
+
const result = await saveDocumentState({
|
|
293
|
+
section: 'product',
|
|
294
|
+
checkpoint: '2.1_product_guide',
|
|
295
|
+
content: 'Content',
|
|
296
|
+
filePath: '/invalid/path/that/does/not/exist/file.md',
|
|
297
|
+
});
|
|
298
|
+
expect(result.success).toBe(false);
|
|
299
|
+
expect(result.error).toBeDefined();
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
describe('generateDocument', () => {
|
|
303
|
+
it('should orchestrate full document generation workflow', async () => {
|
|
304
|
+
const mockResponder = vi.fn()
|
|
305
|
+
.mockResolvedValueOnce(['A'])
|
|
306
|
+
.mockResolvedValueOnce(['B']);
|
|
307
|
+
const mockApprovalFlow = vi.fn()
|
|
308
|
+
.mockResolvedValueOnce({ approved: true, finalContent: '# Final content' });
|
|
309
|
+
const options = {
|
|
310
|
+
section: 'product',
|
|
311
|
+
questions: mockQuestions,
|
|
312
|
+
analysis: mockAnalysis,
|
|
313
|
+
responder: mockResponder,
|
|
314
|
+
approvalFlow: mockApprovalFlow,
|
|
315
|
+
outputPath: 'conductor-cdd/product.md',
|
|
316
|
+
};
|
|
317
|
+
const result = await generateDocument(options);
|
|
318
|
+
expect(result.success).toBe(true);
|
|
319
|
+
expect(result.checkpoint).toBe('2.1_product_guide');
|
|
320
|
+
expect(mockResponder).toHaveBeenCalled();
|
|
321
|
+
expect(mockApprovalFlow).toHaveBeenCalled();
|
|
322
|
+
});
|
|
323
|
+
it('should handle auto-generate early exit', async () => {
|
|
324
|
+
const mockResponder = vi.fn()
|
|
325
|
+
.mockResolvedValueOnce(['E']); // Auto-generate immediately
|
|
326
|
+
const mockApprovalFlow = vi.fn()
|
|
327
|
+
.mockResolvedValueOnce({ approved: true, finalContent: '# Auto-generated content' });
|
|
328
|
+
const options = {
|
|
329
|
+
section: 'product',
|
|
330
|
+
questions: mockQuestions,
|
|
331
|
+
analysis: mockAnalysis,
|
|
332
|
+
responder: mockResponder,
|
|
333
|
+
approvalFlow: mockApprovalFlow,
|
|
334
|
+
outputPath: 'conductor-cdd/product.md',
|
|
335
|
+
};
|
|
336
|
+
const result = await generateDocument(options);
|
|
337
|
+
expect(result.success).toBe(true);
|
|
338
|
+
expect(result.autoGenerated).toBe(true);
|
|
339
|
+
expect(mockResponder).toHaveBeenCalledTimes(1); // Stopped after E
|
|
340
|
+
});
|
|
341
|
+
it('should handle approval rejection and revision loop', async () => {
|
|
342
|
+
const mockResponder = vi.fn()
|
|
343
|
+
.mockResolvedValueOnce(['A']);
|
|
344
|
+
const mockApprovalFlow = vi.fn()
|
|
345
|
+
.mockResolvedValueOnce({ approved: false, revisionGuidance: 'Add more details' })
|
|
346
|
+
.mockResolvedValueOnce({ approved: true, finalContent: '# Revised content' });
|
|
347
|
+
const options = {
|
|
348
|
+
section: 'product',
|
|
349
|
+
questions: mockQuestions.slice(0, 1),
|
|
350
|
+
analysis: mockAnalysis,
|
|
351
|
+
responder: mockResponder,
|
|
352
|
+
approvalFlow: mockApprovalFlow,
|
|
353
|
+
outputPath: 'conductor-cdd/product.md',
|
|
354
|
+
};
|
|
355
|
+
const result = await generateDocument(options);
|
|
356
|
+
expect(result.success).toBe(true);
|
|
357
|
+
expect(result.revisionCount).toBe(1);
|
|
358
|
+
expect(mockApprovalFlow).toHaveBeenCalledTimes(2);
|
|
359
|
+
});
|
|
360
|
+
it('should fail after max revision attempts', async () => {
|
|
361
|
+
const mockResponder = vi.fn()
|
|
362
|
+
.mockResolvedValueOnce(['A']);
|
|
363
|
+
const mockApprovalFlow = vi.fn()
|
|
364
|
+
.mockResolvedValue({ approved: false, revisionGuidance: 'Still not good' });
|
|
365
|
+
const options = {
|
|
366
|
+
section: 'product',
|
|
367
|
+
questions: mockQuestions.slice(0, 1),
|
|
368
|
+
analysis: mockAnalysis,
|
|
369
|
+
responder: mockResponder,
|
|
370
|
+
approvalFlow: mockApprovalFlow,
|
|
371
|
+
outputPath: 'conductor-cdd/product.md',
|
|
372
|
+
maxRevisions: 3,
|
|
373
|
+
};
|
|
374
|
+
const result = await generateDocument(options);
|
|
375
|
+
expect(result.success).toBe(false);
|
|
376
|
+
expect(result.error).toContain('revision');
|
|
377
|
+
expect(mockApprovalFlow).toHaveBeenCalledTimes(3);
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
});
|