From b55836daa30d8f8dc994f6b06da93c64d9c07bd4 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 30 Sep 2026 20:46:25 +0000 Subject: [PATCH] fix(lua): a bare call is never a table method; Lua's globals are Lua's (#2202) - A bare Lua call cannot reach a table's method (`function M.x`, `function M:x`) without its table: kong's 118 `error(...)` calls went to its response PDK's `_RESPONSE.error`, 111 to a mock's `error`, busted's `setup`/`teardown` to perf helpers' and factories'. - A bare call to a Lua global (`ipairs`, `error`, `require`, busted's `describe`/`it`/`setup`...) is Lua's unless the file defines its own function of that name: telescope's 120 `for _, v in ipairs(...)` went to a linked list's `ipairs` method, apisix's 36 `require(...)` to a wasm module's `_M.require`. - A function's own locals are not nodes, so the alias reader now reads `local x = ...` from source above the call within its function, and a member of a host global table (`kong.request.get_query`) resolves to the one method of a table named after its holder (`_REQUEST`). kong's `local clear_header = kong.service.request.clear_header` had resolved to the RESPONSE module's - now nothing, correctly. telescope -129, kong -516 +58 (indexing 4.7s -> 3.5s), apisix -57 +18, lua-resty-core -4, roact -3 +1. Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/lua-bare-builtins.test.ts | 88 +++++++++++++++++++++++++++++ src/resolution/name-matcher.ts | 35 +++++++++++- 3 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 __tests__/lua-bare-builtins.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b5c6e79d..b607eb8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- A bare Lua call like `ipairs(t)`, `error("…")` or busted's `setup()` now means Lua's own function unless the file defines one. It no longer lands on a table method of the same name: telescope's `ipairs` calls had gone to a linked list's `ipairs`, and kong's `error` calls to its response module. A function's own alias of a host table member, like `local get_query = kong.request.get_query`, is now followed as well. - A C++ call into a macro-opened namespace, like `fmt::format("{}", x)`, now picks the overload its arguments fit. Before, the first overload indexed won: fmt's 1,400 `fmt::format` calls all reached the `text_style` overload in `color.h`. A narrow string literal now fits a narrow format string, a wide `L"…"` fits the wide overload, and the argument count has to fit too. - Calls inside an inline route handler now keep their receivers, so they resolve like any other method call. Before, Hono's `app.get('/', (c) => c.text('Hello'))` linked the route to the one other `text` method in the project, its client's `ClientResponse.text`; that happened in over 400 routes. A call like `userService.lookup(id)` reaches the service it names. - A Scala chained call like `xs.map { … }` no longer lands on an object that happens to be named `map`, as with cats' `alleycats.std.map` and its `syntax`/`instances` objects. A type or object in a chain is only matched when the receiver names what holds it, as in `sttp.client4.Response(…)`. diff --git a/__tests__/lua-bare-builtins.test.ts b/__tests__/lua-bare-builtins.test.ts new file mode 100644 index 00000000..85ad547f --- /dev/null +++ b/__tests__/lua-bare-builtins.test.ts @@ -0,0 +1,88 @@ +/** + * A bare Lua call cannot reach a table's method — `function M.error()` is + * `M.error(…)`, never `error(…)` — and a Lua global (`ipairs`, `error`, + * busted's `setup`) is Lua's unless the file defines its own. Telescope's + * 120 `for _, v in ipairs(…)` went to a linked list's `ipairs` method, + * kong's 118 `error(…)` to its response PDK's `error`. A function's own + * `local get_query = kong.request.get_query` alias still reaches the method + * of the table named after its holder. + */ +import { describe, it, expect, afterAll, beforeAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; + +let root = ''; +let cg: CodeGraph; + +beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-lua-builtins-')); + const files: Record = { + 'lua/app/linked_list.lua': `local LinkedList = {} + +function LinkedList:ipairs() + return function() end +end + +return LinkedList +`, + 'lua/app/response.lua': `local _RESPONSE = {} + +function _RESPONSE.error(status) + return status +end + +return _RESPONSE +`, + 'lua/app/request.lua': `local _REQUEST = {} + +function _REQUEST.get_query() + return {} +end + +return _REQUEST +`, + 'lua/app/handler.lua': `local M = {} + +function M.run(items) + for _, v in ipairs(items) do + if not v then + error("missing") + end + end + local get_query = kong.request.get_query + return get_query() +end + +return M +`, + }; + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + cg = await CodeGraph.init(root, { index: true }); +}); + +afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); +}); + +const callsFrom = (file: string) => { + const ids = cg.getNodesInFile(file).map((n) => n.id); + return cg.getOutgoingEdgesFrom(ids).filter((e) => e.kind === 'calls').map((e) => cg.getNode(e.target)!.qualifiedName).sort(); +}; + +describe('bare Lua calls', () => { + it('are Lua’s globals, never a table’s method of that name', () => { + const calls = callsFrom('lua/app/handler.lua'); + expect(calls).not.toContain('LinkedList::ipairs'); + expect(calls).not.toContain('_RESPONSE::error'); + }); + + it('still follow a function’s own alias of a host table’s member', () => { + expect(callsFrom('lua/app/handler.lua')).toContain('_REQUEST::get_query'); + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 1d189cbe..44fe7894 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -1012,6 +1012,22 @@ function luaLocalDecl(name: string, file: string, line: number, context: Resolut if (!isLuaLocal(n, context)) continue; if (!best || n.startLine > best.startLine) best = n; } + // A function's own locals are not nodes: kong's `local clear_header = + // kong.response.clear_header` inside an access handler. Read the nearest + // one above the call within the innermost function around it. + const fn = fns.filter((f) => f.startLine <= line && line <= f.endLine && f.startLine < line) + .sort((a, b) => (a.endLine - a.startLine) - (b.endLine - b.startLine))[0]; + if (fn && (!best || best.startLine < fn.startLine)) { + const lines = context.getFileLines?.(file) ?? context.readFile(file)?.split(/\r?\n/) ?? []; + const decl = new RegExp(`^\\s*local\\s+${name}\\s*=\\s*(.+?)\\s*$`); + for (let at = line - 1; at > fn.startLine; at--) { + const m = decl.exec(lines[at - 1] ?? ''); + if (!m) continue; + const fileNode = nodes.find((n) => n.kind === 'file'); + if (!fileNode) break; + return { ...fileNode, name, kind: 'variable', signature: `= ${m[1]}`, startLine: at, endLine: at }; + } + } return best; } @@ -1044,7 +1060,17 @@ function luaAliasExpr(rhs: string, site: LuaSite, context: ResolutionContext, de return undefined; } if (path.length === 0) return LUA_GLOBAL_FUNCTIONS.has(root) ? null : undefined; - if (!LUA_LIBRARY_TABLES.has(root) || path.length !== 1) return undefined; + if (!LUA_LIBRARY_TABLES.has(root)) { + // A member of a global table the host provides — kong's `local clear_header = + // kong.response.clear_header` — is the one method of a table named after its holder. + const member = path[path.length - 1]!; + const holder = path.length > 1 ? path[path.length - 2]! : root; + const owned = context.getNodesByName(member).filter((n) => + (n.language === 'lua' || n.language === 'luau') && n.kind === 'method' && sharesReceiverWord(holder, n) && + !(isTestPath(n.filePath) && !isTestPath(site.file))); + return owned.length === 1 ? owned[0]! : undefined; + } + if (path.length !== 1) return undefined; // A library function the project patches itself (kong's `ngx.sleep`) is the project's — // a test's stand-in (`function ngx.get_phase()` in a spec) only for that test. const patched = context.getNodesByName(path[0]!).filter((n) => @@ -3822,6 +3848,7 @@ export function matchByExactName( const bareNoMembers = isBareGoCall(ref, context) || isBareRCall(ref, context); const solidityBare = isReceiverLessSolidityCall(ref, context); const barePhp = isBarePhpCall(ref, context); + const luaBareCall = (ref.language === 'lua' || ref.language === 'luau') && ref.referenceKind === 'calls' && /^[A-Za-z_]\w*$/.test(ref.referenceName); // A type, a value or an import the file binds from a package outside the // repository names nothing in it, whatever kind of reference it is. if (!bareJs && JS_FAMILY.has(ref.language) && ref.referenceKind !== 'calls' && @@ -3913,6 +3940,8 @@ export function matchByExactName( !((bareJs || bareNoMembers) && TYPE_MEMBER_KINDS.has(n.kind)) && // A bare PHP call is a function call: nothing else is callable without a receiver. !(barePhp && n.kind !== 'function') && + // Nor is a table's method (`function M.x`, `function M:x`) in Lua, without its table. + !(luaBareCall && n.kind === 'method') && // A Vue component's own method is `this.m()` inside that component — not // `this.$refs['input'].click()` on an element another component renders. !(ref.referenceKind === 'calls' && JS_FAMILY.has(ref.language) && isVueComponentMethod(n) && !isThisCallInOwnFile(n, ref, context)) && @@ -7806,6 +7835,10 @@ function matchReferenceInner( if ((ref.language === 'lua' || ref.language === 'luau') && ref.referenceKind === 'calls' && /^[A-Za-z_]\w*$/.test(ref.referenceName)) { const aliased = luaAliasTarget(ref, context); if (aliased !== undefined) return aliased; + // `ipairs(t)` is Lua's, unless the file defines its own: telescope's 122 + // `for _, v in ipairs(…)` went to a linked list's `ipairs` method. + if (LUA_GLOBAL_FUNCTIONS.has(ref.referenceName) && + !context.getNodesInFile(ref.filePath).some((n) => n.name === ref.referenceName && n.kind === 'function')) return null; } // Erlang `-behaviour(m)` refs target a MODULE. Letting them fall through to