From 013b91c1be37ca6a6ee2c19bb89be56dd0faa37a Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Fri, 23 Oct 2026 03:16:07 +0000 Subject: [PATCH] fix(js,ts): a named class expression is a class with its own members (#2825) A named class expression, like register(class Widget extends Base {}) or module.exports = class Router {}, was not indexed as a class, as issue #2499 found. Its methods became free functions, its fields and supertypes were dropped, and calls in it had no class to start from, so its this-calls, super calls and own static calls went unresolved or to a by-name guess, and calls elsewhere guessed into its methods. Both engines now index a named class expression as a class, with its methods, properties, extends and implements. An anonymous one is still passed over. Its head (class Widget extends Base) is its signature, which marks it as an expression, and it is never exported by itself. JavaScript binds the name only inside the expression, and the resolver now scopes it the same way. A reference written outside every class expression resolves in a view of the graph that hides class expressions and their members, so new Widget() elsewhere links nothing, and a same-named class, function or binding keeps its links. A name only class expressions carry is unknown there. A reference inside one sees the whole graph, unless it names another class expression. A class a CommonJS module exports, module.exports = class Router {} or exports.Store = class Store {}, is the module's value, which require hands to every importer, so it is not scoped: it reads as a declaration does, and a require of it reaches its static methods and its instances' methods. The reader of CommonJS named exports takes exports.X = class Y {} too. Super calls in a class expression now resolve through the super-call matcher. Validation: the new test fails 11 of 13 on main d5fc1fbb and passes 13 of 13 on the fix, and a mutation of each of 7 parts (the view, the inside check, the name rule, the expression marker, the class node, the CommonJS export rule, the named-export reader) fails a test. A/B against main d5fc1fbb on 22 repos (20 TS/JS, flask and gin): 193 classes are added in 10 repos with their members, and 801 edge sites change in 7 repos, each bucketed and sampled by hand. 563 are jest's calls to its global test(), 0.4 guesses that move from one wrong namesake to another: the old one, a class expression's test() in an e2e fixture, is now a method. Of the rest, 85 are new extends, implements and type references of class expressions; 74 calls, news and references move to the member they are written in; 65 calls and news gain or change a target, inside class expressions (this-calls, own static calls) and through require (static calls, typed instance calls, new); and 14 guesses are removed, among them 3 super calls whose parent the index can't name. 12 repos' dumps, the controls among them, are byte-identical. Kernel parity holds on 22 repos. Three sync scenarios match a fresh index. Index time, median of 5 interleaved runs: lit +0.8%, webpack +2.7%, jest +0.6%; insomnia +12.8%, though it indexes byte-identically, holds no class expression, and its resolver time is the same on both arms, so that gap is other jobs' load. Fixes #2499 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/js-class-expressions.test.ts | 287 ++++++++++++++++++++++++ codegraph-kernel/src/tsjs/extractors.rs | 16 +- codegraph-kernel/src/tsjs/mod.rs | 15 +- src/extraction/languages/javascript.ts | 3 +- src/extraction/languages/typescript.ts | 5 +- src/extraction/tree-sitter.ts | 30 ++- src/resolution/alias-binding.ts | 7 +- src/resolution/index.ts | 187 +++++++++++++-- src/resolution/js-class-expressions.ts | 66 ++++++ 10 files changed, 592 insertions(+), 25 deletions(-) create mode 100644 __tests__/js-class-expressions.test.ts create mode 100644 src/resolution/js-class-expressions.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index dd8a4d1d..204eb734 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -204,6 +204,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In Java, Kotlin, C# and Scala, each overload of an interface or base-class method now links only to the overrides with the same parameters. Before, every overload linked to every method of that name in the implementing class, so `ExampleService.putTairValue(String)` also led to `putTairValue(List, double)`, and `codegraph_explore`'s call path, callers and impact went into overloads that call never runs. Where a parameter is a type parameter, like `save(T entity)` implemented as `save(String entity)`, or the class is written in another language than its interface, the number of parameters decides; a method declared once on each side links as before. Re-index Java, Kotlin, C# and Scala projects after upgrading. Thanks @hxj1225 for the report and @danusha2345 for the pull request. (#2663) - In Kotlin, a call on what one of your functions returns, like `repository().pending(1)` or `val repo = repository(); repo.pending(1)` with a `private fun repository(): RealRepo` in a test class, now links to the method of the type that function declares, including one the type inherits or an extension on it. Before, the chained call went to whichever `pending` the name matched first, like a test double's in another package, and the local was guessed from its name, which reached the interface. The function is found in the calling class, a class it inherits from or one around it, or at the top level, and a nested type like `Request.Builder` is that type, not another class's `Builder`. When the declared type has no such method, the call now links to nothing rather than a method of the same name elsewhere; a type from outside your project is matched by name as before. Re-index Kotlin projects after upgrading. Thanks @mindcipherrr for the report. (#2805) - In JavaScript and TypeScript, a `super.run()` call now links to the method of the class's parent, or of the nearest ancestor that has it, like three.js's `super.copy(source)` reaching `Object3D.copy`, so `codegraph_explore`'s call path and impact follow an override into the method it builds on. Before, such a call inside a method of the same name was dropped, and elsewhere it went to whatever method had the name, often the class's own or a sibling class's in the same file. A call from a static method reaches the parent's static method, and a call to a parent from outside your project, like `HTMLElement`, `Set` or `Promise`, links nothing. Re-index JavaScript and TypeScript projects after upgrading. Thanks @Puma7 for the report and the pull request. (#2497) +- In JavaScript and TypeScript, a named class expression, like `register(class Widget extends Base {…})`, `customElements.define('x-card', class XCard extends HTMLElement {…})` or `const Panel = class PanelImpl extends Base {…}`, is now indexed as a class with its own methods and the class it extends, so calls between its methods and its `super` calls link as a class declaration's do. Before, its methods were free functions or weren't indexed at all, and it extended nothing. Its name stays bound inside the expression alone, as JavaScript has it: `new Widget()` elsewhere links nothing, a constant, class or interface of the same name around it keeps its links, and code outside never reaches its methods by a guess. A class a CommonJS module exports, like webpack's `module.exports = class MultiCompiler {…}` or `exports.Store = class Store {…}`, is reached through `require` as a class declaration is, along with its static methods and the methods called on its instances. Re-index JavaScript and TypeScript projects after upgrading. Thanks @Puma7 for the report and the pull request. (#2499) - In Go, a call through a struct field that holds a function, like prometheus's `d.refreshf(ctx)`, now reaches the functions put in that field, so `codegraph_explore` follows service discovery from `Discovery.Run` into each provider's `refresh` and names the line where each provider wires its method in, like `RefreshF: d.refresh`. The field can be set in a struct literal or by an assignment, copied from another struct's field (`refreshf: opts.RefreshF`), or given a method of an interface value, and two unrelated structs with a field of the same name, like `fn` or `handler`, stay apart. Before, such a flow stopped at the field. Re-index Go projects after upgrading. - In Go, a type defined from other types, like etcd's `type WatchChan <-chan WatchResponse` or gin's `type HandlerFunc func(*Context)` and `type HandlersChain []HandlerFunc`, now links to the types it is built from, so impact on `WatchResponse` or gin's `Context` reaches these types and the code that uses them. Before, such a type linked to nothing. One written through another package, like etcd's `type PutResponse pb.PutResponse`, links to that package's type, and a recursive one like `type stateFn func(*Lexer) stateFn` doesn't link to itself. Re-index Go projects after upgrading. - In Go, a struct now links to the types its fields are declared with, like etcd's `PutResponse` holding a `*ResponseHeader` or gin's `Context` holding a `HandlersChain`, so impact on `ResponseHeader` or `HandlersChain` reaches every struct that holds one and the code that uses those structs, and `codegraph_node` lists a struct's field types. Before, a struct linked only to the types it embeds. A field written through another package, like `*pb.ResponseHeader`, links to that package's type, one from outside your project, like `context.Context`, links to nothing, the types an embedded generic is filled in with, like the `Pod` in client-go's `listers.ResourceIndexer[*v1.Pod]`, link too, and a struct that holds itself, like a list node's `next *node`, doesn't link to itself. `codegraph_explore` still answers a question about a flow with the code that runs it, not with the structs that merely hold one of its types. Re-index Go projects after upgrading. diff --git a/__tests__/js-class-expressions.test.ts b/__tests__/js-class-expressions.test.ts new file mode 100644 index 00000000..ec4761d3 --- /dev/null +++ b/__tests__/js-class-expressions.test.ts @@ -0,0 +1,287 @@ +/** + * A named class expression, like `register(class Widget extends Base {…})` or + * `const Panel = class PanelImpl extends Base {…}`, is a class: it has a node, + * members of its own and the supertypes it names (#2499). Before, its methods + * were free functions or not indexed at all, and it extended nothing. Its + * name is bound inside the expression alone, so outside it the name reaches + * neither the class nor its members, and a binding of the same name around + * it keeps meaning what it meant. A class a CommonJS module exports is the + * module's value, which `require` hands over as it does a declaration. + */ +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; +import { extractFromSource } from '../src/extraction'; +import { initGrammars, loadGrammarsForLanguages } from '../src/extraction/grammars'; +import { tryKernelExtract, resetKernelForTests } from '../src/extraction/kernel'; +import type { ExtractionResult, Language } from '../src/types'; + +const KERNEL_PATH = path.join(__dirname, '..', 'codegraph-kernel', 'prebuilds', `${process.platform}-${process.arch}`, 'codegraph-kernel.node'); +const kernelAvailable = fs.existsSync(KERNEL_PATH) || process.env.CODEGRAPH_KERNEL_EXPECT === '1'; + +const EXPRESSIONS = `class Base { run() { return 1; } } +register(class Widget extends Base { + run() { return super.run(); } + extra() { return this.run(); } +}); +export const Panel = class PanelImpl extends Base { + go() { return 3; } +}; +register(class extends Base { anonymous() { return 4; } }); +`; + +const ENV_KEYS = ['CODEGRAPH_KERNEL', 'CODEGRAPH_KERNEL_LANGS'] as const; + +describe('a named class expression is a class (#2499)', () => { + let savedEnv: Record = {}; + + beforeAll(async () => { + await initGrammars(); + await loadGrammarsForLanguages(['typescript', 'tsx', 'javascript', 'jsx']); + }); + + beforeEach(() => { + savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]])); + resetKernelForTests(); + }); + + afterEach(() => { + for (const k of ENV_KEYS) { + if (savedEnv[k] === undefined) delete process.env[k]; + else process.env[k] = savedEnv[k]; + } + resetKernelForTests(); + }); + + function extract(backend: 'kernel' | 'wasm', file: string, source: string, language: Language): ExtractionResult { + if (backend === 'wasm') { + process.env.CODEGRAPH_KERNEL = '0'; + return extractFromSource(file, source, language); + } + delete process.env.CODEGRAPH_KERNEL; + process.env.CODEGRAPH_KERNEL_LANGS = 'all'; + const result = tryKernelExtract(file, source, language); + expect(result, `kernel extraction of ${file}`).not.toBeNull(); + return result!; + } + + const backends = kernelAvailable ? (['kernel', 'wasm'] as const) : (['wasm'] as const); + const variants = [['ts', 'typescript'], ['tsx', 'tsx'], ['js', 'javascript'], ['jsx', 'jsx']] as const; + + for (const backend of backends) { + it.each(variants)(`in %s, ${backend}`, (ext, language) => { + const result = extract(backend, `src/widgets.${ext}`, EXPRESSIONS, language); + const symbols = result.nodes + .filter((n) => n.kind === 'class' || n.kind === 'method' || n.kind === 'function') + .map((n) => `${n.kind} ${n.qualifiedName}${n.signature && n.kind === 'class' ? ` (${n.signature})` : ''}${n.isExported ? ' exported' : ''}`); + expect(symbols).toEqual([ + 'class Base', + 'method Base::run', + 'class Widget (class Widget extends Base)', + 'method Widget::run', + 'method Widget::extra', + 'class Panel::PanelImpl (class PanelImpl extends Base)', + 'method Panel::PanelImpl::go', + // An anonymous class expression is passed over, as before. + 'function anonymous', + ]); + const supertypes = result.unresolvedReferences + .filter((r) => r.referenceKind === 'extends') + .map((r) => `${result.nodes.find((n) => n.id === r.fromNodeId)?.qualifiedName} extends ${r.referenceName}`); + expect(supertypes).toEqual(['Widget extends Base', 'Panel::PanelImpl extends Base']); + }); + } + + it.runIf(kernelAvailable)('the kernel and the wasm extractor agree on every node and reference', () => { + for (const [ext, language] of variants) { + const canon = (r: ExtractionResult) => ({ + nodes: r.nodes.map(({ updatedAt: _u, ...n }) => JSON.stringify(n, Object.keys(n).sort())).sort(), + refs: r.unresolvedReferences.map((x) => JSON.stringify(x, Object.keys(x).sort())).sort(), + }); + expect(canon(extract('kernel', `src/widgets.${ext}`, EXPRESSIONS, language)), ext) + .toEqual(canon(extract('wasm', `src/widgets.${ext}`, EXPRESSIONS, language))); + } + }); +}); + +describe.each(['js', 'ts'])('a class expression\'s name reaches it from inside alone (#2499): %s', (ext) => { + let root = ''; + let graph: CodeGraph | null = null; + + afterEach(() => { + graph?.close(); + graph = null; + if (root) fs.rmSync(root, { recursive: true, force: true }); + root = ''; + }); + + /** `kind source -> target [resolvedBy confidence]` for the calls, supertypes and instantiations out of a file's nodes, synthesized ones left out. */ + function edgesFrom(g: CodeGraph, file: string): string[] { + const out: string[] = []; + for (const node of g.getNodesInFile(file)) { + for (const e of g.getOutgoingEdges(node.id)) { + if (!['calls', 'extends', 'implements', 'instantiates'].includes(e.kind) || e.metadata?.synthesizedBy) continue; + out.push(`${e.kind} ${node.qualifiedName} -> ${g.getNode(e.target)!.qualifiedName} [${e.metadata?.resolvedBy} ${e.metadata?.confidence}]`); + } + } + return out.sort(); + } + + it('links the class\'s supertypes, super and this calls, and nothing outside by its name', async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-class-expression-')); + const write = (rel: string, lines: string[]) => { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), lines.join('\n') + '\n'); + }; + write('package.json', [JSON.stringify({ name: 'app' })]); + write(`src/base.${ext}`, ['export class Base { run() { return 1; } }', 'export function register(T) { return T; }']); + write(`src/widgets.${ext}`, [ + "import { Base, register } from './base';", + 'register(class Widget extends Base {', + ' run() { return super.run(); }', + ' extra() { return this.run(); }', + // Another class expression's name is no binding in here either. + ' other() { return new PanelImpl(); }', + '});', + 'export const Panel = class PanelImpl extends Base {', + ' go() { return super.run(); }', + '};', + // The inner name is no binding out here: this throws at runtime. + 'export function make() { return new Widget(); }', + 'export function panel() { return new Panel(); }', + ]); + write(`src/shadow.${ext}`, [ + "import { Base, register } from './base';", + // A declaration and a constant share names with class expressions in their file. + 'export class Gadget { run() { return 0; } }', + 'export const list = [class Gadget extends Base { run() { return super.run(); } }];', + 'export const Tool = class Tool extends Base { static make() { return new Tool(); } };', + 'export function build() { return new Gadget(); }', + 'export function tool() { return new Tool(); }', + 'export function helper() { return Tool.make(); }', + ]); + // nitro's shape: an interface, and a class expression of its name that a + // factory returns. A value typed by the name is the interface's. + if (ext === 'ts') { + write('src/env.ts', [ + 'export interface Env { fetch(url: string): string }', + 'export function define() { return class Env { fetch(url: string) { return url; } }; }', + 'export function use(env: Env) { return env.fetch("/"); }', + ]); + } + graph = await CodeGraph.init(root, { index: true }); + + expect(edgesFrom(graph, `src/widgets.${ext}`)).toEqual([ + 'calls Panel::PanelImpl::go -> Base::run [instance-method 0.9]', + 'calls Widget::extra -> Widget::run [exact-match 0.4]', + 'calls Widget::run -> Base::run [instance-method 0.9]', + `calls src/widgets.${ext} -> register [import 0.9]`, + 'extends Panel::PanelImpl -> Base [import 0.9]', + 'extends Widget -> Base [import 0.9]', + // `new Panel()` is the constant's, as before; `new Widget()` outside + // the expression links nothing. + 'instantiates panel -> Panel [exact-match 0.9]', + ]); + expect(edgesFrom(graph, `src/shadow.${ext}`)).toEqual([ + 'calls list::Gadget::run -> Base::run [instance-method 0.9]', + 'extends Tool::Tool -> Base [import 0.9]', + 'extends list::Gadget -> Base [import 0.9]', + // Outside the expressions, the declaration and the constant keep the + // names, at the confidence they had; inside its own body, `Tool` is + // the class. + 'instantiates Tool::Tool::make -> Tool::Tool [exact-match 0.4]', + 'instantiates build -> Gadget [exact-match 0.9]', + 'instantiates tool -> Tool [exact-match 0.9]', + ]); + if (ext === 'ts') { + expect(edgesFrom(graph, 'src/env.ts').filter((e) => e.startsWith('calls use'))).toEqual(['calls use -> Env::fetch [instance-method 0.9]']); + } + }, 120_000); +}); + +it('a guess by a receiver\'s name never reaches a class expression\'s member from code its value can\'t reach (#2499)', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-class-expression-guess-')); + let graph: CodeGraph | null = null; + try { + fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'app' })); + fs.mkdirSync(path.join(root, 'src')); + // lit's SSR DOM shim declares its document and elements this way. + fs.writeFileSync(path.join(root, 'src/dom-shim.ts'), [ + 'export const DocumentShim = class Document {', + ' createElement(tag: string) { return tag; }', + '};', + 'export const HTMLElementShim = class HTMLElement {', + ' appendChild(child: unknown) { return child; }', + '};', + '', + ].join('\n')); + fs.writeFileSync(path.join(root, 'src/format.ts'), 'export class Formatter { appendChild(node: unknown) { return node; } }\n'); + fs.writeFileSync(path.join(root, 'src/view.ts'), [ + 'let container: HTMLElement;', + "export function view() { container = document.createElement('div'); return container; }", + // A class expression of its own, which it reaches. + 'export const Local = class LocalView { show() { return 1; } };', + 'export function mount(el: unknown) { container.appendChild(el); }', + '', + ].join('\n')); + graph = await CodeGraph.init(root, { index: true }); + for (const name of ['view', 'mount']) { + const fn = graph.getNodesByQualifiedName(name)[0]!; + expect(graph.getOutgoingEdges(fn.id).filter((e) => e.kind === 'calls').map((e) => graph!.getNode(e.target)!.qualifiedName), name).toEqual([]); + } + } finally { + graph?.close(); + fs.rmSync(root, { recursive: true, force: true }); + } +}, 120_000); + +it('a class a CommonJS module exports is reached through `require` as a declaration is (#2499)', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-class-expression-cjs-')); + let graph: CodeGraph | null = null; + try { + fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'app' })); + fs.mkdirSync(path.join(root, 'src')); + fs.writeFileSync(path.join(root, 'src/router.js'), [ + 'module.exports = class Router {', + ' static make() { return 1; }', + ' route() { return 2; }', + '};', + // A class expression the module doesn't export keeps its scope. + 'register(class Hidden { go() { return 3; } });', + '', + ].join('\n')); + fs.writeFileSync(path.join(root, 'src/store.js'), 'exports.Store = class StoreImpl {\n static open() { return 4; }\n};\n'); + fs.writeFileSync(path.join(root, 'src/app.js'), [ + "const Router = require('./router.js');", + "const { Store } = require('./store.js');", + 'exports.start = function start() {', + ' const r = new Router();', + ' Router.make();', + ' Store.open();', + ' new Hidden().go();', + ' return r.route();', + '};', + '', + ].join('\n')); + graph = await CodeGraph.init(root, { index: true }); + const start = graph.getNodesByQualifiedName('start')[0]!; + const targets = graph.getOutgoingEdges(start.id) + .filter((e) => e.kind === 'calls' || e.kind === 'instantiates') + .map((e) => `${e.kind} ${graph!.getNode(e.target)!.kind} ${graph!.getNode(e.target)!.qualifiedName} [${e.metadata?.resolvedBy}]`) + .sort(); + // The class, its static method and its instance's method, by what each + // module exports, `module.exports` or `exports.Store`; the class + // expression neither exports stays out of reach. + expect(targets).toEqual([ + 'calls method Router::make [import]', + 'calls method Router::route [instance-method]', + 'calls method StoreImpl::open [import]', + 'instantiates class Router [import]', + ]); + } finally { + graph?.close(); + fs.rmSync(root, { recursive: true, force: true }); + } +}, 120_000); diff --git a/codegraph-kernel/src/tsjs/extractors.rs b/codegraph-kernel/src/tsjs/extractors.rs index 61b34382..e717f5a8 100644 --- a/codegraph-kernel/src/tsjs/extractors.rs +++ b/codegraph-kernel/src/tsjs/extractors.rs @@ -368,10 +368,15 @@ impl<'t> Walker<'t> { pub(super) fn extract_class(&mut self, node: Node<'t>) { let resolved_body = body_of(node); // skipBodilessClass unset for TS/JS let name = self.extract_name(node); + // A class expression's name is bound inside the expression alone: + // never exported by it, and its head is its signature, which a + // declaration lacks (#2499). Mirrors TreeSitterExtractor.extractClass. + let expression = node.kind() == "class"; let extra = Extra { docstring: crate::docstring::preceding_docstring(node, self.src), visibility: self.visibility_of(node), - is_exported: Some(self.is_exported(node)), + is_exported: Some(!expression && self.is_exported(node)), + signature: if expression { resolved_body.map(|body| class_expression_head(self.src, node, body)) } else { None }, ..Extra::default() }; let Some(row) = self.create_node("class", &name, node, extra) else { @@ -2014,6 +2019,15 @@ impl<'t> Walker<'t> { } } +/// A class expression's head, `class Widget extends Base`: its text up to the +/// body, trailing spaces and line breaks dropped. Mirrors classExpressionHead +/// (tree-sitter.ts). +fn class_expression_head(src: &str, node: Node<'_>, body: Node<'_>) -> String { + src[node.start_byte()..body.start_byte()] + .trim_end_matches(|c: char| matches!(c, ' ' | '\t' | '\r' | '\n')) + .to_string() +} + /// `.replace(/\s+/g, ' ')` for the tuple-contract signature. fn collapse_ws(s: &str) -> String { let mut out = String::with_capacity(s.len()); diff --git a/codegraph-kernel/src/tsjs/mod.rs b/codegraph-kernel/src/tsjs/mod.rs index 12eea4be..cd53f325 100644 --- a/codegraph-kernel/src/tsjs/mod.rs +++ b/codegraph-kernel/src/tsjs/mod.rs @@ -82,7 +82,16 @@ fn is_function_type(kind: &str) -> bool { } fn is_class_type(v: Variant, kind: &str) -> bool { - kind == "class_declaration" || (v.is_ts() && kind == "abstract_class_declaration") + kind == "class_declaration" || kind == "class" || (v.is_ts() && kind == "abstract_class_declaration") +} + +/// Whether `node` is a class to extract: a class declaration, or a class +/// expression (`class`) with a name, `register(class Widget extends Base +/// {…})` (#2499); an anonymous one is passed over. Mirrors +/// TreeSitterExtractor.isClassTypeNode. +fn is_class_node(v: Variant, node: Node) -> bool { + let kind = node.kind(); + is_class_type(v, kind) && (kind != "class" || node.child_by_field_name("name").is_some()) } fn is_variable_type(kind: &str) -> bool { @@ -692,7 +701,7 @@ impl<'t> Walker<'t> { // `declare function`, a function in a `declare module` body or a `.d.ts`. self.extract_function(node, None); skip_children = true; - } else if is_class_type(self.variant, kind) { + } else if is_class_node(self.variant, node) { self.extract_class(node); skip_children = true; } else if is_method_type(self.variant, kind) @@ -839,7 +848,7 @@ impl<'t> Walker<'t> { } } - if is_class_type(self.variant, kind) { + if is_class_node(self.variant, node) { self.extract_class(node); return; } diff --git a/src/extraction/languages/javascript.ts b/src/extraction/languages/javascript.ts index b96956e4..17920e7f 100644 --- a/src/extraction/languages/javascript.ts +++ b/src/extraction/languages/javascript.ts @@ -4,7 +4,8 @@ import { classifyTsClassMember, hasStaticModifier } from './typescript'; export const javascriptExtractor: LanguageExtractor = { functionTypes: ['function_declaration', 'generator_function_declaration', 'arrow_function', 'function_expression', 'generator_function'], - classTypes: ['class_declaration'], + // A named class expression too, as in TypeScript (#2499). + classTypes: ['class_declaration', 'class'], methodTypes: ['method_definition', 'field_definition'], // JS `field_definition` ≙ TS `public_field_definition`: plain fields are // properties, function-valued fields are methods (#808). diff --git a/src/extraction/languages/typescript.ts b/src/extraction/languages/typescript.ts index 40f5a646..ec28e1e8 100644 --- a/src/extraction/languages/typescript.ts +++ b/src/extraction/languages/typescript.ts @@ -150,7 +150,10 @@ export const typescriptExtractor: LanguageExtractor = { preParse: blankFlowSyntax, functionTypes: ['function_declaration', 'generator_function_declaration', 'arrow_function', 'function_expression', 'generator_function'], isAmbientFunction: isAmbientFunctionSignature, - classTypes: ['class_declaration', 'abstract_class_declaration'], + // `class` is a class expression, `register(class Widget extends Base {…})`: + // a named one is a class with its members and supertypes (#2499), and an + // anonymous one is passed over (TreeSitterExtractor.isClassTypeNode). + classTypes: ['class_declaration', 'abstract_class_declaration', 'class'], // `method_signature` is the interface/type-literal form of a method; without it // an interface's members never enter the graph, so a `.d.ts` platform API has // no declaration node for call sites to attach to (#1638). Java/C# don't need diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 7310dc15..162714c9 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -430,6 +430,14 @@ const LITERAL_RECEIVER_TYPES = new Set([ */ const TS_JS_CHAIN_LANGUAGES = new Set(['typescript', 'tsx', 'javascript', 'jsx']); +/** Languages whose `class` node is a class expression (#2499). */ +const CLASS_EXPRESSION_LANGUAGES = new Set(['typescript', 'tsx', 'javascript', 'jsx', 'arkts']); + +/** A class expression's head, `class Widget extends Base`: its text up to the body. Mirrored in the kernel (tsjs extract_class). */ +function classExpressionHead(source: string, node: SyntaxNode, body: SyntaxNode): string { + return source.substring(node.startIndex, body.startIndex).replace(/[ \t\r\n]+$/, ''); +} + /** Receiver node types (TS/JS grammars) that continue a member chain downward. */ const TS_JS_CHAIN_RECEIVER_TYPES = new Set(['member_expression', 'subscript_expression']); @@ -1543,7 +1551,7 @@ export class TreeSitterExtractor { skipChildren = true; } // Check for class declarations - else if (this.extractor.classTypes.includes(nodeType)) { + else if (this.isClassTypeNode(node, nodeType)) { // Some languages reuse class_declaration for structs/enums (e.g. Swift) const classification = this.extractor.classifyClassNode?.(node) ?? 'class'; if (classification === 'struct') { @@ -2540,6 +2548,16 @@ export class TreeSitterExtractor { /** * Extract a class */ + /** + * Whether `node` is a class to extract: one of the language's class types, + * except a TS/JS class expression without a name (`register(class extends + * Base {…})`), which is passed over as before (#2499). + */ + private isClassTypeNode(node: SyntaxNode, nodeType: string): boolean { + if (!this.extractor!.classTypes.includes(nodeType)) return false; + return nodeType !== 'class' || !CLASS_EXPRESSION_LANGUAGES.has(this.language) || !!getChildByField(node, 'name'); + } + private extractClass(node: SyntaxNode, kind: NodeKind = 'class'): void { if (!this.extractor) return; @@ -2555,13 +2573,19 @@ export class TreeSitterExtractor { const name = extractName(node, this.source, this.extractor); const docstring = this.docstringFor(node); const visibility = this.extractor.getVisibility?.(node); - const isExported = this.extractor.isExported?.(node, this.source); + // A class expression's name is bound inside the expression alone, so the + // class is never what a module exports by it, and it carries its head + // (`class Widget extends Base`) as its signature: a class declaration + // has none, which is how resolution tells the two apart (#2499). + const expression = node.type === 'class' && CLASS_EXPRESSION_LANGUAGES.has(this.language); + const isExported = expression ? false : this.extractor.isExported?.(node, this.source); const scoped = this.writtenScopeQualifiedName(node, name); const classNode = this.createNode(kind, name, node, { docstring, visibility, isExported, + ...(expression && resolvedBody ? { signature: classExpressionHead(this.source, node, resolvedBody) } : {}), ...this.cppBodyEnd(resolvedBody), ...(scoped !== undefined ? { qualifiedName: scoped } : {}), }); @@ -7420,7 +7444,7 @@ export class TreeSitterExtractor { // Extract structural nodes found inside function bodies. // Each extract method visits its own children, so we return after extracting. - if (this.extractor!.classTypes.includes(nodeType)) { + if (this.isClassTypeNode(node, nodeType)) { const classification = this.extractor!.classifyClassNode?.(node) ?? 'class'; if (classification === 'struct') this.extractStruct(node); else if (classification === 'enum') this.extractEnum(node); diff --git a/src/resolution/alias-binding.ts b/src/resolution/alias-binding.ts index 1af7958e..0f3ff6c6 100644 --- a/src/resolution/alias-binding.ts +++ b/src/resolution/alias-binding.ts @@ -131,11 +131,12 @@ export function extractLocalExportAliases(content: string): Array<{ exportedName } } } - // CommonJS: `exports.setCharset = function setCharset(…)`, `module.exports.x = impl`. - const memberRe = /^[ \t]*(?:module\.)?exports\.([A-Za-z_$][\w$]*)\s*=\s*(?:(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)\s*\(|([A-Za-z_$][\w$]*)\s*;?[ \t]*$)/gm; + // CommonJS: `exports.setCharset = function setCharset(…)`, `module.exports.x = impl`, + // and a named class expression, `exports.Router = class Router {…}` (#2499). + const memberRe = /^[ \t]*(?:module\.)?exports\.([A-Za-z_$][\w$]*)\s*=\s*(?:(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)\s*\(|class\s+(?!extends\b)([A-Za-z_$][\w$]*)|([A-Za-z_$][\w$]*)\s*;?[ \t]*$)/gm; let member: RegExpExecArray | null; while ((member = memberRe.exec(content)) !== null) { - out.push({ exportedName: member[1]!, localName: member[2] ?? member[3]! }); + out.push({ exportedName: member[1]!, localName: member[2] ?? member[3] ?? member[4]! }); } // `module.exports = { parse, format: formatDate }`. const objectRe = /^[ \t]*module\.exports\s*=\s*\{([^{}]*)\}/gm; diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 5889a4fa..e2e717af 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -40,6 +40,7 @@ import { clearCFeatureVariantMemos } from './c-feature-variants'; import { clearAmbientModuleMemos, ambientModuleDeclarations } from './ambient-modules'; import { matchShopifyThemeFile } from './shopify-themes'; import { isJsSuperCallLanguage, isJsSuperCallSite, matchJsSuperCall } from './js-super-calls'; +import { CLASS_EXPRESSION_REF_LANGUAGES, isCommonJsExportedClass, isJsClassExpression, writtenIn, type ClassExpressionIndex } from './js-class-expressions'; import { resolveViaImport, resolvePhpImportedStaticCall, resolvePhpQualifiedClassRef, resolveTsQualifiedTypeRef, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef, isCobolCopybookRef, isNixPathImportRef, isDartImportRef, isLuaRequireRef, isJsPathImportRef, isBoundToOutOfRepoImport, reachesAmbientModule, importBindingTail, clearImportResolverMemos, resolveImportPath, isExternalImport, ESM_IMPORT_LANGUAGES, jsModuleFile, pythonRootFingerprint, pythonReopenScope, pythonPackageImporters, moduleDefaultExport, isJsFamilyFile, isModuleBindingName } from './import-resolver'; import { ResolverPool, minRefsForPool, shouldEngageAdaptively } from './resolver-pool'; import { resolveAliasBinding } from './alias-binding'; @@ -575,6 +576,10 @@ export class ReferenceResolver { this.mocklessContext = null; this.mocklessBase = null; this.manualMocks = undefined; + this.classExpressionFreeViews = new WeakMap(); + this.classExpressionFreeBases = new WeakMap(); + this.classExpressionIndex = undefined; + this.classExpressionOnlyNameSet = null; this.nodeCache.clear(); this.fileCache.clear(); this.importMappingCache.clear(); @@ -1193,33 +1198,37 @@ export class ReferenceResolver { */ private hasAnyPossibleMatch(name: string): boolean { if (!this.knownNames) return true; // no pre-filter available + // In the view outside the class expressions, names only their classes + // and members carry name nothing (classExpressionFreeContextFor). + const only = this.classExpressionFreeBases.has(this.context) ? this.classExpressionOnlyNames() : null; + const known = (n: string): boolean => this.knownNames!.has(n) && !(only?.has(n) ?? false); // Direct name match - if (this.knownNames.has(name)) return true; + if (known(name)) return true; // For qualified names like "obj.method" or "Class::method", check the parts const dotIdx = name.indexOf('.'); if (dotIdx > 0) { const receiver = name.substring(0, dotIdx); const member = name.substring(dotIdx + 1); - if (this.knownNames.has(receiver) || this.knownNames.has(member)) return true; + if (known(receiver) || known(member)) return true; // Also check capitalized receiver (instance-method resolution) const capitalized = receiver.charAt(0).toUpperCase() + receiver.slice(1); - if (this.knownNames.has(capitalized)) return true; + if (known(capitalized)) return true; // JVM FQN: `com.example.foo.Bar` — the only useful segment is the // last one (`Bar`); the earlier check finds `example.foo.Bar` which // never matches a node name. const lastDot = name.lastIndexOf('.'); if (lastDot > dotIdx) { const tail = name.substring(lastDot + 1); - if (tail && this.knownNames.has(tail)) return true; + if (tail && known(tail)) return true; } } const colonIdx = name.indexOf('::'); if (colonIdx > 0) { const receiver = name.substring(0, colonIdx); const member = name.substring(colonIdx + 2); - if (this.knownNames.has(receiver) || this.knownNames.has(member)) return true; + if (known(receiver) || known(member)) return true; // Multi-segment path `a::b::c` (a Rust/C++ module call like // `database::profiles::find`) — the only segment that names a symbol is // the last (`c`); `member` above is `b::c`, which never matches a node @@ -1228,7 +1237,7 @@ export class ReferenceResolver { const lastColon = name.lastIndexOf('::'); if (lastColon > colonIdx) { const tail = name.substring(lastColon + 2); - if (tail && this.knownNames.has(tail)) return true; + if (tail && known(tail)) return true; } } @@ -1242,9 +1251,9 @@ export class ReferenceResolver { if (sepIdx > 0) { const receiver = name.substring(0, sepIdx); const member = name.substring(sepIdx + 1); - if (this.knownNames.has(member) || this.knownNames.has(receiver)) return true; + if (known(member) || known(receiver)) return true; const capitalized = receiver.charAt(0).toUpperCase() + receiver.slice(1); - if (this.knownNames.has(capitalized)) return true; + if (known(capitalized)) return true; } } @@ -1252,7 +1261,7 @@ export class ReferenceResolver { const slashIdx = name.lastIndexOf('/'); if (slashIdx > 0) { const fileName = name.substring(slashIdx + 1); - if (this.knownNames.has(fileName)) return true; + if (known(fileName)) return true; } return false; @@ -1380,11 +1389,20 @@ export class ReferenceResolver { resolveOne(ref: UnresolvedRef): ResolvedRef | null { // Production code is resolved as if the project's manual mocks were not - // indexed (see mocklessContextFor); every strategy below reads the graph - // through this.context, so it answers for that view. + // indexed (see mocklessContextFor), and code outside the named class + // expressions as if their classes were not (classExpressionFreeContextFor); + // every strategy below reads the graph through this.context, so it + // answers for that view. + const outer = this.context; + const unviewed = this.classExpressionFreeBases.get(outer) ?? outer; + const base = unviewed === this.mocklessContext && this.mocklessBase ? this.mocklessBase : unviewed; + const mockless = this.mocklessContextFor(ref, base) ?? base; + return this.resolveOneWith(this.classExpressionFreeContextFor(ref, mockless) ?? mockless, ref); + } + + /** resolveOneIn with every strategy reading the graph through `context`. */ + private resolveOneWith(context: ResolutionContext, ref: UnresolvedRef): ResolvedRef | null { const outer = this.context; - const base = outer === this.mocklessContext && this.mocklessBase ? this.mocklessBase : outer; - const context = this.mocklessContextFor(ref, base) ?? base; if (context === outer) return this.resolveOneIn(ref); this.context = context; try { @@ -3959,6 +3977,149 @@ export class ReferenceResolver { return this.manualMocks; } + /** + * The context a TS/JS reference is resolved in outside the named class + * expressions (#2499): one without the classes they declare or their + * members. An expression's name is bound inside it alone, and its class's + * value travels in ways resolution doesn't follow. So outside, a reference + * means what it would were those classes not indexed, with the confidence + * it had: `const Panel = class Panel {…}` keeps `new Panel()` on the + * constant, an interface beside a same-named class expression keeps its + * typed calls, and an `HTMLElement` type stays clear of lit's + * `HTMLElementShim = class HTMLElement {…}`. A class a CommonJS module + * exports isn't among them (classExpressions). Null inside a class + * expression, where the whole graph applies. + */ + private classExpressionFreeContextFor(ref: UnresolvedRef, base: ResolutionContext): ResolutionContext | null { + if (!CLASS_EXPRESSION_REF_LANGUAGES.has(ref.language)) return null; + const expressions = this.classExpressions(); + if (!expressions) return null; + const dot = ref.referenceName.indexOf('.'); + const named = expressions.byName.get(dot > 0 ? ref.referenceName.slice(0, dot) : ref.referenceName); + // Written in a class expression, a reference sees its class and the + // classes around it, as JavaScript scopes them, unless it names another + // one, which is bound only inside that one. + const inside = expressions.byFile.get(ref.filePath)?.some((n) => writtenIn(n, ref)) ?? false; + if (inside && !(named && !named.some((n) => writtenIn(n, ref)))) return null; + let view = this.classExpressionFreeViews.get(base); + if (!view) { + view = this.classExpressionFreeView(base); + this.classExpressionFreeViews.set(base, view); + this.classExpressionFreeBases.set(view, base); + } + return view; + } + + /** + * `base` without the classes class expressions declare and their members. + * The lookups an import reads a module's exports by (getNodesInFileNamed, + * getExportedNodesInFile) are left whole. + */ + private classExpressionFreeView(base: ResolutionContext): ResolutionContext { + const { hiddenIds } = this.classExpressions()!; + const hidden = (n: Node): boolean => hiddenIds.has(n.id); + const keep = (nodes: Node[]): Node[] => (nodes.some(hidden) ? nodes.filter((n) => !hidden(n)) : nodes); + const byKind = new Map(); + const getMethodMatches = base.getMethodMatches; + const iterateNodesByKind = base.iterateNodesByKind; + const { files } = this.classExpressions()!; + return { + ...base, + getNodesByName: (name) => keep(base.getNodesByName(name)), + getNodesByLowerName: (lowerName) => keep(base.getNodesByLowerName(lowerName)), + getNodesByQualifiedName: (qualifiedName) => keep(base.getNodesByQualifiedName(qualifiedName)), + getNodesInFile: (filePath) => (files.has(filePath) ? keep(base.getNodesInFile(filePath)) : base.getNodesInFile(filePath)), + getNodesByKind: (kind) => { + let nodes = byKind.get(kind); + if (!nodes) byKind.set(kind, (nodes = keep(base.getNodesByKind(kind)))); + return nodes; + }, + getMethodMatches: getMethodMatches + ? (typeName, methodName, language) => keep(getMethodMatches(typeName, methodName, language)) + : undefined, + iterateNodesByKind: iterateNodesByKind + ? function* (kind) { + for (const n of iterateNodesByKind(kind)) if (!hidden(n)) yield n; + } + : undefined, + }; + } + + /** classExpressionFreeContextFor's views, by the context each hides from and what it hides, and each view's context; clearCaches drops both. */ + private classExpressionFreeViews = new WeakMap(); + private classExpressionFreeBases = new WeakMap(); + /** + * The names that only class expressions' classes and members carry, which + * name nothing where classExpressionFreeView hides those: lit's DOM shims + * (`class HTMLElement`, `class Event`) would otherwise send every + * `HTMLElement` and `new Event()` across the project through the whole + * resolution only to find nothing, where before the name pre-filter + * dropped them. Read once per cache window. + */ + private classExpressionOnlyNames(): Set { + if (this.classExpressionOnlyNameSet) return this.classExpressionOnlyNameSet; + const only = new Set(); + const expressions = this.classExpressions(); + if (expressions) { + const names = new Set(); + for (const id of expressions.hiddenIds) { + const n = this.nodeById(id); + if (n) names.add(n.name); + } + for (const name of names) { + if (!this.queries.getNodesByName(name).some((n) => !expressions.hiddenIds.has(n.id))) only.add(name); + } + } + this.classExpressionOnlyNameSet = only; + return only; + } + + /** classExpressionOnlyNames' answer; clearCaches drops it. */ + private classExpressionOnlyNameSet: Set | null = null; + + /** + * The classes named class expressions declare: by name, the files holding + * them, the ids of them and their members, and the one a member belongs + * to. Null when there are none. Read once per cache window. A class a + * CommonJS module exports is left out: the module binds it, so it is read + * as a declaration is. + */ + private classExpressionIndex: ClassExpressionIndex | null | undefined; + + private classExpressions(): ClassExpressionIndex | null { + if (this.classExpressionIndex !== undefined) return this.classExpressionIndex; + const byName = new Map(); + const byFile = new Map(); + const byKey = new Map(); + const files = new Set(); + const hiddenIds = new Set(); + const linesOf = (file: string): readonly string[] => + this.context.getFileLines?.(file) ?? this.context.readFile(file)?.split(/\r?\n/) ?? []; + for (const n of this.queries.getNodesByKind('class')) { + if (!isJsClassExpression(n) || isCommonJsExportedClass(n, linesOf(n.filePath))) continue; + const named = byName.get(n.name); + if (named) named.push(n); else byName.set(n.name, [n]); + const inFile = byFile.get(n.filePath); + if (inFile) inFile.push(n); else byFile.set(n.filePath, [n]); + byKey.set(`${n.filePath}\0${n.qualifiedName}`, n); + files.add(n.filePath); + hiddenIds.add(n.id); + } + // A member is its class's when it is declared in its file under its + // qualified name (`Widget::run` of `Widget`). + const ownerOf = (member: Node): Node | undefined => { + const cut = member.qualifiedName.lastIndexOf('::'); + return cut > 0 ? byKey.get(`${member.filePath}\0${member.qualifiedName.slice(0, cut)}`) : undefined; + }; + if (files.size > 0) { + for (const n of this.queries.getNodesByFiles([...files])) { + if ((n.kind === 'method' || n.kind === 'property' || n.kind === 'field') && ownerOf(n)) hiddenIds.add(n.id); + } + } + this.classExpressionIndex = byName.size > 0 ? { byName, byFile, files, hiddenIds, ownerOf } : null; + return this.classExpressionIndex; + } + /** Is `filePath` (project-relative) one of the files the index holds? */ private isIndexedFile(filePath: string): boolean { const normalized = filePath.replace(/\\/g, '/'); diff --git a/src/resolution/js-class-expressions.ts b/src/resolution/js-class-expressions.ts new file mode 100644 index 00000000..1f7ad269 --- /dev/null +++ b/src/resolution/js-class-expressions.ts @@ -0,0 +1,66 @@ +/** + * A named class expression's name is bound inside the expression alone + * (#2499): `register(class Gadget {…})` declares no `Gadget` for the code + * around it, where `new Gadget()` throws a ReferenceError. The extractor gives + * such a class a node, so its members are its own and its `extends` is + * known, and marks it with its head as its signature (`class Gadget`), which + * a class declaration doesn't carry. + * + * The class's name is bound nowhere outside the expression, and its value + * travels in ways resolution doesn't follow, except an import of what a + * module exports. So the resolver reads a reference outside the class + * expressions without their classes and members (ReferenceResolver's + * classExpressionFreeContextFor): there the reference means what it meant + * before those classes were indexed. A binding of the same name around an + * expression, like the `Panel` of `const Panel = class Panel {…}`, keeps the + * name, at the confidence it had. A class a CommonJS module exports, + * `module.exports = class Router {…}`, is the module's value, which every + * importer binds, so it isn't scoped: it is read as a declaration is. + */ +import type { Node } from '../types'; +import type { UnresolvedRef } from './types'; + +/** Languages a class expression's class and the references to its name can be written in: TS/JS, and the scripts of Vue, Svelte and Astro files. */ +export const CLASS_EXPRESSION_REF_LANGUAGES: ReadonlySet = new Set(['typescript', 'tsx', 'javascript', 'jsx', 'arkts', 'vue', 'svelte', 'astro']); + +/** What the resolver knows of the classes named class expressions declare (ReferenceResolver.classExpressions). */ +export interface ClassExpressionIndex { + /** The classes, by name. */ + byName: Map; + /** The classes, by the file that holds them. */ + byFile: Map; + /** The files that hold them. */ + files: Set; + /** The ids of the classes and of their members. */ + hiddenIds: Set; + /** The class a member belongs to, when it is one of them. */ + ownerOf: (member: Node) => Node | undefined; +} + +/** Whether `node` is a class a named class expression declares. */ +export function isJsClassExpression(node: Node): boolean { + return node.kind === 'class' && !!node.signature && CLASS_EXPRESSION_REF_LANGUAGES.has(node.language); +} + +/** The end of the text before a class a CommonJS module exports: `module.exports = `, `exports.Router = `, `module.exports.Router = `. */ +const COMMONJS_EXPORT_BEFORE = /(?:^|[^\w$.])(?:module\.)?exports(?:\.[A-Za-z_$][\w$]*)?\s*=\s*$/; + +/** + * Whether a class expression is what its CommonJS module exports, as in + * `module.exports = class Router {…}` or `exports.Router = class Router {…}` + * (`lines`: its file's lines). An importer reaches that class, its statics + * and, through an instance, its methods, as it reaches a declaration's. + */ +export function isCommonJsExportedClass(node: Node, lines: readonly string[]): boolean { + const line = lines[node.startLine - 1]; + if (line === undefined) return false; + return COMMONJS_EXPORT_BEFORE.test(`${lines[node.startLine - 2] ?? ''}\n${line.slice(0, node.startColumn)}`); +} + +/** Whether the reference is written inside `node`'s source range. */ +export function writtenIn(node: Node, ref: UnresolvedRef): boolean { + if (node.filePath !== ref.filePath) return false; + const afterStart = ref.line > node.startLine || (ref.line === node.startLine && ref.column >= node.startColumn); + const beforeEnd = ref.line < node.endLine || (ref.line === node.endLine && ref.column <= node.endColumn); + return afterStart && beforeEnd; +}