import type { Fact } from '../../core/facts/Fact'; type ComplexityThresholds = { maxChangedFiles: number; maxEstimatedLoc: number; }; const DEFAULT_COMPLEXITY_THRESHOLDS: ComplexityThresholds = { maxChangedFiles: 5, maxEstimatedLoc: 120, }; const resolveThreshold = (value: string | undefined, fallback: number): number => { if (!value) { return fallback; } const parsed = Number.parseInt(value, 10); if (!Number.isFinite(parsed) || parsed <= 0) { return fallback; } return parsed; }; const loadThresholds = (): ComplexityThresholds => { return { maxChangedFiles: resolveThreshold( process.env.PUMUKI_TDD_COMPLEX_MAX_FILES, DEFAULT_COMPLEXITY_THRESHOLDS.maxChangedFiles ), maxEstimatedLoc: resolveThreshold( process.env.PUMUKI_TDD_COMPLEX_MAX_LOC, DEFAULT_COMPLEXITY_THRESHOLDS.maxEstimatedLoc ), }; }; const normalizePath = (path: string): string => path.replace(/\\/g, '/'); const isTestPath = (path: string): boolean => { const normalized = normalizePath(path).toLowerCase(); return ( normalized.includes('/__tests__/') || normalized.includes('/tests/') || normalized.includes('/test/') || normalized.endsWith('.spec.ts') || normalized.endsWith('.spec.tsx') || normalized.endsWith('.spec.js') || normalized.endsWith('.spec.jsx') || normalized.endsWith('.test.ts') || normalized.endsWith('.test.tsx') || normalized.endsWith('.test.js') || normalized.endsWith('.test.jsx') || normalized.endsWith('test.swift') || normalized.endsWith('tests.swift') || normalized.endsWith('test.kt') || normalized.endsWith('tests.kt') ); }; const isFeaturePath = (path: string): boolean => { return normalizePath(path).toLowerCase().endsWith('.feature'); }; const isImplementationPath = (path: string): boolean => { const normalized = normalizePath(path).toLowerCase(); if (isTestPath(normalized) || isFeaturePath(normalized)) { return false; } return /\.(ts|tsx|js|jsx|swift|kt|kts)$/i.test(normalized); }; const criticalPathPatterns = [ '/domain/', '/application/', '/core/', '/use-cases/', '/presentation/', '/api/', '/controllers/', '/contracts/', ]; const hasCriticalPath = (path: string): boolean => { const normalized = normalizePath(path).toLowerCase(); return criticalPathPatterns.some((segment) => normalized.includes(segment)); }; const publicInterfacePatterns = [ /\bexport\s+class\b/, /\bexport\s+interface\b/, /\bexport\s+type\b/, /\bexport\s+function\b/, /\bpublic\s+(?:func|class|interface|type|enum)\b/i, /\bprotocol\s+[A-Za-z0-9_]+/i, /\bopen\s+class\b/i, ]; const hasPublicInterfaceToken = (content: string): boolean => { return publicInterfacePatterns.some((pattern) => pattern.test(content)); }; export type TddBddScopeDecision = { inScope: boolean; isNewFeature: boolean; isComplexChange: boolean; reasons: string[]; metrics: { changedFiles: number; estimatedLoc: number; criticalPathFiles: number; publicInterfaceFiles: number; }; }; export const classifyTddBddScope = ( facts: ReadonlyArray ): TddBddScopeDecision => { const thresholds = loadThresholds(); const changedImplementationPaths = new Set(); const addedImplementationPaths = new Set(); const criticalPaths = new Set(); const publicInterfacePaths = new Set(); let estimatedLoc = 0; for (const fact of facts) { if (fact.kind === 'FileChange') { if (!isImplementationPath(fact.path)) { continue; } const normalized = normalizePath(fact.path); changedImplementationPaths.add(normalized); if (fact.changeType === 'added') { addedImplementationPaths.add(normalized); } if (hasCriticalPath(normalized)) { criticalPaths.add(normalized); } continue; } if (fact.kind === 'FileContent') { if (!isImplementationPath(fact.path)) { continue; } const normalized = normalizePath(fact.path); const lineCount = fact.content.split('\n').length; estimatedLoc += lineCount; if (hasCriticalPath(normalized)) { criticalPaths.add(normalized); } if (hasPublicInterfaceToken(fact.content)) { publicInterfacePaths.add(normalized); } } } const reasons: string[] = []; if (changedImplementationPaths.size > 0) { reasons.push('functional.implementation_changed'); } if (changedImplementationPaths.size > thresholds.maxChangedFiles) { reasons.push('complex.changed_files_threshold'); } if (estimatedLoc > thresholds.maxEstimatedLoc) { reasons.push('complex.estimated_loc_threshold'); } if (criticalPaths.size > 0) { reasons.push('complex.critical_paths_touched'); } if (publicInterfacePaths.size > 0) { reasons.push('complex.public_interface_changed'); } const isNewFeature = addedImplementationPaths.size > 0; const isComplexChange = reasons.some((reason) => reason.startsWith('complex.')); return { inScope: changedImplementationPaths.size > 0, isNewFeature, isComplexChange, reasons, metrics: { changedFiles: changedImplementationPaths.size, estimatedLoc, criticalPathFiles: criticalPaths.size, publicInterfaceFiles: publicInterfacePaths.size, }, }; };