GitNexus/gitnexus/test/integration/resolvers/swift.test.ts
Gergő Magyar 51fb64c976
fix(swift): model Swift modules like the compiler (nested packages, Xcode targets, linear visibility) (#3387)
* fix(swift): discover nested Package.swift manifests for module grouping

A Swift monorepo laid out as Core/<pkg>/Package.swift has no root manifest
and no root Sources/, so every Swift file fell into one __default__ module.
The Swift resolver now walks the repo (bounded, skipping dot, ignored, and
Xcode bundle directories) and adds each nested package's targets, keyed by
repo-relative directory and ordered deepest-first so first-match grouping
picks the most specific target. Import resolution keeps the root view.

Refs #3355

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(swift): bound implicit IMPORTS edges by a total budget

Implicit same-module IMPORTS are n*(n-1) edges per module, and the graph
keeps every relationship in one Map capped by V8 at 2^24 entries. Emission
now fills a 4M-edge budget smallest module first and skips, with a warning,
any module that does not fit, so no module layout can crash analyze.

Refs #3355

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(swift): skip pairwise sibling passes for oversized modules

The target-siblings and sibling-type-bindings passes copy every file's
declarations into every other file of a module, so heap grows as n^2:
about 3.8 GB at 1,000 files with 15 defs each, about 15 GB at 2,000.
Modules over 1,000 files now skip both passes with a warning and resolve
through the global name fallback. GITNEXUS_SWIFT_MAX_MODULE_FILES changes
the ceiling.

Refs #3355

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(swift): cover nested SwiftPM packages end to end

A fixture with three nested Package.swift manifests (two declaring a target
named Net) and no root manifest. Each target gets its own implicit IMPORTS,
none cross packages, and Config() resolves to the caller's own package.
The Swift capture golden and scope-capture fingerprint grow with the new
fixture corpus only.

Refs #3355

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(review): apply review findings

- Rebase nested target paths with the existing Zig path helpers, which also
  reject Windows drive paths and paths that resolve to the repo root (whose
  empty prefix would group every file into one target).
- Document GITNEXUS_SWIFT_MAX_MODULE_FILES in the README env table.
- State that skipped modules resolve through lower-confidence fallback edges,
  why nested-type fragments still run for oversized modules, and fix a stale
  loader name in the target-grouping header.

Refs #3355

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(swift): cover every skipped Xcode bundle suffix in the package walk

Refs #3355

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(swift): model modules the way the compiler does

Replace the caps from the first round with representations that stay
linear, and derive module identity from the same sources the compiler uses.

- Same-module visibility is one File -> Module IMPORTS edge per file
  (reason `module-membership`) instead of an edge per ordered file pair.
  Incremental importer expansion treats files sharing a Module hub as
  importers of each other. The 4M edge budget is gone.
- Sibling declarations and type bindings live in one shared table per
  module in the namespace channel C# uses since #1871, instead of being
  copied into every file. The 1,000-file ceiling and
  GITNEXUS_SWIFT_MAX_MODULE_FILES are gone.
- Modules come from root and nested SwiftPM manifests (Sources, Source,
  src, srcs; plugins under Plugins; the newest Package@swift-X.Y.swift)
  and from Xcode native targets in project.pbxproj, including Xcode 16
  synchronized folders. Target paths are matched from the repo root, so
  a vendored copy of the same layout is no longer grouped into a root
  target (this reverses #2931's floating match).
- `import X` resolves to the modules named X instead of any folder named
  X. A name no module carries is external when every manifest and
  project was read completely.
- The global-name-fallback veto uses the same membership, so test
  targets, custom-path targets and Xcode targets have module identity.

Refs #3355

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(bench): move the Swift package bench to module grouping

The bench still imported the removed groupSwiftFilesBySpmTarget. Its
first-wins probe keeps its meaning under root-anchored grouping: the
clash file sits under Sources/Mod0, and the Sources/Mod1 further down its
path is a vendored copy.

Plugins are now non-importable modules under Plugins/, so parse_targets
counts importable source targets (still 3) and parse_binary_skipped also
checks that the plugin is recorded that way. Baseline values unchanged;
the notes say why the definitions moved.

Refs #3355

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(swift): match compiler module names, manifests, and target filters

- Module names follow the compiler's c99 mangling (`my-lib` imports as
  `my_lib`); Xcode targets use a literal PRODUCT_MODULE_NAME or
  PRODUCT_NAME when the project sets one.
- Package manifests are one-file modules, as SwiftPM compiles them. Files
  outside every target are one-file modules once every manifest and
  project was read; otherwise they keep the shared __default__ module.
- Xcode 16 synchronized-folder exceptions add a file to a target that
  does not list the folder, and remove it from one that does.
- SwiftPM `sources:` / `exclude:` narrow a target; a computed list marks
  the manifest unreadable.
- The default target folder is chosen once per package, as SwiftPM does,
  instead of per target.

Refs #3355

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Address PR review feedback (#3387)

- Inferred Swift folders keep SwiftPM's first predefined parent when a
  target name repeats (Sources before srcs).
- The pbxproj parser rejects a \U escape without four hex digits instead
  of decoding garbage, so the project reads as incomplete.
- Extension owners are stamped in every Xcode membership of a shared file.
- A plugin name never reaches a plugin: neither the fallback veto nor the
  folder-index fallback treats a known non-importable module as imported.
- A file an Xcode target compiles keeps that membership even when it also
  lies under a SwiftPM target directory.
- The workspace scan bounds the queue, not only the directories read.
- Integration tests require each module hub to exist before comparing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 06:24:35 +01:00

1535 lines
63 KiB
TypeScript

/**
* Swift: constructor-inferred type resolution for member calls.
* Verifies that `let user = User(name: "alice"); user.save()` resolves to User.save
* without explicit type annotations, using SymbolTable verification.
*
* NOTE: Swift is installed as an optional dependency. These tests skip gracefully
* if a consumer installs without optional dependencies.
*/
import { describe, it, expect, beforeAll } from 'vitest';
import path from 'path';
import {
FIXTURES,
getRelationships,
getNodesByLabel,
getNodesByLabelFull,
edgeSet,
runPipelineFromRepo,
type PipelineResult,
} from './helpers.js';
import { isLanguageAvailable } from '../../../src/core/tree-sitter/parser-loader.js';
import { SupportedLanguages } from '../../../src/config/supported-languages.js';
import { MODULE_MEMBERSHIP_REASON } from '../../../src/core/graph/edge-reasons.js';
const swiftAvailable = isLanguageAvailable(SupportedLanguages.Swift);
/**
* Module nodes a file is a member of. Same-module visibility is one
* File -> Module membership edge per file (#3355), so two files see each
* other exactly when they share a hub.
*/
function moduleHubsOf(result: PipelineResult, filePath: string): string[] {
return getRelationships(result, 'IMPORTS')
.filter((c) => c.rel.reason === MODULE_MEMBERSHIP_REASON && c.sourceFilePath === filePath)
.map((c) => c.rel.targetId)
.sort();
}
describe.skipIf(!swiftAvailable)('Swift constructor-inferred type resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'swift-constructor-type-inference'),
() => {},
);
}, 60000);
it('detects User and Repo classes, both with save methods', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
expect(getNodesByLabel(result, 'Class')).toContain('Repo');
const saveFns = getNodesByLabel(result, 'Function').filter((m) => m === 'save');
expect(saveFns.length).toBe(2);
});
it('resolves user.save() to Models/User.swift via constructor-inferred type', () => {
const calls = getRelationships(result, 'CALLS');
const userSave = calls.find(
(c) => c.target === 'save' && c.targetFilePath === 'Models/User.swift',
);
expect(userSave).toBeDefined();
expect(userSave!.source).toBe('processEntities');
});
it('resolves repo.save() to Models/Repo.swift via constructor-inferred type', () => {
const calls = getRelationships(result, 'CALLS');
const repoSave = calls.find(
(c) => c.target === 'save' && c.targetFilePath === 'Models/Repo.swift',
);
expect(repoSave).toBeDefined();
expect(repoSave!.source).toBe('processEntities');
});
it('emits exactly 2 save() CALLS edges (one per receiver type)', () => {
const calls = getRelationships(result, 'CALLS');
const saveCalls = calls.filter((c) => c.target === 'save');
expect(saveCalls.length).toBe(2);
});
});
// ---------------------------------------------------------------------------
// self.save() resolves to enclosing class's own save method
// Build-dep issue (NOT a feature gap): tree-sitter-swift has build issues on Node 22.
// The self/super resolution code already exists in type-env.ts lookupInEnv (lines 56-66).
// ---------------------------------------------------------------------------
describe.skipIf(!swiftAvailable)('Swift self resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'swift-self-this-resolution'), () => {});
}, 60000);
it('detects User and Repo classes, each with a save function', () => {
expect(getNodesByLabel(result, 'Class')).toEqual(['Repo', 'User']);
const saveFns = getNodesByLabel(result, 'Function').filter((m) => m === 'save');
expect(saveFns.length).toBe(2);
});
it('resolves self.save() inside User.process to User.save, not Repo.save', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find((c) => c.target === 'save' && c.source === 'process');
expect(saveCall).toBeDefined();
expect(saveCall!.targetFilePath).toBe('Sources/Models/User.swift');
});
});
// ---------------------------------------------------------------------------
// Parent class resolution: EXTENDS + protocol conformance
// Build-dep issue (NOT a feature gap): tree-sitter-swift has build issues on Node 22.
// findEnclosingParentClassName in type-env.ts already has Swift inheritance_specifier handler.
// ---------------------------------------------------------------------------
describe.skipIf(!swiftAvailable)('Swift parent resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'swift-parent-resolution'), () => {});
}, 60000);
it('detects BaseModel and User classes plus Serializable protocol', () => {
expect(getNodesByLabel(result, 'Class')).toEqual(['BaseModel', 'User']);
expect(getNodesByLabel(result, 'Interface')).toEqual(['Serializable']);
});
it('emits EXTENDS edge: User → BaseModel', () => {
const extends_ = getRelationships(result, 'EXTENDS');
const extendsEdge = extends_.find((e) => e.source === 'User' && e.target === 'BaseModel');
expect(extendsEdge).toBeDefined();
});
it('emits IMPLEMENTS edge: User → Serializable (protocol conformance)', () => {
const implements_ = getRelationships(result, 'IMPLEMENTS');
const implEdge = implements_.find((e) => e.source === 'User' && e.target === 'Serializable');
expect(implEdge).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Swift cross-file User.init() type inference
// ---------------------------------------------------------------------------
describe.skipIf(!swiftAvailable)('Swift cross-file User.init() inference', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'swift-init-cross-file'), () => {});
}, 60000);
it('resolves user.save() via User.init(name:) inference', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find((c) => c.target === 'save' && c.targetFilePath === 'User.swift');
expect(saveCall).toBeDefined();
expect(saveCall!.source).toBe('main');
});
it('resolves user.greet() via User.init(name:) inference', () => {
const calls = getRelationships(result, 'CALLS');
const greetCall = calls.find((c) => c.target === 'greet' && c.targetFilePath === 'User.swift');
expect(greetCall).toBeDefined();
expect(greetCall!.source).toBe('main');
});
});
// ---------------------------------------------------------------------------
// Return type inference: let user = getUser(name: "alice"); user.save()
// Swift's CONSTRUCTOR_BINDING_SCANNER captures property_declaration with
// call_expression values, enabling return type inference from function results.
// ---------------------------------------------------------------------------
describe.skipIf(!swiftAvailable)('Swift return type inference', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'swift-return-type'), () => {});
}, 60000);
it('detects User class and getUser function', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
expect(getNodesByLabel(result, 'Function')).toContain('getUser');
});
it('detects save function on User (Swift class methods are Function nodes)', () => {
expect(getNodesByLabel(result, 'Function')).toContain('save');
});
it('resolves user.save() to User#save via return type of getUser() -> User', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(
(c) =>
c.target === 'save' &&
c.source === 'processUser' &&
c.targetFilePath.includes('Models.swift'),
);
expect(saveCall).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Return-type inference with competing methods:
// Two classes both have save(), factory functions disambiguate via return type
// ---------------------------------------------------------------------------
describe.skipIf(!swiftAvailable)('Swift return-type inference via function return type', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'swift-return-type-inference'),
() => {},
);
}, 60000);
it('resolves user.save() to User#save via return type of getUser()', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(
(c) =>
c.target === 'save' &&
c.source === 'processUser' &&
c.targetFilePath.includes('Models.swift'),
);
expect(saveCall).toBeDefined();
});
it('user.save() does NOT resolve to Repo#save', () => {
const calls = getRelationships(result, 'CALLS');
const wrongSave = calls.find((c) => c.target === 'save' && c.source === 'processUser');
// Should resolve to exactly one target — if it resolves at all, check it's the right one
if (wrongSave) {
expect(wrongSave.targetFilePath).toContain('Models.swift');
}
});
it('resolves repo.save() to Repo#save via return type of getRepo()', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(
(c) =>
c.target === 'save' &&
c.source === 'processRepo' &&
c.targetFilePath.includes('Models.swift'),
);
expect(saveCall).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Implicit imports: Swift files in the same module see each other without
// explicit import statements. This is the foundation of all cross-file
// resolution — without addSwiftImplicitImports, Tier 2a lookups fail.
// ---------------------------------------------------------------------------
describe.skipIf(!swiftAvailable)('Swift implicit imports (cross-file visibility)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'swift-implicit-imports'), () => {});
}, 60000);
it('detects UserService class in Models.swift', () => {
expect(getNodesByLabel(result, 'Class')).toContain('UserService');
});
it('resolves UserService() constructor call across files (no explicit import)', () => {
const calls = getRelationships(result, 'CALLS');
const ctorCall = calls.find(
(c) => c.target === 'UserService' && c.targetFilePath === 'Models.swift',
);
expect(ctorCall).toBeDefined();
});
it('resolves service.fetchUser() member call across files', () => {
const calls = getRelationships(result, 'CALLS');
const memberCall = calls.find(
(c) => c.target === 'fetchUser' && c.targetFilePath === 'Models.swift',
);
expect(memberCall).toBeDefined();
});
it('makes files in the same module members of one Module hub', () => {
expect(moduleHubsOf(result, 'App.swift')).toHaveLength(1);
expect(moduleHubsOf(result, 'App.swift')).toEqual(moduleHubsOf(result, 'Models.swift'));
});
});
// ---------------------------------------------------------------------------
// Extension deduplication: Swift extensions create multiple Class nodes
// with the same name. The resolver should deduplicate and prefer the
// primary definition (shortest file path).
// ---------------------------------------------------------------------------
describe.skipIf(!swiftAvailable)('Swift extension deduplication', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'swift-extension-dedup'), () => {});
}, 60000);
it('detects Product class', () => {
expect(getNodesByLabel(result, 'Class')).toContain('Product');
});
it('resolves Product() constructor despite extension creating duplicate class node', () => {
const calls = getRelationships(result, 'CALLS');
const ctorCall = calls.find((c) => c.target === 'Product' && c.source === 'process');
expect(ctorCall).toBeDefined();
});
it('resolves product.save() to Product.swift (primary definition)', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(
(c) => c.target === 'save' && c.source === 'process' && c.targetFilePath === 'Product.swift',
);
expect(saveCall).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Protocol-extension implicit self (issue #3273): a conforming type may call
// default implementation methods without an explicit receiver. Resolution
// must traverse the protocol's extension surface instead of falling back to
// unrelated same-named private methods elsewhere in the module.
// ---------------------------------------------------------------------------
describe.skipIf(!swiftAvailable)('Swift protocol-extension implicit self (#3273)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'swift-protocol-extension-implicit-self'),
() => {},
);
}, 60000);
it('resolves unqualified helper calls to the protocol extension', () => {
const calls = getRelationships(result, 'CALLS').filter((c) => c.source === 'run');
for (const target of ['makeStore', 'makeValue', 'insertItem']) {
const call = calls.find((c) => c.target === target);
expect(call?.targetFilePath).toBe('Support.swift');
}
});
it('keeps unrelated private same-name methods unreachable', () => {
const calls = getRelationships(result, 'CALLS').filter((c) => c.source === 'run');
expect(calls.some((c) => c.targetFilePath === 'AUnrelated.swift')).toBe(false);
});
it('preserves the downstream call through the extension helper return type', () => {
const calls = getRelationships(result, 'CALLS');
const execute = calls.find((c) => c.source === 'run' && c.target === 'execute');
expect(execute?.targetFilePath).toBe('Support.swift');
expect(
result.resolutionOutcomes.some(
(outcome) =>
outcome.kind === 'suppressed' &&
outcome.reason === 'receiver-unresolved' &&
outcome.filePath === 'Scenario.swift' &&
outcome.name === 'execute',
),
).toBe(false);
});
});
// ---------------------------------------------------------------------------
// Constructor fallback: Swift constructors look like free function calls
// (no `new` keyword). The resolver retries with constructor form when
// free-form finds no callable but the name resolves to a Class/Struct.
// ---------------------------------------------------------------------------
describe.skipIf(!swiftAvailable)('Swift constructor call fallback (no new keyword)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'swift-constructor-fallback'), () => {});
}, 60000);
it('resolves OCRService() as constructor call across files', () => {
const calls = getRelationships(result, 'CALLS');
const ctorCall = calls.find(
(c) => c.target === 'OCRService' && c.targetFilePath === 'Service.swift',
);
expect(ctorCall).toBeDefined();
});
it('resolves ocr.recognize() member call via constructor-inferred type', () => {
const calls = getRelationships(result, 'CALLS');
const memberCall = calls.find(
(c) => c.target === 'recognize' && c.targetFilePath === 'Service.swift',
);
expect(memberCall).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Export visibility: internal (default) symbols are cross-file visible,
// private/fileprivate are not. Verifies the export detection inversion.
// ---------------------------------------------------------------------------
describe.skipIf(!swiftAvailable)('Swift export visibility (internal vs private)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'swift-export-visibility'), () => {});
}, 60000);
it('resolves PublicService() constructor across files', () => {
const calls = getRelationships(result, 'CALLS');
const ctorCall = calls.find(
(c) => c.target === 'PublicService' && c.targetFilePath === 'Visible.swift',
);
expect(ctorCall).toBeDefined();
});
it('resolves internalHelper() across files (internal = module-scoped)', () => {
const calls = getRelationships(result, 'CALLS');
const helperCall = calls.find(
(c) => c.target === 'internalHelper' && c.targetFilePath === 'Visible.swift',
);
expect(helperCall).toBeDefined();
});
// NOTE: private/fileprivate symbols are marked as unexported, which prevents
// Tier 2a (import-scoped) resolution. However, Tier 3 (global) still resolves
// them — export filtering at global scope is a separate enhancement.
// 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).
//
// KNOWN GAP: The type-env correctly stores declarationTypeNodes for Swift
// array types ([User]), but the scope-resolution call 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 the TypeEnv is rebuilt 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);
});
});
// ── Phase 8: Field-type resolution ──────────────────────────────────────
describe.skipIf(!swiftAvailable)('Swift field-type resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'swift-field-types'), () => {});
}, 60000);
it('detects classes and their properties', () => {
expect(getNodesByLabel(result, 'Class')).toEqual(expect.arrayContaining(['Address', 'User']));
const properties = getNodesByLabel(result, 'Property');
expect(properties).toContain('address');
expect(properties).toContain('city');
expect(properties).toContain('name');
});
it('emits HAS_PROPERTY edges from class to field', () => {
const propEdges = getRelationships(result, 'HAS_PROPERTY');
expect(edgeSet(propEdges)).toEqual(
expect.arrayContaining(['User → address', 'Address → city']),
);
});
it('resolves field-chain call user.address.save() → Address#save', () => {
const calls = getRelationships(result, 'CALLS');
const saveCalls = calls.filter((c) => c.target === 'save' && c.source === 'processUser');
expect(saveCalls.length).toBe(1);
expect(saveCalls[0]!.targetFilePath).toContain('Models.swift');
});
it('emits ACCESSES edges for field reads in chains', () => {
const accesses = getRelationships(result, 'ACCESSES');
const addressReads = accesses.filter((e) => e.target === 'address' && e.rel.reason === 'read');
expect(addressReads.length).toBeGreaterThanOrEqual(1);
expect(addressReads[0]!.source).toBe('processUser');
expect(addressReads[0]!.targetLabel).toBe('Property');
});
it('populates field metadata (visibility, declaredType) on Property nodes', () => {
const properties = getNodesByLabelFull(result, 'Property');
const city = properties.find((p) => p.name === 'city');
expect(city).toBeDefined();
// Swift default visibility is 'internal', not 'public'
expect(city!.properties.visibility).toBe('internal');
expect(city!.properties.isStatic).toBe(false);
expect(city!.properties.declaredType).toBe('String');
const addr = properties.find((p) => p.name === 'address');
expect(addr).toBeDefined();
expect(addr!.properties.visibility).toBe('internal');
expect(addr!.properties.declaredType).toBe('Address');
});
});
// ── Phase 9: Call-result binding ────────────────────────────────────────
describe.skipIf(!swiftAvailable)('Swift call-result binding', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'swift-call-result-binding'), () => {});
}, 60000);
it('resolves call-result-bound method call user.save() → User#save', () => {
const calls = getRelationships(result, 'CALLS');
const saveCalls = calls.filter((c) => c.target === 'save' && c.source === 'processUser');
expect(saveCalls.length).toBe(1);
expect(saveCalls[0]!.targetFilePath).toContain('Models.swift');
});
it('getUser() is present as a defined function', () => {
expect(getNodesByLabel(result, 'Function')).toContain('getUser');
});
it('emits processUser -> getUser CALLS edge for let-assigned free function call', () => {
const calls = getRelationships(result, 'CALLS');
const getUserCall = calls.find((c) => c.target === 'getUser' && c.source === 'processUser');
expect(getUserCall).toBeDefined();
expect(getUserCall!.targetFilePath).toContain('Models.swift');
});
});
// ---------------------------------------------------------------------------
// Method enrichment: isAbstract, isFinal, isStatic, annotations
// Animal protocol with speak(), Dog class with speak(), static classify(),
// @objc final breathe(). App.swift calls dog.speak() and Dog.classify().
// ---------------------------------------------------------------------------
describe.skipIf(!swiftAvailable)('Swift method enrichment', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'swift-method-enrichment'), () => {});
}, 60000);
it('detects Animal protocol and Dog class', () => {
expect(getNodesByLabel(result, 'Interface')).toContain('Animal');
expect(getNodesByLabel(result, 'Class')).toContain('Dog');
});
it('emits IMPLEMENTS edge Dog -> Animal', () => {
const implements_ = getRelationships(result, 'IMPLEMENTS');
const edge = implements_.find((e) => e.source === 'Dog' && e.target === 'Animal');
expect(edge).toBeDefined();
});
it('emits HAS_METHOD edges for Dog methods', () => {
const hasMethod = getRelationships(result, 'HAS_METHOD');
const dogMethods = hasMethod
.filter((e) => e.source === 'Dog')
.map((e) => e.target)
.sort();
expect(dogMethods).toContain('speak');
expect(dogMethods).toContain('classify');
expect(dogMethods).toContain('breathe');
});
it('marks protocol Animal.speak as isAbstract', () => {
// Protocol method declarations are emitted as 'Method' nodes (not 'Function')
const methods = getNodesByLabelFull(result, 'Method');
const speak = methods.find(
(n) => n.name === 'speak' && n.properties.filePath === 'Sources/Animal.swift',
);
expect(speak).toBeDefined();
expect(speak!.properties.isAbstract).toBe(true);
});
it('marks Dog.speak as NOT isAbstract', () => {
// Dog's speak is a 'Function' node; both protocol and Dog are in Animal.swift,
// so distinguish by startLine: Dog.speak is at line 5 (0-indexed).
const methods = getNodesByLabelFull(result, 'Function');
const dogSpeak = methods.find((n) => n.name === 'speak' && n.properties.startLine === 5);
expect(dogSpeak).toBeDefined();
expect(dogSpeak!.properties.isAbstract).toBe(false);
});
it('marks breathe as isFinal', () => {
const methods = getNodesByLabelFull(result, 'Function');
const breathe = methods.find((n) => n.name === 'breathe');
expect(breathe).toBeDefined();
expect(breathe!.properties.isFinal).toBe(true);
});
it('marks classify as isStatic', () => {
const methods = getNodesByLabelFull(result, 'Function');
const classify = methods.find((n) => n.name === 'classify');
expect(classify).toBeDefined();
expect(classify!.properties.isStatic).toBe(true);
});
it('captures @objc annotation on breathe', () => {
const methods = getNodesByLabelFull(result, 'Function');
const breathe = methods.find((n) => n.name === 'breathe');
expect(breathe).toBeDefined();
expect(breathe!.properties.annotations).toContain('@objc');
});
it('populates parameterTypes for classify(_ name: String)', () => {
const methods = getNodesByLabelFull(result, 'Function');
const classify = methods.find((n) => n.name === 'classify');
expect(classify).toBeDefined();
expect(classify!.properties.parameterTypes).toContain('String');
});
it('records parameterCount for classify', () => {
const methods = getNodesByLabelFull(result, 'Function');
const classify = methods.find((n) => n.name === 'classify');
expect(classify).toBeDefined();
expect(classify!.properties.parameterCount).toBe(1);
});
it('records returnType for speak', () => {
// Dog.speak is a 'Function' node at startLine 5 (0-indexed); the protocol speak
// is a 'Method' node, so filtering Function by name gives Dog's implementation.
const methods = getNodesByLabelFull(result, 'Function');
const speak = methods.find((n) => n.name === 'speak' && n.properties.startLine === 5);
expect(speak).toBeDefined();
expect(speak!.properties.returnType).toBe('String');
});
it('resolves dog.speak() CALLS edge', () => {
const calls = getRelationships(result, 'CALLS');
const speakCall = calls.find(
(c) => c.target === 'speak' && c.sourceFilePath === 'Sources/App.swift',
);
expect(speakCall).toBeDefined();
});
it('resolves Dog.classify("dog") CALLS edge', () => {
const calls = getRelationships(result, 'CALLS');
const classifyCall = calls.find(
(c) => c.target === 'classify' && c.sourceFilePath === 'Sources/App.swift',
);
expect(classifyCall).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Abstract dispatch: protocol base + concrete implementation + receiver resolution
// Repository protocol with find(id:), save(entity:)
// SqlRepository class implements both
// App.swift: repo = SqlRepository(); repo.find(id: 42); repo.save(entity: user)
// ---------------------------------------------------------------------------
describe.skipIf(!swiftAvailable)('Swift abstract dispatch', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'swift-abstract-dispatch'), () => {});
}, 60000);
it('detects Repository protocol and SqlRepository class', () => {
expect(getNodesByLabel(result, 'Interface')).toContain('Repository');
expect(getNodesByLabel(result, 'Class')).toContain('SqlRepository');
});
it('emits IMPLEMENTS edge SqlRepository -> Repository', () => {
const implements_ = getRelationships(result, 'IMPLEMENTS');
const edge = implements_.find((e) => e.source === 'SqlRepository' && e.target === 'Repository');
expect(edge).toBeDefined();
});
it('emits HAS_METHOD edges for Repository.find and Repository.save', () => {
const hasMethod = getRelationships(result, 'HAS_METHOD');
const repoFind = hasMethod.find((e) => e.source === 'Repository' && e.target === 'find');
const repoSave = hasMethod.find((e) => e.source === 'Repository' && e.target === 'save');
expect(repoFind).toBeDefined();
expect(repoSave).toBeDefined();
});
it('emits HAS_METHOD edges for SqlRepository.find and SqlRepository.save', () => {
const hasMethod = getRelationships(result, 'HAS_METHOD');
const sqlFind = hasMethod.find((e) => e.source === 'SqlRepository' && e.target === 'find');
const sqlSave = hasMethod.find((e) => e.source === 'SqlRepository' && e.target === 'save');
expect(sqlFind).toBeDefined();
expect(sqlSave).toBeDefined();
});
it('marks base Repository.find as isAbstract', () => {
// Protocol method declarations are emitted as 'Method' nodes (not 'Function')
const methods = getNodesByLabelFull(result, 'Method');
const baseFind = methods.find(
(n) => n.name === 'find' && n.properties.filePath === 'Sources/Repository.swift',
);
expect(baseFind).toBeDefined();
expect(baseFind!.properties.isAbstract).toBe(true);
});
it('marks base Repository.save as isAbstract', () => {
// Protocol method declarations are emitted as 'Method' nodes (not 'Function')
const methods = getNodesByLabelFull(result, 'Method');
const baseSave = methods.find(
(n) => n.name === 'save' && n.properties.filePath === 'Sources/Repository.swift',
);
expect(baseSave).toBeDefined();
expect(baseSave!.properties.isAbstract).toBe(true);
});
it('marks concrete SqlRepository.find as NOT isAbstract', () => {
// SqlRepository and Repository are both in Repository.swift; distinguish by
// startLine: SqlRepository.find starts at line 6 (0-indexed).
const methods = getNodesByLabelFull(result, 'Function');
const sqlFind = methods.find((n) => n.name === 'find' && n.properties.startLine === 6);
expect(sqlFind).toBeDefined();
expect(sqlFind!.properties.isAbstract).toBe(false);
});
it('resolves repo.find(id: 42) CALLS edge', () => {
const calls = getRelationships(result, 'CALLS');
const findCall = calls.find(
(c) => c.target === 'find' && c.sourceFilePath === 'Sources/App.swift',
);
expect(findCall).toBeDefined();
});
it('resolves repo.save(entity: user) CALLS edge', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(
(c) => c.target === 'save' && c.sourceFilePath === 'Sources/App.swift',
);
expect(saveCall).toBeDefined();
});
it('populates parameterTypes for Repository.find', () => {
// Protocol method declarations are 'Method' nodes
const methods = getNodesByLabelFull(result, 'Method');
const baseFind = methods.find(
(n) => n.name === 'find' && n.properties.filePath === 'Sources/Repository.swift',
);
expect(baseFind).toBeDefined();
expect(baseFind!.properties.parameterTypes).toContain('Int');
});
it('populates parameterTypes for Repository.save', () => {
// Protocol method declarations are 'Method' nodes
const methods = getNodesByLabelFull(result, 'Method');
const baseSave = methods.find(
(n) => n.name === 'save' && n.properties.filePath === 'Sources/Repository.swift',
);
expect(baseSave).toBeDefined();
expect(baseSave!.properties.parameterTypes).toContain('String');
});
it('records returnType for SqlRepository.find', () => {
// SqlRepository.find is a 'Function' node at startLine 6 (0-indexed)
const methods = getNodesByLabelFull(result, 'Function');
const sqlFind = methods.find((n) => n.name === 'find' && n.properties.startLine === 6);
expect(sqlFind).toBeDefined();
expect(sqlFind!.properties.returnType).toBe('String');
});
it('emits METHOD_IMPLEMENTS edges from SqlRepository methods → Repository protocol methods', () => {
const mi = getRelationships(result, 'METHOD_IMPLEMENTS');
const edges = mi.filter((e) => e.sourceFilePath.includes('Repository.swift'));
expect(edges.length).toBe(2);
const names = edges.map((e) => e.source).sort();
expect(names).toEqual(['find', 'save']);
});
});
// ---------------------------------------------------------------------------
// Overloaded method disambiguation: protocol with overloaded find + save,
// concrete class implements all three. Verifies METHOD_IMPLEMENTS edges
// correctly distinguish between overloaded signatures.
// ---------------------------------------------------------------------------
describe.skipIf(!swiftAvailable)('Swift overloaded method disambiguation', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'swift-overload-dispatch'), () => {});
}, 60000);
it('detects 2 distinct find Method nodes on SqlRepository', () => {
const methods = getNodesByLabelFull(result, 'Method');
const sqlRepoFinds = methods.filter(
(m) => m.name === 'find' && m.properties.filePath?.includes('SqlRepository'),
);
// Swift class methods may be emitted as Function nodes
const functions = getNodesByLabelFull(result, 'Function');
const sqlRepoFindFns = functions.filter(
(m) => m.name === 'find' && m.properties.filePath?.includes('SqlRepository'),
);
const totalFinds = sqlRepoFinds.length + sqlRepoFindFns.length;
expect(totalFinds).toBe(2);
});
it('emits METHOD_IMPLEMENTS edges for both find overloads', () => {
const mi = getRelationships(result, 'METHOD_IMPLEMENTS');
const findEdges = mi.filter(
(e) =>
e.source === 'find' &&
e.target === 'find' &&
e.sourceFilePath.includes('SqlRepository') &&
e.targetFilePath.includes('Repository'),
);
expect(findEdges.length).toBe(2);
});
it('emits METHOD_IMPLEMENTS edge for save', () => {
const mi = getRelationships(result, 'METHOD_IMPLEMENTS');
const saveEdge = mi.find(
(e) =>
e.source === 'save' &&
e.target === 'save' &&
e.sourceFilePath.includes('SqlRepository') &&
e.targetFilePath.includes('Repository'),
);
expect(saveEdge).toBeDefined();
});
it('emits exactly 3 METHOD_IMPLEMENTS edges total', () => {
const mi = getRelationships(result, 'METHOD_IMPLEMENTS');
expect(mi.length).toBe(3);
});
});
// ---------------------------------------------------------------------------
// SM-9/SM-10: inherited method resolution — Swift first-wins inheritance walk
// ---------------------------------------------------------------------------
describe.skipIf(!swiftAvailable)(
'Swift Child extends Parent — inherited method resolution (SM-9)',
() => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'swift-child-extends-parent'),
() => {},
);
}, 60000);
it('detects Parent and Child classes', () => {
const classes = getNodesByLabel(result, 'Class');
expect(classes).toContain('Parent');
expect(classes).toContain('Child');
});
it('resolves c.parentMethod() to Parent.parentMethod via first-wins MRO walk', () => {
const calls = getRelationships(result, 'CALLS');
const parentMethodCall = calls.find(
(c) => c.target === 'parentMethod' && c.targetFilePath.includes('Parent.swift'),
);
expect(parentMethodCall).toBeDefined();
expect(parentMethodCall!.source).toBe('run');
});
},
);
// ---------------------------------------------------------------------------
// U3 — SPM-target subtree grouping (issue #1948). A Swift module is an SPM
// TARGET (a directory SUBTREE `Sources/<Target>/…`), not the immediate
// containing directory. `swift-multidir-target` has target `Alpha` spread
// across `Sources/Alpha/Core` + `Sources/Alpha/Entry` plus a colliding
// same-simple-named `User` in target `Beta` (`Sources/Beta/Core`). Grouping
// by SPM target (not by immediate dir) keeps Alpha's two subdirs in one
// module, so `User()` in Alpha/Entry resolves to Alpha/Core/User (NOT
// Beta/Core/User) and cross-dir IMPORTS within Alpha emit — while Alpha and
// Beta stay distinct modules with NO IMPORTS between them.
// ---------------------------------------------------------------------------
describe.skipIf(!swiftAvailable)('Swift SPM multi-directory target grouping', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'swift-multidir-target'), () => {});
}, 60000);
it('resolves User() in Alpha/Entry to Alpha/Core/User, not Beta/Core/User', () => {
const calls = getRelationships(result, 'CALLS');
const ctorCall = calls.find(
(c) =>
c.target === 'User' &&
c.targetLabel === 'Class' &&
c.sourceFilePath === 'Sources/Alpha/Entry/App.swift',
);
expect(ctorCall).toBeDefined();
expect(ctorCall!.targetFilePath).toBe('Sources/Alpha/Core/User.swift');
});
it('resolves user.alphaSave() across directories within the Alpha target', () => {
const calls = getRelationships(result, 'CALLS');
const memberCall = calls.find(
(c) => c.target === 'alphaSave' && c.source === 'processEntities',
);
expect(memberCall).toBeDefined();
expect(memberCall!.targetFilePath).toBe('Sources/Alpha/Core/User.swift');
});
it('puts both directories of the Alpha target in one module (Entry <-> Core)', () => {
const entry = moduleHubsOf(result, 'Sources/Alpha/Entry/App.swift');
expect(entry).toHaveLength(1);
expect(entry).toEqual(moduleHubsOf(result, 'Sources/Alpha/Core/User.swift'));
expect(entry).not.toEqual(moduleHubsOf(result, 'Sources/Beta/Core/User.swift'));
});
it('does NOT emit IMPORTS across distinct targets (no Alpha <-> Beta)', () => {
const imports = getRelationships(result, 'IMPORTS');
const crossTarget = imports.find(
(c) =>
(c.sourceFilePath.startsWith('Sources/Alpha/') &&
c.targetFilePath.startsWith('Sources/Beta/')) ||
(c.sourceFilePath.startsWith('Sources/Beta/') &&
c.targetFilePath.startsWith('Sources/Alpha/')),
);
expect(crossTarget).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// U3 — No-package multi-folder fallback (issue #1948). With NO scanned
// source dir (`Sources/`/`Package/Sources/`/`src/`) `loadSwiftPackageConfig`
// returns null, so ALL files form one `__default__` module (single-Xcode-
// project assumption). `swift-multifolder-nopackage` spreads files across
// `Models/` + `Services/` with no manifest; cross-folder visibility must
// still resolve and emit IMPORTS because the whole repo is one module.
// ---------------------------------------------------------------------------
describe.skipIf(!swiftAvailable)(
'Swift no-package multi-folder fallback (__default__ module)',
() => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'swift-multifolder-nopackage'),
() => {},
);
}, 60000);
it('resolves User() in Services to Models/User.swift across folders', () => {
const calls = getRelationships(result, 'CALLS');
const ctorCall = calls.find(
(c) =>
c.target === 'User' &&
c.targetLabel === 'Class' &&
c.sourceFilePath === 'Services/App.swift',
);
expect(ctorCall).toBeDefined();
expect(ctorCall!.targetFilePath).toBe('Models/User.swift');
});
it('resolves user.save() across folders to Models/User.swift', () => {
const calls = getRelationships(result, 'CALLS');
const memberCall = calls.find((c) => c.target === 'save' && c.source === 'processEntities');
expect(memberCall).toBeDefined();
expect(memberCall!.targetFilePath).toBe('Models/User.swift');
});
it('puts Models and Services in one __default__ module', () => {
expect(moduleHubsOf(result, 'Services/App.swift')).toHaveLength(1);
expect(moduleHubsOf(result, 'Services/App.swift')).toEqual(
moduleHubsOf(result, 'Models/User.swift'),
);
});
},
);
// ---------------------------------------------------------------------------
// #3355 — Nested SwiftPM packages with no root manifest. Each package's
// targets are their own module: files see same-target siblings only, and two
// packages declaring a target named `Net` stay two modules.
// ---------------------------------------------------------------------------
describe.skipIf(!swiftAvailable)('Swift nested packages (no root Package.swift)', () => {
let result: PipelineResult;
const login = 'Features/Login/Sources/Login';
const otherNet = 'Features/Other/Sources/Net';
const coreNet = 'Core/Net/Sources/Net';
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'swift-nested-packages'), () => {});
}, 60000);
it('puts files of the same target in one module', () => {
expect(moduleHubsOf(result, `${coreNet}/Session.swift`)).toHaveLength(1);
expect(moduleHubsOf(result, `${coreNet}/Session.swift`)).toEqual(
moduleHubsOf(result, `${coreNet}/Client.swift`),
);
expect(moduleHubsOf(result, `${login}/LoginFlow.swift`)).toHaveLength(1);
expect(moduleHubsOf(result, `${login}/LoginFlow.swift`)).toEqual(
moduleHubsOf(result, `${login}/Config.swift`),
);
});
it('keeps every package in its own module, including same-named targets', () => {
const hubs = [
`${coreNet}/Client.swift`,
`${otherNet}/Config.swift`,
`${login}/Config.swift`,
].map((file) => moduleHubsOf(result, file));
expect(hubs.every((h) => h.length === 1)).toBe(true);
expect(new Set(hubs.map((h) => h[0])).size).toBe(3);
});
it('keeps package manifests and files outside every target out of any module', () => {
for (const file of [
'Core/Net/Package.swift',
'Features/Login/Package.swift',
'Docs/Net/Snippet.swift',
]) {
expect(moduleHubsOf(result, file)).toEqual([]);
}
});
it('resolves `import Net` by module name, not by a folder that happens to be named Net', () => {
const imported = getRelationships(result, 'IMPORTS')
.filter((c) => c.sourceFilePath === `${login}/Connect.swift` && c.targetLabel === 'File')
.map((c) => c.targetFilePath)
.sort();
expect(imported).toEqual([
`${coreNet}/Client.swift`,
`${coreNet}/Session.swift`,
`${otherNet}/Config.swift`,
`${otherNet}/Use.swift`,
]);
});
it("resolves Config() to the caller's own package", () => {
const ctorCalls = getRelationships(result, 'CALLS').filter(
(c) => c.target === 'Config' && c.targetLabel === 'Class',
);
expect(ctorCalls.map((c) => `${c.sourceFilePath}->${c.targetFilePath}`).sort()).toEqual([
`${login}/LoginFlow.swift->${login}/Config.swift`,
`${otherNet}/Use.swift->${otherNet}/Config.swift`,
]);
});
});
// ---------------------------------------------------------------------------
// #3355 — Xcode native targets from project.pbxproj. The app and its widget
// extension are separate modules that each declare a `Config` class.
// ---------------------------------------------------------------------------
describe.skipIf(!swiftAvailable)('Swift Xcode targets (project.pbxproj)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'swift-xcode-targets'), () => {});
}, 60000);
it('makes each Xcode target its own module', () => {
const app = moduleHubsOf(result, 'App/AppMain.swift');
const widget = moduleHubsOf(result, 'Widget/WidgetMain.swift');
expect(app).toHaveLength(1);
expect(widget).toHaveLength(1);
expect(app).toEqual(moduleHubsOf(result, 'App/Config.swift'));
expect(widget).toEqual(moduleHubsOf(result, 'Widget/Config.swift'));
expect(app).not.toEqual(widget);
});
it("resolves Config() to the caller's own target", () => {
const ctorCalls = getRelationships(result, 'CALLS').filter(
(c) => c.target === 'Config' && c.targetLabel === 'Class',
);
expect(ctorCalls.map((c) => `${c.sourceFilePath}->${c.targetFilePath}`).sort()).toEqual([
'App/AppMain.swift->App/Config.swift',
'Widget/WidgetMain.swift->Widget/Config.swift',
]);
});
});
// ---------------------------------------------------------------------------
// U4 — BUG1: member-write read/write classification (issue #1948). A Swift
// assignment LHS `obj.field = x` is wrapped in `directly_assignable_expression`
// (verified, tree-sitter-swift 0.7.1), so the old `parent.type === 'assignment'`
// write guard was dead — member writes leaked as spurious READ ACCESSES and no
// WRITE edge emitted. The fix re-tags the write-LHS navigation to
// `@reference.write.member`, so a `write` ACCESSES edge emits (for BOTH a
// `self.field = x` receiver-bound write AND a non-self `obj.field = x`) and no
// spurious read appears at the LHS. Genuine standalone field READs
// (`let y = obj.field`) — not just field-access CHAINS feeding a call — emit a
// read ACCESSES.
// ---------------------------------------------------------------------------
describe.skipIf(!swiftAvailable)('Swift member-write ACCESSES (read/write classification)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'swift-member-write-access'), () => {});
}, 60000);
it('emits write ACCESSES for self.field = x (self-receiver) with no spurious read at the LHS', () => {
const accesses = getRelationships(result, 'ACCESSES');
// init: `self.balance = start`; deposit: `self.balance = amount`.
const balanceWrites = accesses.filter(
(e) => e.target === 'balance' && e.targetLabel === 'Property' && e.rel.reason === 'write',
);
const writeSources = balanceWrites.map((e) => e.source).sort();
expect(writeSources).toContain('init');
expect(writeSources).toContain('deposit');
// No spurious READ at the write LHS (init/deposit only WRITE balance).
const balanceReadsFromWriters = accesses.filter(
(e) =>
e.target === 'balance' &&
e.rel.reason === 'read' &&
(e.source === 'init' || e.source === 'deposit'),
);
expect(balanceReadsFromWriters).toHaveLength(0);
});
it('emits write ACCESSES for obj.field = y (non-self receiver) with no spurious read at the LHS', () => {
const accesses = getRelationships(result, 'ACCESSES');
// App.swift `transfer`: `acct.owner = "alice"` — non-self receiver,
// `acct`'s type (Account) must resolve first, then `owner` resolves.
const ownerWrite = accesses.find(
(e) =>
e.target === 'owner' &&
e.targetLabel === 'Property' &&
e.source === 'transfer' &&
e.rel.reason === 'write',
);
expect(ownerWrite).toBeDefined();
expect(ownerWrite!.targetFilePath).toBe('Models.swift');
// No spurious READ at the LHS of the non-self write.
const spuriousRead = accesses.find(
(e) => e.target === 'owner' && e.source === 'transfer' && e.rel.reason === 'read',
);
expect(spuriousRead).toBeUndefined();
});
// A STANDALONE field read (`let current = self.balance`, `let who = acct.owner`)
// — not just a field-access CHAIN feeding a call (e.g. `user.address.save()`) —
// emits a read ACCESSES via the reference-site `read` kind.
it('still emits a read ACCESSES for a genuine standalone field read (not the write LHS)', () => {
const accesses = getRelationships(result, 'ACCESSES');
// readBalance: `let current = self.balance` (self read).
const balanceRead = accesses.find(
(e) => e.target === 'balance' && e.source === 'readBalance' && e.rel.reason === 'read',
);
expect(balanceRead).toBeDefined();
expect(balanceRead!.targetLabel).toBe('Property');
// inspect: `let who = acct.owner` (non-self read).
const ownerRead = accesses.find(
(e) => e.target === 'owner' && e.source === 'inspect' && e.rel.reason === 'read',
);
expect(ownerRead).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// U4 — BUG2: `class func` self-binding (issue #1948). A Swift `class func`
// (type method) emits a BARE anonymous `class` token directly under
// `function_declaration` (verified, tree-sitter-swift 0.7.1), whereas
// `static func` emits it under a `modifiers > property_modifier` wrapper. The
// old `isStaticMethod` scanned only the `modifiers` wrapper, so a `class func`
// wrongly received a `self: <Type>` INSTANCE binding (it should have none — a
// type method has no instance receiver). The fix delegates to
// `swiftMethodConfig.isStatic`, which detects both via `hasKeyword('class')`.
//
// Observable signal: an instance `self.label` property read resolves with full
// self-binding provenance (`reason === 'read'`), but inside a `class func` /
// `static func` `self.label` has no instance binding, so it resolves only via
// the weaker lexical name fallback (`reason === 'scope-resolution: read'`).
// Pre-fix, the `class func` read carried the instance-binding provenance like
// `instanceCaller`; post-fix it matches `staticCaller`.
// ---------------------------------------------------------------------------
describe.skipIf(!swiftAvailable)('Swift class func receiver (no instance self-binding)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'swift-class-func-receiver'), () => {});
}, 60000);
it('a class func gets no instance self-binding (parity with static func; instance method differs)', () => {
const accesses = getRelationships(result, 'ACCESSES');
const reasonFor = (src: string): string | undefined =>
accesses.find((e) => e.target === 'label' && e.source === src && e.targetLabel === 'Property')
?.rel.reason;
const instanceReason = reasonFor('instanceCaller');
const classFuncReason = reasonFor('classCaller');
const staticFuncReason = reasonFor('staticCaller');
// Instance method has a real `self` receiver: full self-binding provenance.
expect(instanceReason).toBe('read');
// `class func` must behave EXACTLY like `static func`: no instance
// self-binding, so the read resolves only via the lexical name fallback.
expect(classFuncReason).toBe('scope-resolution: read');
expect(staticFuncReason).toBe('scope-resolution: read');
expect(classFuncReason).toBe(staticFuncReason);
// And it must NOT carry the instance method's self-binding provenance.
expect(classFuncReason).not.toBe(instanceReason);
});
});
// ---------------------------------------------------------------------------
// U4 — BUG3: multi-clause `if let` / `guard let` (issue #1948).
// `if let a = makeA(), let b = makeB()` has a FLAT child list where each clause
// is `value_binding_pattern · simple_identifier · = · call_expression`
// (verified, tree-sitter-swift 0.7.1). The old code read only the FIRST clause
// (`childForFieldName('bound_identifier')` returns just `a`), so the second
// binding `b: makeB() -> B` was never inferred. The fix walks all clauses and
// emits one `@type-binding.constructor` per clause.
//
// Observable signal: `b.shared()` where B.shared collides with Decoy.shared, so
// it resolves to B.shared ONLY via the second clause binding — a unique-name
// global fallback is ambiguous. The first clause `a.m()` (unique name) resolves
// directly; the second clause `b.shared()` resolves only via its type binding,
// since the ambiguous `shared` defeats the name fallback.
// ---------------------------------------------------------------------------
describe.skipIf(!swiftAvailable)('Swift multi-clause if-let / guard-let binding', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'swift-multi-if-let'), () => {});
}, 60000);
it('detects A, B and Decoy classes', () => {
const classes = getNodesByLabel(result, 'Class');
expect(classes).toContain('A');
expect(classes).toContain('B');
expect(classes).toContain('Decoy');
});
it('resolves b.shared() to B.shared via the SECOND if-let clause binding', () => {
const calls = getRelationships(result, 'CALLS');
const sharedCall = calls.find(
(c) =>
c.target === 'shared' &&
c.source === 'processIfLet' &&
c.rel.targetId === 'Function:Models.swift:B.shared#0',
);
expect(sharedCall).toBeDefined();
// It must NOT resolve to the colliding Decoy.shared.
const decoyCall = calls.find(
(c) =>
c.target === 'shared' &&
c.source === 'processIfLet' &&
c.rel.targetId === 'Function:Models.swift:Decoy.shared#0',
);
expect(decoyCall).toBeUndefined();
});
it('resolves b.shared() to B.shared via the SECOND guard-let clause binding', () => {
const calls = getRelationships(result, 'CALLS');
const sharedCall = calls.find(
(c) =>
c.target === 'shared' &&
c.source === 'processGuardLet' &&
c.rel.targetId === 'Function:Models.swift:B.shared#0',
);
expect(sharedCall).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// U4 — BUG4: nested-type extension re-keying (issue #1948).
// `extension Foo.Bar` parses to a `(user_type (type_identifier Foo)
// (type_identifier Bar))` name. The old code took `firstNamedChild` (`Foo`) as
// the extended type, re-keying the extension's members onto `Foo` and binding
// `self` to `Foo`. The fix uses `lastNamedChild` (`Bar`, the trailing
// identifier) in BOTH the captures re-key and `enclosingTypeName`, so members
// hoist onto Bar and `self == Bar`. Single-identifier `extension Foo` is
// unchanged (first === last). `base()` is split across files (Types.swift /
// Extension.swift) with a colliding Decoy.base so resolution depends purely on
// `self == Bar`.
//
// The HAS_METHOD hoisting assertion and the `self.base() -> Bar.base`
// resolution both run on the scope-resolution path, which resolves the
// cross-file extension self-call via `self == Bar`.
// ---------------------------------------------------------------------------
describe.skipIf(!swiftAvailable)('Swift nested-type extension (extension Foo.Bar)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'swift-nested-extension'), () => {});
}, 60000);
it('hoists added onto Bar (HAS_METHOD Foo.Bar -> added), not Foo', () => {
const hasMethod = getRelationships(result, 'HAS_METHOD');
const addedEdge = hasMethod.find(
(e) => e.target === 'added' && e.rel.sourceId === 'Class:Extension.swift:Foo.Bar',
);
expect(addedEdge).toBeDefined();
// Must NOT hoist onto a bare `Foo` owner.
const onFoo = hasMethod.find(
(e) => e.target === 'added' && e.rel.sourceId === 'Class:Types.swift:Foo',
);
expect(onFoo).toBeUndefined();
});
it('resolves self.base() inside added() to Bar.base (self == Bar), not Foo', () => {
const calls = getRelationships(result, 'CALLS');
const baseCall = calls.find((c) => c.target === 'base' && c.source === 'added');
expect(baseCall).toBeDefined();
expect(baseCall!.rel.targetId).toBe('Function:Types.swift:Bar.base#0');
});
});
// ---------------------------------------------------------------------------
// A bare constructor inside an extension must prefer a nested type owned by
// the extended type over an unrelated top-level type with the same short name.
// ---------------------------------------------------------------------------
describe.skipIf(!swiftAvailable)(
'Swift nested constructor lookup in a public qualified extension (#3262)',
() => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'swift-nested-constructor-extension'),
() => {},
);
}, 60000);
it('resolves Entry(id:text:) to Outer.Container.Entry and not the top-level Entry', () => {
const entryCalls = getRelationships(result, 'CALLS').filter(
(call) => call.source === 'makeEntry' && call.target === 'Entry',
);
expect(entryCalls.map((call) => call.rel.targetId)).toEqual(['Struct:Types.swift:Entry']);
expect(entryCalls.some((call) => call.rel.targetId === 'Struct:Standalone.swift:Entry')).toBe(
false,
);
});
},
);
// ---------------------------------------------------------------------------
// F75: protocol property requirements (`var title: String { get }`) are
// extracted as Property symbols owned by the protocol. Before the fix these
// protocol_property_declaration nodes were dropped (the structure query and
// field config only knew property_declaration).
// ---------------------------------------------------------------------------
describe.skipIf(!swiftAvailable)('Swift protocol property requirements (F75)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'swift-protocol-property'), () => {}, {
skipGraphPhases: true,
});
}, 60000);
it('detects the Repository protocol and its property requirements', () => {
expect(getNodesByLabel(result, 'Interface')).toContain('Repository');
const properties = getNodesByLabel(result, 'Property');
expect(properties).toContain('title');
expect(properties).toContain('count');
expect(properties).toContain('shared');
});
it('emits HAS_PROPERTY edges from the protocol to each requirement', () => {
const propEdges = getRelationships(result, 'HAS_PROPERTY');
expect(edgeSet(propEdges)).toEqual(
expect.arrayContaining(['Repository → title', 'Repository → count', 'Repository → shared']),
);
});
it('populates type + static metadata on protocol requirement Property nodes', () => {
const properties = getNodesByLabelFull(result, 'Property');
const title = properties.find(
(p) => p.name === 'title' && p.properties.filePath === 'Repository.swift',
);
expect(title).toBeDefined();
expect(title!.properties.declaredType).toBe('String');
expect(title!.properties.isStatic).toBe(false);
const count = properties.find(
(p) => p.name === 'count' && p.properties.filePath === 'Repository.swift',
);
expect(count).toBeDefined();
expect(count!.properties.declaredType).toBe('Int');
const shared = properties.find(
(p) => p.name === 'shared' && p.properties.filePath === 'Repository.swift',
);
expect(shared).toBeDefined();
expect(shared!.properties.isStatic).toBe(true);
});
it('still extracts the class stored property exactly once (regression)', () => {
const propEdges = getRelationships(result, 'HAS_PROPERTY');
const nameEdges = propEdges.filter((e) => e.target === 'name' && e.source === 'FileRepository');
expect(nameEdges).toHaveLength(1);
});
});
// ---------------------------------------------------------------------------
// F79: methods/members declared inside a Swift enum (enum_class_body) are
// extracted via the proper body-node path. Before the fix they only resolved
// through the generic findBodies fallback, which logs a dev-mode warning.
// ---------------------------------------------------------------------------
describe.skipIf(!swiftAvailable)('Swift enum members (F79)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'swift-enum-members'), () => {}, {
skipGraphPhases: true,
});
}, 60000);
it('extracts enum methods owned by the enum', () => {
const hasMethod = getRelationships(result, 'HAS_METHOD');
const enumMethods = hasMethod
.filter((e) => e.source === 'Direction')
.map((e) => e.target)
.sort();
expect(enumMethods).toContain('describe');
expect(enumMethods).toContain('make');
});
it('extracts each enum method exactly once (no double-count)', () => {
const hasMethod = getRelationships(result, 'HAS_METHOD');
const describeEdges = hasMethod.filter(
(e) => e.target === 'describe' && e.source === 'Direction',
);
expect(describeEdges).toHaveLength(1);
});
it('extracts an enum computed property as a Property of the enum', () => {
const propEdges = getRelationships(result, 'HAS_PROPERTY');
const labelEdge = propEdges.find((e) => e.target === 'label' && e.source === 'Direction');
expect(labelEdge).toBeDefined();
});
it('still extracts class methods (no regression / double-count)', () => {
const hasMethod = getRelationships(result, 'HAS_METHOD');
const headingEdges = hasMethod.filter((e) => e.target === 'heading' && e.source === 'Compass');
expect(headingEdges).toHaveLength(1);
});
});