From e62032e95a5d0e025c022b587f090c4057810a3f Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 30 Sep 2026 23:18:21 +0000 Subject: [PATCH] fix(python): a member value through an untyped receiver is a method only its owner names (#2218) A lone project method now stands for an untyped receiver's member value only when the receiver is named after the method's class: netbox's `device=self.parent.device` model-field reads no longer reach a GraphQL filter's `device` method. Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/member-function-ref-owner.test.ts | 61 +++++++++++++++++++++ src/resolution/name-matcher.ts | 10 +++- 3 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 __tests__/member-function-ref-owner.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 02b0bd527..ad3be1098 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 +- In Python, an attribute passed as a value through an object nothing types, like netbox's `device=self.parent.device`, is no longer linked to the project's only method of that name when the object isn't named after the method's class. Before, netbox's model-field reads went to a GraphQL filter's `device` method, and healthchecks' `check.last_ping` went to a notification transport's. - In Scala, a kind-projector placeholder in a type, like `Align[Either[A, *]]`, is no longer linked to an operator method named `*`. A symbolic name in a type, like cats' `F ~> G`, now resolves only to a type. - C# type names now follow namespaces. A type is in reach from its own namespace or one nested inside it, or through a `using` of its namespace. Also counted: a project-wide `global using`, the project's `` entries, the SDK's implicit usings, or an alias that names it. Before, Newtonsoft's `async Task` tests linked `Task` to a test class of that name in another namespace, and AutoMapper's DTOs linked to another namespace's `BaseEntity`. - Rust type and function names now follow Rust's scoping. A name from another file counts only when a `use` brings it in (or a `*` glob over its module). One imported from outside the project, like `use std::task::{Context, Poll}` or `use futures::Stream`, means that outside item. A path names its module: `io::Result` isn't an unrelated `Result` alias elsewhere, and `std::io::Error` is std's. On tokio, over 1,200 `io::Result` and 600 `Context` references had been landing on private project types. diff --git a/__tests__/member-function-ref-owner.test.ts b/__tests__/member-function-ref-owner.test.ts new file mode 100644 index 000000000..697057da8 --- /dev/null +++ b/__tests__/member-function-ref-owner.test.ts @@ -0,0 +1,61 @@ +/** + * A member passed as a value through a receiver nothing types — + * `device=self.parent.device`, `self.check.last_ping` — is only the + * project's one method of that name when the receiver is named after its + * owner: netbox's model-field reads bound to a GraphQL filter's `device` + * method, healthchecks' `check.last_ping` to a transport's. + */ +import { describe, it, expect, afterAll, beforeAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; + +let root = ''; +let cg: CodeGraph; + +beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-member-fnref-')); + const files: Record = { + 'app/filters.py': `class FHRPGroupAssignmentFilter: + def device(self, queryset): + return queryset +`, + 'app/components.py': `def describe(parent): + return "{interface}".format(interface=parent, device=parent.device) +`, + 'app/recorder.py': `class HookRecorder: + def finish_recording(self): + return None + + +def register(request, hook_recorder): + request.addfinalizer(hook_recorder.finish_recording) +`, + }; + 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); + } + cg = await CodeGraph.init(root, { index: true }); +}); + +afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); +}); + +const refsFrom = (file: string) => { + const ids = cg.getNodesInFile(file).map((n) => n.id); + return cg.getOutgoingEdgesFrom(ids).filter((e) => e.kind === 'references').map((e) => cg.getNode(e.target)!.qualifiedName); +}; + +describe('member values through an untyped receiver', () => { + it('are not a method whose owner the receiver is not named after', () => { + expect(refsFrom('app/components.py')).not.toContain('FHRPGroupAssignmentFilter::device'); + }); + + it('are a method whose owner the receiver is named after', () => { + expect(refsFrom('app/recorder.py')).toContain('HookRecorder::finish_recording'); + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 5777451ec..888ac17dd 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -325,8 +325,14 @@ function matchMemberFunctionRef(ref: UnresolvedRef, context: ResolutionContext): } } // Unknown receivers retain the old unique-or-drop discipline, across ALL - // files. Tests and abstract-looking bodies are candidates too. - return result(context.getNodesByName(member), 0.8); + // files. Tests and abstract-looking bodies are candidates too. A lone method + // stands only when the receiver is named after its owner: netbox's + // `device=self.parent.device` is a model field, not the project's one + // `device` method (a GraphQL filter's). A veto, never a filter — filtering + // first would promote some other lone match into a new guess. + const unique = result(context.getNodesByName(member), 0.8); + const target = unique ? context.getNodeById?.(unique.targetNodeId) : null; + return target && target.kind === 'method' && !sharesReceiverWord(receiverLink(receiver), target) ? null : unique; } function pythonRefClass(name: string, ref: UnresolvedRef, context: ResolutionContext): Node | null {