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/LICENSE +21 -0
- package/README.md +311 -0
- package/dist/bin.mjs +722 -0
- package/dist/setup-BGSEp6JC.mjs +441 -0
- package/package.json +51 -0
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 };
|