Просмотр исходного кода

feat(resolution): Swift↔Objective-C cross-language bridge (Phase 1)

Closes the cross-language flow gap in mixed iOS codebases. Swift @objc
exports + Cocoa-style ObjC selectors now resolve across the language
boundary, so trace / callers / callees work for flows like an
Objective-C demo VC calling into a Swift framework.

Architecture (resolver pattern, per dynamic-dispatch playbook §3a):

- src/resolution/swift-objc-bridge.ts — pure name math implementing
  Apple's auto-bridging rules. `objcSelectorForSwiftMethod` produces
  the bridged selector for a Swift method declaration (`play(song:)`
  → `playWithSong:`; `tableView(_:didSelectRowAtIndexPath:)` →
  `tableView:didSelectRowAtIndexPath:`). `objcSelectorForSwiftInit`
  handles initializers (`initWith<First>:`). `objcAccessorsForSwiftProperty`
  produces getter+setter for `@objc` vars (`name` → `name` /
  `setName:`). `swiftBaseNamesForObjcSelector` does the reverse:
  given an ObjC selector, returns Swift base name candidates the
  resolver should look for (`objectForKey:` → `object`;
  `playWithSong:by:` → `playWithSong` or `play`). Preposition list
  (`With|For|By|In|On|At|From|To|Of|As`) covers both Swift's @objc
  export rule and Cocoa's natively-imported selectors. 31 unit tests.

- src/resolution/frameworks/swift-objc.ts — framework resolver
  registered alongside the existing Swift resolvers. Builds a one-time
  per-context reverse-bridge map (`Swift base name → ObjC method
  nodes`) on first resolve(); claims selector-shape refs through the
  pre-filter; routes each ref to the correct direction by source
  language. 12 integration tests with a mock ResolutionContext.

- src/index.ts — re-initialize the resolver after `indexAll()`
  completes. The resolver's `initialize()` runs at construction
  (before any files are indexed), so framework resolvers whose
  `detect()` consults the indexed file list (UIKit/SwiftUI scanning
  for imports, swift-objc-bridge looking for both Swift and ObjC files)
  all returned false on that initial pass and silently dropped
  themselves. Now they see the actual project before resolution runs
  — fixes a pre-existing, broader issue, not just the bridge.

Precision controls:
- Generic Cocoa names (`init`, `description`, `hash`, `isEqual`,
  `copy`, `count`, `add`, `value`, `load`, etc.) are
  blocklisted from the reverse-bridge map. Bridging them produces
  noise (every NSObject subclass has `init`), and the regular
  name-matcher handles them on its own.
- Bridge confidence is 0.6 so the regular name-matcher's 1.0 exact
  match wins on tie — bridge only fires when name-match returns
  nothing.
- ObjC→Swift direction requires the Swift candidate to be
  `@objc`-exposed (source-window check for `@objc` /
  `@nonobjc` annotations) — filters out same-named Swift methods
  that aren't bridged.

Charts (small iOS — 205 Swift + 59 ObjC files) validation:
- 28 bridge-resolved objc→swift edges; representative samples:
  - `handleOption:forChartView:` (ObjC demo) → `animate` (Swift)
  - `handleOption:forChartView:` (ObjC demo) → `notifyDataSetChanged` (Swift)
  - `setupPieChartView:` (ObjC demo) → `setExtraOffsets` (Swift)
  - `setDataCount:range:` (ObjC demo) → `setColor` (Swift)
- Pure-language baselines unchanged: Swift→Swift=2154, ObjC→ObjC=207.
- trace(handleOption:forChartView:, animate) connects across the
  bridge — the canonical demo→library flow.

902/904 existing tests still pass (2 skipped); +47 new bridge tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Colby McHenry 4 недель назад
Родитель
Сommit
958288c492

+ 205 - 0
__tests__/swift-objc-bridge-resolver.test.ts

