@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.
@@ -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
+ });