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 <noreply@anthropic.com>
This commit is contained in:
Eric Minish
2026-10-20 01:08:42 -04:00
committed by GitHub
parent 18248d191e
commit 8beab37c46
3 changed files with 145 additions and 2 deletions
+1 -1
View File
@@ -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.
@@ -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<number>) {
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 `<kind>:<name>`. */
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']);
});
});
+5 -1
View File
@@ -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<string> = 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',
]);
/**