@@ -0,0 +1,205 @@
+import { describe, it, expect } from 'vitest';
+import type { Node } from '../src/types';
+import type { ResolutionContext, UnresolvedRef } from '../src/resolution/types';
+import { swiftObjcBridgeResolver } from '../src/resolution/frameworks/swift-objc';
+
+/**
+ * Lightweight ResolutionContext mock — implements only the methods the
+ * bridge resolver actually calls. Anything else throws so a leaked call
+ * surfaces loudly in tests.
+ */
+function makeContext(nodes: Node[], fileContents: Record<string, string> = {}): ResolutionContext {
+  const byName = new Map<string, Node[]>();
+  for (const n of nodes) {
+    const arr = byName.get(n.name);
+    if (arr) arr.push(n);
+    else byName.set(n.name, [n]);
+  }
+  const allFiles = new Set(nodes.map((n) => n.filePath));
+  return {
+    getNodesInFile: (fp) => nodes.filter((n) => n.filePath === fp),
+    getNodesByName: (name) => byName.get(name) ?? [],
+    getNodesByQualifiedName: () => { throw new Error('not used'); },
+    getNodesByKind: (kind) => nodes.filter((n) => n.kind === kind),
+    getNodesByLowerName: () => { throw new Error('not used'); },
+    fileExists: (fp) => allFiles.has(fp),
+    readFile: (fp) => fileContents[fp] ?? null,
+    getProjectRoot: () => '/test',
+    getAllFiles: () => Array.from(allFiles),
+    getImportMappings: () => [],
+  };
+}
+
+function method(name: string, language: 'swift' | 'objc', filePath: string, startLine = 10): Node {
+  return {
+    id: `${language}:${filePath}:${name}:${startLine}`,
+    kind: 'method',
+    name,
+    qualifiedName: `${filePath}::${name}`,
+    filePath,
+    language,
+    startLine,
+    endLine: startLine + 5,
+    startColumn: 0,
+    endColumn: 0,
+    updatedAt: Date.now(),
+  } as Node;
+}
+
+function ref(name: string, language: 'swift' | 'objc', filePath: string): UnresolvedRef {
+  return {
+    fromNodeId: `caller:${filePath}`,
+    referenceName: name,
+    referenceKind: 'calls',
+    line: 1,
+    column: 0,
+    filePath,
+    language,
+  };
+}
+
+describe('swiftObjcBridgeResolver integration', () => {
+  describe('detect()', () => {
+    it('returns true when both .swift and .m files exist', () => {
+      const ctx = makeContext([
+        method('foo', 'swift', 'A.swift'),
+        method('bar', 'objc', 'B.m'),
+      ]);
+      expect(swiftObjcBridgeResolver.detect(ctx)).toBe(true);
+    });
+
+    it('returns false when only .swift files exist', () => {
+      const ctx = makeContext([method('foo', 'swift', 'A.swift')]);
+      expect(swiftObjcBridgeResolver.detect(ctx)).toBe(false);
+    });
+
+    it('returns true when .swift and .mm exist (ObjC++)', () => {
+      const ctx = makeContext([
+        method('foo', 'swift', 'A.swift'),
+        method('bar', 'objc', 'B.mm'),
+      ]);
+      expect(swiftObjcBridgeResolver.detect(ctx)).toBe(true);
+    });
+  });
+
+  describe('claimsReference()', () => {
+    it('claims selector-shape names (contain :)', () => {
+      expect(swiftObjcBridgeResolver.claimsReference?.('fooWithBar:')).toBe(true);
+      expect(swiftObjcBridgeResolver.claimsReference?.('tableView:didSelectRowAtIndexPath:')).toBe(true);
+      expect(swiftObjcBridgeResolver.claimsReference?.('setName:')).toBe(true);
+    });
+
+    it('does not claim bare names (handled by normal name-matcher)', () => {
+      expect(swiftObjcBridgeResolver.claimsReference?.('foo')).toBe(false);
+      expect(swiftObjcBridgeResolver.claimsReference?.('init')).toBe(false);
+    });
+  });
+
+  describe('resolve() — Swift → ObjC direction', () => {
+    it('resolves Swift call to Cocoa-style ObjC method (fetchEntry → fetchEntryForKey:)', () => {
+      // Swift writes `cache.fetchEntry(forKey: "x")` → ref name `fetchEntry`.
+      // ObjC method is `fetchEntryForKey:` (preposition-prefix shape).
+      // `fetchEntry` is project-specific (not in the generic-names blocklist
+      // that filters init/count/description/etc. to avoid Cocoa noise).
+      const objcTarget = method('fetchEntryForKey:', 'objc', 'Cache.m');
+      const ctx = makeContext([objcTarget]);
+      const result = swiftObjcBridgeResolver.resolve(
+        ref('fetchEntry', 'swift', 'Caller.swift'),
+        ctx
+      );
+      expect(result).not.toBeNull();
+      expect(result?.targetNodeId).toBe(objcTarget.id);
+      expect(result?.resolvedBy).toBe('framework');
+      expect(result?.confidence).toBe(0.6);
+    });
+
+    it('does NOT bridge generic Cocoa names like "init" or "description"', () => {
+      // Bridging Swift `init()` calls to arbitrary ObjC `init*:` methods is
+      // noise — every NSObject subclass has them. The regular name-matcher
+      // handles `init` on its own.
+      const objcInit = method('initWithFrame:', 'objc', 'View.m');
+      const ctx = makeContext([objcInit]);
+      const result = swiftObjcBridgeResolver.resolve(
+        ref('init', 'swift', 'Caller.swift'),
+        ctx
+      );
+      expect(result).toBeNull();
+    });
+
+    it('resolves bridged "With" form: Swift `play(song:)` → ObjC `playWithSong:`', () => {
+      const objcTarget = method('playWithSong:', 'objc', 'Player.m');
+      const ctx = makeContext([objcTarget]);
+      const result = swiftObjcBridgeResolver.resolve(
+        ref('play', 'swift', 'Caller.swift'),
+        ctx
+      );
+      expect(result?.targetNodeId).toBe(objcTarget.id);
+    });
+
+    it('returns null when no matching ObjC method exists', () => {
+      const ctx = makeContext([method('unrelated:thing:', 'objc', 'X.m')]);
+      const result = swiftObjcBridgeResolver.resolve(
+        ref('completelyDifferent', 'swift', 'Caller.swift'),
+        ctx
+      );
+      expect(result).toBeNull();
+    });
+  });
+
+  describe('resolve() — ObjC → Swift direction', () => {
+    it('resolves ObjC selector to @objc-exposed Swift method (exporter form)', () => {
+      // Swift @objc export of `func animate(xAxisDuration:, yAxisDuration:)`
+      // produces ObjC selector `animateWithXAxisDuration:yAxisDuration:`
+      // (always "With" insertion on first explicit label).
+      const swiftTarget = method('animate', 'swift', 'Chart.swift', 10);
+      const ctx = makeContext([swiftTarget], {
+        'Chart.swift':
+          '\n'.repeat(8) +
+          '@objc open func animate(xAxisDuration: Double, yAxisDuration: Double) {}\n',
+      });
+      const result = swiftObjcBridgeResolver.resolve(
+        ref('animateWithXAxisDuration:yAxisDuration:', 'objc', 'Caller.m'),
+        ctx
+      );
+      expect(result?.targetNodeId).toBe(swiftTarget.id);
+      expect(result?.resolvedBy).toBe('framework');
+    });
+
+    it('does NOT resolve if the Swift method is not @objc-exposed', () => {
+      const swiftTarget = method('animate', 'swift', 'Chart.swift', 10);
+      const ctx = makeContext([swiftTarget], {
+        // Plain `func` without @objc — bridge correctly skips it
+        'Chart.swift':
+          '\n'.repeat(8) +
+          'func animate(xAxisDuration: Double, yAxisDuration: Double) {}\n',
+      });
+      const result = swiftObjcBridgeResolver.resolve(
+        ref('animateWithXAxisDuration:yAxisDuration:', 'objc', 'Caller.m'),
+        ctx
+      );
+      expect(result).toBeNull();
+    });
+
+    it('resolves init selectors to Swift init', () => {
+      const swiftTarget = method('init', 'swift', 'MyClass.swift', 10);
+      const ctx = makeContext([swiftTarget], {
+        'MyClass.swift':
+          '\n'.repeat(8) + '@objc init(name: String, age: Int) {}\n',
+      });
+      const result = swiftObjcBridgeResolver.resolve(
+        ref('initWithName:age:', 'objc', 'Caller.m'),
+        ctx
+      );
+      expect(result?.targetNodeId).toBe(swiftTarget.id);
+    });
+
+    it('returns null for selectors with no derivable Swift candidates that exist', () => {
+      const ctx = makeContext([]);
+      const result = swiftObjcBridgeResolver.resolve(
+        ref('someUnknownThing:', 'objc', 'Caller.m'),
+        ctx
+      );
+      expect(result).toBeNull();
+    });
+  });
+});

