@claudemini/shit-cli 1.4.0 โ 1.6.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 +139 -116
- package/bin/shit.js +17 -6
- package/lib/checkpoint.js +39 -32
- package/lib/checkpoints.js +3 -15
- package/lib/commit.js +7 -15
- package/lib/config.js +3 -1
- package/lib/disable.js +54 -18
- package/lib/doctor.js +17 -24
- package/lib/enable.js +24 -13
- package/lib/explain.js +43 -38
- package/lib/reset.js +8 -16
- package/lib/resume.js +32 -27
- package/lib/review.js +728 -0
- package/lib/rewind.js +63 -38
- package/lib/session.js +6 -0
- package/lib/status.js +44 -19
- package/lib/summarize.js +2 -13
- package/package.json +21 -5
- package/.claude/settings.json +0 -81
- package/.claude/settings.local.json +0 -20
- package/COMPARISON.md +0 -92
- package/DESIGN_PHILOSOPHY.md +0 -138
- package/QUICKSTART.md +0 -109
package/lib/review.js
ADDED
|
@@ -0,0 +1,728 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured code review over session artifacts.
|
|
3
|
+
* Inspired by mco's findings model:
|
|
4
|
+
* - fixed findings schema
|
|
5
|
+
* - evidence-grounded findings
|
|
6
|
+
* - deterministic severity/confidence
|
|
7
|
+
* - dedup + aggregation for CI/PR workflows
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
|
|
11
|
+
import { join } from 'path';
|
|
12
|
+
import { createHash } from 'crypto';
|
|
13
|
+
import { getLogDir, getProjectRoot, SESSION_ID_REGEX } from './config.js';
|
|
14
|
+
import { redactSecrets } from './redact.js';
|
|
15
|
+
|
|
16
|
+
const SEVERITY_SCORE = {
|
|
17
|
+
info: 1,
|
|
18
|
+
low: 2,
|
|
19
|
+
medium: 3,
|
|
20
|
+
high: 4,
|
|
21
|
+
critical: 5,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const CONFIDENCE_SCORE = {
|
|
25
|
+
low: 1,
|
|
26
|
+
medium: 2,
|
|
27
|
+
high: 3,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function parseArgs(args) {
|
|
31
|
+
const options = {
|
|
32
|
+
format: 'text', // text | json | markdown
|
|
33
|
+
strict: false,
|
|
34
|
+
minSeverity: 'info',
|
|
35
|
+
failOn: 'high',
|
|
36
|
+
sessionId: null,
|
|
37
|
+
recent: 1,
|
|
38
|
+
all: false,
|
|
39
|
+
help: false,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
for (const arg of args) {
|
|
43
|
+
if (arg === '--json') {
|
|
44
|
+
options.format = 'json';
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (arg === '--markdown' || arg === '--md') {
|
|
48
|
+
options.format = 'markdown';
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (arg === '--strict') {
|
|
52
|
+
options.strict = true;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (arg === '--all') {
|
|
56
|
+
options.all = true;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (arg === '--help' || arg === '-h') {
|
|
60
|
+
options.help = true;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (arg.startsWith('--recent=')) {
|
|
64
|
+
const value = Number.parseInt(arg.split('=')[1], 10);
|
|
65
|
+
if (Number.isFinite(value) && value > 0) {
|
|
66
|
+
options.recent = value;
|
|
67
|
+
}
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (arg.startsWith('--min-severity=')) {
|
|
71
|
+
const value = (arg.split('=')[1] || '').toLowerCase();
|
|
72
|
+
if (SEVERITY_SCORE[value]) {
|
|
73
|
+
options.minSeverity = value;
|
|
74
|
+
}
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (arg.startsWith('--fail-on=')) {
|
|
78
|
+
const value = (arg.split('=')[1] || '').toLowerCase();
|
|
79
|
+
if (SEVERITY_SCORE[value]) {
|
|
80
|
+
options.failOn = value;
|
|
81
|
+
}
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (!arg.startsWith('-') && !options.sessionId) {
|
|
85
|
+
options.sessionId = arg;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return options;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function printUsage() {
|
|
93
|
+
console.log('Usage: shit review [session-id] [options]');
|
|
94
|
+
console.log('');
|
|
95
|
+
console.log('Options:');
|
|
96
|
+
console.log(' --json Output JSON report');
|
|
97
|
+
console.log(' --markdown, --md Output Markdown report');
|
|
98
|
+
console.log(' --recent=<n> Review latest n sessions (default: 1)');
|
|
99
|
+
console.log(' --all Review all sessions');
|
|
100
|
+
console.log(' --min-severity=<level> Filter findings below severity');
|
|
101
|
+
console.log(' --fail-on=<level> Strict mode failure threshold (default: high)');
|
|
102
|
+
console.log(' --strict Exit non-zero when findings hit fail-on threshold');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function getSessionDirs(logDir) {
|
|
106
|
+
if (!existsSync(logDir)) {
|
|
107
|
+
return [];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const sessions = [];
|
|
111
|
+
for (const name of readdirSync(logDir)) {
|
|
112
|
+
if (!SESSION_ID_REGEX.test(name)) {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const fullPath = join(logDir, name);
|
|
117
|
+
let stat;
|
|
118
|
+
try {
|
|
119
|
+
stat = statSync(fullPath);
|
|
120
|
+
} catch {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (!stat.isDirectory()) {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
sessions.push({
|
|
128
|
+
id: name,
|
|
129
|
+
dir: fullPath,
|
|
130
|
+
mtime: stat.mtime.getTime(),
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return sessions.sort((a, b) => b.mtime - a.mtime);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function loadJsonIfExists(filePath, fallback = null) {
|
|
138
|
+
if (!existsSync(filePath)) {
|
|
139
|
+
return fallback;
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
return JSON.parse(readFileSync(filePath, 'utf-8'));
|
|
143
|
+
} catch {
|
|
144
|
+
return fallback;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function normalizeEvidence(evidence) {
|
|
149
|
+
return evidence
|
|
150
|
+
.filter(Boolean)
|
|
151
|
+
.map(item => String(item).trim())
|
|
152
|
+
.filter(Boolean);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function normalizeFinding(input) {
|
|
156
|
+
return {
|
|
157
|
+
id: input.id || '',
|
|
158
|
+
rule_id: input.rule_id || 'review.generic',
|
|
159
|
+
category: input.category || 'maintainability',
|
|
160
|
+
severity: input.severity || 'low',
|
|
161
|
+
confidence: input.confidence || 'medium',
|
|
162
|
+
summary: input.summary || '',
|
|
163
|
+
details: input.details || '',
|
|
164
|
+
suggestion: input.suggestion || '',
|
|
165
|
+
location: input.location || null,
|
|
166
|
+
evidence: normalizeEvidence(input.evidence || []),
|
|
167
|
+
sessions: Array.isArray(input.sessions) ? [...new Set(input.sessions)] : [],
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function normalizeLocation(location) {
|
|
172
|
+
if (!location || typeof location !== 'object') {
|
|
173
|
+
return '';
|
|
174
|
+
}
|
|
175
|
+
const file = location.file || '';
|
|
176
|
+
const line = location.line || '';
|
|
177
|
+
const column = location.column || '';
|
|
178
|
+
return `${file}:${line}:${column}`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function canonicalFindingKey(finding) {
|
|
182
|
+
return [
|
|
183
|
+
String(finding.rule_id || '').trim().toLowerCase(),
|
|
184
|
+
String(finding.category || '').trim().toLowerCase(),
|
|
185
|
+
normalizeLocation(finding.location),
|
|
186
|
+
String(finding.summary || '').trim().toLowerCase(),
|
|
187
|
+
].join('|');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function buildFindingId(finding) {
|
|
191
|
+
const canonical = canonicalFindingKey(finding);
|
|
192
|
+
const digest = createHash('sha256').update(canonical).digest('hex');
|
|
193
|
+
return `f_${digest.slice(0, 16)}`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function dedupeFindings(findings) {
|
|
197
|
+
const map = new Map();
|
|
198
|
+
|
|
199
|
+
for (const candidate of findings) {
|
|
200
|
+
const finding = normalizeFinding(candidate);
|
|
201
|
+
const key = canonicalFindingKey(finding);
|
|
202
|
+
finding.id = buildFindingId(finding);
|
|
203
|
+
|
|
204
|
+
if (!map.has(key)) {
|
|
205
|
+
map.set(key, finding);
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const prev = map.get(key);
|
|
210
|
+
const merged = {
|
|
211
|
+
...prev,
|
|
212
|
+
severity: SEVERITY_SCORE[finding.severity] > SEVERITY_SCORE[prev.severity] ? finding.severity : prev.severity,
|
|
213
|
+
confidence: (CONFIDENCE_SCORE[finding.confidence] || 0) > (CONFIDENCE_SCORE[prev.confidence] || 0)
|
|
214
|
+
? finding.confidence
|
|
215
|
+
: prev.confidence,
|
|
216
|
+
details: prev.details.length >= finding.details.length ? prev.details : finding.details,
|
|
217
|
+
suggestion: prev.suggestion || finding.suggestion,
|
|
218
|
+
evidence: [...new Set([...prev.evidence, ...finding.evidence])],
|
|
219
|
+
sessions: [...new Set([...(prev.sessions || []), ...(finding.sessions || [])])],
|
|
220
|
+
};
|
|
221
|
+
merged.id = prev.id;
|
|
222
|
+
map.set(key, merged);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return [...map.values()].sort((a, b) => SEVERITY_SCORE[b.severity] - SEVERITY_SCORE[a.severity]);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function getAllCommands(summary) {
|
|
229
|
+
const commandGroups = summary?.activity?.commands || {};
|
|
230
|
+
return Object.values(commandGroups).flatMap(list => Array.isArray(list) ? list : []);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const WINDOWS_ABS_PATH_REGEX = /(^|[\s([{:="'`])([A-Za-z]:\\(?:[^\\\s"'`|<>]+\\){1,}[^\\\s"'`|<>]+)/g;
|
|
234
|
+
const UNIX_SENSITIVE_ROOT_PATH_REGEX = /(^|[\s([{:="'`])(\/(?:Users|home|var|tmp|opt|etc|private|Volumes|workspace|workspaces|usr)(?:\/[^\s"'`|<>]+){2,})/g;
|
|
235
|
+
const UNIX_FILE_PATH_WITH_EXT_REGEX = /(^|[\s([{:="'`])(\/(?:[^\/\s"'`|<>]+\/){2,}[^\/\s"'`|<>]*\.[A-Za-z0-9]{1,10})/g;
|
|
236
|
+
|
|
237
|
+
function sanitizeErrorDetails(message) {
|
|
238
|
+
const text = String(message || 'Tool returned an error');
|
|
239
|
+
const redacted = redactSecrets(text)
|
|
240
|
+
.replace(WINDOWS_ABS_PATH_REGEX, '$1[PATH]')
|
|
241
|
+
.replace(UNIX_SENSITIVE_ROOT_PATH_REGEX, '$1[PATH]')
|
|
242
|
+
.replace(UNIX_FILE_PATH_WITH_EXT_REGEX, '$1[PATH]')
|
|
243
|
+
.replace(/\b[A-Z_]{2,}=[^\s"'`|<>]+/g, '[ENV_REDACTED]');
|
|
244
|
+
return redacted.slice(0, 200);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function generateFindings(summary, state, sessionId) {
|
|
248
|
+
const findings = [];
|
|
249
|
+
const reviewHints = summary?.review_hints || {};
|
|
250
|
+
const changes = summary?.changes?.files || [];
|
|
251
|
+
const activity = summary?.activity || {};
|
|
252
|
+
const errors = Array.isArray(activity.errors) ? activity.errors : [];
|
|
253
|
+
const commands = getAllCommands(summary);
|
|
254
|
+
const modifiedSourceFiles = changes.filter(file =>
|
|
255
|
+
file.category === 'source' &&
|
|
256
|
+
Array.isArray(file.operations) &&
|
|
257
|
+
file.operations.some(op => op !== 'read')
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
if (modifiedSourceFiles.length > 0 && !reviewHints.tests_run) {
|
|
261
|
+
findings.push({
|
|
262
|
+
rule_id: 'testing.no_tests_after_source_changes',
|
|
263
|
+
category: 'testing',
|
|
264
|
+
severity: modifiedSourceFiles.length >= 5 ? 'high' : 'medium',
|
|
265
|
+
confidence: 'high',
|
|
266
|
+
summary: 'Source code changed without test execution evidence',
|
|
267
|
+
details: `Detected ${modifiedSourceFiles.length} modified source file(s), but no test command was recorded.`,
|
|
268
|
+
suggestion: 'Run targeted tests for changed modules and attach the command/output in the session.',
|
|
269
|
+
evidence: [
|
|
270
|
+
`session_id=${sessionId}`,
|
|
271
|
+
`review_hints.tests_run=${Boolean(reviewHints.tests_run)}`,
|
|
272
|
+
`modified_source_files=${modifiedSourceFiles.length}`,
|
|
273
|
+
],
|
|
274
|
+
sessions: [sessionId],
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (reviewHints.migration_added && !reviewHints.tests_run) {
|
|
279
|
+
findings.push({
|
|
280
|
+
rule_id: 'correctness.migration_without_tests',
|
|
281
|
+
category: 'correctness',
|
|
282
|
+
severity: 'high',
|
|
283
|
+
confidence: 'high',
|
|
284
|
+
summary: 'Database migration changed without test validation',
|
|
285
|
+
details: 'Migration-related changes are present and no test run was captured.',
|
|
286
|
+
suggestion: 'Run migration verification and regression tests before merge.',
|
|
287
|
+
evidence: [
|
|
288
|
+
`session_id=${sessionId}`,
|
|
289
|
+
`review_hints.migration_added=${Boolean(reviewHints.migration_added)}`,
|
|
290
|
+
`review_hints.tests_run=${Boolean(reviewHints.tests_run)}`,
|
|
291
|
+
],
|
|
292
|
+
sessions: [sessionId],
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (reviewHints.config_changed && !reviewHints.build_verified) {
|
|
297
|
+
findings.push({
|
|
298
|
+
rule_id: 'reliability.config_without_build',
|
|
299
|
+
category: 'reliability',
|
|
300
|
+
severity: 'medium',
|
|
301
|
+
confidence: 'high',
|
|
302
|
+
summary: 'Configuration changed without build verification',
|
|
303
|
+
details: 'Config-level edits were detected, but no build/compile command was recorded.',
|
|
304
|
+
suggestion: 'Run a full build/compile and include output to reduce deployment risk.',
|
|
305
|
+
evidence: [
|
|
306
|
+
`session_id=${sessionId}`,
|
|
307
|
+
`review_hints.config_changed=${Boolean(reviewHints.config_changed)}`,
|
|
308
|
+
`review_hints.build_verified=${Boolean(reviewHints.build_verified)}`,
|
|
309
|
+
],
|
|
310
|
+
sessions: [sessionId],
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (reviewHints.large_change && !reviewHints.tests_run && !reviewHints.build_verified) {
|
|
315
|
+
findings.push({
|
|
316
|
+
rule_id: 'maintainability.large_change_without_validation',
|
|
317
|
+
category: 'maintainability',
|
|
318
|
+
severity: 'high',
|
|
319
|
+
confidence: 'medium',
|
|
320
|
+
summary: 'Large change set lacks both test and build signals',
|
|
321
|
+
details: 'A large multi-file change was made without recorded test/build validation.',
|
|
322
|
+
suggestion: 'Split change into smaller PRs or provide CI/local validation evidence.',
|
|
323
|
+
evidence: [
|
|
324
|
+
`session_id=${sessionId}`,
|
|
325
|
+
`review_hints.large_change=${Boolean(reviewHints.large_change)}`,
|
|
326
|
+
`review_hints.tests_run=${Boolean(reviewHints.tests_run)}`,
|
|
327
|
+
`review_hints.build_verified=${Boolean(reviewHints.build_verified)}`,
|
|
328
|
+
],
|
|
329
|
+
sessions: [sessionId],
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (Array.isArray(reviewHints.files_without_tests) && reviewHints.files_without_tests.length > 0) {
|
|
334
|
+
const sample = reviewHints.files_without_tests.slice(0, 3);
|
|
335
|
+
findings.push({
|
|
336
|
+
rule_id: 'testing.files_without_tests',
|
|
337
|
+
category: 'testing',
|
|
338
|
+
severity: reviewHints.files_without_tests.length > 5 ? 'high' : 'medium',
|
|
339
|
+
confidence: 'medium',
|
|
340
|
+
summary: 'Modified source files have no corresponding test changes',
|
|
341
|
+
details: `Detected ${reviewHints.files_without_tests.length} file(s) without related test updates.`,
|
|
342
|
+
suggestion: 'Add/adjust tests for changed source files or document why tests are not needed.',
|
|
343
|
+
location: { file: sample[0] },
|
|
344
|
+
evidence: [
|
|
345
|
+
`session_id=${sessionId}`,
|
|
346
|
+
`review_hints.files_without_tests_count=${reviewHints.files_without_tests.length}`,
|
|
347
|
+
...sample.map(file => `file_without_test=${file}`),
|
|
348
|
+
],
|
|
349
|
+
sessions: [sessionId],
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
for (const error of errors.slice(-3)) {
|
|
354
|
+
findings.push({
|
|
355
|
+
rule_id: 'reliability.tool_error',
|
|
356
|
+
category: 'reliability',
|
|
357
|
+
severity: 'medium',
|
|
358
|
+
confidence: 'high',
|
|
359
|
+
summary: `Tool error detected: ${error.tool || 'unknown tool'}`,
|
|
360
|
+
details: sanitizeErrorDetails(error.message),
|
|
361
|
+
suggestion: 'Confirm the error is resolved and include successful rerun evidence.',
|
|
362
|
+
evidence: [
|
|
363
|
+
`session_id=${sessionId}`,
|
|
364
|
+
'activity.errors_present=true',
|
|
365
|
+
`error_tool=${error.tool || 'unknown'}`,
|
|
366
|
+
],
|
|
367
|
+
sessions: [sessionId],
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const rollbackCommands = commands.filter(cmd =>
|
|
372
|
+
/\bgit\s+(reset|restore|revert)\b/i.test(cmd) ||
|
|
373
|
+
/\bgit\s+checkout\s+--\b/i.test(cmd) ||
|
|
374
|
+
/\bundo\b/i.test(cmd)
|
|
375
|
+
);
|
|
376
|
+
if (rollbackCommands.length >= 2) {
|
|
377
|
+
findings.push({
|
|
378
|
+
rule_id: 'maintainability.frequent_rollback_commands',
|
|
379
|
+
category: 'maintainability',
|
|
380
|
+
severity: rollbackCommands.length >= 4 ? 'high' : 'medium',
|
|
381
|
+
confidence: 'medium',
|
|
382
|
+
summary: 'Frequent rollback/undo commands detected in one session',
|
|
383
|
+
details: `Detected ${rollbackCommands.length} rollback-style command(s), which may indicate unstable iteration.`,
|
|
384
|
+
suggestion: 'Split the change and validate each step to reduce back-and-forth edits.',
|
|
385
|
+
evidence: [
|
|
386
|
+
`session_id=${sessionId}`,
|
|
387
|
+
`rollback_command_count=${rollbackCommands.length}`,
|
|
388
|
+
...rollbackCommands.slice(0, 3).map(cmd => `rollback_cmd=${cmd.slice(0, 80)}`),
|
|
389
|
+
],
|
|
390
|
+
sessions: [sessionId],
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if ((summary?.session?.risk || '') === 'high') {
|
|
395
|
+
findings.push({
|
|
396
|
+
rule_id: 'maintainability.high_risk_session',
|
|
397
|
+
category: 'maintainability',
|
|
398
|
+
severity: 'medium',
|
|
399
|
+
confidence: 'medium',
|
|
400
|
+
summary: 'Session-level risk was classified as high',
|
|
401
|
+
details: 'The extraction engine labeled this session as high risk based on change shape.',
|
|
402
|
+
suggestion: 'Require manual reviewer sign-off and verify rollback/checkpoint strategy.',
|
|
403
|
+
evidence: [
|
|
404
|
+
`session_id=${sessionId}`,
|
|
405
|
+
`summary.session.risk=${summary.session.risk}`,
|
|
406
|
+
],
|
|
407
|
+
sessions: [sessionId],
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (state?.checkpoints && state.checkpoints.length > 0) {
|
|
412
|
+
findings.push({
|
|
413
|
+
rule_id: 'maintainability.checkpoints_present',
|
|
414
|
+
category: 'maintainability',
|
|
415
|
+
severity: 'info',
|
|
416
|
+
confidence: 'high',
|
|
417
|
+
summary: 'Checkpoint chain exists for this session',
|
|
418
|
+
details: `Detected ${state.checkpoints.length} checkpoint commit(s), enabling safer rollback.`,
|
|
419
|
+
suggestion: 'Use "shit rewind <checkpoint>" if post-merge regression appears.',
|
|
420
|
+
evidence: [
|
|
421
|
+
`session_id=${sessionId}`,
|
|
422
|
+
`state.checkpoints_count=${state.checkpoints.length}`,
|
|
423
|
+
],
|
|
424
|
+
sessions: [sessionId],
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return findings;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function generateSummaryMissingFinding(sessionId) {
|
|
432
|
+
return {
|
|
433
|
+
rule_id: 'integrity.missing_summary',
|
|
434
|
+
category: 'correctness',
|
|
435
|
+
severity: 'high',
|
|
436
|
+
confidence: 'high',
|
|
437
|
+
summary: 'Session summary artifact is missing or invalid',
|
|
438
|
+
details: `summary.json was missing/corrupted for session ${sessionId}, review confidence is degraded.`,
|
|
439
|
+
suggestion: 'Re-run session processing or inspect raw events to reconstruct summary artifacts.',
|
|
440
|
+
evidence: [`session_id=${sessionId}`],
|
|
441
|
+
sessions: [sessionId],
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function generateCrossSessionFindings(snapshots) {
|
|
446
|
+
if (snapshots.length < 2) {
|
|
447
|
+
return [];
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const fileToSessions = new Map();
|
|
451
|
+
|
|
452
|
+
for (const snap of snapshots) {
|
|
453
|
+
const files = Array.isArray(snap.summary?.changes?.files) ? snap.summary.changes.files : [];
|
|
454
|
+
for (const file of files) {
|
|
455
|
+
if (file.category !== 'source') {
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
if (!fileToSessions.has(file.path)) {
|
|
459
|
+
fileToSessions.set(file.path, new Set());
|
|
460
|
+
}
|
|
461
|
+
fileToSessions.get(file.path).add(snap.id);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const hotFiles = [...fileToSessions.entries()]
|
|
466
|
+
.filter(([, sessions]) => sessions.size >= 2)
|
|
467
|
+
.sort((a, b) => b[1].size - a[1].size);
|
|
468
|
+
|
|
469
|
+
if (hotFiles.length === 0) {
|
|
470
|
+
return [];
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const HOT_FILE_LIMIT = 3;
|
|
474
|
+
return hotFiles.slice(0, HOT_FILE_LIMIT).map(([filePath, sessionSet], idx) => ({
|
|
475
|
+
rule_id: 'maintainability.hot_file_across_sessions',
|
|
476
|
+
category: 'maintainability',
|
|
477
|
+
severity: sessionSet.size >= 4 ? 'high' : 'medium',
|
|
478
|
+
confidence: 'medium',
|
|
479
|
+
summary: 'Same source file modified across multiple reviewed sessions',
|
|
480
|
+
details: `File "${filePath}" was modified in ${sessionSet.size} reviewed sessions, which may signal unresolved churn.`,
|
|
481
|
+
suggestion: 'Investigate root cause and consolidate related changes into a single reviewed thread.',
|
|
482
|
+
location: { file: filePath },
|
|
483
|
+
evidence: [
|
|
484
|
+
`hot_file=${filePath}`,
|
|
485
|
+
`session_count=${sessionSet.size}`,
|
|
486
|
+
`hot_file_rank=${idx + 1}`,
|
|
487
|
+
...[...sessionSet].slice(0, 5).map(id => `session_id=${id}`),
|
|
488
|
+
],
|
|
489
|
+
sessions: [...sessionSet],
|
|
490
|
+
}));
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function filterBySeverity(findings, minSeverity) {
|
|
494
|
+
const threshold = SEVERITY_SCORE[minSeverity] || SEVERITY_SCORE.info;
|
|
495
|
+
return findings.filter(f => (SEVERITY_SCORE[f.severity] || 0) >= threshold);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function shouldFailByThreshold(findings, failOn) {
|
|
499
|
+
const threshold = SEVERITY_SCORE[failOn] || SEVERITY_SCORE.high;
|
|
500
|
+
return findings.some(f => (SEVERITY_SCORE[f.severity] || 0) >= threshold);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function computeVerdict(findings, failOn) {
|
|
504
|
+
if (shouldFailByThreshold(findings, failOn)) {
|
|
505
|
+
return 'fail';
|
|
506
|
+
}
|
|
507
|
+
if (findings.length > 0) {
|
|
508
|
+
return 'warn';
|
|
509
|
+
}
|
|
510
|
+
return 'pass';
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function buildSessionSnapshot(sessionEntry) {
|
|
514
|
+
const summaryPath = join(sessionEntry.dir, 'summary.json');
|
|
515
|
+
const statePath = join(sessionEntry.dir, 'state.json');
|
|
516
|
+
const metadataPath = join(sessionEntry.dir, 'metadata.json');
|
|
517
|
+
|
|
518
|
+
const summary = loadJsonIfExists(summaryPath);
|
|
519
|
+
const state = loadJsonIfExists(statePath, {});
|
|
520
|
+
const metadata = loadJsonIfExists(metadataPath, {});
|
|
521
|
+
|
|
522
|
+
return {
|
|
523
|
+
id: sessionEntry.id,
|
|
524
|
+
summary,
|
|
525
|
+
state,
|
|
526
|
+
metadata,
|
|
527
|
+
source: {
|
|
528
|
+
summary_file: summaryPath,
|
|
529
|
+
state_file: statePath,
|
|
530
|
+
},
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function buildReport(selectedSessions, options) {
|
|
535
|
+
const snapshots = selectedSessions.map(buildSessionSnapshot);
|
|
536
|
+
const rawFindings = [];
|
|
537
|
+
|
|
538
|
+
for (const snap of snapshots) {
|
|
539
|
+
if (!snap.summary) {
|
|
540
|
+
rawFindings.push(generateSummaryMissingFinding(snap.id));
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
rawFindings.push(...generateFindings(snap.summary, snap.state, snap.id));
|
|
544
|
+
}
|
|
545
|
+
rawFindings.push(...generateCrossSessionFindings(snapshots.filter(snap => snap.summary)));
|
|
546
|
+
|
|
547
|
+
const deduped = dedupeFindings(rawFindings);
|
|
548
|
+
const filteredFindings = filterBySeverity(deduped, options.minSeverity);
|
|
549
|
+
const verdict = computeVerdict(filteredFindings, options.failOn);
|
|
550
|
+
|
|
551
|
+
return {
|
|
552
|
+
schema_version: '1.1',
|
|
553
|
+
generated_at: new Date().toISOString(),
|
|
554
|
+
policy: {
|
|
555
|
+
min_severity: options.minSeverity,
|
|
556
|
+
fail_on: options.failOn,
|
|
557
|
+
strict: options.strict,
|
|
558
|
+
recent: options.all ? 'all' : options.recent,
|
|
559
|
+
session_filter: options.sessionId || null,
|
|
560
|
+
},
|
|
561
|
+
verdict,
|
|
562
|
+
sessions: snapshots.map(snap => ({
|
|
563
|
+
id: snap.id,
|
|
564
|
+
type: snap.summary?.session?.type || snap.metadata?.type || 'unknown',
|
|
565
|
+
risk: snap.summary?.session?.risk || snap.metadata?.risk || 'unknown',
|
|
566
|
+
duration_minutes: snap.summary?.session?.duration_minutes ?? snap.metadata?.duration_minutes ?? 0,
|
|
567
|
+
source: snap.source,
|
|
568
|
+
})),
|
|
569
|
+
findings: filteredFindings,
|
|
570
|
+
stats: {
|
|
571
|
+
reviewed_sessions: snapshots.length,
|
|
572
|
+
total_findings: filteredFindings.length,
|
|
573
|
+
by_severity: Object.keys(SEVERITY_SCORE).reduce((acc, key) => {
|
|
574
|
+
acc[key] = filteredFindings.filter(f => f.severity === key).length;
|
|
575
|
+
return acc;
|
|
576
|
+
}, {}),
|
|
577
|
+
},
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function printHumanReport(report) {
|
|
582
|
+
console.log(`๐งช Code Review`);
|
|
583
|
+
console.log(` Sessions: ${report.stats.reviewed_sessions}`);
|
|
584
|
+
console.log(` Verdict: ${report.verdict.toUpperCase()} | Findings: ${report.findings.length}`);
|
|
585
|
+
console.log(` Policy: min=${report.policy.min_severity}, fail-on=${report.policy.fail_on}`);
|
|
586
|
+
console.log();
|
|
587
|
+
|
|
588
|
+
if (report.sessions.length === 1) {
|
|
589
|
+
const session = report.sessions[0];
|
|
590
|
+
console.log(`๐ฆ Session: ${session.id}`);
|
|
591
|
+
console.log(` Type: ${session.type} | Risk: ${session.risk} | Duration: ${session.duration_minutes}m`);
|
|
592
|
+
console.log();
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
if (report.findings.length === 0) {
|
|
596
|
+
console.log('โ
No findings above current severity threshold.');
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
for (const [idx, finding] of report.findings.entries()) {
|
|
601
|
+
console.log(`${idx + 1}. [${finding.severity.toUpperCase()}][${finding.category}] ${finding.summary}`);
|
|
602
|
+
console.log(` Rule: ${finding.rule_id} | Confidence: ${finding.confidence}`);
|
|
603
|
+
if (finding.details) {
|
|
604
|
+
console.log(` Detail: ${finding.details}`);
|
|
605
|
+
}
|
|
606
|
+
if (finding.location?.file) {
|
|
607
|
+
console.log(` Location: ${finding.location.file}`);
|
|
608
|
+
}
|
|
609
|
+
if (finding.sessions?.length > 0) {
|
|
610
|
+
console.log(` Sessions: ${finding.sessions.slice(0, 3).join(', ')}${finding.sessions.length > 3 ? ` (+${finding.sessions.length - 3})` : ''}`);
|
|
611
|
+
}
|
|
612
|
+
if (finding.evidence.length > 0) {
|
|
613
|
+
console.log(` Evidence: ${finding.evidence.slice(0, 3).join(' | ')}`);
|
|
614
|
+
}
|
|
615
|
+
if (finding.suggestion) {
|
|
616
|
+
console.log(` Suggestion: ${finding.suggestion}`);
|
|
617
|
+
}
|
|
618
|
+
console.log();
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function printMarkdownReport(report) {
|
|
623
|
+
const lines = [];
|
|
624
|
+
lines.push('# Code Review Report');
|
|
625
|
+
lines.push('');
|
|
626
|
+
lines.push(`- Verdict: **${report.verdict.toUpperCase()}**`);
|
|
627
|
+
lines.push(`- Sessions: **${report.stats.reviewed_sessions}**`);
|
|
628
|
+
lines.push(`- Findings: **${report.findings.length}**`);
|
|
629
|
+
lines.push(`- Policy: \`min=${report.policy.min_severity}, fail-on=${report.policy.fail_on}\``);
|
|
630
|
+
lines.push('');
|
|
631
|
+
|
|
632
|
+
lines.push('## Findings');
|
|
633
|
+
lines.push('');
|
|
634
|
+
if (report.findings.length === 0) {
|
|
635
|
+
lines.push('No findings above threshold.');
|
|
636
|
+
console.log(lines.join('\n'));
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
lines.push('| Severity | Category | Rule | Summary | Sessions |');
|
|
641
|
+
lines.push('|---|---|---|---|---|');
|
|
642
|
+
for (const finding of report.findings) {
|
|
643
|
+
const sessions = finding.sessions?.length || 0;
|
|
644
|
+
const summary = finding.summary.replace(/\|/g, '\\|');
|
|
645
|
+
lines.push(`| ${finding.severity.toUpperCase()} | ${finding.category} | \`${finding.rule_id}\` | ${summary} | ${sessions} |`);
|
|
646
|
+
}
|
|
647
|
+
lines.push('');
|
|
648
|
+
lines.push('## Details');
|
|
649
|
+
lines.push('');
|
|
650
|
+
for (const finding of report.findings) {
|
|
651
|
+
lines.push(`### [${finding.severity.toUpperCase()}] ${finding.summary}`);
|
|
652
|
+
lines.push(`- Rule: \`${finding.rule_id}\``);
|
|
653
|
+
lines.push(`- Confidence: \`${finding.confidence}\``);
|
|
654
|
+
if (finding.sessions?.length > 0) {
|
|
655
|
+
lines.push(`- Sessions: ${finding.sessions.join(', ')}`);
|
|
656
|
+
}
|
|
657
|
+
if (finding.location?.file) {
|
|
658
|
+
lines.push(`- Location: \`${finding.location.file}\``);
|
|
659
|
+
}
|
|
660
|
+
if (finding.details) {
|
|
661
|
+
lines.push(`- Detail: ${finding.details}`);
|
|
662
|
+
}
|
|
663
|
+
if (finding.suggestion) {
|
|
664
|
+
lines.push(`- Suggestion: ${finding.suggestion}`);
|
|
665
|
+
}
|
|
666
|
+
if (finding.evidence.length > 0) {
|
|
667
|
+
lines.push(`- Evidence: ${finding.evidence.slice(0, 5).join(' | ')}`);
|
|
668
|
+
}
|
|
669
|
+
lines.push('');
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
console.log(lines.join('\n'));
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function selectSessions(allSessions, options) {
|
|
676
|
+
if (options.sessionId) {
|
|
677
|
+
const matched = allSessions.find(s => s.id === options.sessionId || s.id.startsWith(options.sessionId));
|
|
678
|
+
return matched ? [matched] : [];
|
|
679
|
+
}
|
|
680
|
+
if (options.all) {
|
|
681
|
+
return allSessions;
|
|
682
|
+
}
|
|
683
|
+
return allSessions.slice(0, options.recent);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
export default async function review(args) {
|
|
687
|
+
try {
|
|
688
|
+
const options = parseArgs(args);
|
|
689
|
+
if (options.help) {
|
|
690
|
+
printUsage();
|
|
691
|
+
return 0;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const projectRoot = getProjectRoot();
|
|
695
|
+
const logDir = getLogDir(projectRoot);
|
|
696
|
+
const sessions = getSessionDirs(logDir);
|
|
697
|
+
|
|
698
|
+
if (sessions.length === 0) {
|
|
699
|
+
console.error('โ No sessions found for review.');
|
|
700
|
+
return 1;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
const selectedSessions = selectSessions(sessions, options);
|
|
704
|
+
if (selectedSessions.length === 0) {
|
|
705
|
+
console.error(`โ Session not found: ${options.sessionId}`);
|
|
706
|
+
return 1;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const report = buildReport(selectedSessions, options);
|
|
710
|
+
|
|
711
|
+
if (options.format === 'json') {
|
|
712
|
+
console.log(JSON.stringify(report, null, 2));
|
|
713
|
+
} else if (options.format === 'markdown') {
|
|
714
|
+
printMarkdownReport(report);
|
|
715
|
+
} else {
|
|
716
|
+
printHumanReport(report);
|
|
717
|
+
console.log('Options: --json --markdown --recent=<n> --all --strict --min-severity=<...> --fail-on=<...>');
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
if (options.strict && shouldFailByThreshold(report.findings, options.failOn)) {
|
|
721
|
+
return 1;
|
|
722
|
+
}
|
|
723
|
+
return 0;
|
|
724
|
+
} catch (error) {
|
|
725
|
+
console.error('โ Failed to run review:', error.message);
|
|
726
|
+
return 1;
|
|
727
|
+
}
|
|
728
|
+
}
|