import { hasIdentifierAt, scanCodeLikeSource } from './utils'; export type KotlinSemanticNodeMatch = { kind: 'class' | 'property' | 'call' | 'member'; name: string; lines: readonly number[]; }; export type KotlinPresentationSrpMatch = { primary_node: KotlinSemanticNodeMatch; related_nodes: readonly KotlinSemanticNodeMatch[]; why: string; impact: string; expected_fix: string; lines: readonly number[]; }; export type KotlinConcreteDependencyDipMatch = { primary_node: KotlinSemanticNodeMatch; related_nodes: readonly KotlinSemanticNodeMatch[]; why: string; impact: string; expected_fix: string; lines: readonly number[]; }; export type KotlinOpenClosedOcpMatch = { primary_node: KotlinSemanticNodeMatch; related_nodes: readonly KotlinSemanticNodeMatch[]; why: string; impact: string; expected_fix: string; lines: readonly number[]; }; export type KotlinInterfaceSegregationMatch = { primary_node: KotlinSemanticNodeMatch; related_nodes: readonly KotlinSemanticNodeMatch[]; why: string; impact: string; expected_fix: string; lines: readonly number[]; }; export type KotlinLiskovSubstitutionMatch = { primary_node: KotlinSemanticNodeMatch; related_nodes: readonly KotlinSemanticNodeMatch[]; why: string; impact: string; expected_fix: string; lines: readonly number[]; }; type KotlinInterfaceMemberDeclaration = { name: string; line: number; }; type KotlinInterfaceDeclaration = { name: string; line: number; members: readonly KotlinInterfaceMemberDeclaration[]; }; type KotlinTypeDeclaration = { name: string; line: number; conformances: readonly string[]; bodyStartLine: number; bodyEndLine: number; }; type KotlinResponsibilityMatch = { key: string; node: KotlinSemanticNodeMatch; }; const kotlinQueryMemberNamePattern = /^(get|find|list|fetch|read|load|restore|refresh|is|has|can)/i; const kotlinCommandMemberNamePattern = /^(create|update|delete|remove|save|insert|upsert|set|write|persist|clear|reset|sync|store)/i; const registerKotlinResponsibility = ( nodes: KotlinResponsibilityMatch[], key: string, kind: KotlinSemanticNodeMatch['kind'], name: string, lines: readonly number[] ): void => { if (lines.length === 0) { return; } nodes.push({ key, node: { kind, name, lines } }); }; const hasKotlinResponsibilityKeys = ( nodes: readonly KotlinResponsibilityMatch[], keys: readonly string[] ): boolean => { const observedKeys = new Set(nodes.map((node) => node.key)); return keys.every((key) => observedKeys.has(key)); }; const isKotlinQueryMemberName = (name: string): boolean => kotlinQueryMemberNamePattern.test(name); const isKotlinCommandMemberName = (name: string): boolean => kotlinCommandMemberNamePattern.test(name); const stripKotlinLineForSemanticScan = (line: string): string => { return line .replace(/\/\/.*$/, '') .replace(/"(?:\\.|[^"\\])*"/g, '""'); }; const collectKotlinRegexLines = (source: string, regex: RegExp): readonly number[] => { const matches: number[] = []; source.split(/\r?\n/).forEach((line, index) => { const sanitized = stripKotlinLineForSemanticScan(line); if (sanitized.trimStart().startsWith('import ')) { return; } regex.lastIndex = 0; if (regex.test(sanitized)) { matches.push(index + 1); } }); return matches; }; const sortedUniqueLines = (lines: ReadonlyArray): readonly number[] => { return Array.from(new Set(lines.filter((line) => Number.isFinite(line)).map((line) => Math.trunc(line)))) .sort((left, right) => left - right); }; const androidCustomSingletonObjectNamePattern = /(?:Repository|Service|Manager|Client|Store|DataSource|Gateway|Controller|Coordinator)$/; const androidSafeObjectNamePattern = /(?:Route|Routes|Screen|Screens|Destination|Destinations|Module|Modules|Constants|Config|Keys|Theme|Colors|Typography)$/; const isAndroidHiltModuleObjectContext = ( lines: readonly string[], index: number ): boolean => { const context = lines .slice(Math.max(0, index - 4), index) .map((line) => stripKotlinLineForSemanticScan(line)) .join('\n'); return /@(?:Module|InstallIn|Provides|Binds)\b/.test(context); }; export const collectAndroidCustomSingletonObjectLines = (source: string): readonly number[] => { const lines = source.split(/\r?\n/); const matches: number[] = []; lines.forEach((line, index) => { const sanitized = stripKotlinLineForSemanticScan(line); if (sanitized.trimStart().startsWith('import ')) { return; } const objectMatch = sanitized.match(/^\s*object\s+([A-Za-z_][A-Za-z0-9_]*)\b/); const objectName = objectMatch?.[1]; if (!objectName) { return; } if (androidSafeObjectNamePattern.test(objectName) || isAndroidHiltModuleObjectContext(lines, index)) { return; } if (androidCustomSingletonObjectNamePattern.test(objectName)) { matches.push(index + 1); } }); return sortedUniqueLines(matches); }; export const hasAndroidCustomSingletonObjectUsage = (source: string): boolean => collectAndroidCustomSingletonObjectLines(source).length > 0; export const collectAndroidHiltInjectionWithoutEntryPointLines = ( source: string ): readonly number[] => { const lines = source.split(/\r?\n/); const matches: number[] = []; for (let index = 0; index < lines.length; index += 1) { const sanitized = stripKotlinLineForSemanticScan(lines[index] ?? ''); const classMatch = sanitized.match( /^\s*(?:class|open\s+class)\s+([A-Za-z_][A-Za-z0-9_]*)\b[^{]*(?:Activity|Fragment)\s*\(/ ); if (!classMatch) { continue; } const annotationContext = lines .slice(Math.max(0, index - 4), index) .map((line) => stripKotlinLineForSemanticScan(line)) .join('\n'); if (/@AndroidEntryPoint\b/.test(annotationContext)) { continue; } const classStartLine = index + 1; let braceDepth = countTokenOccurrences(sanitized, '{') - countTokenOccurrences(sanitized, '}'); for (let cursor = index + 1; cursor < lines.length; cursor += 1) { const candidate = stripKotlinLineForSemanticScan(lines[cursor] ?? ''); if (/@Inject\b\s+lateinit\s+var\b/.test(candidate)) { matches.push(classStartLine, cursor + 1); } braceDepth += countTokenOccurrences(candidate, '{'); braceDepth -= countTokenOccurrences(candidate, '}'); if (braceDepth <= 0) { break; } } } return sortedUniqueLines(matches); }; export const hasAndroidHiltInjectionWithoutEntryPointUsage = (source: string): boolean => collectAndroidHiltInjectionWithoutEntryPointLines(source).length > 0; const countTokenOccurrences = (line: string, token: string): number => { return line.split(token).length - 1; }; const extractBalancedCallSegment = (source: string, openParenIndex: number): string | undefined => { let depth = 0; for (let index = openParenIndex; index < source.length; index += 1) { const character = source[index]; if (character === '(') { depth += 1; } else if (character === ')') { depth -= 1; if (depth === 0) { return source.slice(openParenIndex, index + 1); } } } return undefined; }; const extractBalancedBlockSegment = (source: string, openBraceIndex: number): string | undefined => { let depth = 0; for (let index = openBraceIndex; index < source.length; index += 1) { const character = source[index]; if (character === '{') { depth += 1; } else if (character === '}') { depth -= 1; if (depth === 0) { return source.slice(openBraceIndex, index + 1); } } } return undefined; }; const normalizeKotlinWhenBranchName = (rawLabel: string): string => { const normalized = rawLabel.split(',')[0]?.trim() ?? rawLabel.trim(); const withoutGuard = normalized.split(/\s+if\s+/)[0]?.trim() ?? normalized; return withoutGuard.match(/([A-Za-z_][A-Za-z0-9_]*)$/)?.[1] ?? withoutGuard; }; const parseKotlinInterfaceDeclarations = (source: string): readonly KotlinInterfaceDeclaration[] => { const lines = source.split(/\r?\n/); const declarations: KotlinInterfaceDeclaration[] = []; const interfacePattern = /^\s*(?:fun\s+)?interface\s+([A-Za-z_][A-Za-z0-9_]*)\b/; const functionPattern = /^\s*fun\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(/; const suspendFunctionPattern = /^\s*suspend\s+fun\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(/; const propertyPattern = /^\s*(?:val|var)\s+([A-Za-z_][A-Za-z0-9_]*)\b/; for (let index = 0; index < lines.length; index += 1) { const sanitizedLine = stripKotlinLineForSemanticScan(lines[index] ?? ''); const interfaceMatch = sanitizedLine.match(interfacePattern); if (!interfaceMatch) { continue; } const interfaceName = interfaceMatch[1]; if (!interfaceName) { continue; } const members: KotlinInterfaceMemberDeclaration[] = []; let braceDepth = countTokenOccurrences(sanitizedLine, '{') - countTokenOccurrences(sanitizedLine, '}'); for (let cursor = index + 1; cursor < lines.length; cursor += 1) { const candidateLine = stripKotlinLineForSemanticScan(lines[cursor] ?? ''); const functionMatch = candidateLine.match(suspendFunctionPattern) ?? candidateLine.match(functionPattern); if (functionMatch?.[1]) { members.push({ name: functionMatch[1], line: cursor + 1, }); } else { const propertyMatch = candidateLine.match(propertyPattern); if (propertyMatch?.[1]) { members.push({ name: propertyMatch[1], line: cursor + 1, }); } } braceDepth += countTokenOccurrences(candidateLine, '{'); braceDepth -= countTokenOccurrences(candidateLine, '}'); if (braceDepth <= 0) { break; } } declarations.push({ name: interfaceName, line: index + 1, members, }); } return declarations; }; const parseKotlinTypeDeclarations = (source: string): readonly KotlinTypeDeclaration[] => { const lines = source.split(/\r?\n/); const declarations: KotlinTypeDeclaration[] = []; const classPattern = /^\s*(?:internal\s+|private\s+|public\s+)?(?:abstract\s+|open\s+|sealed\s+|data\s+|final\s+)?class\s+([A-Za-z_][A-Za-z0-9_]*)\b([^{]*)/; for (let index = 0; index < lines.length; index += 1) { const sanitizedLine = stripKotlinLineForSemanticScan(lines[index] ?? ''); const classMatch = sanitizedLine.match(classPattern); if (!classMatch) { continue; } const typeName = classMatch[1]; const tail = classMatch[2] ?? ''; if (!typeName) { continue; } const conformanceSection = tail.includes(':') ? tail.split(':').slice(1).join(':') : ''; const conformances = conformanceSection .split(',') .map((entry) => entry.trim()) .filter((entry) => entry.length > 0) .map((entry) => entry.replace(/\(.*$/, '').trim()) .map((entry) => entry.match(/([A-Za-z_][A-Za-z0-9_]*)$/)?.[1] ?? entry) .filter((entry) => entry.length > 0); let braceDepth = countTokenOccurrences(sanitizedLine, '{') - countTokenOccurrences(sanitizedLine, '}'); const bodyStartLine = index + 1; let bodyEndLine = index + 1; for (let cursor = index + 1; cursor < lines.length; cursor += 1) { const candidateLine = stripKotlinLineForSemanticScan(lines[cursor] ?? ''); braceDepth += countTokenOccurrences(candidateLine, '{'); braceDepth -= countTokenOccurrences(candidateLine, '}'); bodyEndLine = cursor + 1; if (braceDepth <= 0) { break; } } declarations.push({ name: typeName, line: index + 1, conformances, bodyStartLine, bodyEndLine, }); } return declarations; }; export const hasKotlinHardcodedBackgroundDispatcherUsage = (source: string): boolean => { return collectKotlinRegexLines(source, /\bDispatchers\s*\.\s*(?:IO|Default)\b/).length > 0; }; export const hasKotlinWithContextUsage = (source: string): boolean => { return collectKotlinRegexLines(source, /\bwithContext\s*(?:<[^>\n]+>\s*)?\(/).length > 0; }; export const hasKotlinLifecycleScopeUsage = (source: string): boolean => { return collectKotlinRegexLines(source, /\blifecycleScope\s*\./).length > 0; }; export const hasKotlinSharedPreferencesUsage = (source: string): boolean => { return collectKotlinRegexLines(source, /\b(?:SharedPreferences|PreferenceManager\s*\.|getSharedPreferences\s*\()/).length > 0; }; export const hasKotlinJUnit4Usage = (source: string): boolean => { return source.split(/\r?\n/).some((line) => { const sanitized = stripKotlinLineForSemanticScan(line); return ( /\bimport\s+org\.junit\.(?:Test|Before|After|BeforeClass|AfterClass|Assert|Rule|ClassRule|runner\.RunWith|rules\.)\b/.test(sanitized) || /@(?:RunWith|Rule|ClassRule)\b/.test(sanitized) || /\bAssert\s*\./.test(sanitized) ); }); }; export const hasKotlinProductionMockUsage = (source: string): boolean => { return collectKotlinRegexLines( source, /(?:\b(?:mockk|spyk|mockkObject|mockkStatic|mockkConstructor|mockito|mockitoSession|mockitoRule)\s*(?:<[^>\n]+>\s*)?\(|\bMockito\s*\.\s*(?:mock|spy)\s*\(|@(?:Mock|Spy|MockK|RelaxedMockK)\b)/ ).length > 0; }; export const collectKotlinHardcodedUiStringLines = (source: string): readonly number[] => { return source .split(/\r?\n/) .map((line, index) => ({ line, number: index + 1 })) .filter(({ line }) => { const withoutComment = line.replace(/\/\/.*$/, ''); if (withoutComment.trimStart().startsWith('import ')) { return false; } return ( /\bText\s*\(\s*"[^"$\n]+"/.test(withoutComment) || /\bcontentDescription\s*=\s*"[^"$\n]+"/.test(withoutComment) || /\b(?:Button|OutlinedButton|TextButton|NavigationBarItem)\s*\([^)]*text\s*=\s*"[^"$\n]+"/.test(withoutComment) ); }) .map(({ number }) => number); }; export const hasKotlinHardcodedUiStringUsage = (source: string): boolean => { return collectKotlinHardcodedUiStringLines(source).length > 0; }; export const hasKotlinSupervisorScopeUsage = (source: string): boolean => { return collectKotlinRegexLines(source, /\bsupervisorScope\s*(?:<[^>\n]+>\s*)?(?:\(|\{)/).length > 0; }; export const hasKotlinCoroutineTryCatchUsage = (source: string): boolean => { const sanitizedSource = source .split(/\r?\n/) .map((line) => stripKotlinLineForSemanticScan(line)) .filter((line) => !line.trimStart().startsWith('import ')) .join('\n'); return ( /\btry\s*\{[\s\S]*\bcatch\s*\(/.test(sanitizedSource) && /\b(?:suspend\s+fun|launch\s*\{|async\s*\{|withContext\s*\(|supervisorScope\s*(?:<[^>\n]+>\s*)?(?:\(|\{))/.test(sanitizedSource) ); }; export const hasKotlinThreadSleepCall = (source: string): boolean => { return scanCodeLikeSource(source, ({ source: kotlinSource, index, current }) => { if (current !== 'T') { return false; } return ( hasIdentifierAt(kotlinSource, index, 'Thread') && kotlinSource.startsWith('.sleep', index + 'Thread'.length) ); }); }; export const hasKotlinGlobalScopeUsage = (source: string): boolean => { return scanCodeLikeSource(source, ({ source: kotlinSource, index, current }) => { if (current !== 'G' || !hasIdentifierAt(kotlinSource, index, 'GlobalScope')) { return false; } const start = index + 'GlobalScope'.length; const tail = kotlinSource.slice(start, start + 32); return /^\s*\.(launch|async|produce|actor)\b/.test(tail); }); }; export const hasKotlinRunBlockingUsage = (source: string): boolean => { return scanCodeLikeSource(source, ({ source: kotlinSource, index, current }) => { if (current !== 'r' || !hasIdentifierAt(kotlinSource, index, 'runBlocking')) { return false; } const start = index + 'runBlocking'.length; const tail = kotlinSource.slice(start, start + 48); return /^\s*(<[^>\n]+>\s*)?(\(|\{)/.test(tail); }); }; export const hasAndroidAsyncTaskUsage = (source: string): boolean => { return scanCodeLikeSource(source, ({ source: androidSource, index, current }) => { if (current !== 'A' && current !== 'a') { return false; } const isBareAsyncTask = hasIdentifierAt(androidSource, index, 'AsyncTask'); const isQualifiedAsyncTask = hasIdentifierAt(androidSource, index, 'android') && androidSource.startsWith('.os.AsyncTask', index + 'android'.length); if (!isBareAsyncTask && !isQualifiedAsyncTask) { return false; } const start = isBareAsyncTask ? index + 'AsyncTask'.length : index + 'android.os.AsyncTask'.length; const tail = androidSource.slice(start, start + 48); return /^\s*(<[^>\n]+>\s*)?(?:\(|\{|:|\.|$)/.test(tail); }); }; const androidActivityBaseTypes = new Set([ 'Activity', 'ComponentActivity', 'AppCompatActivity', 'FragmentActivity', ]); export const hasKotlinGodActivityUsage = (source: string): boolean => { const lines = source.split(/\r?\n/); const declarations = parseKotlinTypeDeclarations(source); return declarations.some((declaration) => { const isActivity = declaration.conformances.some((conformance) => androidActivityBaseTypes.has(conformance) ); if (!isActivity) { return false; } const body = lines .slice(declaration.bodyStartLine - 1, declaration.bodyEndLine) .map((line) => stripKotlinLineForSemanticScan(line)) .filter((line) => !line.trimStart().startsWith('import ')) .join('\n'); const hasUiEntrypoint = /\bsetContent\s*\{/.test(body) || /@Composable\b/.test(body); const hasNetworkResponsibility = /\b(?:OkHttpClient|Retrofit|HttpURLConnection|newCall|enqueue|execute)\b/.test(body); const hasPersistenceResponsibility = /\b(?:Room\.databaseBuilder|SharedPreferences|getSharedPreferences|DataStore|SQLiteDatabase)\b/.test(body); const hasNavigationResponsibility = /\b(?:NavController|rememberNavController|navigate\s*\(|NavHost\s*\()\b/.test(body); const functionCount = (body.match(/\bfun\s+[A-Za-z_][A-Za-z0-9_]*\s*\(/g) ?? []).length; const activityBodyLines = Math.max(0, declaration.bodyEndLine - declaration.bodyStartLine + 1); return ( (hasUiEntrypoint && [hasNetworkResponsibility, hasPersistenceResponsibility, hasNavigationResponsibility].filter(Boolean) .length >= 2) || (hasUiEntrypoint && functionCount >= 8) || activityBodyLines >= 140 ); }); }; export const hasKotlinApplicationOnCreateHeavyInitializationUsage = (source: string): boolean => { const lines = source.split(/\r?\n/); const declarations = parseKotlinTypeDeclarations(source); return declarations.some((declaration) => { const isApplication = declaration.conformances.some((conformance) => conformance === 'Application' ); if (!isApplication) { return false; } const body = lines .slice(declaration.bodyStartLine - 1, declaration.bodyEndLine) .map((line) => stripKotlinLineForSemanticScan(line)) .filter((line) => !line.trimStart().startsWith('import ')) .join('\n'); const onCreateMatch = /\boverride\s+fun\s+onCreate\s*\([^)]*\)\s*\{/g.exec(body); if (!onCreateMatch) { return false; } const openBraceIndex = body.indexOf('{', onCreateMatch.index); const onCreateBody = extractBalancedBlockSegment(body, openBraceIndex); if (!onCreateBody) { return false; } return /\b(?:FirebaseApp|WorkManager|Room|Retrofit|OkHttpClient|Glide|Coil|Timber)\s*(?:\.|Builder\s*\(|\()/.test(onCreateBody); }); }; export const hasKotlinNonLazyScrollableCollectionUsage = (source: string): boolean => { const sanitizedSource = source .split(/\r?\n/) .map((line) => stripKotlinLineForSemanticScan(line)) .filter((line) => !line.trimStart().startsWith('import ')) .join('\n'); const scrollableColumnOrRowPattern = /\b(?:Column|Row)\s*\([^)]*\bModifier\s*\.[^)]*(?:verticalScroll|horizontalScroll)\s*\([^)]*\)[\s\S]*?\{([\s\S]*?)\n\s*\}/g; let match: RegExpExecArray | null; while ((match = scrollableColumnOrRowPattern.exec(sanitizedSource)) !== null) { const body = match[1] ?? ''; if (/\b(?:forEach|forEachIndexed|map|repeat)\s*(?:\(|\{)|\bfor\s*\([^)]+\bin\b[^)]*\)/.test(body)) { return true; } } return false; }; export const hasKotlinUnstableLaunchedEffectKeyUsage = (source: string): boolean => { return collectKotlinRegexLines( source, /\bLaunchedEffect\s*\(\s*(?:Unit|true|false|null)?\s*\)\s*\{/ ).length > 0; }; export const hasKotlinLaunchedEffectBusyLoopUsage = (source: string): boolean => { const sanitizedSource = source .split(/\r?\n/) .map((line) => stripKotlinLineForSemanticScan(line)) .filter((line) => !line.trimStart().startsWith('import ')) .join('\n'); const launchedEffectPattern = /\bLaunchedEffect\s*\([^)]*\)\s*\{/g; let match: RegExpExecArray | null; while ((match = launchedEffectPattern.exec(sanitizedSource)) !== null) { const bodyStartIndex = sanitizedSource.indexOf('{', match.index); const segment = extractBalancedBlockSegment(sanitizedSource, bodyStartIndex); if ( segment && /\bwhile\s*\(\s*(?:true|isActive)\s*\)\s*\{/.test(segment) && !/\bdelay\s*\(/.test(segment) ) { return true; } } return false; }; export const hasKotlinProductionLoggingUsage = (source: string): boolean => { return source.split(/\r?\n/).some((line) => { const sanitized = stripKotlinLineForSemanticScan(line); if (sanitized.trimStart().startsWith('import ') || sanitized.includes('BuildConfig.DEBUG')) { return false; } return /\b(?:println\s*\(|System\s*\.\s*(?:out|err)\s*\.\s*println\s*\(|Log\s*\.\s*(?:v|d|i|w|e|wtf)\s*\(|Timber\s*\.\s*(?:v|d|i|w|e|wtf)\s*\()/.test(sanitized); }); }; export const hasKotlinModifierBackgroundBeforePaddingUsage = (source: string): boolean => { const sanitizedSource = source .split(/\r?\n/) .map((line) => stripKotlinLineForSemanticScan(line)) .filter((line) => !line.trimStart().startsWith('import ')) .join('\n'); return /\bModifier\b[\s\S]*?\.background\s*\([^)]*\)[\s\S]*?\.padding\s*\(/.test(sanitizedSource); }; export const hasKotlinMissingContentDescriptionUsage = (source: string): boolean => { const sanitizedSource = source .split(/\r?\n/) .map((line) => stripKotlinLineForSemanticScan(line)) .filter((line) => !line.trimStart().startsWith('import ')) .join('\n'); const callPattern = /\b(?:Image|Icon)\s*\(/g; let match: RegExpExecArray | null; while ((match = callPattern.exec(sanitizedSource)) !== null) { const openParenIndex = sanitizedSource.indexOf('(', match.index); const segment = extractBalancedCallSegment(sanitizedSource, openParenIndex); if (segment && !/\bcontentDescription\s*=/.test(segment)) { return true; } } return false; }; export const hasKotlinFontScaleDisabledUsage = (source: string): boolean => { return collectKotlinRegexLines( source, /\bfontScale\s*=\s*(?:1(?:\.0)?f?|1(?:\.0)?)\b/ ).length > 0; }; export const hasKotlinIncompleteMaterialThemeUsage = (source: string): boolean => { const sanitizedSource = source .split(/\r?\n/) .map((line) => stripKotlinLineForSemanticScan(line)) .filter((line) => !line.trimStart().startsWith('import ')) .join('\n'); const callPattern = /\bMaterialTheme\s*\(/g; let match: RegExpExecArray | null; while ((match = callPattern.exec(sanitizedSource)) !== null) { const openParenIndex = sanitizedSource.indexOf('(', match.index); const segment = extractBalancedCallSegment(sanitizedSource, openParenIndex); if ( segment && (!/\bcolorScheme\s*=/.test(segment) || !/\btypography\s*=/.test(segment) || !/\bshapes\s*=/.test(segment)) ) { return true; } } return false; }; export const hasKotlinLegacyBottomNavigationUsage = (source: string): boolean => { return collectKotlinRegexLines( source, /\b(?:BottomNavigation|BottomNavigationItem)\s*(?:\(|\{)/ ).length > 0; }; export const hasKotlinImperativeNavigationUsage = (source: string): boolean => { return collectKotlinRegexLines( source, /\b(?:startActivity\s*\(\s*Intent\s*\(|supportFragmentManager\s*\.\s*beginTransaction\s*\(|childFragmentManager\s*\.\s*beginTransaction\s*\(|parentFragmentManager\s*\.\s*beginTransaction\s*\(|FragmentTransaction\b|FragmentManager\b)/ ).length > 0; }; export const hasKotlinComposableObjectCreationWithoutRememberUsage = (source: string): boolean => { const lines = source.split(/\r?\n/); let pendingComposableAnnotation = false; let insideComposable = false; let braceDepth = 0; for (const rawLine of lines) { const sanitized = stripKotlinLineForSemanticScan(rawLine); if (sanitized.trimStart().startsWith('import ')) { continue; } if (/@Composable\b/.test(sanitized)) { pendingComposableAnnotation = true; } if (pendingComposableAnnotation && /\bfun\s+[A-Za-z_][A-Za-z0-9_]*\s*\(/.test(sanitized)) { insideComposable = true; pendingComposableAnnotation = false; braceDepth = 0; } if (insideComposable) { const createsObject = /\b(?:Regex|SimpleDateFormat|DecimalFormat)\s*\(/.test(sanitized) || /\bDateTimeFormatter\s*\.\s*ofPattern\s*\(/.test(sanitized); if (createsObject && !/\bremember\s*\{/.test(sanitized)) { return true; } braceDepth += countTokenOccurrences(sanitized, '{'); braceDepth -= countTokenOccurrences(sanitized, '}'); if (braceDepth <= 0 && sanitized.includes('}')) { insideComposable = false; } } } return false; }; export const hasKotlinComposableStateCreationWithoutRememberUsage = (source: string): boolean => { const lines = source.split(/\r?\n/); let pendingComposableAnnotation = false; let insideComposable = false; let braceDepth = 0; for (const rawLine of lines) { const sanitized = stripKotlinLineForSemanticScan(rawLine); if (sanitized.trimStart().startsWith('import ')) { continue; } if (/@Composable\b/.test(sanitized)) { pendingComposableAnnotation = true; } if (pendingComposableAnnotation && /\bfun\s+[A-Za-z_][A-Za-z0-9_]*\s*\(/.test(sanitized)) { insideComposable = true; pendingComposableAnnotation = false; braceDepth = 0; } if (insideComposable) { const createsState = /\b(?:mutableStateOf|derivedStateOf)\s*\(/.test(sanitized); if (createsState && !/\bremember\s*\{/.test(sanitized)) { return true; } braceDepth += countTokenOccurrences(sanitized, '{'); braceDepth -= countTokenOccurrences(sanitized, '}'); if (braceDepth <= 0 && sanitized.includes('}')) { insideComposable = false; } } } return false; }; export const hasKotlinForceUnwrapUsage = (source: string): boolean => { return source.split(/\r?\n/).some((line) => { const sanitized = stripKotlinLineForSemanticScan(line); if (sanitized.trimStart().startsWith('import ')) { return false; } return /!!(?!\s*(?:=|is))/.test(sanitized); }); }; export const hasKotlinLiveDataStateExposureUsage = (source: string): boolean => { return collectKotlinRegexLines( source, /\b(?:MutableLiveData|LiveData)\s*(?:<|\(|\.)/ ).length > 0; }; export const hasKotlinViewModelFlowWithoutStateInUsage = (source: string): boolean => { const lines = source.split(/\r?\n/); let insideViewModel = false; let braceDepth = 0; let exposesFlowState = false; let usesStateIn = false; for (const rawLine of lines) { const sanitized = stripKotlinLineForSemanticScan(rawLine); if (sanitized.trimStart().startsWith('import ')) { continue; } if (!insideViewModel && /\bclass\s+\w*ViewModel\b/.test(sanitized)) { insideViewModel = true; braceDepth = countTokenOccurrences(sanitized, '{') - countTokenOccurrences(sanitized, '}'); } else if (insideViewModel) { braceDepth += countTokenOccurrences(sanitized, '{'); braceDepth -= countTokenOccurrences(sanitized, '}'); } if (insideViewModel) { if (/\b(?:val|var)\s+\w+\s*:\s*Flow\s* { const lines = source.split(/\r?\n/); let insideViewModel = false; let braceDepth = 0; for (const rawLine of lines) { const sanitized = stripKotlinLineForSemanticScan(rawLine); if (sanitized.trimStart().startsWith('import ')) { continue; } if (!insideViewModel && /\bclass\s+\w*ViewModel\b/.test(sanitized)) { insideViewModel = true; braceDepth = countTokenOccurrences(sanitized, '{') - countTokenOccurrences(sanitized, '}'); } else if (insideViewModel) { braceDepth += countTokenOccurrences(sanitized, '{'); braceDepth -= countTokenOccurrences(sanitized, '}'); } if ( insideViewModel && /\b(?:val|var)\s+(?:_)?(?:uiState|state|screenState|viewState)\b[^:\n]*:\s*(?:Mutable)?SharedFlow\s*/.test(sanitized) ) { return true; } if (insideViewModel && braceDepth <= 0 && sanitized.includes('}')) { insideViewModel = false; } } return false; }; export const hasKotlinManualCoroutineScopeInViewModelUsage = (source: string): boolean => { const lines = source.split(/\r?\n/); let insideViewModel = false; let braceDepth = 0; for (const rawLine of lines) { const sanitized = stripKotlinLineForSemanticScan(rawLine); if (sanitized.trimStart().startsWith('import ')) { continue; } if (!insideViewModel && /\bclass\s+\w*ViewModel\b/.test(sanitized)) { insideViewModel = true; braceDepth = countTokenOccurrences(sanitized, '{') - countTokenOccurrences(sanitized, '}'); } else if (insideViewModel) { braceDepth += countTokenOccurrences(sanitized, '{'); braceDepth -= countTokenOccurrences(sanitized, '}'); } if (insideViewModel && /\bCoroutineScope\s*\(/.test(sanitized)) { return true; } if (insideViewModel && braceDepth <= 0 && sanitized.includes('}')) { insideViewModel = false; } } return false; }; export const hasKotlinDispatcherMainBoundaryLeakUsage = (source: string): boolean => { return collectKotlinRegexLines(source, /\bDispatchers\s*\.\s*Main\b/).length > 0; }; export const findKotlinPresentationSrpMatch = ( source: string ): KotlinPresentationSrpMatch | undefined => { const classPattern = /\b(?:internal\s+|private\s+|public\s+)?(?:sealed\s+|data\s+)?class\s+([A-Za-z0-9_]*(?:ViewModel|Presenter))\b/; const classLines = collectKotlinRegexLines(source, classPattern); if (classLines.length === 0) { return undefined; } const classLine = source.split(/\r?\n/)[classLines[0] - 1] ?? ''; const className = classLine.match(classPattern)?.[1]; if (!className) { return undefined; } const responsibilities: KotlinResponsibilityMatch[] = []; const registerNode = ( key: string, kind: KotlinSemanticNodeMatch['kind'], name: string, regex: RegExp ): void => { registerKotlinResponsibility(responsibilities, key, kind, name, collectKotlinRegexLines(source, regex)); }; registerNode( 'session', 'member', 'session/auth flow', /\b(?:restore|bootstrap|refresh|resume|signIn|signOut|authenticate|session)\w*\s*\(/ ); registerNode( 'networking', 'call', 'remote networking', /\b(?:OkHttpClient\s*\(|Retrofit\.Builder\s*\(|HttpURLConnection\b)/ ); registerNode( 'persistence', 'call', 'local persistence', /\b(?:SharedPreferences\b.*\)|PreferenceDataStoreFactory\.create|preferencesDataStore|DataStore<|RoomDatabase\b)/ ); registerNode( 'navigation', 'member', 'navigation flow', /\b(?:findNavController\s*\(|\.\s*navigate\s*\()/ ); if (!hasKotlinResponsibilityKeys(responsibilities, ['session', 'networking', 'persistence', 'navigation'])) { return undefined; } const relatedNodes = responsibilities.map((entry) => entry.node); const allLines = sortedUniqueLines([ ...classLines, ...relatedNodes.flatMap((node) => [...node.lines]), ]); return { primary_node: { kind: 'class', name: className, lines: classLines, }, related_nodes: relatedNodes, why: `${className} concentra session/auth flow, networking remoto, persistencia local y navegación dentro del mismo tipo de presentation Android, rompiendo SRP.`, impact: 'Presentation acumula múltiples razones de cambio y queda más frágil ante cambios de sesión, transporte, almacenamiento o navegación.', expected_fix: 'Deja el tipo limitado a estado observable y delegación; extrae sesión, persistencia, networking y navegación a casos de uso o coordinadores dedicados.', lines: allLines, }; }; export const findKotlinConcreteDependencyDipMatch = ( source: string ): KotlinConcreteDependencyDipMatch | undefined => { const classPattern = /\b(?:internal\s+|private\s+|public\s+)?(?:sealed\s+|data\s+)?class\s+([A-Za-z0-9_]*(?:UseCase|Service|ViewModel|Presenter|Controller|Coordinator))\b/; const classLines = collectKotlinRegexLines(source, classPattern); if (classLines.length === 0) { return undefined; } const classLine = source.split(/\r?\n/)[classLines[0] - 1] ?? ''; const className = classLine.match(classPattern)?.[1]; if (!className) { return undefined; } const relatedNodes: KotlinSemanticNodeMatch[] = []; const registerNode = ( kind: KotlinSemanticNodeMatch['kind'], name: string, regex: RegExp ): void => { const lines = collectKotlinRegexLines(source, regex); if (lines.length === 0) { return; } relatedNodes.push({ kind, name, lines }); }; registerNode( 'property', 'concrete dependency: SharedPreferences', /\b(?:private|internal|public)?\s*val\s+\w+\s*:\s*SharedPreferences\b/ ); registerNode( 'property', 'concrete dependency: OkHttpClient', /\b(?:private|internal|public)?\s*val\s+\w+\s*:\s*OkHttpClient\b/ ); registerNode( 'property', 'concrete dependency: Retrofit', /\b(?:private|internal|public)?\s*val\s+\w+\s*:\s*Retrofit\b/ ); registerNode( 'property', 'concrete dependency: DataStore', /\b(?:private|internal|public)?\s*val\s+\w+\s*:\s*DataStore(?:<|$)/ ); registerNode( 'property', 'concrete dependency: RoomDatabase', /\b(?:private|internal|public)?\s*val\s+\w+\s*:\s*\w*RoomDatabase\b/ ); registerNode('call', 'OkHttpClient()', /\bOkHttpClient\s*\(/); registerNode('call', 'Retrofit.Builder()', /\bRetrofit\.Builder\s*\(/); registerNode( 'call', 'PreferenceDataStoreFactory.create', /\bPreferenceDataStoreFactory\.create(?:WithPath)?\s*\(/ ); registerNode('call', 'Room.databaseBuilder', /\bRoom\.databaseBuilder\s*\(/); if (relatedNodes.length === 0) { return undefined; } const allLines = sortedUniqueLines([ ...classLines, ...relatedNodes.flatMap((node) => [...node.lines]), ]); return { primary_node: { kind: 'class', name: className, lines: classLines, }, related_nodes: relatedNodes, why: `${className} depende directamente de servicios concretos del framework o infraestructura en application/presentation Android, rompiendo DIP al saltarse puertos o abstracciones.`, impact: 'La capa de alto nivel queda acoplada a detalles concretos de transporte o persistencia, dificulta el test aislado y aumenta el coste de sustituir infraestructura.', expected_fix: 'Introduce puertos o gateways en application/domain e inyecta adaptadores concretos desde infrastructure, evitando depender directamente de clientes o stores del framework.', lines: allLines, }; }; export const findKotlinOpenClosedWhenMatch = ( source: string ): KotlinOpenClosedOcpMatch | undefined => { const classPattern = /^\s*(?:internal\s+|private\s+|public\s+)?(?:sealed\s+|data\s+)?class\s+([A-Za-z0-9_]*(?:UseCase|Service|ViewModel|Presenter|Controller|Coordinator))\b/; const lines = source.split(/\r?\n/); const classLines = collectKotlinRegexLines(source, classPattern); if (classLines.length === 0) { return undefined; } const classLine = lines[classLines[0] - 1] ?? ''; const className = classLine.match(classPattern)?.[1]; if (!className) { return undefined; } for (let index = 0; index < lines.length; index += 1) { const sanitizedLine = stripKotlinLineForSemanticScan(lines[index] ?? ''); const whenMatch = sanitizedLine.match( /\bwhen\s*\(\s*(?:[A-Za-z_][A-Za-z0-9_]*\s*\.\s*)*([A-Za-z_][A-Za-z0-9_]*)\s*\)\s*\{/ ); if (!whenMatch) { continue; } const discriminatorName = whenMatch[1]; let braceDepth = countTokenOccurrences(sanitizedLine, '{') - countTokenOccurrences(sanitizedLine, '}'); const branchNodes: KotlinSemanticNodeMatch[] = []; for (let cursor = index + 1; cursor < lines.length; cursor += 1) { const candidateLine = stripKotlinLineForSemanticScan(lines[cursor] ?? ''); const branchMatch = candidateLine.match(/^\s*(?!else\b)([A-Za-z0-9_.,\s]+?)\s*->/); if (branchMatch) { const branchName = normalizeKotlinWhenBranchName(branchMatch[1] ?? ''); if (branchName.length > 0) { branchNodes.push({ kind: 'member', name: `branch ${branchName}`, lines: [cursor + 1], }); } } braceDepth += countTokenOccurrences(candidateLine, '{'); braceDepth -= countTokenOccurrences(candidateLine, '}'); if (braceDepth <= 0) { break; } } const [firstBranchNode, secondBranchNode] = branchNodes; if (!firstBranchNode || !secondBranchNode) { continue; } const relatedNodes = [ { kind: 'member' as const, name: `discriminator switch: ${discriminatorName}`, lines: [index + 1], }, ...branchNodes, ]; const allLines = sortedUniqueLines([ ...classLines, index + 1, ...branchNodes.flatMap((node) => [...node.lines]), ]); const branchSummary = branchNodes .map((node) => node.name.replace(/^branch /, '')) .join(', '); return { primary_node: { kind: 'class', name: className, lines: classLines, }, related_nodes: relatedNodes, why: `${className} resuelve comportamiento con un when sobre ${discriminatorName} ` + `(${branchSummary}), obligando a modificar el mismo tipo para soportar un nuevo caso y rompiendo OCP.`, impact: 'Cada nuevo caso exige editar la lógica existente, aumenta el riesgo de regresiones y dificulta extender el comportamiento mediante composición o polimorfismo.', expected_fix: 'Extrae una estrategia, interfaz o registry de handlers por caso para añadir nuevos comportamientos sin modificar la lógica existente.', lines: allLines, }; } return undefined; }; export const findKotlinInterfaceSegregationMatch = ( source: string ): KotlinInterfaceSegregationMatch | undefined => { const typePattern = /^\s*(?:internal\s+|private\s+|public\s+)?(?:sealed\s+|data\s+)?class\s+([A-Za-z0-9_]*(?:UseCase|ViewModel|Presenter|Controller|Coordinator|Service))\b/; const typeLines = collectKotlinRegexLines(source, typePattern); if (typeLines.length === 0) { return undefined; } const typeLine = source.split(/\r?\n/)[typeLines[0] - 1] ?? ''; const typeName = typeLine.match(typePattern)?.[1]; if (!typeName) { return undefined; } const interfaceDeclarations = parseKotlinInterfaceDeclarations(source); if (interfaceDeclarations.length === 0) { return undefined; } const sourceLines = source.split(/\r?\n/); for (const interfaceDeclaration of interfaceDeclarations) { const queryMembers = interfaceDeclaration.members.filter((member) => isKotlinQueryMemberName(member.name) ); const commandMembers = interfaceDeclaration.members.filter((member) => isKotlinCommandMemberName(member.name) ); if (queryMembers.length === 0 || commandMembers.length === 0) { continue; } const propertyPattern = new RegExp( `\\b(?:private|internal|public)?\\s*val\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*:\\s*${interfaceDeclaration.name}\\b` ); const propertyLines = collectKotlinRegexLines(source, propertyPattern); if (propertyLines.length === 0) { continue; } const propertyLine = sourceLines[propertyLines[0] - 1] ?? ''; const propertyName = propertyLine.match(propertyPattern)?.[1]; if (!propertyName) { continue; } const usedMembers = new Map(); const memberUsagePattern = new RegExp( `\\b${propertyName}\\.([A-Za-z_][A-Za-z0-9_]*)\\s*(?:\\(|\\b)`, 'g' ); sourceLines.forEach((line, index) => { const sanitizedLine = stripKotlinLineForSemanticScan(line); for (const match of sanitizedLine.matchAll(memberUsagePattern)) { const memberName = match[1]; if (!memberName) { continue; } const existingLines = usedMembers.get(memberName) ?? []; existingLines.push(index + 1); usedMembers.set(memberName, existingLines); } }); const usedMemberNames = [...usedMembers.keys()]; if (usedMemberNames.length === 0) { continue; } const usesQueryContract = usedMemberNames.some(isKotlinQueryMemberName); const usesCommandContract = usedMemberNames.some(isKotlinCommandMemberName); if (usesQueryContract === usesCommandContract) { continue; } const oppositeFamilyMembers = usesQueryContract ? commandMembers : queryMembers; const unusedMembers = oppositeFamilyMembers.filter((member) => !usedMembers.has(member.name)); const firstUnusedMember = unusedMembers[0]; if (!firstUnusedMember) { continue; } const usedDescriptors = Array.from(usedMembers.entries()).map(([name, lines]) => ({ kind: 'call' as const, name: `used member: ${name}`, lines: sortedUniqueLines(lines), })); const relatedNodes: KotlinSemanticNodeMatch[] = [ { kind: 'member', name: `fat interface: ${interfaceDeclaration.name}`, lines: [interfaceDeclaration.line], }, ...usedDescriptors, ...unusedMembers.map((member) => ({ kind: 'member' as const, name: `unused contract member: ${member.name}`, lines: [member.line], })), ]; const usedMemberSummary = usedDescriptors.map((member) => member.name.replace('used member: ', '')); const unusedSummary = unusedMembers.map((member) => member.name); const allLines = sortedUniqueLines([ ...typeLines, interfaceDeclaration.line, ...relatedNodes.flatMap((node) => [...node.lines]), ]); return { primary_node: { kind: 'class', name: typeName, lines: typeLines, }, related_nodes: relatedNodes, why: `${typeName} depende de ${interfaceDeclaration.name}, un contrato demasiado ancho para su ` + `uso real del consumidor, y rompe ISP al acoplarlo a miembros que no necesita.`, impact: `El consumidor queda acoplado a cambios ajenos del contrato (${unusedSummary.join(', ')}), ` + 'lo que eleva el coste de mantenimiento y dificulta tests y evolución independiente.', expected_fix: `Extrae interfaces pequeñas orientadas a capacidades concretas (${usedMemberSummary.join(', ')}) ` + 'o inyecta un puerto mínimo que exponga solo los miembros realmente necesarios.', lines: allLines, }; } return undefined; }; export const findKotlinLiskovSubstitutionMatch = ( source: string ): KotlinLiskovSubstitutionMatch | undefined => { const interfaceDeclarations = parseKotlinInterfaceDeclarations(source); if (interfaceDeclarations.length === 0) { return undefined; } const typeDeclarations = parseKotlinTypeDeclarations(source); const sourceLines = source.split(/\r?\n/); for (const interfaceDeclaration of interfaceDeclarations) { const memberNames = interfaceDeclaration.members.map((member) => member.name); if (memberNames.length === 0) { continue; } const conformingTypes = typeDeclarations.filter((typeDeclaration) => typeDeclaration.conformances.includes(interfaceDeclaration.name) ); for (const memberName of memberNames) { let safeType: KotlinTypeDeclaration | undefined; let unsafeType: | (KotlinTypeDeclaration & { narrowedPreconditionLine: number; failureLine: number; failureName: string; }) | undefined; for (const typeDeclaration of conformingTypes) { const methodPattern = new RegExp(`\\boverride\\s+fun\\s+${memberName}\\s*\\(`); let methodLine = -1; for ( let lineIndex = typeDeclaration.bodyStartLine - 1; lineIndex < typeDeclaration.bodyEndLine; lineIndex += 1 ) { const candidateLine = stripKotlinLineForSemanticScan(sourceLines[lineIndex] ?? ''); if (methodPattern.test(candidateLine)) { methodLine = lineIndex + 1; break; } } if (methodLine < 0) { continue; } let methodBraceDepth = countTokenOccurrences(stripKotlinLineForSemanticScan(sourceLines[methodLine - 1] ?? ''), '{') - countTokenOccurrences(stripKotlinLineForSemanticScan(sourceLines[methodLine - 1] ?? ''), '}'); let narrowedPreconditionLine: number | undefined; let failureLine: number | undefined; let failureName: string | undefined; for ( let lineIndex = methodLine; lineIndex < typeDeclaration.bodyEndLine && methodBraceDepth > 0; lineIndex += 1 ) { const candidateLine = stripKotlinLineForSemanticScan(sourceLines[lineIndex] ?? ''); if ( narrowedPreconditionLine === undefined && /\b(?:require|check)\s*\(/.test(candidateLine) ) { narrowedPreconditionLine = lineIndex + 1; } if (failureLine === undefined) { const failureMatch = candidateLine.match( /\b(error|TODO)\s*\(|\bthrow\s+(UnsupportedOperationException|NotImplementedError|IllegalStateException)\b/ ); if (failureMatch) { failureLine = lineIndex + 1; failureName = failureMatch[1] ?? failureMatch[2] ?? 'throw'; } } methodBraceDepth += countTokenOccurrences(candidateLine, '{'); methodBraceDepth -= countTokenOccurrences(candidateLine, '}'); } if ( narrowedPreconditionLine !== undefined && failureLine !== undefined && failureName !== undefined ) { unsafeType = { ...typeDeclaration, narrowedPreconditionLine, failureLine, failureName, }; } else if (!safeType) { safeType = typeDeclaration; } } if (!safeType || !unsafeType) { continue; } const allLines = sortedUniqueLines([ interfaceDeclaration.line, safeType.line, unsafeType.line, unsafeType.narrowedPreconditionLine, unsafeType.failureLine, ]); return { primary_node: { kind: 'class', name: unsafeType.name, lines: [unsafeType.line], }, related_nodes: [ { kind: 'member', name: `base contract: ${interfaceDeclaration.name}`, lines: [interfaceDeclaration.line], }, { kind: 'member', name: `safe substitute: ${safeType.name}`, lines: [safeType.line], }, { kind: 'member', name: `narrowed precondition: ${memberName}`, lines: [unsafeType.narrowedPreconditionLine], }, { kind: 'call', name: unsafeType.failureName, lines: [unsafeType.failureLine], }, ], why: `${unsafeType.name} endurece la precondición de ${memberName} y añade una ruta no segura ` + `frente al contrato ${interfaceDeclaration.name}, rompiendo LSP porque deja de ser sustituible por el subtipo base.`, impact: 'Los consumidores del contrato base pueden romperse en runtime, introducir ramas especiales o sufrir regresiones cuando reciben el subtipo inseguro.', expected_fix: 'Haz que el subtipo respete el contrato base sin endurecer precondiciones ni rutas no soportadas, o separa el comportamiento incompatible en otra estrategia o abstracción.', lines: allLines, }; } } return undefined; }; const legacyFingerprintApiPattern = /\b(?:android\.hardware\.fingerprint\.)?FingerprintManager(?:Compat)?\b/; export const hasAndroidLegacyFingerprintApiUsage = (source: string): boolean => scanCodeLikeSource(source, ({ source: scannedSource, index }) => { const match = scannedSource.slice(index).match(legacyFingerprintApiPattern); return match?.index === 0; }); export const collectAndroidLegacyFingerprintApiLines = ( source: string ): readonly number[] => { const lines: number[] = []; source.split(/\r?\n/).forEach((line, index) => { const sanitized = stripKotlinLineForSemanticScan(line); if (legacyFingerprintApiPattern.test(sanitized)) { lines.push(index + 1); } }); return sortedUniqueLines(lines); };