+ 189 - 0
__tests__/swift-objc-bridge.test.ts

@@ -0,0 +1,189 @@
+import { describe, it, expect } from 'vitest';
+import {
+  objcSelectorForSwiftMethod,
+  objcSelectorForSwiftInit,
+  objcAccessorsForSwiftProperty,
+  swiftBaseNamesForObjcSelector,
+  detectExplicitObjcName,
+  isObjcExposed,
+} from '../src/resolution/swift-objc-bridge';
+
+describe('Swift → ObjC selector bridging (auto-name rules)', () => {
+  describe('objcSelectorForSwiftMethod', () => {
+    it('no parameters → bare base name', () => {
+      expect(objcSelectorForSwiftMethod('play', [])).toBe('play');
+    });
+
+    it('single _ param → base + ":"', () => {
+      expect(objcSelectorForSwiftMethod('play', ['_'])).toBe('play:');
+      expect(objcSelectorForSwiftMethod('play', [null])).toBe('play:');
+    });
+
+    it('single labeled param → "baseWithLabel:"', () => {
+      expect(objcSelectorForSwiftMethod('play', ['song'])).toBe('playWithSong:');
+    });
+
+    it('multi-param with leading _ → "base:label2:..."', () => {
+      expect(objcSelectorForSwiftMethod('play', ['_', 'by'])).toBe('play:by:');
+      expect(
+        objcSelectorForSwiftMethod('tableView', ['_', 'didSelectRowAtIndexPath'])
+      ).toBe('tableView:didSelectRowAtIndexPath:');
+    });
+
+    it('multi-param with leading explicit label → "baseWithFirst:rest:"', () => {
+      expect(objcSelectorForSwiftMethod('play', ['song', 'by'])).toBe(
+        'playWithSong:by:'
+      );
+    });
+
+    it('@objc(custom:) overrides the rule literally', () => {
+      expect(
+        objcSelectorForSwiftMethod('whateverName', ['ignored'], 'custom:')
+      ).toBe('custom:');
+    });
+
+    it('returns null on empty base name', () => {
+      expect(objcSelectorForSwiftMethod('', [])).toBeNull();
+    });
+  });
+
+  describe('objcSelectorForSwiftInit', () => {
+    it('init() → "init"', () => {
+      expect(objcSelectorForSwiftInit([], [])).toBe('init');
+    });
+
+    it('init(name:) → "initWithName:"', () => {
+      expect(objcSelectorForSwiftInit(['name'], ['name'])).toBe('initWithName:');
+    });
+
+    it('init(name:, age:) → "initWithName:age:"', () => {
+      expect(objcSelectorForSwiftInit(['name', 'age'], ['name', 'age'])).toBe(
+        'initWithName:age:'
+      );
+    });
+
+    it('init(_ name:) uses internal name → "initWithName:"', () => {
+      expect(objcSelectorForSwiftInit(['_'], ['name'])).toBe('initWithName:');
+    });
+
+    it('@objc(custom) override on init', () => {
+      expect(objcSelectorForSwiftInit(['name'], ['name'], 'custom:')).toBe(
+        'custom:'
+      );
+    });
+  });
+
+  describe('objcAccessorsForSwiftProperty', () => {
+    it('getter = name, setter = setName:', () => {
+      expect(objcAccessorsForSwiftProperty('name')).toEqual({
+        getter: 'name',
+        setter: 'setName:',
+      });
+    });
+
+    it('camelCase → set capitalizes first', () => {
+      expect(objcAccessorsForSwiftProperty('isReady')).toEqual({
+        getter: 'isReady',
+        setter: 'setIsReady:',
+      });
+    });
+
+    it('explicit @objc(custom) overrides getter name', () => {
+      expect(objcAccessorsForSwiftProperty('name', 'displayName')).toEqual({
+        getter: 'displayName',
+        setter: 'setDisplayName:',
+      });
+    });
+  });
+});
+
+describe('ObjC selector → Swift base name candidates (reverse map)', () => {
+  it('bare no-colon selector → itself', () => {
+    expect(swiftBaseNamesForObjcSelector('play')).toEqual(['play']);
+  });
+
+  it('"play:" → ["play"]', () => {
+    expect(swiftBaseNamesForObjcSelector('play:')).toEqual(['play']);
+  });
+
+  it('"playWithSong:" → ["playWithSong", "play"]', () => {
+    expect(swiftBaseNamesForObjcSelector('playWithSong:').sort()).toEqual(
+      ['play', 'playWithSong'].sort()
+    );
+  });
+
+  it('Cocoa-style "objectForKey:" → includes "object"', () => {
+    expect(swiftBaseNamesForObjcSelector('objectForKey:')).toContain('object');
+  });
+
+  it('Cocoa-style "stringWithFormat:" → includes "string"', () => {
+    expect(swiftBaseNamesForObjcSelector('stringWithFormat:')).toContain('string');
+  });
+
+  it('Cocoa-style "imageNamed:inBundle:" → first keyword has no preposition, falls through', () => {
+    // First keyword is `imageNamed` — no With/For/By in it, so candidates is
+    // just the raw keyword. (`Named` is not in our preposition list — keep
+    // it that way, otherwise we over-match on perfectly normal verbs.)
+    expect(swiftBaseNamesForObjcSelector('imageNamed:inBundle:')).toEqual(['imageNamed']);
+  });
+
+  it('"play:by:" → ["play"]', () => {
+    expect(swiftBaseNamesForObjcSelector('play:by:')).toEqual(['play']);
+  });
+
+  it('"playWithSong:by:" → ["playWithSong", "play"]', () => {
+    expect(swiftBaseNamesForObjcSelector('playWithSong:by:').sort()).toEqual(
+      ['play', 'playWithSong'].sort()
+    );
+  });
+
+  it('"initWithName:" → includes "init"', () => {
+    expect(swiftBaseNamesForObjcSelector('initWithName:')).toContain('init');
+  });
+
+  it('"initWithName:age:" → includes "init"', () => {
+    expect(swiftBaseNamesForObjcSelector('initWithName:age:')).toContain('init');
+  });
+
+  it('"setName:" → includes the property name "name"', () => {
+    expect(swiftBaseNamesForObjcSelector('setName:')).toContain('name');
+  });
+
+  it('"tableView:didSelectRowAtIndexPath:" → ["tableView"]', () => {
+    expect(
+      swiftBaseNamesForObjcSelector('tableView:didSelectRowAtIndexPath:')
+    ).toEqual(['tableView']);
+  });
+});
+
+describe('Source-window attribute detection', () => {
+  it('detects literal @objc(custom)', () => {
+    expect(detectExplicitObjcName('  @objc(custom:)\n  func foo() {}')).toBe(
+      'custom:'
+    );
+  });
+
+  it('returns null for plain @objc', () => {
+    expect(detectExplicitObjcName('@objc func foo() {}')).toBeNull();
+  });
+
+  it('returns null when no @objc at all', () => {
+    expect(detectExplicitObjcName('public func foo() {}')).toBeNull();
+  });
+
+  it('isObjcExposed true for @objc', () => {
+    expect(isObjcExposed('@objc func foo() {}')).toBe(true);
+  });
+
+  it('isObjcExposed true for @objc(custom)', () => {
+    expect(isObjcExposed('@objc(custom:) func foo() {}')).toBe(true);
+  });
+
+  it('isObjcExposed false for no annotation', () => {
+    expect(isObjcExposed('public func foo() {}')).toBe(false);
+  });
+
+  it('@nonobjc opts out even if @objc also present (e.g. inside @objcMembers class)', () => {
+    expect(isObjcExposed('@nonobjc @objc func foo() {}')).toBe(false);
+  });
+});

