claude-git-hooks 2.35.2 → 2.43.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.
@@ -12,19 +12,25 @@
12
12
  * - Analyzes staged, unstaged, or all tracked files
13
13
  * - Interactive confirmation with detailed issue view
14
14
  * - Generates resolution prompt on abort
15
+ * - Headless mode for CI (--headless): skips prompts, never commits
16
+ * - JSON output (--format json): single JSON line on stdout
15
17
  *
16
18
  * Usage:
17
19
  * claude-hooks analyze # Analyze staged files (default)
18
20
  * claude-hooks analyze --unstaged # Analyze unstaged changes
19
21
  * claude-hooks analyze --all # Analyze all tracked files
22
+ * claude-hooks analyze --headless --format json # CI mode with JSON output
20
23
  */
21
24
 
22
25
  import {
23
26
  getStagedFiles,
24
27
  getUnstagedFiles,
25
28
  getAllTrackedFiles,
26
- createCommit
29
+ createCommit,
30
+ getRepoRoot,
31
+ getStagedTreeSha
27
32
  } from '../utils/git-operations.js';
33
+ import { writeMarker } from '../utils/hooks-verified-marker.js';
28
34
  import { filterFiles } from '../utils/file-operations.js';
29
35
  import {
30
36
  buildFilesData,
@@ -36,9 +42,52 @@ import { promptUserConfirmation, promptConfirmation } from '../utils/interactive
36
42
  import { generateResolutionPrompt } from '../utils/resolution-prompt.js';
37
43
  import { getConfig } from '../config.js';
38
44
  import { loadPreset } from '../utils/preset-loader.js';
45
+ import { CostTracker } from '../utils/cost-tracker.js';
46
+ import { getVersion } from '../utils/package-info.js';
39
47
  import logger from '../utils/logger.js';
40
48
  import { error, success, info } from './helpers.js';
41
49
 
50
+ // ─── JSON Error Helper ────────────────────────────────────────────────────
51
+
52
+ /**
53
+ * Emit error JSON to stdout and set exit code
54
+ * @param {string} message - Error message
55
+ * @param {number} exitCode - Process exit code (default: 1)
56
+ * @private
57
+ */
58
+ function _emitErrorJSON(message, exitCode = 1) {
59
+ process.stdout.write(`${JSON.stringify({ status: 'error', error: message })}\n`);
60
+ process.exitCode = exitCode;
61
+ }
62
+
63
+ /**
64
+ * Emit JSON result to stdout (does not exit — caller handles exit)
65
+ * @param {Object} payload - JSON payload
66
+ * @private
67
+ */
68
+ function _emitJSON(payload) {
69
+ process.stdout.write(`${JSON.stringify(payload)}\n`);
70
+ }
71
+
72
+ /**
73
+ * Build no-files JSON payload for headless/JSON mode
74
+ * @private
75
+ */
76
+ function _buildNoFilesPayload(scope, presetName, startTime, skippedCount, costTracker) {
77
+ return {
78
+ status: 'no-files',
79
+ scope,
80
+ preset: presetName,
81
+ fileCount: 0,
82
+ skippedCount,
83
+ durationMs: Date.now() - startTime,
84
+ issueCount: 0,
85
+ severityCounts: { blocker: 0, critical: 0, major: 0, minor: 0, info: 0 },
86
+ issues: [],
87
+ costs: costTracker ? costTracker.toJSON() : null
88
+ };
89
+ }
90
+
42
91
  /**
43
92
  * Main analyze command
44
93
  * Why: Provides interactive analysis before committing
@@ -47,9 +96,24 @@ import { error, success, info } from './helpers.js';
47
96
  * @param {boolean} options.staged - Analyze staged files (default: true)
48
97
  * @param {boolean} options.unstaged - Analyze unstaged files
49
98
  * @param {boolean} options.all - Analyze all tracked files
99
+ * @param {boolean} options.headless - Skip interactive prompts (CI mode)
100
+ * @param {string} options.format - Output format ('json' or null)
50
101
  */
51
102
  export const runAnalyze = async (options = {}) => {
52
- const { unstaged = false, all = false } = options;
103
+ const { unstaged = false, all = false, headless = false, format = null } = options;
104
+ const isJSON = format === 'json';
105
+
106
+ // Disallowed combo: --format json without --headless
107
+ if (isJSON && !headless) {
108
+ _emitErrorJSON('--format json requires --headless', 2);
109
+ return;
110
+ }
111
+
112
+ // Activate JSON mode before any output so info/warning route to stderr
113
+ if (isJSON) logger.setJSONMode(true);
114
+
115
+ const startTime = Date.now();
116
+ const costTracker = headless ? new CostTracker() : null;
53
117
 
54
118
  try {
55
119
  // Load configuration
@@ -66,6 +130,7 @@ export const runAnalyze = async (options = {}) => {
66
130
  const allowedExtensions = metadata.fileExtensions;
67
131
 
68
132
  // Determine scope
133
+ const scope = all ? 'all' : unstaged ? 'unstaged' : 'staged';
69
134
  let scopeLabel = 'staged changes';
70
135
  if (all) {
71
136
  scopeLabel = 'all tracked files';
@@ -86,8 +151,12 @@ export const runAnalyze = async (options = {}) => {
86
151
  }
87
152
 
88
153
  if (files.length === 0) {
154
+ if (isJSON) {
155
+ _emitJSON(_buildNoFilesPayload(scope, presetName, startTime, 0, costTracker));
156
+ }
89
157
  info(`No files to analyze in ${scopeLabel}.`);
90
158
  process.exit(0);
159
+ return; // guard test-mode fallthrough
91
160
  }
92
161
 
93
162
  logger.debug('analyze', 'Files found', {
@@ -113,8 +182,12 @@ export const runAnalyze = async (options = {}) => {
113
182
  }
114
183
 
115
184
  if (validFiles.length === 0) {
185
+ if (isJSON) {
186
+ _emitJSON(_buildNoFilesPayload(scope, presetName, startTime, invalidFiles.length, costTracker));
187
+ }
116
188
  info(`No valid files found to analyze in ${scopeLabel}.`);
117
189
  process.exit(0);
190
+ return; // guard test-mode fallthrough
118
191
  }
119
192
 
120
193
  info(`Sending ${validFiles.length} file(s) for analysis...`);
@@ -123,12 +196,83 @@ export const runAnalyze = async (options = {}) => {
123
196
  const filesData = buildFilesData(validFiles, { staged: !unstaged && !all });
124
197
 
125
198
  if (filesData.length === 0) {
199
+ if (isJSON) {
200
+ _emitJSON({
201
+ status: 'no-files',
202
+ scope,
203
+ preset: presetName,
204
+ fileCount: 0,
205
+ skippedCount: invalidFiles.length,
206
+ durationMs: Date.now() - startTime,
207
+ issueCount: 0,
208
+ severityCounts: { blocker: 0, critical: 0, major: 0, minor: 0, info: 0 },
209
+ issues: [],
210
+ costs: costTracker ? costTracker.toJSON() : null
211
+ });
212
+ }
126
213
  info('No file data could be extracted.');
127
214
  process.exit(0);
215
+ return;
128
216
  }
129
217
 
130
218
  // Run analysis using shared engine
131
- const result = await runAnalysis(filesData, config, { hook: 'analyze' });
219
+ const result = await runAnalysis(filesData, config, {
220
+ hook: 'analyze',
221
+ headless,
222
+ costTracker
223
+ });
224
+
225
+ // Write hooks-verified marker when analysis completes without critical/blocker issues.
226
+ // For headless: marker stays for a downstream `git commit` to pick up.
227
+ // For interactive: prepare-commit-msg reads it when createCommit() fires.
228
+ const { blocker = 0, critical = 0 } = result.issues || {};
229
+ const hasCritical = (blocker + critical) > 0;
230
+
231
+ if (!hasCritical && scope === 'staged') {
232
+ try {
233
+ const repoRoot = getRepoRoot();
234
+ const treeSha = getStagedTreeSha();
235
+ const version = await getVersion();
236
+ writeMarker(repoRoot, treeSha, version);
237
+ logger.debug('analyze', 'Hooks-verified marker written', { treeSha });
238
+ } catch (markerErr) {
239
+ logger.warning(`Could not write hooks-verified marker: ${markerErr.message}`);
240
+ }
241
+ }
242
+
243
+ // ── Headless path: emit result and exit (never commit) ──
244
+ if (headless) {
245
+ const { major = 0, minor = 0, info: infoCount = 0 } = result.issues || {};
246
+
247
+ if (isJSON) {
248
+ _emitJSON({
249
+ status: hasCritical ? 'fail' : 'pass',
250
+ scope,
251
+ preset: presetName,
252
+ fileCount: validFiles.length,
253
+ skippedCount: invalidFiles.length,
254
+ durationMs: Date.now() - startTime,
255
+ issueCount: blocker + critical + major + minor + infoCount,
256
+ severityCounts: { blocker, critical, major, minor, info: infoCount },
257
+ issues: result.details || [],
258
+ costs: costTracker ? costTracker.toJSON() : null
259
+ });
260
+ process.exit(hasCritical ? 1 : 0);
261
+ return;
262
+ }
263
+
264
+ // Headless without JSON: print summary to stderr, exit
265
+ if (hasAnyIssues(result)) {
266
+ displayIssueSummary(result);
267
+ } else {
268
+ success('No issues found. Code is ready to commit!');
269
+ }
270
+
271
+ process.exit(hasCritical ? 1 : 0);
272
+ return; // guard test-mode fallthrough
273
+ }
274
+
275
+ // ── Interactive path (unchanged) ──
132
276
 
133
277
  // Check results
134
278
  if (!hasAnyIssues(result)) {
@@ -215,6 +359,10 @@ export const runAnalyze = async (options = {}) => {
215
359
  }
216
360
  } catch (err) {
217
361
  logger.error('analyze', 'Analysis failed', err);
362
+ if (isJSON) {
363
+ _emitErrorJSON(`Analysis failed: ${err.message}`);
364
+ return;
365
+ }
218
366
  error(`Analysis failed: ${err.message}`);
219
367
  process.exit(1);
220
368
  }