import { hasIdentifierAt, isIdentifierCharacter, nextNonWhitespaceIndex, prevNonWhitespaceIndex, readIdentifierBackward, scanCodeLikeSource, } from './utils'; import { getIosSwiftUiModernizationEntry } from './iosSwiftUiModernizationSnapshot'; export type SwiftSemanticNodeMatch = { kind: 'class' | 'property' | 'call' | 'member'; name: string; lines: readonly number[]; }; export type SwiftIOSCanary001Match = { primary_node: SwiftSemanticNodeMatch; related_nodes: readonly SwiftSemanticNodeMatch[]; why: string; impact: string; expected_fix: string; lines: readonly number[]; }; export type SwiftPresentationSrpMatch = { primary_node: SwiftSemanticNodeMatch; related_nodes: readonly SwiftSemanticNodeMatch[]; why: string; impact: string; expected_fix: string; lines: readonly number[]; }; export type SwiftConcreteDependencyDipMatch = { primary_node: SwiftSemanticNodeMatch; related_nodes: readonly SwiftSemanticNodeMatch[]; why: string; impact: string; expected_fix: string; lines: readonly number[]; }; export type SwiftOpenClosedSwitchMatch = { primary_node: SwiftSemanticNodeMatch; related_nodes: readonly SwiftSemanticNodeMatch[]; why: string; impact: string; expected_fix: string; lines: readonly number[]; }; export type SwiftInterfaceSegregationMatch = { primary_node: SwiftSemanticNodeMatch; related_nodes: readonly SwiftSemanticNodeMatch[]; why: string; impact: string; expected_fix: string; lines: readonly number[]; }; export type SwiftLiskovSubstitutionMatch = { primary_node: SwiftSemanticNodeMatch; related_nodes: readonly SwiftSemanticNodeMatch[]; why: string; impact: string; expected_fix: string; lines: readonly number[]; }; const stripSwiftLineForSemanticScan = (line: string): string => { return line .replace(/\/\/.*$/, '') .replace(/"(?:\\.|[^"\\])*"/g, '""'); }; const collectSwiftRegexLines = (source: string, regex: RegExp): readonly number[] => { const matches: number[] = []; source.split(/\r?\n/).forEach((line, index) => { const sanitized = stripSwiftLineForSemanticScan(line); regex.lastIndex = 0; if (regex.test(sanitized)) { matches.push(index + 1); } }); return matches; }; const sanitizeSwiftSourceForMultilineRegex = (source: string): string => { return source .replace(/\/\*[\s\S]*?\*\//g, ' ') .replace(/\/\/.*$/gm, '') .replace(/"(?:\\.|[^"\\])*"/g, '""'); }; const hasSwiftSanitizedRegexMatch = (source: string, regex: RegExp): boolean => { regex.lastIndex = 0; return regex.test(sanitizeSwiftSourceForMultilineRegex(source)); }; const stripSwiftStringLiterals = (line: string): string => { return line.replace(/"(?:\\.|[^"\\])*"/g, '""'); }; export const hasSwiftProductionCommentUsage = (source: string): boolean => { return source.split(/\r?\n/).some((rawLine) => { const line = stripSwiftStringLiterals(rawLine); return /(^|[^:])\/\/|\/\*/.test(line); }); }; export const collectSwiftWarningSuppressionLines = (source: string): readonly number[] => { const lines: number[] = []; source.split(/\r?\n/).forEach((rawLine, index) => { const line = stripSwiftStringLiterals(rawLine); if ( /^\s*#warning\s*\(/.test(line) || /\/\/\s*(?:swiftlint|swiftformat|periphery)\s*:\s*disable(?::|\b)/i.test(line) ) { lines.push(index + 1); } }); return sortedUniqueLines(lines); }; export const hasSwiftWarningSuppressionUsage = (source: string): boolean => { return collectSwiftWarningSuppressionLines(source).length > 0; }; export const collectSwiftCombineSinkWithoutStoreLines = (source: string): readonly number[] => { const lines = source.split(/\r?\n/); const matches: number[] = []; for (let index = 0; index < lines.length; index += 1) { const line = stripSwiftLineForSemanticScan(lines[index] ?? ''); if (!/\.(?:sink|assign)\s*(?:\(|\{)/.test(line)) { continue; } const chain = lines .slice(index, Math.min(lines.length, index + 8)) .map((candidate) => stripSwiftLineForSemanticScan(candidate)) .join('\n'); if (!/\.store\s*\(\s*in\s*:\s*&[A-Za-z_][A-Za-z0-9_]*\s*\)/.test(chain)) { matches.push(index + 1); } } return sortedUniqueLines(matches); }; export const hasSwiftCombineSinkWithoutStoreUsage = (source: string): boolean => { return collectSwiftCombineSinkWithoutStoreLines(source).length > 0; }; export const hasSwiftProductionTestDoubleUsage = (source: string): boolean => { return hasSwiftSanitizedRegexMatch( source, /\b(?:class|struct|enum|actor)\s+(?:Mock|Fake|Spy|Stub)[A-Za-z0-9_]*\b|\b(?:Mock|Fake|Spy|Stub)[A-Za-z0-9_]*\s*\(/g ); }; export const hasSwiftUnownedSelfCaptureUsage = (source: string): boolean => { return hasSwiftSanitizedRegexMatch( source, /\[\s*unowned\s+(?:self|[A-Za-z_][A-Za-z0-9_]*)\s*\]/g ); }; export const collectSwiftManualMemoryManagementLines = (source: string): readonly number[] => { return sortedUniqueLines([ ...collectSwiftRegexLines(source, /\bUnmanaged\s* { return collectSwiftManualMemoryManagementLines(source).length > 0; }; const swiftPascalCaseTypeNamePattern = /^[A-Z][A-Za-z0-9]*$/; export const collectSwiftNonPascalCaseTypeDeclarationLines = (source: string): readonly number[] => { const matches: number[] = []; source.split(/\r?\n/).forEach((rawLine, index) => { const line = stripSwiftLineForSemanticScan(rawLine); const declarationMatches = line.matchAll( /\b(?:class|struct|enum|actor|protocol)\s+([A-Za-z_][A-Za-z0-9_]*)\b/g ); for (const match of declarationMatches) { const typeName = match[1]; if (typeName && !swiftPascalCaseTypeNamePattern.test(typeName)) { matches.push(index + 1); break; } } }); return sortedUniqueLines(matches); }; export const hasSwiftNonPascalCaseTypeDeclarationUsage = (source: string): boolean => { return collectSwiftNonPascalCaseTypeDeclarationLines(source).length > 0; }; export const collectSwiftEndpointEnumLines = (source: string): readonly number[] => { const matches: number[] = []; const endpointEnumPattern = /\benum\s+(?:APIEndpoint|[A-Za-z_][A-Za-z0-9_]*(?:Endpoint|Endpoints))\b/; source.split(/\r?\n/).forEach((rawLine, index) => { const line = stripSwiftLineForSemanticScan(rawLine); if (endpointEnumPattern.test(line)) { matches.push(index + 1); } }); return sortedUniqueLines(matches); }; export const hasSwiftEndpointEnumUsage = (source: string): boolean => { return collectSwiftEndpointEnumLines(source).length > 0; }; const collectSwiftMethodBodyLines = ( source: string, methodPattern: RegExp, bodyMatcher: (line: string) => boolean ): readonly number[] => { const lines = source.split(/\r?\n/); const matches: number[] = []; for (let index = 0; index < lines.length; index += 1) { const declarationLine = stripSwiftLineForSemanticScan(lines[index] ?? ''); methodPattern.lastIndex = 0; if (!methodPattern.test(declarationLine)) { continue; } let braceDepth = countTokenOccurrences(declarationLine, '{') - countTokenOccurrences(declarationLine, '}'); const bodyStart = index + 1; const bodyEnd = lines.length; let hasReuse = /\.dequeueReusableCell\s*\(/.test(declarationLine); const localMatches: number[] = []; for (let cursor = bodyStart; cursor < bodyEnd; cursor += 1) { const line = stripSwiftLineForSemanticScan(lines[cursor] ?? ''); if (/\.dequeueReusableCell\s*\(/.test(line)) { hasReuse = true; } if (bodyMatcher(line)) { localMatches.push(cursor + 1); } braceDepth += countTokenOccurrences(line, '{'); braceDepth -= countTokenOccurrences(line, '}'); if (braceDepth <= 0) { break; } } if (!hasReuse) { matches.push(...localMatches); } } return sortedUniqueLines(matches); }; export const collectSwiftCellCreationWithoutReuseLines = (source: string): readonly number[] => { return sortedUniqueLines([ ...collectSwiftMethodBodyLines( source, /\bfunc\s+tableView\s*\([^)]*\bcellForRowAt\b[^)]*\)\s*->\s*UITableViewCell\b/, (line) => /\bUITableViewCell\s*\(/.test(line) ), ...collectSwiftMethodBodyLines( source, /\bfunc\s+collectionView\s*\([^)]*\bcellForItemAt\b[^)]*\)\s*->\s*UICollectionViewCell\b/, (line) => /\bUICollectionViewCell\s*\(/.test(line) ), ]); }; export const hasSwiftCellCreationWithoutReuseUsage = (source: string): boolean => { return collectSwiftCellCreationWithoutReuseLines(source).length > 0; }; export const hasSwiftNestedIfPyramidUsage = (source: string): boolean => { const lines = source.split(/\r?\n/); const ifBraceDepths: number[] = []; let braceDepth = 0; for (const rawLine of lines) { const line = stripSwiftLineForSemanticScan(rawLine); const ifMatches = Array.from(line.matchAll(/\bif\b[^{]*\{/g)); for (const _match of ifMatches) { if (ifBraceDepths.length >= 2) { return true; } ifBraceDepths.push(braceDepth + 1); } braceDepth += countTokenOccurrences(line, '{'); braceDepth -= countTokenOccurrences(line, '}'); while (ifBraceDepths.length > 0 && braceDepth < (ifBraceDepths[ifBraceDepths.length - 1] ?? 0)) { ifBraceDepths.pop(); } } return false; }; const hasSwiftUiModernizationSnapshotMatch = (source: string, entryId: string): boolean => { const entry = getIosSwiftUiModernizationEntry(entryId); if (!entry) { return false; } return hasSwiftSanitizedRegexMatch(source, new RegExp(entry.match.pattern, 'g')); }; 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 countTokenOccurrences = (line: string, token: string): number => { let count = 0; let currentIndex = line.indexOf(token); while (currentIndex >= 0) { count += 1; currentIndex = line.indexOf(token, currentIndex + token.length); } return count; }; const escapeRegex = (value: string): string => { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }; type SwiftProtocolMember = { name: string; line: number; }; type SwiftProtocolDeclaration = { name: string; line: number; members: readonly SwiftProtocolMember[]; }; type SwiftTypeDeclaration = { name: string; line: number; conformances: readonly string[]; bodyStartLine: number; bodyEndLine: number; }; type SwiftResponsibilityMatch = { key: string; node: SwiftSemanticNodeMatch; }; const swiftQueryMemberNamePattern = /^(get|find|list|fetch|read|load|restore|refresh|is|has|can)/i; const swiftCommandMemberNamePattern = /^(create|update|delete|remove|save|insert|upsert|set|write|persist|clear|reset|sync|store)/i; const registerSwiftResponsibility = ( nodes: SwiftResponsibilityMatch[], key: string, kind: SwiftSemanticNodeMatch['kind'], name: string, lines: readonly number[] ): void => { if (lines.length === 0) { return; } nodes.push({ key, node: { kind, name, lines } }); }; const hasSwiftResponsibilityKeys = ( nodes: readonly SwiftResponsibilityMatch[], keys: readonly string[] ): boolean => { const observedKeys = new Set(nodes.map((node) => node.key)); return keys.every((key) => observedKeys.has(key)); }; const isSwiftQueryMemberName = (name: string): boolean => swiftQueryMemberNamePattern.test(name); const isSwiftCommandMemberName = (name: string): boolean => swiftCommandMemberNamePattern.test(name); const parseSwiftProtocolDeclarations = (source: string): readonly SwiftProtocolDeclaration[] => { const lines = source.split(/\r?\n/); const declarations: SwiftProtocolDeclaration[] = []; for (let index = 0; index < lines.length; index += 1) { const sanitizedLine = stripSwiftLineForSemanticScan(lines[index] ?? ''); const protocolMatch = sanitizedLine.match(/\bprotocol\s+([A-Za-z_][A-Za-z0-9_]*)\b/); if (!protocolMatch) { continue; } const protocolName = protocolMatch[1]; if (!protocolName) { continue; } let braceDepth = countTokenOccurrences(sanitizedLine, '{') - countTokenOccurrences(sanitizedLine, '}'); const members: SwiftProtocolMember[] = []; for (let cursor = index + 1; cursor < lines.length; cursor += 1) { const candidateLine = stripSwiftLineForSemanticScan(lines[cursor] ?? ''); const funcMatches = candidateLine.matchAll(/\bfunc\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(/g); for (const match of funcMatches) { const memberName = match[1]; if (memberName) { members.push({ name: memberName, line: cursor + 1 }); } } const varMatches = candidateLine.matchAll(/\bvar\s+([A-Za-z_][A-Za-z0-9_]*)\b/g); for (const match of varMatches) { const memberName = match[1]; if (memberName) { members.push({ name: memberName, line: cursor + 1 }); } } braceDepth += countTokenOccurrences(candidateLine, '{'); braceDepth -= countTokenOccurrences(candidateLine, '}'); if (braceDepth <= 0) { break; } } declarations.push({ name: protocolName, line: index + 1, members, }); } return declarations; }; const visitSwiftTopLevelTypeBodyLines = ( sourceLines: readonly string[], typeDeclaration: SwiftTypeDeclaration, visitor: (params: { line: string; lineNumber: number }) => boolean ): boolean => { const declarationLine = stripSwiftLineForSemanticScan( sourceLines[typeDeclaration.bodyStartLine - 1] ?? '' ); let braceDepth = countTokenOccurrences(declarationLine, '{') - countTokenOccurrences(declarationLine, '}'); for ( let lineIndex = typeDeclaration.bodyStartLine; lineIndex < typeDeclaration.bodyEndLine && braceDepth > 0; lineIndex += 1 ) { const line = stripSwiftLineForSemanticScan(sourceLines[lineIndex] ?? ''); if (braceDepth === 1 && visitor({ line, lineNumber: lineIndex + 1 })) { return true; } braceDepth += countTokenOccurrences(line, '{'); braceDepth -= countTokenOccurrences(line, '}'); } return false; }; const buildSwiftManagedObjectTypePatternSource = (source: string): string => { const patternParts = new Set(['NSManagedObject\\b(?!ID\\b|Context\\b)']); for (const typeDeclaration of parseSwiftTypeDeclarations(source)) { if (typeDeclaration.conformances.includes('NSManagedObject')) { patternParts.add(`${escapeRegex(typeDeclaration.name)}\\b`); } } return Array.from(patternParts).join('|'); }; const parseSwiftTypeDeclarations = (source: string): readonly SwiftTypeDeclaration[] => { const lines = source.split(/\r?\n/); const declarations: SwiftTypeDeclaration[] = []; for (let index = 0; index < lines.length; index += 1) { const sanitizedLine = stripSwiftLineForSemanticScan(lines[index] ?? ''); const typeMatch = sanitizedLine.match( /\b(?:final\s+)?(?:class|struct)\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*([A-Za-z0-9_,\s]+)\s*\{/ ); if (!typeMatch) { continue; } const typeName = typeMatch[1]; const rawConformances = typeMatch[2]; if (!typeName || !rawConformances) { continue; } let braceDepth = countTokenOccurrences(sanitizedLine, '{') - countTokenOccurrences(sanitizedLine, '}'); let bodyEndLine = index + 1; for (let cursor = index + 1; cursor < lines.length; cursor += 1) { const candidateLine = stripSwiftLineForSemanticScan(lines[cursor] ?? ''); braceDepth += countTokenOccurrences(candidateLine, '{'); braceDepth -= countTokenOccurrences(candidateLine, '}'); bodyEndLine = cursor + 1; if (braceDepth <= 0) { break; } } declarations.push({ name: typeName, line: index + 1, conformances: rawConformances .split(',') .map((entry) => entry.trim()) .filter((entry) => entry.length > 0), bodyStartLine: index + 1, bodyEndLine, }); } return declarations; }; const isLikelySwiftTypeAnnotation = (source: string, identifierStart: number): boolean => { if (identifierStart <= 0) { return false; } const before = prevNonWhitespaceIndex(source, identifierStart - 1); return before >= 0 && source[before] === ':'; }; const isForceUnwrapAt = (source: string, index: number): boolean => { const previousIndex = prevNonWhitespaceIndex(source, index - 1); if (previousIndex < 0) { return false; } const nextIndex = nextNonWhitespaceIndex(source, index + 1); if (nextIndex >= 0 && (source[nextIndex] === '=' || source[nextIndex] === '!')) { return false; } const previousChar = source[previousIndex]; const previousIdentifier = readIdentifierBackward(source, previousIndex); if (previousIdentifier.value === 'as' || previousIdentifier.value === 'try') { return false; } if ( previousIdentifier.start >= 0 && isLikelySwiftTypeAnnotation(source, previousIdentifier.start) ) { return false; } const isPostfixToken = isIdentifierCharacter(previousChar) || previousChar === ')' || previousChar === ']' || previousChar === '}'; if (!isPostfixToken) { return false; } return true; }; export const hasSwiftForceUnwrap = (source: string): boolean => { return scanCodeLikeSource(source, ({ source: swiftSource, index, current }) => { return current === '!' && isForceUnwrapAt(swiftSource, index); }); }; export const collectSwiftForceUnwrapLines = (source: string): readonly number[] => { const lines: number[] = []; source.split(/\r?\n/).forEach((line, index) => { const sanitizedLine = stripSwiftLineForSemanticScan(line); for (let cursor = 0; cursor < sanitizedLine.length; cursor += 1) { if (sanitizedLine[cursor] === '!' && isForceUnwrapAt(sanitizedLine, cursor)) { lines.push(index + 1); break; } } }); return sortedUniqueLines(lines); }; export const hasSwiftAnyViewUsage = (source: string): boolean => { return scanCodeLikeSource(source, ({ source: swiftSource, index, current }) => { if (current !== 'A') { return false; } return hasIdentifierAt(swiftSource, index, 'AnyView'); }); }; export const collectSwiftAnyViewLines = (source: string): readonly number[] => { return sortedUniqueLines(collectSwiftRegexLines(source, /\bAnyView\b/)); }; export const hasSwiftAnyTypeErasureUsage = (source: string): boolean => { return collectSwiftAnyTypeErasureLines(source).length > 0; }; export const collectSwiftAnyTypeErasureLines = (source: string): readonly number[] => { return sortedUniqueLines([ ...collectSwiftRegexLines( source, /:\s*(?:Any\b|AnyObject\b|AnyHashable\b|\[\s*Any\s*\]|\[[^\]\n]+:\s*Any\s*\]|Dictionary\s*<\s*[^,\n>]+,\s*Any\s*>)/ ), ...collectSwiftRegexLines(source, /\bas\s+(?:Any|AnyObject|AnyHashable)\b/), ]); }; export const collectSwiftCallbackStyleSignatureLines = (source: string): readonly number[] => { return sortedUniqueLines( collectSwiftRegexLines( source, /\b(?:completion|completionHandler|callback|handler)\s*:\s*(?:@[A-Za-z_][A-Za-z0-9_]*(?:\([^)]*\))?\s+)*@escaping\b/ ) ); }; export const hasSwiftNonLazyScrollForEachUsage = (source: string): boolean => { const swiftSource = sanitizeSwiftSourceForMultilineRegex(source); const nonLazyScrollableCollectionPattern = /\bScrollView\s*(?:\([^)]*\))?\s*\{[\s\S]{0,2000}\b(?:VStack|HStack)\s*(?:\([^)]*\))?\s*\{[\s\S]{0,1200}\bForEach\s*\(/; return nonLazyScrollableCollectionPattern.test(swiftSource); }; export const collectSwiftNonLazyScrollForEachLines = (source: string): readonly number[] => { if (!hasSwiftNonLazyScrollForEachUsage(source)) { return []; } return sortedUniqueLines([ ...collectSwiftRegexLines(source, /\bScrollView\s*(?:\([^)]*\))?\s*\{/), ...collectSwiftRegexLines(source, /\bForEach\s*\(/), ]); }; const findMatchingSwiftParen = (source: string, openParenIndex: number): number => { let depth = 0; for (let index = openParenIndex; index < source.length; index += 1) { const current = source[index]; if (current === '(') { depth += 1; } else if (current === ')') { depth -= 1; if (depth === 0) { return index; } } } return -1; }; const getLineNumberAtIndex = (source: string, targetIndex: number): number => { let lineNumber = 1; for (let index = 0; index < targetIndex && index < source.length; index += 1) { if (source[index] === '\n') { lineNumber += 1; } } return lineNumber; }; const collectSwiftUiForEachDirectConditionalLines = (source: string): readonly number[] => { const sanitized = sanitizeSwiftSourceForMultilineRegex(source); const lines: number[] = []; const forEachPattern = /\bForEach\s*\(/g; for (const match of sanitized.matchAll(forEachPattern)) { const matchIndex = match.index ?? 0; const openParenIndex = sanitized.indexOf('(', matchIndex); if (openParenIndex < 0) { continue; } const closeParenIndex = findMatchingSwiftParen(sanitized, openParenIndex); if (closeParenIndex < 0) { continue; } const openBraceIndex = sanitized.indexOf('{', closeParenIndex); if (openBraceIndex < 0) { continue; } const closeBraceIndex = findMatchingSwiftBrace(sanitized, openBraceIndex); if (closeBraceIndex < 0) { continue; } const body = sanitized.slice(openBraceIndex + 1, closeBraceIndex); let depth = 0; let hasDirectConditional = false; const conditionalPattern = /\b(?:if|switch)\b/g; for (const conditionalMatch of body.matchAll(conditionalPattern)) { const conditionalIndex = conditionalMatch.index ?? 0; for (let index = 0; index < conditionalIndex; index += 1) { const current = body[index]; if (current === '{') { depth += 1; } else if (current === '}') { depth = Math.max(0, depth - 1); } } if (depth === 0) { lines.push(getLineNumberAtIndex(sanitized, matchIndex)); lines.push(getLineNumberAtIndex(sanitized, openBraceIndex + 1 + conditionalIndex)); hasDirectConditional = true; break; } depth = 0; } if (!hasDirectConditional) { continue; } } return sortedUniqueLines(lines); }; export const hasSwiftUiForEachConditionalViewCountUsage = (source: string): boolean => { return collectSwiftUiForEachDirectConditionalLines(source).length > 0; }; export const collectSwiftUiForEachConditionalViewCountLines = ( source: string ): readonly number[] => { return collectSwiftUiForEachDirectConditionalLines(source); }; export const hasSwiftViewBodyObjectCreationUsage = (source: string): boolean => { const swiftSource = sanitizeSwiftSourceForMultilineRegex(source); const viewBodyObjectCreationPattern = /\bvar\s+body\s*:\s*some\s+View\s*\{[\s\S]{0,2400}\b(?:DateFormatter|NumberFormatter|RelativeDateTimeFormatter|ISO8601DateFormatter|ByteCountFormatter|MeasurementFormatter|DateComponentsFormatter)\s*\(/; return viewBodyObjectCreationPattern.test(swiftSource); }; export const hasSwiftUiImageDataDecodingUsage = (source: string): boolean => { const swiftSource = sanitizeSwiftSourceForMultilineRegex(source); return /\bUIImage\s*\(\s*data\s*:/.test(swiftSource); }; export const collectSwiftUiManualRenderingWithoutImageRendererLines = (source: string): readonly number[] => { const swiftSource = sanitizeSwiftSourceForMultilineRegex(source); if (/\bImageRenderer\s*\(/.test(swiftSource)) { return []; } const hasHostedSwiftUiView = /\bUIHostingController\s*\(\s*rootView\s*:/.test(swiftSource); const hasManualRenderer = /\bUIGraphicsImageRenderer\s*\(/.test(swiftSource) || /\.drawHierarchy\s*\(/.test(swiftSource) || /\.layer\s*\.\s*render\s*\(/.test(swiftSource); if (!hasHostedSwiftUiView || !hasManualRenderer) { return []; } return sortedUniqueLines([ ...collectSwiftRegexLines(source, /\bUIHostingController\s*\(\s*rootView\s*:/), ...collectSwiftRegexLines(source, /\bUIGraphicsImageRenderer\s*\(/), ...collectSwiftRegexLines(source, /\.drawHierarchy\s*\(/), ...collectSwiftRegexLines(source, /\.layer\s*\.\s*render\s*\(/), ]); }; export const hasSwiftUiManualRenderingWithoutImageRendererUsage = (source: string): boolean => { return collectSwiftUiManualRenderingWithoutImageRendererLines(source).length > 0; }; export const collectSwiftLargeViewBuilderFunctionLines = (source: string): readonly number[] => { const lines = source.split(/\r?\n/); const matches: number[] = []; for (let index = 0; index < lines.length; index += 1) { const annotationLine = stripSwiftLineForSemanticScan(lines[index] ?? ''); if (!/@ViewBuilder\b/.test(annotationLine)) { continue; } let functionLineIndex = -1; for (let candidateIndex = index; candidateIndex < Math.min(lines.length, index + 4); candidateIndex += 1) { const candidate = stripSwiftLineForSemanticScan(lines[candidateIndex] ?? ''); if (/\bfunc\s+[A-Za-z_][A-Za-z0-9_]*\s*\(/.test(candidate)) { functionLineIndex = candidateIndex; break; } } if (functionLineIndex < 0) { continue; } let braceDepth = 0; let bodyStarted = false; let bodyLineCount = 0; for (let bodyIndex = functionLineIndex; bodyIndex < lines.length; bodyIndex += 1) { const line = stripSwiftLineForSemanticScan(lines[bodyIndex] ?? ''); if (!bodyStarted && line.includes('{')) { bodyStarted = true; } else if (bodyStarted) { const bodyOnly = line.replace(/[{}]/g, '').trim(); if (bodyOnly.length > 0) { bodyLineCount += 1; } } braceDepth += countTokenOccurrences(line, '{'); braceDepth -= countTokenOccurrences(line, '}'); if (bodyStarted && braceDepth <= 0) { break; } } if (bodyLineCount > 12) { matches.push(index + 1, functionLineIndex + 1); } } return sortedUniqueLines(matches); }; export const hasSwiftLargeViewBuilderFunctionUsage = (source: string): boolean => { return collectSwiftLargeViewBuilderFunctionLines(source).length > 0; }; const swiftInlineActionLogicPattern = /\b(?:if|guard|switch|for|while|Task)\b/; const swiftSnippetContainsInlineActionLogic = (snippet: string): boolean => { return swiftInlineActionLogicPattern.test(snippet); }; export const collectSwiftUiInlineActionLogicLines = (source: string): readonly number[] => { const swiftSource = sanitizeSwiftSourceForMultilineRegex(source); const matches: number[] = []; const inlineButtonActionPattern = /\bButton\s*\{(?[\s\S]{0,900}?)\}\s*label\s*:/g; const inlineActionParameterPattern = /\bButton\s*\([^)]*\baction\s*:\s*\{(?[\s\S]{0,900}?)\}/g; for (const pattern of [inlineButtonActionPattern, inlineActionParameterPattern]) { for (const match of swiftSource.matchAll(pattern)) { const body = match.groups?.body ?? ''; if (!swiftSnippetContainsInlineActionLogic(body)) { continue; } matches.push(getLineNumberAtIndex(swiftSource, match.index ?? 0)); } } return sortedUniqueLines(matches); }; export const hasSwiftUiInlineActionLogicUsage = (source: string): boolean => { return collectSwiftUiInlineActionLogicLines(source).length > 0; }; export const collectSwiftAnimationWithoutReduceMotionLines = (source: string): readonly number[] => { const swiftSource = sanitizeSwiftSourceForMultilineRegex(source); if (/\baccessibilityReduceMotion\b|\bUIAccessibility\s*\.\s*isReduceMotionEnabled\b/.test(swiftSource)) { return []; } return sortedUniqueLines([ ...collectSwiftRegexLines(source, /\bwithAnimation\s*(?:\(|\{)/), ...collectSwiftRegexLines(source, /\.animation\s*\(/), ]); }; export const hasSwiftAnimationWithoutReduceMotionUsage = (source: string): boolean => { return collectSwiftAnimationWithoutReduceMotionLines(source).length > 0; }; export const hasSwiftDispatchQueueUsage = (source: string): boolean => { return scanCodeLikeSource(source, ({ source: swiftSource, index, current }) => { if (current !== 'D' || !hasIdentifierAt(swiftSource, index, 'DispatchQueue')) { return false; } const dotIndex = nextNonWhitespaceIndex(swiftSource, index + 'DispatchQueue'.length); return dotIndex >= 0 && swiftSource[dotIndex] === '.'; }); }; export const collectSwiftDispatchQueueLines = (source: string): readonly number[] => { return sortedUniqueLines(collectSwiftRegexLines(source, /\bDispatchQueue\s*\./)); }; export const hasSwiftDispatchGroupUsage = (source: string): boolean => { return scanCodeLikeSource(source, ({ source: swiftSource, index, current }) => { if (current !== 'D') { return false; } return hasIdentifierAt(swiftSource, index, 'DispatchGroup'); }); }; export const collectSwiftDispatchGroupLines = (source: string): readonly number[] => { return sortedUniqueLines(collectSwiftRegexLines(source, /\bDispatchGroup\b/)); }; export const hasSwiftDispatchSemaphoreUsage = (source: string): boolean => { return scanCodeLikeSource(source, ({ source: swiftSource, index, current }) => { if (current !== 'D') { return false; } return hasIdentifierAt(swiftSource, index, 'DispatchSemaphore'); }); }; export const collectSwiftDispatchSemaphoreLines = (source: string): readonly number[] => { return sortedUniqueLines(collectSwiftRegexLines(source, /\bDispatchSemaphore\b/)); }; export const hasSwiftOperationQueueUsage = (source: string): boolean => { return scanCodeLikeSource(source, ({ source: swiftSource, index, current }) => { if (current !== 'O') { return false; } return hasIdentifierAt(swiftSource, index, 'OperationQueue'); }); }; export const hasSwiftTaskDetachedUsage = (source: string): boolean => { return scanCodeLikeSource(source, ({ source: swiftSource, index, current }) => { if (current !== 'T' || !hasIdentifierAt(swiftSource, index, 'Task')) { return false; } const dotIndex = nextNonWhitespaceIndex(swiftSource, index + 'Task'.length); if (dotIndex < 0 || swiftSource[dotIndex] !== '.') { return false; } const detachedIndex = nextNonWhitespaceIndex(swiftSource, dotIndex + 1); return detachedIndex >= 0 && hasIdentifierAt(swiftSource, detachedIndex, 'detached'); }); }; const swiftCancellationCheckPattern = /\bTask\s*\.\s*isCancelled\b|\bTask\s*\.\s*checkCancellation\s*\(/; const swiftLoopPattern = /\b(?:for\s+[A-Za-z_][A-Za-z0-9_]*\s+in|while\s+|repeat\b)/; export const collectSwiftLongAsyncOperationWithoutCancellationCheckLines = ( source: string ): readonly number[] => { const lines = source.split(/\r?\n/); const matches: number[] = []; for (let index = 0; index < lines.length; index += 1) { const declarationLine = stripSwiftLineForSemanticScan(lines[index] ?? ''); if (!/\bfunc\s+[A-Za-z_][A-Za-z0-9_]*[\s\S]*\basync\b/.test(declarationLine)) { continue; } let braceDepth = countTokenOccurrences(declarationLine, '{') - countTokenOccurrences(declarationLine, '}'); if (braceDepth <= 0 && !declarationLine.includes('{')) { continue; } const localLoopLines: number[] = []; let hasCancellationCheck = swiftCancellationCheckPattern.test(declarationLine); for (let cursor = index + 1; cursor < lines.length; cursor += 1) { const line = stripSwiftLineForSemanticScan(lines[cursor] ?? ''); if (swiftCancellationCheckPattern.test(line)) { hasCancellationCheck = true; } if (swiftLoopPattern.test(line)) { localLoopLines.push(cursor + 1); } braceDepth += countTokenOccurrences(line, '{'); braceDepth -= countTokenOccurrences(line, '}'); if (braceDepth <= 0) { break; } } if (!hasCancellationCheck && localLoopLines.length > 0) { matches.push(...localLoopLines); } } return sortedUniqueLines(matches); }; export const hasSwiftLongAsyncOperationWithoutCancellationCheckUsage = ( source: string ): boolean => { return collectSwiftLongAsyncOperationWithoutCancellationCheckLines(source).length > 0; }; const findMatchingSwiftBrace = (source: string, openBraceIndex: number): number => { let depth = 0; for (let index = openBraceIndex; index < source.length; index += 1) { const current = source[index]; if (current === '{') { depth += 1; } else if (current === '}') { depth -= 1; if (depth === 0) { return index; } } } return -1; }; export const hasSwiftAsyncWithoutAwaitUsage = (source: string): boolean => { const sanitized = sanitizeSwiftSourceForMultilineRegex(source); const privateAsyncFunctionPattern = /\bprivate\s+(?:static\s+|class\s+)?func\s+[A-Za-z_][A-Za-z0-9_]*[^{};]*\basync\b[^{};]*\{/g; for (const match of sanitized.matchAll(privateAsyncFunctionPattern)) { const header = match[0] ?? ''; if (/\boverride\b|\bprotocol\b/.test(header)) { continue; } const openBraceIndex = (match.index ?? 0) + header.length - 1; const closeBraceIndex = findMatchingSwiftBrace(sanitized, openBraceIndex); if (closeBraceIndex < 0) { continue; } const body = sanitized.slice(openBraceIndex + 1, closeBraceIndex); if (!/\bawait\b/.test(body)) { return true; } } return false; }; export const collectSwiftDummyAwaitLines = (source: string): readonly number[] => { return sortedUniqueLines([ ...collectSwiftRegexLines(source, /\bawait\s+Task\s*\.\s*yield\s*\(\s*\)/), ...collectSwiftRegexLines( source, /\bawait\s+Task\s*\.\s*sleep\s*\([^)]*(?:nanoseconds\s*:\s*0\b|for\s*:\s*\.(?:zero|seconds\s*\(\s*0\s*\)))/, ), ]); }; export const hasSwiftDummyAwaitUsage = (source: string): boolean => { return collectSwiftDummyAwaitLines(source).length > 0; }; export const hasSwiftEmptyCatchUsage = (source: string): boolean => { const sanitized = sanitizeSwiftSourceForMultilineRegex(source); return /\bcatch(?:\s+(?:let|var)\s+[A-Za-z_][A-Za-z0-9_]*)?\s*\{\s*\}/.test(sanitized); }; export const collectSwiftNSErrorThrowLines = (source: string): readonly number[] => { return collectSwiftRegexLines(source, /\bthrow\s+NSError\s*\(/g); }; export const hasSwiftNSErrorThrowUsage = (source: string): boolean => { return collectSwiftNSErrorThrowLines(source).length > 0; }; export const collectSwiftPackageBranchDependencyLines = (source: string): readonly number[] => { const matches: number[] = []; source.split(/\r?\n/).forEach((line, index) => { const sanitized = line .replace(/"(?:\\.|[^"\\])*"/g, '""') .replace(/\/\/.*$/, ''); if (/\.\s*package\s*\([^)]*\bbranch\s*:/.test(sanitized)) { matches.push(index + 1); } }); return matches; }; export const hasSwiftPackageBranchDependencyUsage = (source: string): boolean => { return collectSwiftPackageBranchDependencyLines(source).length > 0; }; export const collectSwiftPackageToolsVersionBelow62Lines = (source: string): readonly number[] => { const lines: number[] = []; source.split(/\r?\n/).forEach((line, index) => { const match = line.match(/^\s*\/\/\s*swift-tools-version\s*:\s*(\d+)(?:\.(\d+))?/); if (!match) { return; } const major = Number.parseInt(match[1] ?? '0', 10); const minor = Number.parseInt(match[2] ?? '0', 10); if (major < 6 || (major === 6 && minor < 2)) { lines.push(index + 1); } }); return lines; }; export const hasSwiftPackageToolsVersionBelow62Usage = (source: string): boolean => { return collectSwiftPackageToolsVersionBelow62Lines(source).length > 0; }; export const collectSwiftPackageDefaultIsolationNotMainActorLines = ( source: string ): readonly number[] => { const lines: number[] = []; source.split(/\r?\n/).forEach((rawLine, index) => { const line = stripSwiftLineForSemanticScan(rawLine); const match = /\.defaultIsolation\s*\(\s*([A-Za-z_][A-Za-z0-9_.]*)\s*\)/.exec(line); if (!match) { return; } const value = (match[1] ?? '').trim().toLowerCase(); if (value !== 'mainactor' && value !== 'mainactor.self') { lines.push(index + 1); } }); return sortedUniqueLines(lines); }; export const hasSwiftPackageDefaultIsolationNotMainActorUsage = (source: string): boolean => { return collectSwiftPackageDefaultIsolationNotMainActorLines(source).length > 0; }; export const collectSwiftPackageStrictConcurrencyBelowCompleteLines = ( source: string ): readonly number[] => { const lines: number[] = []; source.split(/\r?\n/).forEach((rawLine, index) => { const line = rawLine.trim(); if (line.startsWith('//')) { return; } const match = /\.enable(?:Experimental|Upcoming)Feature\s*\(\s*"StrictConcurrency\s*=\s*(minimal|targeted)"\s*\)/i.exec( line ); if (match) { lines.push(index + 1); } }); return sortedUniqueLines(lines); }; export const hasSwiftPackageStrictConcurrencyBelowCompleteUsage = (source: string): boolean => { return collectSwiftPackageStrictConcurrencyBelowCompleteLines(source).length > 0; }; export const collectSwiftStrictConcurrencyBelowCompleteLines = (source: string): readonly number[] => { const lines: number[] = []; source.split(/\r?\n/).forEach((rawLine, index) => { const line = stripSwiftLineForSemanticScan(rawLine); const match = /\bSWIFT_STRICT_CONCURRENCY\s*=\s*([A-Za-z_][A-Za-z0-9_]*)\s*;?/.exec(line); if (!match) { return; } const value = (match[1] ?? '').trim().toLowerCase(); if (value === 'minimal' || value === 'targeted') { lines.push(index + 1); } }); return sortedUniqueLines(lines); }; export const hasSwiftStrictConcurrencyBelowCompleteUsage = (source: string): boolean => { return collectSwiftStrictConcurrencyBelowCompleteLines(source).length > 0; }; export const collectSwiftDefaultActorIsolationNotMainActorLines = ( source: string ): readonly number[] => { const lines: number[] = []; source.split(/\r?\n/).forEach((rawLine, index) => { const line = stripSwiftLineForSemanticScan(rawLine); const match = /\bSWIFT_DEFAULT_ACTOR_ISOLATION\s*=\s*([A-Za-z_][A-Za-z0-9_.]*)\s*;?/.exec( line ); if (!match) { return; } const value = (match[1] ?? '').trim().toLowerCase(); if (value !== 'mainactor' && value !== 'mainactor.self') { lines.push(index + 1); } }); return sortedUniqueLines(lines); }; export const hasSwiftDefaultActorIsolationNotMainActorUsage = (source: string): boolean => { return collectSwiftDefaultActorIsolationNotMainActorLines(source).length > 0; }; export const collectSwiftUpcomingFeatureDisabledLines = (source: string): readonly number[] => { const lines: number[] = []; source.split(/\r?\n/).forEach((rawLine, index) => { const line = stripSwiftLineForSemanticScan(rawLine); const upcomingFeatureMatch = /\bSWIFT_UPCOMING_FEATURE_[A-Za-z0-9_]+\s*=\s*([A-Za-z0-9_]+)\s*;?/.exec(line); if (upcomingFeatureMatch) { const value = (upcomingFeatureMatch[1] ?? '').trim().toLowerCase(); if (value === 'no' || value === 'false' || value === '0') { lines.push(index + 1); } return; } const experimentalFeaturesMatch = /\bSWIFT_ENABLE_EXPERIMENTAL_FEATURES\s*=\s*(.*?)\s*;?$/.exec(line); if (!experimentalFeaturesMatch) { return; } const value = (experimentalFeaturesMatch[1] ?? '').trim().toLowerCase(); if (value === '' || value === 'no' || value === 'false' || value === '0') { lines.push(index + 1); } }); return sortedUniqueLines(lines); }; export const hasSwiftUpcomingFeatureDisabledUsage = (source: string): boolean => { return collectSwiftUpcomingFeatureDisabledLines(source).length > 0; }; const swiftUiStateOwnerDeclarationPattern = /\b(?:final\s+)?(?:class|struct)\s+([A-Za-z_][A-Za-z0-9_]*(?:ViewModel|Presenter|Store))\b/; const hasSwiftMainActorAnnotationNear = ( lines: readonly string[], declarationIndex: number ): boolean => { const start = Math.max(0, declarationIndex - 3); const context = lines .slice(start, declarationIndex + 1) .map((line) => stripSwiftLineForSemanticScan(line)) .join('\n'); return /@MainActor\b/.test(context); }; const hasSwiftObservableUiStateEvidence = ( lines: readonly string[], declarationIndex: number ): boolean => { const annotationStart = Math.max(0, declarationIndex - 2); const end = Math.min(lines.length, declarationIndex + 60); const annotationContext = lines .slice(annotationStart, declarationIndex + 1) .map((line) => stripSwiftLineForSemanticScan(line)) .join('\n'); const bodyContext = lines .slice(declarationIndex, end) .map((line) => stripSwiftLineForSemanticScan(line)) .join('\n'); return ( /@Observable\b/.test(annotationContext) || /\bObservableObject\b/.test(bodyContext) || /@Published\b/.test(bodyContext) ); }; export const collectSwiftUiStateWithoutMainActorLines = (source: string): readonly number[] => { const matches: number[] = []; const lines = source.split(/\r?\n/); lines.forEach((rawLine, index) => { const line = stripSwiftLineForSemanticScan(rawLine); if (!swiftUiStateOwnerDeclarationPattern.test(line)) { return; } if (hasSwiftMainActorAnnotationNear(lines, index)) { return; } if (!hasSwiftObservableUiStateEvidence(lines, index)) { return; } matches.push(index + 1); }); return sortedUniqueLines(matches); }; export const hasSwiftUiStateWithoutMainActorUsage = (source: string): boolean => { return collectSwiftUiStateWithoutMainActorLines(source).length > 0; }; const swiftSharedStateOwnerDeclarationPattern = /\b(?:final\s+)?class\s+([A-Za-z_][A-Za-z0-9_]*(?:Cache|Manager|Session|Store|Repository))\b/; const findSwiftDeclarationBlockEnd = (lines: readonly string[], declarationIndex: number): number => { let depth = 0; let started = false; for (let index = declarationIndex; index < lines.length; index += 1) { const line = stripSwiftLineForSemanticScan(lines[index] ?? ''); for (const char of line) { if (char === '{') { depth += 1; started = true; } else if (char === '}') { depth -= 1; if (started && depth <= 0) { return index + 1; } } } } return Math.min(lines.length, declarationIndex + 80); }; const hasSwiftSharedMutableStateBody = (body: string): boolean => { return ( /\b(?:private\s+|fileprivate\s+|internal\s+|public\s+|open\s+)?var\s+[A-Za-z_][A-Za-z0-9_]*\b/.test( body ) && /\bfunc\s+[A-Za-z_][A-Za-z0-9_]*\s*\(/.test(body) && !/@(?:Observable|Published)\b/.test(body) && !/\bObservableObject\b/.test(body) ); }; export const collectSwiftSharedMutableStateWithoutActorLines = ( source: string ): readonly number[] => { const matches: number[] = []; const lines = source.split(/\r?\n/); lines.forEach((rawLine, index) => { const line = stripSwiftLineForSemanticScan(rawLine); if (!swiftSharedStateOwnerDeclarationPattern.test(line)) { return; } if (hasSwiftMainActorAnnotationNear(lines, index)) { return; } const blockEnd = findSwiftDeclarationBlockEnd(lines, index); const body = lines .slice(index, blockEnd) .map((candidate) => stripSwiftLineForSemanticScan(candidate)) .join('\n'); if (!hasSwiftSharedMutableStateBody(body)) { return; } matches.push(index + 1); }); return sortedUniqueLines(matches); }; export const hasSwiftSharedMutableStateWithoutActorUsage = (source: string): boolean => { return collectSwiftSharedMutableStateWithoutActorLines(source).length > 0; }; const swiftActorPatchOwnerDeclarationPattern = /\b(?:final\s+)?(?:class|struct)\s+([A-Za-z_][A-Za-z0-9_]*(?:ViewModel|Presenter|Store|Manager))\b/; const findNearestSwiftActorPatchOwnerIndex = ( lines: readonly string[], usageIndex: number ): number | undefined => { const start = Math.max(0, usageIndex - 80); for (let index = usageIndex; index >= start; index -= 1) { const line = stripSwiftLineForSemanticScan(lines[index] ?? ''); if (swiftActorPatchOwnerDeclarationPattern.test(line)) { return index; } if (/^\s*(?:actor|enum|protocol)\s+[A-Za-z_][A-Za-z0-9_]*\b/.test(line)) { return undefined; } } return undefined; }; export const collectSwiftMainActorRunPatchLines = (source: string): readonly number[] => { const matches: number[] = []; const lines = source.split(/\r?\n/); lines.forEach((rawLine, index) => { const line = stripSwiftLineForSemanticScan(rawLine); if (!/\bMainActor\s*\.\s*run\s*(?:\(|\{)/.test(line)) { return; } const ownerIndex = findNearestSwiftActorPatchOwnerIndex(lines, index); if (ownerIndex === undefined) { return; } if (hasSwiftMainActorAnnotationNear(lines, ownerIndex)) { return; } matches.push(index + 1); }); return sortedUniqueLines(matches); }; export const hasSwiftMainActorRunPatchUsage = (source: string): boolean => { return collectSwiftMainActorRunPatchLines(source).length > 0; }; const swiftNavigationPathRestorationPattern = /@(?:SceneStorage|AppStorage)\b|\bCodableRepresentation\b|\.codable\b|\b(?:restore|rehydrate|persist|save|load)[A-Za-z0-9_]*(?:Navigation)?Path\b/i; export const collectSwiftNavigationPathWithoutRestorationLines = ( source: string ): readonly number[] => { const lines = collectSwiftRegexLines(source, /\bNavigationPath\s*\(/); if (lines.length === 0) { return []; } const sanitized = sanitizeSwiftSourceForMultilineRegex(source); if (swiftNavigationPathRestorationPattern.test(sanitized)) { return []; } return sortedUniqueLines(lines); }; export const hasSwiftNavigationPathWithoutRestorationUsage = (source: string): boolean => { return collectSwiftNavigationPathWithoutRestorationLines(source).length > 0; }; export const hasSwiftOnAppearTaskUsage = (source: string): boolean => { return collectSwiftOnAppearTaskLines(source).length > 0; }; export const hasSwiftOnChangeTaskUsage = (source: string): boolean => { return collectSwiftOnChangeTaskLines(source).length > 0; }; const collectSwiftLifecycleTaskLines = ( source: string, lifecyclePattern: RegExp ): readonly number[] => { const lines: number[] = []; let insideLifecycle = false; let braceDepth = 0; source.split(/\r?\n/).forEach((rawLine, index) => { const line = stripSwiftLineForSemanticScan(rawLine); if (!insideLifecycle && lifecyclePattern.test(line)) { insideLifecycle = true; braceDepth = 0; } if (insideLifecycle && /\bTask\s*(?:\([^)]*\))?\s*\{/.test(line)) { lines.push(index + 1); } if (insideLifecycle) { const opened = (line.match(/\{/g) ?? []).length; const closed = (line.match(/\}/g) ?? []).length; braceDepth += opened - closed; if (braceDepth <= 0) { insideLifecycle = false; } } }); return sortedUniqueLines(lines); }; export const collectSwiftOnAppearTaskLines = (source: string): readonly number[] => collectSwiftLifecycleTaskLines(source, /\.onAppear\s*\{/); export const collectSwiftOnChangeTaskLines = (source: string): readonly number[] => collectSwiftLifecycleTaskLines(source, /\.onChange\s*\([^)]*\)\s*\{/); export const collectSwiftOnChangeReadonlyVarLines = (source: string): readonly number[] => { const lines: number[] = []; let insideOnChange = false; let braceDepth = 0; source.split(/\r?\n/).forEach((line, index) => { const sanitizedLine = stripSwiftLineForSemanticScan(line); if (!insideOnChange && /\.onChange\s*\([^)]*\)\s*\{/.test(sanitizedLine)) { insideOnChange = true; braceDepth = 0; } if (insideOnChange && /\bvar\s+[A-Za-z_][A-Za-z0-9_]*\s*=/.test(sanitizedLine)) { lines.push(index + 1); } if (insideOnChange) { const opened = (sanitizedLine.match(/\{/g) ?? []).length; const closed = (sanitizedLine.match(/\}/g) ?? []).length; braceDepth += opened - closed; if (braceDepth <= 0) { insideOnChange = false; } } }); return sortedUniqueLines(lines); }; export const hasSwiftOnChangeReadonlyVarUsage = (source: string): boolean => { return collectSwiftOnChangeReadonlyVarLines(source).length > 0; }; export const hasSwiftStrongDelegateReferenceUsage = (source: string): boolean => { const delegatePropertyPattern = /\b(?:var|let)\s+(?:[A-Za-z_][A-Za-z0-9_]*(?:Delegate|DataSource)|delegate|dataSource)\s*:\s*(?:any\s+)?[A-Za-z_][A-Za-z0-9_]*(?:Delegate|DataSource)\b/; return source.split(/\r?\n/).some((line) => { const sanitizedLine = stripSwiftLineForSemanticScan(line); if (!delegatePropertyPattern.test(sanitizedLine)) { return false; } return !/\bweak\s+var\b/.test(sanitizedLine); }); }; export const collectSwiftStrongDelegateReferenceLines = (source: string): readonly number[] => { const delegatePropertyPattern = /\b(?:var|let)\s+(?:[A-Za-z_][A-Za-z0-9_]*(?:Delegate|DataSource)|delegate|dataSource)\s*:\s*(?:any\s+)?[A-Za-z_][A-Za-z0-9_]*(?:Delegate|DataSource)\b/; const lines: number[] = []; source.split(/\r?\n/).forEach((line, index) => { const sanitizedLine = stripSwiftLineForSemanticScan(line); if (!delegatePropertyPattern.test(sanitizedLine)) { return; } if (/\bweak\s+var\b/.test(sanitizedLine)) { return; } lines.push(index + 1); }); return sortedUniqueLines(lines); }; const swiftStrongSelfEscapingClosurePatterns = [ /\bTask\s*(?:\([^)]*\))?\s*\{/g, /\bDispatchQueue\s*\.\s*[A-Za-z0-9_.]+\s*\.\s*async(?:After)?\s*(?:\([^)]*\))?\s*\{/g, /\bTimer\s*\.\s*scheduledTimer\s*\([\s\S]{0,320}?\)\s*\{/g, /\bNotificationCenter\s*\.\s*default\s*\.\s*addObserver\s*\([\s\S]{0,420}?\busing\s*:\s*\{/g, /\bNotificationCenter\s*\.\s*default\s*\.\s*addObserver\s*\([\s\S]{0,420}?\)\s*\{/g, /\.\s*sink\s*\([\s\S]{0,420}?\b(?:receiveValue|receiveCompletion)\s*:\s*\{/g, /\.\s*sink\s*\{/g, /\.\s*handleEvents\s*\([\s\S]{0,420}?\b(?:receiveOutput|receiveCompletion|receiveCancel)\s*:\s*\{/g, ]; const findMatchingSwiftBraceIndex = (source: string, openingBraceIndex: number): number => { let depth = 0; for (let index = openingBraceIndex; index < source.length; index += 1) { const char = source[index]; if (char === '{') { depth += 1; continue; } if (char !== '}') { continue; } depth -= 1; if (depth === 0) { return index; } } return -1; }; const hasWeakOrUnownedSelfCaptureList = (closureBody: string): boolean => { const trimmedStart = closureBody.trimStart(); if (!trimmedStart.startsWith('[')) { return false; } const captureListEndIndex = trimmedStart.indexOf(']'); if (captureListEndIndex < 0) { return false; } const captureList = trimmedStart.slice(1, captureListEndIndex); return /\b(?:weak|unowned)\s+self\b/.test(captureList); }; export const hasSwiftStrongSelfEscapingClosureUsage = (source: string): boolean => { const sanitized = sanitizeSwiftSourceForMultilineRegex(source); for (const pattern of swiftStrongSelfEscapingClosurePatterns) { pattern.lastIndex = 0; for (const match of sanitized.matchAll(pattern)) { const matchedSource = match[0] ?? ''; const openingBraceOffset = matchedSource.lastIndexOf('{'); if (openingBraceOffset < 0 || match.index === undefined) { continue; } const openingBraceIndex = match.index + openingBraceOffset; const closingBraceIndex = findMatchingSwiftBraceIndex(sanitized, openingBraceIndex); if (closingBraceIndex < 0) { continue; } const closureBody = sanitized.slice(openingBraceIndex + 1, closingBraceIndex); if (hasWeakOrUnownedSelfCaptureList(closureBody)) { continue; } if (/\bself\s*\./.test(closureBody)) { return true; } } } return false; }; const toSwiftLineNumberAtOffset = (source: string, offset: number): number => source.slice(0, Math.max(0, offset)).split(/\r?\n/).length; export const collectSwiftStrongSelfEscapingClosureLines = (source: string): readonly number[] => { const sanitized = sanitizeSwiftSourceForMultilineRegex(source); const lines: number[] = []; for (const pattern of swiftStrongSelfEscapingClosurePatterns) { pattern.lastIndex = 0; for (const match of sanitized.matchAll(pattern)) { const matchedSource = match[0] ?? ''; const openingBraceOffset = matchedSource.lastIndexOf('{'); if (openingBraceOffset < 0 || match.index === undefined) { continue; } const openingBraceIndex = match.index + openingBraceOffset; const closingBraceIndex = findMatchingSwiftBraceIndex(sanitized, openingBraceIndex); if (closingBraceIndex < 0) { continue; } const closureBodyStartIndex = openingBraceIndex + 1; const closureBody = sanitized.slice(closureBodyStartIndex, closingBraceIndex); if (hasWeakOrUnownedSelfCaptureList(closureBody)) { continue; } const strongSelfMatches = closureBody.matchAll(/\bself\s*\./g); for (const strongSelfMatch of strongSelfMatches) { if (strongSelfMatch.index === undefined) { continue; } lines.push(toSwiftLineNumberAtOffset(sanitized, closureBodyStartIndex + strongSelfMatch.index)); } } } return sortedUniqueLines(lines); }; export const hasSwiftCustomSingletonUsage = (source: string): boolean => { const singletonDeclarationPattern = /^\s*(?:(?:private|fileprivate|internal|public|open)\s+)?static\s+(?:let|var)\s+shared\b(?:\s*:\s*[A-Za-z_][A-Za-z0-9_.<>]*)?\s*=/; return source.split(/\r?\n/).some((line) => { const sanitizedLine = stripSwiftLineForSemanticScan(line); return singletonDeclarationPattern.test(sanitizedLine); }); }; export const hasSwiftSwinjectUsage = (source: string): boolean => { return hasSwiftSanitizedRegexMatch( source, /\bimport\s+Swinject\b|\b(?:Container|Assembler)\s*\(/ ); }; export const collectSwiftComposableArchitectureUsageLines = (source: string): readonly number[] => { return sortedUniqueLines([ ...collectSwiftRegexLines(source, /\bimport\s+ComposableArchitecture\b/), ...collectSwiftRegexLines(source, /\b(?:StoreOf|ViewStore|WithViewStore|ReducerOf)\s* { return collectSwiftComposableArchitectureUsageLines(source).length > 0; }; export const hasSwiftMassiveViewControllerResponsibilityUsage = (source: string): boolean => { const sanitized = sanitizeSwiftSourceForMultilineRegex(source); const viewControllerPattern = /\bclass\s+[A-Za-z_][A-Za-z0-9_]*\s*:\s*(?:[A-Za-z_][A-Za-z0-9_]*\s*,\s*)*UIViewController\b[\s\S]{0,8000}?\n\}/g; const infrastructurePattern = /\b(?:URLSession\s*\.\s*shared|JSONSerialization|UserDefaults\s*\.\s*standard|NSManagedObjectContext|NSPersistentContainer|NSFetchRequest|FileManager\s*\.\s*default)\b/; for (const match of sanitized.matchAll(viewControllerPattern)) { const body = match[0] ?? ''; if (infrastructurePattern.test(body)) { return true; } } return false; }; export const hasSwiftNonIBOutletImplicitlyUnwrappedOptionalUsage = (source: string): boolean => { const lines = source.split(/\r?\n/); const implicitlyUnwrappedPropertyPattern = /\b(?:var|let)\s+[A-Za-z_][A-Za-z0-9_]*\s*:\s*(?:[A-Za-z_][A-Za-z0-9_.<>?]*\s*)!\s*(?:[=,{]|$)/; return lines.some((line, index) => { const sanitizedLine = stripSwiftLineForSemanticScan(line); if (!implicitlyUnwrappedPropertyPattern.test(sanitizedLine)) { return false; } const previousLine = index > 0 ? stripSwiftLineForSemanticScan(lines[index - 1] ?? '') : ''; return !/\B@IBOutlet\b/.test(`${previousLine} ${sanitizedLine}`); }); }; export const hasSwiftMagicNumberLayoutUsage = (source: string): boolean => { const swiftUiLayoutNumberPattern = /(?:\b(?:VStack|HStack|ZStack|LazyVStack|LazyHStack)\s*\([^)]*\bspacing\s*:\s*|\.(?:padding|frame|offset|position|shadow|blur)\s*\([^)]*(?:\b(?:width|height|spacing|radius|x|y)\s*:\s*)?)\b(?:[3-9]|[1-9][0-9]+)(?:\.[0-9]+)?\b/; return collectSwiftRegexLines(source, swiftUiLayoutNumberPattern).length > 0; }; export const collectSwiftMagicNumberLayoutLines = (source: string): readonly number[] => { const swiftUiLayoutNumberPattern = /(?:\b(?:VStack|HStack|ZStack|LazyVStack|LazyHStack)\s*\([^)]*\bspacing\s*:\s*|\.(?:padding|frame|offset|position|shadow|blur)\s*\([^)]*(?:\b(?:width|height|spacing|radius|x|y)\s*:\s*)?)\b(?:[3-9]|[1-9][0-9]+)(?:\.[0-9]+)?\b/; return collectSwiftRegexLines(source, swiftUiLayoutNumberPattern); }; export const hasSwiftAdHocLoggingUsage = (source: string): boolean => { return collectSwiftAdHocLoggingLines(source).length > 0; }; export const collectSwiftAdHocLoggingLines = (source: string): readonly number[] => { return collectSwiftRegexLines( source, /\b(?:print|debugPrint|dump|NSLog|os_log)\s*\(/ ); }; export const hasSwiftSensitiveLoggingUsage = (source: string): boolean => { return collectSwiftSensitiveLoggingLines(source).length > 0; }; export const collectSwiftSensitiveLoggingLines = (source: string): readonly number[] => { const lines: number[] = []; source.split(/\r?\n/).forEach((line, index) => { const sanitized = stripSwiftLineForSemanticScan(line); const lineWithoutComments = line.replace(/\/\/.*$/, ''); const hasLoggingCall = /\b(?:print|debugPrint|dump|NSLog|os_log)\s*\(/.test(sanitized) || /\b(?:logger|log)\s*\.\s*(?:debug|info|notice|warning|error|critical|log)\s*\(/i.test( sanitized ); if (!hasLoggingCall) { return; } if (/\b(?:accessToken|refreshToken|authToken|token|password|secret|credential|authorization|email|userId)\b/i.test( lineWithoutComments )) { lines.push(index + 1); } }); return sortedUniqueLines(lines); }; const swiftHardcodedSensitiveStringPattern = /\b(?:(?:private|fileprivate|internal|public|open|static|class|final|lazy)\s+)*(?:let|var)\s+(?=[A-Za-z_])[A-Za-z0-9_]*(?:token|secret|password|apikey|clientsecret|privatekey|sessionid)[A-Za-z0-9_]*\s*(?::\s*String\s*)?=\s*"((?:\\.|[^"\\])*)"/i; export const collectSwiftHardcodedSensitiveStringLines = (source: string): readonly number[] => { const matches: number[] = []; source.split(/\r?\n/).forEach((rawLine, index) => { const line = rawLine.replace(/\/\/.*$/, ''); const match = swiftHardcodedSensitiveStringPattern.exec(line); const literalValue = match?.[1] ?? ''; if (literalValue.length > 0) { matches.push(index + 1); } }); return matches; }; export const hasSwiftHardcodedSensitiveStringUsage = (source: string): boolean => { return collectSwiftHardcodedSensitiveStringLines(source).length > 0; }; export const hasSwiftUnlocalizedDateFormatterUsage = (source: string): boolean => { const sanitizedSource = sanitizeSwiftSourceForMultilineRegex(source); const formatterDeclarations = sanitizedSource.matchAll( /\b(?:let|var)\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*DateFormatter\s*\(\s*\)/g ); for (const match of formatterDeclarations) { const formatterName = match[1]; if (!formatterName) { continue; } const formatterPattern = escapeRegex(formatterName); const hasFixedDateFormat = new RegExp(`\\b${formatterPattern}\\s*\\.\\s*dateFormat\\s*=`).test( sanitizedSource ); if (!hasFixedDateFormat) { continue; } const hasExplicitLocale = new RegExp(`\\b${formatterPattern}\\s*\\.\\s*locale\\s*=`).test( sanitizedSource ); if (!hasExplicitLocale) { return true; } } return false; }; export const collectSwiftAlamofireLines = (source: string): readonly number[] => { return sortedUniqueLines([ ...collectSwiftRegexLines(source, /^\s*import\s+Alamofire\b/), ...collectSwiftRegexLines(source, /\b(?:AF|Alamofire)\s*\.\s*request\b/), ]); }; export const hasSwiftAlamofireUsage = (source: string): boolean => { return collectSwiftAlamofireLines(source).length > 0; }; export const collectSwiftJSONSerializationLines = (source: string): readonly number[] => { return collectSwiftRegexLines(source, /\bJSONSerialization\s*\./); }; export const hasSwiftJSONSerializationUsage = (source: string): boolean => { return collectSwiftJSONSerializationLines(source).length > 0; }; export const hasSwiftSensitiveUserDefaultsStorageUsage = (source: string): boolean => { return collectSwiftSensitiveUserDefaultsStorageLines(source).length > 0; }; export const collectSwiftSensitiveUserDefaultsStorageLines = (source: string): readonly number[] => { const lines: number[] = []; source.split(/\r?\n/).forEach((line, index) => { const sanitized = stripSwiftLineForSemanticScan(line); const lineWithoutComments = line.replace(/\/\/.*$/, ''); const hasUserDefaultsWrite = /\bUserDefaults\s*\.\s*standard\s*\.\s*set\s*\(/.test(sanitized); const hasAppStorage = /@\s*AppStorage\s*\(/.test(sanitized); if (!hasUserDefaultsWrite && !hasAppStorage) { return; } if (/\b(?:accessToken|refreshToken|authToken|token|password|secret|credential|authorization|bearer|apiKey|sessionId)\b/i.test( lineWithoutComments )) { lines.push(index + 1); } }); return sortedUniqueLines(lines); }; export const hasSwiftInsecureTransportUsage = (source: string): boolean => { return collectSwiftInsecureTransportLines(source).length > 0; }; export const collectSwiftInsecureTransportLines = (source: string): readonly number[] => { const lines = source.split(/\r?\n/); const result: number[] = []; let pendingAtsKeyLine: number | null = null; lines.forEach((line, index) => { const lineNumber = index + 1; const sanitized = stripSwiftLineForSemanticScan(line); if (!/^\s*\/\//.test(line) && /["']http:\/\/[^"']*["']/.test(line)) { result.push(lineNumber); } if (/\bNSAllowsArbitraryLoads\b\s*=\s*(?:true|YES|1)\b/i.test(sanitized)) { result.push(lineNumber); pendingAtsKeyLine = null; return; } if (/\s*NSAllowsArbitraryLoads\s*<\/key>/i.test(line)) { pendingAtsKeyLine = lineNumber; return; } if (pendingAtsKeyLine !== null && //i.test(line)) { result.push(pendingAtsKeyLine); pendingAtsKeyLine = null; } }); return sortedUniqueLines(result); }; export const collectSwiftUrlSessionTrustBypassLines = (source: string): readonly number[] => { const result: number[] = []; source.split(/\r?\n/).forEach((line, index) => { const sanitized = stripSwiftLineForSemanticScan(line); if ( /completionHandler\s*\(\s*\.useCredential\s*,\s*URLCredential\s*\(\s*trust\s*:/.test( sanitized ) ) { result.push(index + 1); } }); return sortedUniqueLines(result); }; export const hasSwiftUrlSessionTrustBypassUsage = (source: string): boolean => { return collectSwiftUrlSessionTrustBypassLines(source).length > 0; }; const swiftUiLiteralTextPatterns = [ /\b(?:Text|Button|Label|TextField|SecureField)\s*\(\s*"((?:\\.|[^"\\])*)"/, /\.navigationTitle\s*\(\s*"((?:\\.|[^"\\])*)"/, /\.navigationSubtitle\s*\(\s*"((?:\\.|[^"\\])*)"/, /\.accessibilityLabel\s*\(\s*"((?:\\.|[^"\\])*)"/, ]; const looksLikeLocalizationKey = (value: string): boolean => { return /^[A-Za-z0-9_]+(?:[.-][A-Za-z0-9_]+)+$/.test(value); }; export const hasSwiftHardcodedUiStringUsage = (source: string): boolean => { const withoutBlockComments = source.replace(/\/\*[\s\S]*?\*\//g, '\n'); return withoutBlockComments.split(/\r?\n/).some((line) => { if (/^\s*\/\//.test(line)) { return false; } const withoutInlineComment = line.replace(/\/\/.*$/, ''); return swiftUiLiteralTextPatterns.some((pattern) => { const match = withoutInlineComment.match(pattern); if (!match) { return false; } const literal = match[1]?.trim() ?? ''; if (literal.length === 0) { return false; } return !looksLikeLocalizationKey(literal); }); }); }; export const collectSwiftHardcodedUiStringLines = (source: string): readonly number[] => { const withoutBlockComments = source.replace(/\/\*[\s\S]*?\*\//g, '\n'); const matches: number[] = []; withoutBlockComments.split(/\r?\n/).forEach((line, index) => { if (/^\s*\/\//.test(line)) { return; } const withoutInlineComment = line.replace(/\/\/.*$/, ''); const hasHardcodedUiLiteral = swiftUiLiteralTextPatterns.some((pattern) => { const match = withoutInlineComment.match(pattern); if (!match) { return false; } const literal = match[1]?.trim() ?? ''; if (literal.length === 0) { return false; } return !looksLikeLocalizationKey(literal); }); if (hasHardcodedUiLiteral) { matches.push(index + 1); } }); return matches; }; export const hasSwiftLooseAssetResourceUsage = (source: string): boolean => { const withoutBlockComments = source.replace(/\/\*[\s\S]*?\*\//g, '\n'); return withoutBlockComments.split(/\r?\n/).some((line) => { if (/^\s*\/\//.test(line)) { return false; } const sanitized = stripSwiftLineForSemanticScan(line); return ( /\bUIImage\s*\(\s*contentsOfFile\s*:/.test(sanitized) || /\bNSImage\s*\(\s*contentsOfFile\s*:/.test(sanitized) || /\bBundle\s*\.\s*main\s*\.\s*(?:path|url)\s*\(\s*forResource\s*:\s*""\s*,\s*withExtension\s*:\s*""/.test( sanitized ) ); }); }; export const hasSwiftFixedFontSizeUsage = (source: string): boolean => { const withoutBlockComments = source.replace(/\/\*[\s\S]*?\*\//g, '\n'); return withoutBlockComments.split(/\r?\n/).some((line) => { if (/^\s*\/\//.test(line)) { return false; } const sanitized = stripSwiftLineForSemanticScan(line); return ( /\.\s*font\s*\(\s*\.\s*system\s*\(\s*size\s*:/.test(sanitized) || /\bFont\s*\.\s*system\s*\(\s*size\s*:/.test(sanitized) || /\bUIFont\s*\.\s*(?:systemFont|boldSystemFont|italicSystemFont)\s*\(\s*ofSize\s*:/.test( sanitized ) ); }); }; export const collectSwiftFixedFontSizeLines = (source: string): readonly number[] => { const withoutBlockComments = source.replace(/\/\*[\s\S]*?\*\//g, '\n'); const matches: number[] = []; withoutBlockComments.split(/\r?\n/).forEach((line, index) => { if (/^\s*\/\//.test(line)) { return; } const sanitized = stripSwiftLineForSemanticScan(line); if ( /\.\s*font\s*\(\s*\.\s*system\s*\(\s*size\s*:/.test(sanitized) || /\bFont\s*\.\s*system\s*\(\s*size\s*:/.test(sanitized) || /\bUIFont\s*\.\s*(?:systemFont|boldSystemFont|italicSystemFont)\s*\(\s*ofSize\s*:/.test( sanitized ) ) { matches.push(index + 1); } }); return matches; }; export const hasSwiftPhysicalTextAlignmentUsage = (source: string): boolean => { const withoutBlockComments = source.replace(/\/\*[\s\S]*?\*\//g, '\n'); return withoutBlockComments.split(/\r?\n/).some((line) => { if (/^\s*\/\//.test(line)) { return false; } const sanitized = stripSwiftLineForSemanticScan(line); return ( /\.\s*multilineTextAlignment\s*\(\s*\.\s*(?:left|right)\s*\)/.test(sanitized) || /\.\s*frame\s*\([^)]*alignment\s*:\s*\.\s*(?:left|right)\b/.test(sanitized) || /\bTextAlignment\s*\.\s*(?:left|right)\b/.test(sanitized) || /\bNSTextAlignment\s*\.\s*(?:left|right)\b/.test(sanitized) ); }); }; export const hasSwiftMainThreadBlockingSleepUsage = (source: string): boolean => { const withoutBlockComments = source.replace(/\/\*[\s\S]*?\*\//g, '\n'); return withoutBlockComments.split(/\r?\n/).some((line) => { if (/^\s*\/\//.test(line)) { return false; } const sanitized = stripSwiftLineForSemanticScan(line); return ( /\bThread\s*\.\s*sleep\s*\(/.test(sanitized) || /(^|[^\w.])sleep\s*\(/.test(sanitized) || /(^|[^\w.])usleep\s*\(/.test(sanitized) ); }); }; export const collectSwiftThreadCentricDebuggingLines = (source: string): readonly number[] => { return sortedUniqueLines([ ...collectSwiftRegexLines(source, /\bThread\s*\.\s*(?:current|isMainThread)\b/), ...collectSwiftRegexLines(source, /\bpthread_self\s*\(/), ...collectSwiftRegexLines(source, /\bpthread_mach_thread_np\s*\(/), ]); }; export const hasSwiftThreadCentricDebuggingUsage = (source: string): boolean => { return collectSwiftThreadCentricDebuggingLines(source).length > 0; }; export const hasSwiftIconOnlyControlWithoutAccessibilityLabelUsage = (source: string): boolean => { const sanitized = sanitizeSwiftSourceForMultilineRegex(source); const iconOnlyButtonPattern = /\bButton\s*(?:\([^)]*\))?\s*\{[\s\S]{0,240}?\bImage\s*\(\s*systemName\s*:\s*""\s*\)[\s\S]{0,240}?\}/g; for (const match of sanitized.matchAll(iconOnlyButtonPattern)) { const segment = match[0] ?? ''; const following = sanitized.slice(match.index ?? 0, (match.index ?? 0) + segment.length + 160); if (!/\.\s*accessibilityLabel\s*\(/.test(following)) { return true; } } return false; }; export const collectSwiftIconOnlyControlWithoutAccessibilityLabelLines = ( source: string ): readonly number[] => { if (!hasSwiftIconOnlyControlWithoutAccessibilityLabelUsage(source)) { return []; } return collectSwiftRegexLines(source, /\bButton\s*(?:\(|\{)/); }; export const collectSwiftInteractiveControlWithoutAccessibilityIdentifierLines = ( source: string ): readonly number[] => { const lines = sanitizeSwiftSourceForMultilineRegex(source).split(/\r?\n/); const matches: number[] = []; const interactiveControlPattern = /\b(?:Button|TextField|SecureField|Toggle|NavigationLink)\s*(?:<[^>]+>)?\s*\(/; lines.forEach((line, index) => { if (!interactiveControlPattern.test(line)) { return; } const window = lines.slice(index, Math.min(lines.length, index + 7)).join('\n'); if (!/\.\s*accessibilityIdentifier\s*\(/.test(window)) { matches.push(index + 1); } }); return sortedUniqueLines(matches); }; export const hasSwiftInteractiveControlWithoutAccessibilityIdentifierUsage = ( source: string ): boolean => { return collectSwiftInteractiveControlWithoutAccessibilityIdentifierLines(source).length > 0; }; const collectSwiftBindableMissingForObservableBindingLines = ( source: string ): readonly number[] => { const sanitized = sanitizeSwiftSourceForMultilineRegex(source); if (!/@\s*Observable\b/.test(sanitized)) { return []; } const lines = sanitized.split(/\r?\n/); const bindableDeclarations = new Set(); const plainObservableCandidates = new Map(); lines.forEach((line, index) => { const bindableMatch = line.match(/@\s*Bindable\s+(?:private\s+)?var\s+([A-Za-z_][A-Za-z0-9_]*)\s*:/); if (bindableMatch?.[1]) { bindableDeclarations.add(bindableMatch[1]); } const plainMatch = line.match(/\b(?:let|var)\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*[A-Za-z_][A-Za-z0-9_]*/); if (plainMatch?.[1] && !/@\s*Bindable\b/.test(line)) { plainObservableCandidates.set(plainMatch[1], index + 1); } }); const matches: number[] = []; for (const [name, line] of plainObservableCandidates) { if (bindableDeclarations.has(name)) { continue; } if (sanitized.includes(`$${name}.`)) { matches.push(line); } } return sortedUniqueLines(matches); }; export const collectSwiftBindableMissingForObservableBindingUsageLines = ( source: string ): readonly number[] => { return collectSwiftBindableMissingForObservableBindingLines(source); }; export const hasSwiftBindableMissingForObservableBindingUsage = (source: string): boolean => { return collectSwiftBindableMissingForObservableBindingLines(source).length > 0; }; const allowedCrossFeatureImportModules = new Set([ 'Combine', 'CoreData', 'DesignSystem', 'Foundation', 'LocalAuthentication', 'MapKit', 'Navigation', 'Observation', 'PhotosUI', 'Shared', 'SharedKernel', 'SwiftData', 'SwiftUI', 'Testing', 'UIKit', 'XCTest', ]); const featureNameFromSwiftPath = (path: string): string | undefined => { const normalized = path.replace(/\\/g, '/'); const match = /\/Features\/([^/]+)\//.exec(normalized); return match?.[1]; }; export const collectSwiftCrossFeatureImportLines = ( source: string, path: string ): readonly number[] => { const currentFeature = featureNameFromSwiftPath(path); if (!currentFeature) { return []; } const lines: number[] = []; source.split(/\r?\n/).forEach((rawLine, index) => { const line = stripSwiftLineForSemanticScan(rawLine); const match = /^\s*import\s+(?:@\w+\s+)?([A-Za-z_][A-Za-z0-9_]*)\b/.exec(line); if (!match?.[1]) { return; } const moduleName = match[1]; if (moduleName === currentFeature || allowedCrossFeatureImportModules.has(moduleName)) { return; } if (moduleName.endsWith('Kit') || moduleName.startsWith('Swift')) { return; } lines.push(index + 1); }); return sortedUniqueLines(lines); }; export const hasSwiftCrossFeatureImportUsage = (source: string, path: string): boolean => { return collectSwiftCrossFeatureImportLines(source, path).length > 0; }; type SwiftArchitectureLayer = 'domain' | 'application' | 'presentation' | 'infrastructure'; const forbiddenLayerImports: Record> = { domain: new Set([ 'Alamofire', 'AppKit', 'CloudKit', 'Combine', 'ComposableArchitecture', 'CoreData', 'Firebase', 'GRDB', 'Kingfisher', 'RealmSwift', 'Swinject', 'SwiftData', 'SwiftUI', 'UIKit', 'WatchKit', ]), application: new Set([ 'Alamofire', 'AppKit', 'CloudKit', 'ComposableArchitecture', 'CoreData', 'Firebase', 'GRDB', 'Kingfisher', 'RealmSwift', 'Swinject', 'SwiftData', 'SwiftUI', 'UIKit', 'WatchKit', ]), presentation: new Set([ 'Alamofire', 'CloudKit', 'CoreData', 'Firebase', 'GRDB', 'RealmSwift', 'Swinject', 'SwiftData', ]), infrastructure: new Set([]), }; const swiftArchitectureLayerFromPath = (path: string): SwiftArchitectureLayer | undefined => { const normalized = path.replace(/\\/g, '/').toLowerCase(); if (normalized.includes('/domain/')) { return 'domain'; } if (normalized.includes('/application/') || normalized.includes('/usecases/')) { return 'application'; } if (normalized.includes('/presentation/')) { return 'presentation'; } if (normalized.includes('/infrastructure/')) { return 'infrastructure'; } return undefined; }; const hasForbiddenLayerImportName = ( layer: SwiftArchitectureLayer, moduleName: string ): boolean => { if (forbiddenLayerImports[layer].has(moduleName)) { return true; } if (moduleName.startsWith('Firebase')) { return true; } if (layer === 'domain') { return ( moduleName.includes('Application') || moduleName.includes('Presentation') || moduleName.includes('Infrastructure') ); } if (layer === 'application') { return moduleName.includes('Presentation') || moduleName.includes('Infrastructure'); } if (layer === 'presentation') { return moduleName.includes('Infrastructure'); } return false; }; export const collectSwiftLayerDirectionViolationLines = ( source: string, path: string ): readonly number[] => { const layer = swiftArchitectureLayerFromPath(path); if (!layer) { return []; } if (layer === 'infrastructure') { return []; } const lines: number[] = []; source.split(/\r?\n/).forEach((rawLine, index) => { const line = stripSwiftLineForSemanticScan(rawLine); const match = /^\s*import\s+(?:@\w+\s+)?([A-Za-z_][A-Za-z0-9_]*)\b/.exec(line); if (match?.[1] && hasForbiddenLayerImportName(layer, match[1])) { lines.push(index + 1); } }); return sortedUniqueLines(lines); }; export const hasSwiftLayerDirectionViolationUsage = (source: string, path: string): boolean => { return collectSwiftLayerDirectionViolationLines(source, path).length > 0; }; const isSwiftAppImplementationPath = (path: string): boolean => { const normalized = path.replace(/\\/g, '/').toLowerCase(); if ( !normalized.endsWith('.swift') || /(^|\/)(tests?|uitests?|testsupport|fixtures?|mocks?)(\/|$)/.test(normalized) || /(?:tests?|spec)\.swift$/.test(normalized) ) { return false; } if (normalized.includes('/sources/') || normalized.includes('/public/') || normalized.includes('/exports/')) { return false; } return normalized.includes('/apps/ios/') || normalized.includes('/ios/'); }; export const collectSwiftExcessivePublicApiLines = ( source: string, path: string ): readonly number[] => { if (!isSwiftAppImplementationPath(path)) { return []; } const lines: number[] = []; source.split(/\r?\n/).forEach((rawLine, index) => { const line = stripSwiftLineForSemanticScan(rawLine); if ( /^\s*(?:@(?:MainActor|Observable|objc|objcMembers|available|Published|ViewBuilder|discardableResult)[^\n]*\s*)*(?:public|open)\s+(?:(?:final|static|class|override|mutating|nonisolated)\s+)*(?:class|struct|enum|actor|protocol|extension|func|var|let|subscript|init)\b/.test(line) ) { lines.push(index + 1); } }); return lines.length >= 3 ? sortedUniqueLines(lines) : []; }; export const hasSwiftExcessivePublicApiUsage = (source: string, path: string): boolean => { return collectSwiftExcessivePublicApiLines(source, path).length > 0; }; export const hasSwiftUncheckedSendableUsage = (source: string): boolean => { return scanCodeLikeSource(source, ({ source: swiftSource, index, current }) => { if (current !== '@' || !swiftSource.startsWith('@unchecked', index)) { return false; } const sendableIndex = nextNonWhitespaceIndex(swiftSource, index + '@unchecked'.length); return sendableIndex >= 0 && hasIdentifierAt(swiftSource, sendableIndex, 'Sendable'); }); }; export const hasSwiftPreconcurrencyUsage = (source: string): boolean => { return hasSwiftSanitizedRegexMatch(source, /@\s*preconcurrency\b/); }; export const hasSwiftNonisolatedUnsafeUsage = (source: string): boolean => { return hasSwiftSanitizedRegexMatch(source, /\bnonisolated\s*\(\s*unsafe\s*\)/); }; export const hasSwiftAssumeIsolatedUsage = (source: string): boolean => { return hasSwiftSanitizedRegexMatch(source, /\bassumeIsolated\b/); }; export const hasSwiftObservableObjectUsage = (source: string): boolean => { return scanCodeLikeSource(source, ({ source: swiftSource, index, current }) => { if (current !== 'O') { return false; } return hasIdentifierAt(swiftSource, index, 'ObservableObject'); }); }; export const hasSwiftLegacyPreviewProviderUsage = (source: string): boolean => { return hasSwiftSanitizedRegexMatch( source, /\b(?:struct|enum|final\s+class|class)\s+[A-Za-z_][A-Za-z0-9_]*\s*:\s*PreviewProvider\b/g ); }; export const hasSwiftTestDoubleWithoutProtocolConformanceUsage = (source: string): boolean => { return source.split(/\r?\n/).some((line) => { const sanitizedLine = stripSwiftLineForSemanticScan(line); return /\b(?:final\s+)?class\s+(?:Mock|Fake|Spy|Stub)[A-Za-z0-9_]*\s*\{/.test( sanitizedLine ); }); }; const extractSwiftColorToken = (value: string): string | null => { const match = value.match(/\b(?:Color\s*\.\s*)?([A-Za-z][A-Za-z0-9_]*)\b/); return match?.[1]?.toLowerCase() ?? null; }; const isLowContrastSwiftColorPair = (foreground: string, background: string): boolean => { if (foreground === background) { return true; } const pair = `${foreground}:${background}`; return new Set(['white:yellow', 'yellow:white', 'white:cyan', 'cyan:white']).has(pair); }; export const hasSwiftLowContrastStaticColorPairUsage = (source: string): boolean => { const sanitizedSource = sanitizeSwiftSourceForMultilineRegex(source); const chainPattern = /\.(?:foregroundStyle|foregroundColor)\s*\(([^)]{1,80})\)([\s\S]{0,300}?)(?:\.(?:background|fill)\s*\(([^)]{1,80})\))/g; let match: RegExpExecArray | null; while ((match = chainPattern.exec(sanitizedSource)) !== null) { const foreground = extractSwiftColorToken(match[1] ?? ''); const background = extractSwiftColorToken(match[3] ?? ''); if (foreground && background && isLowContrastSwiftColorPair(foreground, background)) { return true; } } return false; }; export const hasSwiftForEachIndicesUsage = (source: string): boolean => { return hasSwiftSanitizedRegexMatch( source, /\bForEach\s*\(\s*(?:Array\s*\(\s*)?[A-Za-z_][A-Za-z0-9_.]*\.indices\b/g ); }; export const collectSwiftForEachIndicesLines = (source: string): readonly number[] => { return collectSwiftRegexLines( source, /\bForEach\s*\(\s*(?:Array\s*\(\s*)?[A-Za-z_][A-Za-z0-9_.]*\.indices\b/ ); }; export const hasSwiftForEachSelfIdentityUsage = (source: string): boolean => { return hasSwiftSanitizedRegexMatch(source, /\bForEach\s*\([^)]*\bid\s*:\s*\\\.self\b/g); }; export const collectSwiftForEachSelfIdentityLines = (source: string): readonly number[] => { return collectSwiftRegexLines(source, /\bForEach\s*\([^)]*\bid\s*:\s*\\\.self\b/); }; export const hasSwiftSelfPrintChangesUsage = (source: string): boolean => { return hasSwiftSanitizedRegexMatch(source, /\bSelf\s*\.\s*_printChanges\s*\(/g); }; export const hasSwiftInlineForEachTransformUsage = (source: string): boolean => { const sanitized = sanitizeSwiftSourceForMultilineRegex(source); return /\bForEach\s*\(\s*(?:Array\s*\(\s*)?[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*\s*\.\s*(?:filter|map|compactMap|sorted)\s*(?:\{|\()/g.test( sanitized ); }; export const collectSwiftInlineForEachTransformLines = (source: string): readonly number[] => { return collectSwiftRegexLines( source, /\bForEach\s*\(\s*(?:Array\s*\(\s*)?[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*\s*\.\s*(?:filter|map|compactMap|sorted)\s*(?:\{|\()/ ); }; const isUserSearchIdentifier = (value: string): boolean => { return /^(?:query|search(?:Text|Term|Query|Value)?|filter(?:Text|Value)?|text|term|input)$/i.test( value ); }; export const hasSwiftContainsUserFilterUsage = (source: string): boolean => { return scanCodeLikeSource(source, ({ source: swiftSource, index, current }) => { if (current !== 'c' || !hasIdentifierAt(swiftSource, index, 'contains')) { return false; } const openingParenIndex = nextNonWhitespaceIndex(swiftSource, index + 'contains'.length); if (openingParenIndex < 0 || swiftSource[openingParenIndex] !== '(') { return false; } const argumentIndex = nextNonWhitespaceIndex(swiftSource, openingParenIndex + 1); if (argumentIndex < 0 || !isIdentifierCharacter(swiftSource[argumentIndex] ?? '')) { return false; } let argumentEnd = argumentIndex; while (isIdentifierCharacter(swiftSource[argumentEnd + 1] ?? '')) { argumentEnd += 1; } const argumentIdentifier = swiftSource.slice(argumentIndex, argumentEnd + 1); return isUserSearchIdentifier(argumentIdentifier); }); }; export const hasSwiftGeometryReaderUsage = (source: string): boolean => { return scanCodeLikeSource(source, ({ source: swiftSource, index, current }) => { if (current !== 'G') { return false; } return hasIdentifierAt(swiftSource, index, 'GeometryReader'); }); }; export const hasSwiftFontWeightBoldUsage = (source: string): boolean => { return hasSwiftSanitizedRegexMatch(source, /\.\s*fontWeight\s*\(\s*\.bold\s*\)/g); }; export const hasSwiftExplicitColorStaticMemberUsage = (source: string): boolean => { return hasSwiftSanitizedRegexMatch( source, /\bColor\s*\.\s*(?:accentColor|black|blue|brown|clear|cyan|gray|green|indigo|mint|orange|pink|primary|purple|red|secondary|teal|white|yellow)\b/g ); }; export const collectSwiftExplicitColorStaticMemberLines = (source: string): readonly number[] => { return collectSwiftRegexLines( source, /\bColor\s*\.\s*(?:accentColor|black|blue|brown|clear|cyan|gray|green|indigo|mint|orange|pink|primary|purple|red|secondary|teal|white|yellow)\b/g ); }; export const hasSwiftClosureBasedViewBuilderContentUsage = (source: string): boolean => { return hasSwiftSanitizedRegexMatch( source, /\b(?:let|var)\s+content\s*:\s*(?:\(\s*\)\s*->|@\s*escaping\s*\(\s*\)\s*->)\s*(?:some\s+View|Content)\b/g ); }; export const hasSwiftLargeConfigContextViewPropertyUsage = (source: string): boolean => { const sanitized = sanitizeSwiftSourceForMultilineRegex(source); const swiftUIViewPattern = /\bstruct\s+[A-Za-z_][A-Za-z0-9_]*\s*:\s*View\s*\{[\s\S]{0,2200}?\bvar\s+body\s*:\s*some\s+View\s*\{/g; for (const viewMatch of sanitized.matchAll(swiftUIViewPattern)) { const viewSegment = viewMatch[0] ?? ''; if ( /\b(?:let|var)\s+(?:config|configuration|context)\s*:\s*[A-Za-z_][A-Za-z0-9_]*(?:Config|Configuration|Context)\b/.test( viewSegment ) ) { return true; } } return false; }; const swiftIdentitySensitiveViewConstructors = [ 'Text', 'Image', 'Button', 'Label', 'HStack', 'VStack', 'ZStack', 'Group', 'RoundedRectangle', 'Circle', 'Capsule', 'Rectangle', ] as const; export const hasSwiftUiConditionalSameViewIdentityUsage = (source: string): boolean => { const sanitized = sanitizeSwiftSourceForMultilineRegex(source); const swiftUIViewBodyPattern = /\bstruct\s+[A-Za-z_][A-Za-z0-9_]*\s*:\s*View\s*\{[\s\S]{0,2200}?\bvar\s+body\s*:\s*some\s+View\s*\{/m; if (!swiftUIViewBodyPattern.test(sanitized)) { return false; } for (const constructor of swiftIdentitySensitiveViewConstructors) { const escapedConstructor = escapeRegex(constructor); const sameViewBranchPattern = new RegExp( `\\bif\\s+[^{}]+\\{\\s*${escapedConstructor}\\s*(?:\\(|\\{|\\.)[\\s\\S]{0,600}?\\}\\s*else\\s*\\{\\s*${escapedConstructor}\\s*(?:\\(|\\{|\\.)`, 'm' ); if (sameViewBranchPattern.test(sanitized)) { return true; } } return false; }; export const collectSwiftUiConditionalSameViewIdentityLines = ( source: string ): readonly number[] => { if (!hasSwiftUiConditionalSameViewIdentityUsage(source)) { return []; } return collectSwiftRegexLines(source, /\bif\s+[^{}]+\{/); }; export const hasSwiftUiParentOwnedSheetActionUsage = (source: string): boolean => { const sanitized = sanitizeSwiftSourceForMultilineRegex(source); const swiftUIViewBodyPattern = /\bstruct\s+[A-Za-z_][A-Za-z0-9_]*\s*:\s*View\s*\{[\s\S]{0,2200}?\bvar\s+body\s*:\s*some\s+View\s*\{/m; if (!swiftUIViewBodyPattern.test(sanitized)) { return false; } const sheetWithCallbackPattern = /\.(?:sheet|fullScreenCover)\s*\([^)]*\)\s*\{[\s\S]{0,1200}?\b[A-Za-z_][A-Za-z0-9_]*\s*\([\s\S]{0,900}?\b(?:onSave|onCancel|onDismiss|onClose|onDone|onDelete|onConfirm)\s*:\s*\{/g; return sheetWithCallbackPattern.test(sanitized); }; export const hasSwiftRedundantReactiveStateAssignmentUsage = (source: string): boolean => { const sanitized = sanitizeSwiftSourceForMultilineRegex(source); const reactiveAssignmentPattern = /\.(?:onChange|onReceive)\s*\([^)]*\)\s*\{[\s\S]{0,500}?\b(?:self\s*\.\s*)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(?:newValue|value|output|receivedValue)\b/g; for (const match of sanitized.matchAll(reactiveAssignmentPattern)) { const target = match[1]; const segment = match[0] ?? ''; if (!target) { continue; } const guardedAssignmentPattern = new RegExp( `\\b(?:if|guard)\\s+(?:self\\s*\\.\\s*)?${target}\\s*!=\\s*(?:newValue|value|output|receivedValue)\\b` ); if (!guardedAssignmentPattern.test(segment)) { return true; } } return false; }; export const hasSwiftLegacySwiftUiObservableWrapperUsage = (source: string): boolean => { return hasSwiftSanitizedRegexMatch(source, /@\s*(?:StateObject|ObservedObject)\b/); }; export const collectSwiftEnvironmentObjectLines = (source: string): readonly number[] => { return collectSwiftRegexLines( source, /@\s*EnvironmentObject\s+(?:private\s+)?var\s+[A-Za-z_][A-Za-z0-9_]*/g ); }; export const hasSwiftEnvironmentObjectUsage = (source: string): boolean => { return collectSwiftEnvironmentObjectLines(source).length > 0; }; export const hasSwiftNonPrivateStateOwnershipUsage = (source: string): boolean => { return source.split(/\r?\n/).some((line) => { const sanitizedLine = stripSwiftLineForSemanticScan(line); return ( /@\s*(?:State|StateObject)\b/.test(sanitizedLine) && /\bvar\b/.test(sanitizedLine) && !/\bprivate\b/.test(sanitizedLine) ); }); }; const hasSwiftPassedValueWrapperInitialization = ( source: string, options: { wrapperAttribute: 'State' | 'StateObject'; wrapperFactoryPattern: string; } ): boolean => { const sanitized = sanitizeSwiftSourceForMultilineRegex(source); const propertyPattern = new RegExp( `@\\s*${options.wrapperAttribute}\\b[\\s\\S]{0,120}?\\b(?:var|let)\\s+([A-Za-z_][A-Za-z0-9_]*)\\b`, 'g' ); for (const propertyMatch of sanitized.matchAll(propertyPattern)) { const propertyName = propertyMatch[1]; if (!propertyName) { continue; } const initPattern = new RegExp( `\\binit\\s*\\(([^)]*)\\)[\\s\\S]{0,400}?\\b(?:self\\.)?_${propertyName}\\s*=\\s*${options.wrapperFactoryPattern}\\s*([A-Za-z_][A-Za-z0-9_]*)\\b`, 'g' ); for (const initMatch of sanitized.matchAll(initPattern)) { const initParameters = initMatch[1] ?? ''; const assignedIdentifier = initMatch[2]; if (!assignedIdentifier) { continue; } const parameterPattern = new RegExp(`\\b${assignedIdentifier}\\s*:`); if (parameterPattern.test(initParameters)) { return true; } } } return false; }; export const hasSwiftPassedValueStateWrapperUsage = (source: string): boolean => { return ( hasSwiftPassedValueWrapperInitialization(source, { wrapperAttribute: 'State', wrapperFactoryPattern: 'State\\s*\\(\\s*initialValue\\s*:', }) || hasSwiftPassedValueWrapperInitialization(source, { wrapperAttribute: 'StateObject', wrapperFactoryPattern: 'StateObject\\s*\\(\\s*wrappedValue\\s*:', }) ); }; export const hasSwiftNavigationViewUsage = (source: string): boolean => { return scanCodeLikeSource(source, ({ source: swiftSource, index, current }) => { if (current !== 'N') { return false; } return hasIdentifierAt(swiftSource, index, 'NavigationView'); }); }; export const hasSwiftNSLayoutConstraintUsage = (source: string): boolean => { return scanCodeLikeSource(source, ({ source: swiftSource, index, current }) => { if (current !== 'N') { return false; } return ( hasIdentifierAt(swiftSource, index, 'NSLayoutConstraint') || hasIdentifierAt(swiftSource, index, 'NSLayoutAnchor') || hasIdentifierAt(swiftSource, index, 'NSLayoutXAxisAnchor') || hasIdentifierAt(swiftSource, index, 'NSLayoutYAxisAnchor') || hasIdentifierAt(swiftSource, index, 'NSLayoutDimension') ); }); }; export const collectSwiftUIKitManualFrameLayoutLines = (source: string): readonly number[] => { const manualFramePattern = /\b(?:UIView|UIStackView|UILabel|UIButton|UIImageView|UITableView|UICollectionView|UIScrollView|UITextField|UITextView|UIViewController)\s*\(\s*frame\s*:\s*CGRect\s*\(|\.\s*frame\s*=\s*CGRect\s*\(/g; return collectSwiftRegexLines(source, manualFramePattern); }; export const hasSwiftUIKitManualFrameLayoutUsage = (source: string): boolean => { return collectSwiftUIKitManualFrameLayoutLines(source).length > 0; }; export const hasSwiftUntypedNavigationLinkDestinationUsage = (source: string): boolean => { const swiftSource = sanitizeSwiftSourceForMultilineRegex(source); const destinationParameterPattern = /\bNavigationLink\s*\([^)]*\bdestination\s*:/; const trailingDestinationPattern = /\bNavigationLink\s*\{[\s\S]{0,900}\b[A-Z][A-Za-z0-9_]*View\s*\([^}]*\)[\s\S]{0,900}\}\s*label\s*:/; return destinationParameterPattern.test(swiftSource) || trailingDestinationPattern.test(swiftSource); }; export const hasSwiftForegroundColorUsage = (source: string): boolean => { return hasSwiftUiModernizationSnapshotMatch(source, 'foreground-color'); }; export const collectSwiftForegroundColorLines = (source: string): readonly number[] => { return collectSwiftRegexLines(source, /\.\s*foregroundColor\s*\(/); }; export const hasSwiftCornerRadiusUsage = (source: string): boolean => { return hasSwiftUiModernizationSnapshotMatch(source, 'corner-radius'); }; export const collectSwiftCornerRadiusLines = (source: string): readonly number[] => { return collectSwiftRegexLines(source, /\.\s*cornerRadius\s*\(/); }; export const hasSwiftTabItemUsage = (source: string): boolean => { return hasSwiftUiModernizationSnapshotMatch(source, 'tab-item'); }; export const hasSwiftOnTapGestureUsage = (source: string): boolean => { return scanCodeLikeSource(source, ({ source: swiftSource, index, current }) => { if (current !== 'o') { return false; } return hasIdentifierAt(swiftSource, index, 'onTapGesture'); }); }; export const collectSwiftOnTapGestureWithoutButtonTraitLines = (source: string): readonly number[] => { const sanitizedLines = sanitizeSwiftSourceForMultilineRegex(source).split(/\r?\n/); const originalLines = source.split(/\r?\n/); const matches: number[] = []; for (let index = 0; index < sanitizedLines.length; index += 1) { if (!/\.onTapGesture\s*(?:\(|\{)/.test(sanitizedLines[index] ?? '')) { continue; } const modifierWindow = sanitizedLines .slice(index, Math.min(sanitizedLines.length, index + 8)) .join('\n'); if (/\.accessibilityAddTraits\s*\(\s*\.isButton\s*\)/.test(modifierWindow)) { continue; } if (/\.accessibilityAddTraits\s*\(\s*AccessibilityTraits\s*\.\s*isButton\s*\)/.test(modifierWindow)) { continue; } if (!/\.onTapGesture\s*(?:\(|\{)/.test(stripSwiftLineForSemanticScan(originalLines[index] ?? ''))) { continue; } matches.push(index + 1); } return sortedUniqueLines(matches); }; export const hasSwiftOnTapGestureWithoutButtonTraitUsage = (source: string): boolean => { return collectSwiftOnTapGestureWithoutButtonTraitLines(source).length > 0; }; export const collectSwiftGlassInteractiveOnStaticElementLines = (source: string): readonly number[] => { const sanitizedLines = sanitizeSwiftSourceForMultilineRegex(source).split(/\r?\n/); const originalLines = source.split(/\r?\n/); const matches: number[] = []; for (let index = 0; index < sanitizedLines.length; index += 1) { const line = sanitizedLines[index] ?? ''; if (!/\.glassEffect\s*\(/.test(line)) { continue; } const previousWindow = sanitizedLines.slice(Math.max(0, index - 4), index).join('\n'); const followingWindow = sanitizedLines .slice(index, Math.min(sanitizedLines.length, index + 8)) .join('\n'); const modifierWindow = `${previousWindow}\n${followingWindow}`; const currentStartsModifierChain = /^\s*\./.test(line); if (!/\.interactive\s*\(/.test(modifierWindow)) { continue; } if ( /\b(?:Button|NavigationLink|Menu|Toggle|Picker|Slider|Stepper|TextField|SecureField)\s*(?:<[^>]+>)?\s*\(/.test( modifierWindow ) || /^\s*\.onTapGesture\s*(?:\(|\{)/m.test(followingWindow) || (currentStartsModifierChain && /\.onTapGesture\s*(?:\(|\{)/.test(previousWindow)) || /^\s*\.accessibilityAction\s*\(/m.test(followingWindow) || (currentStartsModifierChain && /\.accessibilityAction\s*\(/.test(previousWindow)) || /^\s*\.accessibilityAddTraits\s*\(\s*(?:AccessibilityTraits\s*\.\s*)?\.isButton\s*\)/m.test(followingWindow) || (currentStartsModifierChain && /\.accessibilityAddTraits\s*\(\s*(?:AccessibilityTraits\s*\.\s*)?\.isButton\s*\)/.test(previousWindow)) || /^\s*\.focusable\s*\(\s*true\s*\)/m.test(followingWindow) || (currentStartsModifierChain && /\.focusable\s*\(\s*true\s*\)/.test(previousWindow)) ) { continue; } if (!/\.glassEffect\s*\(/.test(stripSwiftLineForSemanticScan(originalLines[index] ?? ''))) { continue; } matches.push(index + 1); } return sortedUniqueLines(matches); }; export const hasSwiftGlassInteractiveOnStaticElementUsage = (source: string): boolean => { return collectSwiftGlassInteractiveOnStaticElementLines(source).length > 0; }; export const collectSwiftGlassEffectIDWithoutNamespaceLines = (source: string): readonly number[] => { const sanitized = sanitizeSwiftSourceForMultilineRegex(source); if (/@Namespace\b/.test(sanitized) || /\bNamespace\s*\.\s*ID\b/.test(sanitized)) { return []; } return collectSwiftRegexLines(source, /\.\s*glassEffectID\s*\(/); }; export const hasSwiftGlassEffectIDWithoutNamespaceUsage = (source: string): boolean => { return collectSwiftGlassEffectIDWithoutNamespaceLines(source).length > 0; }; export const hasSwiftStringFormatUsage = (source: string): boolean => { return scanCodeLikeSource(source, ({ source: swiftSource, index, current }) => { if (current !== 'S' || !hasIdentifierAt(swiftSource, index, 'String')) { return false; } const openingParenIndex = nextNonWhitespaceIndex(swiftSource, index + 'String'.length); if (openingParenIndex < 0 || swiftSource[openingParenIndex] !== '(') { return false; } const formatIndex = nextNonWhitespaceIndex(swiftSource, openingParenIndex + 1); if (formatIndex < 0 || !hasIdentifierAt(swiftSource, formatIndex, 'format')) { return false; } const colonIndex = nextNonWhitespaceIndex(swiftSource, formatIndex + 'format'.length); return colonIndex >= 0 && swiftSource[colonIndex] === ':'; }); }; export const hasSwiftUIScreenMainBoundsUsage = (source: string): boolean => { return scanCodeLikeSource(source, ({ source: swiftSource, index, current }) => { if (current !== 'U' || !hasIdentifierAt(swiftSource, index, 'UIScreen')) { return false; } const dotMainIndex = nextNonWhitespaceIndex(swiftSource, index + 'UIScreen'.length); if (dotMainIndex < 0 || swiftSource[dotMainIndex] !== '.') { return false; } const mainIndex = nextNonWhitespaceIndex(swiftSource, dotMainIndex + 1); if (mainIndex < 0 || !hasIdentifierAt(swiftSource, mainIndex, 'main')) { return false; } const dotBoundsIndex = nextNonWhitespaceIndex(swiftSource, mainIndex + 'main'.length); if (dotBoundsIndex < 0 || swiftSource[dotBoundsIndex] !== '.') { return false; } const boundsIndex = nextNonWhitespaceIndex(swiftSource, dotBoundsIndex + 1); return boundsIndex >= 0 && hasIdentifierAt(swiftSource, boundsIndex, 'bounds'); }); }; export const hasSwiftScrollViewShowsIndicatorsUsage = (source: string): boolean => { return hasSwiftUiModernizationSnapshotMatch(source, 'scrollview-shows-indicators'); }; export const hasSwiftSheetIsPresentedUsage = (source: string): boolean => { return hasSwiftUiModernizationSnapshotMatch(source, 'sheet-is-presented'); }; export const hasSwiftLegacyOnChangeUsage = (source: string): boolean => { return hasSwiftUiModernizationSnapshotMatch(source, 'legacy-onchange'); }; const hasSwiftXCTestImportUsage = (source: string): boolean => { return collectSwiftRegexLines(source, /^\s*import\s+XCTest\b/).length > 0; }; const hasSwiftLegacyXCTestUiOrPerformanceUsage = (source: string): boolean => { return hasSwiftSanitizedRegexMatch(source, /\bXCUIApplication\b|\bXCTMetric\b|\bmeasure\s*(?:\(|\{)/); }; const hasSwiftTestingImportUsage = (source: string): boolean => { return collectSwiftRegexLines(source, /^\s*import\s+Testing\b/).length > 0; }; const hasSwiftTestingSuiteAttributeUsage = (source: string): boolean => { return hasSwiftSanitizedRegexMatch(source, /\B@(?:Test|Suite)\b/); }; const hasSwiftXCTestCaseSubclassUsage = (source: string): boolean => { return hasSwiftSanitizedRegexMatch( source, /\bclass\s+[A-Za-z_][A-Za-z0-9_]*\s*:\s*XCTestCase\b/ ); }; const hasSwiftLegacyXCTestMethodUsage = (source: string): boolean => { return collectSwiftRegexLines(source, /^\s*(?:override\s+)?func\s+test[A-Za-z0-9_]*\s*\(/) .length > 0; }; const hasSwiftBrownfieldXCTestQualityPattern = (source: string): boolean => { const sanitized = sanitizeSwiftSourceForMultilineRegex(source); return /\bmakeSUT\s*\(/.test(sanitized) && /\btrackForMemoryLeaks\s*\(/.test(sanitized); }; export const collectSwiftMakeSUTWithoutMemoryTrackingLines = (source: string): readonly number[] => { const sanitized = sanitizeSwiftSourceForMultilineRegex(source); if (!/\bmakeSUT\s*\(/.test(sanitized) || /\btrackForMemoryLeaks\s*\(/.test(sanitized)) { return []; } return collectSwiftRegexLines(source, /\bmakeSUT\s*\(/g); }; export const hasSwiftMakeSUTWithoutMemoryTrackingUsage = (source: string): boolean => { return collectSwiftMakeSUTWithoutMemoryTrackingLines(source).length > 0; }; export const collectSwiftDirectSUTInstantiationWithoutMakeSUTLines = (source: string): readonly number[] => { const sanitized = sanitizeSwiftSourceForMultilineRegex(source); if (/\bmakeSUT\s*\(/.test(sanitized)) { return []; } return collectSwiftRegexLines( source, /^\s*(?:let|var)\s+sut\s*=\s*[A-Z][A-Za-z0-9_]*(?:<[^>]+>)?\s*\(/g ); }; export const hasSwiftDirectSUTInstantiationWithoutMakeSUTUsage = (source: string): boolean => { return collectSwiftDirectSUTInstantiationWithoutMakeSUTLines(source).length > 0; }; export const hasSwiftLegacyXCTestImportUsage = (source: string): boolean => { if (!hasSwiftXCTestImportUsage(source)) { return false; } if (hasSwiftLegacyXCTestUiOrPerformanceUsage(source)) { return false; } if (hasSwiftBrownfieldXCTestQualityPattern(source)) { return false; } return true; }; export const hasSwiftModernizableXCTestSuiteUsage = (source: string): boolean => { if (!hasSwiftLegacyXCTestImportUsage(source)) { return false; } if (!hasSwiftXCTestCaseSubclassUsage(source) || !hasSwiftLegacyXCTestMethodUsage(source)) { return false; } if (hasSwiftTestingImportUsage(source) || hasSwiftTestingSuiteAttributeUsage(source)) { return false; } return true; }; export const collectSwiftModernizableXCTestSuiteLines = (source: string): readonly number[] => { if (!hasSwiftModernizableXCTestSuiteUsage(source)) { return []; } return sortedUniqueLines([ ...collectSwiftRegexLines(source, /\bimport\s+XCTest\b/), ...collectSwiftRegexLines(source, /\bclass\s+[A-Za-z_][A-Za-z0-9_]*\s*:\s*XCTestCase\b/), ...collectSwiftRegexLines(source, /^\s*(?:override\s+)?func\s+test[A-Za-z0-9_]*\s*\(/), ]); }; export const hasSwiftMixedTestingFrameworksUsage = (source: string): boolean => { if (!hasSwiftXCTestImportUsage(source) || !hasSwiftXCTestCaseSubclassUsage(source)) { return false; } return hasSwiftTestingImportUsage(source) || hasSwiftTestingSuiteAttributeUsage(source); }; export const collectSwiftMixedTestingFrameworkLines = (source: string): readonly number[] => { if (!hasSwiftMixedTestingFrameworksUsage(source)) { return []; } return sortedUniqueLines([ ...collectSwiftRegexLines(source, /\bimport\s+XCTest\b/), ...collectSwiftRegexLines(source, /\bimport\s+Testing\b/), ...collectSwiftRegexLines(source, /\bclass\s+[A-Za-z_][A-Za-z0-9_]*\s*:\s*XCTestCase\b/), ...collectSwiftRegexLines(source, /@\s*(?:Suite|Test)\b/), ]); }; export const hasSwiftQuickNimbleUsage = (source: string): boolean => { return hasSwiftSanitizedRegexMatch( source, /\bimport\s+(?:Quick|Nimble)\b|\bclass\s+[A-Za-z_][A-Za-z0-9_]*\s*:\s*QuickSpec\b/ ); }; export const collectSwiftQuickNimbleLines = (source: string): readonly number[] => { if (!hasSwiftQuickNimbleUsage(source)) { return []; } return sortedUniqueLines([ ...collectSwiftRegexLines(source, /\bimport\s+(?:Quick|Nimble)\b/), ...collectSwiftRegexLines(source, /\bclass\s+[A-Za-z_][A-Za-z0-9_]*\s*:\s*QuickSpec\b/), ...collectSwiftRegexLines(source, /\b(?:describe|context|it)\s*\(/), ...collectSwiftRegexLines(source, /\bexpect\s*\(/), ]); }; export const hasSwiftThirdPartyUiTestFrameworkUsage = (source: string): boolean => { return hasSwiftSanitizedRegexMatch( source, /\bimport\s+(?:KIF|EarlGrey|GREYMatchers|GREYActions|Detox|Appium|Calabash)\b|\b(?:tester|KIFUITestActor|EarlGrey|GREYMatchers|GREYActions|Detox|Appium|Calabash)\b/ ); }; export const collectSwiftThirdPartyUiTestFrameworkLines = (source: string): readonly number[] => { if (!hasSwiftThirdPartyUiTestFrameworkUsage(source)) { return []; } return sortedUniqueLines([ ...collectSwiftRegexLines(source, /\bimport\s+(?:KIF|EarlGrey|GREYMatchers|GREYActions|Detox|Appium|Calabash)\b/), ...collectSwiftRegexLines(source, /\b(?:tester|KIFUITestActor|EarlGrey|GREYMatchers|GREYActions|Detox|Appium|Calabash)\b/), ]); }; export const hasSwiftXCTestAssertionUsage = (source: string): boolean => { if (hasSwiftLegacyXCTestUiOrPerformanceUsage(source)) { return false; } if (hasSwiftBrownfieldXCTestQualityPattern(source)) { return false; } return ( collectSwiftRegexLines(source, /\bXCTAssert[A-Za-z0-9_]*\s*\(/).length > 0 || collectSwiftRegexLines(source, /\bXCTFail\s*\(/).length > 0 ); }; export const collectSwiftXCTestAssertionLines = (source: string): readonly number[] => { if (!hasSwiftXCTestAssertionUsage(source)) { return []; } return sortedUniqueLines([ ...collectSwiftRegexLines(source, /\bXCTAssert[A-Za-z0-9_]*\s*\(/), ...collectSwiftRegexLines(source, /\bXCTFail\s*\(/), ]); }; export const hasSwiftXCTUnwrapUsage = (source: string): boolean => { if (hasSwiftBrownfieldXCTestQualityPattern(source)) { return false; } return collectSwiftRegexLines(source, /\bXCTUnwrap\s*\(/).length > 0; }; export const collectSwiftXCTUnwrapLines = (source: string): readonly number[] => { if (!hasSwiftXCTUnwrapUsage(source)) { return []; } return sortedUniqueLines(collectSwiftRegexLines(source, /\bXCTUnwrap\s*\(/)); }; const hasSwiftAwaitFulfillmentUsage = (source: string): boolean => { return hasSwiftSanitizedRegexMatch(source, /\bawait\s+fulfillment\s*\(\s*of\s*:/); }; const hasSwiftConfirmationUsage = (source: string): boolean => { return hasSwiftSanitizedRegexMatch(source, /\bawait\s+confirmation\b/); }; export const collectSwiftWaitForExpectationsLines = (source: string): readonly number[] => { if (hasSwiftLegacyXCTestUiOrPerformanceUsage(source)) { return []; } return sortedUniqueLines([ ...collectSwiftRegexLines(source, /\bself\s*\.\s*wait\s*\(\s*for\s*:/), ...collectSwiftRegexLines(source, /(? { return collectSwiftWaitForExpectationsLines(source).length > 0; }; export const collectSwiftLegacyExpectationDescriptionLines = (source: string): readonly number[] => { return sortedUniqueLines(collectSwiftRegexLines( source, /\bexpectation\s*\(\s*description\s*:/ )); }; export const hasSwiftLegacyExpectationDescriptionUsage = (source: string): boolean => { if (collectSwiftLegacyExpectationDescriptionLines(source).length === 0) { return false; } if (hasSwiftAwaitFulfillmentUsage(source) || hasSwiftConfirmationUsage(source)) { return false; } return true; }; export const hasSwiftNSManagedObjectBoundaryUsage = (source: string): boolean => { return hasSwiftSanitizedRegexMatch( source, /\bfunc\b[\s\S]{0,240}\([^)]*\bNSManagedObject\b(?!ID\b|Context\b)[^)]*\)|\b(?:var|let)\s+[A-Za-z_][A-Za-z0-9_]*\s*:\s*(?:\[[^\]]*NSManagedObject\b(?!ID\b|Context\b)[^\]]*\]|NSManagedObject\b(?!ID\b|Context\b))/g ); }; export const hasSwiftNSManagedObjectAsyncBoundaryUsage = (source: string): boolean => { return hasSwiftSanitizedRegexMatch( source, /\bfunc\b[\s\S]{0,240}\basync\b[\s\S]{0,200}(?:\([^)]*\bNSManagedObject\b(?!ID\b|Context\b)[^)]*\)|->\s*(?:\[[^\]]*NSManagedObject\b(?!ID\b|Context\b)[^\]]*\]|NSManagedObject\b(?!ID\b|Context\b)))/g ); }; export const hasSwiftCoreDataLayerLeakUsage = (source: string): boolean => { return hasSwiftSanitizedRegexMatch( source, /\bimport\s+CoreData\b|@\s*FetchRequest\b|\b(?:FetchRequest|FetchedResults|NSPersistentContainer|NSManagedObjectContext|NSFetchRequest|NSFetchedResultsController|NSEntityDescription)\b|\.managedObjectContext\b/g ); }; export const hasSwiftSwiftDataLayerLeakUsage = (source: string): boolean => { return hasSwiftSanitizedRegexMatch( source, /\bimport\s+SwiftData\b|@\s*Query\b|@\s*Model\b|\b(?:ModelContext|ModelContainer|FetchDescriptor)\b|\.modelContext\b/g ); }; export const hasSwiftNSManagedObjectStateLeakUsage = (source: string): boolean => { const typeDeclarations = parseSwiftTypeDeclarations(source); if (typeDeclarations.length === 0) { return false; } const managedObjectTypePatternSource = buildSwiftManagedObjectTypePatternSource(source); const propertyPattern = new RegExp( `\\b(?:var|let)\\s+[A-Za-z_][A-Za-z0-9_]*\\s*:\\s*(?:\\[[^\\]]*(?:${managedObjectTypePatternSource})[^\\]]*\\]|(?:${managedObjectTypePatternSource})(?:[?!])?)` ); const stateWrapperPattern = /@\s*(?:State|Binding|Bindable|StateObject|ObservedObject|EnvironmentObject|Published)\b/; const sourceLines = source.split(/\r?\n/); for (const typeDeclaration of typeDeclarations) { const isSwiftUIView = typeDeclaration.conformances.includes('View'); const isViewModel = typeDeclaration.name.endsWith('ViewModel') || typeDeclaration.conformances.includes('ObservableObject'); if (!isSwiftUIView && !isViewModel) { continue; } let pendingStateWrapper = false; const hasLeak = visitSwiftTopLevelTypeBodyLines(sourceLines, typeDeclaration, ({ line }) => { const isWrapperLine = stateWrapperPattern.test(line); const hasManagedObjectProperty = propertyPattern.test(line); if (isViewModel && hasManagedObjectProperty) { return true; } if (isSwiftUIView && hasManagedObjectProperty && (pendingStateWrapper || isWrapperLine)) { return true; } if (isWrapperLine && !hasManagedObjectProperty) { pendingStateWrapper = true; return false; } pendingStateWrapper = false; return false; }); if (hasLeak) { return true; } } return false; }; export const hasSwiftForceTryUsage = (source: string): boolean => { return scanCodeLikeSource(source, ({ source: swiftSource, index, current }) => { if (current !== 't' || !hasIdentifierAt(swiftSource, index, 'try')) { return false; } const bangIndex = nextNonWhitespaceIndex(swiftSource, index + 'try'.length); return bangIndex >= 0 && swiftSource[bangIndex] === '!'; }); }; export const collectSwiftForceTryLines = (source: string): readonly number[] => { return sortedUniqueLines(collectSwiftRegexLines(source, /\btry\s*!/)); }; export const hasSwiftForceCastUsage = (source: string): boolean => { return scanCodeLikeSource(source, ({ source: swiftSource, index, current }) => { if (current !== 'a' || !hasIdentifierAt(swiftSource, index, 'as')) { return false; } const bangIndex = nextNonWhitespaceIndex(swiftSource, index + 'as'.length); return bangIndex >= 0 && swiftSource[bangIndex] === '!'; }); }; export const collectSwiftForceCastLines = (source: string): readonly number[] => { return sortedUniqueLines(collectSwiftRegexLines(source, /\bas\s*!/)); }; export const hasSwiftCallbackStyleSignature = (source: string): boolean => { return scanCodeLikeSource(source, ({ source: swiftSource, index, current }) => { if (current !== '@' || !swiftSource.startsWith('@escaping', index)) { return false; } const segmentStart = Math.max(0, index - 180); const segmentEnd = Math.min(swiftSource.length, index + 260); const segment = swiftSource.slice(segmentStart, segmentEnd); return ( /\b(?:completion|handler|callback)\s*:\s*(?:@[A-Za-z0-9_]+\s+)?@escaping\b/.test( segment ) || /\bfunc\b[\s\S]{0,180}@escaping[\s\S]{0,120}->\s*Void\b/.test(segment) ); }); }; export const findSwiftIOSCanary001Match = (source: string): SwiftIOSCanary001Match | undefined => { const classPattern = /\b(?:final\s+)?class\s+([A-Za-z0-9_]*ViewModel)\b/; const classLines = collectSwiftRegexLines(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 explicitInfraResponsibilities: SwiftResponsibilityMatch[] = []; registerSwiftResponsibility( explicitInfraResponsibilities, 'shared-state', 'property', 'shared singleton', collectSwiftRegexLines(source, /\bstatic\s+let\s+shared\b/) ); registerSwiftResponsibility( explicitInfraResponsibilities, 'networking', 'call', 'URLSession.shared', collectSwiftRegexLines(source, /\bURLSession\.shared\b/) ); registerSwiftResponsibility( explicitInfraResponsibilities, 'persistence', 'call', 'FileManager.default', collectSwiftRegexLines(source, /\bFileManager\.default\b/) ); registerSwiftResponsibility( explicitInfraResponsibilities, 'navigation', 'member', 'navigation flow', collectSwiftRegexLines( source, /\b(?:router|route|coordinator|navigationPath|navigationDestination)\b|\b(?:navigate|dismiss|present)\s*\(/i ) ); if (hasSwiftResponsibilityKeys(explicitInfraResponsibilities, ['networking', 'persistence', 'navigation'])) { const explicitInfraNodes = explicitInfraResponsibilities.map((entry) => entry.node); const relatedNodeNames = explicitInfraNodes.map((node) => node.name).join(', '); const allLines = sortedUniqueLines([ ...classLines, ...explicitInfraNodes.flatMap((node) => [...node.lines]), ]); return { primary_node: { kind: 'class', name: className, lines: classLines, }, related_nodes: explicitInfraNodes, why: `${className} mezcla ${relatedNodeNames} dentro del mismo ViewModel, rompiendo SRP y el boundary de Clean Architecture en iOS.`, impact: 'Presentation queda acoplada a infraestructura y navegación, se dificulta el aislamiento en tests y aumenta el riesgo de regresiones al cambiar cualquier responsabilidad.', expected_fix: 'Extrae networking, persistencia y navegación a colaboradores separados de application/infrastructure y deja el ViewModel limitado a estado y orquestación por puertos.', lines: allLines, }; } const appShellResponsibilities: SwiftResponsibilityMatch[] = []; registerSwiftResponsibility( appShellResponsibilities, 'session', 'member', 'session bootstrap/restoration', collectSwiftRegexLines(source, /\b(?:restorePersistedSessionIfNeeded|continueAsGuest|bootstrapAuthenticatedSession)\s*\(/) ); registerSwiftResponsibility( appShellResponsibilities, 'store', 'member', 'store selection orchestration', collectSwiftRegexLines(source, /\bselectStore\s*\(/) ); registerSwiftResponsibility( appShellResponsibilities, 'shopping-list', 'member', 'shopping list synchronization', collectSwiftRegexLines(source, /\bsyncShoppingList\s*\(/) ); registerSwiftResponsibility( appShellResponsibilities, 'route', 'member', 'route progression', collectSwiftRegexLines(source, /\b(?:markNextStopCompleted|scanCheckpoint|rebuildRouteStatus)\s*\(/) ); registerSwiftResponsibility( appShellResponsibilities, 'offline-queue', 'member', 'offline queue coordination', collectSwiftRegexLines(source, /\b(?:flushOfflineQueue|enqueueOfflineCheckpoint)\s*\(/) ); registerSwiftResponsibility( appShellResponsibilities, 'navigation', 'member', 'deep link/navigation flow', collectSwiftRegexLines(source, /\bopenDeepLink\s*\(/) ); if (!hasSwiftResponsibilityKeys(appShellResponsibilities, ['session', 'store', 'route', 'navigation'])) { return undefined; } const appShellNodes = appShellResponsibilities.map((entry) => entry.node); const relatedNodeNames = appShellNodes.map((node) => node.name).join(', '); const allLines = sortedUniqueLines([ ...classLines, ...appShellNodes.flatMap((node) => [...node.lines]), ]); return { primary_node: { kind: 'class', name: className, lines: classLines, }, related_nodes: appShellNodes, why: `${className} concentra ${relatedNodeNames} en presentation, mezclando múltiples razones de cambio incompatibles con SRP y Clean Architecture.`, impact: 'El ViewModel queda sobreacoplado a bootstrap de sesión, selección de tienda, sincronización de lista, navegación y cola offline, dificultando aislamiento, testing y evolución segura de la feature.', expected_fix: 'Extrae bootstrap/restauración de sesión, coordinación de tienda/ruta, deep links y cola offline a casos de uso o coordinadores dedicados; deja el ViewModel como orquestador ligero de estado.', lines: allLines, }; }; export const findSwiftPresentationSrpMatch = ( source: string ): SwiftPresentationSrpMatch | undefined => { const classPattern = /\b(?:final\s+)?class\s+([A-Za-z0-9_]*(?:ViewModel|Presenter))\b/; const classLines = collectSwiftRegexLines(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: SwiftResponsibilityMatch[] = []; const registerNode = ( key: string, kind: SwiftSemanticNodeMatch['kind'], name: string, regex: RegExp ): void => { registerSwiftResponsibility(responsibilities, key, kind, name, collectSwiftRegexLines(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(?:URLSession\.shared|URLRequest\b|dataTask\s*\(|uploadTask\s*\(|downloadTask\s*\()/ ); registerNode( 'persistence', 'call', 'local persistence', /\b(?:UserDefaults\.standard|FileManager\.default|Keychain|NSPersistentContainer|CoreData)\b/ ); registerNode( 'navigation', 'member', 'navigation flow', /\b(?:navigationPath|navigationDestination)\b|(?:\.\s*(?:navigate|present|dismiss|push|open)\s*\()/ ); if (!hasSwiftResponsibilityKeys(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, 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 coordinadores o casos de uso dedicados.', lines: allLines, }; }; export const findSwiftConcreteDependencyDipMatch = ( source: string ): SwiftConcreteDependencyDipMatch | undefined => { const classPattern = /\b(?:final\s+)?class\s+([A-Za-z0-9_]*(?:UseCase|Service|ViewModel|Presenter|Controller|Coordinator))\b/; const classLines = collectSwiftRegexLines(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: SwiftSemanticNodeMatch[] = []; const registerNode = ( kind: SwiftSemanticNodeMatch['kind'], name: string, regex: RegExp ): void => { const lines = collectSwiftRegexLines(source, regex); if (lines.length === 0) { return; } relatedNodes.push({ kind, name, lines }); }; registerNode( 'property', 'concrete dependency: URLSession', /\b(?:let|var)\s+\w+\s*:\s*URLSession\b/ ); registerNode('call', 'URLSession.shared', /\bURLSession\.shared\b/); registerNode( 'property', 'concrete dependency: UserDefaults', /\b(?:let|var)\s+\w+\s*:\s*UserDefaults\b/ ); registerNode('call', 'UserDefaults.standard', /\bUserDefaults\.standard\b/); registerNode( 'property', 'concrete dependency: FileManager', /\b(?:let|var)\s+\w+\s*:\s*FileManager\b/ ); registerNode('call', 'FileManager.default', /\bFileManager\.default\b/); 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 en application/presentation, rompiendo DIP al saltarse puertos o abstracciones.`, impact: 'La capa de alto nivel queda acoplada a detalles de infraestructura concretos, se dificulta el test aislado y aumenta el coste de sustituir transporte o persistencia.', expected_fix: 'Introduce puertos para networking o preferencias y adapta URLSession/UserDefaults/FileManager detrás de implementaciones de infrastructure inyectadas.', lines: allLines, }; }; export const findSwiftOpenClosedSwitchMatch = ( source: string ): SwiftOpenClosedSwitchMatch | undefined => { const typePattern = /\b(?:final\s+)?(?:class|struct)\s+([A-Za-z0-9_]*(?:UseCase|ViewModel|Presenter|Controller|Coordinator|Service|Factory))\b/; const typeLines = collectSwiftRegexLines(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 lines = source.split(/\r?\n/); const discriminatorPattern = /\b(?:kind|type|mode|channel|variant|provider|route|flow|source|experience)\b/i; const switchPattern = /\bswitch\s+([A-Za-z_][A-Za-z0-9_\.]*)\s*\{/; for (let index = 0; index < lines.length; index += 1) { const sanitizedLine = stripSwiftLineForSemanticScan(lines[index] ?? ''); const switchMatch = sanitizedLine.match(switchPattern); if (!switchMatch) { continue; } const discriminatorPath = switchMatch[1] ?? ''; const discriminatorName = discriminatorPath.split('.').pop() ?? discriminatorPath; if (!discriminatorPattern.test(discriminatorName)) { continue; } let braceDepth = countTokenOccurrences(sanitizedLine, '{') - countTokenOccurrences(sanitizedLine, '}'); const caseNodes: SwiftSemanticNodeMatch[] = []; for (let cursor = index + 1; cursor < lines.length; cursor += 1) { const candidateLine = stripSwiftLineForSemanticScan(lines[cursor] ?? ''); const caseMatch = candidateLine.match(/\bcase\s+([^:]+):/); if (caseMatch) { const rawLabel = (caseMatch[1] ?? '').trim(); const semanticCaseLabel = rawLabel.match(/\.[A-Za-z_][A-Za-z0-9_]*/)?.[0] ?? rawLabel.split(',')[0]?.trim() ?? rawLabel; if (!/^default\b/.test(rawLabel) && semanticCaseLabel.length > 0) { caseNodes.push({ kind: 'member', name: `case ${semanticCaseLabel}`, lines: [cursor + 1], }); } } braceDepth += countTokenOccurrences(candidateLine, '{'); braceDepth -= countTokenOccurrences(candidateLine, '}'); if (braceDepth <= 0) { break; } } const [firstCaseNode, secondCaseNode] = caseNodes; if (!firstCaseNode || !secondCaseNode) { continue; } const relatedNodes = [ { kind: 'member' as const, name: `discriminator switch: ${discriminatorName}`, lines: [index + 1], }, ...caseNodes, ]; const allLines = sortedUniqueLines([ ...typeLines, index + 1, ...caseNodes.flatMap((node) => [...node.lines]), ]); const caseSummary = caseNodes .map((node) => node.name.replace(/^case\s+/, '')) .join(', '); return { primary_node: { kind: 'class', name: typeName, lines: typeLines, }, related_nodes: relatedNodes, why: `${typeName} resuelve comportamiento con un switch sobre ${discriminatorName} ` + `(${caseSummary}), obligando a modificar el mismo tipo para soportar un nuevo caso y rompiendo OCP.`, impact: 'Cada nuevo caso o comportamiento obliga a editar y revalidar el tipo de alto nivel, aumentando regresiones y dificultando extender la feature por composición.', expected_fix: 'Extrae una estrategia, protocolo o registry de handlers por caso y deja el tipo de application/presentation abierto a extensión y cerrado a modificación.', lines: allLines, }; } return undefined; }; export const findSwiftInterfaceSegregationMatch = ( source: string ): SwiftInterfaceSegregationMatch | undefined => { const typePattern = /\b(?:final\s+)?(?:class|struct)\s+([A-Za-z0-9_]*(?:UseCase|ViewModel|Presenter|Controller|Coordinator|Service))\b/; const typeLines = collectSwiftRegexLines(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 protocolDeclarations = parseSwiftProtocolDeclarations(source); if (protocolDeclarations.length === 0) { return undefined; } const sourceLines = source.split(/\r?\n/); for (const protocolDeclaration of protocolDeclarations) { const queryMembers = protocolDeclaration.members.filter((member) => isSwiftQueryMemberName(member.name) ); const commandMembers = protocolDeclaration.members.filter((member) => isSwiftCommandMemberName(member.name) ); if (queryMembers.length === 0 || commandMembers.length === 0) { continue; } const propertyPattern = new RegExp( `\\b(?:let|var)\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*:\\s*${protocolDeclaration.name}\\b` ); const propertyLines = collectSwiftRegexLines(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*\\(`, 'g' ); sourceLines.forEach((line, index) => { const sanitizedLine = stripSwiftLineForSemanticScan(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(isSwiftQueryMemberName); const usesCommandContract = usedMemberNames.some(isSwiftCommandMemberName); 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 firstUsedMember = [...usedMembers.entries()][0]; if (!firstUsedMember) { continue; } const [usedMemberName, usedMemberLines] = firstUsedMember; const relatedNodes: SwiftSemanticNodeMatch[] = [ { kind: 'member', name: `fat protocol: ${protocolDeclaration.name}`, lines: [protocolDeclaration.line], }, { kind: 'call', name: `used member: ${usedMemberName}`, lines: sortedUniqueLines(usedMemberLines), }, ...unusedMembers.map((member) => ({ kind: 'member' as const, name: `unused contract member: ${member.name}`, lines: [member.line], })), ]; const allLines = sortedUniqueLines([ ...typeLines, protocolDeclaration.line, ...usedMemberLines, ...unusedMembers.map((member) => member.line), ]); return { primary_node: { kind: 'class', name: typeName, lines: typeLines, }, related_nodes: relatedNodes, why: `${typeName} depende de ${protocolDeclaration.name}, un protocolo demasiado ancho para el ` + `uso real del consumidor, y rompe ISP al acoplarlo a miembros que no necesita.`, impact: 'El consumidor queda expuesto a cambios ajenos de un contrato demasiado ancho, aumenta el coste de mocks/dobles y se erosiona el aislamiento semántico del caso de uso o presenter.', expected_fix: 'Segrega el protocolo en contratos más pequeños y haz que el consumidor dependa solo del puerto mínimo que realmente utiliza.', lines: allLines, }; } return undefined; }; export const findSwiftLiskovSubstitutionMatch = ( source: string ): SwiftLiskovSubstitutionMatch | undefined => { const protocolDeclarations = parseSwiftProtocolDeclarations(source); if (protocolDeclarations.length === 0) { return undefined; } const typeDeclarations = parseSwiftTypeDeclarations(source); const sourceLines = source.split(/\r?\n/); for (const protocolDeclaration of protocolDeclarations) { const memberNames = protocolDeclaration.members.map((member) => member.name); if (memberNames.length === 0) { continue; } const conformingTypes = typeDeclarations.filter((typeDeclaration) => typeDeclaration.conformances.includes(protocolDeclaration.name) ); for (const memberName of memberNames) { let safeType: SwiftTypeDeclaration | undefined; let unsafeType: | (SwiftTypeDeclaration & { narrowedPreconditionLine: number; failureLine: number; }) | undefined; for (const typeDeclaration of conformingTypes) { const methodPattern = new RegExp(`\\bfunc\\s+${memberName}\\s*\\(`); let methodLine = -1; for ( let lineIndex = typeDeclaration.bodyStartLine - 1; lineIndex < typeDeclaration.bodyEndLine; lineIndex += 1 ) { const candidateLine = stripSwiftLineForSemanticScan(sourceLines[lineIndex] ?? ''); if (methodPattern.test(candidateLine)) { methodLine = lineIndex + 1; break; } } if (methodLine < 0) { continue; } let methodBraceDepth = countTokenOccurrences(stripSwiftLineForSemanticScan(sourceLines[methodLine - 1] ?? ''), '{') - countTokenOccurrences(stripSwiftLineForSemanticScan(sourceLines[methodLine - 1] ?? ''), '}'); let narrowedPreconditionLine: number | undefined; let failureLine: number | undefined; for ( let lineIndex = methodLine; lineIndex < typeDeclaration.bodyEndLine && methodBraceDepth > 0; lineIndex += 1 ) { const candidateLine = stripSwiftLineForSemanticScan(sourceLines[lineIndex] ?? ''); if (narrowedPreconditionLine === undefined && /\bguard\b/.test(candidateLine)) { narrowedPreconditionLine = lineIndex + 1; } if ( failureLine === undefined && /\b(?:fatalError|preconditionFailure|precondition)\s*\(/.test(candidateLine) ) { failureLine = lineIndex + 1; } methodBraceDepth += countTokenOccurrences(candidateLine, '{'); methodBraceDepth -= countTokenOccurrences(candidateLine, '}'); } if (narrowedPreconditionLine !== undefined && failureLine !== undefined) { unsafeType = { ...typeDeclaration, narrowedPreconditionLine, failureLine, }; } else if (!safeType) { safeType = typeDeclaration; } } if (!safeType || !unsafeType) { continue; } const allLines = sortedUniqueLines([ protocolDeclaration.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: ${protocolDeclaration.name}`, lines: [protocolDeclaration.line], }, { kind: 'member', name: `safe substitute: ${safeType.name}`, lines: [safeType.line], }, { kind: 'member', name: `narrowed precondition: ${memberName}`, lines: [unsafeType.narrowedPreconditionLine], }, { kind: 'call', name: 'fatalError', lines: [unsafeType.failureLine], }, ], why: `${unsafeType.name} endurece la precondición del contrato ${protocolDeclaration.name} ` + `y deja de ser sustituible por un consumidor que espera el comportamiento base, rompiendo LSP.`, impact: 'La sustitución deja de ser segura, aparecen regresiones cuando se inyecta el subtipo en lugar del contrato base y se vuelven frágiles los tests y flujos de aplicación.', expected_fix: 'Mantén precondiciones y postcondiciones compatibles con el contrato base o extrae un contrato/estrategia separado para el comportamiento especializado.', lines: allLines, }; } } return undefined; };