@claudemini/ses-cli 1.4.3
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 +465 -0
- package/bin/ses.js +85 -0
- package/lib/agent-review.js +722 -0
- package/lib/checkpoint.js +320 -0
- package/lib/checkpoints.js +54 -0
- package/lib/clean.js +45 -0
- package/lib/commit.js +60 -0
- package/lib/config.js +28 -0
- package/lib/disable.js +152 -0
- package/lib/doctor.js +307 -0
- package/lib/enable.js +294 -0
- package/lib/explain.js +212 -0
- package/lib/extract.js +265 -0
- package/lib/git-shadow.js +136 -0
- package/lib/init.js +83 -0
- package/lib/list.js +62 -0
- package/lib/log.js +77 -0
- package/lib/prompts.js +125 -0
- package/lib/query.js +110 -0
- package/lib/redact.js +170 -0
- package/lib/report.js +296 -0
- package/lib/reset.js +122 -0
- package/lib/resume.js +224 -0
- package/lib/review-common.js +100 -0
- package/lib/review.js +652 -0
- package/lib/rewind.js +198 -0
- package/lib/session.js +225 -0
- package/lib/shadow.js +51 -0
- package/lib/status.js +198 -0
- package/lib/summarize.js +315 -0
- package/lib/view.js +50 -0
- package/lib/webhook.js +224 -0
- package/package.json +41 -0
package/lib/review.js
ADDED
|
@@ -0,0 +1,652 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured code review over session artifacts.
|
|
3
|
+
* Uses goatchain agent output and keeps the existing findings/report schema.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { existsSync, readdirSync, statSync, writeFileSync } from 'fs';
|
|
7
|
+
import { join } from 'path';
|
|
8
|
+
import { createHash } from 'crypto';
|
|
9
|
+
import { getLogDir, getProjectRoot, SESSION_ID_REGEX } from './config.js';
|
|
10
|
+
import { dispatchWebhook } from './webhook.js';
|
|
11
|
+
import {
|
|
12
|
+
loadJsonIfExists,
|
|
13
|
+
normalizeEvidence,
|
|
14
|
+
normalizeLocation,
|
|
15
|
+
normalizeLocationKey,
|
|
16
|
+
} from './review-common.js';
|
|
17
|
+
|
|
18
|
+
const SEVERITY_SCORE = {
|
|
19
|
+
info: 1,
|
|
20
|
+
low: 2,
|
|
21
|
+
medium: 3,
|
|
22
|
+
high: 4,
|
|
23
|
+
critical: 5,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const CONFIDENCE_SCORE = {
|
|
27
|
+
low: 1,
|
|
28
|
+
medium: 2,
|
|
29
|
+
high: 3,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function parseArgs(args) {
|
|
33
|
+
const options = {
|
|
34
|
+
format: 'text',
|
|
35
|
+
strict: false,
|
|
36
|
+
minSeverity: 'info',
|
|
37
|
+
failOn: 'high',
|
|
38
|
+
sessionId: null,
|
|
39
|
+
recent: 1,
|
|
40
|
+
all: false,
|
|
41
|
+
help: false,
|
|
42
|
+
engine: 'agent',
|
|
43
|
+
agentTimeoutMs: null,
|
|
44
|
+
allowWorktreeDiff: false,
|
|
45
|
+
agentAutoApprove: true,
|
|
46
|
+
deprecatedFlags: [],
|
|
47
|
+
deprecatedEngineValue: null,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
for (const arg of args) {
|
|
51
|
+
if (arg === '--json') {
|
|
52
|
+
options.format = 'json';
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (arg === '--markdown' || arg === '--md') {
|
|
56
|
+
options.format = 'markdown';
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (arg === '--strict') {
|
|
60
|
+
options.strict = true;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (arg === '--all') {
|
|
64
|
+
options.all = true;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (arg === '--agent') {
|
|
68
|
+
options.deprecatedFlags.push('--agent');
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (arg === '--help' || arg === '-h') {
|
|
72
|
+
options.help = true;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (arg.startsWith('--recent=')) {
|
|
76
|
+
const value = Number.parseInt(arg.split('=')[1], 10);
|
|
77
|
+
if (Number.isFinite(value) && value > 0) {
|
|
78
|
+
options.recent = value;
|
|
79
|
+
}
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (arg.startsWith('--min-severity=')) {
|
|
83
|
+
const value = (arg.split('=')[1] || '').toLowerCase();
|
|
84
|
+
if (SEVERITY_SCORE[value]) {
|
|
85
|
+
options.minSeverity = value;
|
|
86
|
+
}
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (arg.startsWith('--fail-on=')) {
|
|
90
|
+
const value = (arg.split('=')[1] || '').toLowerCase();
|
|
91
|
+
if (SEVERITY_SCORE[value]) {
|
|
92
|
+
options.failOn = value;
|
|
93
|
+
}
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (arg.startsWith('--engine=')) {
|
|
97
|
+
const value = (arg.split('=')[1] || '').toLowerCase();
|
|
98
|
+
options.deprecatedFlags.push('--engine');
|
|
99
|
+
options.deprecatedEngineValue = value || null;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (arg.startsWith('--agent-timeout-ms=')) {
|
|
103
|
+
const value = Number.parseInt(arg.split('=')[1], 10);
|
|
104
|
+
if (Number.isFinite(value) && value > 0) {
|
|
105
|
+
options.agentTimeoutMs = value;
|
|
106
|
+
}
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (arg === '--allow-worktree-diff') {
|
|
110
|
+
options.allowWorktreeDiff = true;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (arg === '--agent-auto-approve') {
|
|
114
|
+
options.agentAutoApprove = true;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (arg === '--no-agent-auto-approve') {
|
|
118
|
+
options.agentAutoApprove = false;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (arg.startsWith('--agent-auto-approve=')) {
|
|
122
|
+
const value = (arg.split('=')[1] || '').toLowerCase();
|
|
123
|
+
if (value === 'true' || value === '1' || value === 'yes') {
|
|
124
|
+
options.agentAutoApprove = true;
|
|
125
|
+
}
|
|
126
|
+
if (value === 'false' || value === '0' || value === 'no') {
|
|
127
|
+
options.agentAutoApprove = false;
|
|
128
|
+
}
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (!arg.startsWith('-') && !options.sessionId) {
|
|
132
|
+
options.sessionId = arg;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return options;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function printUsage() {
|
|
140
|
+
console.log('Usage: ses review [session-id] [options]');
|
|
141
|
+
console.log('');
|
|
142
|
+
console.log('Options:');
|
|
143
|
+
console.log(' --json Output JSON report');
|
|
144
|
+
console.log(' --markdown, --md Output Markdown report');
|
|
145
|
+
console.log(' --recent=<n> Review latest n sessions (default: 1)');
|
|
146
|
+
console.log(' --all Review all sessions');
|
|
147
|
+
console.log(' --min-severity=<level> Filter findings below severity');
|
|
148
|
+
console.log(' --fail-on=<level> Strict mode failure threshold (default: high)');
|
|
149
|
+
console.log(' --agent-timeout-ms=<ms> Agent timeout (default: 60s base + 30s/session)');
|
|
150
|
+
console.log(' --allow-worktree-diff Allow worktree diff fallback without checkpoint commits');
|
|
151
|
+
console.log(' --agent-auto-approve=<bool> Auto approve agent tool calls (default: true)');
|
|
152
|
+
console.log(' --no-agent-auto-approve Disable auto approval for agent tool calls');
|
|
153
|
+
console.log(' --strict Exit non-zero when findings hit fail-on threshold');
|
|
154
|
+
console.log('');
|
|
155
|
+
console.log('Deprecated options (accepted but ignored):');
|
|
156
|
+
console.log(' --agent');
|
|
157
|
+
console.log(' --engine=<name>');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function getSessionDirs(logDir) {
|
|
161
|
+
if (!existsSync(logDir)) {
|
|
162
|
+
return [];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const sessions = [];
|
|
166
|
+
for (const name of readdirSync(logDir)) {
|
|
167
|
+
if (!SESSION_ID_REGEX.test(name)) {
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const fullPath = join(logDir, name);
|
|
172
|
+
let stat;
|
|
173
|
+
try {
|
|
174
|
+
stat = statSync(fullPath);
|
|
175
|
+
} catch {
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (!stat.isDirectory()) {
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
sessions.push({
|
|
184
|
+
id: name,
|
|
185
|
+
dir: fullPath,
|
|
186
|
+
mtime: stat.mtime.getTime(),
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return sessions.sort((a, b) => b.mtime - a.mtime);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function normalizeFinding(input) {
|
|
194
|
+
const sessions = Array.isArray(input.sessions) ? [...new Set(input.sessions)] : [];
|
|
195
|
+
return {
|
|
196
|
+
id: input.id || '',
|
|
197
|
+
rule_id: input.rule_id || 'review.generic',
|
|
198
|
+
category: input.category || 'maintainability',
|
|
199
|
+
severity: input.severity || 'low',
|
|
200
|
+
confidence: input.confidence || 'medium',
|
|
201
|
+
summary: input.summary || '',
|
|
202
|
+
details: input.details || '',
|
|
203
|
+
suggestion: input.suggestion || '',
|
|
204
|
+
location: normalizeLocation(input.location),
|
|
205
|
+
evidence: normalizeEvidence(input.evidence, {
|
|
206
|
+
fallbackItems: sessions.length > 0 ? sessions.map(id => `session_id=${id}`) : ['evidence_missing=true'],
|
|
207
|
+
}),
|
|
208
|
+
sessions,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function canonicalFindingKey(finding) {
|
|
213
|
+
return [
|
|
214
|
+
String(finding.rule_id || '').trim().toLowerCase(),
|
|
215
|
+
String(finding.category || '').trim().toLowerCase(),
|
|
216
|
+
normalizeLocationKey(finding.location),
|
|
217
|
+
String(finding.summary || '').trim().toLowerCase(),
|
|
218
|
+
].join('|');
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function buildFindingId(finding) {
|
|
222
|
+
const canonical = canonicalFindingKey(finding);
|
|
223
|
+
const digest = createHash('sha256').update(canonical).digest('hex');
|
|
224
|
+
return `f_${digest.slice(0, 16)}`;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function dedupeFindings(findings) {
|
|
228
|
+
const map = new Map();
|
|
229
|
+
|
|
230
|
+
for (const candidate of findings) {
|
|
231
|
+
const finding = normalizeFinding(candidate);
|
|
232
|
+
const key = canonicalFindingKey(finding);
|
|
233
|
+
finding.id = buildFindingId(finding);
|
|
234
|
+
|
|
235
|
+
if (!map.has(key)) {
|
|
236
|
+
map.set(key, finding);
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const prev = map.get(key);
|
|
241
|
+
const merged = {
|
|
242
|
+
...prev,
|
|
243
|
+
severity: SEVERITY_SCORE[finding.severity] > SEVERITY_SCORE[prev.severity] ? finding.severity : prev.severity,
|
|
244
|
+
confidence: (CONFIDENCE_SCORE[finding.confidence] || 0) > (CONFIDENCE_SCORE[prev.confidence] || 0)
|
|
245
|
+
? finding.confidence
|
|
246
|
+
: prev.confidence,
|
|
247
|
+
details: prev.details.length >= finding.details.length ? prev.details : finding.details,
|
|
248
|
+
suggestion: prev.suggestion || finding.suggestion,
|
|
249
|
+
evidence: [...new Set([...prev.evidence, ...finding.evidence])],
|
|
250
|
+
sessions: [...new Set([...(prev.sessions || []), ...(finding.sessions || [])])],
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
merged.id = prev.id;
|
|
254
|
+
map.set(key, merged);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return [...map.values()].sort((a, b) => SEVERITY_SCORE[b.severity] - SEVERITY_SCORE[a.severity]);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function generateSummaryMissingFinding(sessionId) {
|
|
261
|
+
return {
|
|
262
|
+
rule_id: 'integrity.missing_summary',
|
|
263
|
+
category: 'correctness',
|
|
264
|
+
severity: 'high',
|
|
265
|
+
confidence: 'high',
|
|
266
|
+
summary: 'Session summary artifact is missing or invalid',
|
|
267
|
+
details: `summary.json was missing/corrupted for session ${sessionId}, review confidence is degraded.`,
|
|
268
|
+
suggestion: 'Re-run session processing or inspect raw events to reconstruct summary artifacts.',
|
|
269
|
+
evidence: [`session_id=${sessionId}`],
|
|
270
|
+
sessions: [sessionId],
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function generateCrossSessionFindings(snapshots) {
|
|
275
|
+
if (snapshots.length < 2) {
|
|
276
|
+
return [];
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const fileToSessions = new Map();
|
|
280
|
+
|
|
281
|
+
for (const snap of snapshots) {
|
|
282
|
+
const sourcePaths = collectSessionSourcePaths(snap);
|
|
283
|
+
for (const sourcePath of sourcePaths) {
|
|
284
|
+
if (!fileToSessions.has(sourcePath)) {
|
|
285
|
+
fileToSessions.set(sourcePath, new Set());
|
|
286
|
+
}
|
|
287
|
+
fileToSessions.get(sourcePath).add(snap.id);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const hotFiles = [...fileToSessions.entries()]
|
|
292
|
+
.filter(([, sessions]) => sessions.size >= 2)
|
|
293
|
+
.sort((a, b) => b[1].size - a[1].size);
|
|
294
|
+
|
|
295
|
+
if (hotFiles.length === 0) {
|
|
296
|
+
return [];
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const HOT_FILE_LIMIT = 3;
|
|
300
|
+
return hotFiles.slice(0, HOT_FILE_LIMIT).map(([filePath, sessionSet], idx) => ({
|
|
301
|
+
rule_id: 'maintainability.hot_file_across_sessions',
|
|
302
|
+
category: 'maintainability',
|
|
303
|
+
severity: sessionSet.size >= 4 ? 'high' : 'medium',
|
|
304
|
+
confidence: 'medium',
|
|
305
|
+
summary: 'Same source file modified across multiple reviewed sessions',
|
|
306
|
+
details: `File "${filePath}" was modified in ${sessionSet.size} reviewed sessions, which may signal unresolved churn.`,
|
|
307
|
+
suggestion: 'Investigate root cause and consolidate related changes into a single reviewed thread.',
|
|
308
|
+
location: { file: filePath },
|
|
309
|
+
evidence: [
|
|
310
|
+
`hot_file=${filePath}`,
|
|
311
|
+
`session_count=${sessionSet.size}`,
|
|
312
|
+
`hot_file_rank=${idx + 1}`,
|
|
313
|
+
...[...sessionSet].slice(0, 5).map(id => `session_id=${id}`),
|
|
314
|
+
],
|
|
315
|
+
sessions: [...sessionSet],
|
|
316
|
+
}));
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function collectSessionSourcePaths(snapshot) {
|
|
320
|
+
const pathsFromSummary = Array.isArray(snapshot.summary?.changes?.files)
|
|
321
|
+
? snapshot.summary.changes.files
|
|
322
|
+
.filter(file => file?.category === 'source')
|
|
323
|
+
.map(file => String(file.path || '').trim())
|
|
324
|
+
.filter(Boolean)
|
|
325
|
+
: [];
|
|
326
|
+
|
|
327
|
+
if (pathsFromSummary.length > 0) {
|
|
328
|
+
return [...new Set(pathsFromSummary)];
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const statePaths = new Set();
|
|
332
|
+
collectPathCandidates(snapshot.state?.edits, statePaths);
|
|
333
|
+
collectPathCandidates(snapshot.state?.file_ops, statePaths);
|
|
334
|
+
return [...statePaths];
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function collectPathCandidates(entries, outputSet) {
|
|
338
|
+
if (!Array.isArray(entries)) {
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
for (const entry of entries) {
|
|
343
|
+
if (typeof entry === 'string') {
|
|
344
|
+
addPathCandidate(entry, outputSet);
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
if (!entry || typeof entry !== 'object') {
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
addPathCandidate(entry.path, outputSet);
|
|
352
|
+
addPathCandidate(entry.file, outputSet);
|
|
353
|
+
addPathCandidate(entry.target, outputSet);
|
|
354
|
+
addPathCandidate(entry.source, outputSet);
|
|
355
|
+
addPathCandidate(entry.from, outputSet);
|
|
356
|
+
addPathCandidate(entry.to, outputSet);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function addPathCandidate(pathValue, outputSet) {
|
|
361
|
+
const path = String(pathValue || '').trim();
|
|
362
|
+
if (!path) {
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
if (path.startsWith('.git/') || path.startsWith('.ses-logs/') || path.includes('/.git/')) {
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
outputSet.add(path);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function filterBySeverity(findings, minSeverity) {
|
|
372
|
+
const threshold = SEVERITY_SCORE[minSeverity] || SEVERITY_SCORE.info;
|
|
373
|
+
return findings.filter(f => (SEVERITY_SCORE[f.severity] || 0) >= threshold);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function shouldFailByThreshold(findings, failOn) {
|
|
377
|
+
const threshold = SEVERITY_SCORE[failOn] || SEVERITY_SCORE.high;
|
|
378
|
+
return findings.some(f => (SEVERITY_SCORE[f.severity] || 0) >= threshold);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function computeVerdict(findings, failOn) {
|
|
382
|
+
if (shouldFailByThreshold(findings, failOn)) {
|
|
383
|
+
return 'fail';
|
|
384
|
+
}
|
|
385
|
+
if (findings.length > 0) {
|
|
386
|
+
return 'warn';
|
|
387
|
+
}
|
|
388
|
+
return 'pass';
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function buildSessionSnapshot(sessionEntry) {
|
|
392
|
+
const summaryPath = join(sessionEntry.dir, 'summary.json');
|
|
393
|
+
const statePath = join(sessionEntry.dir, 'state.json');
|
|
394
|
+
const metadataPath = join(sessionEntry.dir, 'metadata.json');
|
|
395
|
+
|
|
396
|
+
const summary = loadJsonIfExists(summaryPath);
|
|
397
|
+
const state = loadJsonIfExists(statePath, {});
|
|
398
|
+
const metadata = loadJsonIfExists(metadataPath, {});
|
|
399
|
+
|
|
400
|
+
return {
|
|
401
|
+
id: sessionEntry.id,
|
|
402
|
+
summary,
|
|
403
|
+
state,
|
|
404
|
+
metadata,
|
|
405
|
+
source: {
|
|
406
|
+
summary_file: summaryPath,
|
|
407
|
+
state_file: statePath,
|
|
408
|
+
},
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function buildReportFromRawFindings(snapshots, rawFindings, options) {
|
|
413
|
+
const deduped = dedupeFindings(rawFindings);
|
|
414
|
+
const filteredFindings = filterBySeverity(deduped, options.minSeverity);
|
|
415
|
+
const verdict = computeVerdict(filteredFindings, options.failOn);
|
|
416
|
+
|
|
417
|
+
return {
|
|
418
|
+
schema_version: '1.2',
|
|
419
|
+
generated_at: new Date().toISOString(),
|
|
420
|
+
policy: {
|
|
421
|
+
min_severity: options.minSeverity,
|
|
422
|
+
fail_on: options.failOn,
|
|
423
|
+
strict: options.strict,
|
|
424
|
+
engine: options.engine,
|
|
425
|
+
recent: options.all ? 'all' : options.recent,
|
|
426
|
+
session_filter: options.sessionId || null,
|
|
427
|
+
},
|
|
428
|
+
verdict,
|
|
429
|
+
sessions: snapshots.map(snap => ({
|
|
430
|
+
id: snap.id,
|
|
431
|
+
type: snap.summary?.session?.type || snap.metadata?.type || 'unknown',
|
|
432
|
+
risk: snap.summary?.session?.risk || snap.metadata?.risk || 'unknown',
|
|
433
|
+
duration_minutes: snap.summary?.session?.duration_minutes ?? snap.metadata?.duration_minutes ?? 0,
|
|
434
|
+
source: snap.source,
|
|
435
|
+
})),
|
|
436
|
+
findings: filteredFindings,
|
|
437
|
+
stats: {
|
|
438
|
+
reviewed_sessions: snapshots.length,
|
|
439
|
+
total_findings: filteredFindings.length,
|
|
440
|
+
by_severity: Object.keys(SEVERITY_SCORE).reduce((acc, key) => {
|
|
441
|
+
acc[key] = filteredFindings.filter(f => f.severity === key).length;
|
|
442
|
+
return acc;
|
|
443
|
+
}, {}),
|
|
444
|
+
},
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function printHumanReport(report) {
|
|
449
|
+
console.log(`🧪 Code Review`);
|
|
450
|
+
console.log(` Sessions: ${report.stats.reviewed_sessions}`);
|
|
451
|
+
console.log(` Verdict: ${report.verdict.toUpperCase()} | Findings: ${report.findings.length}`);
|
|
452
|
+
console.log(` Policy: engine=${report.policy.engine}, min=${report.policy.min_severity}, fail-on=${report.policy.fail_on}`);
|
|
453
|
+
console.log();
|
|
454
|
+
|
|
455
|
+
if (report.sessions.length === 1) {
|
|
456
|
+
const session = report.sessions[0];
|
|
457
|
+
console.log(`📦 Session: ${session.id}`);
|
|
458
|
+
console.log(` Type: ${session.type} | Risk: ${session.risk} | Duration: ${session.duration_minutes}m`);
|
|
459
|
+
console.log();
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
if (report.findings.length === 0) {
|
|
463
|
+
console.log('✅ No findings above current severity threshold.');
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
for (const [idx, finding] of report.findings.entries()) {
|
|
468
|
+
console.log(`${idx + 1}. [${finding.severity.toUpperCase()}][${finding.category}] ${finding.summary}`);
|
|
469
|
+
console.log(` Rule: ${finding.rule_id} | Confidence: ${finding.confidence}`);
|
|
470
|
+
if (finding.details) {
|
|
471
|
+
console.log(` Detail: ${finding.details}`);
|
|
472
|
+
}
|
|
473
|
+
if (finding.location?.file) {
|
|
474
|
+
console.log(` Location: ${finding.location.file}`);
|
|
475
|
+
}
|
|
476
|
+
if (finding.sessions?.length > 0) {
|
|
477
|
+
console.log(` Sessions: ${finding.sessions.slice(0, 3).join(', ')}${finding.sessions.length > 3 ? ` (+${finding.sessions.length - 3})` : ''}`);
|
|
478
|
+
}
|
|
479
|
+
if (finding.evidence.length > 0) {
|
|
480
|
+
console.log(` Evidence: ${finding.evidence.slice(0, 3).join(' | ')}`);
|
|
481
|
+
}
|
|
482
|
+
if (finding.suggestion) {
|
|
483
|
+
console.log(` Suggestion: ${finding.suggestion}`);
|
|
484
|
+
}
|
|
485
|
+
console.log();
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function buildMarkdownReport(report) {
|
|
490
|
+
const lines = [];
|
|
491
|
+
lines.push('# Code Review Report');
|
|
492
|
+
lines.push('');
|
|
493
|
+
lines.push(`- Generated: **${report.generated_at}**`);
|
|
494
|
+
lines.push(`- Verdict: **${report.verdict.toUpperCase()}**`);
|
|
495
|
+
lines.push(`- Sessions: **${report.stats.reviewed_sessions}**`);
|
|
496
|
+
lines.push(`- Findings: **${report.findings.length}**`);
|
|
497
|
+
lines.push(`- Policy: \`engine=${report.policy.engine}, min=${report.policy.min_severity}, fail-on=${report.policy.fail_on}\``);
|
|
498
|
+
lines.push('');
|
|
499
|
+
|
|
500
|
+
lines.push('## Findings');
|
|
501
|
+
lines.push('');
|
|
502
|
+
|
|
503
|
+
if (report.findings.length === 0) {
|
|
504
|
+
lines.push('No findings above threshold.');
|
|
505
|
+
return lines.join('\n');
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
lines.push('| Severity | Category | Rule | Summary | Sessions |');
|
|
509
|
+
lines.push('|---|---|---|---|---|');
|
|
510
|
+
for (const finding of report.findings) {
|
|
511
|
+
const sessions = finding.sessions?.length || 0;
|
|
512
|
+
const summary = finding.summary.replace(/\|/g, '\\|');
|
|
513
|
+
lines.push(`| ${finding.severity.toUpperCase()} | ${finding.category} | \`${finding.rule_id}\` | ${summary} | ${sessions} |`);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
lines.push('');
|
|
517
|
+
lines.push('## Details');
|
|
518
|
+
lines.push('');
|
|
519
|
+
|
|
520
|
+
for (const finding of report.findings) {
|
|
521
|
+
lines.push(`### [${finding.severity.toUpperCase()}] ${finding.summary}`);
|
|
522
|
+
lines.push(`- Rule: \`${finding.rule_id}\``);
|
|
523
|
+
lines.push(`- Confidence: \`${finding.confidence}\``);
|
|
524
|
+
if (finding.sessions?.length > 0) {
|
|
525
|
+
lines.push(`- Sessions: ${finding.sessions.join(', ')}`);
|
|
526
|
+
}
|
|
527
|
+
if (finding.location?.file) {
|
|
528
|
+
lines.push(`- Location: \`${finding.location.file}\``);
|
|
529
|
+
}
|
|
530
|
+
if (finding.details) {
|
|
531
|
+
lines.push(`- Detail: ${finding.details}`);
|
|
532
|
+
}
|
|
533
|
+
if (finding.suggestion) {
|
|
534
|
+
lines.push(`- Suggestion: ${finding.suggestion}`);
|
|
535
|
+
}
|
|
536
|
+
if (finding.evidence.length > 0) {
|
|
537
|
+
lines.push(`- Evidence: ${finding.evidence.slice(0, 5).join(' | ')}`);
|
|
538
|
+
}
|
|
539
|
+
lines.push('');
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
return lines.join('\n');
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function saveReviewArtifacts(logDir, report, markdown) {
|
|
546
|
+
const sessionIds = report.sessions.map(s => s.id);
|
|
547
|
+
|
|
548
|
+
for (const sessionId of sessionIds) {
|
|
549
|
+
const sessionDir = join(logDir, sessionId);
|
|
550
|
+
if (!existsSync(sessionDir)) {
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
try {
|
|
554
|
+
writeFileSync(join(sessionDir, 'review.md'), markdown, 'utf8');
|
|
555
|
+
writeFileSync(join(sessionDir, 'review.json'), JSON.stringify(report, null, 2), 'utf8');
|
|
556
|
+
} catch {
|
|
557
|
+
// Best-effort, don't fail review.
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const savedBoth = sessionIds.filter((id) => {
|
|
562
|
+
const sessionDir = join(logDir, id);
|
|
563
|
+
return existsSync(join(sessionDir, 'review.md')) && existsSync(join(sessionDir, 'review.json'));
|
|
564
|
+
});
|
|
565
|
+
if (savedBoth.length > 0) {
|
|
566
|
+
console.error(`📝 Review saved to ${savedBoth.length} session(s): review.md, review.json`);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function selectSessions(allSessions, options) {
|
|
571
|
+
if (options.sessionId) {
|
|
572
|
+
const matched = allSessions.find(s => s.id === options.sessionId || s.id.startsWith(options.sessionId));
|
|
573
|
+
return matched ? [matched] : [];
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
if (options.all) {
|
|
577
|
+
return allSessions;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
return allSessions.slice(0, options.recent);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
export default async function review(args) {
|
|
584
|
+
try {
|
|
585
|
+
const options = parseArgs(args);
|
|
586
|
+
if (options.help) {
|
|
587
|
+
printUsage();
|
|
588
|
+
return 0;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
if (options.deprecatedFlags.length > 0) {
|
|
592
|
+
console.error(`⚠️ Deprecated review flags are ignored: ${[...new Set(options.deprecatedFlags)].join(', ')}.`);
|
|
593
|
+
if (options.deprecatedEngineValue && options.deprecatedEngineValue !== 'agent') {
|
|
594
|
+
console.error(`⚠️ --engine=${options.deprecatedEngineValue} is unsupported. Using goatchain agent review.`);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const projectRoot = getProjectRoot();
|
|
599
|
+
const logDir = getLogDir(projectRoot);
|
|
600
|
+
const sessions = getSessionDirs(logDir);
|
|
601
|
+
|
|
602
|
+
if (sessions.length === 0) {
|
|
603
|
+
console.error('❌ No sessions found for review.');
|
|
604
|
+
return 1;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const selectedSessions = selectSessions(sessions, options);
|
|
608
|
+
if (selectedSessions.length === 0) {
|
|
609
|
+
console.error(`❌ Session not found: ${options.sessionId}`);
|
|
610
|
+
return 1;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const snapshots = selectedSessions.map(buildSessionSnapshot);
|
|
614
|
+
const rawFindings = [];
|
|
615
|
+
|
|
616
|
+
for (const snap of snapshots) {
|
|
617
|
+
if (!snap.summary) {
|
|
618
|
+
rawFindings.push(generateSummaryMissingFinding(snap.id));
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const { runAgentReview } = await import('./agent-review.js');
|
|
623
|
+
const agentFindings = await runAgentReview(projectRoot, selectedSessions, options);
|
|
624
|
+
rawFindings.push(...agentFindings);
|
|
625
|
+
// Intentional: include snapshots without summary and fallback to state paths when available.
|
|
626
|
+
rawFindings.push(...generateCrossSessionFindings(snapshots));
|
|
627
|
+
|
|
628
|
+
const report = buildReportFromRawFindings(snapshots, rawFindings, options);
|
|
629
|
+
const markdown = buildMarkdownReport(report);
|
|
630
|
+
|
|
631
|
+
saveReviewArtifacts(logDir, report, markdown);
|
|
632
|
+
await dispatchWebhook(projectRoot, 'review.completed', report);
|
|
633
|
+
|
|
634
|
+
if (options.format === 'json') {
|
|
635
|
+
console.log(JSON.stringify(report, null, 2));
|
|
636
|
+
} else if (options.format === 'markdown') {
|
|
637
|
+
console.log(markdown);
|
|
638
|
+
} else {
|
|
639
|
+
printHumanReport(report);
|
|
640
|
+
console.log('Options: --json --markdown --recent=<n> --all --strict --min-severity=<...> --fail-on=<...> --agent-timeout-ms=<...>');
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
if (options.strict && shouldFailByThreshold(report.findings, options.failOn)) {
|
|
644
|
+
return 1;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
return 0;
|
|
648
|
+
} catch (error) {
|
|
649
|
+
console.error('❌ Failed to run review:', error.message);
|
|
650
|
+
return 1;
|
|
651
|
+
}
|
|
652
|
+
}
|