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.
@@ -0,0 +1,441 @@
1
+ import { n as loadConfig, r as resolveConfigPath, t as log } from "./bin.mjs";
2
+ import { access, mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import * as v from "valibot";
5
+ import { stringify } from "yaml";
6
+ import inquirer from "inquirer";
7
+
8
+ //#region src/pm.ts
9
+ const lockfiles = [
10
+ {
11
+ file: "pnpm-lock.yaml",
12
+ pm: "pnpm"
13
+ },
14
+ {
15
+ file: "bun.lockb",
16
+ pm: "bun"
17
+ },
18
+ {
19
+ file: "bun.lock",
20
+ pm: "bun"
21
+ },
22
+ {
23
+ file: "yarn.lock",
24
+ pm: "yarn"
25
+ },
26
+ {
27
+ file: "package-lock.json",
28
+ pm: "npm"
29
+ }
30
+ ];
31
+ const detectPackageManager = async (cwd) => {
32
+ for (const { file, pm } of lockfiles) try {
33
+ await access(resolve(cwd, file));
34
+ return pm;
35
+ } catch {}
36
+ return "npm";
37
+ };
38
+ const executors = {
39
+ pnpm: "pnpm exec",
40
+ npm: "npx",
41
+ yarn: "yarn exec",
42
+ bun: "bunx"
43
+ };
44
+ const getExecutor = (pm) => executors[pm];
45
+ const runners = {
46
+ pnpm: "pnpm",
47
+ npm: "npx",
48
+ yarn: "yarn",
49
+ bun: "bunx"
50
+ };
51
+ const getRunner = (pm) => runners[pm];
52
+
53
+ //#endregion
54
+ //#region src/presets.ts
55
+ const presets = [
56
+ {
57
+ name: "prettier",
58
+ description: "Format with Prettier",
59
+ checks: { prettier: {
60
+ match: "\\.((m|c)?(j|t)sx?|json|css|scss|less|html|md|ya?ml)$",
61
+ command: "prettier --write --no-error-on-unmatched-pattern {{ ctx.CHANGED_FILES }}",
62
+ group: "format"
63
+ } }
64
+ },
65
+ {
66
+ name: "oxfmt",
67
+ description: "Format with oxfmt",
68
+ checks: { oxfmt: {
69
+ match: "\\.(m|c)?(j|t)sx?$",
70
+ command: "oxfmt --write --no-error-on-unmatched-pattern {{ ctx.CHANGED_FILES }}",
71
+ group: "format"
72
+ } }
73
+ },
74
+ {
75
+ name: "eslint",
76
+ description: "Lint with ESLint",
77
+ checks: { eslint: {
78
+ match: "\\.(m|c)?(j|t)sx?$",
79
+ command: "eslint {{ ctx.CHANGED_FILES }}",
80
+ group: "lint"
81
+ } }
82
+ },
83
+ {
84
+ name: "oxlint",
85
+ description: "Lint with oxlint",
86
+ checks: { oxlint: {
87
+ match: "\\.(m|c)?(j|t)sx?$",
88
+ command: "oxlint --type-aware --fix {{ ctx.CHANGED_FILES }}",
89
+ group: "lint"
90
+ } }
91
+ },
92
+ {
93
+ name: "biome",
94
+ description: "Lint & format with Biome",
95
+ checks: {
96
+ "biome-format": {
97
+ match: "\\.((m|c)?(j|t)sx?|json|jsonc|css)$",
98
+ command: "biome format --write {{ ctx.CHANGED_FILES }}",
99
+ group: "format"
100
+ },
101
+ "biome-check": {
102
+ match: "\\.((m|c)?(j|t)sx?|json|jsonc|css)$",
103
+ command: "biome check --write {{ ctx.CHANGED_FILES }}",
104
+ group: "lint"
105
+ }
106
+ }
107
+ },
108
+ {
109
+ name: "tsc",
110
+ description: "Type-check with TypeScript compiler",
111
+ checks: { typecheck: {
112
+ match: "\\.(m|c)?tsx?$",
113
+ command: "tsc --noEmit",
114
+ group: "typecheck"
115
+ } }
116
+ },
117
+ {
118
+ name: "tsgo",
119
+ description: "Type-check with tsgo (native TypeScript)",
120
+ checks: { "typecheck-tsgo": {
121
+ match: "\\.(m|c)?tsx?$",
122
+ command: "tsgo --noEmit",
123
+ group: "typecheck"
124
+ } }
125
+ },
126
+ {
127
+ name: "vitest",
128
+ description: "Run related tests with Vitest",
129
+ checks: { vitest: {
130
+ match: "\\.(m|c)?(j|t)sx?$",
131
+ command: "vitest related --run --passWithNoTests {{ ctx.CHANGED_FILES }}",
132
+ group: "test"
133
+ } }
134
+ },
135
+ {
136
+ name: "jest",
137
+ description: "Run related tests with Jest",
138
+ checks: { jest: {
139
+ match: "\\.(m|c)?(j|t)sx?$",
140
+ command: "jest --findRelatedTests --passWithNoTests {{ ctx.CHANGED_FILES }}",
141
+ group: "test"
142
+ } }
143
+ }
144
+ ];
145
+ const REVIEW_PROMPT = [
146
+ "Changed files: {{ ctx.CHANGED_FILES }}",
147
+ "",
148
+ "You are a professional software architect.",
149
+ "Please review the changes above.",
150
+ "Point out any design issues, bug risks, or improvements."
151
+ ].join("\n");
152
+ const reviewPresets = [{
153
+ name: "codex",
154
+ description: "Architecture review with OpenAI Codex",
155
+ reviews: { "codex-review": {
156
+ match: ".*",
157
+ exclude: "**/*.md",
158
+ vars: { prompt: REVIEW_PROMPT },
159
+ command: "codex exec --sandbox 'workspace-write' {{ vars.prompt }}"
160
+ } }
161
+ }, {
162
+ name: "claude",
163
+ description: "Architecture review with Claude Code",
164
+ reviews: { "claude-review": {
165
+ match: ".*",
166
+ exclude: "**/*.md",
167
+ vars: { prompt: REVIEW_PROMPT },
168
+ command: "claude --permission-mode 'auto' -p {{ vars.prompt }}"
169
+ } }
170
+ }];
171
+
172
+ //#endregion
173
+ //#region src/setup.ts
174
+ const DEFAULT_CHANGED = "untracked,unstaged,staged,branch:main";
175
+ const DEFAULT_TARGET = "all";
176
+ const promptDefaults = async (existing) => {
177
+ const answers = await inquirer.prompt([{
178
+ type: "input",
179
+ name: "changed",
180
+ message: "Default changed sources (comma-separated):",
181
+ default: existing?.changed ?? DEFAULT_CHANGED
182
+ }, {
183
+ type: "input",
184
+ name: "target",
185
+ message: "Default target groups (comma-separated or 'all'):",
186
+ default: existing?.target ?? DEFAULT_TARGET
187
+ }]);
188
+ return {
189
+ changed: answers.changed,
190
+ target: answers.target
191
+ };
192
+ };
193
+ const resolveDefaults = (existing) => ({
194
+ changed: existing?.changed ?? DEFAULT_CHANGED,
195
+ target: existing?.target ?? DEFAULT_TARGET
196
+ });
197
+ const presetDependencies = {
198
+ prettier: ["prettier"],
199
+ oxfmt: ["oxfmt"],
200
+ eslint: ["eslint"],
201
+ oxlint: ["oxlint"],
202
+ biome: ["@biomejs/biome"],
203
+ tsc: ["typescript"],
204
+ tsgo: ["@typescript/native-preview"],
205
+ vitest: ["vitest"],
206
+ jest: ["jest"]
207
+ };
208
+ const PackageJsonSchema = v.object({
209
+ dependencies: v.optional(v.record(v.string(), v.string())),
210
+ devDependencies: v.optional(v.record(v.string(), v.string()))
211
+ });
212
+ const readInstalledDeps = async (cwd) => {
213
+ try {
214
+ const raw = await readFile(join(cwd, "package.json"), "utf-8");
215
+ const json = JSON.parse(raw);
216
+ const pkg = v.parse(PackageJsonSchema, json);
217
+ const deps = /* @__PURE__ */ new Set();
218
+ if (pkg.dependencies !== void 0) for (const name of Object.keys(pkg.dependencies)) deps.add(name);
219
+ if (pkg.devDependencies !== void 0) for (const name of Object.keys(pkg.devDependencies)) deps.add(name);
220
+ return deps;
221
+ } catch {
222
+ return /* @__PURE__ */ new Set();
223
+ }
224
+ };
225
+ const prefixCommand = (command, executor) => `${executor} ${command}`;
226
+ const isPresetDetected = (presetName, existingNames, installedDeps) => {
227
+ const preset = presets.find((p) => p.name === presetName);
228
+ if (preset !== void 0 && Object.keys(preset.checks).some((k) => existingNames.has(k))) return true;
229
+ const deps = presetDependencies[presetName];
230
+ if (deps !== void 0 && deps.some((d) => installedDeps.has(d))) return true;
231
+ return false;
232
+ };
233
+ const toCheckEntries = (selected, existingChecks, executor) => {
234
+ const existingByName = new Map(existingChecks.map((c) => [c.name, c]));
235
+ return selected.flatMap((name) => {
236
+ const preset = presets.find((p) => p.name === name);
237
+ if (preset === void 0) return [];
238
+ const checks = preset.checks;
239
+ return Object.entries(checks).map(([checkName, checkConfig]) => {
240
+ const existing = existingByName.get(checkName);
241
+ if (existing !== void 0) return existing;
242
+ return {
243
+ name: checkName,
244
+ match: checkConfig.match,
245
+ group: checkConfig.group,
246
+ command: prefixCommand(checkConfig.command, executor)
247
+ };
248
+ });
249
+ });
250
+ };
251
+ const promptPresets = async (existingChecks, executor, installedDeps) => {
252
+ const existingNames = new Set(existingChecks.map((c) => c.name));
253
+ const { selected } = await inquirer.prompt([{
254
+ type: "checkbox",
255
+ name: "selected",
256
+ message: "Select checks to add:",
257
+ choices: presets.map((p) => ({
258
+ name: `${p.name} - ${p.description}`,
259
+ value: p.name,
260
+ checked: isPresetDetected(p.name, existingNames, installedDeps)
261
+ }))
262
+ }]);
263
+ return toCheckEntries(selected, existingChecks, executor);
264
+ };
265
+ const autoSelectPresets = (existingChecks, executor, installedDeps) => {
266
+ const existingNames = new Set(existingChecks.map((c) => c.name));
267
+ return toCheckEntries(presets.filter((p) => isPresetDetected(p.name, existingNames, installedDeps)).map((p) => p.name), existingChecks, executor);
268
+ };
269
+ const toReviewEntries = (selected, existingReviews) => {
270
+ const existingByName = new Map(existingReviews.map((r) => [r.name, r]));
271
+ return selected.flatMap((name) => {
272
+ const preset = reviewPresets.find((p) => p.name === name);
273
+ if (preset === void 0) return [];
274
+ const reviews = preset.reviews;
275
+ return Object.entries(reviews).map(([reviewName, reviewConfig]) => {
276
+ const existing = existingByName.get(reviewName);
277
+ if (existing !== void 0) return existing;
278
+ return {
279
+ name: reviewName,
280
+ ...reviewConfig
281
+ };
282
+ });
283
+ });
284
+ };
285
+ const promptReviewPresets = async (existingReviews) => {
286
+ const { selected } = await inquirer.prompt([{
287
+ type: "select",
288
+ name: "selected",
289
+ message: "Select a review preset:",
290
+ choices: [...reviewPresets.map((p) => ({
291
+ name: `${p.name} - ${p.description}`,
292
+ value: p.name
293
+ })), {
294
+ name: "none - Skip review setup",
295
+ value: "none"
296
+ }]
297
+ }]);
298
+ if (selected === "none") return existingReviews;
299
+ return toReviewEntries([selected], existingReviews);
300
+ };
301
+ const StopHookEntrySchema = v.object({
302
+ matcher: v.string(),
303
+ hooks: v.array(v.object({
304
+ type: v.string(),
305
+ command: v.string()
306
+ }))
307
+ });
308
+ const ClaudeSettingsSchema = v.looseObject({ hooks: v.optional(v.looseObject({ Stop: v.optional(v.array(StopHookEntrySchema)) })) });
309
+ const resolveClaudeSettingsPath = (cwd) => join(cwd, ".claude", "settings.json");
310
+ const readClaudeSettings = async (path) => {
311
+ try {
312
+ const raw = await readFile(path, "utf-8");
313
+ const json = JSON.parse(raw);
314
+ return v.parse(ClaudeSettingsSchema, json);
315
+ } catch {
316
+ return {};
317
+ }
318
+ };
319
+ const hasStopHook = (settings, command) => {
320
+ return (settings.hooks?.Stop ?? []).some((entry) => entry.hooks.some((h) => h.command === command));
321
+ };
322
+ const promptClaudeCodeHooks = async (cwd, runner) => {
323
+ const settingsPath = resolveClaudeSettingsPath(cwd);
324
+ const command = `${runner} gatecheck check --format claude-code-hooks`;
325
+ const settings = await readClaudeSettings(settingsPath);
326
+ if (hasStopHook(settings, command)) {
327
+ log("Claude Code Stop hook is already configured.");
328
+ return;
329
+ }
330
+ const { enable } = await inquirer.prompt([{
331
+ type: "confirm",
332
+ name: "enable",
333
+ message: "Set up Claude Code Stop hook? This runs checks before Claude finishes and blocks it from stopping if any check fails.",
334
+ default: true
335
+ }]);
336
+ if (!enable) return;
337
+ const hookEntry = {
338
+ matcher: "",
339
+ hooks: [{
340
+ type: "command",
341
+ command
342
+ }]
343
+ };
344
+ const existingStop = settings.hooks?.Stop ?? [];
345
+ const updated = {
346
+ ...settings,
347
+ hooks: {
348
+ ...settings.hooks,
349
+ Stop: [...existingStop, hookEntry]
350
+ }
351
+ };
352
+ await mkdir(dirname(settingsPath), { recursive: true });
353
+ await writeFile(settingsPath, `${JSON.stringify(updated, null, 2)}\n`);
354
+ log(`Claude Code hook written to ${settingsPath}`);
355
+ };
356
+ const CopilotHookEntrySchema = v.object({
357
+ type: v.string(),
358
+ bash: v.string()
359
+ });
360
+ const CopilotHooksFileSchema = v.looseObject({
361
+ version: v.number(),
362
+ hooks: v.optional(v.looseObject({ agentStop: v.optional(v.array(CopilotHookEntrySchema)) }))
363
+ });
364
+ const resolveCopilotHooksPath = (cwd) => join(cwd, ".github", "hooks", "gatecheck.json");
365
+ const readCopilotHooksFile = async (path) => {
366
+ try {
367
+ const raw = await readFile(path, "utf-8");
368
+ const json = JSON.parse(raw);
369
+ return v.parse(CopilotHooksFileSchema, json);
370
+ } catch {
371
+ return { version: 1 };
372
+ }
373
+ };
374
+ const hasCopilotAgentStopHook = (config, bash) => {
375
+ return (config.hooks?.agentStop ?? []).some((entry) => entry.bash === bash);
376
+ };
377
+ const promptCopilotCliHooks = async (cwd, runner) => {
378
+ const hooksPath = resolveCopilotHooksPath(cwd);
379
+ const bash = `${runner} gatecheck check --format copilot-cli-hooks`;
380
+ const config = await readCopilotHooksFile(hooksPath);
381
+ if (hasCopilotAgentStopHook(config, bash)) {
382
+ log("Copilot CLI agentStop hook is already configured.");
383
+ return;
384
+ }
385
+ const { enable } = await inquirer.prompt([{
386
+ type: "confirm",
387
+ name: "enable",
388
+ message: "Set up Copilot CLI agentStop hook? This runs checks before Copilot finishes and blocks it from stopping if any check fails.",
389
+ default: true
390
+ }]);
391
+ if (!enable) return;
392
+ const hookEntry = {
393
+ type: "command",
394
+ bash
395
+ };
396
+ const existingAgentStop = config.hooks?.agentStop ?? [];
397
+ const updated = {
398
+ ...config,
399
+ version: config.version,
400
+ hooks: {
401
+ ...config.hooks,
402
+ agentStop: [...existingAgentStop, hookEntry]
403
+ }
404
+ };
405
+ await mkdir(dirname(hooksPath), { recursive: true });
406
+ await writeFile(hooksPath, `${JSON.stringify(updated, null, 2)}\n`);
407
+ log(`Copilot CLI hook written to ${hooksPath}`);
408
+ };
409
+ const runSetup = async (cwd, options) => {
410
+ const nonInteractive = options?.nonInteractive === true;
411
+ let existing;
412
+ try {
413
+ existing = await loadConfig(cwd);
414
+ } catch {}
415
+ log("gatecheck setup\n");
416
+ const pm = await detectPackageManager(cwd);
417
+ const executor = getExecutor(pm);
418
+ const runner = getRunner(pm);
419
+ log(`Detected package manager: ${pm}\n`);
420
+ const installedDeps = existing !== void 0 ? /* @__PURE__ */ new Set() : await readInstalledDeps(cwd);
421
+ const defaults = nonInteractive ? resolveDefaults(existing?.defaults) : await promptDefaults(existing?.defaults);
422
+ const checks = nonInteractive ? autoSelectPresets(existing?.checks ?? [], executor, installedDeps) : await promptPresets(existing?.checks ?? [], executor, installedDeps);
423
+ const reviews = nonInteractive ? existing?.reviews ?? [] : await promptReviewPresets(existing?.reviews ?? []);
424
+ const config = {
425
+ defaults,
426
+ checks,
427
+ ...reviews.length > 0 ? { reviews } : {}
428
+ };
429
+ const configPath = resolveConfigPath(cwd);
430
+ await writeFile(configPath, stringify(config, { lineWidth: 120 }));
431
+ log(`\nConfig written to ${configPath}`);
432
+ if (!nonInteractive) {
433
+ log("");
434
+ await promptClaudeCodeHooks(cwd, runner);
435
+ log("");
436
+ await promptCopilotCliHooks(cwd, runner);
437
+ }
438
+ };
439
+
440
+ //#endregion
441
+ export { runSetup };
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "gatecheck",
3
+ "version": "0.0.1-beta.5",
4
+ "description": "Quality gate for git changes — run checks and AI reviews against changed files",
5
+ "license": "MIT",
6
+ "bin": {
7
+ "gatecheck": "./dist/bin.mjs"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "type": "module",
13
+ "dependencies": {
14
+ "commander": "14.0.3",
15
+ "inquirer": "13.3.0",
16
+ "valibot": "1.2.0",
17
+ "yaml": "2.8.2"
18
+ },
19
+ "devDependencies": {
20
+ "@tsconfig/strictest": "2.0.8",
21
+ "@types/inquirer": "9.0.9",
22
+ "@types/node": "25.3.3",
23
+ "@valibot/to-json-schema": "1.5.0",
24
+ "lefthook": "2.1.2",
25
+ "npm-run-all2": "8.0.4",
26
+ "oxfmt": "0.36.0",
27
+ "oxlint": "1.51.0",
28
+ "oxlint-tsgolint": "0.16.0",
29
+ "release-it": "19.2.4",
30
+ "release-it-pnpm": "4.6.6",
31
+ "tsdown": "0.20.3",
32
+ "tsx": "4.21.0",
33
+ "typescript": "5.9.3",
34
+ "vitest": "4.0.18"
35
+ },
36
+ "engines": {
37
+ "node": ">=22"
38
+ },
39
+ "scripts": {
40
+ "cli": "tsx ./src/bin.ts",
41
+ "test": "vitest run",
42
+ "build": "tsdown",
43
+ "typecheck": "tsc --noEmit",
44
+ "lint": "run-s lint:*",
45
+ "lint:oxlint": "oxlint --type-aware --type-check --ignore-path .gitignore --report-unused-disable-directives --tsconfig ./tsconfig.json",
46
+ "lint:oxfmt": "pnpm fix:oxfmt --check",
47
+ "fix": "run-s fix:*",
48
+ "fix:oxlint": "pnpm lint:oxlint --fix --fix-suggestions --fix-dangerously",
49
+ "fix:oxfmt": "oxfmt --ignore-path .gitignore --no-error-on-unmatched-pattern"
50
+ }
51
+ }