From 65bc99c448db5c7a0333fb7bcac51b352ef63e7e Mon Sep 17 00:00:00 2001 From: marxo126 Date: Sat, 21 Mar 2026 10:08:44 +0100 Subject: [PATCH] feat: full Swift cross-file resolution (export, imports, constructors) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes that together enable cross-file call resolution for Swift: 1. export-detection.ts: Treat internal (default) Swift symbols as exported. Swift's default access level is `internal` (module-scoped, visible to all files in the same target). Only private/fileprivate are file-scoped. Previously all non-public/open symbols were marked unexported. 2. import-processor.ts: Add implicit import edges between all Swift files in the same module/target. Swift has no file-level imports — all files see each other automatically. Without these edges, the tiered resolver can't find cross-file symbols at Tier 2a (import-scoped). Supports SPM targets via Package.swift; falls back to single-module for Xcode projects without SPM. 3. call-processor.ts: Add constructor fallback for free-form calls. Swift constructors look like free function calls (no `new` keyword): `let ocr = OCRService()`. The call form is inferred as `free`, which filters out Class/Struct targets. Now retries with `constructor` form when free-form finds no callable but the name resolves to a type. Tested on 61-file iOS 26 project (PricePal): - Before: 0 cross-file CALLS edges - After: full cross-file resolution (OCRService traced from ScanViewModel) - 3,099 nodes, 10,449 edges, 246 clusters, 243 flows Related: #406, #407 Co-Authored-By: Claude Opus 4.6 (1M context) --- gitnexus/src/core/ingestion/call-processor.ts | 14 ++++- .../src/core/ingestion/export-detection.ts | 14 +++-- .../src/core/ingestion/import-processor.ts | 52 +++++++++++++++++++ 3 files changed, 76 insertions(+), 4 deletions(-) diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index ec095023d..2c323ad4b 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -827,7 +827,19 @@ const resolveCallTarget = ( const tiered = ctx.resolve(call.calledName, currentFile); if (!tiered) return null; - const filteredCandidates = filterCallableCandidates(tiered.candidates, call.argCount, call.callForm); + let filteredCandidates = filterCallableCandidates(tiered.candidates, call.argCount, call.callForm); + + // Swift/Kotlin: constructor calls look like free function calls (no `new` keyword). + // If free-form filtering found no callable candidates but the symbol resolves to a + // Class/Struct, retry with constructor form so CONSTRUCTOR_TARGET_TYPES applies. + if (filteredCandidates.length === 0 && call.callForm === 'free') { + const hasTypeTarget = tiered.candidates.some(c => + c.type === 'Class' || c.type === 'Struct' || c.type === 'Enum', + ); + if (hasTypeTarget) { + filteredCandidates = filterCallableCandidates(tiered.candidates, call.argCount, 'constructor'); + } + } // D. Receiver-type filtering: for member calls with a known receiver type, // resolve the type through the same tiered import infrastructure, then diff --git a/gitnexus/src/core/ingestion/export-detection.ts b/gitnexus/src/core/ingestion/export-detection.ts index e6c6e89df..27e0b26a0 100644 --- a/gitnexus/src/core/ingestion/export-detection.ts +++ b/gitnexus/src/core/ingestion/export-detection.ts @@ -192,17 +192,25 @@ const phpExportChecker: ExportChecker = (node, _name) => { return true; }; -/** Swift: check for 'public' or 'open' access modifiers. */ +/** + * Swift: treat symbols as exported unless explicitly marked private/fileprivate. + * + * Swift's default access level is `internal`, which means visible to all files + * in the same module/target. Since GitNexus indexes at the target level, + * `internal` symbols should be treated as exported (cross-file visible). + * Only `private` and `fileprivate` symbols are truly file-scoped. + */ const swiftExportChecker: ExportChecker = (node, _name) => { let current: SyntaxNode | null = node; while (current) { if (current.type === 'modifiers' || current.type === 'visibility_modifier') { const text = current.text || ''; - if (text.includes('public') || text.includes('open')) return true; + if (text.includes('private') || text.includes('fileprivate')) return false; } current = current.parent; } - return false; + // Default (internal), public, and open are all cross-file visible + return true; }; // ============================================================================ diff --git a/gitnexus/src/core/ingestion/import-processor.ts b/gitnexus/src/core/ingestion/import-processor.ts index eef7543da..33d512442 100644 --- a/gitnexus/src/core/ingestion/import-processor.ts +++ b/gitnexus/src/core/ingestion/import-processor.ts @@ -306,6 +306,35 @@ export const processImports = async ( // Tree is now owned by the LRU cache — no manual delete needed } + // ---- Swift: implicit module-level visibility ---- + // In Swift, all files in the same module/target see each other without explicit imports. + // Add implicit import edges between all Swift files so the call resolver can find + // cross-file symbols at Tier 2a (import-scoped) instead of falling to Tier 3 (global). + const swiftFiles = files + .filter(f => getLanguageFromFilename(f.path) === SupportedLanguages.Swift) + .map(f => f.path); + + if (swiftFiles.length > 1) { + // Group Swift files by target directory (SPM target or common root) + const targetGroups = groupSwiftFilesByTarget(swiftFiles, configs.swiftPackageConfig); + + for (const group of targetGroups.values()) { + for (const srcFile of group) { + for (const otherFile of group) { + if (srcFile === otherFile) continue; + // Only add if not already imported (from explicit `import TargetName`) + if (importMap.has(srcFile) && importMap.get(srcFile)!.has(otherFile)) continue; + addImportEdge(srcFile, otherFile); + } + } + } + + if (isDev) { + const totalGroups = targetGroups.size; + console.log(`📊 Swift: ${swiftFiles.length} files in ${totalGroups} target group(s), implicit imports added`); + } + } + if (skippedByLang && skippedByLang.size > 0) { for (const [lang, count] of skippedByLang.entries()) { console.warn( @@ -375,6 +404,29 @@ export const processImportsFromExtracted = async ( onProgress?.(totalFiles, totalFiles); + // ---- Swift: implicit module-level visibility (fast path) ---- + const swiftFilePaths = files + .filter(f => getLanguageFromFilename(f.path) === SupportedLanguages.Swift) + .map(f => f.path); + + if (swiftFilePaths.length > 1) { + const targetGroups = groupSwiftFilesByTarget(swiftFilePaths, configs.swiftPackageConfig); + + for (const group of targetGroups.values()) { + for (const srcFile of group) { + for (const otherFile of group) { + if (srcFile === otherFile) continue; + if (importMap.has(srcFile) && importMap.get(srcFile)!.has(otherFile)) continue; + addImportEdge(srcFile, otherFile); + } + } + } + + if (isDev) { + console.log(`📊 Swift: ${swiftFilePaths.length} files in ${targetGroups.size} target group(s), implicit imports added (fast path)`); + } + } + if (isDev) { console.log(`📊 Import processing (fast path): ${getResolvedCount()}/${totalImportsFound} imports resolved to graph edges`); }