fix(resolution): a super call in an override is not a self-call (#2136)

Extraction keeps `super.didMoveToWindow()` under the bare method name, so
every strategy resolved it to the enclosing override itself. Across the
bridge sweep that was 139 self-edges on Charts, 142 on commons-lang, 101
on BookStack, 92 on realm-swift, 82 on react-native-svg — lifecycle
overrides and delegates drawn as recursion.

`gateSuperSelfCall` declines a `calls` result that targets the calling
node when the call site is written through `super` / `base` (C#) /
`[super …]` (Objective-C) / `parent::` (PHP) / `super().` / `super(C, self).`
(Python). Plain recursion keeps its edge.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-09-30 07:48:48 +00:00
committed by GitHub
parent 72a9346b12
commit a4f9820233
3 changed files with 125 additions and 3 deletions
+1
View File
@@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Fixes
- A `super` call in an overriding method, like `super.viewDidLoad()`, `[super init]`, `base.Handle()`, `parent::setUp()` or `super().dispatch(…)`, no longer makes the method look like it calls itself. The call goes to the parent's version, so an override stops showing up as recursive and no longer lists itself among its own callers.
- Expo Router API routes (`app/hello+api.ts`) are now endpoints, one per exported method, like `GET /hello`, bound to the function that handles them. They used to show up as a screen named `/hello+api`. In a repository whose root declares Expo Router for an example app, a Next.js app in its own folder, like react-native-true-sheet's `docs/`, no longer gets Expo screens made from its files.
- Calls into an Expo module now reach the module's native functions even when the JavaScript names the module something else, like expo-camera's `CameraManager` for `requireNativeModule('ExpoCamera')`. Both the iOS and the Android implementations are linked. expo-camera's calls used to link back to the same-named method making the call, so a method looked like it called itself.
- A JavaScript or TypeScript call on something the file imports, like `fireEvent.click(…)` or `util.omit(…)`, no longer links to a same-named method declared in the calling file itself.
+96
View File
@@ -0,0 +1,96 @@
/**
* A `super` call inside an override calls the parent's implementation, never
* the method making the call.
*
* Extraction keeps `super.didMoveToWindow()` under the bare method name, so
* every strategy resolved it to the enclosing override itself — a self-edge
* that made a view's lifecycle methods look recursive (eleven of one
* expo-camera view's overrides). Real recursion keeps its edge.
*/
import { describe, it, expect, afterAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
const roots: string[] = [];
afterAll(() => {
for (const r of roots.splice(0)) fs.rmSync(r, { recursive: true, force: true });
});
async function project(files: Record<string, string>): Promise<CodeGraph> {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-super-self-'));
roots.push(root);
for (const [rel, content] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
fs.writeFileSync(path.join(root, rel), content);
}
return CodeGraph.init(root, { index: true });
}
/** `Owner.method` names of every method whose calls include itself. */
function selfCalling(cg: CodeGraph): string[] {
const out: string[] = [];
for (const kind of ['method', 'function'] as const) {
for (const n of cg.getNodesByKind(kind)) {
if (cg.getOutgoingEdges(n.id).some((e) => e.kind === 'calls' && e.target === n.id)) out.push(n.name);
}
}
return out.sort();
}
describe('a super call is not a self-call', () => {
it('Swift, Kotlin, Java, Python, TypeScript and C#', async () => {
const cg = await project({
'ios/CameraView.swift': `import UIKit
class CameraView: UIView {
override func didMoveToWindow() {
super.didMoveToWindow()
setup()
}
func setup() {}
func countdown(_ n: Int) {
if n > 0 { countdown(n - 1) }
}
}
`,
'android/CameraView.kt': `package app
class CameraView(context: Context) : FrameLayout(context) {
override fun onAttachedToWindow() {
super.onAttachedToWindow()
}
}
`,
'java/Service.java': `package app;
public class Service extends Base {
@Override
public void start() {
super.start();
}
}
`,
'py/views.py': `class ProfileView(BaseView):
def dispatch(self, request):
return super().dispatch(request)
`,
'ts/list.ts': `export class List extends Base {
render(): string {
return super.render() + '!';
}
}
`,
'cs/Handler.cs': `public class Handler : BaseHandler {
public override void Handle() {
base.Handle();
}
}
`,
});
try {
// Only the real recursion is left.
expect(selfCalling(cg)).toEqual(['countdown']);
} finally {
cg.close();
}
});
});
+28 -3
View File
@@ -1000,9 +1000,10 @@ export class ReferenceResolver {
this.context,
);
const scoped = this.gateRustScope(candidate, ref);
const resolved = scoped?.resolvedBy === 'framework'
? this.gateFrameworkLanguage(scoped, ref)
: this.gateLanguage(scoped, ref);
const resolved = this.gateSuperSelfCall(
scoped?.resolvedBy === 'framework' ? this.gateFrameworkLanguage(scoped, ref) : this.gateLanguage(scoped, ref),
ref,
);
if (!resolved || ref.referenceKind !== 'calls') return resolved;
const target = this.nodeById(resolved.targetNodeId);
@@ -2869,6 +2870,30 @@ export class ReferenceResolver {
return result;
}
/**
* `super.didMoveToWindow()` inside an override of `didMoveToWindow` calls
* the PARENT's implementation, never the method making the call. Extraction
* keeps a `super` call under the bare method name, so every strategy found
* the enclosing method itself: one expo-camera view had eleven of its
* overrides "calling" themselves. The parent's method is usually a
* framework's (UIKit, Android, React); a self-edge is never it. Real
* recursion keeps its edge — only a call written through `super` / `base`
* (C#) / `[super …]` (Objective-C) / `parent::` (PHP) / `super().` (Python)
* is declined.
*/
private gateSuperSelfCall(result: ResolvedRef | null, ref: UnresolvedRef): ResolvedRef | null {
if (!result || ref.referenceKind !== 'calls' || result.targetNodeId !== ref.fromNodeId) return result;
const name = ref.referenceName.slice(Math.max(ref.referenceName.lastIndexOf('.'), ref.referenceName.lastIndexOf(':')) + 1);
if (!/^[A-Za-z_$][\w$]*$/.test(name)) return result;
const line = (this.context.getFileLines?.(ref.filePath) ?? this.context.readFile(ref.filePath)?.split(/\r?\n/))?.[ref.line - 1];
if (!line) return result;
const escaped = name.replace(/\$/g, '\\$');
const viaSuper = new RegExp(
String.raw`(?:\b(?:super|base)\s*(?:\(\s*(?:[\w.]+\s*,\s*\w+)?\s*\))?\s*\??\.\s*|\[\s*super\s+|\bparent\s*::\s*)` + escaped + String.raw`\b`,
);
return viaSuper.test(line) ? null : result;
}
/**
* A bare Rust name reaches only what is in scope — every strategy's result,
* a framework resolver's `Ok(x)` → `struct Ok` construction included (see