import { PolicyV2 } from '@nihal1983/core'; import { Checker, PRContext, Violation } from '../types'; /** * StructureChecker validates MR structure (title, description, branch naming) */ export class StructureChecker implements Checker { name = 'StructureChecker'; constructor(private policy: PolicyV2) {} async check(context: PRContext): Promise { const violations: Violation[] = []; const structure = this.policy.mr_structure; if (!structure) { return violations; } // Check 1: Title requirements if (structure.title) { violations.push(...this.checkTitle(context)); } // Check 2: Description requirements if (structure.description) { violations.push(...this.checkDescription(context)); } // Check 3: Branch naming if (structure.branch_naming) { violations.push(...this.checkBranchNaming(context)); } return violations; } /** * Check PR title requirements */ private checkTitle(context: PRContext): Violation[] { const violations: Violation[] = []; const titleReq = this.policy.mr_structure!.title!; // Skip if no PR context (e.g., full repo scan) if (!context.pr?.title) { return violations; } const title = context.pr.title; // Check minimum length if (titleReq.min_length && title.length < titleReq.min_length) { violations.push({ ruleId: 'title-too-short', severity: titleReq.enforcement.toLowerCase() as 'block' | 'warn', message: `PR title too short: ${title.length} chars (min: ${titleReq.min_length})`, location: 'PR title', suggestion: 'Provide a more descriptive title', reference: 'CODE-POLICY.md: mr_structure.title.min_length' }); } // Check pattern (e.g., conventional commits) if (titleReq.pattern) { const regex = new RegExp(titleReq.pattern); if (!regex.test(title)) { violations.push({ ruleId: 'title-invalid-format', severity: titleReq.enforcement.toLowerCase() as 'block' | 'warn', message: titleReq.message || `PR title doesn't match required pattern: ${titleReq.pattern}`, location: 'PR title', suggestion: 'Use conventional commits format (e.g., "feat: add user API")', reference: 'CODE-POLICY.md: mr_structure.title.pattern' }); } } return violations; } /** * Check PR description requirements */ private checkDescription(context: PRContext): Violation[] { const violations: Violation[] = []; const descReq = this.policy.mr_structure!.description!; // Skip if no PR context (e.g., full repo scan) if (!context.pr) { return violations; } const description = context.pr.description || ''; // Check minimum total length if (descReq.min_total_length && description.length < descReq.min_total_length) { violations.push({ ruleId: 'description-too-short', severity: descReq.enforcement.toLowerCase() as 'block' | 'warn', message: `PR description too short: ${description.length} chars (min: ${descReq.min_total_length})`, location: 'PR description', suggestion: 'Provide more details about what changed, why, and how it was tested', reference: 'CODE-POLICY.md: mr_structure.description.min_total_length' }); } // Check required sections if (descReq.required_sections) { for (const section of descReq.required_sections) { const sectionExists = description.includes(section.name); if (!sectionExists) { violations.push({ ruleId: 'description-missing-section', severity: section.enforcement.toLowerCase() as 'block' | 'warn', message: `PR description missing required section: ${section.name}`, location: 'PR description', suggestion: `Add section: ${section.name}`, reference: 'CODE-POLICY.md: mr_structure.description.required_sections' }); continue; } // Check section length (if specified) if (section.min_length) { const sectionContent = this.extractSectionContent(description, section.name); if (sectionContent && sectionContent.length < section.min_length) { violations.push({ ruleId: 'description-section-too-short', severity: section.enforcement.toLowerCase() as 'block' | 'warn', message: `Section "${section.name}" too short: ${sectionContent.length} chars (min: ${section.min_length})`, location: `PR description: ${section.name}`, suggestion: 'Provide more details in this section', reference: 'CODE-POLICY.md: mr_structure.description.required_sections' }); } } } } return violations; } /** * Check branch naming requirements */ private checkBranchNaming(context: PRContext): Violation[] { const violations: Violation[] = []; const branchReq = this.policy.mr_structure!.branch_naming!; // Skip if no PR context (e.g., full repo scan) if (!context.pr?.branch) { return violations; } const branch = context.pr.branch; const regex = new RegExp(branchReq.pattern); if (!regex.test(branch)) { let suggestion = `Branch name should match pattern: ${branchReq.pattern}`; if (branchReq.examples && branchReq.examples.length > 0) { suggestion += `\nExamples: ${branchReq.examples.join(', ')}`; } violations.push({ ruleId: 'branch-naming-invalid', severity: branchReq.enforcement.toLowerCase() as 'block' | 'warn', message: `Branch name doesn't match required pattern: ${branchReq.pattern}`, location: `Branch: ${branch}`, suggestion, reference: 'CODE-POLICY.md: mr_structure.branch_naming' }); } return violations; } /** * Extract content from a markdown section */ private extractSectionContent(markdown: string, sectionName: string): string | null { // Find section by heading const sectionRegex = new RegExp(`${sectionName}[\\s\\S]*?(?=##|$)`, 'i'); const match = markdown.match(sectionRegex); if (!match) { return null; } // Remove the heading itself const content = match[0].replace(new RegExp(sectionName, 'i'), '').trim(); return content; } }