sequant 1.11.0 → 1.13.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.
- package/README.md +93 -7
- package/dist/bin/cli.js +12 -9
- package/dist/src/commands/doctor.js +25 -20
- package/dist/src/commands/init.js +152 -65
- package/dist/src/commands/logs.js +7 -6
- package/dist/src/commands/run.d.ts +13 -1
- package/dist/src/commands/run.js +75 -12
- package/dist/src/commands/stats.js +67 -48
- package/dist/src/commands/status.js +30 -12
- package/dist/src/index.d.ts +6 -0
- package/dist/src/index.js +4 -0
- package/dist/src/lib/ac-linter.d.ts +116 -0
- package/dist/src/lib/ac-linter.js +304 -0
- package/dist/src/lib/cli-ui.d.ts +196 -0
- package/dist/src/lib/cli-ui.js +544 -0
- package/dist/src/lib/content-analyzer.d.ts +89 -0
- package/dist/src/lib/content-analyzer.js +437 -0
- package/dist/src/lib/phase-signal.d.ts +94 -0
- package/dist/src/lib/phase-signal.js +171 -0
- package/dist/src/lib/plugin-version-sync.d.ts +26 -0
- package/dist/src/lib/plugin-version-sync.js +91 -0
- package/dist/src/lib/project-name.d.ts +40 -0
- package/dist/src/lib/project-name.js +191 -0
- package/dist/src/lib/semgrep.d.ts +136 -0
- package/dist/src/lib/semgrep.js +406 -0
- package/dist/src/lib/solve-comment-parser.d.ts +84 -0
- package/dist/src/lib/solve-comment-parser.js +200 -0
- package/dist/src/lib/stack-config.d.ts +51 -0
- package/dist/src/lib/stack-config.js +77 -0
- package/dist/src/lib/stacks.d.ts +66 -0
- package/dist/src/lib/stacks.js +332 -0
- package/dist/src/lib/templates.d.ts +2 -0
- package/dist/src/lib/templates.js +12 -3
- package/dist/src/lib/upstream/assessment.d.ts +70 -0
- package/dist/src/lib/upstream/assessment.js +385 -0
- package/dist/src/lib/upstream/index.d.ts +11 -0
- package/dist/src/lib/upstream/index.js +14 -0
- package/dist/src/lib/upstream/issues.d.ts +38 -0
- package/dist/src/lib/upstream/issues.js +267 -0
- package/dist/src/lib/upstream/relevance.d.ts +50 -0
- package/dist/src/lib/upstream/relevance.js +209 -0
- package/dist/src/lib/upstream/report.d.ts +29 -0
- package/dist/src/lib/upstream/report.js +391 -0
- package/dist/src/lib/upstream/types.d.ts +207 -0
- package/dist/src/lib/upstream/types.js +5 -0
- package/dist/src/lib/workflow/log-writer.d.ts +1 -1
- package/dist/src/lib/workflow/metrics-schema.d.ts +3 -3
- package/dist/src/lib/workflow/qa-cache.d.ts +199 -0
- package/dist/src/lib/workflow/qa-cache.js +440 -0
- package/dist/src/lib/workflow/run-log-schema.d.ts +34 -6
- package/dist/src/lib/workflow/run-log-schema.js +12 -1
- package/dist/src/lib/workflow/state-schema.d.ts +4 -4
- package/dist/src/lib/workflow/types.d.ts +4 -0
- package/package.json +6 -1
- package/templates/hooks/pre-tool.sh +6 -0
- package/templates/memory/constitution.md +1 -5
- package/templates/skills/_shared/references/prompt-templates.md +350 -0
- package/templates/skills/_shared/references/subagent-types.md +131 -0
- package/templates/skills/exec/SKILL.md +82 -0
- package/templates/skills/fullsolve/SKILL.md +19 -2
- package/templates/skills/loop/SKILL.md +3 -1
- package/templates/skills/qa/SKILL.md +79 -9
- package/templates/skills/qa/references/quality-gates.md +85 -1
- package/templates/skills/qa/references/semgrep-rules.md +207 -0
- package/templates/skills/qa/scripts/quality-checks.sh +525 -15
- package/templates/skills/spec/SKILL.md +322 -9
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Semgrep integration for static analysis
|
|
3
|
+
*
|
|
4
|
+
* Provides stack-aware ruleset mapping and execution utilities
|
|
5
|
+
* for integrating Semgrep into the /qa workflow.
|
|
6
|
+
*/
|
|
7
|
+
import { spawn } from "child_process";
|
|
8
|
+
import { existsSync } from "fs";
|
|
9
|
+
import { join } from "path";
|
|
10
|
+
/**
|
|
11
|
+
* Stack-to-ruleset mapping
|
|
12
|
+
*
|
|
13
|
+
* Maps detected stacks to appropriate Semgrep rulesets.
|
|
14
|
+
* Uses Semgrep's public rule registry identifiers.
|
|
15
|
+
*/
|
|
16
|
+
export const STACK_RULESETS = {
|
|
17
|
+
nextjs: {
|
|
18
|
+
name: "Next.js",
|
|
19
|
+
description: "TypeScript/JavaScript security and best practices for Next.js",
|
|
20
|
+
rules: [
|
|
21
|
+
"p/typescript",
|
|
22
|
+
"p/javascript",
|
|
23
|
+
"p/react",
|
|
24
|
+
"p/security-audit",
|
|
25
|
+
"p/secrets",
|
|
26
|
+
],
|
|
27
|
+
},
|
|
28
|
+
astro: {
|
|
29
|
+
name: "Astro",
|
|
30
|
+
description: "TypeScript/JavaScript security for Astro projects",
|
|
31
|
+
rules: ["p/typescript", "p/javascript", "p/security-audit", "p/secrets"],
|
|
32
|
+
},
|
|
33
|
+
sveltekit: {
|
|
34
|
+
name: "SvelteKit",
|
|
35
|
+
description: "TypeScript/JavaScript security for SvelteKit",
|
|
36
|
+
rules: ["p/typescript", "p/javascript", "p/security-audit", "p/secrets"],
|
|
37
|
+
},
|
|
38
|
+
remix: {
|
|
39
|
+
name: "Remix",
|
|
40
|
+
description: "TypeScript/JavaScript security for Remix",
|
|
41
|
+
rules: [
|
|
42
|
+
"p/typescript",
|
|
43
|
+
"p/javascript",
|
|
44
|
+
"p/react",
|
|
45
|
+
"p/security-audit",
|
|
46
|
+
"p/secrets",
|
|
47
|
+
],
|
|
48
|
+
},
|
|
49
|
+
nuxt: {
|
|
50
|
+
name: "Nuxt",
|
|
51
|
+
description: "TypeScript/JavaScript security for Nuxt/Vue",
|
|
52
|
+
rules: ["p/typescript", "p/javascript", "p/security-audit", "p/secrets"],
|
|
53
|
+
},
|
|
54
|
+
rust: {
|
|
55
|
+
name: "Rust",
|
|
56
|
+
description: "Rust security and best practices",
|
|
57
|
+
rules: ["p/rust", "p/security-audit", "p/secrets"],
|
|
58
|
+
},
|
|
59
|
+
python: {
|
|
60
|
+
name: "Python",
|
|
61
|
+
description: "Python security and best practices",
|
|
62
|
+
rules: ["p/python", "p/django", "p/flask", "p/security-audit", "p/secrets"],
|
|
63
|
+
},
|
|
64
|
+
go: {
|
|
65
|
+
name: "Go",
|
|
66
|
+
description: "Go security and best practices",
|
|
67
|
+
rules: ["p/golang", "p/security-audit", "p/secrets"],
|
|
68
|
+
},
|
|
69
|
+
generic: {
|
|
70
|
+
name: "Generic",
|
|
71
|
+
description: "General security rules for any codebase",
|
|
72
|
+
rules: ["p/security-audit", "p/secrets"],
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* Get the appropriate Semgrep ruleset for a detected stack
|
|
77
|
+
*
|
|
78
|
+
* @param stack - The detected stack name (e.g., "nextjs", "python")
|
|
79
|
+
* @returns The ruleset configuration for the stack
|
|
80
|
+
*/
|
|
81
|
+
export function getRulesForStack(stack) {
|
|
82
|
+
if (!stack) {
|
|
83
|
+
return STACK_RULESETS.generic;
|
|
84
|
+
}
|
|
85
|
+
return STACK_RULESETS[stack] || STACK_RULESETS.generic;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Path to custom rules file
|
|
89
|
+
*/
|
|
90
|
+
export const CUSTOM_RULES_PATH = ".sequant/semgrep-rules.yaml";
|
|
91
|
+
/**
|
|
92
|
+
* Check if custom rules file exists
|
|
93
|
+
*
|
|
94
|
+
* @param projectRoot - Root directory of the project
|
|
95
|
+
* @returns true if custom rules file exists
|
|
96
|
+
*/
|
|
97
|
+
export function hasCustomRules(projectRoot = process.cwd()) {
|
|
98
|
+
return existsSync(join(projectRoot, CUSTOM_RULES_PATH));
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Get path to custom rules file if it exists
|
|
102
|
+
*
|
|
103
|
+
* @param projectRoot - Root directory of the project
|
|
104
|
+
* @returns Path to custom rules file, or null if it doesn't exist
|
|
105
|
+
*/
|
|
106
|
+
export function getCustomRulesPath(projectRoot = process.cwd()) {
|
|
107
|
+
const customPath = join(projectRoot, CUSTOM_RULES_PATH);
|
|
108
|
+
return existsSync(customPath) ? customPath : null;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Check if Semgrep is available
|
|
112
|
+
*
|
|
113
|
+
* Checks for both 'semgrep' command and 'npx semgrep' fallback
|
|
114
|
+
*
|
|
115
|
+
* @returns Object with availability info and command to use
|
|
116
|
+
*/
|
|
117
|
+
export async function checkSemgrepAvailability() {
|
|
118
|
+
// First try native semgrep
|
|
119
|
+
try {
|
|
120
|
+
await executeCommand("semgrep", ["--version"]);
|
|
121
|
+
return { available: true, command: "semgrep", useNpx: false };
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
// Native semgrep not available, try npx
|
|
125
|
+
}
|
|
126
|
+
// Try npx semgrep
|
|
127
|
+
try {
|
|
128
|
+
await executeCommand("npx", ["semgrep", "--version"]);
|
|
129
|
+
return { available: true, command: "npx", useNpx: true };
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
// npx semgrep also not available
|
|
133
|
+
}
|
|
134
|
+
return { available: false, command: "", useNpx: false };
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Execute a command and return stdout
|
|
138
|
+
*
|
|
139
|
+
* @param command - Command to execute
|
|
140
|
+
* @param args - Command arguments
|
|
141
|
+
* @returns stdout from the command
|
|
142
|
+
*/
|
|
143
|
+
function executeCommand(command, args) {
|
|
144
|
+
return new Promise((resolve, reject) => {
|
|
145
|
+
const proc = spawn(command, args, {
|
|
146
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
147
|
+
shell: process.platform === "win32",
|
|
148
|
+
});
|
|
149
|
+
let stdout = "";
|
|
150
|
+
let stderr = "";
|
|
151
|
+
proc.stdout?.on("data", (data) => {
|
|
152
|
+
stdout += data.toString();
|
|
153
|
+
});
|
|
154
|
+
proc.stderr?.on("data", (data) => {
|
|
155
|
+
stderr += data.toString();
|
|
156
|
+
});
|
|
157
|
+
proc.on("close", (code) => {
|
|
158
|
+
if (code === 0) {
|
|
159
|
+
resolve(stdout);
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
reject(new Error(stderr || `Command exited with code ${code}`));
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
proc.on("error", (err) => {
|
|
166
|
+
reject(err);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Parse Semgrep JSON output into findings
|
|
172
|
+
*
|
|
173
|
+
* @param output - Raw JSON output from Semgrep
|
|
174
|
+
* @returns Array of parsed findings
|
|
175
|
+
*/
|
|
176
|
+
export function parseSemgrepOutput(output) {
|
|
177
|
+
try {
|
|
178
|
+
const data = JSON.parse(output);
|
|
179
|
+
const results = data.results || [];
|
|
180
|
+
return results.map((result) => ({
|
|
181
|
+
path: result.path,
|
|
182
|
+
line: result.start.line,
|
|
183
|
+
column: result.start.col,
|
|
184
|
+
endLine: result.end?.line,
|
|
185
|
+
endColumn: result.end?.col,
|
|
186
|
+
message: result.extra.message,
|
|
187
|
+
ruleId: result.check_id,
|
|
188
|
+
severity: mapSeverity(result.extra.severity || "warning"),
|
|
189
|
+
category: result.extra.metadata?.category,
|
|
190
|
+
}));
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
return [];
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Map Semgrep severity to our internal severity type
|
|
198
|
+
*/
|
|
199
|
+
function mapSeverity(severity) {
|
|
200
|
+
const lower = severity.toLowerCase();
|
|
201
|
+
if (lower === "error" || lower === "critical" || lower === "high") {
|
|
202
|
+
return "error";
|
|
203
|
+
}
|
|
204
|
+
if (lower === "warning" || lower === "medium") {
|
|
205
|
+
return "warning";
|
|
206
|
+
}
|
|
207
|
+
return "info";
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Count findings by severity
|
|
211
|
+
*
|
|
212
|
+
* @param findings - Array of Semgrep findings
|
|
213
|
+
* @returns Object with counts by severity
|
|
214
|
+
*/
|
|
215
|
+
export function countFindingsBySeverity(findings) {
|
|
216
|
+
return findings.reduce((acc, finding) => {
|
|
217
|
+
if (finding.severity === "error") {
|
|
218
|
+
acc.critical++;
|
|
219
|
+
}
|
|
220
|
+
else if (finding.severity === "warning") {
|
|
221
|
+
acc.warning++;
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
acc.info++;
|
|
225
|
+
}
|
|
226
|
+
return acc;
|
|
227
|
+
}, { critical: 0, warning: 0, info: 0 });
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Run Semgrep scan on specified files or directories
|
|
231
|
+
*
|
|
232
|
+
* @param options - Scan options
|
|
233
|
+
* @returns Scan result with findings
|
|
234
|
+
*/
|
|
235
|
+
export async function runSemgrepScan(options) {
|
|
236
|
+
const { targets, stack = null, projectRoot = process.cwd() } = options;
|
|
237
|
+
const useCustomRules = options.useCustomRules ?? true;
|
|
238
|
+
// Check availability
|
|
239
|
+
const availability = await checkSemgrepAvailability();
|
|
240
|
+
if (!availability.available) {
|
|
241
|
+
return {
|
|
242
|
+
success: true,
|
|
243
|
+
findings: [],
|
|
244
|
+
criticalCount: 0,
|
|
245
|
+
warningCount: 0,
|
|
246
|
+
infoCount: 0,
|
|
247
|
+
skipped: true,
|
|
248
|
+
skipReason: "Semgrep not installed (install with: pip install semgrep)",
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
// Build command arguments
|
|
252
|
+
const args = [];
|
|
253
|
+
// If using npx, prepend 'semgrep' to args
|
|
254
|
+
if (availability.useNpx) {
|
|
255
|
+
args.push("semgrep");
|
|
256
|
+
}
|
|
257
|
+
// Add output format
|
|
258
|
+
args.push("--json");
|
|
259
|
+
// Add rules from stack
|
|
260
|
+
const ruleset = getRulesForStack(stack);
|
|
261
|
+
for (const rule of ruleset.rules) {
|
|
262
|
+
args.push("--config", rule);
|
|
263
|
+
}
|
|
264
|
+
// Add custom rules if available
|
|
265
|
+
if (useCustomRules) {
|
|
266
|
+
const customRulesPath = getCustomRulesPath(projectRoot);
|
|
267
|
+
if (customRulesPath) {
|
|
268
|
+
args.push("--config", customRulesPath);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
// Add targets
|
|
272
|
+
args.push(...targets);
|
|
273
|
+
try {
|
|
274
|
+
const output = await executeCommand(availability.command, args);
|
|
275
|
+
const findings = parseSemgrepOutput(output);
|
|
276
|
+
const counts = countFindingsBySeverity(findings);
|
|
277
|
+
return {
|
|
278
|
+
success: true,
|
|
279
|
+
findings,
|
|
280
|
+
criticalCount: counts.critical,
|
|
281
|
+
warningCount: counts.warning,
|
|
282
|
+
infoCount: counts.info,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
catch (error) {
|
|
286
|
+
// Semgrep returns non-zero exit code when findings are present
|
|
287
|
+
// Try to parse the output anyway
|
|
288
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
289
|
+
// Check if this is just findings being reported (exit code 1 with JSON output)
|
|
290
|
+
try {
|
|
291
|
+
const findings = parseSemgrepOutput(errorMessage);
|
|
292
|
+
if (findings.length > 0) {
|
|
293
|
+
const counts = countFindingsBySeverity(findings);
|
|
294
|
+
return {
|
|
295
|
+
success: true,
|
|
296
|
+
findings,
|
|
297
|
+
criticalCount: counts.critical,
|
|
298
|
+
warningCount: counts.warning,
|
|
299
|
+
infoCount: counts.info,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
// Not valid JSON output, real error
|
|
305
|
+
}
|
|
306
|
+
return {
|
|
307
|
+
success: false,
|
|
308
|
+
findings: [],
|
|
309
|
+
criticalCount: 0,
|
|
310
|
+
warningCount: 0,
|
|
311
|
+
infoCount: 0,
|
|
312
|
+
error: errorMessage,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Format findings for display in QA output
|
|
318
|
+
*
|
|
319
|
+
* @param findings - Array of findings to format
|
|
320
|
+
* @returns Formatted markdown string
|
|
321
|
+
*/
|
|
322
|
+
export function formatFindingsForDisplay(findings) {
|
|
323
|
+
if (findings.length === 0) {
|
|
324
|
+
return "✅ No findings";
|
|
325
|
+
}
|
|
326
|
+
const lines = [];
|
|
327
|
+
const grouped = groupFindingsBySeverity(findings);
|
|
328
|
+
if (grouped.critical.length > 0) {
|
|
329
|
+
lines.push("### ❌ Critical Issues");
|
|
330
|
+
lines.push("");
|
|
331
|
+
for (const finding of grouped.critical) {
|
|
332
|
+
lines.push(formatSingleFinding(finding));
|
|
333
|
+
}
|
|
334
|
+
lines.push("");
|
|
335
|
+
}
|
|
336
|
+
if (grouped.warning.length > 0) {
|
|
337
|
+
lines.push("### ⚠️ Warnings");
|
|
338
|
+
lines.push("");
|
|
339
|
+
for (const finding of grouped.warning) {
|
|
340
|
+
lines.push(formatSingleFinding(finding));
|
|
341
|
+
}
|
|
342
|
+
lines.push("");
|
|
343
|
+
}
|
|
344
|
+
if (grouped.info.length > 0) {
|
|
345
|
+
lines.push("### ℹ️ Info");
|
|
346
|
+
lines.push("");
|
|
347
|
+
for (const finding of grouped.info) {
|
|
348
|
+
lines.push(formatSingleFinding(finding));
|
|
349
|
+
}
|
|
350
|
+
lines.push("");
|
|
351
|
+
}
|
|
352
|
+
return lines.join("\n");
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Group findings by severity
|
|
356
|
+
*/
|
|
357
|
+
function groupFindingsBySeverity(findings) {
|
|
358
|
+
return {
|
|
359
|
+
critical: findings.filter((f) => f.severity === "error"),
|
|
360
|
+
warning: findings.filter((f) => f.severity === "warning"),
|
|
361
|
+
info: findings.filter((f) => f.severity === "info"),
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Format a single finding for display
|
|
366
|
+
*/
|
|
367
|
+
function formatSingleFinding(finding) {
|
|
368
|
+
const location = `${finding.path}:${finding.line}`;
|
|
369
|
+
return `- \`${location}\` - ${finding.message} (${finding.ruleId})`;
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Generate QA verdict contribution from Semgrep results
|
|
373
|
+
*
|
|
374
|
+
* @param result - Semgrep scan result
|
|
375
|
+
* @returns Verdict contribution (blocking if critical findings)
|
|
376
|
+
*/
|
|
377
|
+
export function getSemgrepVerdictContribution(result) {
|
|
378
|
+
if (result.skipped) {
|
|
379
|
+
return {
|
|
380
|
+
blocking: false,
|
|
381
|
+
reason: `Semgrep skipped: ${result.skipReason}`,
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
if (!result.success) {
|
|
385
|
+
return {
|
|
386
|
+
blocking: false,
|
|
387
|
+
reason: `Semgrep error: ${result.error}`,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
if (result.criticalCount > 0) {
|
|
391
|
+
return {
|
|
392
|
+
blocking: true,
|
|
393
|
+
reason: `${result.criticalCount} critical security finding(s) detected`,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
if (result.warningCount > 0) {
|
|
397
|
+
return {
|
|
398
|
+
blocking: false,
|
|
399
|
+
reason: `${result.warningCount} warning(s) detected (review recommended)`,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
return {
|
|
403
|
+
blocking: false,
|
|
404
|
+
reason: "No security issues detected",
|
|
405
|
+
};
|
|
406
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Solve Comment Parser
|
|
3
|
+
*
|
|
4
|
+
* Detects and parses /solve command output from GitHub issue comments.
|
|
5
|
+
* When a solve comment exists, its phase recommendations take precedence
|
|
6
|
+
* over content analysis (but not over labels).
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```typescript
|
|
10
|
+
* import { findSolveComment, parseSolveWorkflow } from './solve-comment-parser';
|
|
11
|
+
*
|
|
12
|
+
* const comments = [
|
|
13
|
+
* { body: "Some regular comment" },
|
|
14
|
+
* { body: "## Solve Workflow for Issues: 123\n..." },
|
|
15
|
+
* ];
|
|
16
|
+
*
|
|
17
|
+
* const solveComment = findSolveComment(comments);
|
|
18
|
+
* if (solveComment) {
|
|
19
|
+
* const phases = parseSolveWorkflow(solveComment.body);
|
|
20
|
+
* // phases: { phases: ['spec', 'exec', 'test', 'qa'], qualityLoop: false }
|
|
21
|
+
* }
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
import type { Phase } from "./workflow/types.js";
|
|
25
|
+
import type { PhaseSignal } from "./phase-signal.js";
|
|
26
|
+
/**
|
|
27
|
+
* Result of parsing a solve comment
|
|
28
|
+
*/
|
|
29
|
+
export interface SolveWorkflowResult {
|
|
30
|
+
/** Phases recommended by solve */
|
|
31
|
+
phases: Phase[];
|
|
32
|
+
/** Whether quality loop is recommended */
|
|
33
|
+
qualityLoop: boolean;
|
|
34
|
+
/** The issue numbers mentioned in the solve comment */
|
|
35
|
+
issueNumbers: number[];
|
|
36
|
+
/** Raw workflow string (e.g., "spec → exec → test → qa") */
|
|
37
|
+
workflowString?: string;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Comment structure (simplified from GitHub API)
|
|
41
|
+
*/
|
|
42
|
+
export interface IssueComment {
|
|
43
|
+
body: string;
|
|
44
|
+
author?: {
|
|
45
|
+
login: string;
|
|
46
|
+
};
|
|
47
|
+
createdAt?: string;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Check if a comment is a solve command output
|
|
51
|
+
*
|
|
52
|
+
* @param body - The comment body
|
|
53
|
+
* @returns True if this appears to be a solve comment
|
|
54
|
+
*/
|
|
55
|
+
export declare function isSolveComment(body: string): boolean;
|
|
56
|
+
/**
|
|
57
|
+
* Find the most recent solve comment from a list of comments
|
|
58
|
+
*
|
|
59
|
+
* @param comments - Array of issue comments
|
|
60
|
+
* @returns The solve comment if found, null otherwise
|
|
61
|
+
*/
|
|
62
|
+
export declare function findSolveComment(comments: IssueComment[]): IssueComment | null;
|
|
63
|
+
/**
|
|
64
|
+
* Parse a solve comment to extract workflow information
|
|
65
|
+
*
|
|
66
|
+
* @param body - The solve comment body
|
|
67
|
+
* @returns Parsed workflow result
|
|
68
|
+
*/
|
|
69
|
+
export declare function parseSolveWorkflow(body: string): SolveWorkflowResult;
|
|
70
|
+
/**
|
|
71
|
+
* Convert solve workflow result to phase signals
|
|
72
|
+
*
|
|
73
|
+
* @param workflow - The parsed solve workflow
|
|
74
|
+
* @returns Array of phase signals with 'solve' source
|
|
75
|
+
*/
|
|
76
|
+
export declare function solveWorkflowToSignals(workflow: SolveWorkflowResult): PhaseSignal[];
|
|
77
|
+
/**
|
|
78
|
+
* Check if solve comment covers the current issue
|
|
79
|
+
*
|
|
80
|
+
* @param workflow - The parsed solve workflow
|
|
81
|
+
* @param issueNumber - The current issue number
|
|
82
|
+
* @returns True if the solve comment includes this issue
|
|
83
|
+
*/
|
|
84
|
+
export declare function solveCoversIssue(workflow: SolveWorkflowResult, issueNumber: number): boolean;
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Solve Comment Parser
|
|
3
|
+
*
|
|
4
|
+
* Detects and parses /solve command output from GitHub issue comments.
|
|
5
|
+
* When a solve comment exists, its phase recommendations take precedence
|
|
6
|
+
* over content analysis (but not over labels).
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```typescript
|
|
10
|
+
* import { findSolveComment, parseSolveWorkflow } from './solve-comment-parser';
|
|
11
|
+
*
|
|
12
|
+
* const comments = [
|
|
13
|
+
* { body: "Some regular comment" },
|
|
14
|
+
* { body: "## Solve Workflow for Issues: 123\n..." },
|
|
15
|
+
* ];
|
|
16
|
+
*
|
|
17
|
+
* const solveComment = findSolveComment(comments);
|
|
18
|
+
* if (solveComment) {
|
|
19
|
+
* const phases = parseSolveWorkflow(solveComment.body);
|
|
20
|
+
* // phases: { phases: ['spec', 'exec', 'test', 'qa'], qualityLoop: false }
|
|
21
|
+
* }
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* Markers that indicate a solve comment
|
|
26
|
+
*/
|
|
27
|
+
const SOLVE_MARKERS = [
|
|
28
|
+
"## Solve Workflow for Issues:",
|
|
29
|
+
"## Solve Workflow for Issue:",
|
|
30
|
+
"### Recommended Workflow",
|
|
31
|
+
"*📝 Generated by `/solve",
|
|
32
|
+
];
|
|
33
|
+
/**
|
|
34
|
+
* Pattern to extract phases from solve workflow
|
|
35
|
+
* Matches: `/spec`, `/exec`, `/test`, `/qa`, etc.
|
|
36
|
+
* Also matches without slash: `spec`, `exec`, `test`, `qa` (for arrow notation)
|
|
37
|
+
*/
|
|
38
|
+
const PHASE_PATTERN = /\/?(?<!\w)(spec|exec|test|qa|security-review|testgen|loop)(?!\w)/g;
|
|
39
|
+
/**
|
|
40
|
+
* Pattern to detect quality loop recommendation
|
|
41
|
+
*/
|
|
42
|
+
const QUALITY_LOOP_PATTERNS = [
|
|
43
|
+
/quality\s*loop.*auto-enable/i,
|
|
44
|
+
/--quality-loop/i,
|
|
45
|
+
/quality\s*loop.*recommended/i,
|
|
46
|
+
/enable.*quality\s*loop/i,
|
|
47
|
+
];
|
|
48
|
+
/**
|
|
49
|
+
* Pattern to extract issue numbers from solve header
|
|
50
|
+
* Matches: "## Solve Workflow for Issues: 123, 456"
|
|
51
|
+
*/
|
|
52
|
+
const ISSUE_NUMBER_PATTERN = /#?(\d+)/g;
|
|
53
|
+
/**
|
|
54
|
+
* Check if a comment is a solve command output
|
|
55
|
+
*
|
|
56
|
+
* @param body - The comment body
|
|
57
|
+
* @returns True if this appears to be a solve comment
|
|
58
|
+
*/
|
|
59
|
+
export function isSolveComment(body) {
|
|
60
|
+
return SOLVE_MARKERS.some((marker) => body.includes(marker));
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Find the most recent solve comment from a list of comments
|
|
64
|
+
*
|
|
65
|
+
* @param comments - Array of issue comments
|
|
66
|
+
* @returns The solve comment if found, null otherwise
|
|
67
|
+
*/
|
|
68
|
+
export function findSolveComment(comments) {
|
|
69
|
+
// Search from most recent to oldest (assuming comments are in chronological order)
|
|
70
|
+
for (let i = comments.length - 1; i >= 0; i--) {
|
|
71
|
+
if (isSolveComment(comments[i].body)) {
|
|
72
|
+
return comments[i];
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Parse phases from a solve workflow string
|
|
79
|
+
*
|
|
80
|
+
* @param workflowString - String like "spec → exec → test → qa"
|
|
81
|
+
* @returns Array of phases
|
|
82
|
+
*/
|
|
83
|
+
function parseWorkflowString(workflowString) {
|
|
84
|
+
const phases = [];
|
|
85
|
+
const matches = workflowString.matchAll(PHASE_PATTERN);
|
|
86
|
+
for (const match of matches) {
|
|
87
|
+
const phase = match[1];
|
|
88
|
+
if (!phases.includes(phase)) {
|
|
89
|
+
phases.push(phase);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return phases;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Parse a solve comment to extract workflow information
|
|
96
|
+
*
|
|
97
|
+
* @param body - The solve comment body
|
|
98
|
+
* @returns Parsed workflow result
|
|
99
|
+
*/
|
|
100
|
+
export function parseSolveWorkflow(body) {
|
|
101
|
+
const result = {
|
|
102
|
+
phases: [],
|
|
103
|
+
qualityLoop: false,
|
|
104
|
+
issueNumbers: [],
|
|
105
|
+
};
|
|
106
|
+
// Extract issue numbers from header
|
|
107
|
+
const headerMatch = body.match(/## Solve Workflow for Issues?:\s*([^\n]+)/i);
|
|
108
|
+
if (headerMatch) {
|
|
109
|
+
const numberMatches = headerMatch[1].matchAll(ISSUE_NUMBER_PATTERN);
|
|
110
|
+
for (const match of numberMatches) {
|
|
111
|
+
const num = parseInt(match[1], 10);
|
|
112
|
+
if (!isNaN(num) && !result.issueNumbers.includes(num)) {
|
|
113
|
+
result.issueNumbers.push(num);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// Find workflow lines (e.g., "/spec 152" or "spec → exec → qa")
|
|
118
|
+
const lines = body.split("\n");
|
|
119
|
+
// First pass: look for arrow notation (most reliable)
|
|
120
|
+
for (const line of lines) {
|
|
121
|
+
if (line.includes("→") || line.includes("->")) {
|
|
122
|
+
const phases = parseWorkflowString(line);
|
|
123
|
+
if (phases.length > 0) {
|
|
124
|
+
result.phases = phases;
|
|
125
|
+
result.workflowString = line.trim();
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
// Second pass: if no arrow notation found, collect phases from multiple lines
|
|
131
|
+
if (result.phases.length === 0) {
|
|
132
|
+
const collectedPhases = [];
|
|
133
|
+
for (const line of lines) {
|
|
134
|
+
// Look for slash command patterns on individual lines
|
|
135
|
+
if (line.includes("/spec") ||
|
|
136
|
+
line.includes("/exec") ||
|
|
137
|
+
line.includes("/test") ||
|
|
138
|
+
line.includes("/qa")) {
|
|
139
|
+
const linePhases = parseWorkflowString(line);
|
|
140
|
+
for (const phase of linePhases) {
|
|
141
|
+
if (!collectedPhases.includes(phase)) {
|
|
142
|
+
collectedPhases.push(phase);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (collectedPhases.length > 0) {
|
|
148
|
+
result.phases = collectedPhases;
|
|
149
|
+
result.workflowString = collectedPhases.map((p) => `/${p}`).join(" → ");
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
// If no phases found from lines, try full body
|
|
153
|
+
if (result.phases.length === 0) {
|
|
154
|
+
result.phases = parseWorkflowString(body);
|
|
155
|
+
}
|
|
156
|
+
// Check for quality loop recommendation
|
|
157
|
+
for (const pattern of QUALITY_LOOP_PATTERNS) {
|
|
158
|
+
if (pattern.test(body)) {
|
|
159
|
+
result.qualityLoop = true;
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return result;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Convert solve workflow result to phase signals
|
|
167
|
+
*
|
|
168
|
+
* @param workflow - The parsed solve workflow
|
|
169
|
+
* @returns Array of phase signals with 'solve' source
|
|
170
|
+
*/
|
|
171
|
+
export function solveWorkflowToSignals(workflow) {
|
|
172
|
+
const signals = [];
|
|
173
|
+
for (const phase of workflow.phases) {
|
|
174
|
+
signals.push({
|
|
175
|
+
phase,
|
|
176
|
+
source: "solve",
|
|
177
|
+
confidence: "high",
|
|
178
|
+
reason: `Recommended by /solve command: ${workflow.workflowString || phase}`,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
if (workflow.qualityLoop) {
|
|
182
|
+
signals.push({
|
|
183
|
+
phase: "quality-loop",
|
|
184
|
+
source: "solve",
|
|
185
|
+
confidence: "high",
|
|
186
|
+
reason: "Quality loop recommended by /solve command",
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
return signals;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Check if solve comment covers the current issue
|
|
193
|
+
*
|
|
194
|
+
* @param workflow - The parsed solve workflow
|
|
195
|
+
* @param issueNumber - The current issue number
|
|
196
|
+
* @returns True if the solve comment includes this issue
|
|
197
|
+
*/
|
|
198
|
+
export function solveCoversIssue(workflow, issueNumber) {
|
|
199
|
+
return workflow.issueNumbers.includes(issueNumber);
|
|
200
|
+
}
|