From 956dfd0bb4f7a09f423ee36335625a65b298387b Mon Sep 17 00:00:00 2001 From: marxo126 Date: Mon, 23 Mar 2026 11:40:24 +0100 Subject: [PATCH] feat: add Swift integration tests for if-let, await/try, for-loop + fix cross-chunk imports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 3 new test fixtures: swift-if-let-guard-let, swift-await-try, swift-for-loop-inference - Add integration tests for if let/guard let binding resolution (4 assertions) - Add integration tests for await/try expression unwrapping (3 assertions) - Add for-loop-inference fixture (documented as known gap — type-env infrastructure is in place but call-processor re-parse path doesn't propagate the binding yet) - Fix cross-chunk Swift implicit imports: standard processImports path now passes allFileList instead of chunk-only files to addSwiftImplicitImports, matching the fast-path behavior - Add Swift type_annotation fallback in type-env declarationTypeNodes population (handles [User] array sugar where childForFieldName('type') returns null) - Handle Swift 'pattern' node in extractVarName fallback (pattern wraps simple_identifier) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/core/ingestion/import-processor.ts | 2 +- gitnexus/src/core/ingestion/type-env.ts | 16 ++- .../lang-resolution/swift-await-try/App.swift | 9 ++ .../swift-await-try/Models.swift | 15 ++ .../swift-for-loop-inference/App.swift | 6 + .../swift-for-loop-inference/Models.swift | 7 + .../swift-if-let-guard-let/App.swift | 10 ++ .../swift-if-let-guard-let/Models.swift | 15 ++ .../test/integration/resolvers/swift.test.ts | 128 ++++++++++++++++++ 9 files changed, 206 insertions(+), 2 deletions(-) create mode 100644 gitnexus/test/fixtures/lang-resolution/swift-await-try/App.swift create mode 100644 gitnexus/test/fixtures/lang-resolution/swift-await-try/Models.swift create mode 100644 gitnexus/test/fixtures/lang-resolution/swift-for-loop-inference/App.swift create mode 100644 gitnexus/test/fixtures/lang-resolution/swift-for-loop-inference/Models.swift create mode 100644 gitnexus/test/fixtures/lang-resolution/swift-if-let-guard-let/App.swift create mode 100644 gitnexus/test/fixtures/lang-resolution/swift-if-let-guard-let/Models.swift diff --git a/gitnexus/src/core/ingestion/import-processor.ts b/gitnexus/src/core/ingestion/import-processor.ts index 6796f20b2..e4753ef9e 100644 --- a/gitnexus/src/core/ingestion/import-processor.ts +++ b/gitnexus/src/core/ingestion/import-processor.ts @@ -382,7 +382,7 @@ export const processImports = async ( // Tree is now owned by the LRU cache — no manual delete needed } - addSwiftImplicitImports(files, configs.swiftPackageConfig, importMap, addImportEdge); + addSwiftImplicitImports(allFileList.map(p => ({ path: p })), configs.swiftPackageConfig, importMap, addImportEdge); if (skippedByLang && skippedByLang.size > 0) { for (const [lang, count] of skippedByLang.entries()) { diff --git a/gitnexus/src/core/ingestion/type-env.ts b/gitnexus/src/core/ingestion/type-env.ts index a1099fc46..baec16881 100644 --- a/gitnexus/src/core/ingestion/type-env.ts +++ b/gitnexus/src/core/ingestion/type-env.ts @@ -854,13 +854,27 @@ export const buildTypeEnv = ( } } } + // Swift: property_declaration has type_annotation as a direct child (not a 'type' field). + // Extract the inner type node (array_type, user_type, etc.) for declarationTypeNodes. + if (!typeNode) { + for (let i = 0; i < node.namedChildCount; i++) { + const c = node.namedChild(i); + if (c?.type === 'type_annotation') { + // Use the inner type (array_type, user_type) rather than the annotation wrapper + typeNode = c.firstNamedChild ?? c; + break; + } + } + } } if (typeNode) { const nameNode = node.childForFieldName('name') ?? node.childForFieldName('left') ?? node.childForFieldName('pattern'); if (nameNode) { - const varName = extractVarName(nameNode); + // Swift: pattern node wraps a simple_identifier — unwrap it + const varName = extractVarName(nameNode) + ?? (nameNode.type === 'pattern' ? extractVarName(nameNode.firstNamedChild!) ?? nameNode.text : undefined); if (varName && !declarationTypeNodes.has(`${scope}\0${varName}`)) { declarationTypeNodes.set(`${scope}\0${varName}`, typeNode); } diff --git a/gitnexus/test/fixtures/lang-resolution/swift-await-try/App.swift b/gitnexus/test/fixtures/lang-resolution/swift-await-try/App.swift new file mode 100644 index 000000000..11c2ab3bf --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-await-try/App.swift @@ -0,0 +1,9 @@ +func processAwait() async { + let user = await fetchUser() + user.save() +} + +func processTry() throws { + let repo = try parseRepo("main") + repo.save() +} diff --git a/gitnexus/test/fixtures/lang-resolution/swift-await-try/Models.swift b/gitnexus/test/fixtures/lang-resolution/swift-await-try/Models.swift new file mode 100644 index 000000000..0c18f3c8b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-await-try/Models.swift @@ -0,0 +1,15 @@ +class User { + func save() {} +} + +class Repo { + func save() {} +} + +func fetchUser() async -> User { + return User() +} + +func parseRepo(_ name: String) throws -> Repo { + return Repo() +} diff --git a/gitnexus/test/fixtures/lang-resolution/swift-for-loop-inference/App.swift b/gitnexus/test/fixtures/lang-resolution/swift-for-loop-inference/App.swift new file mode 100644 index 000000000..7be8bdad9 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-for-loop-inference/App.swift @@ -0,0 +1,6 @@ +func processAll() { + let users: [User] = [] + for user in users { + user.save() + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/swift-for-loop-inference/Models.swift b/gitnexus/test/fixtures/lang-resolution/swift-for-loop-inference/Models.swift new file mode 100644 index 000000000..36776f201 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-for-loop-inference/Models.swift @@ -0,0 +1,7 @@ +class User { + func save() {} +} + +class Repo { + func save() {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/swift-if-let-guard-let/App.swift b/gitnexus/test/fixtures/lang-resolution/swift-if-let-guard-let/App.swift new file mode 100644 index 000000000..359788ff6 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-if-let-guard-let/App.swift @@ -0,0 +1,10 @@ +func processIfLet() { + if let user = findUser() { + user.save() + } +} + +func processGuardLet() { + guard let repo = findRepo() else { return } + repo.save() +} diff --git a/gitnexus/test/fixtures/lang-resolution/swift-if-let-guard-let/Models.swift b/gitnexus/test/fixtures/lang-resolution/swift-if-let-guard-let/Models.swift new file mode 100644 index 000000000..45600907f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-if-let-guard-let/Models.swift @@ -0,0 +1,15 @@ +class User { + func save() {} +} + +class Repo { + func save() {} +} + +func findUser() -> User? { + return User() +} + +func findRepo() -> Repo? { + return Repo() +} diff --git a/gitnexus/test/integration/resolvers/swift.test.ts b/gitnexus/test/integration/resolvers/swift.test.ts index 5107491ce..32c912ea7 100644 --- a/gitnexus/test/integration/resolvers/swift.test.ts +++ b/gitnexus/test/integration/resolvers/swift.test.ts @@ -378,3 +378,131 @@ describe.skipIf(!swiftAvailable)('Swift export visibility (internal vs private)' // These tests verify the symbols ARE marked correctly in export detection // (covered by parsing.test.ts mock tests), not end-to-end call blocking. }); + +// --------------------------------------------------------------------------- +// if let / guard let optional binding resolution: +// Swift's most common unwrap patterns — extractIfGuardBinding extracts the +// variable name and infers type from the RHS call result. +// --------------------------------------------------------------------------- + +describe.skipIf(!swiftAvailable)('Swift if let / guard let binding resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'swift-if-let-guard-let'), + () => {}, + ); + }, 60000); + + it('detects User and Repo classes', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Class')).toContain('Repo'); + }); + + it('resolves user.save() inside if-let to User#save', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'processIfLet' && c.targetFilePath === 'Models.swift', + ); + expect(saveCall).toBeDefined(); + }); + + it('resolves repo.save() inside guard-let to Repo#save', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'processGuardLet' && c.targetFilePath === 'Models.swift', + ); + expect(saveCall).toBeDefined(); + }); + + it('user.save() in if-let does NOT resolve to Repo#save', () => { + const calls = getRelationships(result, 'CALLS'); + const wrongSave = calls.find(c => + c.target === 'save' && c.source === 'processIfLet', + ); + if (wrongSave) { + // If resolved, it should be to User's save (in Models.swift), not Repo's + expect(wrongSave.targetFilePath).toBe('Models.swift'); + } + }); +}); + +// --------------------------------------------------------------------------- +// await / try expression unwrapping: +// Swift's await_expression and try_expression wrap call_expression nodes. +// extractPendingAssignment must unwrap these to find the inner call. +// --------------------------------------------------------------------------- + +describe.skipIf(!swiftAvailable)('Swift await / try expression unwrapping', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'swift-await-try'), + () => {}, + ); + }, 60000); + + it('resolves user.save() via await fetchUser() return type', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'processAwait' && c.targetFilePath === 'Models.swift', + ); + expect(saveCall).toBeDefined(); + }); + + it('resolves repo.save() via try parseRepo() return type', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'processTry' && c.targetFilePath === 'Models.swift', + ); + expect(saveCall).toBeDefined(); + }); + + it('detects fetchUser and parseRepo as functions', () => { + const fns = getNodesByLabel(result, 'Function'); + expect(fns).toContain('fetchUser'); + expect(fns).toContain('parseRepo'); + }); +}); + +// --------------------------------------------------------------------------- +// for-in loop element type inference: +// extractForLoopBinding derives element type from the iterable's declared +// type annotation (e.g., [User] → User). +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// For-in loop element type inference: extractForLoopBinding derives element +// type from the iterable's declared type annotation (e.g., [User] → User). +// +// KNOWN GAP: The type-env correctly stores declarationTypeNodes for Swift +// array types ([User]), but the call-processor's re-parse path doesn't +// propagate the for-loop binding to receiver resolution. The type-env +// infrastructure (extractForLoopBinding, extractSwiftElementTypeFromTypeNode, +// declarationTypeNodes population for type_annotation) is in place — the +// integration gap is in how processCalls rebuilds TypeEnv for call resolution. +// Fixture: swift-for-loop-inference/ (ready for when this is wired up). +// --------------------------------------------------------------------------- + +describe.skipIf(!swiftAvailable)('Swift for-in loop element type inference', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'swift-for-loop-inference'), + () => {}, + ); + }, 60000); + + it('detects User and Repo classes', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Class')).toContain('Repo'); + }); + + it('creates implicit import edges between files', () => { + const imports = getRelationships(result, 'IMPORTS'); + expect(imports.length).toBeGreaterThan(0); + }); +});