feat: full Swift cross-file resolution (export, imports, constructors)

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) <noreply@anthropic.com>
This commit is contained in:
marxo126 2026-03-21 10:08:44 +01:00
parent 0c8ec952ee
commit 65bc99c448
3 changed files with 76 additions and 4 deletions

View file

@ -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

View file

@ -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;
};
// ============================================================================

View file

@ -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`);
}