Initial commit
This commit is contained in:
324
hooks/type-checker.ts
Executable file
324
hooks/type-checker.ts
Executable file
@@ -0,0 +1,324 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
/**
|
||||
* Type Checker Hook
|
||||
*
|
||||
* Pre-validates TypeScript types before Edit/Write operations.
|
||||
* Uses @jbabin91/tsc-files programmatic API for incremental type checking with tsgo support.
|
||||
* Informs about type errors but allows file modifications (informative only).
|
||||
*
|
||||
* Smart behavior:
|
||||
* - Skips non-TypeScript files (no performance impact)
|
||||
* - Skips projects without tsconfig.json
|
||||
* - Dynamically imports type checker only when needed
|
||||
*
|
||||
* Performance: ~100-200ms with tsgo (10x faster than tsc)
|
||||
* Performance target: <2s (ADR-0010) - typically well under this
|
||||
* Hook behavior: Informative (does not block execution)
|
||||
*
|
||||
* @see {@link https://github.com/jbabin91/super-claude} for documentation
|
||||
*/
|
||||
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { CheckResult } from '@jbabin91/tsc-files';
|
||||
|
||||
import {
|
||||
checkHookEnabled,
|
||||
checkPerformance,
|
||||
ensureBunInstalled,
|
||||
formatError,
|
||||
parseStdin,
|
||||
} from './utils/index.js';
|
||||
|
||||
/**
|
||||
* Check if file is TypeScript
|
||||
*
|
||||
* @param filePath File path to check
|
||||
* @returns true if TypeScript file
|
||||
*/
|
||||
function isTypeScriptFile(filePath: string): boolean {
|
||||
return /\.(ts|tsx)$/.test(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if tsconfig.json exists
|
||||
*
|
||||
* @param cwd Current working directory
|
||||
* @returns true if tsconfig.json found
|
||||
*/
|
||||
function hasTsConfig(cwd: string): boolean {
|
||||
return (
|
||||
existsSync(path.join(cwd, 'tsconfig.json')) ||
|
||||
existsSync(path.join(cwd, 'tsconfig.base.json'))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run TypeScript type checking on file using programmatic API
|
||||
*
|
||||
* @param cwd Current working directory
|
||||
* @param filePath File path to check
|
||||
* @returns CheckResult with structured error data
|
||||
*/
|
||||
async function checkTypes(cwd: string, filePath: string): Promise<CheckResult> {
|
||||
try {
|
||||
// Dynamically import checkFiles only when needed
|
||||
// This prevents loading TypeScript dependencies for non-TS files
|
||||
const module = await import('@jbabin91/tsc-files');
|
||||
|
||||
// Defensive check: ensure checkFiles exists after import
|
||||
if (!module.checkFiles || typeof module.checkFiles !== 'function') {
|
||||
throw new Error(
|
||||
'Type checker module loaded but checkFiles function not found. ' +
|
||||
'This may indicate a version mismatch. ' +
|
||||
'Try: bun install @jbabin91/tsc-files@latest',
|
||||
);
|
||||
}
|
||||
|
||||
const { checkFiles } = module;
|
||||
|
||||
// Use programmatic API for structured error data
|
||||
// Automatically uses tsgo if available (10x faster)
|
||||
const result = await checkFiles([filePath], {
|
||||
cwd,
|
||||
skipLibCheck: true,
|
||||
verbose: false,
|
||||
throwOnError: false,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error: unknown) {
|
||||
// Handle different error scenarios with helpful messages
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
|
||||
// Check if this is a module not found error
|
||||
const isModuleNotFound =
|
||||
errorMessage.includes('Cannot find package') ||
|
||||
errorMessage.includes('Cannot find module') ||
|
||||
errorMessage.includes('@jbabin91/tsc-files');
|
||||
|
||||
let helpfulMessage = errorMessage;
|
||||
if (isModuleNotFound) {
|
||||
helpfulMessage =
|
||||
'Type checker dependency not installed.\n\n' +
|
||||
'To enable type checking, install the required package:\n' +
|
||||
' bun install @jbabin91/tsc-files\n\n' +
|
||||
'Or disable this hook in .claude/super-claude-config.json:\n' +
|
||||
' "workflow": { "hooks": { "typeChecker": { "enabled": false } } }\n\n' +
|
||||
`Original error: ${errorMessage}`;
|
||||
}
|
||||
|
||||
// Return a failed result with helpful error message
|
||||
return {
|
||||
success: false,
|
||||
errorCount: 1,
|
||||
warningCount: 0,
|
||||
errors: [
|
||||
{
|
||||
file: filePath,
|
||||
line: 0,
|
||||
column: 0,
|
||||
message: helpfulMessage,
|
||||
code: isModuleNotFound ? 'HOOK_ERROR' : 'TS0000',
|
||||
severity: 'error',
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
checkedFiles: [filePath],
|
||||
duration: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format type errors for display with categorization
|
||||
*
|
||||
* @param result CheckResult from tsc-files API
|
||||
* @param targetFile The file being edited
|
||||
* @returns Formatted error message
|
||||
*/
|
||||
function formatTypeErrors(result: CheckResult, targetFile: string): string {
|
||||
// Check if this is a hook error (not a type error)
|
||||
const hasHookError = result.errors.some((e) => e.code === 'HOOK_ERROR');
|
||||
|
||||
if (hasHookError) {
|
||||
// Format hook configuration errors differently
|
||||
const hookError = result.errors.find((e) => e.code === 'HOOK_ERROR');
|
||||
return [
|
||||
'',
|
||||
'═'.repeat(70),
|
||||
'⚠️ TYPE CHECKER HOOK ERROR',
|
||||
'═'.repeat(70),
|
||||
'',
|
||||
hookError?.message ?? 'Unknown hook error',
|
||||
'',
|
||||
'═'.repeat(70),
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// Categorize errors by file
|
||||
const targetFileErrors = result.errors.filter((e) => e.file === targetFile);
|
||||
const dependencyErrors = result.errors.filter((e) => e.file !== targetFile);
|
||||
|
||||
// Header
|
||||
const sections: string[] = [
|
||||
'',
|
||||
'═'.repeat(70),
|
||||
'⚠️ TYPE ERRORS DETECTED - ACTION REQUIRED',
|
||||
'═'.repeat(70),
|
||||
'',
|
||||
];
|
||||
|
||||
// Target file errors (critical)
|
||||
if (targetFileErrors.length > 0) {
|
||||
sections.push(
|
||||
'🎯 ERRORS IN THIS FILE:',
|
||||
` File: ${targetFile}`,
|
||||
' Action: Fix these before proceeding to next task',
|
||||
'',
|
||||
);
|
||||
|
||||
for (const err of targetFileErrors.slice(0, 10)) {
|
||||
sections.push(
|
||||
` ${err.file}:${err.line}:${err.column}`,
|
||||
` ${err.code}: ${err.message}`,
|
||||
'',
|
||||
);
|
||||
}
|
||||
|
||||
if (targetFileErrors.length > 10) {
|
||||
sections.push(
|
||||
` ... and ${targetFileErrors.length - 10} more errors in this file`,
|
||||
'',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Dependency errors (informational)
|
||||
if (dependencyErrors.length > 0) {
|
||||
sections.push(
|
||||
'─'.repeat(70),
|
||||
'ℹ️ ERRORS IN DEPENDENCIES:',
|
||||
' These errors are in imported files',
|
||||
' Fix them separately or add to your todo list',
|
||||
'',
|
||||
);
|
||||
|
||||
// Group by file
|
||||
const byFile = new Map<string, CheckResult['errors'][number][]>();
|
||||
for (const err of dependencyErrors) {
|
||||
if (!byFile.has(err.file)) {
|
||||
byFile.set(err.file, []);
|
||||
}
|
||||
byFile.get(err.file)!.push(err);
|
||||
}
|
||||
|
||||
let fileCount = 0;
|
||||
for (const [file, errors] of byFile.entries()) {
|
||||
if (fileCount >= 5) break;
|
||||
sections.push(` 📄 ${file} (${errors.length} errors)`);
|
||||
fileCount++;
|
||||
}
|
||||
|
||||
if (byFile.size > 5) {
|
||||
sections.push(` ... and ${byFile.size - 5} more files`);
|
||||
}
|
||||
sections.push('');
|
||||
}
|
||||
|
||||
// Footer with workflow guidance
|
||||
sections.push(
|
||||
'─'.repeat(70),
|
||||
'🤖 CLAUDE: Type errors detected.',
|
||||
'',
|
||||
'Recommended workflow:',
|
||||
' 1. If working on a task: Add "Fix type errors" to your todo list',
|
||||
' 2. Complete your current task first',
|
||||
' 3. Then fix these type errors before moving to next task',
|
||||
'',
|
||||
'If the type error is directly related to your current edit:',
|
||||
' → Fix it immediately as part of this change',
|
||||
'',
|
||||
'User: To disable this hook, add to .claude/settings.json:',
|
||||
' { "customHooks": { "typeChecker": { "enabled": false } } }',
|
||||
'═'.repeat(70),
|
||||
'',
|
||||
);
|
||||
|
||||
return sections.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Main hook execution
|
||||
*/
|
||||
async function main(): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// Ensure Bun is installed (fail fast with helpful message)
|
||||
ensureBunInstalled();
|
||||
|
||||
const input = await parseStdin();
|
||||
|
||||
// Check if hook is enabled
|
||||
checkHookEnabled(input.cwd, 'typeChecker');
|
||||
|
||||
// Only run for Edit and Write tools
|
||||
if (input.tool_name !== 'Edit' && input.tool_name !== 'Write') {
|
||||
process.exit(0); // Not a file modification tool
|
||||
}
|
||||
|
||||
// Extract file path from tool input
|
||||
const toolInput = input.tool_input!;
|
||||
const filePath = toolInput?.file_path as string | undefined;
|
||||
|
||||
if (!filePath) {
|
||||
process.exit(0); // No file path
|
||||
}
|
||||
|
||||
// Skip if not TypeScript file
|
||||
if (!isTypeScriptFile(filePath)) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Skip if no tsconfig
|
||||
if (!hasTsConfig(input.cwd)) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Check types using programmatic API
|
||||
const result = await checkTypes(input.cwd, filePath);
|
||||
|
||||
if (!result.success) {
|
||||
// Type errors found - inform but allow operation
|
||||
const errorMessage = formatTypeErrors(result, filePath);
|
||||
|
||||
// Output hookSpecificOutput to inform (not block)
|
||||
const output = {
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PreToolUse',
|
||||
permissionDecision: 'allow', // Informative only
|
||||
permissionDecisionReason: errorMessage,
|
||||
},
|
||||
};
|
||||
|
||||
console.log(JSON.stringify(output));
|
||||
checkPerformance(startTime, 2000, 'type-checker');
|
||||
process.exit(0); // Exit 0 after informing
|
||||
}
|
||||
|
||||
// Types are valid - allow operation silently
|
||||
checkPerformance(startTime, 2000, 'type-checker');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error(formatError(error, 'type-checker'));
|
||||
// On hook error, don't block the operation
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Execute
|
||||
await main();
|
||||
Reference in New Issue
Block a user