From 8beab37c46b5006231b9201f4b44a1fc7ecd4979 Mon Sep 17 00:00:00 2001 From: Eric Minish Date: Tue, 20 Oct 2026 01:08:42 -0400 Subject: [PATCH] fix(resolution): a call that is a braceless if/while/for/do body is a bare call (#2592) A receiver-less JS/TS call written as the body of a braceless if, while, for or do, like `if (e.key === "Escape") setOpen(false);`, is now read as a bare call. In the JS family a receiver ends in `.` or `?.`, so a `)` right before a call's name can only close a statement header; `do` joins the bare-call keywords. Go keeps its old reading. Before, such a call skipped the bare-call guards and could link to another file's same-named member, like a context interface's `setOpen` property. Fixes issue 2585. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- __tests__/js-braceless-statement-call.test.ts | 139 ++++++++++++++++++ src/resolution/name-matcher.ts | 6 +- 3 files changed, 145 insertions(+), 2 deletions(-) create mode 100644 __tests__/js-braceless-statement-call.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c214f5db..db92767b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,8 +14,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- In JavaScript and TypeScript, a call written as the body of an `if`, `while`, `for` or `do` without braces, like `if (e.key === "Escape") setOpen(false);`, no longer links to a same-named property or method in another file when the name is the file's own, such as a `setOpen` from `useState` (#2585). - `codegraph sync` no longer fails with "FOREIGN KEY constraint failed" in a project whose code lazy-loads a workspace package, like Supabase Studio's `dynamic(() => import('icons').then((mod) => mod.EdgeFunctions))` or vben's `() => import('@vben/layouts').then((m) => m.IFrameView)`. Before, every sync after an edit in such a project rolled back, so the index stopped following your changes until a full re-index. Thanks @bompus for the report and @inth3shadows. (#2565) - - In VB.NET, every member of a `Structure` is now indexed, including its fields, properties, methods, constructors and nested enums. Before, only the first member was, so the rest could not be found and their callers looked empty. - In VB.NET and C#, what a property's `Get` and `Set` code calls, creates and reads now belongs to that property, as do C#'s `get => …` accessors and `=> …` property bodies. Before, it was dropped, so a method used only from a property looked unused. - In VB.NET, a field or property initializer like `= Compute()` or `As New List(Of Order)` now links what it calls and creates, and so do a `Custom Event`'s `AddHandler`, `RemoveHandler` and `RaiseEvent` blocks. Re-index VB.NET and C# projects after upgrading. diff --git a/__tests__/js-braceless-statement-call.test.ts b/__tests__/js-braceless-statement-call.test.ts new file mode 100644 index 000000000..9528d5173 --- /dev/null +++ b/__tests__/js-braceless-statement-call.test.ts @@ -0,0 +1,139 @@ +/** + * A receiver-less call that is the body of a braceless `if`, `while`, `for` or + * `do` — `if (e.key === "Escape") setOpen(false);` — is still a bare call + * (#2585), also when the header wraps lines or holds a `)` in a string. + * The character before the name is the `)` that closes the statement's header, + * and that `)` used to read as the end of a receiver. The call then skipped + * both guards a bare JS/TS call gets: no type member, and no other file's + * symbol when the file binds the name itself. A `setOpen` from + * `const [open, setOpen] = useState(false)` linked to a context interface's + * `setOpen` property in another file. + */ +import { describe, it, expect, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; + +const CTX = `export interface PaletteCtx { + open: boolean; + setOpen: (v: boolean) => void; +} +`; + +const DIALOG = `import { useState } from "react"; +export function Dialog(xs: number[]) { + const [open, setOpen] = useState(false); + function a(e: KeyboardEvent) { + if (e.key === "Escape") setOpen(false); + } + function b() { + setOpen(false); + } + function c(e: KeyboardEvent) { + if (e.key === "Escape") { setOpen(false); } + } + function w() { + while (xs.length > 99) setOpen(false); + } + function f() { + for (const x of xs) setOpen(x > 0); + } + function g() { + if (open) { setOpen(false); } else setOpen(true); + } + async function h(src: AsyncIterable) { + for await (const x of src) setOpen(x > 0); + } + function k(e: KeyboardEvent) { + if (e.key === "Escape" && (open || xs.length)) setOpen(false); + } + function m(s: string) { + if (xs.length > 0 && + open) setOpen(false); + if (s === ")") setOpen(true); + if (/\\)/.test(s)) setOpen(false); + if (\`\${s})\` === s) setOpen(true); + if (s === "(") setOpen(false); + } + function n() { + do setOpen(false); while (open); + } + return [a, b, c, w, f, g, h, k, m, n]; +} +`; + +describe('a call that is the body of a braceless if/while/for/do (#2585)', () => { + let tmpDir: string | undefined; + let cg: CodeGraph | undefined; + + afterEach(() => { + cg?.close(); + cg = undefined; + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 5 }); + tmpDir = undefined; + }); + + /** `calls` targets of the function `name`, as `:`. */ + const callees = (graph: CodeGraph, name: string): string[] => { + const from = graph.getNodesByName(name).find((n) => n.kind === 'function')!; + return graph.getOutgoingEdges(from.id).filter((e) => e.kind === 'calls') + .map((e) => graph.getNode(e.target)!) + .map((n) => `${n.kind}:${n.name}`); + }; + + it('is a bare call: it never reaches another file\'s type member', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-braceless-')); + fs.mkdirSync(path.join(tmpDir, 'src')); + fs.writeFileSync(path.join(tmpDir, 'src/ctx.ts'), CTX); + fs.writeFileSync(path.join(tmpDir, 'src/Dialog.tsx'), DIALOG); + cg = await CodeGraph.init(tmpDir, { index: true }); + + for (const fn of ['a', 'b', 'c', 'w', 'f', 'g', 'h', 'k', 'm', 'n']) { + expect(callees(cg, fn), fn).not.toContain('property:setOpen'); + } + }); + + it('still resolves a bare call after a braceless header to its own target', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-braceless-')); + fs.writeFileSync(path.join(tmpDir, 'util.ts'), `export function helper() { return 1; } +export function use(open: boolean) { + if (open) helper(); +} +`); + cg = await CodeGraph.init(tmpDir, { index: true }); + + expect(callees(cg, 'use')).toEqual(['function:helper']); + }); + + it('still resolves a call through a parenthesized receiver', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-braceless-')); + fs.writeFileSync(path.join(tmpDir, 'svc.ts'), `export class Svc { + run() { return 1; } +} +export function make(): Svc { return new Svc(); } +export function use() { + return (make()).run(); +} +`); + cg = await CodeGraph.init(tmpDir, { index: true }); + + expect(callees(cg, 'use')).toContain('function:make'); + }); + + it('still resolves a call on a receiver after a braceless header', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-braceless-')); + fs.writeFileSync(path.join(tmpDir, 'svc.ts'), `export class Svc { + setOpen(v: boolean) { return v; } + run() { return 1; } +} +export function use(svc: Svc, open: boolean) { + if (open) svc.setOpen(false); + while (open) svc.run(); +} +`); + cg = await CodeGraph.init(tmpDir, { index: true }); + + expect(callees(cg, 'use').sort()).toEqual(['method:run', 'method:setOpen']); + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index f905764a0..c77621fb2 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -3473,6 +3473,10 @@ function isReceiverLessCall(ref: UnresolvedRef, context: ResolutionContext): boo let end = Math.min(ref.column, line.length); while (end > 0 && WHITESPACE.test(line[end - 1]!)) end--; if (end === 0 || !RECEIVER_TAIL_CHAR.test(line[end - 1]!)) return true; + // In JS/TS a receiver ends in `.` or `?.`, so a `)` right before the name + // closes a statement header — `if (open) setOpen()`, `for (…) f()`, a header + // that wraps lines or holds a `)` in a string — and the call is bare (#2585). + if (line[end - 1] === ')' && JS_FAMILY.has(ref.language)) return true; let start = end; while (start > 0 && WORD_CHAR.test(line[start - 1]!)) start--; return BARE_CALL_KEYWORDS.has(line.slice(start, end)); @@ -3490,7 +3494,7 @@ const RECEIVER_TAIL_CHAR = /[.\w$\])]/; */ const BARE_CALL_KEYWORDS: ReadonlySet = new Set([ 'return', 'await', 'yield', 'typeof', 'void', 'new', 'else', 'case', 'throw', 'in', 'of', 'instanceof', 'go', 'defer', - 'if', 'switch', 'for', 'range', + 'if', 'switch', 'for', 'range', 'do', ]); /**