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 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-09-30 20:46:25 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 116feb462d
commit b55836daa3
3 changed files with 123 additions and 1 deletions
+1
View File
@@ -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(…)`.
+88
View File
@@ -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<string, string> = {
'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');
});
});
+34 -1
View File
@@ -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