+ 11 - 0
src/index.ts

@@ -327,6 +327,17 @@ export class CodeGraph {
       try {
         const result = await this.orchestrator.indexAll(options.onProgress, options.signal, options.verbose);
 
+        // Re-detect frameworks now that the index is populated. The resolver
+        // is constructed with createResolver() before any files exist, so
+        // framework resolvers whose detect() consults the indexed file list
+        // (e.g. UIKit/SwiftUI scanning for imports, swift-objc-bridge looking
+        // for both Swift and ObjC files) all return false on that initial pass
+        // and silently drop themselves. Re-initializing here gives them a
+        // chance to see the actual project before resolution runs.
+        if (result.success && result.filesIndexed > 0) {
+          this.resolver.initialize();
+        }
+
         // Resolve references to create call/import/extends edges
         if (result.success && result.filesIndexed > 0) {
           // Get count without loading all refs into memory

+ 4 - 0
src/resolution/frameworks/index.ts

@@ -21,6 +21,7 @@ import { goResolver } from './go';
 import { rustResolver } from './rust';
 import { aspnetResolver } from './csharp';
 import { swiftUIResolver, uikitResolver, vaporResolver } from './swift';
+import { swiftObjcBridgeResolver } from './swift-objc';
 
 /**
  * All registered framework resolvers
@@ -54,6 +55,8 @@ const FRAMEWORK_RESOLVERS: FrameworkResolver[] = [
   swiftUIResolver,
   uikitResolver,
   vaporResolver,
+  // Swift ↔ Objective-C cross-language bridging (mixed iOS apps)
+  swiftObjcBridgeResolver,
 ];
 
 /**
@@ -124,3 +127,4 @@ export { goResolver } from './go';
 export { rustResolver } from './rust';
 export { aspnetResolver } from './csharp';
 export { swiftUIResolver, uikitResolver, vaporResolver } from './swift';
+export { swiftObjcBridgeResolver } from './swift-objc';

+ 299 - 0
src/resolution/frameworks/swift-objc.ts

@@ -0,0 +1,299 @@
+/**
+ * Swift ↔ Objective-C bridge resolver.
+ *
+ * Closes the cross-language flow gap in mixed iOS codebases. The pure
+ * bridging name math lives in `../swift-objc-bridge.ts`; this file wires
+ * it into the resolution pipeline.
+ *
+ * **Two directions to close:**
+ *
+ * 1. **Swift call → ObjC method** — A Swift caller writes
+ *    `imageDownloader.download(url:completion:)`. Tree-sitter-swift parses
+ *    this as a call_expression whose callee identifier is `download`
+ *    (parameter labels live in the argument list, not the callee). The
+ *    name-matcher tries to find any node named `download` and fails (no
+ *    Swift method by that name in this project; the ObjC implementation is
+ *    `-downloadURL:completion:`). We catch it here: from the bare Swift
+ *    name `download`, look up ObjC methods whose bridged Swift base name
+ *    would be `download` (using `swiftBaseNamesForObjcSelector`'s reverse
+ *    map, precomputed once per session).
+ *
+ * 2. **ObjC call → Swift method** — An ObjC caller writes
+ *    `[swiftThing fooWithBar:42]`. Tree-sitter-objc parses this as a
+ *    message_expression with selector `fooWithBar:` (after the multi-
+ *    keyword fix in this branch). The name-matcher tries to find a node
+ *    named `fooWithBar:` — no Swift node has colons in its name, so it
+ *    fails. We catch it: from the ObjC selector, derive candidate Swift
+ *    base names (`['fooWithBar', 'foo']`), and look up Swift methods
+ *    named those.
+ *
+ * **Provenance:** every edge produced here is recorded as a framework-
+ * resolved reference (`resolvedBy: 'framework'`) with `confidence: 0.7`
+ * (matches the django ORM dynamic-dispatch precedent — not exact, but
+ * deterministic from the bridging rule).
+ */
+import { FrameworkResolver, ResolutionContext, ResolvedRef, UnresolvedRef } from '../types';
+import type { Node } from '../../types';
+import {
+  swiftBaseNamesForObjcSelector,
+  isObjcExposed,
+} from '../swift-objc-bridge';
+
+/**
+ * Memoized "Swift base name → ObjC method nodes" map.
+ *
+ * Built lazily on first `resolve()` per resolver instance — the resolver is
+ * recreated when the index is rebuilt, so this naturally invalidates with
+ * the graph. Keyed by ResolutionContext identity so multiple projects sharing
+ * a process (the daemon) don't bleed maps between them.
+ */
+const objcByCandidateSwiftBase: WeakMap<
+  ResolutionContext,
+  Map<string, Node[]>
+> = new WeakMap();
+
+/**
+ * Build the reverse-bridge map: for every ObjC method node in the graph,
+ * compute the Swift base names that would auto-bridge to its selector and
+ * record the node under each.
+ *
+ * Runs once per resolver lifetime; the cost scales linearly with the count
+ * of ObjC method nodes. On Wikipedia-iOS (~2500 files, ~25k ObjC methods)
+ * this is a few hundred ms — much cheaper than re-parsing source on each
+ * unresolved ref.
+ */
+/**
+ * Names that are too generic to bridge with any precision. These are common
+ * Cocoa / NSObject conventions that almost every ObjC class implements; if a
+ * Swift caller writes `init()` or `description`, mapping it to an arbitrary
+ * project-local ObjC method of the same name produces noise, not signal.
+ *
+ * Critically, refs of these names virtually always resolve via the regular
+ * name-matcher (every project has many `init` nodes) — skipping them here
+ * just keeps the bridge from competing with name-match on already-handled
+ * refs.
+ */
+const GENERIC_NAMES = new Set([
+  'init',
+  'description',
+  'debugDescription',
+  'hash',
+  'isEqual',
+  'isEqualTo',
+  'copy',
+  'mutableCopy',
+  'class',
+  'self',
+  'count',
+  'length',
+  'value',
+  'name',
+  'data',
+  'string',
+  'object',
+  'add',
+  'remove',
+  'update',
+  'load',
+  'save',
+  'reload',
+  'cancel',
+  'start',
+  'stop',
+  'pause',
+  'resume',
+  'close',
+  'open',
+  'show',
+  'hide',
+  'toString',
+  'dealloc',
+  'release',
+  'retain',
+  'autorelease',
+]);
+
+function buildObjcMap(context: ResolutionContext): Map<string, Node[]> {
+  const cached = objcByCandidateSwiftBase.get(context);
+  if (cached) return cached;
+
+  const map = new Map<string, Node[]>();
+  const objcMethods = context
+    .getNodesByKind('method')
+    .filter((n) => n.language === 'objc');
+  for (const node of objcMethods) {
+    const candidates = swiftBaseNamesForObjcSelector(node.name);
+    for (const c of candidates) {
+      // Skip the trivial case where the Swift base name equals the ObjC
+      // method name verbatim (no colons) — the regular name-matcher
+      // already handles that and our map would just duplicate the work.
+      if (c === node.name && !node.name.includes(':')) continue;
+      // Skip generic Cocoa names (init, description, etc.) — they would
+      // false-positive against any project-local ObjC method of the same
+      // name. The regular name-matcher handles them.
+      if (GENERIC_NAMES.has(c)) continue;
+      const arr = map.get(c);
+      if (arr) arr.push(node);
+      else map.set(c, [node]);
+    }
+  }
+  objcByCandidateSwiftBase.set(context, map);
+  return map;
+}
+
+/**
+ * Window of source text around a Swift declaration used by `isObjcExposed`
+ * to spot `@objc` / `@nonobjc` annotations. Read line above + the
+ * declaration line — Swift attributes typically sit on the preceding line
+ * (`@objc` on a line of its own) or inline.
+ */
+const SOURCE_PROBE_LINES = 3;
+
+/**
+ * Read a small window of source ending at `node.startLine`, used to
+ * inspect Swift attribute annotations attached to a declaration. Returns
+ * an empty string if the source can't be read.
+ */
+function declarationSourceWindow(node: Node, context: ResolutionContext): string {
+  const content = context.readFile(node.filePath);
+  if (!content) return '';
+  const lines = content.split(/\r?\n/);
+  const startIdx = Math.max(0, node.startLine - 1 - SOURCE_PROBE_LINES);
+  const endIdx = Math.min(lines.length, node.startLine);
+  return lines.slice(startIdx, endIdx).join('\n');
+}
+
+/**
+ * Try to resolve a Swift caller's bare reference to an ObjC implementation.
+ *
+ * Strategy: look up the ObjC reverse-bridge map for nodes whose Swift base
+ * name would match. Return the first match (matches the existing
+ * single-target resolution contract).
+ */
+function resolveSwiftCallToObjc(
+  ref: UnresolvedRef,
+  context: ResolutionContext
+): ResolvedRef | null {
+  // Swift call sites of `obj.foo(bar:)` reach the resolver as either bare
+  // name `foo` (tree-sitter-swift) or qualified `obj.foo` — strip prefix.
+  const rawName = ref.referenceName.includes('.')
+    ? ref.referenceName.slice(ref.referenceName.lastIndexOf('.') + 1)
+    : ref.referenceName;
+
+  const map = buildObjcMap(context);
+  const candidates = map.get(rawName);
+  if (!candidates || candidates.length === 0) return null;
+
+  // Prefer ObjC methods whose corresponding Swift declaration isn't itself
+  // present (so we don't wrongly redirect a Swift call to ObjC when a Swift
+  // method of the same name is the real target — that's the in-language case
+  // and should already be resolved by the name-matcher). Since this resolver
+  // runs AFTER exact-match, any matching Swift node would already have won;
+  // so a candidate reaching us is a legitimate cross-language hit.
+  const target = candidates[0];
+  if (!target) return null;
+  return {
+    original: ref,
+    targetNodeId: target.id,
+    confidence: 0.6,
+    resolvedBy: 'framework',
+  };
+}
+
+/**
+ * Try to resolve an ObjC caller's selector reference to a Swift `@objc`
+ * implementation.
+ *
+ * Strategy: derive candidate Swift base names from the selector via
+ * `swiftBaseNamesForObjcSelector`. For each, look up Swift methods named
+ * that and verify with a source-window check that the declaration is
+ * `@objc`-exposed (filters out false matches where a Swift function
+ * happens to share the name but isn't bridged).
+ */
+function resolveObjcCallToSwift(
+  ref: UnresolvedRef,
+  context: ResolutionContext
+): ResolvedRef | null {
+  // ObjC call sites get receiver-prefixed when the receiver isn't self/super
+  // (see tree-sitter.ts message_expression handling): `[obj foo:bar:]`
+  // becomes `obj.foo:bar:`. Strip the receiver prefix to recover the raw
+  // selector for the bridge math.
+  const rawSelector = ref.referenceName.includes('.')
+    ? ref.referenceName.slice(ref.referenceName.lastIndexOf('.') + 1)
+    : ref.referenceName;
+
+  // Bridge math only applies to selector-shape names (contain `:`).
+  if (!rawSelector.includes(':')) return null;
+
+  const candidates = swiftBaseNamesForObjcSelector(rawSelector);
+  for (const candidate of candidates) {
+    const matches = context
+      .getNodesByName(candidate)
+      .filter((n) => n.language === 'swift' && (n.kind === 'method' || n.kind === 'function'));
+    for (const match of matches) {
+      const window = declarationSourceWindow(match, context);
+      if (isObjcExposed(window)) {
+        return {
+          original: ref,
+          targetNodeId: match.id,
+          confidence: 0.6,
+          resolvedBy: 'framework',
+        };
+      }
+    }
+  }
+  return null;
+}
+
+export const swiftObjcBridgeResolver: FrameworkResolver = {
+  name: 'swift-objc-bridge',
+  // Applies to both languages — bridging crosses the boundary.
+  languages: ['swift', 'objc'],
+
+  /**
+   * Detect: this resolver is relevant when the project has both Swift and
+   * Objective-C source. Either-side-only projects don't need bridging
+   * (and the empty reverse-map would be a no-op anyway).
+   */
+  detect(context) {
+    const files = context.getAllFiles();
+    let hasSwift = false;
+    let hasObjc = false;
+    for (const f of files) {
+      if (f.endsWith('.swift')) hasSwift = true;
+      else if (f.endsWith('.m') || f.endsWith('.mm')) hasObjc = true;
+      if (hasSwift && hasObjc) return true;
+    }
+    return false;
+  },
+
+  /**
+   * Let selector-shape references (anything containing a `:`) through the
+   * resolver's name-exists pre-filter — no Swift node has a colon in its
+   * name, so without this opt-in those refs would be dropped before
+   * `resolve()` sees them. Also opt-in `setX:`-style names that aren't
+   * otherwise declared symbols, in case the Swift side is a property.
+   */
+  claimsReference(name) {
+    if (name.includes(':')) return true;
+    // Bare names without colons are handled by the regular name-exists
+    // pre-filter — no need to opt them in here.
+    return false;
+  },
+
+  /**
+   * Route based on which language the caller is in. The two directions are
+   * symmetric in shape but very different in implementation (forward
+   * direction uses the precomputed reverse-bridge map; reverse direction
+   * uses the deterministic name-derivation).
+   */
+  resolve(ref, context) {
+    if (ref.language === 'swift') {
+      return resolveSwiftCallToObjc(ref, context);
+    }
+    if (ref.language === 'objc') {
+      return resolveObjcCallToSwift(ref, context);
+    }
+    return null;
+  },
+};

+ 276 - 0
src/resolution/swift-objc-bridge.ts

@@ -0,0 +1,276 @@
+/**
+ * Swift ↔ Objective-C bridging rules.
+ *
+ * Apple's auto-bridging mechanism exposes Swift declarations to the ObjC
+ * runtime under a deterministic selector name. The full rule set:
+ * https://developer.apple.com/documentation/swift/importing-swift-into-objective-c
+ *
+ * This module is **pure name math** — given a Swift declaration's base name
+ * + parameter external labels (or the raw signature text), produce the
+ * bridged ObjC selector(s); given an ObjC selector, produce the
+ * candidate Swift base names. No graph/DB access here.
+ *
+ * Used by `frameworks/swift-objc.ts` (the framework resolver that wires
+ * the rules into the resolution pipeline) and by its tests.
+ *
+ * ─── Bridging cheat sheet ───────────────────────────────────────────────
+ *
+ *   Swift declaration                             ObjC selector
+ *   ─────────────────────────────────────────     ─────────────────────────
+ *   func play()                                    play
+ *   func play(_ song: String)                      play:
+ *   func play(song: String)                        playWithSong:
+ *   func play(_ song: String, by artist: String)   play:by:
+ *   func play(song: String, by artist: String)     playWithSong:by:
+ *   init(name: String)                             initWithName:
+ *   init(name: String, age: Int)                   initWithName:age:
+ *   var name: String  (getter / setter)            name  /  setName:
+ *   @objc(custom:) func f(_ x: Int)                custom:        (literal override)
+ *
+ * The reverse direction (ObjC → Swift) collapses the bridge: a Swift call
+ * site for `play(song:)` reaches us as the bare base name `play` (Swift's
+ * tree-sitter call_expression strips parameter labels from the callee
+ * name). So `swiftBaseNamesForObjcSelector('playWithSong:')` returns
+ * `['play']` — the resolver looks up Swift methods named `play`.
+ */
+
+/**
+ * Capitalize the first character of a string. Used for the "With"-prefix
+ * form on the first selector keyword when the Swift declaration has an
+ * explicit first-parameter label (e.g. `func play(song:)` → `playWithSong:`).
+ */
+function capFirst(s: string): string {
+  return s.length > 0 ? s.charAt(0).toUpperCase() + s.slice(1) : s;
+}
+
+/**
+ * Lowercase the first character. Used in reverse: `setName:` setter ↔
+ * Swift property `name`.
+ */
+function lowerFirst(s: string): string {
+  return s.length > 0 ? s.charAt(0).toLowerCase() + s.slice(1) : s;
+}
+
+/**
+ * Compute the auto-bridged ObjC selector for a Swift method declaration.
+ *
+ * @param baseName  The Swift method's base name (e.g. `play`).
+ * @param externalLabels  Parameter EXTERNAL labels in declaration order;
+ *                        `null` for a `_` (unlabeled) parameter.
+ *                        `[]` for a no-parameter method.
+ * @param explicitObjcName  If `@objc(customSel:)` was specified, the
+ *                          literal selector — short-circuits the rule
+ *                          and is returned as-is.
+ * @returns The ObjC selector (e.g. `playWithSong:by:`), or `null` if it
+ *          can't be determined.
+ *
+ * **Method rules:**
+ * - No params → base name (no colons)
+ * - Single param, `_` label → `baseName:`
+ * - Single param, explicit label `L` → `baseNameWithL:`
+ * - Multi-param, `_` first label → `baseName:label2:label3:`
+ * - Multi-param, explicit first label `L1` → `baseNameWithL1:label2:label3:`
+ *
+ * Initializer rules are handled by `objcSelectorForSwiftInit`.
+ */
+export function objcSelectorForSwiftMethod(
+  baseName: string,
+  externalLabels: (string | null)[],
+  explicitObjcName?: string | null
+): string | null {
+  if (!baseName) return null;
+  if (explicitObjcName) return explicitObjcName;
+
+  if (externalLabels.length === 0) {
+    return baseName;
+  }
+
+  const [first, ...rest] = externalLabels;
+  // Single param: "_" → "base:" ; "label" → "baseWithLabel:"
+  // Multi-param mirrors the same first-keyword formation, then appends each
+  // subsequent label as its own keyword. A `null` later label is invalid
+  // ObjC (no way to express unlabeled middle params) — keep as `:` to be safe.
+  const firstKeyword =
+    first === null || first === undefined || first === '_' || first === ''
+      ? `${baseName}:`
+      : `${baseName}With${capFirst(first)}:`;
+
+  const restKeywords = rest.map((l) => `${l ?? ''}:`).join('');
+  return firstKeyword + restKeywords;
+}
+
+/**
+ * Compute the bridged ObjC selector for a Swift `init(...)` declaration.
+ *
+ * **Init rules** (different from regular methods — Apple always uses
+ * `initWith` regardless of whether the first label is `_`):
+ * - `init()`                       → `init`
+ * - `init(_ name: String)`         → `initWithName:`  (uses the INTERNAL
+ *                                    name when external is `_`, per Apple's
+ *                                    bridging conventions)
+ * - `init(name: String)`           → `initWithName:`
+ * - `init(name: String, age: Int)` → `initWithName:age:`
+ *
+ * For the `_` case we need the internal (second identifier) name —
+ * passed via `internalNames`.
+ */
+export function objcSelectorForSwiftInit(
+  externalLabels: (string | null)[],
+  internalNames: string[],
+  explicitObjcName?: string | null
+): string | null {
+  if (explicitObjcName) return explicitObjcName;
+
+  if (externalLabels.length === 0) {
+    return 'init';
+  }
+
+  const [firstExt, ...restExt] = externalLabels;
+  const [firstInt] = internalNames;
+  // Use the internal name when external is "_"; ObjC needs *some* keyword,
+  // and Swift's auto-bridger uses the parameter's local name in this case.
+  const firstLabel =
+    firstExt === null || firstExt === '_' || firstExt === ''
+      ? firstInt
+      : firstExt;
+  if (!firstLabel) return null;
+
+  const firstKeyword = `initWith${capFirst(firstLabel)}:`;
+  const restKeywords = restExt
+    .map((label, idx) => {
+      const internal = internalNames[idx + 1];
+      const name = label && label !== '_' ? label : internal ?? '';
+      return `${name}:`;
+    })
+    .join('');
+  return firstKeyword + restKeywords;
+}
+
+/**
+ * Compute the bridged ObjC getter + setter for a Swift `@objc` property.
+ *
+ * - `var name: String`        → getter `name`, setter `setName:`
+ * - `var isReady: Bool`       → getter `isReady`, setter `setIsReady:`
+ *   (no special `is` handling — Swift's `isReady` stays as `isReady` in ObjC;
+ *   `@objc(name:)` overrides if a Cocoa-style getter `isReady` / setter
+ *   `setReady:` pairing is needed — that's the responsibility of the
+ *   declaration's `@objc(customGetter)` annotation, which we surface via
+ *   `explicitObjcName`.)
+ */
+export function objcAccessorsForSwiftProperty(
+  swiftName: string,
+  explicitObjcName?: string | null
+): { getter: string; setter: string } | null {
+  if (!swiftName) return null;
+  // The override syntax `@objc(customGetterName)` re-points the GETTER only;
+  // the setter still follows the `setX:` rule but is keyed off the override.
+  // (`@objc(getX:setY:)` is not currently supported — that's a rarer
+  // shape; can extend later if a real codebase needs it.)
+  const getter = explicitObjcName ?? swiftName;
+  return {
+    getter,
+    setter: `set${capFirst(getter)}:`,
+  };
+}
+
+/**
+ * Reverse: from an ObjC selector, return the candidate Swift base names
+ * the resolver should try when looking for the bridged Swift declaration.
+ *
+ * Examples:
+ *   `play`                 → ['play']
+ *   `play:`                → ['play']
+ *   `playWithSong:`        → ['play', 'playWithSong']
+ *   `play:by:`             → ['play']
+ *   `playWithSong:by:`     → ['play', 'playWithSong']
+ *   `initWithName:`        → ['init']                      (init is its own base name)
+ *   `initWithName:age:`    → ['init']
+ *   `setName:`             → ['name', 'setName']           (could be a setter OR a regular func)
+ *   `tableView:didSel…:`   → ['tableView']
+ *
+ * Returns multiple candidates because the bare base name is ambiguous —
+ * `playWithSong:` could correspond to either `func play(song:)` or
+ * `func playWithSong(_ x:)` (a Swift method literally named that with a
+ * `_` first label). The resolver tries each.
+ */
+export function swiftBaseNamesForObjcSelector(selector: string): string[] {
+  if (!selector) return [];
+
+  // Strip trailing colons and split into keywords.
+  const keywords = selector.replace(/:+$/g, '').split(':');
+  const firstKeyword = keywords[0];
+  if (!firstKeyword) return [];
+
+  const candidates: Set<string> = new Set();
+
+  // Always a candidate: the raw first keyword. Covers
+  //   `play:`           → `play`
+  //   `play:by:`        → `play`
+  //   `playWithSong:`   → `playWithSong` (a literal Swift name)
+  //   `tableView:...:`  → `tableView`
+  candidates.add(firstKeyword);
+
+  // `initWith<X>:` and `initWith<X>:<more>:` always reduce to `init`.
+  if (firstKeyword.startsWith('initWith')) {
+    candidates.add('init');
+  }
+
+  // Preposition-prefix patterns: `<base>(With|For|By|In|On|At|From|To|Of|As)<Cap>:`
+  // covers both Swift's @objc EXPORT rule (always "With") and Cocoa's
+  // IMPORTED selectors which use other prepositions natively (e.g.
+  // `objectForKey:`, `stringWithFormat:`, `compareTo:`,
+  // `imageNamed:inBundle:`). Strip to recover the Swift base name a caller
+  // would use (e.g. `object`, `string`, `compare`, `image`).
+  const prepositionMatch = firstKeyword.match(
+    /^([a-z][a-zA-Z0-9]*?)(?:With|For|By|In|On|At|From|To|Of|As)[A-Z]/
+  );
+  if (prepositionMatch && prepositionMatch[1]) {
+    candidates.add(prepositionMatch[1]);
+  }
+
+  // `setX:` could be a property setter — the Swift property is `x` (lowercase).
+  // Only fires for the obvious shape: `set` + capital letter + ':' (one param).
+  if (
+    keywords.length === 1 &&
+    /^set[A-Z]/.test(firstKeyword) &&
+    selector.endsWith(':')
+  ) {
+    const propName = lowerFirst(firstKeyword.slice(3));
+    if (propName) candidates.add(propName);
+  }
+
+  return Array.from(candidates);
+}
+
+/**
+ * Detect whether a Swift method `@objc` declaration uses the `@objc(custom:)`
+ * override form, returning the literal selector when present.
+ *
+ * Regex-based scan over the small chunk of source preceding the declaration —
+ * tree-sitter would be more precise but this is only consulted as a fallback
+ * when the structured AST isn't available (e.g. resolver-time lookups
+ * via `context.readFile`).
+ *
+ * Returns `null` when the declaration is plain `@objc` (no override) or has
+ * no `@objc` attribute at all.
+ */
+export function detectExplicitObjcName(sourceSlice: string): string | null {
+  // `@objc(customName:)` or `@objc(custom:name:)` — the parens contents are
+  // the literal ObjC selector. Whitespace permitted.
+  const m = sourceSlice.match(/@objc\s*\(\s*([^)\s]+)\s*\)/);
+  return m && m[1] ? m[1] : null;
+}
+
+/**
+ * Detect whether a Swift declaration is `@objc`-exposed by scanning the
+ * source slice that precedes it. Returns true for explicit `@objc`,
+ * `@objc(custom:)`, or membership in a `@objcMembers` class (caller's
+ * responsibility to pass class-level context if relevant).
+ *
+ * `@nonobjc` returns false even if `@objc` also appears (per Swift's rule
+ * that `@nonobjc` opts out of class-level `@objcMembers`).
+ */
+export function isObjcExposed(sourceSlice: string): boolean {
+  if (/@nonobjc\b/.test(sourceSlice)) return false;
+  return /@objc\b/.test(sourceSlice);
+}