From a4f982023329371fa24b72d036edd5e597bfe97d Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 30 Sep 2026 07:48:48 +0000 Subject: [PATCH] fix(resolution): a super call in an override is not a self-call (#2136) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 1 + __tests__/super-call-self-edge.test.ts | 96 ++++++++++++++++++++++++++ src/resolution/index.ts | 31 ++++++++- 3 files changed, 125 insertions(+), 3 deletions(-) create mode 100644 __tests__/super-call-self-edge.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 25ff68733..dde38f8da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/__tests__/super-call-self-edge.test.ts b/__tests__/super-call-self-edge.test.ts new file mode 100644 index 000000000..6067a5491 --- /dev/null +++ b/__tests__/super-call-self-edge.test.ts @@ -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): Promise { + 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(); + } + }); +}); diff --git a/src/resolution/index.ts b/src/resolution/index.ts index e5fa4f332..1a9906d81 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -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