import { PolicyV2, FileRule } from '@nihal1983/core'; import { Checker, PRContext, Violation } from '../types'; /** * FileChecker validates file-level rules */ export class FileChecker implements Checker { name = 'FileChecker'; constructor(private policy: PolicyV2) {} async check(context: PRContext): Promise { const violations: Violation[] = []; const fileRules = this.policy.file_rules; if (!fileRules) { return violations; } // Check each file rule for (const rule of fileRules) { violations.push(...await this.checkFileRule(context, rule)); } return violations; } /** * Check a single file rule */ private async checkFileRule(context: PRContext, rule: FileRule): Promise { const violations: Violation[] = []; // Find files that match the "when" condition const matchingFiles = this.findMatchingFiles(context, rule); if (matchingFiles.length === 0) { return violations; // Rule doesn't apply to this PR } // Check "then" actions if (rule.then) { // Check 1: Required files if (rule.then.require) { violations.push(...this.checkRequiredFiles(context, rule, matchingFiles)); } // Check 2: Content must include if (rule.then.content_must_include) { violations.push(...await this.checkContentIncludes(context, rule, matchingFiles)); } // Check 3: Require ADR if (rule.then.require_adr) { violations.push(...this.checkADRRequired(context, rule, matchingFiles)); } } return violations; } /** * Find files matching the "when" condition */ private findMatchingFiles(context: PRContext, rule: FileRule): string[] { const matchingFiles: string[] = []; for (const file of context.files) { let matches = false; // Check pattern if (rule.when.pattern) { const regex = new RegExp(rule.when.pattern.replace(/\*/g, '.*')); matches = regex.test(file.path); } // Check "any" patterns if (rule.when.any) { matches = rule.when.any.some((pattern: string) => { const regex = new RegExp(pattern.replace(/\*/g, '.*')); return regex.test(file.path); }); } // Check exceptions if (matches && rule.exceptions) { const isException = rule.exceptions.some((exception: string) => { const regex = new RegExp(exception.replace(/\*/g, '.*')); return regex.test(file.path); }); if (isException) { matches = false; } } if (matches) { matchingFiles.push(file.path); } } return matchingFiles; } /** * Check if required files are present */ private checkRequiredFiles(context: PRContext, rule: FileRule, matchingFiles: string[]): Violation[] { const violations: Violation[] = []; const requiredFiles = rule.then!.require!; for (const requiredPattern of requiredFiles) { const regex = new RegExp(requiredPattern.replace(/\*/g, '.*')); const exists = context.files.some(f => regex.test(f.path)); if (!exists) { violations.push({ ruleId: rule.name, severity: rule.enforcement.toLowerCase() as 'block' | 'warn', message: rule.message, location: matchingFiles.join(', '), suggestion: `Add required file: ${requiredPattern}`, reference: rule.reference }); } } return violations; } /** * Check if file content includes required strings */ private async checkContentIncludes(context: PRContext, rule: FileRule, matchingFiles: string[]): Promise { const violations: Violation[] = []; const requiredStrings = rule.then!.content_must_include!; for (const filePath of matchingFiles) { const fileChange = context.files.find(f => f.path === filePath); if (!fileChange || !fileChange.content) { // Content not available - warn but don't block violations.push({ ruleId: rule.name, severity: 'warn', message: `Cannot validate content for ${filePath} (content not loaded)`, location: filePath, suggestion: 'Ensure file content is available for validation' }); continue; } const content = fileChange.content; const missingStrings: string[] = []; for (const requiredString of requiredStrings) { if (!content.includes(requiredString)) { missingStrings.push(requiredString); } } if (missingStrings.length > 0) { const errorMessage = rule.then!.or_fail || `${rule.message}: Missing ${missingStrings.join(', ')}`; violations.push({ ruleId: rule.name, severity: rule.enforcement.toLowerCase() as 'block' | 'warn', message: errorMessage, location: filePath, suggestion: `Add required content: ${missingStrings.join(', ')}`, reference: rule.reference }); } } return violations; } /** * Check if ADR is required and present */ private checkADRRequired(context: PRContext, rule: FileRule, matchingFiles: string[]): Violation[] { const violations: Violation[] = []; // Check if any ADR file exists in the changeset const adrPattern = rule.then!.adr_pattern || 'docs/adr/*.md'; const regex = new RegExp(adrPattern.replace(/\*/g, '.*')); const hasADR = context.files.some(f => regex.test(f.path)); if (!hasADR) { violations.push({ ruleId: rule.name, severity: rule.enforcement.toLowerCase() as 'block' | 'warn', message: `${rule.message}: Requires ADR`, location: matchingFiles.join(', '), suggestion: `Add ADR documenting this change (pattern: ${adrPattern})`, reference: rule.reference }); } return violations; } }