gatecheck 0.0.1-beta.5

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/dist/bin.mjs ADDED
@@ -0,0 +1,722 @@
1
+ #!/usr/bin/env node
2
+ import { Command, Option } from "commander";
3
+ import { readFile } from "node:fs/promises";
4
+ import { matchesGlob, relative, resolve } from "node:path";
5
+ import * as v from "valibot";
6
+ import { parse } from "yaml";
7
+ import { execFile, spawn } from "node:child_process";
8
+
9
+ //#region package.json
10
+ var version = "0.0.1-beta.5";
11
+ var description = "Quality gate for git changes — run checks and AI reviews against changed files";
12
+
13
+ //#endregion
14
+ //#region src/config.ts
15
+ const RegexStringSchema = v.pipe(v.string(), v.check((value) => {
16
+ try {
17
+ new RegExp(value);
18
+ return true;
19
+ } catch {
20
+ return false;
21
+ }
22
+ }, "Invalid regular expression"));
23
+ const ChangedFilesSchema = v.object({
24
+ separator: v.optional(v.string()),
25
+ path: v.optional(v.picklist(["relative", "absolute"]))
26
+ });
27
+ const CheckEntrySchema = v.object({
28
+ name: v.string(),
29
+ match: RegexStringSchema,
30
+ exclude: v.optional(v.string()),
31
+ group: v.string(),
32
+ command: v.string(),
33
+ changedFiles: v.optional(ChangedFilesSchema)
34
+ });
35
+ const ReviewEntrySchema = v.object({
36
+ name: v.string(),
37
+ match: RegexStringSchema,
38
+ exclude: v.optional(v.string()),
39
+ vars: v.optional(v.record(v.string(), v.string())),
40
+ command: v.string(),
41
+ fallbacks: v.optional(v.array(v.string()))
42
+ });
43
+ const DefaultsSchema = v.object({
44
+ changed: v.optional(v.string()),
45
+ target: v.optional(v.string())
46
+ });
47
+ const GatecheckConfigSchema = v.object({
48
+ defaults: v.optional(DefaultsSchema),
49
+ checks: v.optional(v.array(CheckEntrySchema)),
50
+ reviews: v.optional(v.array(ReviewEntrySchema))
51
+ });
52
+ const CONFIG_FILENAME = "gatecheck.yaml";
53
+ const resolveConfigPath = (cwd) => resolve(cwd, CONFIG_FILENAME);
54
+ var ConfigNotFoundError = class extends Error {
55
+ constructor(configPath) {
56
+ super(`Config file not found: ${configPath}\nRun \`gatecheck setup\` to create one.`);
57
+ this.name = "ConfigNotFoundError";
58
+ }
59
+ };
60
+ const loadConfig = async (cwd) => {
61
+ const configPath = resolveConfigPath(cwd);
62
+ let raw;
63
+ try {
64
+ raw = await readFile(configPath, "utf-8");
65
+ } catch (err) {
66
+ if (err instanceof Error && "code" in err && err.code === "ENOENT") throw new ConfigNotFoundError(configPath);
67
+ throw err;
68
+ }
69
+ const parsed = parse(raw);
70
+ return v.parse(GatecheckConfigSchema, parsed);
71
+ };
72
+
73
+ //#endregion
74
+ //#region src/git.ts
75
+ const exec = (cmd, args, cwd) => new Promise((res, rej) => {
76
+ execFile(cmd, [...args], { cwd }, (error, stdout) => {
77
+ if (error) {
78
+ rej(error);
79
+ return;
80
+ }
81
+ res(stdout);
82
+ });
83
+ });
84
+ const gitCommandForSource = (source) => {
85
+ switch (source.type) {
86
+ case "untracked": return [
87
+ "ls-files",
88
+ "--others",
89
+ "--exclude-standard"
90
+ ];
91
+ case "unstaged": return [
92
+ "diff",
93
+ "--name-only",
94
+ "--diff-filter=d"
95
+ ];
96
+ case "staged": return [
97
+ "diff",
98
+ "--cached",
99
+ "--name-only",
100
+ "--diff-filter=d"
101
+ ];
102
+ case "branch": return [
103
+ "diff",
104
+ "--name-only",
105
+ "--diff-filter=d",
106
+ `${source.name}...HEAD`
107
+ ];
108
+ case "sha": return [
109
+ "diff",
110
+ "--name-only",
111
+ "--diff-filter=d",
112
+ `${source.sha}...HEAD`
113
+ ];
114
+ default: {
115
+ const _exhaustive = source;
116
+ throw new Error(`Unknown source type: ${JSON.stringify(_exhaustive)}`);
117
+ }
118
+ }
119
+ };
120
+ const parseFileList = (output) => output.split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
121
+ const describeSource = (source) => {
122
+ switch (source.type) {
123
+ case "untracked": return "untracked files";
124
+ case "unstaged": return "unstaged changes";
125
+ case "staged": return "staged changes";
126
+ case "branch": return `branch '${source.name}'`;
127
+ case "sha": return `sha '${source.sha}'`;
128
+ default: {
129
+ const _exhaustive = source;
130
+ throw new Error(`Unknown source type: ${JSON.stringify(_exhaustive)}`);
131
+ }
132
+ }
133
+ };
134
+ const getDiffSummary = async (sources, cwd) => {
135
+ return (await Promise.all(sources.map(async (source) => {
136
+ const args = gitCommandForSource(source).filter((a) => a !== "--name-only");
137
+ try {
138
+ return await exec("git", args, cwd);
139
+ } catch (error) {
140
+ const detail = error instanceof Error ? error.message : String(error);
141
+ throw new Error(`Failed to get diff for ${describeSource(source)}: ${detail}`);
142
+ }
143
+ }))).filter((r) => r.trim().length > 0).join("\n");
144
+ };
145
+ const getChangedFiles = async (sources, cwd) => {
146
+ const results = await Promise.all(sources.map(async (source) => {
147
+ const args = gitCommandForSource(source);
148
+ try {
149
+ return parseFileList(await exec("git", args, cwd));
150
+ } catch (error) {
151
+ const detail = error instanceof Error ? error.message : String(error);
152
+ throw new Error(`Failed to get changed files for ${describeSource(source)}: ${detail}`);
153
+ }
154
+ }));
155
+ const seen = /* @__PURE__ */ new Set();
156
+ const files = [];
157
+ for (const list of results) for (const file of list) {
158
+ const abs = resolve(cwd, file);
159
+ if (!seen.has(abs)) {
160
+ seen.add(abs);
161
+ files.push(abs);
162
+ }
163
+ }
164
+ return files;
165
+ };
166
+
167
+ //#endregion
168
+ //#region src/logger.ts
169
+ const log = (message) => {
170
+ process.stdout.write(`${message}\n`);
171
+ };
172
+ const logError = (message) => {
173
+ process.stderr.write(`${message}\n`);
174
+ };
175
+
176
+ //#endregion
177
+ //#region src/matcher.ts
178
+ const matchFiles = (files, match, cwd, exclude) => {
179
+ const matchRegex = new RegExp(match);
180
+ const results = [];
181
+ for (const file of files) {
182
+ const rel = relative(cwd, file);
183
+ if (exclude !== void 0 && matchesGlob(rel, exclude)) continue;
184
+ const m = matchRegex.exec(rel);
185
+ if (!m) continue;
186
+ const groups = {};
187
+ if (m.groups) {
188
+ for (const [key, value] of Object.entries(m.groups)) if (value !== void 0) groups[key] = value;
189
+ }
190
+ results.push({
191
+ file,
192
+ groups
193
+ });
194
+ }
195
+ return results;
196
+ };
197
+ const groupMatchResults = (results) => {
198
+ const map = /* @__PURE__ */ new Map();
199
+ for (const result of results) {
200
+ const keyParts = Object.values(result.groups);
201
+ const key = keyParts.length > 0 ? keyParts.join("/") : "";
202
+ const existing = map.get(key);
203
+ if (existing) existing.files.push(result.file);
204
+ else map.set(key, {
205
+ groups: result.groups,
206
+ files: [result.file]
207
+ });
208
+ }
209
+ return map;
210
+ };
211
+
212
+ //#endregion
213
+ //#region src/template.ts
214
+ const TEMPLATE_PATTERN = /\{\{\s*(\w+)\.(\w+)\s*\}\}/g;
215
+ const lookupValue = (scope, key, context) => {
216
+ switch (scope) {
217
+ case "env": return context.env[key];
218
+ case "match": return context.match[key];
219
+ case "ctx": return context.ctx[key];
220
+ case "vars": return context.vars[key];
221
+ default: return;
222
+ }
223
+ };
224
+ const resolveTemplate = (template, context) => template.replaceAll(TEMPLATE_PATTERN, (original, scope, key) => {
225
+ return lookupValue(scope, key, context) ?? original;
226
+ });
227
+ const resolveVars = (vars, baseContext) => {
228
+ const resolved = {};
229
+ const contextForVars = {
230
+ ...baseContext,
231
+ vars: {}
232
+ };
233
+ for (const [key, value] of Object.entries(vars)) resolved[key] = resolveTemplate(value, contextForVars);
234
+ return resolved;
235
+ };
236
+ const resolve$1 = (template, context) => resolveTemplate(template, context);
237
+ const resolveCommand = (template, context) => template.replaceAll(TEMPLATE_PATTERN, (original, scope, key) => {
238
+ const value = lookupValue(scope, key, context);
239
+ if (value === void 0) return original;
240
+ return scope === "vars" ? shellEscape(value) : value;
241
+ });
242
+ const getEnv = () => process.env;
243
+ const shellEscape = (value) => `'${value.replaceAll("'", "'\\''")}'`;
244
+ const buildContext = (overrides) => ({
245
+ env: getEnv(),
246
+ match: overrides.match ?? {},
247
+ ctx: overrides.ctx ?? {},
248
+ vars: overrides.vars ?? {}
249
+ });
250
+
251
+ //#endregion
252
+ //#region src/runner.ts
253
+ const runCommand = (command, cwd) => new Promise((res) => {
254
+ const child = spawn("sh", ["-c", command], {
255
+ cwd,
256
+ stdio: [
257
+ "ignore",
258
+ "pipe",
259
+ "pipe"
260
+ ]
261
+ });
262
+ const stdoutChunks = [];
263
+ const stderrChunks = [];
264
+ child.stdout.on("data", (chunk) => stdoutChunks.push(chunk));
265
+ child.stderr.on("data", (chunk) => stderrChunks.push(chunk));
266
+ child.on("close", (code) => {
267
+ res({
268
+ exitCode: code ?? 1,
269
+ stdout: Buffer.concat(stdoutChunks).toString(),
270
+ stderr: Buffer.concat(stderrChunks).toString()
271
+ });
272
+ });
273
+ });
274
+ const formatFileList = (files, cwd, changedFiles) => {
275
+ const sep = changedFiles?.separator ?? " ";
276
+ return (changedFiles?.path === "absolute" ? files : files.map((f) => relative(cwd, f))).map((p) => shellEscape(p)).join(sep);
277
+ };
278
+ const formatFileListPlain = (files, cwd) => files.map((f) => relative(cwd, f)).join(" ");
279
+ const hasNamedGroups = (pattern) => /\(\?<[^>]+>/.test(pattern);
280
+ const runSingleCheckEntry = async (entry, changedFiles, cwd) => {
281
+ const matched = matchFiles(changedFiles, entry.match, cwd, entry.exclude);
282
+ if (matched.length === 0) return [{
283
+ status: "skip",
284
+ name: entry.name
285
+ }];
286
+ if (hasNamedGroups(entry.match)) {
287
+ const grouped = groupMatchResults(matched);
288
+ return await Promise.all([...grouped.entries()].map(async ([groupKey, group]) => {
289
+ const checkName = groupKey ? `${entry.name}[${groupKey}]` : entry.name;
290
+ const ctx = { CHANGED_FILES: formatFileList(group.files, cwd, entry.changedFiles) };
291
+ const context = buildContext({
292
+ match: group.groups,
293
+ ctx
294
+ });
295
+ const command = resolve$1(entry.command, context);
296
+ const { exitCode, stdout, stderr } = await runCommand(command, cwd);
297
+ if (exitCode === 0) return {
298
+ status: "passed",
299
+ name: checkName,
300
+ command
301
+ };
302
+ return {
303
+ status: "failed",
304
+ name: checkName,
305
+ command,
306
+ exitCode,
307
+ stdout,
308
+ stderr
309
+ };
310
+ }));
311
+ }
312
+ const ctx = { CHANGED_FILES: formatFileList(matched.map((m) => m.file), cwd, entry.changedFiles) };
313
+ const context = buildContext({ ctx });
314
+ const command = resolve$1(entry.command, context);
315
+ const { exitCode, stdout, stderr } = await runCommand(command, cwd);
316
+ if (exitCode === 0) return [{
317
+ status: "passed",
318
+ name: entry.name,
319
+ command
320
+ }];
321
+ return [{
322
+ status: "failed",
323
+ name: entry.name,
324
+ command,
325
+ exitCode,
326
+ stdout,
327
+ stderr
328
+ }];
329
+ };
330
+ const runChecks = async (entries, changedFiles, cwd) => {
331
+ return (await Promise.allSettled(entries.map((entry) => runSingleCheckEntry(entry, changedFiles, cwd)))).flatMap((result, i) => {
332
+ if (result.status === "fulfilled") return result.value;
333
+ const entry = entries[i];
334
+ if (!entry) throw new Error(`Unexpected missing entry at index ${i}`);
335
+ return [{
336
+ status: "failed",
337
+ name: entry.name,
338
+ command: entry.command,
339
+ exitCode: 1,
340
+ stdout: "",
341
+ stderr: result.reason instanceof Error ? result.reason.message : String(result.reason)
342
+ }];
343
+ });
344
+ };
345
+ const runReviewWithFallbacks = async (commands, cwd, name) => {
346
+ for (const command of commands) {
347
+ const { exitCode, stdout, stderr } = await runCommand(command, cwd);
348
+ if (exitCode === 0) return {
349
+ status: "completed",
350
+ name,
351
+ command,
352
+ stdout
353
+ };
354
+ if (command === commands[commands.length - 1]) return {
355
+ status: "failed",
356
+ name,
357
+ command,
358
+ exitCode,
359
+ stdout,
360
+ stderr
361
+ };
362
+ }
363
+ return {
364
+ status: "failed",
365
+ name,
366
+ command: commands[commands.length - 1] ?? "",
367
+ exitCode: 1,
368
+ stdout: "",
369
+ stderr: "No commands to execute"
370
+ };
371
+ };
372
+ const runSingleReviewEntry = async (entry, changedFiles, cwd, diffSummary) => {
373
+ const matched = matchFiles(changedFiles, entry.match, cwd, entry.exclude);
374
+ if (matched.length === 0) return [{
375
+ status: "skip",
376
+ name: entry.name
377
+ }];
378
+ const allFiles = matched.map((m) => m.file);
379
+ const matchGroups = matched[0]?.groups ?? {};
380
+ const ctx = {
381
+ DIFF_SUMMARY: diffSummary,
382
+ CHANGED_FILES: formatFileListPlain(allFiles, cwd)
383
+ };
384
+ const baseContext = buildContext({
385
+ match: matchGroups,
386
+ ctx
387
+ });
388
+ const resolvedVars = entry.vars ? resolveVars(entry.vars, baseContext) : {};
389
+ const fullContext = buildContext({
390
+ match: matchGroups,
391
+ ctx,
392
+ vars: resolvedVars
393
+ });
394
+ return [await runReviewWithFallbacks([entry.command, ...entry.fallbacks ?? []].map((cmd) => resolveCommand(cmd, fullContext)), cwd, entry.name)];
395
+ };
396
+ const runReviews = async (entries, changedFiles, cwd, diffSummary) => {
397
+ return (await Promise.allSettled(entries.map((entry) => runSingleReviewEntry(entry, changedFiles, cwd, diffSummary)))).flatMap((result, i) => {
398
+ if (result.status === "fulfilled") return result.value;
399
+ const entry = entries[i];
400
+ if (!entry) throw new Error(`Unexpected missing entry at index ${i}`);
401
+ return [{
402
+ status: "failed",
403
+ name: entry.name,
404
+ command: entry.command,
405
+ exitCode: 1,
406
+ stdout: "",
407
+ stderr: result.reason instanceof Error ? result.reason.message : String(result.reason)
408
+ }];
409
+ });
410
+ };
411
+ const dryRunChecks = (entries, changedFiles, cwd) => {
412
+ for (const entry of entries) {
413
+ const matched = matchFiles(changedFiles, entry.match, cwd, entry.exclude);
414
+ if (matched.length === 0) {
415
+ log(` [skip] ${entry.name} (no matching files)`);
416
+ continue;
417
+ }
418
+ if (hasNamedGroups(entry.match)) {
419
+ const grouped = groupMatchResults(matched);
420
+ for (const [groupKey, group] of grouped) {
421
+ const checkName = groupKey ? `${entry.name}[${groupKey}]` : entry.name;
422
+ const ctx = { CHANGED_FILES: formatFileList(group.files, cwd) };
423
+ const context = buildContext({
424
+ match: group.groups,
425
+ ctx
426
+ });
427
+ const command = resolve$1(entry.command, context);
428
+ log(` [run] ${checkName}`);
429
+ log(` $ ${command}`);
430
+ }
431
+ } else {
432
+ const ctx = { CHANGED_FILES: formatFileList(matched.map((m) => m.file), cwd) };
433
+ const context = buildContext({ ctx });
434
+ const command = resolve$1(entry.command, context);
435
+ log(` [run] ${entry.name}`);
436
+ log(` $ ${command}`);
437
+ }
438
+ }
439
+ };
440
+ const dryRunReviews = (entries, changedFiles, cwd, diffSummary) => {
441
+ for (const entry of entries) {
442
+ const matched = matchFiles(changedFiles, entry.match, cwd, entry.exclude);
443
+ if (matched.length === 0) {
444
+ log(`${entry.name}: (no matching files)\n`);
445
+ continue;
446
+ }
447
+ const allFiles = matched.map((m) => m.file);
448
+ const matchGroups = matched[0]?.groups ?? {};
449
+ const ctx = {
450
+ DIFF_SUMMARY: diffSummary,
451
+ CHANGED_FILES: formatFileListPlain(allFiles, cwd)
452
+ };
453
+ const baseContext = buildContext({
454
+ match: matchGroups,
455
+ ctx
456
+ });
457
+ const resolvedVars = entry.vars ? resolveVars(entry.vars, baseContext) : {};
458
+ const fullContext = buildContext({
459
+ match: matchGroups,
460
+ ctx,
461
+ vars: resolvedVars
462
+ });
463
+ log(`${entry.name}:`);
464
+ log("");
465
+ log(" ctx:");
466
+ for (const [key, value] of Object.entries(ctx)) {
467
+ const lines = value.split("\n");
468
+ if (lines.length <= 5) log(` ${key}: ${value}`);
469
+ else log(` ${key}: (${lines.length} lines)`);
470
+ }
471
+ log("");
472
+ if (entry.vars !== void 0) {
473
+ log(" vars:");
474
+ for (const [key, value] of Object.entries(resolvedVars)) log(` ${key}: ${value}`);
475
+ log("");
476
+ }
477
+ const commands = [entry.command, ...entry.fallbacks ?? []].map((cmd) => resolveCommand(cmd, fullContext));
478
+ for (const cmd of commands) log(` $ ${cmd}`);
479
+ log("");
480
+ }
481
+ };
482
+ const reportCheckResults = (results) => {
483
+ const skipped = results.filter((r) => r.status === "skip");
484
+ const passed = results.filter((r) => r.status === "passed");
485
+ const failed = results.filter((r) => r.status === "failed");
486
+ if (failed.length > 0) for (const r of failed) {
487
+ logError(`\n── ${r.name} ──`);
488
+ if (r.stdout) logError(r.stdout.trimEnd());
489
+ if (r.stderr) logError(r.stderr.trimEnd());
490
+ }
491
+ log("");
492
+ for (const r of skipped) log(` - ${r.name} [skipped]`);
493
+ for (const r of passed) log(` ✓ ${r.name} [passed]`);
494
+ for (const r of failed) log(` ✗ ${r.name} [failed]`);
495
+ if (failed.length > 0) {
496
+ log(`\n${passed.length} passed, ${failed.length} failed, ${skipped.length} skipped`);
497
+ return false;
498
+ }
499
+ log(`\n${passed.length} passed, ${skipped.length} skipped`);
500
+ return true;
501
+ };
502
+ const reportCheckResultsJson = (results) => {
503
+ const passed = results.filter((r) => r.status === "passed");
504
+ const failed = results.filter((r) => r.status === "failed");
505
+ const skipped = results.filter((r) => r.status === "skip");
506
+ const checks = results.map((r) => {
507
+ switch (r.status) {
508
+ case "skip": return {
509
+ name: r.name,
510
+ status: r.status
511
+ };
512
+ case "passed": return {
513
+ name: r.name,
514
+ status: r.status,
515
+ command: r.command
516
+ };
517
+ case "failed": return {
518
+ name: r.name,
519
+ status: r.status,
520
+ command: r.command,
521
+ exitCode: r.exitCode,
522
+ stdout: r.stdout,
523
+ stderr: r.stderr
524
+ };
525
+ default: {
526
+ const _exhaustive = r;
527
+ throw new Error(`Unexpected status: ${JSON.stringify(_exhaustive)}`);
528
+ }
529
+ }
530
+ });
531
+ return {
532
+ status: failed.length > 0 ? "failed" : "passed",
533
+ summary: {
534
+ passed: passed.length,
535
+ failed: failed.length,
536
+ skipped: skipped.length
537
+ },
538
+ checks
539
+ };
540
+ };
541
+ const reportCheckResultsHooks = (results) => {
542
+ const failed = results.filter((r) => r.status === "failed");
543
+ if (failed.length === 0) return null;
544
+ const failureDetails = failed.map((r) => {
545
+ const lines = [`── ${r.name} ($ ${r.command}) ──`];
546
+ if (r.stdout) lines.push(r.stdout.trimEnd());
547
+ if (r.stderr) lines.push(r.stderr.trimEnd());
548
+ return lines.join("\n");
549
+ }).join("\n\n");
550
+ const passed = results.filter((r) => r.status === "passed").length;
551
+ const skipped = results.filter((r) => r.status === "skip").length;
552
+ return {
553
+ decision: "block",
554
+ reason: [
555
+ "gatecheck blocked: checks failed. Fix the errors below and try again.",
556
+ "",
557
+ failureDetails,
558
+ "",
559
+ `${passed} passed, ${failed.length} failed, ${skipped} skipped`
560
+ ].join("\n")
561
+ };
562
+ };
563
+ const reportReviewResults = (results) => {
564
+ const skipped = results.filter((r) => r.status === "skip");
565
+ const completed = results.filter((r) => r.status === "completed");
566
+ const failed = results.filter((r) => r.status === "failed");
567
+ for (const r of completed) {
568
+ log(`\n── ${r.name} ──`);
569
+ if (r.stdout) log(r.stdout.trimEnd());
570
+ }
571
+ if (failed.length > 0) for (const r of failed) {
572
+ logError(`\n── ${r.name} (failed) ──`);
573
+ if (r.stdout) logError(r.stdout.trimEnd());
574
+ if (r.stderr) logError(r.stderr.trimEnd());
575
+ }
576
+ log("");
577
+ for (const r of skipped) log(` - ${r.name} [skipped]`);
578
+ for (const r of completed) log(` ✓ ${r.name} [completed]`);
579
+ for (const r of failed) log(` ✗ ${r.name} [failed]`);
580
+ if (failed.length > 0) {
581
+ log(`\n${completed.length} completed, ${failed.length} failed, ${skipped.length} skipped`);
582
+ return false;
583
+ }
584
+ log(`\n${completed.length} completed, ${skipped.length} skipped`);
585
+ return true;
586
+ };
587
+
588
+ //#endregion
589
+ //#region src/bin.ts
590
+ const parseChangedSource = (raw) => {
591
+ if (raw === "untracked") return { type: "untracked" };
592
+ if (raw === "unstaged") return { type: "unstaged" };
593
+ if (raw === "staged") return { type: "staged" };
594
+ if (raw.startsWith("branch:")) {
595
+ const name = raw.slice(7);
596
+ if (name === "") throw new Error("branch: requires a branch name (e.g. branch:main)");
597
+ return {
598
+ type: "branch",
599
+ name
600
+ };
601
+ }
602
+ if (raw.startsWith("sha:")) {
603
+ const sha = raw.slice(4);
604
+ if (sha === "") throw new Error("sha: requires a commit SHA (e.g. sha:abc1234)");
605
+ return {
606
+ type: "sha",
607
+ sha
608
+ };
609
+ }
610
+ throw new Error(`Unknown changed source: ${raw}`);
611
+ };
612
+ const parseChangedSources = (raw) => raw.split(",").map((s) => parseChangedSource(s.trim()));
613
+ const DEFAULT_SOURCES = [{ type: "unstaged" }, { type: "staged" }];
614
+ const filterByTarget = (entries, target) => {
615
+ if (target === void 0 || target === "all") return entries;
616
+ const groups = target.split(",").map((s) => s.trim());
617
+ const knownGroups = new Set(entries.map((e) => e.group));
618
+ const unknown = groups.filter((g) => !knownGroups.has(g));
619
+ if (unknown.length > 0) logError(`Warning: unknown target group(s): ${unknown.join(", ")}`);
620
+ return entries.filter((e) => groups.includes(e.group));
621
+ };
622
+ const parseFormat = (raw) => {
623
+ if (raw === "json") return "json";
624
+ if (raw === "claude-code-hooks") return "claude-code-hooks";
625
+ if (raw === "copilot-cli-hooks") return "copilot-cli-hooks";
626
+ return "text";
627
+ };
628
+ const check = async (opts) => {
629
+ const cwd = process.cwd();
630
+ const fmt = parseFormat(opts.format);
631
+ const config = await loadConfig(cwd);
632
+ if (opts.dryRun === true && fmt !== "text") throw new Error("--dry-run can only be used with --format text");
633
+ const entries = filterByTarget(config.checks ?? [], opts.target ?? config.defaults?.target);
634
+ if (entries.length === 0) {
635
+ if (fmt === "text") log("No checks configured.");
636
+ if (fmt === "json") log(JSON.stringify(reportCheckResultsJson([])));
637
+ if (fmt === "claude-code-hooks" || fmt === "copilot-cli-hooks") {}
638
+ return;
639
+ }
640
+ const changedFiles = await getChangedFiles(opts.changed !== void 0 ? parseChangedSources(opts.changed) : config.defaults?.changed !== void 0 ? parseChangedSources(config.defaults.changed) : DEFAULT_SOURCES, cwd);
641
+ if (changedFiles.length === 0) {
642
+ if (fmt === "text") log("No changed files found.");
643
+ if (fmt === "json") log(JSON.stringify(reportCheckResultsJson([])));
644
+ return;
645
+ }
646
+ if (fmt === "text") log(`Found ${changedFiles.length} changed file(s).`);
647
+ if (opts.dryRun === true) {
648
+ log("");
649
+ dryRunChecks(entries, changedFiles, cwd);
650
+ return;
651
+ }
652
+ if (fmt === "text") log(`Running ${entries.length} check(s)...`);
653
+ const results = await runChecks(entries, changedFiles, cwd);
654
+ switch (fmt) {
655
+ case "text":
656
+ if (!reportCheckResults(results)) process.exitCode = 1;
657
+ break;
658
+ case "json": {
659
+ const output = reportCheckResultsJson(results);
660
+ log(JSON.stringify(output, null, 2));
661
+ if (output.status === "failed") process.exitCode = 1;
662
+ break;
663
+ }
664
+ case "claude-code-hooks":
665
+ case "copilot-cli-hooks": {
666
+ const output = reportCheckResultsHooks(results);
667
+ if (output !== null) {
668
+ log(JSON.stringify(output));
669
+ process.exitCode = 1;
670
+ }
671
+ break;
672
+ }
673
+ default: {
674
+ const _exhaustive = fmt;
675
+ throw new Error(`Unknown format: ${String(_exhaustive)}`);
676
+ }
677
+ }
678
+ };
679
+ const review = async (opts) => {
680
+ const cwd = process.cwd();
681
+ const config = await loadConfig(cwd);
682
+ const entries = config.reviews ?? [];
683
+ if (entries.length === 0) {
684
+ log("No reviews configured.");
685
+ return;
686
+ }
687
+ const changedSources = opts.changed !== void 0 ? parseChangedSources(opts.changed) : config.defaults?.changed !== void 0 ? parseChangedSources(config.defaults.changed) : DEFAULT_SOURCES;
688
+ const changedFiles = await getChangedFiles(changedSources, cwd);
689
+ if (changedFiles.length === 0) {
690
+ log("No changed files found.");
691
+ return;
692
+ }
693
+ log(`Found ${changedFiles.length} changed file(s).`);
694
+ const diffSummary = await getDiffSummary(changedSources, cwd);
695
+ if (opts.dryRun === true) {
696
+ log("");
697
+ dryRunReviews(entries, changedFiles, cwd, diffSummary);
698
+ return;
699
+ }
700
+ log(`Running ${entries.length} review(s)...`);
701
+ if (!reportReviewResults(await runReviews(entries, changedFiles, cwd, diffSummary))) process.exitCode = 1;
702
+ };
703
+ const program = new Command().name("gatecheck").description(description).version(version);
704
+ program.command("check").description("Run deterministic checks (lint, typecheck, test) against changed files").option("-c, --changed <sources>", "Changed sources (comma-separated: untracked,unstaged,staged,branch:<name>,sha:<sha>)").option("-t, --target <groups>", "Target groups (comma-separated or \"all\")").option("-d, --dry-run", "Show which checks would run without executing them").addOption(new Option("-f, --format <format>", "Output format").choices([
705
+ "text",
706
+ "json",
707
+ "claude-code-hooks",
708
+ "copilot-cli-hooks"
709
+ ])).action(check);
710
+ program.command("review").description("Run AI-powered reviews against changed files").option("-c, --changed <sources>", "Changed sources (comma-separated: untracked,unstaged,staged,branch:<name>,sha:<sha>)").option("-d, --dry-run", "Show review configuration and matched files without executing").action(review);
711
+ program.command("setup").description("Create or update gatecheck.yaml").option("--non-interactive", "Skip prompts and use defaults with auto-detected presets").action(async (opts) => {
712
+ const { runSetup } = await import("./setup-BGSEp6JC.mjs");
713
+ await runSetup(process.cwd(), { nonInteractive: opts.nonInteractive });
714
+ });
715
+ program.parseAsync().catch((error) => {
716
+ if (error instanceof ConfigNotFoundError) logError(error.message);
717
+ else logError(error instanceof Error ? error.message : String(error));
718
+ process.exitCode = 1;
719
+ });
720
+
721
+ //#endregion
722
+ export { loadConfig as n, resolveConfigPath as r, log as t };