@cyanheads/mcp-ts-core 0.1.0 → 0.1.2
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/CLAUDE.md +2 -2
- package/README.md +1 -1
- package/dist/cli/init.js +41 -18
- package/dist/cli/init.js.map +1 -1
- package/dist/core/app.d.ts.map +1 -1
- package/dist/core/app.js +16 -3
- package/dist/core/app.js.map +1 -1
- package/dist/mcp-server/transports/http/httpTransport.d.ts.map +1 -1
- package/dist/mcp-server/transports/http/httpTransport.js +4 -2
- package/dist/mcp-server/transports/http/httpTransport.js.map +1 -1
- package/dist/storage/core/storageFactory.d.ts +1 -1
- package/dist/storage/core/storageFactory.d.ts.map +1 -1
- package/dist/storage/core/storageFactory.js +2 -2
- package/dist/storage/core/storageFactory.js.map +1 -1
- package/dist/utils/telemetry/instrumentation.d.ts.map +1 -1
- package/dist/utils/telemetry/instrumentation.js +3 -1
- package/dist/utils/telemetry/instrumentation.js.map +1 -1
- package/package.json +6 -9
- package/scripts/build.ts +129 -0
- package/scripts/clean.ts +90 -0
- package/scripts/devcheck.ts +962 -0
- package/scripts/tree.ts +324 -0
- package/skills/design-mcp-server/SKILL.md +191 -0
- package/skills/setup/SKILL.md +11 -6
- package/templates/AGENTS.md +26 -8
- package/templates/CLAUDE.md +26 -8
- package/templates/_tsconfig.json +2 -22
- package/templates/biome.template.json +1 -41
- package/templates/package.json +33 -11
- package/templates/vitest.config.ts +13 -11
|
@@ -0,0 +1,962 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import process from 'node:process';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
/// <reference types="bun-types" />
|
|
7
|
+
/**
|
|
8
|
+
* @fileoverview Comprehensive development script for quality and security checks.
|
|
9
|
+
* @module scripts/devcheck
|
|
10
|
+
* @description
|
|
11
|
+
* This script runs a series of checks (linting, types, formatting, security, etc.).
|
|
12
|
+
* It is optimized for speed with caching, incremental builds, and parallel execution.
|
|
13
|
+
* Pre-commit hooks analyze only staged files for maximum performance.
|
|
14
|
+
*
|
|
15
|
+
* @performance
|
|
16
|
+
* - Uses Biome for unified linting and formatting
|
|
17
|
+
* - Uses TypeScript incremental builds (.tsbuildinfo) for faster type checking
|
|
18
|
+
* - Runs all checks in parallel using Promise.allSettled
|
|
19
|
+
* - Fast mode (--fast) skips slow network-bound checks
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* // Run all checks (Auto-fixing enabled):
|
|
23
|
+
* // bun run scripts/devcheck.ts
|
|
24
|
+
*
|
|
25
|
+
* // Run in read-only mode:
|
|
26
|
+
* // bun run scripts/devcheck.ts --no-fix
|
|
27
|
+
*
|
|
28
|
+
* // Fast mode (skip network-bound checks like audit, outdated):
|
|
29
|
+
* // bun run scripts/devcheck.ts --fast
|
|
30
|
+
*
|
|
31
|
+
* // Skip specific checks:
|
|
32
|
+
* // bun run scripts/devcheck.ts --no-lint --no-audit
|
|
33
|
+
*
|
|
34
|
+
* // Enable optional checks (e.g., tests are off by default):
|
|
35
|
+
* // bun run scripts/devcheck.ts --test
|
|
36
|
+
*
|
|
37
|
+
* // Run only a single check (case-insensitive partial match):
|
|
38
|
+
* // bun run scripts/devcheck.ts --only lint
|
|
39
|
+
*/
|
|
40
|
+
import { type Subprocess, spawn } from 'bun';
|
|
41
|
+
|
|
42
|
+
/** Track active child processes for clean shutdown on SIGINT/SIGTERM. */
|
|
43
|
+
const activeProcs = new Set<Subprocess>();
|
|
44
|
+
|
|
45
|
+
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
|
|
46
|
+
process.on(signal, () => {
|
|
47
|
+
for (const proc of activeProcs) {
|
|
48
|
+
proc.kill();
|
|
49
|
+
}
|
|
50
|
+
process.exit(130);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// =============================================================================
|
|
55
|
+
// Embedded Dependencies
|
|
56
|
+
// =============================================================================
|
|
57
|
+
|
|
58
|
+
// picocolors (https://github.com/alexeyraspopov/picocolors) - MIT License
|
|
59
|
+
// Embedded so the script runs without needing 'npm install'.
|
|
60
|
+
// Respects NO_COLOR (https://no-color.org/) and FORCE_COLOR conventions.
|
|
61
|
+
const isColorSupported =
|
|
62
|
+
!process.env.NO_COLOR &&
|
|
63
|
+
((!!process.env.FORCE_COLOR && process.env.FORCE_COLOR !== '0') || !!process.stdout.isTTY);
|
|
64
|
+
|
|
65
|
+
const createColor = (open: string, close: string, closeRe: RegExp) => (str: string | number) => {
|
|
66
|
+
if (!isColorSupported) return `${str}`;
|
|
67
|
+
// Replace any inner close sequences so outer color is restored
|
|
68
|
+
return open + `${str}`.replace(closeRe, close + open) + close;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const esc = (code: string) => new RegExp(code.replace('[', '\\['), 'g');
|
|
72
|
+
const c = {
|
|
73
|
+
bold: createColor('\x1b[1m', '\x1b[22m', esc('\x1b[22m')),
|
|
74
|
+
dim: createColor('\x1b[2m', '\x1b[22m', esc('\x1b[22m')),
|
|
75
|
+
red: createColor('\x1b[31m', '\x1b[39m', esc('\x1b[39m')),
|
|
76
|
+
green: createColor('\x1b[32m', '\x1b[39m', esc('\x1b[39m')),
|
|
77
|
+
yellow: createColor('\x1b[33m', '\x1b[39m', esc('\x1b[39m')),
|
|
78
|
+
blue: createColor('\x1b[34m', '\x1b[39m', esc('\x1b[39m')),
|
|
79
|
+
magenta: createColor('\x1b[35m', '\x1b[39m', esc('\x1b[39m')),
|
|
80
|
+
cyan: createColor('\x1b[36m', '\x1b[39m', esc('\x1b[39m')),
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** A type alias for the picocolors object. */
|
|
84
|
+
type Colors = typeof c;
|
|
85
|
+
|
|
86
|
+
// =============================================================================
|
|
87
|
+
// Types & Interfaces
|
|
88
|
+
// =============================================================================
|
|
89
|
+
|
|
90
|
+
type RunMode = 'check' | 'fix';
|
|
91
|
+
type UIMode = 'Checking' | 'Fixing';
|
|
92
|
+
|
|
93
|
+
interface AppContext {
|
|
94
|
+
fastMode: boolean;
|
|
95
|
+
flags: Set<string>;
|
|
96
|
+
isHuskyHook: boolean;
|
|
97
|
+
noFix: boolean;
|
|
98
|
+
/** When set, only run checks whose name matches (case-insensitive). */
|
|
99
|
+
onlyCheck: string | null;
|
|
100
|
+
rootDir: string;
|
|
101
|
+
/** List of staged files, populated only if isHuskyHook is true. */
|
|
102
|
+
stagedFiles: string[];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
interface CommandResult {
|
|
106
|
+
checkName: string;
|
|
107
|
+
duration: number;
|
|
108
|
+
exitCode: number;
|
|
109
|
+
/** Buffered log lines captured during parallel execution. */
|
|
110
|
+
logLines: string[];
|
|
111
|
+
skipped: boolean;
|
|
112
|
+
stderr: string;
|
|
113
|
+
stdout: string;
|
|
114
|
+
/** If set, check passed but with a warning (e.g., upstream-only vulnerabilities). */
|
|
115
|
+
warning?: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Represents the raw result from a shell execution. */
|
|
119
|
+
type ShellResult = Omit<CommandResult, 'checkName' | 'duration' | 'skipped' | 'logLines'>;
|
|
120
|
+
|
|
121
|
+
interface Check {
|
|
122
|
+
/** Indicates if the check supports auto-fixing. */
|
|
123
|
+
canFix: boolean;
|
|
124
|
+
/** The flag to skip this check (e.g., '--no-lint'). */
|
|
125
|
+
flag: string;
|
|
126
|
+
/** Function that returns the command array based on the context and mode. Returns null to skip. */
|
|
127
|
+
getCommand: (ctx: AppContext, mode: RunMode) => string[] | null;
|
|
128
|
+
/**
|
|
129
|
+
* Optional predicate to determine success.
|
|
130
|
+
* Useful for tools that signal issues via stdout or have non-standard exit codes.
|
|
131
|
+
* Return `{ success, warning }` to pass with a visible warning (e.g., upstream-only vulns).
|
|
132
|
+
*/
|
|
133
|
+
isSuccess?: (
|
|
134
|
+
result: ShellResult,
|
|
135
|
+
mode: RunMode,
|
|
136
|
+
) => boolean | { success: boolean; warning?: string };
|
|
137
|
+
name: string;
|
|
138
|
+
/** If true, check is off by default — only runs when its flag is explicitly provided. */
|
|
139
|
+
requiresFlag?: boolean;
|
|
140
|
+
/** If true, this check is skipped in fast mode (typically network-bound or very slow). */
|
|
141
|
+
slowCheck?: boolean;
|
|
142
|
+
tip?: (c: Colors) => string;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// =============================================================================
|
|
146
|
+
// Shell Operations
|
|
147
|
+
// =============================================================================
|
|
148
|
+
|
|
149
|
+
const Shell = {
|
|
150
|
+
/**
|
|
151
|
+
* Executes a shell command using Bun.spawn and returns a structured result.
|
|
152
|
+
*/
|
|
153
|
+
async exec(cmd: string[], options: { cwd: string }): Promise<ShellResult> {
|
|
154
|
+
try {
|
|
155
|
+
// Use 'pipe' to capture output for the summary.
|
|
156
|
+
const proc = spawn(cmd, {
|
|
157
|
+
cwd: options.cwd,
|
|
158
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
159
|
+
});
|
|
160
|
+
activeProcs.add(proc);
|
|
161
|
+
|
|
162
|
+
const [stdout, stderr] = await Promise.all([
|
|
163
|
+
new Response(proc.stdout).text(),
|
|
164
|
+
new Response(proc.stderr).text(),
|
|
165
|
+
]);
|
|
166
|
+
|
|
167
|
+
const exitCode = await proc.exited;
|
|
168
|
+
activeProcs.delete(proc);
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
exitCode,
|
|
172
|
+
stdout: stdout.trim(),
|
|
173
|
+
stderr: stderr.trim(),
|
|
174
|
+
};
|
|
175
|
+
} catch (error: unknown) {
|
|
176
|
+
// Handle cases where the command itself fails to spawn (e.g., command not found)
|
|
177
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
178
|
+
return {
|
|
179
|
+
exitCode: 127,
|
|
180
|
+
stdout: '',
|
|
181
|
+
stderr: `Failed to execute command: ${cmd[0]}\nError: ${errorMessage}`,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Retrieves the list of currently staged files, filtering out deleted files.
|
|
188
|
+
*/
|
|
189
|
+
async getStagedFiles(rootDir: string): Promise<string[]> {
|
|
190
|
+
// ACMR = Added, Copied, Modified, Renamed. We exclude D (Deleted).
|
|
191
|
+
const { stdout, exitCode, stderr } = await Shell.exec(
|
|
192
|
+
['git', 'diff', '--name-only', '--cached', '--diff-filter=ACMR'],
|
|
193
|
+
{ cwd: rootDir },
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
if (exitCode !== 0) {
|
|
197
|
+
UI.log(
|
|
198
|
+
c.yellow(
|
|
199
|
+
'Warning: Could not retrieve staged files. Is this a Git repository? Proceeding with full scan.',
|
|
200
|
+
),
|
|
201
|
+
);
|
|
202
|
+
UI.log(c.dim(stderr));
|
|
203
|
+
return [];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return stdout.split('\n').filter(Boolean);
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
// =============================================================================
|
|
211
|
+
// Configuration
|
|
212
|
+
// =============================================================================
|
|
213
|
+
|
|
214
|
+
const ROOT_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
215
|
+
|
|
216
|
+
// Packages allowed to be outdated without failing the check.
|
|
217
|
+
// zod is pinned due to the MCP SDK's hard version requirement.
|
|
218
|
+
const OUTDATED_ALLOWLIST = new Set(['zod']);
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Direct dependencies from package.json, used to classify audit vulnerabilities
|
|
222
|
+
* as direct (fixable by us) vs transitive/upstream (requires upstream fix).
|
|
223
|
+
*/
|
|
224
|
+
const DIRECT_DEPS: ReadonlySet<string> = (() => {
|
|
225
|
+
try {
|
|
226
|
+
const pkg = JSON.parse(readFileSync(path.join(ROOT_DIR, 'package.json'), 'utf-8'));
|
|
227
|
+
return new Set<string>([
|
|
228
|
+
...Object.keys(pkg.dependencies ?? {}),
|
|
229
|
+
...Object.keys(pkg.devDependencies ?? {}),
|
|
230
|
+
]);
|
|
231
|
+
} catch {
|
|
232
|
+
return new Set<string>();
|
|
233
|
+
}
|
|
234
|
+
})();
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Parses `bun audit` output and classifies high/critical vulnerabilities as
|
|
238
|
+
* direct (in our package.json) or upstream (transitive dependency we can't fix).
|
|
239
|
+
*
|
|
240
|
+
* Bun audit format per vulnerability block:
|
|
241
|
+
* <package> <version-range> ← header (no indent, 2+ spaces before range)
|
|
242
|
+
* <parent> › <child> [› ...] ← dependency path (indented, › = transitive)
|
|
243
|
+
* <severity>: <description> ← advisory (indented)
|
|
244
|
+
*
|
|
245
|
+
* Returns null if parsing yields no results (caller should fall back to default behavior).
|
|
246
|
+
*/
|
|
247
|
+
function classifyAuditVulns(output: string): { direct: string[]; upstream: string[] } | null {
|
|
248
|
+
try {
|
|
249
|
+
const lines = output.split('\n');
|
|
250
|
+
const direct: string[] = [];
|
|
251
|
+
const upstream: string[] = [];
|
|
252
|
+
let i = 0;
|
|
253
|
+
|
|
254
|
+
while (i < lines.length) {
|
|
255
|
+
// Package header: non-indented, name followed by 2+ spaces then version constraint
|
|
256
|
+
const pkgMatch = lines[i]?.match(/^([@\w][\w./-]*)\s{2,}(.+)$/);
|
|
257
|
+
if (!pkgMatch) {
|
|
258
|
+
i++;
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const [, pkg, versionRange] = pkgMatch;
|
|
263
|
+
i++;
|
|
264
|
+
|
|
265
|
+
let hasHighCritical = false;
|
|
266
|
+
const paths: string[] = [];
|
|
267
|
+
|
|
268
|
+
// Collect indented lines belonging to this block
|
|
269
|
+
while (i < lines.length && (lines[i]?.startsWith(' ') ?? false)) {
|
|
270
|
+
const trimmed = (lines[i] ?? '').trim();
|
|
271
|
+
if (/^(critical|high):/i.test(trimmed)) {
|
|
272
|
+
hasHighCritical = true;
|
|
273
|
+
} else if (trimmed && !/^(moderate|low):/i.test(trimmed)) {
|
|
274
|
+
paths.push(trimmed);
|
|
275
|
+
}
|
|
276
|
+
i++;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (!hasHighCritical) continue;
|
|
280
|
+
|
|
281
|
+
// Direct if: the vulnerable package is in our package.json,
|
|
282
|
+
// or any dependency path lacks › (meaning it's not pulled in transitively)
|
|
283
|
+
const pkgName = pkg ?? '';
|
|
284
|
+
const isDirect = DIRECT_DEPS.has(pkgName) || paths.some((p) => !p.includes('\u203a'));
|
|
285
|
+
if (isDirect) {
|
|
286
|
+
direct.push(`${pkgName} ${versionRange}`);
|
|
287
|
+
} else {
|
|
288
|
+
const via = paths[0]?.split(/\s*\u203a\s*/)[0] ?? 'unknown';
|
|
289
|
+
upstream.push(`${pkgName} ${versionRange} (via ${via})`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// If we found nothing despite high/critical text existing, parsing may have failed
|
|
294
|
+
if (direct.length === 0 && upstream.length === 0) return null;
|
|
295
|
+
|
|
296
|
+
return { direct, upstream };
|
|
297
|
+
} catch {
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Define file extensions for linting and formatting
|
|
303
|
+
const LINT_EXTS = ['.ts', '.tsx', '.js', '.jsx'];
|
|
304
|
+
|
|
305
|
+
const ALL_CHECKS: Check[] = [
|
|
306
|
+
// Fast checks first (local operations, no network)
|
|
307
|
+
{
|
|
308
|
+
name: 'TODOs/FIXMEs',
|
|
309
|
+
flag: '--no-todos',
|
|
310
|
+
canFix: false,
|
|
311
|
+
getCommand: (ctx) => {
|
|
312
|
+
// git grep -n (line number) -E (extended regex) -i (case-insensitive)
|
|
313
|
+
const baseCmd = ['git', 'grep', '-nEi', '\\b(TODO|FIXME)\\b'];
|
|
314
|
+
// Exclude files where TODO/FIXME appears as prose or intentional stubs
|
|
315
|
+
const excludes = [
|
|
316
|
+
':!CHANGELOG.md',
|
|
317
|
+
':!changelog/',
|
|
318
|
+
':!*.lock',
|
|
319
|
+
':!scripts/devcheck.ts',
|
|
320
|
+
':!tests/',
|
|
321
|
+
];
|
|
322
|
+
if (ctx.isHuskyHook && ctx.stagedFiles.length > 0) {
|
|
323
|
+
// Check only staged files in the working tree
|
|
324
|
+
return [...baseCmd, '--', ...excludes, ...ctx.stagedFiles];
|
|
325
|
+
}
|
|
326
|
+
// Check the entire tracked repository (default behavior of git grep)
|
|
327
|
+
return [...baseCmd, '--', ...excludes];
|
|
328
|
+
},
|
|
329
|
+
// git grep: exit 0 = matches found, exit 1 = no matches, exit 2+ = error.
|
|
330
|
+
isSuccess: (result) => {
|
|
331
|
+
if (result.exitCode === 0) return false; // Found TODOs — fail
|
|
332
|
+
if (result.exitCode === 1) return true; // No matches — pass
|
|
333
|
+
// Exit code >= 2 means git grep itself errored. Treat as failure
|
|
334
|
+
// but override stdout so the summary shows the actual error, not "TODOs found".
|
|
335
|
+
return false;
|
|
336
|
+
},
|
|
337
|
+
tip: (c) => `Resolve ${c.bold('TODO')} or ${c.bold('FIXME')} comments before committing.`,
|
|
338
|
+
},
|
|
339
|
+
{
|
|
340
|
+
name: 'Tracked Secrets',
|
|
341
|
+
flag: '--no-secrets',
|
|
342
|
+
canFix: false,
|
|
343
|
+
// Check if common sensitive files are tracked by git.
|
|
344
|
+
getCommand: () => [
|
|
345
|
+
'git',
|
|
346
|
+
'ls-files',
|
|
347
|
+
'*.env*',
|
|
348
|
+
'**/.npmrc',
|
|
349
|
+
'**/.netrc',
|
|
350
|
+
'**/credentials.json',
|
|
351
|
+
'**/*.pem',
|
|
352
|
+
'**/*.key',
|
|
353
|
+
'**/secret*',
|
|
354
|
+
'**/.htpasswd',
|
|
355
|
+
],
|
|
356
|
+
// Success if output is empty OR only contains safe patterns.
|
|
357
|
+
isSuccess: (result, _mode) => {
|
|
358
|
+
if (result.exitCode !== 0) return false;
|
|
359
|
+
const SAFE_PATTERNS = ['.env.example', '.env.template', '.env.sample'];
|
|
360
|
+
const files = result.stdout.trim().split('\n').filter(Boolean);
|
|
361
|
+
const dangerous = files.filter((f) => !SAFE_PATTERNS.some((safe) => f.endsWith(safe)));
|
|
362
|
+
return dangerous.length === 0;
|
|
363
|
+
},
|
|
364
|
+
tip: (c) =>
|
|
365
|
+
`Add sensitive files to ${c.bold('.gitignore')} and run ${c.bold('git rm --cached <file>')}.`,
|
|
366
|
+
},
|
|
367
|
+
{
|
|
368
|
+
name: 'Biome',
|
|
369
|
+
flag: '--no-lint',
|
|
370
|
+
canFix: true,
|
|
371
|
+
getCommand: (ctx, mode) => {
|
|
372
|
+
const command = [path.join(ctx.rootDir, 'node_modules', '.bin', 'biome'), 'check'];
|
|
373
|
+
if (mode === 'fix') {
|
|
374
|
+
command.push('--write');
|
|
375
|
+
}
|
|
376
|
+
// In husky mode, target only staged files; otherwise let biome.json includes handle it
|
|
377
|
+
if (ctx.isHuskyHook && ctx.stagedFiles.length > 0) {
|
|
378
|
+
const relevant = ctx.stagedFiles.filter((file) =>
|
|
379
|
+
[...LINT_EXTS, '.json'].includes(path.extname(file)),
|
|
380
|
+
);
|
|
381
|
+
if (relevant.length === 0) return null;
|
|
382
|
+
command.push(...relevant);
|
|
383
|
+
}
|
|
384
|
+
return command;
|
|
385
|
+
},
|
|
386
|
+
tip: (c) => `Run without ${c.bold('--no-fix')} to automatically fix issues.`,
|
|
387
|
+
},
|
|
388
|
+
{
|
|
389
|
+
name: 'TypeScript',
|
|
390
|
+
flag: '--no-types',
|
|
391
|
+
canFix: false,
|
|
392
|
+
// TypeScript generally needs the whole project context for accurate checking.
|
|
393
|
+
getCommand: (ctx) => [path.join(ctx.rootDir, 'node_modules', '.bin', 'tsc'), '--noEmit'],
|
|
394
|
+
tip: () => 'Check TypeScript errors in your IDE or the console output.',
|
|
395
|
+
},
|
|
396
|
+
{
|
|
397
|
+
name: 'Tests',
|
|
398
|
+
flag: '--test',
|
|
399
|
+
canFix: false,
|
|
400
|
+
requiresFlag: true,
|
|
401
|
+
getCommand: (ctx) => [path.join(ctx.rootDir, 'node_modules', '.bin', 'vitest'), 'run'],
|
|
402
|
+
tip: () => 'Fix failing tests before committing.',
|
|
403
|
+
},
|
|
404
|
+
{
|
|
405
|
+
name: 'Unused Dependencies',
|
|
406
|
+
flag: '--no-depcheck',
|
|
407
|
+
canFix: false,
|
|
408
|
+
slowCheck: true,
|
|
409
|
+
getCommand: (ctx) => [
|
|
410
|
+
path.join(ctx.rootDir, 'node_modules', '.bin', 'depcheck'),
|
|
411
|
+
'--ignores=@types/*,pino-pretty,typescript,bun-types,@vitest/coverage-istanbul,repomix,bun,tsc-alias,@cyanheads/mcp-ts-core,@modelcontextprotocol/ext-apps',
|
|
412
|
+
'--ignore-patterns=examples',
|
|
413
|
+
],
|
|
414
|
+
tip: (c) =>
|
|
415
|
+
`Remove unused packages with ${c.bold('bun remove <pkg>')} or add to depcheck ignores.`,
|
|
416
|
+
},
|
|
417
|
+
// Slow checks last (network-bound operations)
|
|
418
|
+
{
|
|
419
|
+
name: 'Security Audit',
|
|
420
|
+
flag: '--no-audit',
|
|
421
|
+
canFix: false, // 'bun audit --fix' exists but often requires manual review.
|
|
422
|
+
slowCheck: true,
|
|
423
|
+
getCommand: () => ['bun', 'audit'],
|
|
424
|
+
isSuccess: (result, _mode) => {
|
|
425
|
+
// If the command exits 0, no vulnerabilities were found.
|
|
426
|
+
if (result.exitCode === 0) return true;
|
|
427
|
+
|
|
428
|
+
const output = result.stdout;
|
|
429
|
+
if (output.includes('0 vulnerabilities found')) return true;
|
|
430
|
+
|
|
431
|
+
// Pass if only low/moderate severity
|
|
432
|
+
const hasHighOrCritical = /high|critical/i.test(output);
|
|
433
|
+
if (!hasHighOrCritical) return true;
|
|
434
|
+
|
|
435
|
+
// Classify: direct deps we can fix vs transitive deps we can't
|
|
436
|
+
const classified = classifyAuditVulns(output);
|
|
437
|
+
|
|
438
|
+
// If parsing failed, fall back to failing (conservative)
|
|
439
|
+
if (!classified) return false;
|
|
440
|
+
|
|
441
|
+
// Direct dep vulnerabilities — we can and should fix these
|
|
442
|
+
if (classified.direct.length > 0) return false;
|
|
443
|
+
|
|
444
|
+
// All high/critical are upstream/transitive — warn but don't fail
|
|
445
|
+
if (classified.upstream.length > 0) {
|
|
446
|
+
const n = classified.upstream.length;
|
|
447
|
+
return {
|
|
448
|
+
success: true,
|
|
449
|
+
warning: [
|
|
450
|
+
`${n} high/critical vulnerabilit${n === 1 ? 'y' : 'ies'} in transitive deps (upstream, no direct fix available):`,
|
|
451
|
+
...classified.upstream.map((v) => ` - ${v}`),
|
|
452
|
+
].join('\n'),
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
return true;
|
|
457
|
+
},
|
|
458
|
+
tip: (c) =>
|
|
459
|
+
`Direct dependency vulnerabilities found. Run ${c.bold('bun update')} or ${c.bold('bun audit --fix')} to resolve.`,
|
|
460
|
+
},
|
|
461
|
+
{
|
|
462
|
+
name: 'Dependencies (Outdated)',
|
|
463
|
+
flag: '--no-deps',
|
|
464
|
+
canFix: false,
|
|
465
|
+
slowCheck: true,
|
|
466
|
+
getCommand: () => ['bun', 'outdated'],
|
|
467
|
+
isSuccess: (result) => {
|
|
468
|
+
// Exit 0 with empty output = everything up to date
|
|
469
|
+
if (result.exitCode === 0 && result.stdout.trim() === '') return true;
|
|
470
|
+
|
|
471
|
+
// Non-zero exit with no tabular output likely means a network/lockfile error — fail hard
|
|
472
|
+
const output = result.stdout.trim();
|
|
473
|
+
if (result.exitCode !== 0 && !output.includes('|')) return false;
|
|
474
|
+
|
|
475
|
+
// Parse the tabular output. Package lines contain '|' separators.
|
|
476
|
+
// Filter out header/separator rows and allowlisted packages.
|
|
477
|
+
const lines = output.split('\n');
|
|
478
|
+
const packageLines = lines.filter((line) => {
|
|
479
|
+
if (!line.includes('|')) return false;
|
|
480
|
+
// Skip table chrome: header row and separator (e.g., "---")
|
|
481
|
+
const firstCell = line.split('|')[0]?.trim() ?? '';
|
|
482
|
+
if (!firstCell || firstCell === 'Package' || /^-+$/.test(firstCell)) return false;
|
|
483
|
+
return true;
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
// Check if every outdated package is in the allowlist
|
|
487
|
+
const unexpected = packageLines.filter((line) => {
|
|
488
|
+
const pkgName = line.split('|')[0]?.trim() ?? '';
|
|
489
|
+
return !OUTDATED_ALLOWLIST.has(pkgName);
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
return unexpected.length === 0;
|
|
493
|
+
},
|
|
494
|
+
tip: (c) =>
|
|
495
|
+
`Run ${c.bold('bun update')} to upgrade dependencies. Allowlisted packages: ${[...OUTDATED_ALLOWLIST].join(', ')}.`,
|
|
496
|
+
},
|
|
497
|
+
];
|
|
498
|
+
|
|
499
|
+
// =============================================================================
|
|
500
|
+
// UI & Logging
|
|
501
|
+
// =============================================================================
|
|
502
|
+
|
|
503
|
+
const UI = {
|
|
504
|
+
log: console.log,
|
|
505
|
+
|
|
506
|
+
// ---------------------------------------------------------------------------
|
|
507
|
+
// Format helpers — return strings for buffered output during parallel execution
|
|
508
|
+
// ---------------------------------------------------------------------------
|
|
509
|
+
|
|
510
|
+
formatCheckStart(check: Check, command: string[], mode: UIMode): string {
|
|
511
|
+
let commandStr = command.join(' ');
|
|
512
|
+
if (commandStr.length > 150) {
|
|
513
|
+
commandStr = `${commandStr.substring(0, 147)}... (truncated)`;
|
|
514
|
+
}
|
|
515
|
+
return [
|
|
516
|
+
`${c.bold(c.blue('🔷'))} ${mode} ${c.yellow(check.name)}${c.blue('...')} `,
|
|
517
|
+
c.dim(` $ ${commandStr}`),
|
|
518
|
+
].join('\n');
|
|
519
|
+
},
|
|
520
|
+
|
|
521
|
+
formatSkipped(check: Check, reason: string): string {
|
|
522
|
+
return `${c.bold(c.yellow(`🔶 Skipping ${check.name}...`))}${c.dim(` (${reason})`)}`;
|
|
523
|
+
},
|
|
524
|
+
|
|
525
|
+
formatCheckResult(result: CommandResult, _mode: UIMode): string {
|
|
526
|
+
const { checkName, exitCode, duration } = result;
|
|
527
|
+
if (exitCode === 0) {
|
|
528
|
+
return `${c.bold(c.green('✅'))} ${c.yellow(checkName)} ${c.green(`finished successfully in ${duration}ms.`)}`;
|
|
529
|
+
}
|
|
530
|
+
return `${c.bold(c.red('❌'))} ${c.yellow(checkName)} ${c.red(`failed (Code ${exitCode}) in ${duration}ms.`)}`;
|
|
531
|
+
},
|
|
532
|
+
|
|
533
|
+
// ---------------------------------------------------------------------------
|
|
534
|
+
// Print helpers — write directly to stdout (used outside parallel sections)
|
|
535
|
+
// ---------------------------------------------------------------------------
|
|
536
|
+
|
|
537
|
+
/** Flush buffered log lines from a completed check result. */
|
|
538
|
+
flushCheckLog(result: CommandResult) {
|
|
539
|
+
for (const line of result.logLines) {
|
|
540
|
+
UI.log(line);
|
|
541
|
+
}
|
|
542
|
+
},
|
|
543
|
+
|
|
544
|
+
printHeader(ctx: AppContext) {
|
|
545
|
+
let modeMessage: string;
|
|
546
|
+
if (ctx.isHuskyHook) {
|
|
547
|
+
const fileCount = ctx.stagedFiles.length;
|
|
548
|
+
const mode = ctx.noFix ? 'Read-only' : 'Auto-fixing';
|
|
549
|
+
modeMessage = c.magenta(
|
|
550
|
+
`(Husky Hook: ${mode} - ${fileCount} file${fileCount === 1 ? '' : 's'} staged)`,
|
|
551
|
+
);
|
|
552
|
+
} else {
|
|
553
|
+
const fixMode = ctx.noFix ? 'Read-only' : 'Auto-fixing';
|
|
554
|
+
const speedMode = ctx.fastMode ? ' - Fast mode' : '';
|
|
555
|
+
modeMessage = ctx.noFix
|
|
556
|
+
? c.dim(`(${fixMode} mode${speedMode})`)
|
|
557
|
+
: c.magenta(`(${fixMode} mode${speedMode})`);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
UI.log(`${c.bold('🚀 DevCheck: Kicking off comprehensive checks...')} ${modeMessage}\n`);
|
|
561
|
+
},
|
|
562
|
+
|
|
563
|
+
printSummary(results: CommandResult[], ctx: AppContext): boolean {
|
|
564
|
+
UI.log(`\n${c.bold('📊 Checkup Summary:')}`);
|
|
565
|
+
UI.log('------------------------------------------------');
|
|
566
|
+
|
|
567
|
+
let overallSuccess = true;
|
|
568
|
+
const failedChecks: Check[] = [];
|
|
569
|
+
|
|
570
|
+
for (const result of results) {
|
|
571
|
+
let status: string;
|
|
572
|
+
if (result.skipped) {
|
|
573
|
+
status = `${c.yellow('⚪ SKIPPED')}`;
|
|
574
|
+
} else if (result.exitCode === 0 && result.warning) {
|
|
575
|
+
status = `${c.yellow('⚠️ WARNING')}`;
|
|
576
|
+
} else if (result.exitCode === 0) {
|
|
577
|
+
status = `${c.green('✅ PASSED')}`;
|
|
578
|
+
} else {
|
|
579
|
+
status = `${c.red('❌ FAILED')}`;
|
|
580
|
+
overallSuccess = false;
|
|
581
|
+
const foundCheck = ALL_CHECKS.find((check) => check.name === result.checkName);
|
|
582
|
+
if (foundCheck) failedChecks.push(foundCheck);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const durationStr = result.skipped ? '' : c.dim(`(${result.duration}ms)`);
|
|
586
|
+
UI.log(`${c.bold(result.checkName.padEnd(25))} ${status} ${durationStr}`);
|
|
587
|
+
|
|
588
|
+
// Display warning details for passing checks with warnings
|
|
589
|
+
if (result.exitCode === 0 && result.warning) {
|
|
590
|
+
UI.log(c.yellow(result.warning.replace(/^/gm, ' | ')));
|
|
591
|
+
UI.log('');
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// Display output only for failed checks
|
|
595
|
+
if (result.exitCode !== 0 && !result.skipped) {
|
|
596
|
+
if (result.stdout) UI.log(c.dim(result.stdout.replace(/^/gm, ' | ')));
|
|
597
|
+
if (result.stderr) UI.log(c.red(result.stderr.replace(/^/gm, ' | ')));
|
|
598
|
+
UI.log('');
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// Highlight the slowest check to help identify bottlenecks
|
|
603
|
+
const ranChecks = results.filter((r) => !r.skipped);
|
|
604
|
+
if (ranChecks.length > 1) {
|
|
605
|
+
const slowest = ranChecks.reduce((a, b) => (a.duration > b.duration ? a : b));
|
|
606
|
+
UI.log(c.dim(`\n Slowest: ${slowest.checkName} (${slowest.duration}ms)`));
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
UI.log('\n------------------------------------------------');
|
|
610
|
+
|
|
611
|
+
if (!overallSuccess) {
|
|
612
|
+
if (ctx.noFix || failedChecks.some((check) => !check.canFix)) {
|
|
613
|
+
UI.log(`\n${c.bold(c.cyan('💡 Tips & Actions:'))}`);
|
|
614
|
+
for (const check of failedChecks) {
|
|
615
|
+
if (check.tip) {
|
|
616
|
+
UI.log(` - ${c.bold(check.name)}: ${c.dim(check.tip(c))}`);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
if (!ctx.noFix) {
|
|
621
|
+
UI.log(
|
|
622
|
+
`\n${c.yellow('⚠️ Note: Some issues may have been fixed automatically, but others require manual intervention.')}`,
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
return overallSuccess;
|
|
628
|
+
},
|
|
629
|
+
|
|
630
|
+
printFooter(success: boolean, totalDuration: number) {
|
|
631
|
+
const timeStr = c.dim(`(total: ${totalDuration}ms)`);
|
|
632
|
+
if (success) {
|
|
633
|
+
UI.log(`\n${c.bold(c.green('🎉 All checks passed! Ship it!'))} ${timeStr}`);
|
|
634
|
+
} else {
|
|
635
|
+
UI.log(`\n${c.bold(c.red('🛑 Found issues. Please review the output above.'))} ${timeStr}`);
|
|
636
|
+
}
|
|
637
|
+
},
|
|
638
|
+
|
|
639
|
+
printError(error: unknown) {
|
|
640
|
+
console.error(`${c.red('\nAn unexpected error occurred in the check script:')}`, error);
|
|
641
|
+
},
|
|
642
|
+
};
|
|
643
|
+
|
|
644
|
+
// =============================================================================
|
|
645
|
+
// Core Logic
|
|
646
|
+
// =============================================================================
|
|
647
|
+
|
|
648
|
+
/** Global flags handled separately from per-check skip flags. */
|
|
649
|
+
const GLOBAL_FLAGS = new Set(['--no-fix', '--husky-hook', '--fast', '--help', '--only']);
|
|
650
|
+
|
|
651
|
+
/** All recognized flags (global + per-check skip flags). */
|
|
652
|
+
const KNOWN_FLAGS = new Set([...GLOBAL_FLAGS, ...ALL_CHECKS.map((check) => check.flag)]);
|
|
653
|
+
|
|
654
|
+
function printHelp() {
|
|
655
|
+
UI.log(`${c.bold('Usage:')} bun run devcheck [options]\n`);
|
|
656
|
+
UI.log(`${c.bold('Options:')}`);
|
|
657
|
+
UI.log(` ${c.yellow('--no-fix')} Run in read-only mode (no auto-fixing)`);
|
|
658
|
+
UI.log(` ${c.yellow('--fast')} Skip slow network-bound checks (audit, outdated)`);
|
|
659
|
+
UI.log(
|
|
660
|
+
` ${c.yellow('--husky-hook')} Run in pre-commit hook mode (analyze staged files only)`,
|
|
661
|
+
);
|
|
662
|
+
UI.log(
|
|
663
|
+
` ${c.yellow('--only <name>')} Run only the named check (case-insensitive partial match)`,
|
|
664
|
+
);
|
|
665
|
+
UI.log(` ${c.yellow('--help')} Show this help message\n`);
|
|
666
|
+
const optOutChecks = ALL_CHECKS.filter((ch) => !ch.requiresFlag);
|
|
667
|
+
const optInChecks = ALL_CHECKS.filter((ch) => ch.requiresFlag);
|
|
668
|
+
|
|
669
|
+
UI.log(`${c.bold('Skip individual checks:')}`);
|
|
670
|
+
for (const check of optOutChecks) {
|
|
671
|
+
const slow = check.slowCheck ? c.dim(' (slow)') : '';
|
|
672
|
+
UI.log(` ${c.yellow(check.flag.padEnd(18))} Skip ${check.name}${slow}`);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
if (optInChecks.length > 0) {
|
|
676
|
+
UI.log(`\n${c.bold('Enable optional checks (off by default):')}`);
|
|
677
|
+
for (const check of optInChecks) {
|
|
678
|
+
UI.log(` ${c.yellow(check.flag.padEnd(18))} Run ${check.name}`);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
UI.log('');
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* Parses CLI arguments and determines the initial run context.
|
|
686
|
+
* Returns null if the program should exit (e.g., --help).
|
|
687
|
+
*/
|
|
688
|
+
function parseArgs(args: string[]): Omit<AppContext, 'rootDir' | 'stagedFiles'> | null {
|
|
689
|
+
const flags = new Set<string>();
|
|
690
|
+
let noFix = false;
|
|
691
|
+
let isHuskyHook = false;
|
|
692
|
+
let fastMode = false;
|
|
693
|
+
let onlyCheck: string | null = null;
|
|
694
|
+
|
|
695
|
+
for (let i = 0; i < args.length; i++) {
|
|
696
|
+
const arg = args[i] as string;
|
|
697
|
+
if (arg === '--help') {
|
|
698
|
+
printHelp();
|
|
699
|
+
return null;
|
|
700
|
+
} else if (arg === '--no-fix') {
|
|
701
|
+
noFix = true;
|
|
702
|
+
} else if (arg === '--husky-hook') {
|
|
703
|
+
isHuskyHook = true;
|
|
704
|
+
} else if (arg === '--fast') {
|
|
705
|
+
fastMode = true;
|
|
706
|
+
} else if (arg === '--only') {
|
|
707
|
+
const next = args[i + 1];
|
|
708
|
+
if (!next || next.startsWith('--')) {
|
|
709
|
+
UI.log(c.red('Error: --only requires a check name argument.'));
|
|
710
|
+
UI.log(c.dim(` Example: ${c.bold('bun run devcheck --only lint')}\n`));
|
|
711
|
+
UI.log(c.dim('Available checks:'));
|
|
712
|
+
for (const check of ALL_CHECKS) {
|
|
713
|
+
UI.log(c.dim(` - ${check.name}`));
|
|
714
|
+
}
|
|
715
|
+
return null;
|
|
716
|
+
}
|
|
717
|
+
onlyCheck = next;
|
|
718
|
+
i++; // consume the next arg
|
|
719
|
+
} else if (arg.startsWith('--')) {
|
|
720
|
+
if (!KNOWN_FLAGS.has(arg)) {
|
|
721
|
+
UI.log(c.yellow(`Warning: Unknown flag '${arg}' — ignoring.`));
|
|
722
|
+
UI.log(c.dim(` Run with ${c.bold('--help')} to see available options.\n`));
|
|
723
|
+
} else {
|
|
724
|
+
flags.add(arg);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// Also detect if running inside environment set by Husky
|
|
730
|
+
if (process.env.HUSKY === '1' || process.env.GIT_PARAMS) {
|
|
731
|
+
isHuskyHook = true;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
return { flags, noFix, isHuskyHook, fastMode, onlyCheck };
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
async function runCheck(check: Check, ctx: AppContext): Promise<CommandResult> {
|
|
738
|
+
const { name, getCommand, isSuccess } = check;
|
|
739
|
+
const log: string[] = [];
|
|
740
|
+
const baseResult: CommandResult = {
|
|
741
|
+
checkName: name,
|
|
742
|
+
exitCode: 0,
|
|
743
|
+
stdout: '',
|
|
744
|
+
stderr: '',
|
|
745
|
+
duration: 0,
|
|
746
|
+
skipped: false,
|
|
747
|
+
logLines: log,
|
|
748
|
+
};
|
|
749
|
+
|
|
750
|
+
// 1. Check for --only filter
|
|
751
|
+
if (ctx.onlyCheck) {
|
|
752
|
+
const match = check.name.toLowerCase().includes(ctx.onlyCheck.toLowerCase());
|
|
753
|
+
if (!match) {
|
|
754
|
+
log.push(UI.formatSkipped(check, `--only ${ctx.onlyCheck}`));
|
|
755
|
+
return { ...baseResult, skipped: true };
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// 2. Handle opt-in vs opt-out flags
|
|
760
|
+
if (check.requiresFlag) {
|
|
761
|
+
// Opt-in: only runs when flag is explicitly provided
|
|
762
|
+
if (!ctx.flags.has(check.flag)) {
|
|
763
|
+
log.push(UI.formatSkipped(check, `Pass ${check.flag} to enable`));
|
|
764
|
+
return { ...baseResult, skipped: true };
|
|
765
|
+
}
|
|
766
|
+
} else {
|
|
767
|
+
// Opt-out: runs by default, skip when flag is provided
|
|
768
|
+
if (ctx.flags.has(check.flag)) {
|
|
769
|
+
log.push(UI.formatSkipped(check, `Flag ${check.flag} provided`));
|
|
770
|
+
return { ...baseResult, skipped: true };
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// 3. Skip slow checks in fast mode
|
|
775
|
+
if (ctx.fastMode && check.slowCheck) {
|
|
776
|
+
log.push(UI.formatSkipped(check, 'Skipped in fast mode'));
|
|
777
|
+
return { ...baseResult, skipped: true };
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// 4. Determine command and mode
|
|
781
|
+
const useFixCommand = !ctx.noFix && check.canFix;
|
|
782
|
+
const runMode: RunMode = useFixCommand ? 'fix' : 'check';
|
|
783
|
+
const uiMode: UIMode = useFixCommand ? 'Fixing' : 'Checking';
|
|
784
|
+
|
|
785
|
+
const command = getCommand(ctx, runMode);
|
|
786
|
+
|
|
787
|
+
// 5. Check if command generation resulted in no action (e.g., no relevant staged files)
|
|
788
|
+
if (!command || command.length === 0) {
|
|
789
|
+
log.push(UI.formatSkipped(check, 'No relevant files to check'));
|
|
790
|
+
return { ...baseResult, skipped: true };
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
log.push(UI.formatCheckStart(check, command, uiMode));
|
|
794
|
+
|
|
795
|
+
// 6. Execute the command
|
|
796
|
+
const startTime = performance.now();
|
|
797
|
+
const result = await Shell.exec(command, { cwd: ctx.rootDir });
|
|
798
|
+
const duration = Math.round(performance.now() - startTime);
|
|
799
|
+
|
|
800
|
+
const finalResult: CommandResult = {
|
|
801
|
+
...baseResult,
|
|
802
|
+
...result,
|
|
803
|
+
duration,
|
|
804
|
+
logLines: log,
|
|
805
|
+
};
|
|
806
|
+
|
|
807
|
+
// 7. Determine success (using custom logic if provided)
|
|
808
|
+
if (isSuccess) {
|
|
809
|
+
const raw = isSuccess(result, runMode);
|
|
810
|
+
const { success, warning } =
|
|
811
|
+
typeof raw === 'boolean' ? { success: raw, warning: undefined } : raw;
|
|
812
|
+
|
|
813
|
+
if (!success && finalResult.exitCode === 0) {
|
|
814
|
+
finalResult.exitCode = 1;
|
|
815
|
+
}
|
|
816
|
+
if (success && finalResult.exitCode !== 0) {
|
|
817
|
+
// Preserve stderr in stdout for the summary when the tool errored but isSuccess normalized it
|
|
818
|
+
if (finalResult.stderr && !finalResult.stdout) {
|
|
819
|
+
finalResult.stdout = finalResult.stderr;
|
|
820
|
+
}
|
|
821
|
+
finalResult.exitCode = 0;
|
|
822
|
+
}
|
|
823
|
+
if (warning) {
|
|
824
|
+
finalResult.warning = warning;
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
log.push(UI.formatCheckResult(finalResult, uiMode));
|
|
829
|
+
|
|
830
|
+
return finalResult;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* Handles the specific logic required for git pre-commit hooks, primarily re-staging
|
|
835
|
+
* files that were modified by auto-fixers (like Biome).
|
|
836
|
+
* Returns false if re-staging failed (should fail the commit).
|
|
837
|
+
*/
|
|
838
|
+
async function handleHuskyReStaging(ctx: AppContext): Promise<boolean> {
|
|
839
|
+
// We only need to re-stage if auto-fixing was enabled.
|
|
840
|
+
if (ctx.noFix) return true;
|
|
841
|
+
|
|
842
|
+
// If no files were staged initially, there's nothing to re-stage.
|
|
843
|
+
if (ctx.stagedFiles.length === 0) return true;
|
|
844
|
+
|
|
845
|
+
UI.log(`\n${c.bold(c.cyan('✨ Husky: Checking for modifications by fixers...'))}`);
|
|
846
|
+
|
|
847
|
+
try {
|
|
848
|
+
const { stdout: gitStatus } = await Shell.exec(['git', 'status', '--porcelain'], {
|
|
849
|
+
cwd: ctx.rootDir,
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
// Identify files modified by fixers after staging.
|
|
853
|
+
// Porcelain format: XY path — X=index status, Y=working tree status.
|
|
854
|
+
// We want files where X is staged (not ' ' or '?') and Y='M' (modified since staging).
|
|
855
|
+
const stagedSet = new Set(ctx.stagedFiles);
|
|
856
|
+
const modifiedStagedFiles = gitStatus
|
|
857
|
+
.split('\n')
|
|
858
|
+
.filter((line) => line.length > 3 && line[1] === 'M' && line[0] !== ' ' && line[0] !== '?')
|
|
859
|
+
.map((line) => line.substring(3).trim())
|
|
860
|
+
// Only re-stage files that were originally staged — avoid pulling in unrelated changes
|
|
861
|
+
.filter((file) => stagedSet.has(file));
|
|
862
|
+
|
|
863
|
+
if (modifiedStagedFiles.length > 0) {
|
|
864
|
+
UI.log(c.yellow(` Re-staging ${modifiedStagedFiles.length} files modified by fixers...`));
|
|
865
|
+
|
|
866
|
+
const cmd = ['git', 'add', ...modifiedStagedFiles];
|
|
867
|
+
const addResult = await Shell.exec(cmd, { cwd: ctx.rootDir });
|
|
868
|
+
|
|
869
|
+
let cmdStr = cmd.join(' ');
|
|
870
|
+
if (cmdStr.length > 100) {
|
|
871
|
+
cmdStr = `${cmdStr.substring(0, 97)}...`;
|
|
872
|
+
}
|
|
873
|
+
UI.log(c.dim(` $ ${cmdStr}`));
|
|
874
|
+
|
|
875
|
+
if (addResult.exitCode !== 0) {
|
|
876
|
+
UI.log(c.red(` ✗ Failed to re-stage files (exit ${addResult.exitCode}).`));
|
|
877
|
+
if (addResult.stderr) UI.log(c.red(` ${addResult.stderr}`));
|
|
878
|
+
return false;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
UI.log(c.green(' ✓ Successfully re-staged files.'));
|
|
882
|
+
} else {
|
|
883
|
+
UI.log(c.green(' ✓ No staged files were modified by fixers.'));
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
return true;
|
|
887
|
+
} catch (error: unknown) {
|
|
888
|
+
UI.log(c.red('🛑 Error during Husky hook file management. Fixes might not be staged.'));
|
|
889
|
+
UI.printError(error);
|
|
890
|
+
return false;
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
async function main() {
|
|
895
|
+
const args = parseArgs(process.argv.slice(2));
|
|
896
|
+
if (!args) process.exit(0);
|
|
897
|
+
|
|
898
|
+
// Initialize context
|
|
899
|
+
const appContext: AppContext = {
|
|
900
|
+
...args,
|
|
901
|
+
rootDir: ROOT_DIR,
|
|
902
|
+
stagedFiles: [],
|
|
903
|
+
};
|
|
904
|
+
|
|
905
|
+
// If in husky mode, populate staged files early for optimized command generation.
|
|
906
|
+
if (appContext.isHuskyHook) {
|
|
907
|
+
appContext.stagedFiles = await Shell.getStagedFiles(ROOT_DIR);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// If it's a husky hook and nothing is staged, we can exit early.
|
|
911
|
+
if (appContext.isHuskyHook && appContext.stagedFiles.length === 0) {
|
|
912
|
+
UI.log(c.green('\nNo files staged. Skipping pre-commit checks.'));
|
|
913
|
+
process.exit(0);
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
UI.printHeader(appContext);
|
|
917
|
+
|
|
918
|
+
// Run checks concurrently, buffering output per check
|
|
919
|
+
const totalStart = performance.now();
|
|
920
|
+
const checkPromises = ALL_CHECKS.map((check) => runCheck(check, appContext));
|
|
921
|
+
const settledResults = await Promise.allSettled(checkPromises);
|
|
922
|
+
const totalDuration = Math.round(performance.now() - totalStart);
|
|
923
|
+
|
|
924
|
+
// Collect results, then flush buffered output in definition order (no interleaving)
|
|
925
|
+
const results: CommandResult[] = settledResults.map((res, index) => {
|
|
926
|
+
if (res.status === 'fulfilled') {
|
|
927
|
+
return res.value;
|
|
928
|
+
}
|
|
929
|
+
const checkName = ALL_CHECKS[index]?.name || 'Unknown';
|
|
930
|
+
return {
|
|
931
|
+
checkName,
|
|
932
|
+
exitCode: 1,
|
|
933
|
+
stdout: '',
|
|
934
|
+
stderr: `Check runner failed: ${String(res.reason)}`,
|
|
935
|
+
duration: 0,
|
|
936
|
+
skipped: false,
|
|
937
|
+
logLines: [`${c.bold(c.red('❌'))} ${c.yellow(checkName)} ${c.red('runner crashed')}`],
|
|
938
|
+
};
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
for (const result of results) {
|
|
942
|
+
UI.flushCheckLog(result);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// If running in Husky hook, manage file staging.
|
|
946
|
+
// We do this BEFORE summarizing success, so that even if checks failed, partial fixes are staged.
|
|
947
|
+
let reStagingOk = true;
|
|
948
|
+
if (appContext.isHuskyHook) {
|
|
949
|
+
reStagingOk = await handleHuskyReStaging(appContext);
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
const overallSuccess = UI.printSummary(results, appContext) && reStagingOk;
|
|
953
|
+
|
|
954
|
+
UI.printFooter(overallSuccess, totalDuration);
|
|
955
|
+
process.exit(overallSuccess ? 0 : 1);
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// Entry point
|
|
959
|
+
main().catch((error) => {
|
|
960
|
+
UI.printError(error);
|
|
961
|
+
process.exit(1);
|
|
962
|
+
});
|