From d88c9afd34e60f40cc4865c32cc0dcd62c54d0a0 Mon Sep 17 00:00:00 2001 From: Juha Kangas <42040080+valuecodes@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:22:41 +0300 Subject: [PATCH 1/3] feat: resolve method calls on receivers typed as repository classes Co-Authored-By: Claude Opus 5.5 --- apps/web/src/examples.ts | 5 +- apps/web/src/world-from-code-lobbies.test.ts | 48 +-- packages/code-graph/src/code-graph.ts | 4 + packages/parser/src/calls.ts | 48 ++- packages/parser/src/functions.ts | 39 ++- packages/parser/src/imports.ts | 107 +++--- packages/parser/src/link-exports.ts | 208 ++++++++++++ packages/parser/src/link-overrides.ts | 138 ++++++++ packages/parser/src/link.test.ts | 5 +- packages/parser/src/link.ts | 242 +++++-------- packages/parser/src/parser.ts | 46 ++- packages/parser/src/receiver-types.ts | 119 +++++++ packages/parser/src/receivers.test.ts | 337 +++++++++++++++++++ packages/parser/src/resolve.ts | 171 ++++++---- packages/parser/src/scope-lookup.ts | 188 +++++++++++ packages/parser/src/scope.ts | 167 ++++++++- packages/parser/src/top-level.ts | 16 +- packages/parser/src/writes.ts | 95 ++++++ 18 files changed, 1650 insertions(+), 333 deletions(-) create mode 100644 packages/parser/src/link-exports.ts create mode 100644 packages/parser/src/link-overrides.ts create mode 100644 packages/parser/src/receiver-types.ts create mode 100644 packages/parser/src/receivers.test.ts create mode 100644 packages/parser/src/scope-lookup.ts create mode 100644 packages/parser/src/writes.ts diff --git a/apps/web/src/examples.ts b/apps/web/src/examples.ts index 0df2a07..0aecafc 100644 --- a/apps/web/src/examples.ts +++ b/apps/web/src/examples.ts @@ -55,7 +55,10 @@ function bar() {} `, }; -/** Methods as rooms: this.*, static and constructor calls resolve. */ +/** + * Methods as rooms: this.*, static and constructor calls resolve, and so + * does a call on `other`, which holds a `new UserService(…)`. + */ const service: SourceFile = { path: "service.ts", source: `class UserService { diff --git a/apps/web/src/world-from-code-lobbies.test.ts b/apps/web/src/world-from-code-lobbies.test.ts index 9715c03..0948d63 100644 --- a/apps/web/src/world-from-code-lobbies.test.ts +++ b/apps/web/src/world-from-code-lobbies.test.ts @@ -33,38 +33,48 @@ const doorsOf = (world: GeneratedWorld, roomId: string) => .map(({ to }) => to); describe("class lobbies", () => { - it("opens the service example's hub onto main and the UserService lobby", () => { - const { codeGraph, sources } = parsed("service"); + it("needs no lobby once main reaches every UserService method", () => { + const { codeGraph } = parsed("service"); const world = worldFromCode(codeGraph, 1); - const lobby = "service.ts//class:UserService"; - expect(lobbiesOf(world)).toEqual([lobby]); - expect(doorsOf(world, "service.ts")).toEqual([lobby, "service.ts::main"]); - // create and the constructor are reached by doors from main; load by nobody. - expect(doorsOf(world, lobby)).toEqual(["service.ts::UserService.load"]); - expect(describeRoom(codeGraph, lobby)).toBe( - "service.ts · class UserService" + // `other` holds a `new UserService(…)`, so `other.load()` is a door from + // main like create and the constructor: no method is left for a lobby. + expect(lobbiesOf(world)).toEqual([]); + expect(doorsOf(world, "service.ts")).toEqual(["service.ts::main"]); + expect(codeGraph.edges).toContainEqual( + expect.objectContaining({ + type: "call", + source: "service.ts::main", + target: "service.ts::UserService.load", + }) ); + }); + + it("names and shows a class lobby in the HUD", () => { + const { codeGraph, sources } = parsed("repo"); + const path = "src/handlers/auth.ts"; + const lobby = `${path}//class:Session`; + expect(describeRoom(codeGraph, lobby)).toBe(`${path} · class Session`); expect( promptOf(codeGraph, [], { kind: "door", - roomId: "service.ts", + roomId: path, targetRoomId: lobby, }) - ).toBe("→ class UserService"); - expect(breadcrumbOf(codeGraph, [], lobby)).toBe("class UserService"); + ).toBe("→ class Session"); + expect(breadcrumbOf(codeGraph, [], lobby)).toBe("class Session"); const view = sourceView(codeGraph, sources, lobby); - const classLines = (sources.get("service.ts") ?? "") - .split("\n") - .findIndex((line) => line === "}"); + const lines = (sources.get(path) ?? "").split("\n"); + const start = lines.findIndex((line) => line.startsWith("export class")); + const end = lines.findIndex((line) => line === "}"); expect(view).toMatchObject({ - path: "service.ts", - fn: "class UserService", + path, + fn: "class Session", region: null, - lines: `1–${classLines + 1}`, + lines: `${start + 1}–${end + 1}`, }); const first = view?.code[0]; expect(`${first?.before}${first?.marked}${first?.after}`).toBe( - "class UserService {" + "export class Session {" ); expect(view?.code.every((line) => line.marked === "")).toBe(true); }); diff --git a/packages/code-graph/src/code-graph.ts b/packages/code-graph/src/code-graph.ts index 3c71789..455d941 100644 --- a/packages/code-graph/src/code-graph.ts +++ b/packages/code-graph/src/code-graph.ts @@ -255,12 +255,16 @@ type CallKind = "call" | "optional-call" | "new"; * How a call reaches an import binding of its module: `helper()` and * `new Svc()` name it directly (`member` null), `ns.run()` and `Svc.make()` * through one member access, `new ns.Svc()` too. Deeper chains (`ns.a.b()`) carry none. + * `svc.run()` on a receiver typed as an imported class (`svc: Svc`, `import + * type` included) names that class, with `instance` set. */ type ImportVia = { /** The import binding's local name. */ readonly localName: string; readonly member: string | null; readonly isNew: boolean; + /** The binding is the receiver's class and `member` an instance member. */ + readonly instance?: boolean; }; type CallSite = { diff --git a/packages/parser/src/calls.ts b/packages/parser/src/calls.ts index e4caa9c..2c6441b 100644 --- a/packages/parser/src/calls.ts +++ b/packages/parser/src/calls.ts @@ -14,6 +14,7 @@ import { callSiteId } from "@repo/code-graph/ids"; import type { Discovered } from "./functions"; import { resolveCallee } from "./resolve"; +import type { Dispatch } from "./resolve"; import type { Scope } from "./scope"; import { spanOf } from "./span"; import { childrenOf, guardDepth } from "./walk"; @@ -21,6 +22,7 @@ import { childrenOf, guardDepth } from "./walk"; type CallCollector = { readonly discovered: Discovered; readonly sites: CallSite[]; + readonly dispatch: Map; }; const callOf = (node: Node): { kind: CallKind; callee: Node } | null => { @@ -66,14 +68,15 @@ const visitCalls = ( } const call = callOf(node); if (call !== null) { - const { calleeName, target, resolution, candidates, via } = resolveCallee( - call.kind, - call.callee, - scope, - collector.discovered.classes - ); + const { calleeName, target, resolution, candidates, via, dispatch } = + resolveCallee( + call.kind, + call.callee, + scope, + collector.discovered.classes + ); const span = spanOf(node); - collector.sites.push({ + const site: CallSite = { id: callSiteId(callerId, span.start), callerId, calleeName, @@ -86,7 +89,11 @@ const visitCalls = ( awaited, span, ...(via === undefined ? {} : { via }), - }); + }; + collector.sites.push(site); + if (dispatch !== undefined) { + collector.dispatch.set(site, dispatch); + } } for (const child of childrenOf(node)) { visitCalls(collector, callerId, scope, root, child, false, depth + 1); @@ -112,6 +119,8 @@ const uniqueIds = (sites: readonly CallSite[]): readonly CallSite[] => { type CallPass = { readonly callSites: readonly CallSite[]; readonly edges: readonly CallEdge[]; + /** By call site id: calls on typed receivers the linker checks for overrides. */ + readonly dispatch: ReadonlyMap; }; const collectCalls = ( @@ -119,7 +128,11 @@ const collectCalls = ( program: Node, discovered: Discovered ): CallPass => { - const collector: CallCollector = { discovered, sites: [] }; + const collector: CallCollector = { + discovered, + sites: [], + dispatch: new Map(), + }; visitCalls( collector, moduleId, @@ -135,11 +148,18 @@ const collectCalls = ( visitCalls(collector, fn.id, scope, node, node, false); } } - const callSites = uniqueIds( - collector.sites.toSorted( - (a, b) => a.span.start - b.span.start || a.span.end - b.span.end - ) + const sorted = collector.sites.toSorted( + (a, b) => a.span.start - b.span.start || a.span.end - b.span.end ); + const callSites = uniqueIds(sorted); + const dispatch = new Map(); + sorted.forEach((site, position) => { + const found = collector.dispatch.get(site); + const unique = callSites[position]; + if (found !== undefined && unique !== undefined) { + dispatch.set(unique.id, found); + } + }); const edges = new Map(); for (const site of callSites) { if (site.calleeId === null || site.callerId === moduleId) { @@ -154,7 +174,7 @@ const collectCalls = ( callSiteIds: [...(existing?.callSiteIds ?? []), site.id], }); } - return { callSites, edges: [...edges.values()] }; + return { callSites, edges: [...edges.values()], dispatch }; }; export { collectCalls }; diff --git a/packages/parser/src/functions.ts b/packages/parser/src/functions.ts index 8fa3c05..7852a9a 100644 --- a/packages/parser/src/functions.ts +++ b/packages/parser/src/functions.ts @@ -27,6 +27,7 @@ import type { } from "@repo/code-graph"; import { classId, functionId, uniqueNames } from "@repo/code-graph/ids"; +import { declareTypes, recordFields } from "./receiver-types"; import { bindingNames, fieldFunction, @@ -44,6 +45,8 @@ import type { } from "./scope"; import { spanOf } from "./span"; import { childrenOf, guardDepth } from "./walk"; +import { writesOf } from "./writes"; +import type { Writes } from "./writes"; type Discovered = { readonly functions: DiscoveredFunction[]; @@ -74,6 +77,8 @@ type Context = { * enclosing scope: a class declared there is not top-level. */ readonly enclosed: boolean; + /** What the module writes to, which unsettles a class inferred from `new`. */ + readonly writes: Writes; readonly out: Discovered; }; @@ -124,7 +129,14 @@ const register = ( if (table === null) { scope.locals.set(name, [...(scope.locals.get(name) ?? []), fn]); } - const inner = functionScope(scope, node, fn, table, isStatic); + const inner = functionScope( + scope, + node, + fn, + table, + isStatic, + context.writes.names + ); context.out.scopeOf.set(fn, inner); for (const child of childrenOf(node)) { visit(context, inner, child, false, depth + 1); @@ -145,6 +157,9 @@ const visitClassBody = ( instance: new Map(), static: new Map(), ctor: null, + fields: new Map(), + superName: isIdentifier(node.superClass) ? node.superClass.name : null, + scope, }; scope.classes.set(name, table); context.out.classes.push(table); @@ -158,6 +173,9 @@ const visitClassBody = ( }); for (const member of node.body.body) { const field = fieldFunction(member); + if (field === null) { + recordFields(context.writes, table, member); + } if (isClassMethod(member) || isClassPrivateMethod(member)) { const method = memberName(member); if (method === null) { @@ -280,6 +298,7 @@ const visit = ( }); continue; } + declareTypes(context.writes, scope, id, init); bindingNames(id, scope.bindings); if (init !== null && init !== undefined) { next(init, scope); @@ -320,7 +339,14 @@ const visit = ( // class expressions are not rooms: their calls belong to the enclosing // function, but their parameters are their own. if (isFunctionLike(node)) { - const inner = functionScope(scope, node, null, null, false); + const inner = functionScope( + scope, + node, + null, + null, + false, + context.writes.names + ); context.out.scopeAt.set(node, inner); for (const child of childrenOf(node)) { next(child, inner); @@ -336,7 +362,8 @@ const discover = ( moduleId: string, program: Program, imports: readonly ImportRecord[], - exportedNames: ReadonlySet + exportedNames: ReadonlySet, + typeImports: readonly ImportRecord[] = [] ): Discovered => { const moduleScope = newScope(null, null, null, false); for (const record of imports) { @@ -344,6 +371,11 @@ const discover = ( moduleScope.imports.set(record.localName, record); } } + for (const record of typeImports) { + if (record.localName !== null) { + moduleScope.typeImports.set(record.localName, record); + } + } const out: Discovered = { functions: [], edges: [], @@ -361,6 +393,7 @@ const discover = ( nextClassName: uniqueNames(), enclosed: false, exportedNames, + writes: writesOf(program), out, }; for (const statement of program.body) { diff --git a/packages/parser/src/imports.ts b/packages/parser/src/imports.ts index e0d50bb..d9914ed 100644 --- a/packages/parser/src/imports.ts +++ b/packages/parser/src/imports.ts @@ -1,8 +1,10 @@ // What a module imports and exports, as plain records the linker can follow // (PR B of Phase 4 resolves `ImportRecord.moduleId`). Type-only imports and -// exports are skipped: they never reach a call. So are `import x = -// require()` and `export =`, which are TypeScript's CommonJS forms. A -// side-effect import is still a dependency: one record without names. +// exports never reach a call, so the module's records skip them; asked for +// by `kind`, they are read on their own, for the classes a receiver is +// annotated with (`runner: AgentRunner`). `import x = require()` and +// `export =`, TypeScript's CommonJS forms, are skipped. A side-effect +// import is still a dependency: one record without names. import { isArrowFunctionExpression, @@ -17,7 +19,7 @@ import { isIdentifier, isImportDeclaration, isImportDefaultSpecifier, - isImportNamespaceSpecifier, + isImportSpecifier, isTSDeclareFunction, isTSEnumDeclaration, isTSInterfaceDeclaration, @@ -41,47 +43,53 @@ import { spanOf } from "./span"; const nameOf = (node: Identifier | StringLiteral): string => isIdentifier(node) ? node.name : node.value; -const importsOf = (program: Program): readonly ImportRecord[] => +/** Value records, or only the type-only ones (`import type`, `{ type X }`). */ +type RecordKind = "value" | "type"; + +const importsOf = ( + program: Program, + kind: RecordKind = "value" +): readonly ImportRecord[] => program.body.flatMap((statement) => { - if (!isImportDeclaration(statement) || statement.importKind === "type") { + if (!isImportDeclaration(statement)) { return []; } const specifier = statement.source.value; + const typeOnly = statement.importKind === "type"; if (statement.specifiers.length === 0) { - return [ - { - localName: null, - importedName: null, - specifier, - moduleId: null, - span: spanOf(statement), - }, - ]; - } - return statement.specifiers.flatMap((item): ImportRecord[] => { - if (isImportDefaultSpecifier(item) || isImportNamespaceSpecifier(item)) { - const importedName = isImportDefaultSpecifier(item) ? "default" : "*"; - return [ - { - localName: item.local.name, - importedName, - specifier, - moduleId: null, - span: spanOf(item), - }, - ]; - } - return item.importKind === "type" + return typeOnly || kind === "type" ? [] : [ { - localName: item.local.name, - importedName: nameOf(item.imported), + localName: null, + importedName: null, specifier, moduleId: null, - span: spanOf(item), + span: spanOf(statement), }, ]; + } + return statement.specifiers.flatMap((item): ImportRecord[] => { + const itemTypeOnly = + typeOnly || (isImportSpecifier(item) && item.importKind === "type"); + if (itemTypeOnly !== (kind === "type")) { + return []; + } + let importedName = "*"; + if (isImportDefaultSpecifier(item)) { + importedName = "default"; + } else if (isImportSpecifier(item)) { + importedName = nameOf(item.imported); + } + return [ + { + localName: item.local.name, + importedName, + specifier, + moduleId: null, + span: spanOf(item), + }, + ]; }); }); @@ -131,16 +139,22 @@ const declarationExports = ( }; const namedExports = ( - statement: ExportNamedDeclaration + statement: ExportNamedDeclaration, + kind: RecordKind ): readonly ExportRecord[] => { - if (statement.exportKind === "type") { - return []; - } + const typeOnly = statement.exportKind === "type"; if (statement.declaration !== null && statement.declaration !== undefined) { - return declarationExports(statement.declaration); + return typeOnly || kind === "type" + ? [] + : declarationExports(statement.declaration); } const specifier = statement.source?.value ?? null; return statement.specifiers.flatMap((item): ExportRecord[] => { + const itemTypeOnly = + typeOnly || (isExportSpecifier(item) && item.exportKind === "type"); + if (itemTypeOnly !== (kind === "type")) { + return []; + } if (isExportNamespaceSpecifier(item)) { return [ { @@ -151,7 +165,7 @@ const namedExports = ( }, ]; } - if (!isExportSpecifier(item) || item.exportKind === "type") { + if (!isExportSpecifier(item)) { return []; } const exportedName = nameOf(item.exported); @@ -184,19 +198,26 @@ const defaultExport = (statement: ExportDefaultDeclaration): ExportRecord => { return local("default", isIdentifier(declaration) ? declaration.name : null); }; -const exportsOf = (program: Program): readonly ExportRecord[] => +const exportsOf = ( + program: Program, + kind: RecordKind = "value" +): readonly ExportRecord[] => program.body.flatMap((statement): readonly ExportRecord[] => { if (isExportNamedDeclaration(statement)) { - return namedExports(statement); + return namedExports(statement, kind); } if (isExportDefaultDeclaration(statement)) { // Overload signatures and `export default interface` are types only. - return isTSDeclareFunction(statement.declaration) || + return kind === "type" || + isTSDeclareFunction(statement.declaration) || isTSInterfaceDeclaration(statement.declaration) ? [] : [defaultExport(statement)]; } - if (isExportAllDeclaration(statement) && statement.exportKind !== "type") { + if ( + isExportAllDeclaration(statement) && + (statement.exportKind === "type") === (kind === "type") + ) { return [ { exportedName: "*", diff --git a/packages/parser/src/link-exports.ts b/packages/parser/src/link-exports.ts new file mode 100644 index 0000000..5b2b0d0 --- /dev/null +++ b/packages/parser/src/link-exports.ts @@ -0,0 +1,208 @@ +// What the link pass follows an import to: the binding a module exports +// under a name, through re-exports, `export *` and namespaces, and the +// functions of a top-level class it finds there. + +import type { + ExportRecord, + ImportRecord, + ImportVia, + ModuleNode, +} from "@repo/code-graph"; +import type { Resolver } from "@repo/code-graph/module-resolver"; + +import type { DiscoveredFunction } from "./scope"; +import type { TopLevel } from "./top-level"; + +/** A top-level binding of a module, or a whole module as a namespace object. */ +type Binding = + | { + readonly kind: "local"; + readonly moduleId: string; + readonly localName: string; + } + | { readonly kind: "namespace"; readonly moduleId: string }; + +/** One binding, several (`export *` from modules that clash), or nothing. */ +type Lookup = Binding | readonly Binding[] | null; + +const listOf = (lookup: Lookup): readonly Binding[] => { + if (lookup === null) { + return []; + } + return "kind" in lookup ? [lookup] : lookup; +}; + +const bindingKey = (binding: Binding): string => + binding.kind === "local" + ? `${binding.moduleId}\0${binding.localName}` + : binding.moduleId; + +type Linker = { + readonly resolve: Resolver; + readonly modules: ReadonlyMap; + readonly functions: ReadonlyMap; + readonly topLevel: ReadonlyMap; + /** Linked, like the modules' own imports. */ + readonly typeImports: ReadonlyMap; + readonly typeExports: ReadonlyMap; +}; + +/** + * A module's imports and exports; with `types`, its type-only ones too, + * after the others, for following a receiver's class (which a barrel may + * re-export as a type only). + */ +const importsFor = ( + linker: Linker, + module: ModuleNode, + types: boolean +): readonly ImportRecord[] => + types + ? [...module.imports, ...(linker.typeImports.get(module.id) ?? [])] + : module.imports; + +const exportsFor = ( + linker: Linker, + module: ModuleNode, + types: boolean +): readonly ExportRecord[] => + types + ? [...module.exports, ...(linker.typeExports.get(module.id) ?? [])] + : module.exports; + +const importBinding = ( + linker: Linker, + record: ImportRecord, + seen: Set, + types = false +): Lookup => { + if (record.moduleId === null || record.importedName === null) { + return null; + } + return record.importedName === "*" + ? { kind: "namespace", moduleId: record.moduleId } + : exportOf(linker, record.moduleId, record.importedName, seen, types); +}; + +/** Distinct hits of the module's `export *` declarations, in source order. */ +const starExports = ( + linker: Linker, + module: ModuleNode, + name: string, + seen: Set, + types: boolean +): Lookup => { + const hits = new Map(); + for (const record of exportsFor(linker, module, types)) { + if (record.exportedName !== "*" || record.specifier === null) { + continue; + } + const target = linker.resolve(module.id, record.specifier); + const found = + target === null ? null : exportOf(linker, target, name, seen, types); + for (const binding of listOf(found)) { + hits.set(bindingKey(binding), binding); + } + } + const all = [...hits.values()]; + return all.length <= 1 ? (all[0] ?? null) : all; +}; + +/** + * What `name` exported from `moduleId` is bound to. A named export wins over + * `export *` (which never carries `default`); a local export that is itself + * an import is followed; `seen` stops re-export cycles. + */ +const exportOf = ( + linker: Linker, + moduleId: string, + name: string, + seen: Set, + types = false +): Lookup => { + const key = `${moduleId}\0${name}`; + const module = linker.modules.get(moduleId); + if (module === undefined || seen.has(key)) { + return null; + } + seen.add(key); + const record = exportsFor(linker, module, types).find( + (item) => item.exportedName === name + ); + if (record === undefined) { + return name === "default" + ? null + : starExports(linker, module, name, seen, types); + } + if (record.specifier === null) { + if (record.localName === null) { + return null; + } + const imported = importsFor(linker, module, types).find( + (item) => item.localName === record.localName + ); + return imported === undefined + ? { kind: "local", moduleId, localName: record.localName } + : importBinding(linker, imported, seen, types); + } + const target = linker.resolve(moduleId, record.specifier); + if (target === null || record.importedName === null) { + return null; + } + return record.importedName === "*" + ? { kind: "namespace", moduleId: target } + : exportOf(linker, target, record.importedName, seen, types); +}; + +/** What the import a call goes through is bound to. */ +const viaBindings = ( + linker: Linker, + module: ModuleNode, + via: ImportVia +): readonly Binding[] => { + const types = via.instance === true; + const record = importsFor(linker, module, types).find( + (item) => item.localName === via.localName + ); + return listOf( + record === undefined + ? null + : importBinding(linker, record, new Set(), types) + ); +}; + +/** A function of the module's own top-level class `name`, not of a block's namesake. */ +const memberOf = ( + linker: Linker, + moduleId: string, + name: string, + test: (fn: DiscoveredFunction) => boolean +): DiscoveredFunction | undefined => { + const range = linker.topLevel.get(moduleId)?.classes.get(name); + return range === undefined + ? undefined + : (linker.functions.get(moduleId) ?? []).find( + (fn) => + fn.className === name && + fn.parentId === null && + fn.span.start >= range.start && + fn.span.end <= range.end && + test(fn) + ); +}; + +/** An instance member (method, accessor or function field) by name. */ +const isInstanceMember = + (member: string) => + (fn: DiscoveredFunction): boolean => + !fn.isStatic && fn.kind !== "constructor" && fn.name === member; + +export { + exportOf, + importBinding, + isInstanceMember, + listOf, + memberOf, + viaBindings, +}; +export type { Binding, Linker }; diff --git a/packages/parser/src/link-overrides.ts b/packages/parser/src/link-overrides.ts new file mode 100644 index 0000000..9c6145e --- /dev/null +++ b/packages/parser/src/link-overrides.ts @@ -0,0 +1,138 @@ +// Overrides across files: a call on a receiver typed as a class may run any +// override of its member in a subclass, which the per-file pass sees only +// within the module. The linker indexes every top-level class's subclasses +// over the whole graph and widens such calls by what they find. + +import type { CallSite, ModuleNode } from "@repo/code-graph"; + +import { + importBinding, + isInstanceMember, + listOf, + memberOf, + viaBindings, +} from "./link-exports"; +import type { Binding, Linker } from "./link-exports"; +import type { Dispatch } from "./resolve"; +import { MAX_LINEAGE } from "./scope-lookup"; + +/** A top-level class of a module. */ +type ClassKey = { readonly moduleId: string; readonly name: string }; + +const classKey = ({ moduleId, name }: ClassKey): string => + `${moduleId}\0${name}`; + +/** The top-level class one binding names, if it names exactly one. */ +const classOf = ( + linker: Linker, + bindings: readonly Binding[] +): ClassKey | null => { + const [only, ...rest] = bindings; + return only?.kind === "local" && + rest.length === 0 && + linker.topLevel.get(only.moduleId)?.classes.has(only.localName) === true + ? { moduleId: only.moduleId, name: only.localName } + : null; +}; + +/** The class a top-level class extends: one of its module, or an imported one. */ +const parentOf = ( + linker: Linker, + { moduleId, name }: ClassKey +): ClassKey | null => { + const classes = linker.topLevel.get(moduleId)?.classes; + const superName = classes?.get(name)?.superName ?? null; + if (superName === null) { + return null; + } + if (classes?.has(superName) === true) { + return { moduleId, name: superName }; + } + const record = linker.modules + .get(moduleId) + ?.imports.find((item) => item.localName === superName); + return record === undefined + ? null + : classOf(linker, listOf(importBinding(linker, record, new Set()))); +}; + +type SubclassIndex = ReadonlyMap; + +/** + * Every top-level class's subclasses, at any depth, across the graph, in + * module then source order. + */ +const subclassIndex = (linker: Linker): SubclassIndex => { + const index = new Map(); + for (const [moduleId, topLevel] of linker.topLevel) { + for (const name of topLevel.classes.keys()) { + const self = { moduleId, name }; + const seen = new Set([classKey(self)]); + for ( + let parent = parentOf(linker, self); + parent !== null && seen.size <= MAX_LINEAGE; + parent = parentOf(linker, parent) + ) { + const key = classKey(parent); + if (seen.has(key)) { + break; + } + seen.add(key); + index.set(key, [...(index.get(key) ?? []), self]); + } + } + } + return index; +}; + +/** + * Widens a call on a typed receiver by every override of its member in a + * subclass of the receiver's class anywhere in the graph: a subclass + * instance may stand where the class is named. One function stays + * resolved, more make the call ambiguous. + */ +const withOverrides = ( + linker: Linker, + subclasses: SubclassIndex, + module: ModuleNode, + site: CallSite, + dispatch: Dispatch +): CallSite => { + if (dispatch.member.startsWith("#")) { + return site; + } + let base: ClassKey | null = null; + if (dispatch.className !== null) { + base = { moduleId: module.id, name: dispatch.className }; + } else if (site.via !== undefined) { + base = classOf(linker, viaBindings(linker, module, site.via)); + } + if (base === null) { + return site; + } + const overrides = (subclasses.get(classKey(base)) ?? []).flatMap((sub) => { + const fn = memberOf( + linker, + sub.moduleId, + sub.name, + isInstanceMember(dispatch.member) + ); + return fn === undefined ? [] : [fn.id]; + }); + let current: readonly string[] = []; + if (site.resolution === "resolved" && site.calleeId !== null) { + current = [site.calleeId]; + } else if (site.resolution === "ambiguous") { + current = site.candidateIds ?? []; + } + const ids = [...new Set([...current, ...overrides])]; + const [only, ...rest] = ids; + if (ids.length === current.length || only === undefined) { + return site; + } + return rest.length === 0 + ? { ...site, resolution: "resolved", calleeId: only } + : { ...site, resolution: "ambiguous", calleeId: null, candidateIds: ids }; +}; + +export { subclassIndex, withOverrides }; diff --git a/packages/parser/src/link.test.ts b/packages/parser/src/link.test.ts index 549983b..a2d7cab 100644 --- a/packages/parser/src/link.test.ts +++ b/packages/parser/src/link.test.ts @@ -213,7 +213,10 @@ describe("linkModules", () => { expect(siteOf(graph, "Session").calleeId).toBe(ctor); expect(siteOf(graph, "Session.open").calleeId).toBe("s.ts::Session.open"); expect(siteOf(graph, "ns.Session").calleeId).toBe(ctor); - expect(siteOf(graph, "session.refresh").resolution).not.toBe("resolved"); + // `session` holds a `new Session()`: its instance member resolves. + expect(siteOf(graph, "session.refresh").calleeId).toBe( + "s.ts::Session.refresh" + ); }); it("links to the module's own binding, not a block's namesake", () => { diff --git a/packages/parser/src/link.ts b/packages/parser/src/link.ts index e1286fc..1c282a6 100644 --- a/packages/parser/src/link.ts +++ b/packages/parser/src/link.ts @@ -6,19 +6,30 @@ // see module-resolver.ts for what is out of scope. An import of anything // else (npm packages, `node:` builtins) stays unresolved and keeps `via`, // which is how the HUD tells a library call from one the analysis could not -// follow. +// follow. Calls on typed receivers are widened by overrides in other files +// (link-overrides.ts). import type { CallEdge, CallSite, + ExportRecord, GraphEdge, ImportRecord, ModuleNode, PackageNode, } from "@repo/code-graph"; import { createResolver } from "@repo/code-graph/module-resolver"; -import type { Resolver } from "@repo/code-graph/module-resolver"; +import { + exportOf, + isInstanceMember, + listOf, + memberOf, + viaBindings, +} from "./link-exports"; +import type { Binding, Linker } from "./link-exports"; +import { subclassIndex, withOverrides } from "./link-overrides"; +import type { Dispatch } from "./resolve"; import type { DiscoveredFunction } from "./scope"; import type { TopLevel } from "./top-level"; @@ -31,113 +42,14 @@ type Linkable = { readonly topLevel: TopLevel; readonly callSites: readonly CallSite[]; readonly edges: readonly GraphEdge[]; -}; - -/** A top-level binding of a module, or a whole module as a namespace object. */ -type Binding = - | { - readonly kind: "local"; - readonly moduleId: string; - readonly localName: string; - } - | { readonly kind: "namespace"; readonly moduleId: string }; - -/** One binding, several (`export *` from modules that clash), or nothing. */ -type Lookup = Binding | readonly Binding[] | null; - -const listOf = (lookup: Lookup): readonly Binding[] => { - if (lookup === null) { - return []; - } - return "kind" in lookup ? [lookup] : lookup; -}; - -const bindingKey = (binding: Binding): string => - binding.kind === "local" - ? `${binding.moduleId}\0${binding.localName}` - : binding.moduleId; - -type Linker = { - readonly resolve: Resolver; - readonly modules: ReadonlyMap; - readonly functions: ReadonlyMap; - readonly topLevel: ReadonlyMap; -}; - -const importBinding = ( - linker: Linker, - record: ImportRecord, - seen: Set -): Lookup => { - if (record.moduleId === null || record.importedName === null) { - return null; - } - return record.importedName === "*" - ? { kind: "namespace", moduleId: record.moduleId } - : exportOf(linker, record.moduleId, record.importedName, seen); -}; - -/** Distinct hits of the module's `export *` declarations, in source order. */ -const starExports = ( - linker: Linker, - module: ModuleNode, - name: string, - seen: Set -): Lookup => { - const hits = new Map(); - for (const record of module.exports) { - if (record.exportedName !== "*" || record.specifier === null) { - continue; - } - const target = linker.resolve(module.id, record.specifier); - const found = target === null ? null : exportOf(linker, target, name, seen); - for (const binding of listOf(found)) { - hits.set(bindingKey(binding), binding); - } - } - const all = [...hits.values()]; - return all.length <= 1 ? (all[0] ?? null) : all; -}; - -/** - * What `name` exported from `moduleId` is bound to. A named export wins over - * `export *` (which never carries `default`); a local export that is itself - * an import is followed; `seen` stops re-export cycles. - */ -const exportOf = ( - linker: Linker, - moduleId: string, - name: string, - seen: Set -): Lookup => { - const key = `${moduleId}\0${name}`; - const module = linker.modules.get(moduleId); - if (module === undefined || seen.has(key)) { - return null; - } - seen.add(key); - const record = module.exports.find((item) => item.exportedName === name); - if (record === undefined) { - return name === "default" ? null : starExports(linker, module, name, seen); - } - if (record.specifier === null) { - if (record.localName === null) { - return null; - } - const imported = module.imports.find( - (item) => item.localName === record.localName - ); - return imported === undefined - ? { kind: "local", moduleId, localName: record.localName } - : importBinding(linker, imported, seen); - } - const target = linker.resolve(moduleId, record.specifier); - if (target === null || record.importedName === null) { - return null; - } - return record.importedName === "*" - ? { kind: "namespace", moduleId: target } - : exportOf(linker, target, record.importedName, seen); + /** + * Type-only imports and exports, never on the module: followed only to + * the class of a typed receiver (`via.instance`). + */ + readonly typeImports: readonly ImportRecord[]; + readonly typeExports: readonly ExportRecord[]; + /** By call site id: calls on typed receivers that subclasses may override. */ + readonly dispatch: ReadonlyMap; }; /** A binding and the member called on it, if any. */ @@ -161,36 +73,34 @@ const calleesOf = ( /** * The function a binding calls: a top-level plain function, `new` on a * top-level class (its constructor) or on a plain function, or with a - * member a static method of a top-level class. Null for anything else, - * a namespace called directly included. + * member a static method of a top-level class, or with `instance` an + * instance member of one. Null for anything else, a namespace called + * directly included. */ const functionOf = ( linker: Linker, { binding, member }: Callee, - isNew: boolean + isNew: boolean, + instance: boolean ): DiscoveredFunction | null => { if (binding.kind === "namespace") { return null; } const name = binding.localName; const plain = linker.topLevel.get(binding.moduleId)?.functions.get(name); - const range = linker.topLevel.get(binding.moduleId)?.classes.get(name); - // A member of the module's own class `name`, not of a block's namesake. const ofClass = (test: (fn: DiscoveredFunction) => boolean) => - range === undefined - ? undefined - : (linker.functions.get(binding.moduleId) ?? []).find( - (fn) => - fn.className === name && - fn.parentId === null && - fn.span.start >= range.start && - fn.span.end <= range.end && - test(fn) - ); + memberOf(linker, binding.moduleId, name, test); if (member !== null) { - return isNew - ? null - : (ofClass((fn) => fn.isStatic && fn.name === member) ?? null); + if (isNew) { + return null; + } + return ( + ofClass( + instance + ? isInstanceMember(member) + : (fn) => fn.isStatic && fn.name === member + ) ?? null + ); } if (!isNew) { return plain ?? null; @@ -208,17 +118,15 @@ const linkSite = ( if (via === undefined || site.resolution !== "unresolved") { return site; } - const record = module.imports.find( - (item) => item.localName === via.localName - ); - const found = - record === undefined ? null : importBinding(linker, record, new Set()); - const callees = listOf(found).flatMap((binding) => - calleesOf(linker, binding, via.member) - ); + const instance = via.instance === true; + const bindings = viaBindings(linker, module, via); + // A receiver's class is called on as it is, never as a namespace. + const callees = instance + ? bindings.map((binding) => ({ binding, member: via.member })) + : bindings.flatMap((binding) => calleesOf(linker, binding, via.member)); const targets = new Map(); for (const callee of callees) { - const fn = functionOf(linker, callee, via.isNew); + const fn = functionOf(linker, callee, via.isNew, instance); if (fn !== null) { targets.set(fn.id, fn); } @@ -239,7 +147,10 @@ const linkSite = ( }; }; -/** Adds a call edge per newly resolved site whose caller is a function. */ +/** + * Adds a call edge per newly resolved site whose caller is a function, and + * takes a site off the edge it no longer resolves along. + */ const withEdges = ( unit: Linkable, sites: readonly CallSite[] @@ -253,11 +164,21 @@ const withEdges = ( }); sites.forEach((site, position) => { const before = unit.callSites[position]; - if ( - site.calleeId === null || - before?.resolution === "resolved" || - site.callerId === unit.module.id - ) { + const was = before?.resolution === "resolved" ? before.calleeId : null; + if (site.calleeId === was || site.callerId === unit.module.id) { + return; + } + // Overrides found across files turned a resolved call ambiguous. + const old = + was === null ? undefined : index.get(`${site.callerId}->${was}`); + const stale = old === undefined ? undefined : edges[old]; + if (old !== undefined && stale?.type === "call") { + edges[old] = { + ...stale, + callSiteIds: stale.callSiteIds.filter((id) => id !== site.id), + }; + } + if (site.calleeId === null) { return; } const key = `${site.callerId}->${site.calleeId}`; @@ -279,7 +200,9 @@ const withEdges = ( index.set(key, edges.length); edges.push(edge); }); - return edges; + return edges.filter( + (edge) => edge.type !== "call" || edge.callSiteIds.length > 0 + ); }; /** @@ -295,16 +218,15 @@ const linkModules = ( new Set(units.map((unit) => unit.module.id)), packages ); + const linked = (moduleId: string, records: readonly ImportRecord[]) => + records.map((record) => ({ + ...record, + moduleId: resolve(moduleId, record.specifier), + })); const modules = new Map( units.map((unit): [string, ModuleNode] => [ unit.module.id, - { - ...unit.module, - imports: unit.module.imports.map((record) => ({ - ...record, - moduleId: resolve(unit.module.id, record.specifier), - })), - }, + { ...unit.module, imports: linked(unit.module.id, unit.module.imports) }, ]) ); const linker: Linker = { @@ -312,12 +234,26 @@ const linkModules = ( modules, functions: new Map(units.map((unit) => [unit.module.id, unit.functions])), topLevel: new Map(units.map((unit) => [unit.module.id, unit.topLevel])), + typeImports: new Map( + units.map((unit) => [ + unit.module.id, + linked(unit.module.id, unit.typeImports), + ]) + ), + typeExports: new Map( + units.map((unit) => [unit.module.id, unit.typeExports]) + ), }; + const subclasses = subclassIndex(linker); return units.map((unit) => { const module = modules.get(unit.module.id) ?? unit.module; - const callSites = unit.callSites.map((site) => - linkSite(linker, module, site) - ); + const callSites = unit.callSites.map((site) => { + const next = linkSite(linker, module, site); + const dispatch = unit.dispatch.get(site.id); + return dispatch === undefined + ? next + : withOverrides(linker, subclasses, module, next, dispatch); + }); return { ...unit, module, callSites, edges: withEdges(unit, callSites) }; }); }; diff --git a/packages/parser/src/parser.ts b/packages/parser/src/parser.ts index 6960836..562bf1c 100644 --- a/packages/parser/src/parser.ts +++ b/packages/parser/src/parser.ts @@ -2,8 +2,10 @@ import type { CallSite, ClassNode, CodeGraph, + ExportRecord, FunctionNode, GraphEdge, + ImportRecord, ModuleNode, } from "@repo/code-graph"; import { moduleId } from "@repo/code-graph/ids"; @@ -18,6 +20,7 @@ import { exportedLocalNames, exportsOf, importsOf } from "./imports"; import { linkModules } from "./link"; import { packagesOf, splitFiles } from "./manifests"; import type { OnParseError } from "./manifests"; +import type { Dispatch } from "./resolve"; import type { DiscoveredFunction } from "./scope"; import { lineCountOf } from "./span"; import { topLevelOf } from "./top-level"; @@ -73,6 +76,9 @@ type Analysed = { readonly topLevel: TopLevel; readonly callSites: readonly CallSite[]; readonly edges: readonly GraphEdge[]; + readonly typeImports: readonly ImportRecord[]; + readonly typeExports: readonly ExportRecord[]; + readonly dispatch: ReadonlyMap; readonly discovered: Discovered; readonly source: string; }; @@ -80,22 +86,27 @@ type Analysed = { const analyseModule = (file: SourceFile): Analysed => { const id = moduleId(file.path); const ast = parseSource(file.path, file.source); - const { imports, exports, discovered, calls } = withPath(file.path, () => { - const importRecords = importsOf(ast.program); - const exportRecords = exportsOf(ast.program); - const found = discover( - id, - ast.program, - importRecords, - exportedLocalNames(exportRecords) - ); - return { - imports: importRecords, - exports: exportRecords, - discovered: found, - calls: collectCalls(id, ast.program, found), - }; - }); + const { imports, exports, typeImports, typeExports, discovered, calls } = + withPath(file.path, () => { + const importRecords = importsOf(ast.program); + const exportRecords = exportsOf(ast.program); + const typeImportRecords = importsOf(ast.program, "type"); + const found = discover( + id, + ast.program, + importRecords, + exportedLocalNames(exportRecords), + typeImportRecords + ); + return { + imports: importRecords, + exports: exportRecords, + typeImports: typeImportRecords, + typeExports: exportsOf(ast.program, "type"), + discovered: found, + calls: collectCalls(id, ast.program, found), + }; + }); return { module: { id, @@ -110,6 +121,9 @@ const analyseModule = (file: SourceFile): Analysed => { topLevel: topLevelOf(ast.program, discovered.byNode), callSites: calls.callSites, edges: [...discovered.edges, ...calls.edges], + typeImports, + typeExports, + dispatch: calls.dispatch, discovered, source: file.source, }; diff --git a/packages/parser/src/receiver-types.ts b/packages/parser/src/receiver-types.ts new file mode 100644 index 0000000..ac9f64c --- /dev/null +++ b/packages/parser/src/receiver-types.ts @@ -0,0 +1,119 @@ +// The classes the discovery pass records for receivers: a variable's +// (`const svc: Svc`, `let svc = new Svc()`) in its scope, and a class's +// instance fields (`svc: Svc`, `constructor(private svc: Svc)`) in its table. + +import { + isAssignmentPattern, + isClassMethod, + isClassPrivateProperty, + isClassProperty, + isIdentifier, + isTSParameterProperty, +} from "@babel/types"; +import type { ClassBody, Node } from "@babel/types"; + +import { bindingNames, declareType, receiverOf } from "./scope"; +import type { ClassTable, ReceiverType, Scope } from "./scope"; +import type { Writes } from "./writes"; + +/** + * The class of a variable, before its names join the scope's bindings: one + * already bound in the (folded) scope must agree with it. A destructured + * name has no class. + */ +const declareTypes = ( + writes: Writes, + scope: Scope, + id: Node, + init: Node | null | undefined +): void => { + const bound = (name: string) => + scope.bindings.has(name) || + scope.locals.has(name) || + scope.classes.has(name); + if (isIdentifier(id)) { + declareType( + scope.types, + bound(id.name), + id.name, + receiverOf(id.typeAnnotation, init, writes.names.has(id.name), false) + ); + return; + } + const names = new Set(); + bindingNames(id, names); + for (const name of names) { + declareType(scope.types, bound(name), name, null); + } +}; + +/** Records one instance field, typed or not; a redeclaration must agree. */ +const recordField = ( + table: ClassTable, + name: string, + type: ReceiverType | null +): void => { + if (table.fields.has(name)) { + declareType(table.fields, true, name, type); + } else { + table.fields.set(name, type); + } +}; + +/** + * The instance fields a member declares: a property that does not hold a + * function (`svc: Svc`, `#svc = new Svc()`), or the constructor's parameter + * properties (`constructor(private svc: Svc)`). Anyone holding the instance + * may replace a field, so a field's class is never exact. + */ +const recordFields = ( + writes: Writes, + table: ClassTable, + member: ClassBody["body"][number] +): void => { + if (isClassProperty(member) || isClassPrivateProperty(member)) { + if (member.static) { + return; + } + let fieldName: string | null = null; + if (isClassPrivateProperty(member)) { + fieldName = `#${member.key.id.name}`; + } else if (!member.computed && isIdentifier(member.key)) { + fieldName = member.key.name; + } + if (fieldName !== null) { + recordField( + table, + fieldName, + receiverOf( + member.typeAnnotation, + member.value, + writes.members.has(fieldName), + true + ) + ); + } + return; + } + if (!isClassMethod(member) || member.kind !== "constructor") { + return; + } + for (const param of member.params) { + if (!isTSParameterProperty(param)) { + continue; + } + const binding = param.parameter; + const [id, init] = isAssignmentPattern(binding) + ? [binding.left, binding.right] + : [binding, null]; + if (isIdentifier(id)) { + recordField( + table, + id.name, + receiverOf(id.typeAnnotation, init, writes.members.has(id.name), true) + ); + } + } +}; + +export { declareTypes, recordFields }; diff --git a/packages/parser/src/receivers.test.ts b/packages/parser/src/receivers.test.ts new file mode 100644 index 0000000..4a273ef --- /dev/null +++ b/packages/parser/src/receivers.test.ts @@ -0,0 +1,337 @@ +import type { CallSite, CodeGraph } from "@repo/code-graph"; +import { describe, expect, it } from "vitest"; + +import { buildCodeGraph, parseModule } from "./parser"; + +/** The one call site whose callee reads `calleeName`. */ +const only = (sites: readonly CallSite[], calleeName: string): CallSite => { + const found = sites.filter((site) => site.calleeName === calleeName); + const [site] = found; + if (site === undefined || found.length > 1) { + throw new Error(`not exactly one call to ${calleeName}`); + } + return site; +}; + +/** Where one call goes: its resolution and callee or candidates. */ +const target = (site: CallSite) => [ + site.resolution, + site.resolution === "ambiguous" ? site.candidateIds : site.calleeId, +]; + +const call = (source: string, calleeName: string) => + target(only(parseModule({ path: "t.ts", source }).callSites, calleeName)); + +const graphOf = (files: Readonly>): CodeGraph => + buildCodeGraph( + Object.entries(files).map(([path, source]) => ({ path, source })) + ); + +const linked = (files: Readonly>, calleeName: string) => + target(only(graphOf(files).callSites, calleeName)); + +const svc = "class Svc { run() {} stop() {} }"; + +describe("typed receivers in one module", () => { + it("resolves a variable holding a new instance", () => { + expect( + call(`${svc}\nfunction main() { const s = new Svc(); s.run(); }`, "s.run") + ).toEqual(["resolved", "t.ts::Svc.run"]); + }); + + it("resolves annotated variables and parameters, generics and optionals", () => { + const source = [ + svc, + "class Box { open() {} }", + "function a(s: Svc) { s.run(); }", + "function b(box: Box) { box.open(); }", + "function c(s?: Svc | undefined) { s?.stop(); }", + "function d(s: Svc | null = null) { let t: Svc; t = s!; t.run(); }", + ].join("\n"); + const sites = parseModule({ path: "t.ts", source }).callSites; + expect(sites.map(target)).toEqual([ + ["resolved", "t.ts::Svc.run"], + ["resolved", "t.ts::Box.open"], + ["resolved", "t.ts::Svc.stop"], + ["resolved", "t.ts::Svc.run"], + ]); + }); + + it("resolves typed fields, parameter properties and private fields", () => { + const source = [ + svc, + "class App {", + " a: Svc;", + " b = new Svc();", + " #c: Svc;", + " constructor(private d: Svc) {}", + " go() { this.a.run(); this.b.run(); this.#c.stop(); this.d?.stop(); }", + "}", + ].join("\n"); + const sites = parseModule({ path: "t.ts", source }).callSites.filter( + (site) => site.kind !== "new" + ); + expect(sites.map((site) => [site.calleeName, ...target(site)])).toEqual([ + ["this.a.run", "resolved", "t.ts::Svc.run"], + ["this.b.run", "resolved", "t.ts::Svc.run"], + ["this.#c.stop", "resolved", "t.ts::Svc.stop"], + ["this.d.stop", "resolved", "t.ts::Svc.stop"], + ]); + }); + + it("follows inherited members and a class declared later", () => { + const source = [ + "function main(s: Child) { s.run(); s.own(); }", + "class Child extends Base { own() { this.run(); } }", + "class Base { run() {} }", + ].join("\n"); + const sites = parseModule({ path: "t.ts", source }).callSites; + expect(sites.map((site) => [site.calleeName, ...target(site)])).toEqual([ + ["s.run", "resolved", "t.ts::Base.run"], + ["s.own", "resolved", "t.ts::Child.own"], + ["this.run", "resolved", "t.ts::Base.run"], + ]); + }); + + it("makes a call ambiguous when a subclass overrides the member", () => { + const source = [ + "class Base { run() {} }", + "class Fast extends Base { run() {} }", + "class Slow extends Fast { run() {} }", + "function main(s: Base) { s.run(); }", + ].join("\n"); + expect(call(source, "s.run")).toEqual([ + "ambiguous", + ["t.ts::Base.run", "t.ts::Fast.run", "t.ts::Slow.run"], + ]); + }); + + it("keeps an exact instance from its subclasses' overrides", () => { + const source = [ + "class Base { run() {} }", + "class Fast extends Base { run() {} }", + "function main() { const s = new Base(); s.run(); }", + ].join("\n"); + expect(call(source, "s.run")).toEqual(["resolved", "t.ts::Base.run"]); + }); + + it("resolves an abstract member to its one implementation", () => { + const source = [ + "abstract class Shape { abstract area(): number; }", + "class Square extends Shape { area() { return 1; } }", + "function main(s: Shape) { s.area(); }", + ].join("\n"); + expect(call(source, "s.area")).toEqual(["resolved", "t.ts::Square.area"]); + }); + + it("leaves a member missing from the typed class unresolved, not guessed", () => { + const source = [ + svc, + "class Other { jump() {} }", + "class Third { jump() {} }", + "function main(s: Svc) { s.jump(); }", + ].join("\n"); + expect(call(source, "s.jump")).toEqual(["unresolved", null]); + }); + + it("keeps today's result where no class is known", () => { + const source = [ + "interface Runner { run(): void }", + "class A { run() {} }", + "class B { run() {} }", + "function main(r: Runner, m: Map, u: A | B, l: A[]) {", + " r.run(); m.get('x'); u.run(); l.pop();", + "}", + ].join("\n"); + const sites = parseModule({ path: "t.ts", source }).callSites; + expect(sites.map((site) => [site.calleeName, site.resolution])).toEqual([ + ["r.run", "ambiguous"], + ["m.get", "unresolved"], + ["u.run", "ambiguous"], + ["l.pop", "unresolved"], + ]); + }); + + it("drops a class inferred from new once the name is written", () => { + const source = [ + "class A { m() {} }", + "class B { m() {} }", + "function main() { let x = new A(); x = new B(); x.m(); }", + "function typed() { let y: A = new A(); y = new A(); y.m(); }", + ].join("\n"); + expect(call(source, "x.m")).toEqual([ + "ambiguous", + ["t.ts::A.m", "t.ts::B.m"], + ]); + expect(call(source, "y.m")).toEqual(["resolved", "t.ts::A.m"]); + }); + + it("drops a field's inferred class once any object writes the field", () => { + const source = [ + svc, + "class App { s = new Svc(); go() { this.s.run(); } }", + "function swap(app: App) { app.s = null!; }", + ].join("\n"); + expect(call(source, "this.s.run")).toEqual(["unresolved", null]); + }); + + it("lets inner and conflicting bindings hide a type", () => { + const source = [ + svc, + "class Other { run() {} }", + "function outer(s: Svc) { const f = (s) => s.run(); f(s); }", + "function folded(flag: boolean) {", + " if (flag) { const t = new Svc(); t.stop(); }", + " else { const t = new Other(); t.run(); }", + "}", + ].join("\n"); + const sites = parseModule({ path: "t.ts", source }).callSites; + // The untyped arrow parameter hides `s: Svc`; the two `t`s, folded into + // one scope, disagree, so neither keeps a class: today's results. + expect( + sites + .filter((site) => site.kind !== "new" && site.calleeName !== "f") + .map((site) => [site.calleeName, site.resolution]) + ).toEqual([ + ["s.run", "ambiguous"], + ["t.stop", "unresolved"], + ["t.run", "ambiguous"], + ]); + }); + + it("stops field lookup at a subclass's untyped redeclaration", () => { + const source = [ + svc, + "class Base { s: Svc; }", + "class Child extends Base { s; go() { this.s.run(); } }", + ].join("\n"); + expect(call(source, "this.s.run")).toEqual(["unresolved", null]); + }); + + it("never reaches a static member through an instance", () => { + const source = [ + "class Svc { static make() {} }", + "function main(s: Svc) { s.make(); }", + ].join("\n"); + expect(call(source, "s.make")).toEqual(["unresolved", null]); + }); +}); + +describe("typed receivers across files", () => { + const svcFile = "export class Svc { run() {} }"; + + it("resolves through value and type-only imports", () => { + for (const line of [ + 'import { Svc } from "./svc";', + 'import type { Svc } from "./svc";', + 'import { type Svc } from "./svc";', + ]) { + expect( + linked( + { + "a.ts": `${line}\nexport function main(s: Svc) { s.run(); }`, + "svc.ts": svcFile, + }, + "s.run" + ) + ).toEqual(["resolved", "svc.ts::Svc.run"]); + } + }); + + it("follows barrels, type-only re-exports and default exports", () => { + expect( + linked( + { + "a.ts": + 'import type { Svc } from "./index";\nfunction main(s: Svc) { s.run(); }', + "index.ts": 'export type { Svc } from "./svc";', + "svc.ts": svcFile, + }, + "s.run" + ) + ).toEqual(["resolved", "svc.ts::Svc.run"]); + expect( + linked( + { + "a.ts": + 'import type { Svc } from "./index";\nfunction main(s: Svc) { s.run(); }', + "index.ts": 'export * from "./svc";', + "svc.ts": svcFile, + }, + "s.run" + ) + ).toEqual(["resolved", "svc.ts::Svc.run"]); + expect( + linked( + { + "a.ts": + 'import Svc from "./svc";\nfunction main() { const s = new Svc(); s.run(); }', + "svc.ts": "export default class Svc { run() {} }", + }, + "s.run" + ) + ).toEqual(["resolved", "svc.ts::Svc.run"]); + }); + + it("widens a call by overrides in subclasses in other files", () => { + const graph = graphOf({ + "a.ts": [ + 'import { Base } from "./base";', + "export function main(b: Base) { b.run(); }", + "export function exact() { const b = new Base(); b.run(); }", + ].join("\n"), + "base.ts": "export class Base { run() {} }", + "fast.ts": + 'import { Base } from "./base";\nexport class Fast extends Base { run() {} }', + }); + const sites = graph.callSites.filter((site) => site.calleeName === "b.run"); + expect(sites.map(target)).toEqual([ + ["ambiguous", ["base.ts::Base.run", "fast.ts::Fast.run"]], + ["resolved", "base.ts::Base.run"], + ]); + // The widened call is no longer a call edge; the exact one still is. + const edges = graph.edges.flatMap((edge) => + edge.type === "call" ? [[edge.source, edge.target]] : [] + ); + expect(edges).toEqual([["a.ts::exact", "base.ts::Base.run"]]); + }); + + it("widens a local class's call by an override in another file", () => { + const graph = graphOf({ + "base.ts": [ + "export class Base { run() {} }", + "export function main(b: Base) { b.run(); }", + ].join("\n"), + "fast.ts": + 'import { Base } from "./base";\nexport class Fast extends Base { run() {} }', + }); + expect(target(only(graph.callSites, "b.run"))).toEqual([ + "ambiguous", + ["base.ts::Base.run", "fast.ts::Fast.run"], + ]); + expect(graph.edges.some((edge) => edge.type === "call")).toBe(false); + }); + + it("keeps a package class unresolved, naming it as the receiver's class", () => { + const site = only( + graphOf({ + "a.ts": + 'import type { Logger } from "pino";\nfunction main(log: Logger) { log.info(); }', + }).callSites, + "log.info" + ); + expect(site).toMatchObject({ + resolution: "unresolved", + via: { localName: "Logger", member: "info", instance: true }, + }); + }); + + it("keeps type-only imports off the module and its links", () => { + const graph = graphOf({ + "a.ts": 'import type { Svc } from "./svc";\nfunction main(s: Svc) {}', + "svc.ts": svcFile, + }); + // Module links (areas joined in the world) are read from these imports. + expect(graph.modules[0]?.imports).toEqual([]); + }); +}); diff --git a/packages/parser/src/resolve.ts b/packages/parser/src/resolve.ts index 6897aca..e27d174 100644 --- a/packages/parser/src/resolve.ts +++ b/packages/parser/src/resolve.ts @@ -13,24 +13,38 @@ import { isNewExpression, isOptionalCallExpression, isOptionalMemberExpression, - isParenthesizedExpression, isPrivateName, isSequenceExpression, isSuper, isTaggedTemplateExpression, isThisExpression, - isTSAsExpression, - isTSInstantiationExpression, - isTSNonNullExpression, - isTSSatisfiesExpression, - isTSTypeAssertion, isYieldExpression, } from "@babel/types"; import type { Node } from "@babel/types"; import type { CallKind, CallResolution, ImportVia } from "@repo/code-graph"; import { KNOWN_GLOBALS } from "./globals"; +import { unwrap } from "./scope"; import type { ClassTable, DiscoveredFunction, Scope } from "./scope"; +import { + fieldType, + instanceMember, + isImport, + isTypeImport, + overridesOf, + receiverType, + resolveClass, + shadowed, +} from "./scope-lookup"; +import type { Receiver } from "./scope-lookup"; + +/** + * A method called on a receiver typed as a top-level class of this module + * (`className`) or an imported one (`via.instance`, `className` null): a + * subclass in another file may override `member`, which only the linker + * sees. + */ +type Dispatch = { readonly className: string | null; readonly member: string }; type Resolved = { readonly calleeName: string; @@ -40,6 +54,7 @@ type Resolved = { readonly candidates?: readonly DiscoveredFunction[]; /** Set when the callee is an import binding; the call stays unresolved. */ readonly via?: ImportVia; + readonly dispatch?: Dispatch; }; const unresolved = (calleeName: string): Resolved => ({ @@ -100,17 +115,6 @@ const amongst = ( }; }; -/** The expression under TypeScript-only wrappers: `cb!`, `f as F`, `f`. */ -const unwrap = (node: Node): Node => - isTSNonNullExpression(node) || - isTSAsExpression(node) || - isTSSatisfiesExpression(node) || - isTSTypeAssertion(node) || - isTSInstantiationExpression(node) || - isParenthesizedExpression(node) - ? unwrap(node.expression) - : node; - /** Source-like text for a callee the analysis cannot follow. */ const calleeText = (raw: Node): string => { const node = unwrap(raw); @@ -178,61 +182,53 @@ const resolveIdentifier = (name: string, scope: Scope): Resolved => { return KNOWN_GLOBALS.has(name) ? external(name) : unresolved(name); }; -/** The class a name refers to here, or null if it is not one (or shadowed). */ -const resolveClass = (name: string, scope: Scope): ClassTable | null => { - for ( - let current: Scope | null = scope; - current !== null; - current = current.parent - ) { - const table = current.classes.get(name); - if (table !== undefined) { - return table; - } - if (current.locals.has(name) || current.bindings.has(name)) { - return null; - } - } - return null; -}; - -/** Whether the name refers to an import binding here (not shadowed). */ -const isImport = (name: string, scope: Scope): boolean => { - for ( - let current: Scope | null = scope; - current !== null; - current = current.parent - ) { - if (current.imports.has(name)) { - return true; - } - if ( - current.locals.has(name) || - current.classes.has(name) || - current.bindings.has(name) - ) { - return false; - } +/** + * `x.m()` on a receiver whose class is known by name. A class of this + * module resolves to its member (or an inherited one) and to every + * override in its subclasses here: one function resolves, several are + * ambiguous, none leaves the call unresolved rather than guessing by name. + * An imported class is left to the linker. Anything else (an interface, a + * type alias, a global like `Map`) is not a class this can follow: null. + * The annotation is taken as the contract, as an editor's go-to-definition + * does; an object of another class that merely fits it is not considered. + */ +const typedReceiver = ( + name: string, + property: string, + { type, scope }: Receiver, + classes: readonly ClassTable[] +): Resolved | null => { + const table = resolveClass(type.name, scope); + if (table !== null) { + const own = instanceMember(table, property); + const result = amongst(name, [ + ...(own === undefined ? [] : [own]), + ...(type.exact ? [] : overridesOf(table, property, classes)), + ]); + // Only a top-level class can be extended from another file. + return table.scope.parent === null + ? { + ...result, + ...(type.exact + ? {} + : { dispatch: { className: table.name, member: property } }), + } + : result; } - return false; -}; - -/** Any binding of the name between here and the module, inclusive. */ -const shadowed = (name: string, scope: Scope): boolean => { - for ( - let current: Scope | null = scope; - current !== null; - current = current.parent - ) { - if ( - current.locals.has(name) || - current.classes.has(name) || - current.bindings.has(name) - ) { - return true; - } + if (isImport(type.name, scope) || isTypeImport(type.name, scope)) { + return { + ...imported(name, { + localName: type.name, + member: property, + isNew: false, + instance: true, + }), + ...(type.exact + ? {} + : { dispatch: { className: null, member: property } }), + }; } - return false; + return null; }; /** The name a non-computed member access reaches for, `#x` for private. */ @@ -286,6 +282,22 @@ const otherCallee = (callee: Node): Resolved => { : unresolved(name); }; +/** The class of `this.field` (or `this.#field`) in an instance member. */ +const thisField = (object: Node, scope: Scope): Receiver | null => { + const own = scope.classTable; + if ( + own === null || + scope.isStatic || + !(isMemberExpression(object) || isOptionalMemberExpression(object)) || + object.computed || + !isThisExpression(unwrap(object.object)) + ) { + return null; + } + const field = propertyName(object.property); + return field === null ? null : fieldType(own, field); +}; + const resolveMember = ( callee: Node, scope: Scope, @@ -315,9 +327,13 @@ const resolveMember = ( return resolved(name, target); } // A static `this` is a class (or a subclass): instance members do not apply. - return scope.isStatic && own !== null - ? unresolved(name) - : byMemberName(name, property, own, classes); + if (scope.isStatic && own !== null) { + return unresolved(name); + } + const inherited = own === null ? undefined : instanceMember(own, property); + return inherited === undefined + ? byMemberName(name, property, own, classes) + : resolved(name, inherited); } if (isIdentifier(object)) { const table = resolveClass(object.name, scope); @@ -333,6 +349,14 @@ const resolveMember = ( }); } } + const receiver = isIdentifier(object) + ? receiverType(object.name, scope) + : thisField(object, scope); + const typed = + receiver === null ? null : typedReceiver(name, property, receiver, classes); + if (typed !== null) { + return typed; + } const root = rootIdentifier(object); return root !== null && KNOWN_GLOBALS.has(root) && !shadowed(root, scope) ? external(name) @@ -386,3 +410,4 @@ const resolveCallee = ( }; export { resolveCallee }; +export type { Dispatch }; diff --git a/packages/parser/src/scope-lookup.ts b/packages/parser/src/scope-lookup.ts new file mode 100644 index 0000000..4e3a7a4 --- /dev/null +++ b/packages/parser/src/scope-lookup.ts @@ -0,0 +1,188 @@ +// What a name means in a lexical scope, and what a class table holds along +// its `extends` chain: the lookups call resolution is built on. + +import type { + ClassTable, + DiscoveredFunction, + ReceiverType, + Scope, +} from "./scope"; + +/** Whether some binding of `name` in this one scope hides the outer ones. */ +const bindsHere = (current: Scope, name: string): boolean => + current.locals.has(name) || + current.classes.has(name) || + current.bindings.has(name); + +/** The class a name refers to here, or null if it is not one (or shadowed). */ +const resolveClass = (name: string, scope: Scope): ClassTable | null => { + for ( + let current: Scope | null = scope; + current !== null; + current = current.parent + ) { + const table = current.classes.get(name); + if (table !== undefined) { + return table; + } + if (current.locals.has(name) || current.bindings.has(name)) { + return null; + } + } + return null; +}; + +/** Whether the name refers to an import binding here (not shadowed). */ +const isImport = (name: string, scope: Scope): boolean => { + for ( + let current: Scope | null = scope; + current !== null; + current = current.parent + ) { + if (current.imports.has(name)) { + return true; + } + if (bindsHere(current, name)) { + return false; + } + } + return false; +}; + +/** Whether the name refers to a type-only import here (not shadowed). */ +const isTypeImport = (name: string, scope: Scope): boolean => { + for ( + let current: Scope | null = scope; + current !== null; + current = current.parent + ) { + if (current.typeImports.has(name)) { + return true; + } + if (bindsHere(current, name)) { + return false; + } + } + return false; +}; + +/** Any binding of the name between here and the module, inclusive. */ +const shadowed = (name: string, scope: Scope): boolean => { + for ( + let current: Scope | null = scope; + current !== null; + current = current.parent + ) { + if (bindsHere(current, name)) { + return true; + } + } + return false; +}; + +/** A receiver's class and the scope its name resolves from. */ +type Receiver = { readonly type: ReceiverType; readonly scope: Scope }; + +/** The class the binding `name` holds here, where its declaration says. */ +const receiverType = (name: string, scope: Scope): Receiver | null => { + for ( + let current: Scope | null = scope; + current !== null; + current = current.parent + ) { + if (bindsHere(current, name)) { + const type = current.types.get(name); + return type === undefined || type === null + ? null + : { type, scope: current }; + } + } + return null; +}; + +/** Deep enough for any real hierarchy; a longer chain is left alone. */ +const MAX_LINEAGE = 16; + +/** + * The class, then its superclasses nearest first, as far as each `extends` + * names a class of this module; a cycle or an imported superclass ends it. + */ +const lineage = (table: ClassTable): readonly ClassTable[] => { + const chain = [table]; + let current = table; + while (current.superName !== null && chain.length < MAX_LINEAGE) { + const parent = resolveClass(current.superName, current.scope); + if (parent === null || chain.includes(parent)) { + break; + } + chain.push(parent); + current = parent; + } + return chain; +}; + +/** An instance member of the class or, unless private, of a superclass. */ +const instanceMember = ( + table: ClassTable, + member: string +): DiscoveredFunction | undefined => { + const chain = member.startsWith("#") ? [table] : lineage(table); + for (const current of chain) { + const fn = current.instance.get(member); + if (fn !== undefined) { + return fn; + } + } + return undefined; +}; + +/** + * The members of the module's subclasses of `base` that override `member`, + * each of which an instance typed `base` may run instead. Private members + * are never overridden. + */ +const overridesOf = ( + base: ClassTable, + member: string, + classes: readonly ClassTable[] +): readonly DiscoveredFunction[] => + member.startsWith("#") + ? [] + : classes.flatMap((table) => { + const fn = table.instance.get(member); + return table !== base && + fn !== undefined && + lineage(table).includes(base) + ? [fn] + : []; + }); + +/** + * The class of `this.field`: the first class along the lineage that + * declares the field decides, even when it declares it without a type. + */ +const fieldType = (table: ClassTable, field: string): Receiver | null => { + const chain = field.startsWith("#") ? [table] : lineage(table); + for (const current of chain) { + if (current.fields.has(field)) { + const type = current.fields.get(field); + return type === undefined || type === null + ? null + : { type, scope: current.scope }; + } + } + return null; +}; + +export { + fieldType, + instanceMember, + isImport, + isTypeImport, + MAX_LINEAGE, + overridesOf, + receiverType, + resolveClass, + shadowed, +}; +export type { Receiver }; diff --git a/packages/parser/src/scope.ts b/packages/parser/src/scope.ts index e8c8eba..142121b 100644 --- a/packages/parser/src/scope.ts +++ b/packages/parser/src/scope.ts @@ -11,12 +11,25 @@ import { isFunctionDeclaration, isFunctionExpression, isIdentifier, + isNewExpression, isObjectMethod, isObjectPattern, isObjectProperty, + isParenthesizedExpression, isRestElement, isStringLiteral, + isTSAsExpression, + isTSInstantiationExpression, + isTSNonNullExpression, + isTSNullKeyword, isTSParameterProperty, + isTSParenthesizedType, + isTSSatisfiesExpression, + isTSTypeAnnotation, + isTSTypeAssertion, + isTSTypeReference, + isTSUndefinedKeyword, + isTSUnionType, } from "@babel/types"; import type { ArrowFunctionExpression, @@ -27,6 +40,7 @@ import type { FunctionExpression, Node, ObjectMethod, + TSType, } from "@babel/types"; import type { FunctionKind, @@ -40,11 +54,31 @@ import type { */ type DiscoveredFunction = Omit; +/** + * The class a binding holds, by the name its declaration gives it + * (`x: Svc`, `new Svc()`), resolved later from the scope that binds it. + * `exact` when it is known to hold that very class (`const x = new Svc()`, + * never written), so no subclass can stand in for it. + */ +type ReceiverType = { readonly name: string; readonly exact: boolean }; + +/** + * Binding names to their class; null where one folded scope binds the name + * more than once with different or missing types, so it never resolves. + */ +type ReceiverTypes = Map; + type ClassTable = { readonly name: string; readonly instance: Map; readonly static: Map; ctor: DiscoveredFunction | null; + /** Instance fields, typed or not: an untyped one still hides a base's. */ + readonly fields: ReceiverTypes; + /** The `extends` identifier, resolved from `scope`. */ + readonly superName: string | null; + /** The scope the class is declared in. */ + readonly scope: Scope; }; /** One lexical scope: a function body, an anonymous function, or the module. */ @@ -68,6 +102,10 @@ type Scope = { readonly params: Set; /** Module scope only: the bindings that are imports, by local name. */ readonly imports: Map; + /** Module scope only: type-only imports (`import type { Svc }`). */ + readonly typeImports: Map; + /** The classes of this scope's bindings, where their declarations say. */ + readonly types: ReceiverTypes; readonly parent: Scope | null; }; @@ -96,6 +134,8 @@ const newScope = ( bindings: new Set(), params: new Set(), imports: new Map(), + typeImports: new Map(), + types: new Map(), parent, }); @@ -123,6 +163,102 @@ const bindingNames = (pattern: Node, into: Set): void => { } }; +/** The expression under TypeScript-only wrappers: `cb!`, `f as F`, `f`. */ +const unwrap = (node: Node): Node => + isTSNonNullExpression(node) || + isTSAsExpression(node) || + isTSSatisfiesExpression(node) || + isTSTypeAssertion(node) || + isTSInstantiationExpression(node) || + isParenthesizedExpression(node) + ? unwrap(node.expression) + : node; + +/** `Svc` from `Svc`, `Svc` or `Svc | null | undefined`; else null. */ +const referenceName = (type: TSType): string | null => { + if (isTSParenthesizedType(type)) { + return referenceName(type.typeAnnotation); + } + if (isTSTypeReference(type)) { + return isIdentifier(type.typeName) ? type.typeName.name : null; + } + if (isTSUnionType(type)) { + const [only, ...rest] = type.types.filter( + (member) => !isTSNullKeyword(member) && !isTSUndefinedKeyword(member) + ); + return only === undefined || rest.length > 0 ? null : referenceName(only); + } + return null; +}; + +/** The class name a `: Svc` annotation gives, if it gives one. */ +const typeNameOf = (annotation: Node | null | undefined): string | null => + isTSTypeAnnotation(annotation) + ? referenceName(annotation.typeAnnotation) + : null; + +/** `Svc` from `new Svc(…)` (through `as`, `!` and parentheses). */ +const newClassNameOf = (init: Node | null | undefined): string | null => { + if (init === null || init === undefined) { + return null; + } + const node = unwrap(init); + return isNewExpression(node) && isIdentifier(node.callee) + ? node.callee.name + : null; +}; + +/** + * A binding's class: its annotation, a contract TypeScript checks, else the + * `new` it starts with, unless the module writes the name anywhere (so it + * may come to hold anything). `exact` only for a `new` nothing replaces: + * never written and not `replaceable` (a parameter's caller, a field's + * owner may put another instance there). + */ +const receiverOf = ( + annotation: Node | null | undefined, + init: Node | null | undefined, + written: boolean, + replaceable: boolean +): ReceiverType | null => { + const annotated = typeNameOf(annotation); + const created = newClassNameOf(init); + const exact = created !== null && !written && !replaceable; + if (annotated !== null) { + return { name: annotated, exact: exact && created === annotated }; + } + return created === null || written ? null : { name: created, exact }; +}; + +/** + * Records the class of a binding declared in `types`' scope. A name bound + * again in the same (folded) scope keeps a class only while every + * declaration agrees on it. + */ +const declareType = ( + types: ReceiverTypes, + bound: boolean, + name: string, + type: ReceiverType | null +): void => { + if (!bound) { + if (type !== null) { + types.set(name, type); + } + return; + } + const before = types.get(name); + types.set( + name, + before !== undefined && + before !== null && + type !== null && + before.name === type.name + ? { name: type.name, exact: before.exact && type.exact } + : null + ); +}; + const isFunctionLike = (node: Node): node is FunctionLike => isFunctionDeclaration(node) || isFunctionExpression(node) || @@ -144,7 +280,8 @@ const functionScope = ( node: FunctionLike, fn: DiscoveredFunction | null, table: ClassTable | null, - isStatic: boolean + isStatic: boolean, + written: ReadonlySet ): Scope => { const arrow = isArrowFunctionExpression(node); const scope = newScope( @@ -154,10 +291,19 @@ const functionScope = ( table === null ? arrow && parent.isStatic : isStatic ); for (const param of node.params) { - bindingNames( - isTSParameterProperty(param) ? param.parameter : param, - scope.params - ); + const binding = isTSParameterProperty(param) ? param.parameter : param; + bindingNames(binding, scope.params); + const [id, init] = isAssignmentPattern(binding) + ? [binding.left, binding.right] + : [binding, null]; + if (isIdentifier(id)) { + declareType( + scope.types, + false, + id.name, + receiverOf(id.typeAnnotation, init, written.has(id.name), true) + ); + } } for (const name of scope.params) { scope.bindings.add(name); @@ -238,11 +384,20 @@ const fieldFunction = ( export { bindingNames, + declareType, fieldFunction, functionScope, isFunctionLike, memberName, methodKind, newScope, + receiverOf, + unwrap, +}; +export type { + ClassTable, + DiscoveredFunction, + FunctionLike, + ReceiverType, + Scope, }; -export type { ClassTable, DiscoveredFunction, FunctionLike, Scope }; diff --git a/packages/parser/src/top-level.ts b/packages/parser/src/top-level.ts index d00d8ed..30ff69d 100644 --- a/packages/parser/src/top-level.ts +++ b/packages/parser/src/top-level.ts @@ -15,13 +15,18 @@ import type { Node, Program } from "@babel/types"; import type { DiscoveredFunction } from "./scope"; -type Range = { readonly start: number; readonly end: number }; +/** The offsets a class's members lie in, and the identifier it extends. */ +type ClassRange = { + readonly start: number; + readonly end: number; + readonly superName: string | null; +}; type TopLevel = { /** Plain functions by binding name: declarations, `const f = () => …`, an anonymous default. */ readonly functions: ReadonlyMap; - /** Class declarations by name, as the offsets their members lie in. */ - readonly classes: ReadonlyMap; + /** Class declarations by name. */ + readonly classes: ReadonlyMap; }; const topLevelOf = ( @@ -29,7 +34,7 @@ const topLevelOf = ( byNode: ReadonlyMap ): TopLevel => { const functions = new Map(); - const classes = new Map(); + const classes = new Map(); const add = (name: string, node: Node | null | undefined) => { const fn = node === null || node === undefined ? undefined : byNode.get(node); @@ -50,6 +55,9 @@ const topLevelOf = ( classes.set(name, { start: declaration.start ?? 0, end: declaration.end ?? 0, + superName: isIdentifier(declaration.superClass) + ? declaration.superClass.name + : null, }); } } else if (isVariableDeclaration(declaration)) { diff --git a/packages/parser/src/writes.ts b/packages/parser/src/writes.ts new file mode 100644 index 0000000..27011b2 --- /dev/null +++ b/packages/parser/src/writes.ts @@ -0,0 +1,95 @@ +// What a module writes after declaring it: a binding whose class is only +// inferred from the `new` it starts with (`let svc = new Svc()`) may come to +// hold anything once it is assigned, so the discovery pass drops that class. +// By name, module-wide, which is coarse but never misses a write. + +import { + isArrayPattern, + isAssignmentExpression, + isAssignmentPattern, + isForInStatement, + isForOfStatement, + isIdentifier, + isMemberExpression, + isObjectPattern, + isObjectProperty, + isOptionalMemberExpression, + isPrivateName, + isRestElement, + isUpdateExpression, + isVariableDeclaration, +} from "@babel/types"; +import type { Node } from "@babel/types"; + +import { unwrap } from "./scope"; +import { childrenOf } from "./walk"; + +type Writes = { + /** Identifiers assigned, updated or used as a `for…in`/`for…of` target. */ + readonly names: ReadonlySet; + /** Properties written through any object (`a.svc = …`), `#x` for private. */ + readonly members: ReadonlySet; +}; + +const addTargets = ( + raw: Node, + names: Set, + members: Set +): void => { + const target = unwrap(raw); + if (isIdentifier(target)) { + names.add(target.name); + } else if ( + (isMemberExpression(target) || isOptionalMemberExpression(target)) && + !target.computed + ) { + const { property } = target; + if (isIdentifier(property)) { + members.add(property.name); + } else if (isPrivateName(property)) { + members.add(`#${property.id.name}`); + } + } else if (isObjectPattern(target)) { + for (const property of target.properties) { + addTargets( + isObjectProperty(property) ? property.value : property, + names, + members + ); + } + } else if (isArrayPattern(target)) { + for (const element of target.elements) { + if (element !== null) { + addTargets(element, names, members); + } + } + } else if (isRestElement(target)) { + addTargets(target.argument, names, members); + } else if (isAssignmentPattern(target)) { + addTargets(target.left, names, members); + } +}; + +/** Every write in the program, walked without recursion. */ +const writesOf = (program: Node): Writes => { + const names = new Set(); + const members = new Set(); + const stack: Node[] = [program]; + for (let node = stack.pop(); node !== undefined; node = stack.pop()) { + if (isAssignmentExpression(node)) { + addTargets(node.left, names, members); + } else if (isUpdateExpression(node)) { + addTargets(node.argument, names, members); + } else if ( + (isForInStatement(node) || isForOfStatement(node)) && + !isVariableDeclaration(node.left) + ) { + addTargets(node.left, names, members); + } + stack.push(...childrenOf(node)); + } + return { names, members }; +}; + +export { writesOf }; +export type { Writes }; From 27f0207e8cafbeb47422e93c949bc788ff92ac54 Mon Sep 17 00:00:00 2001 From: Juha Kangas <42040080+valuecodes@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:27:38 +0300 Subject: [PATCH 2/3] fix: address review findings Co-Authored-By: Claude Opus 5.5 --- packages/parser/src/link-overrides.ts | 97 ++++++++++++++++++++------- packages/parser/src/link.ts | 24 +++---- packages/parser/src/receiver-types.ts | 5 +- packages/parser/src/receivers.test.ts | 58 ++++++++++++++++ packages/parser/src/resolve.ts | 27 +++++--- packages/parser/src/writes.ts | 72 ++++++++++++-------- 6 files changed, 201 insertions(+), 82 deletions(-) diff --git a/packages/parser/src/link-overrides.ts b/packages/parser/src/link-overrides.ts index 9c6145e..c03739c 100644 --- a/packages/parser/src/link-overrides.ts +++ b/packages/parser/src/link-overrides.ts @@ -1,7 +1,8 @@ -// Overrides across files: a call on a receiver typed as a class may run any -// override of its member in a subclass, which the per-file pass sees only -// within the module. The linker indexes every top-level class's subclasses -// over the whole graph and widens such calls by what they find. +// Class hierarchies across files: a call on a receiver typed as a class may +// run a member inherited from an imported superclass, or any override of it +// in a subclass, both of which the per-file pass sees only within the +// module. The linker follows `extends` over the whole graph and indexes +// every top-level class's subclasses. import type { CallSite, ModuleNode } from "@repo/code-graph"; @@ -14,6 +15,7 @@ import { } from "./link-exports"; import type { Binding, Linker } from "./link-exports"; import type { Dispatch } from "./resolve"; +import type { DiscoveredFunction } from "./scope"; import { MAX_LINEAGE } from "./scope-lookup"; /** A top-level class of a module. */ @@ -56,6 +58,38 @@ const parentOf = ( : classOf(linker, listOf(importBinding(linker, record, new Set()))); }; +/** + * An instance member of a top-level class or, unless private, of its + * superclasses across the graph, nearest first. + */ +const inheritedMember = ( + linker: Linker, + key: ClassKey, + member: string +): DiscoveredFunction | undefined => { + const seen = new Set(); + for ( + let current: ClassKey | null = key; + current !== null && seen.size < MAX_LINEAGE; + current = member.startsWith("#") ? null : parentOf(linker, current) + ) { + if (seen.has(classKey(current))) { + return undefined; + } + seen.add(classKey(current)); + const fn = memberOf( + linker, + current.moduleId, + current.name, + isInstanceMember(member) + ); + if (fn !== undefined) { + return fn; + } + } + return undefined; +}; + type SubclassIndex = ReadonlyMap; /** @@ -86,10 +120,12 @@ const subclassIndex = (linker: Linker): SubclassIndex => { }; /** - * Widens a call on a typed receiver by every override of its member in a - * subclass of the receiver's class anywhere in the graph: a subclass - * instance may stand where the class is named. One function stays - * resolved, more make the call ambiguous. + * Completes a call on a typed receiver across files: a member the class + * inherits from an imported superclass, which the per-file pass cannot + * see, then, unless the receiver is exact, every override of the member in + * a subclass anywhere in the graph (a subclass instance may stand where + * the class is named). One function stays resolved, more make the call + * ambiguous. */ const withOverrides = ( linker: Linker, @@ -98,9 +134,6 @@ const withOverrides = ( site: CallSite, dispatch: Dispatch ): CallSite => { - if (dispatch.member.startsWith("#")) { - return site; - } let base: ClassKey | null = null; if (dispatch.className !== null) { base = { moduleId: module.id, name: dispatch.className }; @@ -110,24 +143,36 @@ const withOverrides = ( if (base === null) { return site; } - const overrides = (subclasses.get(classKey(base)) ?? []).flatMap((sub) => { - const fn = memberOf( - linker, - sub.moduleId, - sub.name, - isInstanceMember(dispatch.member) - ); - return fn === undefined ? [] : [fn.id]; - }); - let current: readonly string[] = []; + const { member } = dispatch; + const overrides = + dispatch.exact || member.startsWith("#") + ? [] + : (subclasses.get(classKey(base)) ?? []).flatMap((sub) => { + const fn = memberOf( + linker, + sub.moduleId, + sub.name, + isInstanceMember(member) + ); + return fn === undefined ? [] : [fn.id]; + }); + let before: readonly string[] = []; if (site.resolution === "resolved" && site.calleeId !== null) { - current = [site.calleeId]; + before = [site.calleeId]; } else if (site.resolution === "ambiguous") { - current = site.candidateIds ?? []; + before = site.candidateIds ?? []; } - const ids = [...new Set([...current, ...overrides])]; + // The class's own member if it has one, which `before` already holds. + const inherited = inheritedMember(linker, base, member); + const ids = [ + ...new Set([ + ...before, + ...(inherited === undefined ? [] : [inherited.id]), + ...overrides, + ]), + ]; const [only, ...rest] = ids; - if (ids.length === current.length || only === undefined) { + if (ids.length === before.length || only === undefined) { return site; } return rest.length === 0 @@ -135,4 +180,4 @@ const withOverrides = ( : { ...site, resolution: "ambiguous", calleeId: null, candidateIds: ids }; }; -export { subclassIndex, withOverrides }; +export { inheritedMember, subclassIndex, withOverrides }; diff --git a/packages/parser/src/link.ts b/packages/parser/src/link.ts index 1c282a6..45745a7 100644 --- a/packages/parser/src/link.ts +++ b/packages/parser/src/link.ts @@ -20,15 +20,13 @@ import type { } from "@repo/code-graph"; import { createResolver } from "@repo/code-graph/module-resolver"; -import { - exportOf, - isInstanceMember, - listOf, - memberOf, - viaBindings, -} from "./link-exports"; +import { exportOf, listOf, memberOf, viaBindings } from "./link-exports"; import type { Binding, Linker } from "./link-exports"; -import { subclassIndex, withOverrides } from "./link-overrides"; +import { + inheritedMember, + subclassIndex, + withOverrides, +} from "./link-overrides"; import type { Dispatch } from "./resolve"; import type { DiscoveredFunction } from "./scope"; import type { TopLevel } from "./top-level"; @@ -74,7 +72,7 @@ const calleesOf = ( * The function a binding calls: a top-level plain function, `new` on a * top-level class (its constructor) or on a plain function, or with a * member a static method of a top-level class, or with `instance` an - * instance member of one. Null for anything else, a namespace called + * instance member of one or of its superclasses. Null for anything else, a namespace called * directly included. */ const functionOf = ( @@ -95,11 +93,9 @@ const functionOf = ( return null; } return ( - ofClass( - instance - ? isInstanceMember(member) - : (fn) => fn.isStatic && fn.name === member - ) ?? null + (instance + ? inheritedMember(linker, { moduleId: binding.moduleId, name }, member) + : ofClass((fn) => fn.isStatic && fn.name === member)) ?? null ); } if (!isNew) { diff --git a/packages/parser/src/receiver-types.ts b/packages/parser/src/receiver-types.ts index ac9f64c..7ba7915 100644 --- a/packages/parser/src/receiver-types.ts +++ b/packages/parser/src/receiver-types.ts @@ -14,6 +14,7 @@ import type { ClassBody, Node } from "@babel/types"; import { bindingNames, declareType, receiverOf } from "./scope"; import type { ClassTable, ReceiverType, Scope } from "./scope"; +import { writesMember } from "./writes"; import type { Writes } from "./writes"; /** @@ -88,7 +89,7 @@ const recordFields = ( receiverOf( member.typeAnnotation, member.value, - writes.members.has(fieldName), + writesMember(writes, fieldName), true ) ); @@ -110,7 +111,7 @@ const recordFields = ( recordField( table, id.name, - receiverOf(id.typeAnnotation, init, writes.members.has(id.name), true) + receiverOf(id.typeAnnotation, init, writesMember(writes, id.name), true) ); } } diff --git a/packages/parser/src/receivers.test.ts b/packages/parser/src/receivers.test.ts index 4a273ef..ce97438 100644 --- a/packages/parser/src/receivers.test.ts +++ b/packages/parser/src/receivers.test.ts @@ -175,6 +175,23 @@ describe("typed receivers in one module", () => { expect(call(source, "this.s.run")).toEqual(["unresolved", null]); }); + it("drops a field's inferred class on a computed write", () => { + const fieldCall = (write: string) => + call( + [ + svc, + `class App { s = new Svc(); go() { this.s.run(); } swap() { ${write}; } }`, + ].join("\n"), + "this.s.run" + ); + expect(fieldCall("this.t = null")).toEqual(["resolved", "t.ts::Svc.run"]); + expect(fieldCall('this["s"] = null!')).toEqual(["unresolved", null]); + expect(fieldCall("const k = 's'; this[k] = null!")).toEqual([ + "unresolved", + null, + ]); + }); + it("lets inner and conflicting bindings hide a type", () => { const source = [ svc, @@ -312,6 +329,47 @@ describe("typed receivers across files", () => { expect(graph.edges.some((edge) => edge.type === "call")).toBe(false); }); + it("follows members inherited from a superclass in another file", () => { + const files = { + "base.ts": "export class Base { run() {} }", + "child.ts": + 'import { Base } from "./base";\nexport class Child extends Base { own() {} }', + }; + expect( + linked( + { + ...files, + "a.ts": + 'import type { Child } from "./child";\nfunction main(c: Child) { c.run(); }', + }, + "c.run" + ) + ).toEqual(["resolved", "base.ts::Base.run"]); + // A local class extending an imported one, typed or created. + const local = [ + 'import { Base } from "./base";', + "class Local extends Base {}", + "function typed(l: Local) { l.run(); }", + "function made() { const m = new Local(); m.run(); }", + ].join("\n"); + const graph = graphOf({ ...files, "a.ts": local }); + expect(target(only(graph.callSites, "l.run"))).toEqual([ + "resolved", + "base.ts::Base.run", + ]); + expect(target(only(graph.callSites, "m.run"))).toEqual([ + "resolved", + "base.ts::Base.run", + ]); + expect(graph.edges).toContainEqual( + expect.objectContaining({ + type: "call", + source: "a.ts::made", + target: "base.ts::Base.run", + }) + ); + }); + it("keeps a package class unresolved, naming it as the receiver's class", () => { const site = only( graphOf({ diff --git a/packages/parser/src/resolve.ts b/packages/parser/src/resolve.ts index e27d174..4d127dc 100644 --- a/packages/parser/src/resolve.ts +++ b/packages/parser/src/resolve.ts @@ -40,11 +40,16 @@ import type { Receiver } from "./scope-lookup"; /** * A method called on a receiver typed as a top-level class of this module - * (`className`) or an imported one (`via.instance`, `className` null): a - * subclass in another file may override `member`, which only the linker - * sees. + * (`className`) or an imported one (`via.instance`, `className` null). The + * class may inherit `member` from a superclass in another file and, unless + * the receiver is `exact`, a subclass there may override it: only the + * linker sees either. */ -type Dispatch = { readonly className: string | null; readonly member: string }; +type Dispatch = { + readonly className: string | null; + readonly member: string; + readonly exact: boolean; +}; type Resolved = { readonly calleeName: string; @@ -205,13 +210,15 @@ const typedReceiver = ( ...(own === undefined ? [] : [own]), ...(type.exact ? [] : overridesOf(table, property, classes)), ]); - // Only a top-level class can be extended from another file. + // Only a top-level class takes part in hierarchies across files. return table.scope.parent === null ? { ...result, - ...(type.exact - ? {} - : { dispatch: { className: table.name, member: property } }), + dispatch: { + className: table.name, + member: property, + exact: type.exact, + }, } : result; } @@ -223,9 +230,7 @@ const typedReceiver = ( isNew: false, instance: true, }), - ...(type.exact - ? {} - : { dispatch: { className: null, member: property } }), + dispatch: { className: null, member: property, exact: type.exact }, }; } return null; diff --git a/packages/parser/src/writes.ts b/packages/parser/src/writes.ts index 27011b2..d25d43b 100644 --- a/packages/parser/src/writes.ts +++ b/packages/parser/src/writes.ts @@ -16,6 +16,7 @@ import { isOptionalMemberExpression, isPrivateName, isRestElement, + isStringLiteral, isUpdateExpression, isVariableDeclaration, } from "@babel/types"; @@ -27,69 +28,82 @@ import { childrenOf } from "./walk"; type Writes = { /** Identifiers assigned, updated or used as a `for…in`/`for…of` target. */ readonly names: ReadonlySet; - /** Properties written through any object (`a.svc = …`), `#x` for private. */ + /** + * Properties written through any object (`a.svc = …`, `a["svc"] = …`), + * `#x` for private. + */ readonly members: ReadonlySet; + /** A write through a computed key (`a[key] = …`) may reach any property. */ + readonly anyMember: boolean; }; -const addTargets = ( - raw: Node, - names: Set, - members: Set -): void => { +type Collector = { + readonly names: Set; + readonly members: Set; + anyMember: boolean; +}; + +const addTargets = (raw: Node, out: Collector): void => { const target = unwrap(raw); if (isIdentifier(target)) { - names.add(target.name); - } else if ( - (isMemberExpression(target) || isOptionalMemberExpression(target)) && - !target.computed - ) { + out.names.add(target.name); + } else if (isMemberExpression(target) || isOptionalMemberExpression(target)) { const { property } = target; - if (isIdentifier(property)) { - members.add(property.name); + if (target.computed) { + if (isStringLiteral(property)) { + out.members.add(property.value); + } else { + out.anyMember = true; + } + } else if (isIdentifier(property)) { + out.members.add(property.name); } else if (isPrivateName(property)) { - members.add(`#${property.id.name}`); + out.members.add(`#${property.id.name}`); } } else if (isObjectPattern(target)) { for (const property of target.properties) { - addTargets( - isObjectProperty(property) ? property.value : property, - names, - members - ); + addTargets(isObjectProperty(property) ? property.value : property, out); } } else if (isArrayPattern(target)) { for (const element of target.elements) { if (element !== null) { - addTargets(element, names, members); + addTargets(element, out); } } } else if (isRestElement(target)) { - addTargets(target.argument, names, members); + addTargets(target.argument, out); } else if (isAssignmentPattern(target)) { - addTargets(target.left, names, members); + addTargets(target.left, out); } }; /** Every write in the program, walked without recursion. */ const writesOf = (program: Node): Writes => { - const names = new Set(); - const members = new Set(); + const out: Collector = { + names: new Set(), + members: new Set(), + anyMember: false, + }; const stack: Node[] = [program]; for (let node = stack.pop(); node !== undefined; node = stack.pop()) { if (isAssignmentExpression(node)) { - addTargets(node.left, names, members); + addTargets(node.left, out); } else if (isUpdateExpression(node)) { - addTargets(node.argument, names, members); + addTargets(node.argument, out); } else if ( (isForInStatement(node) || isForOfStatement(node)) && !isVariableDeclaration(node.left) ) { - addTargets(node.left, names, members); + addTargets(node.left, out); } stack.push(...childrenOf(node)); } - return { names, members }; + return out; }; -export { writesOf }; +/** Whether the module may write property `name` of some object. */ +const writesMember = (writes: Writes, name: string): boolean => + writes.anyMember || writes.members.has(name); + +export { writesMember, writesOf }; export type { Writes }; From 3945c6ed745b1fe0795a2c9d7c8df910dd41491c Mon Sep 17 00:00:00 2001 From: Juha Kangas <42040080+valuecodes@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:53:24 +0300 Subject: [PATCH 3/3] fix: address PR review feedback Co-Authored-By: Claude Opus 5.5 --- packages/parser/src/calls.ts | 7 +-- packages/parser/src/functions.ts | 5 +- packages/parser/src/receivers.test.ts | 21 +++++++ packages/parser/src/resolve.ts | 91 +++++++++++++++++---------- 4 files changed, 85 insertions(+), 39 deletions(-) diff --git a/packages/parser/src/calls.ts b/packages/parser/src/calls.ts index 2c6441b..caa8f0c 100644 --- a/packages/parser/src/calls.ts +++ b/packages/parser/src/calls.ts @@ -69,12 +69,7 @@ const visitCalls = ( const call = callOf(node); if (call !== null) { const { calleeName, target, resolution, candidates, via, dispatch } = - resolveCallee( - call.kind, - call.callee, - scope, - collector.discovered.classes - ); + resolveCallee(call.kind, call.callee, scope, collector.discovered); const span = spanOf(node); const site: CallSite = { id: callSiteId(callerId, span.start), diff --git a/packages/parser/src/functions.ts b/packages/parser/src/functions.ts index 7852a9a..cfec314 100644 --- a/packages/parser/src/functions.ts +++ b/packages/parser/src/functions.ts @@ -63,6 +63,8 @@ type Discovered = { readonly classes: ClassTable[]; /** The same classes as graph nodes. */ readonly classNodes: ClassNode[]; + /** What the module writes to (see writes.ts). */ + readonly writes: Writes; }; type Context = { @@ -386,6 +388,7 @@ const discover = ( moduleScope, classes: [], classNodes: [], + writes: writesOf(program), }; const context: Context = { moduleId, @@ -393,7 +396,7 @@ const discover = ( nextClassName: uniqueNames(), enclosed: false, exportedNames, - writes: writesOf(program), + writes: out.writes, out, }; for (const statement of program.body) { diff --git a/packages/parser/src/receivers.test.ts b/packages/parser/src/receivers.test.ts index ce97438..db79d76 100644 --- a/packages/parser/src/receivers.test.ts +++ b/packages/parser/src/receivers.test.ts @@ -106,6 +106,27 @@ describe("typed receivers in one module", () => { ]); }); + it("keeps an inherited this call ambiguous when a subclass overrides it", () => { + const source = [ + "class Base { run() {} }", + "class Child extends Base { go() { this.run(); } }", + "class GrandChild extends Child { run() {} }", + ].join("\n"); + expect(call(source, "this.run")).toEqual([ + "ambiguous", + ["t.ts::Base.run", "t.ts::GrandChild.run"], + ]); + }); + + it("leaves a method the module writes over to the untyped rules", () => { + const source = [ + svc, + "function main(s: Svc) { s.run = () => {}; s.run(); s.stop(); }", + ].join("\n"); + expect(call(source, "s.run")).toEqual(["unresolved", null]); + expect(call(source, "s.stop")).toEqual(["resolved", "t.ts::Svc.stop"]); + }); + it("keeps an exact instance from its subclasses' overrides", () => { const source = [ "class Base { run() {} }", diff --git a/packages/parser/src/resolve.ts b/packages/parser/src/resolve.ts index 4d127dc..57e24cf 100644 --- a/packages/parser/src/resolve.ts +++ b/packages/parser/src/resolve.ts @@ -37,6 +37,7 @@ import { shadowed, } from "./scope-lookup"; import type { Receiver } from "./scope-lookup"; +import type { Writes } from "./writes"; /** * A method called on a receiver typed as a top-level class of this module @@ -187,40 +188,61 @@ const resolveIdentifier = (name: string, scope: Scope): Resolved => { return KNOWN_GLOBALS.has(name) ? external(name) : unresolved(name); }; +/** What the call pass knows of the whole module. */ +type ModuleFacts = { + /** Every class of the module, in source order, whatever its scope. */ + readonly classes: readonly ClassTable[]; + readonly writes: Writes; +}; + /** - * `x.m()` on a receiver whose class is known by name. A class of this - * module resolves to its member (or an inherited one) and to every - * override in its subclasses here: one function resolves, several are - * ambiguous, none leaves the call unresolved rather than guessing by name. - * An imported class is left to the linker. Anything else (an interface, a - * type alias, a global like `Map`) is not a class this can follow: null. - * The annotation is taken as the contract, as an editor's go-to-definition - * does; an object of another class that merely fits it is not considered. + * `m()` on an instance of a class of this module: its member (or an + * inherited one) and, unless the instance is `exact`, every override in its + * subclasses here. One function resolves, several are ambiguous, none + * leaves the call unresolved rather than guessing by name. + */ +const classReceiver = ( + name: string, + property: string, + table: ClassTable, + exact: boolean, + classes: readonly ClassTable[] +): Resolved => { + const own = instanceMember(table, property); + const result = amongst(name, [ + ...(own === undefined ? [] : [own]), + ...(exact ? [] : overridesOf(table, property, classes)), + ]); + // Only a top-level class takes part in hierarchies across files. + return table.scope.parent === null + ? { + ...result, + dispatch: { className: table.name, member: property, exact }, + } + : result; +}; + +/** + * `x.m()` on a receiver whose class is known by name: a class of this + * module (`classReceiver`), or an imported one, left to the linker. + * Anything else (an interface, a type alias, a global like `Map`) is not a + * class this can follow: null. So is a method the module writes over + * (`x.m = other`), which the class no longer decides. The annotation is + * taken as the contract, as an editor's go-to-definition does; an object + * of another class that merely fits it is not considered. */ const typedReceiver = ( name: string, property: string, { type, scope }: Receiver, - classes: readonly ClassTable[] + { classes, writes }: ModuleFacts ): Resolved | null => { + if (writes.members.has(property)) { + return null; + } const table = resolveClass(type.name, scope); if (table !== null) { - const own = instanceMember(table, property); - const result = amongst(name, [ - ...(own === undefined ? [] : [own]), - ...(type.exact ? [] : overridesOf(table, property, classes)), - ]); - // Only a top-level class takes part in hierarchies across files. - return table.scope.parent === null - ? { - ...result, - dispatch: { - className: table.name, - member: property, - exact: type.exact, - }, - } - : result; + return classReceiver(name, property, table, type.exact, classes); } if (isImport(type.name, scope) || isTypeImport(type.name, scope)) { return { @@ -306,8 +328,9 @@ const thisField = (object: Node, scope: Scope): Receiver | null => { const resolveMember = ( callee: Node, scope: Scope, - classes: readonly ClassTable[] + facts: ModuleFacts ): Resolved => { + const { classes } = facts; if (!(isMemberExpression(callee) || isOptionalMemberExpression(callee))) { return otherCallee(callee); } @@ -335,10 +358,14 @@ const resolveMember = ( if (scope.isStatic && own !== null) { return unresolved(name); } - const inherited = own === null ? undefined : instanceMember(own, property); - return inherited === undefined + // Inherited: `this` may be any subclass, which may override it again. + const inherited = + own === null || facts.writes.members.has(property) + ? null + : classReceiver(name, property, own, false, classes); + return inherited === null || inherited.resolution === "unresolved" ? byMemberName(name, property, own, classes) - : resolved(name, inherited); + : inherited; } if (isIdentifier(object)) { const table = resolveClass(object.name, scope); @@ -358,7 +385,7 @@ const resolveMember = ( ? receiverType(object.name, scope) : thisField(object, scope); const typed = - receiver === null ? null : typedReceiver(name, property, receiver, classes); + receiver === null ? null : typedReceiver(name, property, receiver, facts); if (typed !== null) { return typed; } @@ -402,7 +429,7 @@ const resolveCallee = ( kind: CallKind, raw: Node, scope: Scope, - classes: readonly ClassTable[] + facts: ModuleFacts ): Resolved => { const callee = unwrap(raw); if (kind === "new") { @@ -411,7 +438,7 @@ const resolveCallee = ( if (isIdentifier(callee)) { return resolveIdentifier(callee.name, scope); } - return resolveMember(callee, scope, classes); + return resolveMember(callee, scope, facts); }; export { resolveCallee };