ai-sdlc 0.1.0-alpha.1
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/LICENSE +21 -0
- package/README.md +847 -0
- package/dist/agents/implementation.d.ts +11 -0
- package/dist/agents/implementation.d.ts.map +1 -0
- package/dist/agents/implementation.js +123 -0
- package/dist/agents/implementation.js.map +1 -0
- package/dist/agents/index.d.ts +7 -0
- package/dist/agents/index.d.ts.map +1 -0
- package/dist/agents/index.js +8 -0
- package/dist/agents/index.js.map +1 -0
- package/dist/agents/planning.d.ts +9 -0
- package/dist/agents/planning.d.ts.map +1 -0
- package/dist/agents/planning.js +84 -0
- package/dist/agents/planning.js.map +1 -0
- package/dist/agents/refinement.d.ts +10 -0
- package/dist/agents/refinement.d.ts.map +1 -0
- package/dist/agents/refinement.js +98 -0
- package/dist/agents/refinement.js.map +1 -0
- package/dist/agents/research.d.ts +16 -0
- package/dist/agents/research.d.ts.map +1 -0
- package/dist/agents/research.js +141 -0
- package/dist/agents/research.js.map +1 -0
- package/dist/agents/review.d.ts +24 -0
- package/dist/agents/review.d.ts.map +1 -0
- package/dist/agents/review.js +740 -0
- package/dist/agents/review.js.map +1 -0
- package/dist/agents/rework.d.ts +17 -0
- package/dist/agents/rework.d.ts.map +1 -0
- package/dist/agents/rework.js +139 -0
- package/dist/agents/rework.js.map +1 -0
- package/dist/agents/state-assessor.d.ts +21 -0
- package/dist/agents/state-assessor.d.ts.map +1 -0
- package/dist/agents/state-assessor.js +29 -0
- package/dist/agents/state-assessor.js.map +1 -0
- package/dist/cli/commands.d.ts +87 -0
- package/dist/cli/commands.d.ts.map +1 -0
- package/dist/cli/commands.js +1183 -0
- package/dist/cli/commands.js.map +1 -0
- package/dist/cli/formatting.d.ts +68 -0
- package/dist/cli/formatting.d.ts.map +1 -0
- package/dist/cli/formatting.js +194 -0
- package/dist/cli/formatting.js.map +1 -0
- package/dist/cli/runner.d.ts +57 -0
- package/dist/cli/runner.d.ts.map +1 -0
- package/dist/cli/runner.js +272 -0
- package/dist/cli/runner.js.map +1 -0
- package/dist/cli/story-utils.d.ts +19 -0
- package/dist/cli/story-utils.d.ts.map +1 -0
- package/dist/cli/story-utils.js +44 -0
- package/dist/cli/story-utils.js.map +1 -0
- package/dist/cli/table-renderer.d.ts +22 -0
- package/dist/cli/table-renderer.d.ts.map +1 -0
- package/dist/cli/table-renderer.js +159 -0
- package/dist/cli/table-renderer.js.map +1 -0
- package/dist/core/auth.d.ts +39 -0
- package/dist/core/auth.d.ts.map +1 -0
- package/dist/core/auth.js +128 -0
- package/dist/core/auth.js.map +1 -0
- package/dist/core/client.d.ts +73 -0
- package/dist/core/client.d.ts.map +1 -0
- package/dist/core/client.js +140 -0
- package/dist/core/client.js.map +1 -0
- package/dist/core/config.d.ts +48 -0
- package/dist/core/config.d.ts.map +1 -0
- package/dist/core/config.js +330 -0
- package/dist/core/config.js.map +1 -0
- package/dist/core/kanban.d.ts +34 -0
- package/dist/core/kanban.d.ts.map +1 -0
- package/dist/core/kanban.js +253 -0
- package/dist/core/kanban.js.map +1 -0
- package/dist/core/story.d.ts +91 -0
- package/dist/core/story.d.ts.map +1 -0
- package/dist/core/story.js +349 -0
- package/dist/core/story.js.map +1 -0
- package/dist/core/theme.d.ts +17 -0
- package/dist/core/theme.d.ts.map +1 -0
- package/dist/core/theme.js +136 -0
- package/dist/core/theme.js.map +1 -0
- package/dist/core/workflow-state.d.ts +56 -0
- package/dist/core/workflow-state.d.ts.map +1 -0
- package/dist/core/workflow-state.js +162 -0
- package/dist/core/workflow-state.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +103 -0
- package/dist/index.js.map +1 -0
- package/dist/types/index.d.ts +228 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +38 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/workflow-state.d.ts +54 -0
- package/dist/types/workflow-state.d.ts.map +1 -0
- package/dist/types/workflow-state.js +5 -0
- package/dist/types/workflow-state.js.map +1 -0
- package/package.json +71 -0
- package/templates/story.md +35 -0
|
@@ -0,0 +1,740 @@
|
|
|
1
|
+
import { execSync, spawn } from 'child_process';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { parseStory, moveStory, appendToSection, updateStoryField, isAtMaxRetries, appendReviewHistory, snapshotMaxRetries, getEffectiveMaxRetries } from '../core/story.js';
|
|
6
|
+
import { runAgentQuery } from '../core/client.js';
|
|
7
|
+
import { loadConfig, DEFAULT_TIMEOUTS } from '../core/config.js';
|
|
8
|
+
import { ReviewDecision, ReviewSeverity } from '../types/index.js';
|
|
9
|
+
/**
|
|
10
|
+
* Security: Validate Git branch name to prevent command injection
|
|
11
|
+
* Only allows alphanumeric characters, hyphens, underscores, and forward slashes
|
|
12
|
+
*/
|
|
13
|
+
function validateGitBranchName(branchName) {
|
|
14
|
+
return /^[a-zA-Z0-9/_-]+$/.test(branchName);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Security: Escape shell arguments for safe use in commands
|
|
18
|
+
* For use with execSync when shell execution is required
|
|
19
|
+
*/
|
|
20
|
+
function escapeShellArg(arg) {
|
|
21
|
+
// Replace single quotes with '\'' and wrap in single quotes
|
|
22
|
+
return `'${arg.replace(/'/g, "'\\''")}'`;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Security: Validate and normalize working directory path
|
|
26
|
+
* Prevents path traversal attacks
|
|
27
|
+
*/
|
|
28
|
+
function validateWorkingDirectory(workingDir) {
|
|
29
|
+
// Normalize the path
|
|
30
|
+
const normalized = path.resolve(workingDir);
|
|
31
|
+
// Check if it's an absolute path
|
|
32
|
+
if (!path.isAbsolute(normalized)) {
|
|
33
|
+
throw new Error(`Invalid working directory: must be absolute path (got: ${workingDir})`);
|
|
34
|
+
}
|
|
35
|
+
// Check for path traversal patterns
|
|
36
|
+
if (workingDir.includes('../') || workingDir.includes('..\\')) {
|
|
37
|
+
throw new Error(`Invalid working directory: path traversal detected (${workingDir})`);
|
|
38
|
+
}
|
|
39
|
+
// Verify directory exists
|
|
40
|
+
if (!fs.existsSync(normalized)) {
|
|
41
|
+
throw new Error(`Invalid working directory: does not exist (${normalized})`);
|
|
42
|
+
}
|
|
43
|
+
// Verify it's actually a directory
|
|
44
|
+
if (!fs.statSync(normalized).isDirectory()) {
|
|
45
|
+
throw new Error(`Invalid working directory: not a directory (${normalized})`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Security: Sanitize error messages to prevent information leakage
|
|
50
|
+
* Removes absolute paths, environment details, and stack traces
|
|
51
|
+
*/
|
|
52
|
+
function sanitizeErrorMessage(message, workingDir) {
|
|
53
|
+
let sanitized = message;
|
|
54
|
+
// Replace absolute paths with [PROJECT_ROOT]
|
|
55
|
+
const normalizedWorkingDir = path.resolve(workingDir);
|
|
56
|
+
sanitized = sanitized.replace(new RegExp(normalizedWorkingDir, 'g'), '[PROJECT_ROOT]');
|
|
57
|
+
// Remove home directory paths
|
|
58
|
+
if (process.env.HOME) {
|
|
59
|
+
sanitized = sanitized.replace(new RegExp(process.env.HOME, 'g'), '~');
|
|
60
|
+
}
|
|
61
|
+
// Strip stack traces (keep only first line of error)
|
|
62
|
+
const lines = sanitized.split('\n');
|
|
63
|
+
if (lines.length > 3) {
|
|
64
|
+
sanitized = lines.slice(0, 3).join('\n') + '\n... (stack trace removed)';
|
|
65
|
+
}
|
|
66
|
+
return sanitized;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Security: Sanitize command output before display
|
|
70
|
+
* Strips ANSI codes, control characters, and potential secrets
|
|
71
|
+
*/
|
|
72
|
+
function sanitizeCommandOutput(output) {
|
|
73
|
+
let sanitized = output;
|
|
74
|
+
// Strip ANSI escape codes
|
|
75
|
+
sanitized = sanitized.replace(/\x1b\[[0-9;]*m/g, '');
|
|
76
|
+
// Strip other control characters except newlines and tabs
|
|
77
|
+
sanitized = sanitized.replace(/[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F]/g, '');
|
|
78
|
+
// Redact potential secrets (basic patterns)
|
|
79
|
+
// API keys: long alphanumeric strings after key= or token=
|
|
80
|
+
sanitized = sanitized.replace(/(api[_-]?key|token|password|secret)[\s=:]+[a-zA-Z0-9_-]{20,}/gi, '$1=[REDACTED]');
|
|
81
|
+
return sanitized;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Security: Zod schema for validating LLM review responses
|
|
85
|
+
* Prevents malicious or malformed JSON from causing issues
|
|
86
|
+
*/
|
|
87
|
+
const ReviewIssueSchema = z.object({
|
|
88
|
+
severity: z.enum(['blocker', 'critical', 'major', 'minor']),
|
|
89
|
+
category: z.string().max(100),
|
|
90
|
+
description: z.string().max(5000),
|
|
91
|
+
file: z.string().optional(),
|
|
92
|
+
line: z.number().int().positive().optional(),
|
|
93
|
+
suggestedFix: z.string().max(2000).optional(),
|
|
94
|
+
});
|
|
95
|
+
const ReviewResponseSchema = z.object({
|
|
96
|
+
passed: z.boolean(),
|
|
97
|
+
issues: z.array(ReviewIssueSchema),
|
|
98
|
+
});
|
|
99
|
+
/**
|
|
100
|
+
* Maximum size for test output before truncation (10KB)
|
|
101
|
+
*/
|
|
102
|
+
const MAX_TEST_OUTPUT_SIZE = 10000;
|
|
103
|
+
/**
|
|
104
|
+
* Run a command asynchronously with timeout and progress updates
|
|
105
|
+
*/
|
|
106
|
+
async function runCommandAsync(command, workingDir, timeout, onProgress) {
|
|
107
|
+
return new Promise((resolve) => {
|
|
108
|
+
const outputChunks = [];
|
|
109
|
+
let killed = false;
|
|
110
|
+
// Parse command into executable and args (simple split, handles most cases)
|
|
111
|
+
const parts = command.match(/(?:[^\s"]+|"[^"]*")+/g) || [command];
|
|
112
|
+
const executable = parts[0];
|
|
113
|
+
const args = parts.slice(1).map(arg => arg.replace(/^"|"$/g, ''));
|
|
114
|
+
// Security: Use spawn without shell to prevent command injection
|
|
115
|
+
// Commands must be parseable as: executable + space-separated args
|
|
116
|
+
const child = spawn(executable, args, {
|
|
117
|
+
cwd: workingDir,
|
|
118
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
119
|
+
});
|
|
120
|
+
const timeoutId = setTimeout(() => {
|
|
121
|
+
killed = true;
|
|
122
|
+
child.kill('SIGTERM');
|
|
123
|
+
// Force kill after 5 seconds if SIGTERM didn't work
|
|
124
|
+
setTimeout(() => child.kill('SIGKILL'), 5000);
|
|
125
|
+
}, timeout);
|
|
126
|
+
child.stdout?.on('data', (data) => {
|
|
127
|
+
const text = data.toString();
|
|
128
|
+
outputChunks.push(text);
|
|
129
|
+
onProgress?.(text);
|
|
130
|
+
});
|
|
131
|
+
child.stderr?.on('data', (data) => {
|
|
132
|
+
const text = data.toString();
|
|
133
|
+
outputChunks.push(text);
|
|
134
|
+
onProgress?.(text);
|
|
135
|
+
});
|
|
136
|
+
child.on('close', (code) => {
|
|
137
|
+
clearTimeout(timeoutId);
|
|
138
|
+
const output = outputChunks.join('');
|
|
139
|
+
if (killed) {
|
|
140
|
+
resolve({
|
|
141
|
+
success: false,
|
|
142
|
+
output: output + `\n[Command timed out after ${Math.round(timeout / 1000)} seconds]`,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
resolve({
|
|
147
|
+
success: code === 0,
|
|
148
|
+
output,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
child.on('error', (error) => {
|
|
153
|
+
clearTimeout(timeoutId);
|
|
154
|
+
const sanitizedError = sanitizeErrorMessage(error.message, workingDir);
|
|
155
|
+
resolve({
|
|
156
|
+
success: false,
|
|
157
|
+
output: outputChunks.join('') + `\n[Command error: ${sanitizedError}]`,
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Run build and test commands before review (async version with progress)
|
|
164
|
+
* Returns structured results that can be included in review context
|
|
165
|
+
*/
|
|
166
|
+
async function runVerificationAsync(workingDir, config, onProgress) {
|
|
167
|
+
const result = {
|
|
168
|
+
buildPassed: true,
|
|
169
|
+
buildOutput: '',
|
|
170
|
+
testsPassed: true,
|
|
171
|
+
testsOutput: '',
|
|
172
|
+
};
|
|
173
|
+
const buildTimeout = config.timeouts?.buildTimeout ?? DEFAULT_TIMEOUTS.buildTimeout;
|
|
174
|
+
const testTimeout = config.timeouts?.testTimeout ?? DEFAULT_TIMEOUTS.testTimeout;
|
|
175
|
+
// Run build command if configured
|
|
176
|
+
if (config.buildCommand) {
|
|
177
|
+
onProgress?.('build', 'starting', config.buildCommand);
|
|
178
|
+
const buildResult = await runCommandAsync(config.buildCommand, workingDir, buildTimeout, (output) => onProgress?.('build', 'running', output));
|
|
179
|
+
result.buildPassed = buildResult.success;
|
|
180
|
+
result.buildOutput = buildResult.output;
|
|
181
|
+
onProgress?.('build', buildResult.success ? 'passed' : 'failed');
|
|
182
|
+
}
|
|
183
|
+
// Run test command if configured
|
|
184
|
+
if (config.testCommand) {
|
|
185
|
+
onProgress?.('test', 'starting', config.testCommand);
|
|
186
|
+
const testResult = await runCommandAsync(config.testCommand, workingDir, testTimeout, (output) => onProgress?.('test', 'running', output));
|
|
187
|
+
result.testsPassed = testResult.success;
|
|
188
|
+
result.testsOutput = testResult.output;
|
|
189
|
+
onProgress?.('test', testResult.success ? 'passed' : 'failed');
|
|
190
|
+
}
|
|
191
|
+
return result;
|
|
192
|
+
}
|
|
193
|
+
const REVIEW_OUTPUT_FORMAT = `
|
|
194
|
+
Output your review as a JSON object with this structure:
|
|
195
|
+
{
|
|
196
|
+
"passed": true/false,
|
|
197
|
+
"issues": [
|
|
198
|
+
{
|
|
199
|
+
"severity": "blocker" | "critical" | "major" | "minor",
|
|
200
|
+
"category": "code_quality" | "security" | "requirements" | "testing" | etc,
|
|
201
|
+
"description": "Detailed description of the issue",
|
|
202
|
+
"file": "path/to/file.ts" (if applicable),
|
|
203
|
+
"line": 42 (if applicable),
|
|
204
|
+
"suggestedFix": "How to fix this issue"
|
|
205
|
+
}
|
|
206
|
+
]
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
Severity guidelines:
|
|
210
|
+
- blocker: Must be fixed before merging (security holes, broken functionality)
|
|
211
|
+
- critical: Should be fixed before merging (major bugs, poor practices)
|
|
212
|
+
- major: Should be addressed soon (code quality, maintainability)
|
|
213
|
+
- minor: Nice to have improvements (style, optimizations)
|
|
214
|
+
|
|
215
|
+
If no issues found, return: {"passed": true, "issues": []}
|
|
216
|
+
`;
|
|
217
|
+
const CODE_REVIEW_PROMPT = `You are a senior code reviewer. Review the implementation for:
|
|
218
|
+
1. Code quality and maintainability
|
|
219
|
+
2. Following best practices
|
|
220
|
+
3. Potential bugs or issues
|
|
221
|
+
4. Test coverage adequacy
|
|
222
|
+
|
|
223
|
+
${REVIEW_OUTPUT_FORMAT}`;
|
|
224
|
+
const SECURITY_REVIEW_PROMPT = `You are a security specialist. Review the implementation for:
|
|
225
|
+
1. OWASP Top 10 vulnerabilities
|
|
226
|
+
2. Input validation issues
|
|
227
|
+
3. Authentication/authorization problems
|
|
228
|
+
4. Data exposure risks
|
|
229
|
+
|
|
230
|
+
${REVIEW_OUTPUT_FORMAT}`;
|
|
231
|
+
const PO_REVIEW_PROMPT = `You are a product owner validating the implementation. Check:
|
|
232
|
+
1. Does it meet the acceptance criteria?
|
|
233
|
+
2. Is the user experience appropriate?
|
|
234
|
+
3. Are edge cases handled?
|
|
235
|
+
4. Is documentation adequate?
|
|
236
|
+
|
|
237
|
+
${REVIEW_OUTPUT_FORMAT}`;
|
|
238
|
+
/**
|
|
239
|
+
* Parse review response and extract structured issues
|
|
240
|
+
* Security: Uses zod schema validation to prevent malicious JSON
|
|
241
|
+
*/
|
|
242
|
+
function parseReviewResponse(response, reviewType) {
|
|
243
|
+
try {
|
|
244
|
+
// Try to extract JSON from the response
|
|
245
|
+
const jsonMatch = response.match(/\{[\s\S]*\}/);
|
|
246
|
+
if (!jsonMatch) {
|
|
247
|
+
// Fallback: no JSON found, analyze text
|
|
248
|
+
return parseTextReview(response, reviewType);
|
|
249
|
+
}
|
|
250
|
+
const parsed = JSON.parse(jsonMatch[0]);
|
|
251
|
+
// Security: Validate against zod schema before using the data
|
|
252
|
+
const validationResult = ReviewResponseSchema.safeParse(parsed);
|
|
253
|
+
if (!validationResult.success) {
|
|
254
|
+
// Log validation errors for debugging
|
|
255
|
+
console.warn('Review response failed schema validation:', validationResult.error);
|
|
256
|
+
// Fallback to text analysis
|
|
257
|
+
return parseTextReview(response, reviewType);
|
|
258
|
+
}
|
|
259
|
+
const validated = validationResult.data;
|
|
260
|
+
// Map validated data to ReviewIssue format (additional sanitization)
|
|
261
|
+
const issues = validated.issues.map((issue) => ({
|
|
262
|
+
severity: issue.severity,
|
|
263
|
+
category: issue.category,
|
|
264
|
+
description: issue.description,
|
|
265
|
+
file: issue.file,
|
|
266
|
+
line: issue.line,
|
|
267
|
+
suggestedFix: issue.suggestedFix,
|
|
268
|
+
}));
|
|
269
|
+
return {
|
|
270
|
+
passed: validated.passed !== false && issues.filter(i => i.severity === 'blocker' || i.severity === 'critical').length === 0,
|
|
271
|
+
issues,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
catch (error) {
|
|
275
|
+
// Fallback to text analysis if JSON parsing fails
|
|
276
|
+
console.warn('Review response parsing error:', error);
|
|
277
|
+
return parseTextReview(response, reviewType);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Fallback: Parse text-based review response (for when LLM doesn't return JSON)
|
|
282
|
+
*/
|
|
283
|
+
function parseTextReview(response, reviewType) {
|
|
284
|
+
const lowerResponse = response.toLowerCase();
|
|
285
|
+
const issues = [];
|
|
286
|
+
// Check for blocking keywords
|
|
287
|
+
if (lowerResponse.includes('block') || lowerResponse.includes('must fix') || lowerResponse.includes('critical security')) {
|
|
288
|
+
issues.push({
|
|
289
|
+
severity: 'blocker',
|
|
290
|
+
category: reviewType.toLowerCase().replace(' ', '_'),
|
|
291
|
+
description: response.substring(0, 500), // First 500 chars as description
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
else if (lowerResponse.includes('critical') || lowerResponse.includes('major issue') || lowerResponse.includes('reject')) {
|
|
295
|
+
issues.push({
|
|
296
|
+
severity: 'critical',
|
|
297
|
+
category: reviewType.toLowerCase().replace(' ', '_'),
|
|
298
|
+
description: response.substring(0, 500),
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
else if (lowerResponse.includes('should fix') || lowerResponse.includes('improvement needed')) {
|
|
302
|
+
issues.push({
|
|
303
|
+
severity: 'major',
|
|
304
|
+
category: reviewType.toLowerCase().replace(' ', '_'),
|
|
305
|
+
description: response.substring(0, 500),
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
// Determine pass/fail
|
|
309
|
+
const passed = lowerResponse.includes('approve') ||
|
|
310
|
+
lowerResponse.includes('looks good') ||
|
|
311
|
+
lowerResponse.includes('pass') ||
|
|
312
|
+
issues.length === 0;
|
|
313
|
+
return { passed: passed && issues.length === 0, issues };
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Determine the overall severity of review issues
|
|
317
|
+
*/
|
|
318
|
+
function determineReviewSeverity(issues) {
|
|
319
|
+
const blockerCount = issues.filter(i => i.severity === 'blocker').length;
|
|
320
|
+
const criticalCount = issues.filter(i => i.severity === 'critical').length;
|
|
321
|
+
const majorCount = issues.filter(i => i.severity === 'major').length;
|
|
322
|
+
if (blockerCount > 0) {
|
|
323
|
+
return ReviewSeverity.CRITICAL;
|
|
324
|
+
}
|
|
325
|
+
else if (criticalCount >= 2) {
|
|
326
|
+
return ReviewSeverity.HIGH;
|
|
327
|
+
}
|
|
328
|
+
else if (criticalCount === 1 || majorCount > 0) {
|
|
329
|
+
return ReviewSeverity.MEDIUM;
|
|
330
|
+
}
|
|
331
|
+
else {
|
|
332
|
+
return ReviewSeverity.LOW;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Aggregate issues from multiple reviews and determine overall pass/fail
|
|
337
|
+
*/
|
|
338
|
+
function aggregateReviews(codeResult, securityResult, poResult) {
|
|
339
|
+
const allIssues = [...codeResult.issues, ...securityResult.issues, ...poResult.issues];
|
|
340
|
+
// Count blocking issues
|
|
341
|
+
const blockerCount = allIssues.filter(i => i.severity === 'blocker').length;
|
|
342
|
+
const criticalCount = allIssues.filter(i => i.severity === 'critical').length;
|
|
343
|
+
// Fail if any blockers or 2+ critical issues
|
|
344
|
+
const passed = blockerCount === 0 && criticalCount < 2;
|
|
345
|
+
// Determine severity
|
|
346
|
+
const severity = determineReviewSeverity(allIssues);
|
|
347
|
+
return { passed, allIssues, severity };
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Format issues for display in review notes
|
|
351
|
+
*/
|
|
352
|
+
function formatIssuesForDisplay(issues) {
|
|
353
|
+
if (issues.length === 0) {
|
|
354
|
+
return '✅ No issues found';
|
|
355
|
+
}
|
|
356
|
+
const grouped = {
|
|
357
|
+
blocker: issues.filter(i => i.severity === 'blocker'),
|
|
358
|
+
critical: issues.filter(i => i.severity === 'critical'),
|
|
359
|
+
major: issues.filter(i => i.severity === 'major'),
|
|
360
|
+
minor: issues.filter(i => i.severity === 'minor'),
|
|
361
|
+
};
|
|
362
|
+
let output = '';
|
|
363
|
+
for (const [severity, issueList] of Object.entries(grouped)) {
|
|
364
|
+
if (issueList.length === 0)
|
|
365
|
+
continue;
|
|
366
|
+
const icon = severity === 'blocker' ? '🛑' : severity === 'critical' ? '⚠️' : severity === 'major' ? '📋' : 'ℹ️';
|
|
367
|
+
output += `\n#### ${icon} ${severity.toUpperCase()} (${issueList.length})\n\n`;
|
|
368
|
+
for (const issue of issueList) {
|
|
369
|
+
output += `**${issue.category}**: ${issue.description}\n`;
|
|
370
|
+
if (issue.file) {
|
|
371
|
+
output += ` - File: \`${issue.file}\`${issue.line ? `:${issue.line}` : ''}\n`;
|
|
372
|
+
}
|
|
373
|
+
if (issue.suggestedFix) {
|
|
374
|
+
output += ` - Suggested fix: ${issue.suggestedFix}\n`;
|
|
375
|
+
}
|
|
376
|
+
output += '\n';
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
return output;
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Review Agent
|
|
383
|
+
*
|
|
384
|
+
* Orchestrates code review, security review, and PO acceptance.
|
|
385
|
+
* Now returns structured ReviewResult with pass/fail and issues.
|
|
386
|
+
*/
|
|
387
|
+
export async function runReviewAgent(storyPath, sdlcRoot, options) {
|
|
388
|
+
const story = parseStory(storyPath);
|
|
389
|
+
const changesMade = [];
|
|
390
|
+
const workingDir = path.dirname(sdlcRoot);
|
|
391
|
+
// Security: Validate working directory before any operations
|
|
392
|
+
try {
|
|
393
|
+
validateWorkingDirectory(workingDir);
|
|
394
|
+
}
|
|
395
|
+
catch (error) {
|
|
396
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
397
|
+
return {
|
|
398
|
+
success: false,
|
|
399
|
+
story,
|
|
400
|
+
changesMade,
|
|
401
|
+
error: errorMsg,
|
|
402
|
+
passed: false,
|
|
403
|
+
decision: ReviewDecision.FAILED,
|
|
404
|
+
reviewType: 'combined',
|
|
405
|
+
issues: [{
|
|
406
|
+
severity: 'blocker',
|
|
407
|
+
category: 'security',
|
|
408
|
+
description: `Working directory validation failed: ${errorMsg}`,
|
|
409
|
+
}],
|
|
410
|
+
feedback: errorMsg,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
const config = loadConfig(workingDir);
|
|
414
|
+
try {
|
|
415
|
+
// Snapshot max_retries from config (protects against mid-cycle config changes)
|
|
416
|
+
snapshotMaxRetries(story, config);
|
|
417
|
+
// Check if story has reached max retries
|
|
418
|
+
if (isAtMaxRetries(story, config)) {
|
|
419
|
+
const retryCount = story.frontmatter.retry_count || 0;
|
|
420
|
+
const maxRetries = getEffectiveMaxRetries(story, config);
|
|
421
|
+
const maxRetriesDisplay = Number.isFinite(maxRetries) ? maxRetries : '∞';
|
|
422
|
+
const errorMsg = `Story has reached maximum retry limit (${retryCount}/${maxRetriesDisplay}). Manual intervention required.`;
|
|
423
|
+
updateStoryField(story, 'last_error', errorMsg);
|
|
424
|
+
changesMade.push(errorMsg);
|
|
425
|
+
return {
|
|
426
|
+
success: false,
|
|
427
|
+
story: parseStory(storyPath),
|
|
428
|
+
changesMade,
|
|
429
|
+
error: errorMsg,
|
|
430
|
+
passed: false,
|
|
431
|
+
decision: ReviewDecision.FAILED,
|
|
432
|
+
reviewType: 'combined',
|
|
433
|
+
issues: [{
|
|
434
|
+
severity: 'blocker',
|
|
435
|
+
category: 'max_retries_reached',
|
|
436
|
+
description: errorMsg,
|
|
437
|
+
}],
|
|
438
|
+
feedback: errorMsg,
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
// Run build and tests BEFORE reviews (async with progress)
|
|
442
|
+
changesMade.push('Running build and test verification...');
|
|
443
|
+
const verification = await runVerificationAsync(workingDir, config, options?.onVerificationProgress);
|
|
444
|
+
// Create verification issues if build/tests failed
|
|
445
|
+
const verificationIssues = [];
|
|
446
|
+
let verificationContext = '';
|
|
447
|
+
if (config.buildCommand) {
|
|
448
|
+
if (verification.buildPassed) {
|
|
449
|
+
changesMade.push(`Build passed: ${config.buildCommand}`);
|
|
450
|
+
verificationContext += `\n## Build Results ✅\nBuild command \`${config.buildCommand}\` passed successfully.\n`;
|
|
451
|
+
}
|
|
452
|
+
else {
|
|
453
|
+
changesMade.push(`Build FAILED: ${config.buildCommand}`);
|
|
454
|
+
const sanitizedBuildOutput = sanitizeCommandOutput(verification.buildOutput);
|
|
455
|
+
verificationIssues.push({
|
|
456
|
+
severity: 'blocker',
|
|
457
|
+
category: 'build',
|
|
458
|
+
description: `Build failed. Command: ${config.buildCommand}`,
|
|
459
|
+
suggestedFix: 'Fix build errors before review can proceed.',
|
|
460
|
+
});
|
|
461
|
+
verificationContext += `\n## Build Results ❌\nBuild command \`${config.buildCommand}\` FAILED:\n\`\`\`\n${sanitizedBuildOutput.substring(0, 2000)}\n\`\`\`\n`;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
if (config.testCommand) {
|
|
465
|
+
if (verification.testsPassed) {
|
|
466
|
+
changesMade.push(`Tests passed: ${config.testCommand}`);
|
|
467
|
+
verificationContext += `\n## Test Results ✅\nTest command \`${config.testCommand}\` passed successfully.\n`;
|
|
468
|
+
// Include summary of test output (last 500 chars typically has summary)
|
|
469
|
+
const testSummary = verification.testsOutput.slice(-500);
|
|
470
|
+
if (testSummary) {
|
|
471
|
+
verificationContext += `\`\`\`\n${testSummary}\n\`\`\`\n`;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
else {
|
|
475
|
+
changesMade.push(`Tests FAILED: ${config.testCommand}`);
|
|
476
|
+
// Sanitize and truncate test output if too large, preserving readability
|
|
477
|
+
let testOutput = sanitizeCommandOutput(verification.testsOutput);
|
|
478
|
+
let truncationNote = '';
|
|
479
|
+
if (testOutput.length > MAX_TEST_OUTPUT_SIZE) {
|
|
480
|
+
testOutput = testOutput.substring(0, MAX_TEST_OUTPUT_SIZE);
|
|
481
|
+
truncationNote = '\n\n... (output truncated - showing first 10KB)';
|
|
482
|
+
}
|
|
483
|
+
verificationIssues.push({
|
|
484
|
+
severity: 'blocker',
|
|
485
|
+
category: 'testing',
|
|
486
|
+
description: `Tests must pass before code review can proceed.\n\nCommand: ${config.testCommand}\n\nTest output:\n\`\`\`\n${testOutput}${truncationNote}\n\`\`\``,
|
|
487
|
+
suggestedFix: 'Fix failing tests before review can proceed.',
|
|
488
|
+
});
|
|
489
|
+
verificationContext += `\n## Test Results ❌\nTest command \`${config.testCommand}\` FAILED:\n\`\`\`\n${testOutput}${truncationNote}\n\`\`\`\n`;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
// OPTIMIZATION: If verification failed (build or tests), skip LLM-based reviews to save tokens and time.
|
|
493
|
+
// Return immediately with BLOCKER issues - developers should fix verification issues before review feedback is useful.
|
|
494
|
+
if (verificationIssues.length > 0) {
|
|
495
|
+
changesMade.push('Skipping code/security/PO reviews - verification must pass first');
|
|
496
|
+
return {
|
|
497
|
+
success: true, // Agent executed successfully
|
|
498
|
+
story: parseStory(storyPath),
|
|
499
|
+
changesMade,
|
|
500
|
+
passed: false, // Review did not pass
|
|
501
|
+
decision: ReviewDecision.REJECTED,
|
|
502
|
+
severity: ReviewSeverity.CRITICAL,
|
|
503
|
+
reviewType: 'combined',
|
|
504
|
+
issues: verificationIssues,
|
|
505
|
+
feedback: formatIssuesForDisplay(verificationIssues),
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
// Verification passed - proceed with all reviews in parallel, passing verification context
|
|
509
|
+
changesMade.push('Verification passed - proceeding with code/security/PO reviews');
|
|
510
|
+
const [codeReview, securityReview, poReview] = await Promise.all([
|
|
511
|
+
runSubReview(story, CODE_REVIEW_PROMPT, 'Code Review', workingDir, verificationContext),
|
|
512
|
+
runSubReview(story, SECURITY_REVIEW_PROMPT, 'Security Review', workingDir, verificationContext),
|
|
513
|
+
runSubReview(story, PO_REVIEW_PROMPT, 'Product Owner Review', workingDir, verificationContext),
|
|
514
|
+
]);
|
|
515
|
+
// Parse each review response into structured issues
|
|
516
|
+
const codeResult = parseReviewResponse(codeReview, 'Code Review');
|
|
517
|
+
const securityResult = parseReviewResponse(securityReview, 'Security Review');
|
|
518
|
+
const poResult = parseReviewResponse(poReview, 'Product Owner Review');
|
|
519
|
+
// Add verification issues to code result (they're code-quality related)
|
|
520
|
+
codeResult.issues.unshift(...verificationIssues);
|
|
521
|
+
if (verificationIssues.length > 0) {
|
|
522
|
+
codeResult.passed = false;
|
|
523
|
+
}
|
|
524
|
+
// Aggregate all issues and determine overall pass/fail
|
|
525
|
+
const { passed, allIssues, severity } = aggregateReviews(codeResult, securityResult, poResult);
|
|
526
|
+
// Compile review notes with structured format
|
|
527
|
+
const reviewNotes = `
|
|
528
|
+
### Code Review
|
|
529
|
+
${formatIssuesForDisplay(codeResult.issues)}
|
|
530
|
+
|
|
531
|
+
### Security Review
|
|
532
|
+
${formatIssuesForDisplay(securityResult.issues)}
|
|
533
|
+
|
|
534
|
+
### Product Owner Review
|
|
535
|
+
${formatIssuesForDisplay(poResult.issues)}
|
|
536
|
+
|
|
537
|
+
### Overall Result
|
|
538
|
+
${passed ? '✅ **PASSED** - All reviews approved' : '❌ **FAILED** - Issues must be addressed'}
|
|
539
|
+
|
|
540
|
+
---
|
|
541
|
+
*Reviews completed: ${new Date().toISOString().split('T')[0]}*
|
|
542
|
+
`;
|
|
543
|
+
// Append reviews to story
|
|
544
|
+
appendToSection(story, 'Review Notes', reviewNotes);
|
|
545
|
+
changesMade.push('Added code review notes');
|
|
546
|
+
changesMade.push('Added security review notes');
|
|
547
|
+
changesMade.push('Added product owner review notes');
|
|
548
|
+
// Determine decision
|
|
549
|
+
const decision = passed ? ReviewDecision.APPROVED : ReviewDecision.REJECTED;
|
|
550
|
+
// Create review attempt record (omit undefined fields to avoid YAML serialization errors)
|
|
551
|
+
const reviewAttempt = {
|
|
552
|
+
timestamp: new Date().toISOString(),
|
|
553
|
+
decision,
|
|
554
|
+
...(passed ? {} : { severity }),
|
|
555
|
+
feedback: passed ? 'All reviews passed' : formatIssuesForDisplay(allIssues),
|
|
556
|
+
blockers: allIssues.filter(i => i.severity === 'blocker').map(i => i.description),
|
|
557
|
+
codeReviewPassed: codeResult.passed,
|
|
558
|
+
securityReviewPassed: securityResult.passed,
|
|
559
|
+
poReviewPassed: poResult.passed,
|
|
560
|
+
};
|
|
561
|
+
// Append to review history
|
|
562
|
+
appendReviewHistory(story, reviewAttempt);
|
|
563
|
+
changesMade.push('Recorded review attempt in history');
|
|
564
|
+
if (passed) {
|
|
565
|
+
updateStoryField(story, 'reviews_complete', true);
|
|
566
|
+
changesMade.push('Marked reviews_complete: true');
|
|
567
|
+
}
|
|
568
|
+
else {
|
|
569
|
+
changesMade.push(`Reviews failed with ${allIssues.length} issue(s) - rework required`);
|
|
570
|
+
// Don't mark reviews_complete, this will trigger rework
|
|
571
|
+
}
|
|
572
|
+
return {
|
|
573
|
+
success: true,
|
|
574
|
+
story: parseStory(storyPath),
|
|
575
|
+
changesMade,
|
|
576
|
+
passed,
|
|
577
|
+
decision,
|
|
578
|
+
...(passed ? {} : { severity }),
|
|
579
|
+
reviewType: 'combined',
|
|
580
|
+
issues: allIssues,
|
|
581
|
+
feedback: passed ? 'All reviews passed' : formatIssuesForDisplay(allIssues),
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
catch (error) {
|
|
585
|
+
// Review agent failure - return FAILED decision (doesn't count as retry)
|
|
586
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
587
|
+
return {
|
|
588
|
+
success: false,
|
|
589
|
+
story,
|
|
590
|
+
changesMade,
|
|
591
|
+
error: errorMsg,
|
|
592
|
+
passed: false,
|
|
593
|
+
decision: ReviewDecision.FAILED,
|
|
594
|
+
reviewType: 'combined',
|
|
595
|
+
issues: [{
|
|
596
|
+
severity: 'blocker',
|
|
597
|
+
category: 'review_error',
|
|
598
|
+
description: `Review process failed: ${errorMsg}`,
|
|
599
|
+
}],
|
|
600
|
+
feedback: `Review process failed: ${errorMsg}`,
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Run a sub-review with a specific prompt
|
|
606
|
+
*/
|
|
607
|
+
async function runSubReview(story, systemPrompt, reviewType, workingDir, verificationContext = '') {
|
|
608
|
+
try {
|
|
609
|
+
const prompt = `Review this story implementation:
|
|
610
|
+
|
|
611
|
+
Title: ${story.frontmatter.title}
|
|
612
|
+
${verificationContext ? `\n---\n# Build & Test Verification Results\n${verificationContext}\n---\n` : ''}
|
|
613
|
+
Full story content:
|
|
614
|
+
${story.content}
|
|
615
|
+
|
|
616
|
+
Provide your ${reviewType} feedback. Be specific and actionable.`;
|
|
617
|
+
return await runAgentQuery({
|
|
618
|
+
prompt,
|
|
619
|
+
systemPrompt,
|
|
620
|
+
workingDirectory: workingDir,
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
catch (error) {
|
|
624
|
+
return `${reviewType} failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Create a pull request for the completed story
|
|
629
|
+
*/
|
|
630
|
+
export async function createPullRequest(storyPath, sdlcRoot) {
|
|
631
|
+
let story = parseStory(storyPath);
|
|
632
|
+
const changesMade = [];
|
|
633
|
+
const workingDir = path.dirname(sdlcRoot);
|
|
634
|
+
// Security: Validate working directory
|
|
635
|
+
try {
|
|
636
|
+
validateWorkingDirectory(workingDir);
|
|
637
|
+
}
|
|
638
|
+
catch (error) {
|
|
639
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
640
|
+
return {
|
|
641
|
+
success: false,
|
|
642
|
+
story,
|
|
643
|
+
changesMade,
|
|
644
|
+
error: errorMsg,
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
try {
|
|
648
|
+
const branchName = story.frontmatter.branch || `agentic-sdlc/${story.slug}`;
|
|
649
|
+
// Security: Validate branch name to prevent command injection
|
|
650
|
+
if (!validateGitBranchName(branchName)) {
|
|
651
|
+
const errorMsg = `Invalid branch name: ${branchName} (only alphanumeric, hyphens, underscores, and slashes allowed)`;
|
|
652
|
+
changesMade.push(errorMsg);
|
|
653
|
+
return {
|
|
654
|
+
success: false,
|
|
655
|
+
story,
|
|
656
|
+
changesMade,
|
|
657
|
+
error: errorMsg,
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
// Check if gh CLI is available
|
|
661
|
+
try {
|
|
662
|
+
execSync('gh --version', { stdio: 'pipe' });
|
|
663
|
+
}
|
|
664
|
+
catch {
|
|
665
|
+
changesMade.push('GitHub CLI not available - PR creation skipped');
|
|
666
|
+
// Still move to done for MVP
|
|
667
|
+
story = moveStory(story, 'done', sdlcRoot);
|
|
668
|
+
changesMade.push('Moved story to done/');
|
|
669
|
+
return {
|
|
670
|
+
success: true,
|
|
671
|
+
story,
|
|
672
|
+
changesMade,
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
// Create PR using gh CLI
|
|
676
|
+
try {
|
|
677
|
+
// First, ensure we're on the right branch and have changes committed
|
|
678
|
+
// Security: Branch name is already validated above
|
|
679
|
+
execSync(`git checkout ${branchName}`, { cwd: workingDir, stdio: 'pipe' });
|
|
680
|
+
// Check for uncommitted changes and commit them
|
|
681
|
+
const status = execSync('git status --porcelain', { cwd: workingDir, encoding: 'utf-8' });
|
|
682
|
+
if (status.trim()) {
|
|
683
|
+
execSync('git add -A', { cwd: workingDir, stdio: 'pipe' });
|
|
684
|
+
// Security: Escape shell arguments for commit message
|
|
685
|
+
const commitMsg = `feat: ${story.frontmatter.title}`;
|
|
686
|
+
execSync(`git commit -m ${escapeShellArg(commitMsg)}`, { cwd: workingDir, stdio: 'pipe' });
|
|
687
|
+
changesMade.push('Committed changes');
|
|
688
|
+
}
|
|
689
|
+
// Push branch (already validated)
|
|
690
|
+
execSync(`git push -u origin ${branchName}`, { cwd: workingDir, stdio: 'pipe' });
|
|
691
|
+
changesMade.push(`Pushed branch: ${branchName}`);
|
|
692
|
+
// Create PR using gh CLI with safe arguments
|
|
693
|
+
// Security: Use escaped arguments to prevent shell injection
|
|
694
|
+
const prTitle = story.frontmatter.title;
|
|
695
|
+
const prBody = `## Summary
|
|
696
|
+
|
|
697
|
+
${story.frontmatter.title}
|
|
698
|
+
|
|
699
|
+
## Story
|
|
700
|
+
|
|
701
|
+
${story.content.substring(0, 1000)}...
|
|
702
|
+
|
|
703
|
+
## Checklist
|
|
704
|
+
|
|
705
|
+
- [x] Implementation complete
|
|
706
|
+
- [x] Code review passed
|
|
707
|
+
- [x] Security review passed
|
|
708
|
+
- [x] Product owner approved
|
|
709
|
+
|
|
710
|
+
---
|
|
711
|
+
*Created by agentic-sdlc*`;
|
|
712
|
+
const prOutput = execSync(`gh pr create --title ${escapeShellArg(prTitle)} --body ${escapeShellArg(prBody)}`, { cwd: workingDir, encoding: 'utf-8' });
|
|
713
|
+
const prUrl = prOutput.trim();
|
|
714
|
+
updateStoryField(story, 'pr_url', prUrl);
|
|
715
|
+
changesMade.push(`Created PR: ${prUrl}`);
|
|
716
|
+
}
|
|
717
|
+
catch (error) {
|
|
718
|
+
const sanitizedError = sanitizeErrorMessage(error instanceof Error ? error.message : String(error), workingDir);
|
|
719
|
+
changesMade.push(`PR creation failed: ${sanitizedError}`);
|
|
720
|
+
}
|
|
721
|
+
// Move story to done
|
|
722
|
+
story = moveStory(story, 'done', sdlcRoot);
|
|
723
|
+
changesMade.push('Moved story to done/');
|
|
724
|
+
return {
|
|
725
|
+
success: true,
|
|
726
|
+
story,
|
|
727
|
+
changesMade,
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
catch (error) {
|
|
731
|
+
const sanitizedError = sanitizeErrorMessage(error instanceof Error ? error.message : String(error), workingDir);
|
|
732
|
+
return {
|
|
733
|
+
success: false,
|
|
734
|
+
story,
|
|
735
|
+
changesMade,
|
|
736
|
+
error: sanitizedError,
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
//# sourceMappingURL=review.js.map
|