fix(lua): follow a local alias to the function it names (#2188)

Kong (and OpenResty code generally) localizes everything it calls:
`local splitn = require("kong.tools.string").splitn`, `local check_phase =
phase_checker.check`, `local fmt = string.format`, `local type = type`. Every
bare call then resolved to the same-file alias variable - 8,000 edges on kong,
more than its calls into functions - a dead end for the real callee.

A bare Lua call whose name is a `local` alias in scope is now followed:
- `require "m"` (or a require-named loader like kong's `reload_module`) plus
  members, or a member of such a local, goes to the module's function of that
  name, or to what the module exports under it (`return { check = check_phase }`,
  `unindent = misc.unindent`, up to three hops);
- an alias of a Lua global or standard-library table is no edge, unless the
  project patches that function itself outside its tests (kong's `ngx.sleep`);
- a module outside the repo is no edge;
- anything else resolves as before.
A `local` reached this way is visible across files.

kong: -7057 +1150 (every loss is a std/outside alias or a reroute), indexing
4.3s -> 3.3s. apisix -3507 +170, lua-resty-core -967 +231, telescope -28 +17
(all reroutes or outside modules), lazy.nvim reroute only, roact unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-09-30 18:34:52 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 1f64e15886
commit 60f1db03c8
3 changed files with 288 additions and 2 deletions
+1
View File
@@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Fixes
- Lua calls made through a `local` alias now reach the function it names. That covers `local splitn = require("kong.tools.string").splitn`, a module member such as `utils.select_listener`, and a function a module exports under another name (`return { check = check_phase }`). Aliases of Lua's own functions, such as `local type = type` or `local fmt = string.format`, no longer show up as calls into your project. On Kong that is over 1,000 calls that now reach their real function, and about 5,000 fewer dead-end edges.
- React Router links and `navigate(…)` calls written through a route-config object, like bulletproof-react's `paths.app.discussion.getHref(id)`, now reach the route they open. A `<Link>` with an arrow-function attribute before its `to`, such as `onMouseEnter={() => …}`, is read too.
- C++ classes written around macros and preprocessor conditionals now keep their members. These include an access specifier spelled as a macro (`JSON_PRIVATE_UNLESS_TESTED:`), an attribute macro after a template head or a doc comment, and an `#if` in the middle of a declaration, such as inside a constructor's initializer list. The first branch of a mid-declaration `#if` is the one indexed. nlohmann/json's `basic_json` class used to be lost whole and now has its roughly 390 members. Files with a parse error dropped on redis, curl, pybind11 and leveldb, and no file got worse. Re-index after upgrading.
- Kotlin files using newer syntax now keep their classes intact. That covers `when` guard conditions (`is X if cond ->`), `..<` ranges, multi-dollar strings (`$$"…"`) and nullable receivers in function types (`T?.() -> R`). Before, the class around such code could come out as a scatter of loose functions. On Exposed, files with a parse error dropped from 65 to 43, and on kotlinx.coroutines from 50 to 46. Re-index after upgrading.
+115
View File
@@ -0,0 +1,115 @@
/**
* A bare Lua call through a `local` alias reaches what the alias names. Kong
* localizes everything it calls — `local splitn = require("kong.tools.string").splitn`,
* `local check_phase = phase_checker.check`, `local fmt = string.format`,
* `local type = type` — so its calls stopped at a same-file variable: 8,000 of
* them, most for the standard library. A module member is followed to its
* function, including one the module hands out under another name
* (`return { check = check_phase }`) or re-exports from a module of its own
* (`unindent = misc.unindent`); the standard library and outside modules are
* no edge at all.
*/
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-alias-'));
const files: Record<string, string> = {
'kong/tools/string.lua': `local _M = {}
function _M.splitn(value)
return value
end
return _M
`,
'kong/pdk/phases.lua': `local function check_phase(accepted)
return accepted
end
return {
check = check_phase,
}
`,
'kong/handler.lua': `local type = type
local fmt = string.format
local splitn = require("kong.tools.string").splitn
local phase_checker = require "kong.pdk.phases"
local check_phase = phase_checker.check
local cjson_encode = require("cjson").encode
local function run(v)
check_phase("access")
if type(v) ~= "string" then
error(fmt("bad %s", v))
end
return splitn(v), cjson_encode(v)
end
return { run = run }
`,
'spec/internal/misc.lua': `local function unindent(s)
return s
end
return { unindent = unindent }
`,
'spec/helpers.lua': `local misc = require("spec.internal.misc")
return {
unindent = misc.unindent,
}
`,
'spec/handler_spec.lua': `local helpers = require "spec.helpers"
local unindent = helpers.unindent
describe("handler", function()
it("runs", function()
unindent("a")
end)
end)
`,
};
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 });
});
/** `file: qualified name` of every call edge out of a file. */
function callsFrom(file: string): string[] {
const ids = cg.getNodesInFile(file).map((n) => n.id);
return cg
.getOutgoingEdgesFrom(ids)
.filter((e) => e.kind === 'calls')
.map((e) => {
const t = cg.getNode(e.target)!;
return `${t.filePath}: ${t.qualifiedName}`;
})
.sort();
}
describe('bare Lua calls through a local alias', () => {
it('reach the module function the alias names, and nothing for the standard library', () => {
expect(callsFrom('kong/handler.lua')).toEqual([
'kong/pdk/phases.lua: check_phase',
'kong/tools/string.lua: _M::splitn',
]);
});
it('follow a module that re-exports another module’s member', () => {
expect(callsFrom('spec/handler_spec.lua')).toEqual(['spec/internal/misc.lua: unindent']);
});
});
+172 -2
View File
@@ -862,6 +862,164 @@ function isLuaLocal(candidate: Node, context: ResolutionContext): boolean {
return local;
}
/** Lua's global functions, and the test runner's: `local type = type` is the standard library's `type`. */
const LUA_GLOBAL_FUNCTIONS: ReadonlySet<string> = new Set([
'assert', 'error', 'ipairs', 'pairs', 'next', 'type', 'tostring', 'tonumber', 'setmetatable', 'getmetatable',
'rawget', 'rawset', 'rawequal', 'rawlen', 'select', 'pcall', 'xpcall', 'unpack', 'print', 'load', 'loadstring',
'loadfile', 'dofile', 'collectgarbage', 'require', 'setfenv', 'getfenv', 'newproxy', 'typeof', 'warn', 'tick', 'wait',
'describe', 'it', 'before_each', 'after_each', 'setup', 'teardown', 'lazy_setup', 'lazy_teardown', 'pending', 'finally',
'insulate', 'expose',
]);
/** `require "m"` (or a loader named for it — kong's `reload_module("spec.internal.misc")`), then any `.member`s. */
const LUA_REQUIRE_ALIAS = /^(?:require|[A-Za-z_]\w*(?:[Rr]equire|_module|[Ii]mport))\s*\(?\s*(["'])([^"']+)\1\s*\)?((?:\s*\.\s*[A-Za-z_]\w*)*)\s*$/;
const LUA_NAME_ALIAS = /^([A-Za-z_]\w*)((?:\s*\.\s*[A-Za-z_]\w*)*)\s*$/;
/**
* A bare Lua call through a `local` alias, followed to what the alias names:
*
* local splitn = require("kong.tools.string").splitn → the module's `splitn`
* local select_listener = utils.select_listener → through `local utils = require …`
* local fmt = string.format / local type = type → the standard library's: no edge
*
* Kong localizes every global and module function it uses this way, so its
* calls stopped at a same-file variable — 8,000 of them, most for the
* standard library. `undefined` when the call is not through such an alias
* (it resolves as before), null when the alias names nothing in the project.
*/
function luaAliasTarget(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null | undefined {
const decl = luaLocalDecl(ref.referenceName, ref.filePath, ref.line, context);
if (!decl) return undefined;
const target = luaAliasOf(decl, context, 0);
if (target === undefined) return undefined;
if (target === null) return null;
return { original: ref, targetNodeId: target.id, confidence: 0.9, resolvedBy: 'import' };
}
/** The `local name = …` in scope at `line` of `file`: the nearest one above it that no other function holds. */
function luaLocalDecl(name: string, file: string, line: number, context: ResolutionContext): Node | null {
const nodes = context.getNodesInFile(file);
const fns = nodes.filter((n) => n.kind === 'function' || n.kind === 'method');
let best: Node | null = null;
for (const n of nodes) {
if (n.name !== name || n.kind !== 'variable' || n.startLine > line || !n.signature) continue;
if (fns.some((f) => f.startLine <= n.startLine && n.startLine <= f.endLine && !(f.startLine <= line && line <= f.endLine))) continue;
if (!isLuaLocal(n, context)) continue;
if (!best || n.startLine > best.startLine) best = n;
}
return best;
}
/** Where a Lua alias is written: its file, line and name, and a node there to resolve `require`s from. */
interface LuaSite { file: string; line: number; name: string; node: Node }
/**
* What a Lua alias variable names: a project function (or the module's own
* global), null for the standard library or an outside module, `undefined`
* for an initializer that is not an alias.
*/
function luaAliasOf(decl: Node, context: ResolutionContext, depth: number): Node | null | undefined {
const rhs = decl.signature!.replace(/^\s*=\s*/, '').trim();
return luaAliasExpr(rhs, { file: decl.filePath, line: decl.startLine, name: decl.name, node: decl }, context, depth);
}
const luaMembers = (chain: string): string[] => chain.split('.').map((s) => s.trim()).filter(Boolean);
function luaAliasExpr(rhs: string, site: LuaSite, context: ResolutionContext, depth: number): Node | null | undefined {
const req = LUA_REQUIRE_ALIAS.exec(rhs);
if (req) return luaModuleMember(req[2]!, luaMembers(req[3]!), site.node, context, depth);
const named = LUA_NAME_ALIAS.exec(rhs);
if (!named) return undefined;
const root = named[1]!;
const path = luaMembers(named[2]!);
const rootDecl = root === site.name ? null : luaLocalDecl(root, site.file, site.line - 1, context);
if (rootDecl) {
const module = LUA_REQUIRE_ALIAS.exec(rootDecl.signature!.replace(/^\s*=\s*/, '').trim());
if (module && path.length > 0) return luaModuleMember(module[2]!, [...luaMembers(module[3]!), ...path], rootDecl, context, depth);
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;
// 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) =>
(n.kind === 'function' || n.kind === 'method') && n.qualifiedName.split(/::|\./)[0] === root &&
(n.filePath === site.file || !isTestPath(n.filePath)));
return patched.length === 1 ? patched[0]! : null;
}
/** Per-context memo: `file\0a.b` → what that module member is. */
const LUA_MEMBERS = new WeakMap<ResolutionContext, Map<string, Node | null | undefined>>();
/** `member` of the module `require(name)` returns, from `decl`'s file: its function of that name. */
function luaModuleMember(name: string, path: string[], decl: Node, context: ResolutionContext, depth: number): Node | null | undefined {
const file = luaModuleFile(name, decl, context);
if (!file) return null;
// `require "kong.conf_loader"` called directly: the module's returned value.
if (path.length === 0) return undefined;
let memo = LUA_MEMBERS.get(context);
if (!memo) LUA_MEMBERS.set(context, (memo = new Map()));
const key = `${file}\0${path.join('.')}`;
if (memo.has(key)) return memo.get(key);
memo.set(key, null); // a cycle of re-exports names nothing
const found = luaMemberIn(file, path, context, depth);
memo.set(key, found);
return found;
}
function luaMemberIn(file: string, path: string[], context: ResolutionContext, depth: number): Node | null | undefined {
const member = path[path.length - 1]!;
const nodes = context.getNodesInFile(file);
const inFile = nodes.filter((n) => n.name === member);
const fns = inFile.filter((n) => n.kind === 'function' || n.kind === 'method');
if (fns.length > 0) {
const owner = path.length > 1 ? path[path.length - 2]! : null;
return fns.find((n) => owner !== null && n.qualifiedName.endsWith(`${owner}::${member}`)) ??
fns.find((n) => n.kind === 'method') ?? fns[0]!;
}
if (depth >= 3) return null;
// The module exports something under this name: `return { check = check_phase }`,
// `_M.check = check_phase`, `kong_exec = cmd.kong_exec,` (spec helpers' table).
const source = (context.readFile(file) ?? '')
.replace(/--\[(=*)\[[\s\S]*?\]\1\]/g, (c) => c.replace(/[^\n]/g, ''))
.replace(/--[^\n]*/g, '');
const exported = new RegExp(`(?:^|[\\s{,.])${member}\\s*=\\s*([A-Za-z_]\\w*(?:\\s*\\.\\s*[A-Za-z_]\\w*)*)\\s*(?:[,;}]|$)`, 'm').exec(source);
if (exported && exported[1] !== member) {
const rhs = exported[1]!;
const line = source.slice(0, exported.index).split('\n').length + 1;
if (/^[A-Za-z_]\w*$/.test(rhs)) {
const fn = nodes.find((n) => n.name === rhs && (n.kind === 'function' || n.kind === 'method'));
if (fn) return fn;
}
const at = nodes.find((n) => n.kind === 'file') ?? nodes[0];
const next = at ? luaAliasExpr(rhs, { file, line, name: member, node: at }, context, depth + 1) : undefined;
if (next !== undefined) return next;
}
// The module re-exports an alias of its own (`local splitn = require(…).splitn`).
const alias = inFile.find((n) => n.kind === 'variable' && n.signature && isLuaLocal(n, context));
if (alias) {
const next = luaAliasOf(alias, context, depth + 1);
if (next !== undefined) return next;
}
const global = inFile.find((n) => n.kind === 'variable' && !isLuaLocal(n, context));
return global ?? null;
}
/** The project file `require(name)` loads from `decl`'s file, or null for a module outside it. */
function luaModuleFile(name: string, decl: Node, context: ResolutionContext): string | null {
const resolved = context.resolveImport?.({
fromNodeId: decl.id,
referenceName: name,
referenceKind: 'imports',
line: decl.startLine,
column: 0,
filePath: decl.filePath,
language: decl.language,
});
if (!resolved) return null;
return context.getNodeById?.(resolved.targetNodeId)?.filePath ?? null;
}
/**
* Languages in which `visibility: 'private'` on a definition means no other
* FILE can name it: a Kotlin `private fun` is file- or class-local, and the
@@ -1013,8 +1171,13 @@ export function isVisibleAcrossFiles(candidate: Node, ref: UnresolvedRef, contex
if (lang === 'r' && (candidate.kind === 'variable' || candidate.kind === 'constant') &&
/(?:^|\/)tests?\//.test(candidate.filePath) && !/(?:^|\/)(?:helper|setup)[^/]*\.[rR]$/.test(candidate.filePath)) return false;
// A Lua `local` belongs to its chunk: kong's spec helpers' `local it = it`
// took busted's `it(…)` in every other spec file, 4,166 times.
if ((lang === 'lua' || lang === 'luau') && isLuaLocal(candidate, context)) return false;
// took busted's `it(…)` in every other spec file, 4,166 times. Its module
// can still hand it out — `return { check = check_phase }` — to a file that
// names it through a `require` alias.
if ((lang === 'lua' || lang === 'luau') && isLuaLocal(candidate, context)) {
return ref.referenceKind === 'calls' && /^[A-Za-z_]\w*$/.test(ref.referenceName) &&
luaAliasTarget(ref, context)?.targetNodeId === candidate.id;
}
// JS/TS/ArkTS sealed modules + markdown/JSON call-target guards (#1719).
// Same predicate matchByExactName / matchFuzzy apply to their survivors so a
// rejection here cannot fall through to a promoted runner-up.
@@ -4618,6 +4781,7 @@ export function clearNameMatcherMemos(context: ResolutionContext): void {
SCALA_IMPORTS.delete(context);
ESM_EXPORT_LISTS.delete(context);
LUA_LOCALS.delete(context);
LUA_MEMBERS.delete(context);
PHP_FILE_SCOPES.delete(context);
JAVA_STATIC_IMPORTS.delete(context);
PY_IMPORTS.delete(context);
@@ -7364,6 +7528,12 @@ function matchReferenceInner(
};
}
// A bare Lua call through a `local` alias reaches what the alias names.
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;
}
// Erlang `-behaviour(m)` refs target a MODULE. Letting them fall through to
// bare-name matching grabs any same-named symbol — on emqx,
// `-behaviour(supervisor)` resolved to a `-define(supervisor, …)` macro