fix(resolution): a type never inherits from itself (#2229)

An extends/implements ref that settled on the declaring type is re-chosen
among the other types of its name, the one the written qualifier leads to
(owner path plus file package), or none.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-01 01:46:07 +00:00
committed by GitHub
parent 92f141f04f
commit b281819854
3 changed files with 104 additions and 0 deletions
+1
View File
@@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Fixes
- A type that extends another type of the same name now links to that other type instead of to itself. When the source spells out the qualifier, that qualifier picks between same-named candidates. Before, cats' `trait BigDecimalInstances extends cats.kernel.instances.BigDecimalInstances` and every `trait AllOps … with Functor.AllOps[…]` pointed back at the declaring trait, and Newtonsoft.Json's `JsonConverter<T> : JsonConverter` extended itself.
- In pytest tests, a parameter now takes the class its fixture returns, so a call on it reaches that class's method. If the class comes from a library, the call no longer reaches the project's only method of that name. Fixtures a `conftest.py` pulls in with `from … import *` or `pytest_plugins` now count as in reach. Before, flask's `runner.invoke(…)` on click's `CliRunner()` linked to the project's `FlaskCliRunner.invoke`, and `@app.get(…)` on a `Flask(…)` fixture linked to an unrelated `get`.
- What a Svelte component's instance `<script>` or a Vue component's `<script setup>` declares is now private to that component. Only a Svelte `<script module>`, a plain Vue `<script>`, or a type a `<script setup>` exports can be reached from another file. Before, shadcn-svelte's `<Item.Root>` linked to a `type Item` that one example component declares for itself, and halo's `isActive` imported from its editor core linked to a tab component's local value.
- In JavaScript and TypeScript, a name the calling function binds itself, as a parameter or a plain `var`/`let`/`const` above the reference, now means that local instead of a same-named function elsewhere in the file. Before, every lodash helper's `object` parameter linked to a `function object() {}` declared inside `runInContext`, and zod's `new Class(…)` linked to an unrelated `Class` when `Class` was the function's parameter. Destructured hook results like `const { t } = useI18n()` still link to the function they come from.
+73
View File
@@ -0,0 +1,73 @@
/**
* A type never inherits from itself. cats' `trait BigDecimalInstances extends
* cats.kernel.instances.BigDecimalInstances` and `trait AllOps … with
* Functor.AllOps[F, A]` reached the resolver by the bare name they share with
* the declaring trait, and 111 such `extends` pointed back at it. The
* supertype is the other type of that name the written qualifier leads to.
*/
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-super-self-'));
const files: Record<string, string> = {
'kernel/src/main/scala/cats/kernel/instances/BigDecimalInstances.scala': `package cats.kernel
package instances
trait BigDecimalInstances {
def order: Int = 0
}
`,
'core/src/main/scala/cats/instances/bigDecimal.scala': `package cats
package instances
trait BigDecimalInstances extends cats.kernel.instances.BigDecimalInstances {
def show: String = ""
}
`,
'core/src/main/scala/cats/Functor.scala': `package cats
object Functor {
trait AllOps[F[_], A]
}
object Apply {
trait AllOps[F[_], A] extends Functor.AllOps[F, A]
}
object Foldable {
trait AllOps[F[_], A]
}
`,
};
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 supertypesOf = (file: string, qualifiedName: string) => {
const ids = cg.getNodesInFile(file).filter((n) => n.qualifiedName === qualifiedName).map((n) => n.id);
return cg.getOutgoingEdgesFrom(ids).filter((e) => e.kind === 'extends').map((e) => cg.getNode(e.target)!).map((t) => `${t.filePath}:${t.qualifiedName}`);
};
describe('a supertype sharing the declaring type’s name', () => {
it('is the other type the written qualifier names', () => {
expect(supertypesOf('core/src/main/scala/cats/instances/bigDecimal.scala', 'BigDecimalInstances'))
.toEqual(['kernel/src/main/scala/cats/kernel/instances/BigDecimalInstances.scala:BigDecimalInstances']);
expect(supertypesOf('core/src/main/scala/cats/Functor.scala', 'Apply::AllOps'))
.toEqual(['core/src/main/scala/cats/Functor.scala:Functor::AllOps']);
});
});
+30
View File
@@ -8566,6 +8566,10 @@ export function matchReference(
// selected = useStore(s => s.reset); selected()` is the store's `reset`.)
if (target && target.name === ref.referenceName && isOutsideJsLocal(target, ref, context)) return null;
}
// A type never inherits from itself: cats' `trait BigDecimalInstances extends
// cats.kernel.instances.BigDecimalInstances` and `trait AllOps … with
// Bifoldable.AllOps` name another type of their own name.
if (result && result.targetNodeId === ref.fromNodeId && isInheritanceRef(ref)) return otherSupertypeNamed(ref, context);
// Nor does a value's initializer call the value: sttp's `val response =
// basicRequest.get(…).response(asStringAlways)` is a request's `response`.
if (result && result.targetNodeId === ref.fromNodeId && ref.referenceKind === 'calls' &&
@@ -8573,6 +8577,32 @@ export function matchReference(
return result ? retargetSelfOverload(result, ref, context) : result;
}
/**
* The supertype an inheritance ref names when the name is the declaring
* type's own: another type of that name — the one the written qualifier
* (`cats.kernel.instances.`, `Bifoldable.`) leads to, by its owner and its
* file's package. Null unless exactly one fits.
*/
function otherSupertypeNamed(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
const name = ref.referenceName.split(/::|\./).pop()!;
const line = context.getFileLines?.(ref.filePath)?.[ref.line - 1] ?? context.readFile(ref.filePath)?.split(/\r?\n/)[ref.line - 1] ?? '';
const written = new RegExp(`^\\s*((?:[\\w$]+\\.)+)${name.replace(/\$/g, '\\$')}\\b`).exec(line.slice(Math.max(0, ref.column)));
const qualifier = written ? written[1]!.slice(0, -1) : '';
const candidates = context.getNodesByName(name).filter((n) =>
n.id !== ref.fromNodeId && isSupertypeTarget(n) && sameLanguageFamily(n.language, ref.language) &&
(qualifier === '' || ownerPathOf(n, context) === qualifier || ownerPathOf(n, context).endsWith(`.${qualifier}`)));
return candidates.length === 1 ? { original: ref, targetNodeId: candidates[0]!.id, confidence: 0.8, resolvedBy: 'qualified-name' } : null;
}
/** A declaration's dotted owner path — its file's package clauses, then its enclosing types (`cats.kernel.instances`, `cats.Bifoldable`). */
function ownerPathOf(n: Node, context: ResolutionContext): string {
const text = context.readFile(n.filePath) ?? '';
const pkg = [...text.matchAll(/^\s*package\s+([\w.]+)\s*;?\s*$/gm)].map((m) => m[1]!).join('.');
const cut = n.qualifiedName.lastIndexOf('::');
const owners = cut > 0 ? n.qualifiedName.slice(0, cut).replace(/::/g, '.') : '';
return [pkg, owners].filter((p) => p !== '' && !(pkg !== '' && p === owners && owners.startsWith(pkg))).join('.');
}
/** Reference kinds a bare JS/TS local can be: a call, a value, a construction. */
const JS_LOCAL_REF_KINDS: ReadonlySet<string> = new Set(['calls', 'references', 'function_ref', 'instantiates']);