From 60f1db03c809501baeef06003bb0f12b1c5c1881 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 30 Sep 2026 18:34:52 +0000 Subject: [PATCH] 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 --- CHANGELOG.md | 1 + __tests__/lua-local-alias-calls.test.ts | 115 ++++++++++++++++ src/resolution/name-matcher.ts | 174 +++++++++++++++++++++++- 3 files changed, 288 insertions(+), 2 deletions(-) create mode 100644 __tests__/lua-local-alias-calls.test.ts 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