@claudemini/shit-cli 1.8.2 → 1.9.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 +22 -3
- package/lib/agent-review.js +714 -0
- package/lib/review-common.js +100 -0
- package/lib/review.js +163 -270
- package/lib/summarize.js +19 -2
- package/package.json +6 -4
package/lib/review.js
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Structured code review over session artifacts.
|
|
3
|
-
*
|
|
4
|
-
* - fixed findings schema
|
|
5
|
-
* - evidence-grounded findings
|
|
6
|
-
* - deterministic severity/confidence
|
|
7
|
-
* - dedup + aggregation for CI/PR workflows
|
|
3
|
+
* Uses goatchain agent output and keeps the existing findings/report schema.
|
|
8
4
|
*/
|
|
9
5
|
|
|
10
|
-
import { existsSync,
|
|
6
|
+
import { existsSync, readdirSync, statSync } from 'fs';
|
|
11
7
|
import { join } from 'path';
|
|
12
8
|
import { createHash } from 'crypto';
|
|
13
9
|
import { getLogDir, getProjectRoot, SESSION_ID_REGEX } from './config.js';
|
|
14
|
-
import { redactSecrets } from './redact.js';
|
|
15
10
|
import { dispatchWebhook } from './webhook.js';
|
|
11
|
+
import {
|
|
12
|
+
loadJsonIfExists,
|
|
13
|
+
normalizeEvidence,
|
|
14
|
+
normalizeLocation,
|
|
15
|
+
normalizeLocationKey,
|
|
16
|
+
} from './review-common.js';
|
|
16
17
|
|
|
17
18
|
const SEVERITY_SCORE = {
|
|
18
19
|
info: 1,
|
|
@@ -30,7 +31,7 @@ const CONFIDENCE_SCORE = {
|
|
|
30
31
|
|
|
31
32
|
function parseArgs(args) {
|
|
32
33
|
const options = {
|
|
33
|
-
format: 'text',
|
|
34
|
+
format: 'text',
|
|
34
35
|
strict: false,
|
|
35
36
|
minSeverity: 'info',
|
|
36
37
|
failOn: 'high',
|
|
@@ -38,6 +39,12 @@ function parseArgs(args) {
|
|
|
38
39
|
recent: 1,
|
|
39
40
|
all: false,
|
|
40
41
|
help: false,
|
|
42
|
+
engine: 'agent',
|
|
43
|
+
agentTimeoutMs: 120000,
|
|
44
|
+
allowWorktreeDiff: false,
|
|
45
|
+
agentAutoApprove: true,
|
|
46
|
+
deprecatedFlags: [],
|
|
47
|
+
deprecatedEngineValue: null,
|
|
41
48
|
};
|
|
42
49
|
|
|
43
50
|
for (const arg of args) {
|
|
@@ -57,6 +64,10 @@ function parseArgs(args) {
|
|
|
57
64
|
options.all = true;
|
|
58
65
|
continue;
|
|
59
66
|
}
|
|
67
|
+
if (arg === '--agent') {
|
|
68
|
+
options.deprecatedFlags.push('--agent');
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
60
71
|
if (arg === '--help' || arg === '-h') {
|
|
61
72
|
options.help = true;
|
|
62
73
|
continue;
|
|
@@ -82,6 +93,41 @@ function parseArgs(args) {
|
|
|
82
93
|
}
|
|
83
94
|
continue;
|
|
84
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
|
+
}
|
|
85
131
|
if (!arg.startsWith('-') && !options.sessionId) {
|
|
86
132
|
options.sessionId = arg;
|
|
87
133
|
}
|
|
@@ -100,7 +146,15 @@ function printUsage() {
|
|
|
100
146
|
console.log(' --all Review all sessions');
|
|
101
147
|
console.log(' --min-severity=<level> Filter findings below severity');
|
|
102
148
|
console.log(' --fail-on=<level> Strict mode failure threshold (default: high)');
|
|
149
|
+
console.log(' --agent-timeout-ms=<ms> Agent timeout in milliseconds (default: 120000)');
|
|
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');
|
|
103
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>');
|
|
104
158
|
}
|
|
105
159
|
|
|
106
160
|
function getSessionDirs(logDir) {
|
|
@@ -121,6 +175,7 @@ function getSessionDirs(logDir) {
|
|
|
121
175
|
} catch {
|
|
122
176
|
continue;
|
|
123
177
|
}
|
|
178
|
+
|
|
124
179
|
if (!stat.isDirectory()) {
|
|
125
180
|
continue;
|
|
126
181
|
}
|
|
@@ -135,25 +190,8 @@ function getSessionDirs(logDir) {
|
|
|
135
190
|
return sessions.sort((a, b) => b.mtime - a.mtime);
|
|
136
191
|
}
|
|
137
192
|
|
|
138
|
-
function loadJsonIfExists(filePath, fallback = null) {
|
|
139
|
-
if (!existsSync(filePath)) {
|
|
140
|
-
return fallback;
|
|
141
|
-
}
|
|
142
|
-
try {
|
|
143
|
-
return JSON.parse(readFileSync(filePath, 'utf-8'));
|
|
144
|
-
} catch {
|
|
145
|
-
return fallback;
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
function normalizeEvidence(evidence) {
|
|
150
|
-
return evidence
|
|
151
|
-
.filter(Boolean)
|
|
152
|
-
.map(item => String(item).trim())
|
|
153
|
-
.filter(Boolean);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
193
|
function normalizeFinding(input) {
|
|
194
|
+
const sessions = Array.isArray(input.sessions) ? [...new Set(input.sessions)] : [];
|
|
157
195
|
return {
|
|
158
196
|
id: input.id || '',
|
|
159
197
|
rule_id: input.rule_id || 'review.generic',
|
|
@@ -163,27 +201,19 @@ function normalizeFinding(input) {
|
|
|
163
201
|
summary: input.summary || '',
|
|
164
202
|
details: input.details || '',
|
|
165
203
|
suggestion: input.suggestion || '',
|
|
166
|
-
location: input.location
|
|
167
|
-
evidence: normalizeEvidence(input.evidence
|
|
168
|
-
|
|
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,
|
|
169
209
|
};
|
|
170
210
|
}
|
|
171
211
|
|
|
172
|
-
function normalizeLocation(location) {
|
|
173
|
-
if (!location || typeof location !== 'object') {
|
|
174
|
-
return '';
|
|
175
|
-
}
|
|
176
|
-
const file = location.file || '';
|
|
177
|
-
const line = location.line || '';
|
|
178
|
-
const column = location.column || '';
|
|
179
|
-
return `${file}:${line}:${column}`;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
212
|
function canonicalFindingKey(finding) {
|
|
183
213
|
return [
|
|
184
214
|
String(finding.rule_id || '').trim().toLowerCase(),
|
|
185
215
|
String(finding.category || '').trim().toLowerCase(),
|
|
186
|
-
|
|
216
|
+
normalizeLocationKey(finding.location),
|
|
187
217
|
String(finding.summary || '').trim().toLowerCase(),
|
|
188
218
|
].join('|');
|
|
189
219
|
}
|
|
@@ -219,6 +249,7 @@ function dedupeFindings(findings) {
|
|
|
219
249
|
evidence: [...new Set([...prev.evidence, ...finding.evidence])],
|
|
220
250
|
sessions: [...new Set([...(prev.sessions || []), ...(finding.sessions || [])])],
|
|
221
251
|
};
|
|
252
|
+
|
|
222
253
|
merged.id = prev.id;
|
|
223
254
|
map.set(key, merged);
|
|
224
255
|
}
|
|
@@ -226,209 +257,6 @@ function dedupeFindings(findings) {
|
|
|
226
257
|
return [...map.values()].sort((a, b) => SEVERITY_SCORE[b.severity] - SEVERITY_SCORE[a.severity]);
|
|
227
258
|
}
|
|
228
259
|
|
|
229
|
-
function getAllCommands(summary) {
|
|
230
|
-
const commandGroups = summary?.activity?.commands || {};
|
|
231
|
-
return Object.values(commandGroups).flatMap(list => Array.isArray(list) ? list : []);
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
const WINDOWS_ABS_PATH_REGEX = /(^|[\s([{:="'`])([A-Za-z]:\\(?:[^\\\s"'`|<>]+\\){1,}[^\\\s"'`|<>]+)/g;
|
|
235
|
-
const UNIX_SENSITIVE_ROOT_PATH_REGEX = /(^|[\s([{:="'`])(\/(?:Users|home|var|tmp|opt|etc|private|Volumes|workspace|workspaces|usr)(?:\/[^\s"'`|<>]+){2,})/g;
|
|
236
|
-
const UNIX_FILE_PATH_WITH_EXT_REGEX = /(^|[\s([{:="'`])(\/(?:[^\/\s"'`|<>]+\/){2,}[^\/\s"'`|<>]*\.[A-Za-z0-9]{1,10})/g;
|
|
237
|
-
|
|
238
|
-
function sanitizeErrorDetails(message) {
|
|
239
|
-
const text = String(message || 'Tool returned an error');
|
|
240
|
-
const redacted = redactSecrets(text)
|
|
241
|
-
.replace(WINDOWS_ABS_PATH_REGEX, '$1[PATH]')
|
|
242
|
-
.replace(UNIX_SENSITIVE_ROOT_PATH_REGEX, '$1[PATH]')
|
|
243
|
-
.replace(UNIX_FILE_PATH_WITH_EXT_REGEX, '$1[PATH]')
|
|
244
|
-
.replace(/\b[A-Z_]{2,}=[^\s"'`|<>]+/g, '[ENV_REDACTED]');
|
|
245
|
-
return redacted.slice(0, 200);
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
function generateFindings(summary, state, sessionId) {
|
|
249
|
-
const findings = [];
|
|
250
|
-
const reviewHints = summary?.review_hints || {};
|
|
251
|
-
const changes = summary?.changes?.files || [];
|
|
252
|
-
const activity = summary?.activity || {};
|
|
253
|
-
const errors = Array.isArray(activity.errors) ? activity.errors : [];
|
|
254
|
-
const commands = getAllCommands(summary);
|
|
255
|
-
const modifiedSourceFiles = changes.filter(file =>
|
|
256
|
-
file.category === 'source' &&
|
|
257
|
-
Array.isArray(file.operations) &&
|
|
258
|
-
file.operations.some(op => op !== 'read')
|
|
259
|
-
);
|
|
260
|
-
|
|
261
|
-
if (modifiedSourceFiles.length > 0 && !reviewHints.tests_run) {
|
|
262
|
-
findings.push({
|
|
263
|
-
rule_id: 'testing.no_tests_after_source_changes',
|
|
264
|
-
category: 'testing',
|
|
265
|
-
severity: modifiedSourceFiles.length >= 5 ? 'high' : 'medium',
|
|
266
|
-
confidence: 'high',
|
|
267
|
-
summary: 'Source code changed without test execution evidence',
|
|
268
|
-
details: `Detected ${modifiedSourceFiles.length} modified source file(s), but no test command was recorded.`,
|
|
269
|
-
suggestion: 'Run targeted tests for changed modules and attach the command/output in the session.',
|
|
270
|
-
evidence: [
|
|
271
|
-
`session_id=${sessionId}`,
|
|
272
|
-
`review_hints.tests_run=${Boolean(reviewHints.tests_run)}`,
|
|
273
|
-
`modified_source_files=${modifiedSourceFiles.length}`,
|
|
274
|
-
],
|
|
275
|
-
sessions: [sessionId],
|
|
276
|
-
});
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
if (reviewHints.migration_added && !reviewHints.tests_run) {
|
|
280
|
-
findings.push({
|
|
281
|
-
rule_id: 'correctness.migration_without_tests',
|
|
282
|
-
category: 'correctness',
|
|
283
|
-
severity: 'high',
|
|
284
|
-
confidence: 'high',
|
|
285
|
-
summary: 'Database migration changed without test validation',
|
|
286
|
-
details: 'Migration-related changes are present and no test run was captured.',
|
|
287
|
-
suggestion: 'Run migration verification and regression tests before merge.',
|
|
288
|
-
evidence: [
|
|
289
|
-
`session_id=${sessionId}`,
|
|
290
|
-
`review_hints.migration_added=${Boolean(reviewHints.migration_added)}`,
|
|
291
|
-
`review_hints.tests_run=${Boolean(reviewHints.tests_run)}`,
|
|
292
|
-
],
|
|
293
|
-
sessions: [sessionId],
|
|
294
|
-
});
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
if (reviewHints.config_changed && !reviewHints.build_verified) {
|
|
298
|
-
findings.push({
|
|
299
|
-
rule_id: 'reliability.config_without_build',
|
|
300
|
-
category: 'reliability',
|
|
301
|
-
severity: 'medium',
|
|
302
|
-
confidence: 'high',
|
|
303
|
-
summary: 'Configuration changed without build verification',
|
|
304
|
-
details: 'Config-level edits were detected, but no build/compile command was recorded.',
|
|
305
|
-
suggestion: 'Run a full build/compile and include output to reduce deployment risk.',
|
|
306
|
-
evidence: [
|
|
307
|
-
`session_id=${sessionId}`,
|
|
308
|
-
`review_hints.config_changed=${Boolean(reviewHints.config_changed)}`,
|
|
309
|
-
`review_hints.build_verified=${Boolean(reviewHints.build_verified)}`,
|
|
310
|
-
],
|
|
311
|
-
sessions: [sessionId],
|
|
312
|
-
});
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
if (reviewHints.large_change && !reviewHints.tests_run && !reviewHints.build_verified) {
|
|
316
|
-
findings.push({
|
|
317
|
-
rule_id: 'maintainability.large_change_without_validation',
|
|
318
|
-
category: 'maintainability',
|
|
319
|
-
severity: 'high',
|
|
320
|
-
confidence: 'medium',
|
|
321
|
-
summary: 'Large change set lacks both test and build signals',
|
|
322
|
-
details: 'A large multi-file change was made without recorded test/build validation.',
|
|
323
|
-
suggestion: 'Split change into smaller PRs or provide CI/local validation evidence.',
|
|
324
|
-
evidence: [
|
|
325
|
-
`session_id=${sessionId}`,
|
|
326
|
-
`review_hints.large_change=${Boolean(reviewHints.large_change)}`,
|
|
327
|
-
`review_hints.tests_run=${Boolean(reviewHints.tests_run)}`,
|
|
328
|
-
`review_hints.build_verified=${Boolean(reviewHints.build_verified)}`,
|
|
329
|
-
],
|
|
330
|
-
sessions: [sessionId],
|
|
331
|
-
});
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
if (Array.isArray(reviewHints.files_without_tests) && reviewHints.files_without_tests.length > 0) {
|
|
335
|
-
const sample = reviewHints.files_without_tests.slice(0, 3);
|
|
336
|
-
findings.push({
|
|
337
|
-
rule_id: 'testing.files_without_tests',
|
|
338
|
-
category: 'testing',
|
|
339
|
-
severity: reviewHints.files_without_tests.length > 5 ? 'high' : 'medium',
|
|
340
|
-
confidence: 'medium',
|
|
341
|
-
summary: 'Modified source files have no corresponding test changes',
|
|
342
|
-
details: `Detected ${reviewHints.files_without_tests.length} file(s) without related test updates.`,
|
|
343
|
-
suggestion: 'Add/adjust tests for changed source files or document why tests are not needed.',
|
|
344
|
-
location: { file: sample[0] },
|
|
345
|
-
evidence: [
|
|
346
|
-
`session_id=${sessionId}`,
|
|
347
|
-
`review_hints.files_without_tests_count=${reviewHints.files_without_tests.length}`,
|
|
348
|
-
...sample.map(file => `file_without_test=${file}`),
|
|
349
|
-
],
|
|
350
|
-
sessions: [sessionId],
|
|
351
|
-
});
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
for (const error of errors.slice(-3)) {
|
|
355
|
-
findings.push({
|
|
356
|
-
rule_id: 'reliability.tool_error',
|
|
357
|
-
category: 'reliability',
|
|
358
|
-
severity: 'medium',
|
|
359
|
-
confidence: 'high',
|
|
360
|
-
summary: `Tool error detected: ${error.tool || 'unknown tool'}`,
|
|
361
|
-
details: sanitizeErrorDetails(error.message),
|
|
362
|
-
suggestion: 'Confirm the error is resolved and include successful rerun evidence.',
|
|
363
|
-
evidence: [
|
|
364
|
-
`session_id=${sessionId}`,
|
|
365
|
-
'activity.errors_present=true',
|
|
366
|
-
`error_tool=${error.tool || 'unknown'}`,
|
|
367
|
-
],
|
|
368
|
-
sessions: [sessionId],
|
|
369
|
-
});
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
const rollbackCommands = commands.filter(cmd =>
|
|
373
|
-
/\bgit\s+(reset|restore|revert)\b/i.test(cmd) ||
|
|
374
|
-
/\bgit\s+checkout\s+--\b/i.test(cmd) ||
|
|
375
|
-
/\bundo\b/i.test(cmd)
|
|
376
|
-
);
|
|
377
|
-
if (rollbackCommands.length >= 2) {
|
|
378
|
-
findings.push({
|
|
379
|
-
rule_id: 'maintainability.frequent_rollback_commands',
|
|
380
|
-
category: 'maintainability',
|
|
381
|
-
severity: rollbackCommands.length >= 4 ? 'high' : 'medium',
|
|
382
|
-
confidence: 'medium',
|
|
383
|
-
summary: 'Frequent rollback/undo commands detected in one session',
|
|
384
|
-
details: `Detected ${rollbackCommands.length} rollback-style command(s), which may indicate unstable iteration.`,
|
|
385
|
-
suggestion: 'Split the change and validate each step to reduce back-and-forth edits.',
|
|
386
|
-
evidence: [
|
|
387
|
-
`session_id=${sessionId}`,
|
|
388
|
-
`rollback_command_count=${rollbackCommands.length}`,
|
|
389
|
-
...rollbackCommands.slice(0, 3).map(cmd => `rollback_cmd=${cmd.slice(0, 80)}`),
|
|
390
|
-
],
|
|
391
|
-
sessions: [sessionId],
|
|
392
|
-
});
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
if ((summary?.session?.risk || '') === 'high') {
|
|
396
|
-
findings.push({
|
|
397
|
-
rule_id: 'maintainability.high_risk_session',
|
|
398
|
-
category: 'maintainability',
|
|
399
|
-
severity: 'medium',
|
|
400
|
-
confidence: 'medium',
|
|
401
|
-
summary: 'Session-level risk was classified as high',
|
|
402
|
-
details: 'The extraction engine labeled this session as high risk based on change shape.',
|
|
403
|
-
suggestion: 'Require manual reviewer sign-off and verify rollback/checkpoint strategy.',
|
|
404
|
-
evidence: [
|
|
405
|
-
`session_id=${sessionId}`,
|
|
406
|
-
`summary.session.risk=${summary.session.risk}`,
|
|
407
|
-
],
|
|
408
|
-
sessions: [sessionId],
|
|
409
|
-
});
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
if (state?.checkpoints && state.checkpoints.length > 0) {
|
|
413
|
-
findings.push({
|
|
414
|
-
rule_id: 'maintainability.checkpoints_present',
|
|
415
|
-
category: 'maintainability',
|
|
416
|
-
severity: 'info',
|
|
417
|
-
confidence: 'high',
|
|
418
|
-
summary: 'Checkpoint chain exists for this session',
|
|
419
|
-
details: `Detected ${state.checkpoints.length} checkpoint commit(s), enabling safer rollback.`,
|
|
420
|
-
suggestion: 'Use "shit rewind <checkpoint>" if post-merge regression appears.',
|
|
421
|
-
evidence: [
|
|
422
|
-
`session_id=${sessionId}`,
|
|
423
|
-
`state.checkpoints_count=${state.checkpoints.length}`,
|
|
424
|
-
],
|
|
425
|
-
sessions: [sessionId],
|
|
426
|
-
});
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
return findings;
|
|
430
|
-
}
|
|
431
|
-
|
|
432
260
|
function generateSummaryMissingFinding(sessionId) {
|
|
433
261
|
return {
|
|
434
262
|
rule_id: 'integrity.missing_summary',
|
|
@@ -451,15 +279,12 @@ function generateCrossSessionFindings(snapshots) {
|
|
|
451
279
|
const fileToSessions = new Map();
|
|
452
280
|
|
|
453
281
|
for (const snap of snapshots) {
|
|
454
|
-
const
|
|
455
|
-
for (const
|
|
456
|
-
if (
|
|
457
|
-
|
|
458
|
-
}
|
|
459
|
-
if (!fileToSessions.has(file.path)) {
|
|
460
|
-
fileToSessions.set(file.path, new Set());
|
|
282
|
+
const sourcePaths = collectSessionSourcePaths(snap);
|
|
283
|
+
for (const sourcePath of sourcePaths) {
|
|
284
|
+
if (!fileToSessions.has(sourcePath)) {
|
|
285
|
+
fileToSessions.set(sourcePath, new Set());
|
|
461
286
|
}
|
|
462
|
-
fileToSessions.get(
|
|
287
|
+
fileToSessions.get(sourcePath).add(snap.id);
|
|
463
288
|
}
|
|
464
289
|
}
|
|
465
290
|
|
|
@@ -491,6 +316,58 @@ function generateCrossSessionFindings(snapshots) {
|
|
|
491
316
|
}));
|
|
492
317
|
}
|
|
493
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('.shit-logs/') || path.includes('/.git/')) {
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
outputSet.add(path);
|
|
369
|
+
}
|
|
370
|
+
|
|
494
371
|
function filterBySeverity(findings, minSeverity) {
|
|
495
372
|
const threshold = SEVERITY_SCORE[minSeverity] || SEVERITY_SCORE.info;
|
|
496
373
|
return findings.filter(f => (SEVERITY_SCORE[f.severity] || 0) >= threshold);
|
|
@@ -532,30 +409,19 @@ function buildSessionSnapshot(sessionEntry) {
|
|
|
532
409
|
};
|
|
533
410
|
}
|
|
534
411
|
|
|
535
|
-
function
|
|
536
|
-
const snapshots = selectedSessions.map(buildSessionSnapshot);
|
|
537
|
-
const rawFindings = [];
|
|
538
|
-
|
|
539
|
-
for (const snap of snapshots) {
|
|
540
|
-
if (!snap.summary) {
|
|
541
|
-
rawFindings.push(generateSummaryMissingFinding(snap.id));
|
|
542
|
-
continue;
|
|
543
|
-
}
|
|
544
|
-
rawFindings.push(...generateFindings(snap.summary, snap.state, snap.id));
|
|
545
|
-
}
|
|
546
|
-
rawFindings.push(...generateCrossSessionFindings(snapshots.filter(snap => snap.summary)));
|
|
547
|
-
|
|
412
|
+
function buildReportFromRawFindings(snapshots, rawFindings, options) {
|
|
548
413
|
const deduped = dedupeFindings(rawFindings);
|
|
549
414
|
const filteredFindings = filterBySeverity(deduped, options.minSeverity);
|
|
550
415
|
const verdict = computeVerdict(filteredFindings, options.failOn);
|
|
551
416
|
|
|
552
417
|
return {
|
|
553
|
-
schema_version: '1.
|
|
418
|
+
schema_version: '1.2',
|
|
554
419
|
generated_at: new Date().toISOString(),
|
|
555
420
|
policy: {
|
|
556
421
|
min_severity: options.minSeverity,
|
|
557
422
|
fail_on: options.failOn,
|
|
558
423
|
strict: options.strict,
|
|
424
|
+
engine: options.engine,
|
|
559
425
|
recent: options.all ? 'all' : options.recent,
|
|
560
426
|
session_filter: options.sessionId || null,
|
|
561
427
|
},
|
|
@@ -583,7 +449,7 @@ function printHumanReport(report) {
|
|
|
583
449
|
console.log(`🧪 Code Review`);
|
|
584
450
|
console.log(` Sessions: ${report.stats.reviewed_sessions}`);
|
|
585
451
|
console.log(` Verdict: ${report.verdict.toUpperCase()} | Findings: ${report.findings.length}`);
|
|
586
|
-
console.log(` Policy: min=${report.policy.min_severity}, fail-on=${report.policy.fail_on}`);
|
|
452
|
+
console.log(` Policy: engine=${report.policy.engine}, min=${report.policy.min_severity}, fail-on=${report.policy.fail_on}`);
|
|
587
453
|
console.log();
|
|
588
454
|
|
|
589
455
|
if (report.sessions.length === 1) {
|
|
@@ -627,11 +493,12 @@ function printMarkdownReport(report) {
|
|
|
627
493
|
lines.push(`- Verdict: **${report.verdict.toUpperCase()}**`);
|
|
628
494
|
lines.push(`- Sessions: **${report.stats.reviewed_sessions}**`);
|
|
629
495
|
lines.push(`- Findings: **${report.findings.length}**`);
|
|
630
|
-
lines.push(`- Policy: \`min=${report.policy.min_severity}, fail-on=${report.policy.fail_on}\``);
|
|
496
|
+
lines.push(`- Policy: \`engine=${report.policy.engine}, min=${report.policy.min_severity}, fail-on=${report.policy.fail_on}\``);
|
|
631
497
|
lines.push('');
|
|
632
498
|
|
|
633
499
|
lines.push('## Findings');
|
|
634
500
|
lines.push('');
|
|
501
|
+
|
|
635
502
|
if (report.findings.length === 0) {
|
|
636
503
|
lines.push('No findings above threshold.');
|
|
637
504
|
console.log(lines.join('\n'));
|
|
@@ -645,9 +512,11 @@ function printMarkdownReport(report) {
|
|
|
645
512
|
const summary = finding.summary.replace(/\|/g, '\\|');
|
|
646
513
|
lines.push(`| ${finding.severity.toUpperCase()} | ${finding.category} | \`${finding.rule_id}\` | ${summary} | ${sessions} |`);
|
|
647
514
|
}
|
|
515
|
+
|
|
648
516
|
lines.push('');
|
|
649
517
|
lines.push('## Details');
|
|
650
518
|
lines.push('');
|
|
519
|
+
|
|
651
520
|
for (const finding of report.findings) {
|
|
652
521
|
lines.push(`### [${finding.severity.toUpperCase()}] ${finding.summary}`);
|
|
653
522
|
lines.push(`- Rule: \`${finding.rule_id}\``);
|
|
@@ -678,9 +547,11 @@ function selectSessions(allSessions, options) {
|
|
|
678
547
|
const matched = allSessions.find(s => s.id === options.sessionId || s.id.startsWith(options.sessionId));
|
|
679
548
|
return matched ? [matched] : [];
|
|
680
549
|
}
|
|
550
|
+
|
|
681
551
|
if (options.all) {
|
|
682
552
|
return allSessions;
|
|
683
553
|
}
|
|
554
|
+
|
|
684
555
|
return allSessions.slice(0, options.recent);
|
|
685
556
|
}
|
|
686
557
|
|
|
@@ -692,6 +563,13 @@ export default async function review(args) {
|
|
|
692
563
|
return 0;
|
|
693
564
|
}
|
|
694
565
|
|
|
566
|
+
if (options.deprecatedFlags.length > 0) {
|
|
567
|
+
console.error(`⚠️ Deprecated review flags are ignored: ${[...new Set(options.deprecatedFlags)].join(', ')}.`);
|
|
568
|
+
if (options.deprecatedEngineValue && options.deprecatedEngineValue !== 'agent') {
|
|
569
|
+
console.error(`⚠️ --engine=${options.deprecatedEngineValue} is unsupported. Using goatchain agent review.`);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
695
573
|
const projectRoot = getProjectRoot();
|
|
696
574
|
const logDir = getLogDir(projectRoot);
|
|
697
575
|
const sessions = getSessionDirs(logDir);
|
|
@@ -707,9 +585,23 @@ export default async function review(args) {
|
|
|
707
585
|
return 1;
|
|
708
586
|
}
|
|
709
587
|
|
|
710
|
-
const
|
|
588
|
+
const snapshots = selectedSessions.map(buildSessionSnapshot);
|
|
589
|
+
const rawFindings = [];
|
|
590
|
+
|
|
591
|
+
for (const snap of snapshots) {
|
|
592
|
+
if (!snap.summary) {
|
|
593
|
+
rawFindings.push(generateSummaryMissingFinding(snap.id));
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const { runAgentReview } = await import('./agent-review.js');
|
|
598
|
+
const agentFindings = await runAgentReview(projectRoot, selectedSessions, options);
|
|
599
|
+
rawFindings.push(...agentFindings);
|
|
600
|
+
// Intentional: include snapshots without summary and fallback to state paths when available.
|
|
601
|
+
rawFindings.push(...generateCrossSessionFindings(snapshots));
|
|
602
|
+
|
|
603
|
+
const report = buildReportFromRawFindings(snapshots, rawFindings, options);
|
|
711
604
|
|
|
712
|
-
// Dispatch webhook for review completion
|
|
713
605
|
await dispatchWebhook(projectRoot, 'review.completed', report);
|
|
714
606
|
|
|
715
607
|
if (options.format === 'json') {
|
|
@@ -718,12 +610,13 @@ export default async function review(args) {
|
|
|
718
610
|
printMarkdownReport(report);
|
|
719
611
|
} else {
|
|
720
612
|
printHumanReport(report);
|
|
721
|
-
console.log('Options: --json --markdown --recent=<n> --all --strict --min-severity=<...> --fail-on=<...>');
|
|
613
|
+
console.log('Options: --json --markdown --recent=<n> --all --strict --min-severity=<...> --fail-on=<...> --agent-timeout-ms=<...>');
|
|
722
614
|
}
|
|
723
615
|
|
|
724
616
|
if (options.strict && shouldFailByThreshold(report.findings, options.failOn)) {
|
|
725
617
|
return 1;
|
|
726
618
|
}
|
|
619
|
+
|
|
727
620
|
return 0;
|
|
728
621
|
} catch (error) {
|
|
729
622
|
console.error('❌ Failed to run review:', error.message);
|
package/lib/summarize.js
CHANGED
|
@@ -22,7 +22,7 @@ const DEFAULT_CONFIG = {
|
|
|
22
22
|
/**
|
|
23
23
|
* Get API configuration from environment or config file
|
|
24
24
|
*/
|
|
25
|
-
function getApiConfig(projectRoot) {
|
|
25
|
+
export function getApiConfig(projectRoot) {
|
|
26
26
|
const config = { ...DEFAULT_CONFIG };
|
|
27
27
|
|
|
28
28
|
// Check for project config
|
|
@@ -51,6 +51,18 @@ function getApiConfig(projectRoot) {
|
|
|
51
51
|
if (process.env.OPENAI_ENDPOINT) {
|
|
52
52
|
config.openai_endpoint = process.env.OPENAI_ENDPOINT;
|
|
53
53
|
}
|
|
54
|
+
// Universal override for all providers.
|
|
55
|
+
if (process.env.AI_MODEL) {
|
|
56
|
+
config.model = process.env.AI_MODEL;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Provider-specific overrides take precedence when applicable.
|
|
60
|
+
if (config.provider === 'openai' && process.env.OPENAI_MODEL) {
|
|
61
|
+
config.model = process.env.OPENAI_MODEL;
|
|
62
|
+
}
|
|
63
|
+
if (config.provider === 'anthropic' && process.env.ANTHROPIC_MODEL) {
|
|
64
|
+
config.model = process.env.ANTHROPIC_MODEL;
|
|
65
|
+
}
|
|
54
66
|
|
|
55
67
|
return config;
|
|
56
68
|
}
|
|
@@ -203,7 +215,9 @@ async function callOpenAI(apiKey, endpoint, model, prompt, maxTokens, temperatur
|
|
|
203
215
|
}
|
|
204
216
|
|
|
205
217
|
const data = await response.json();
|
|
206
|
-
|
|
218
|
+
const msg = data.choices[0].message;
|
|
219
|
+
// Some OpenAI-compatible providers return reasoning output in `reasoning_content`.
|
|
220
|
+
return msg.content || msg.reasoning_content || '';
|
|
207
221
|
}
|
|
208
222
|
|
|
209
223
|
/**
|
|
@@ -325,6 +339,9 @@ export default async function summarize(args) {
|
|
|
325
339
|
console.log(' OPENAI_API_KEY # Use OpenAI for summarization');
|
|
326
340
|
console.log(' OPENAI_BASE_URL # OpenAI-compatible base URL (e.g. https://api.openai.com/v1)');
|
|
327
341
|
console.log(' OPENAI_ENDPOINT # Full OpenAI-compatible endpoint (overrides OPENAI_BASE_URL)');
|
|
342
|
+
console.log(' AI_MODEL # Universal model override (applies to all providers)');
|
|
343
|
+
console.log(' OPENAI_MODEL # OpenAI-only model override');
|
|
344
|
+
console.log(' ANTHROPIC_MODEL # Anthropic-only model override');
|
|
328
345
|
console.log(' ANTHROPIC_API_KEY # Use Anthropic for summarization');
|
|
329
346
|
console.log('\nConfiguration (.shit-logs/config.json):');
|
|
330
347
|
console.log(` {"provider": "openai", "model": "gpt-4o-mini", "openai_base_url": "https://api.openai.com/v1"}`);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@claudemini/shit-cli",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Session-based Hook Intelligence Tracker
|
|
3
|
+
"version": "1.9.0",
|
|
4
|
+
"description": "Session-based Hook Intelligence Tracker for human-AI coding sessions",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"shit": "./bin/shit.js"
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"node": ">=18.0.0"
|
|
16
16
|
},
|
|
17
17
|
"scripts": {
|
|
18
|
-
"test": "
|
|
18
|
+
"test": "node --check bin/shit.js && for f in lib/*.js; do node --check \"$f\"; done"
|
|
19
19
|
},
|
|
20
20
|
"keywords": [
|
|
21
21
|
"claude-code",
|
|
@@ -33,6 +33,8 @@
|
|
|
33
33
|
},
|
|
34
34
|
"author": "",
|
|
35
35
|
"license": "UNLICENSED",
|
|
36
|
-
"dependencies": {
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"goatchain": "^0.0.29"
|
|
38
|
+
},
|
|
37
39
|
"devDependencies": {}
|
|
38
40
|
}
|