tdd-guard
Version:
TDD Guard enforces Test-Driven Development principles using Claude Code hooks
132 lines (131 loc) • 4.77 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.PostToolLintHandler = exports.DEFAULT_RESULT = void 0;
exports.handlePostToolLint = handlePostToolLint;
const toolSchemas_1 = require("../contracts/schemas/toolSchemas");
const lintSchemas_1 = require("../contracts/schemas/lintSchemas");
exports.DEFAULT_RESULT = {
decision: undefined,
reason: ''
};
class PostToolLintHandler {
linter;
storage;
constructor(storage, linter) {
this.storage = storage;
this.linter = linter ?? null;
}
async handle(hookData) {
// If no linter is configured, skip linting
if (!this.linter) {
return exports.DEFAULT_RESULT;
}
return handlePostToolLint(hookData, this.storage, this.linter);
}
}
exports.PostToolLintHandler = PostToolLintHandler;
function parseAndValidateHookData(hookData) {
try {
const parsedData = JSON.parse(hookData);
const hookResult = toolSchemas_1.HookDataSchema.safeParse(parsedData);
if (!hookResult.success) {
return null;
}
// Only process PostToolUse hooks
if (hookResult.data.hook_event_name !== 'PostToolUse') {
return null;
}
return hookResult.data;
}
catch {
return null;
}
}
async function getStoredLintData(storage) {
try {
const lintDataStr = await storage.getLint();
if (lintDataStr) {
return lintSchemas_1.LintDataSchema.parse(JSON.parse(lintDataStr));
}
}
catch {
// Treat any error as no stored data
}
return null;
}
function createLintData(lintResults, storedLintData) {
const hasIssues = lintResults.errorCount > 0 || lintResults.warningCount > 0;
return {
...lintResults,
hasNotifiedAboutLintIssues: hasIssues
? (storedLintData?.hasNotifiedAboutLintIssues ?? false) // Preserve flag when issues exist
: false // Reset flag when no issues
};
}
function createBlockResult(lintData) {
const formattedIssues = formatLintIssues(lintData.issues);
const summary = `\n✖ ${lintData.errorCount + lintData.warningCount} problems (${lintData.errorCount} errors, ${lintData.warningCount} warnings)`;
return {
decision: 'block',
reason: `Lint issues detected:${formattedIssues}\n${summary}\n\nPlease fix these issues before proceeding.`
};
}
function formatLintIssues(issues) {
const issuesByFile = new Map();
for (const issue of issues) {
if (!issuesByFile.has(issue.file)) {
issuesByFile.set(issue.file, []);
}
const ruleInfo = issue.rule ? ` ${issue.rule}` : '';
issuesByFile.get(issue.file).push(` ${issue.line}:${issue.column} ${issue.severity} ${issue.message}${ruleInfo}`);
}
let formattedIssues = '';
for (const [file, fileIssues] of issuesByFile) {
formattedIssues += `\n${file}\n${fileIssues.join('\n')}`;
}
return formattedIssues;
}
async function handlePostToolLint(hookData, storage, linter) {
const validatedHookData = parseAndValidateHookData(hookData);
if (!validatedHookData) {
return exports.DEFAULT_RESULT;
}
// Extract file paths from tool operation
const filePaths = extractFilePaths(validatedHookData);
if (filePaths.length === 0) {
return exports.DEFAULT_RESULT;
}
// Get current lint data to check hasNotifiedAboutLintIssues state
const storedLintData = await getStoredLintData(storage);
// Run linting on the files
const lintResults = await linter.lint(filePaths);
// Create and save lint data
const lintData = createLintData(lintResults, storedLintData);
await storage.saveLint(JSON.stringify(lintData));
const hasIssues = lintResults.errorCount > 0 || lintResults.warningCount > 0;
// Block if:
// 1. PreToolUse has notified (flag is true)
// 2. There are still issues
if (storedLintData?.hasNotifiedAboutLintIssues && hasIssues) {
return createBlockResult(lintData);
}
return exports.DEFAULT_RESULT;
}
function extractFilePaths(hookData) {
const toolInput = hookData.tool_input;
if (!toolInput || typeof toolInput !== 'object')
return [];
const paths = [];
if ('file_path' in toolInput && typeof toolInput.file_path === 'string') {
paths.push(toolInput.file_path);
}
// Handle multi-edit operations
if ('edits' in toolInput && Array.isArray(toolInput.edits)) {
for (const edit of toolInput.edits) {
if ('file_path' in edit && typeof edit.file_path === 'string') {
paths.push(edit.file_path);
}
}
}
return [...new Set(paths)]; // Remove duplicates
}