diff --git a/CHANGELOG.md b/CHANGELOG.md
index a85a4d0b8..988ecbf36 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
+- 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 `` 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.
diff --git a/__tests__/lua-local-alias-calls.test.ts b/__tests__/lua-local-alias-calls.test.ts
new file mode 100644
index 000000000..4c9a12093
--- /dev/null
+++ b/__tests__/lua-local-alias-calls.test.ts
@@ -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 = {
+ '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']);
+ });
+});
diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts
index 59a88722e..098b49170 100644
--- a/src/resolution/name-matcher.ts
+++ b/src/resolution/name-matcher.ts
@@ -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 = 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>();
+
+/** `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