mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-02 09:45:39 +08:00
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:
co-authored by
Claude Opus 5.5
parent
1f64e15886
commit
60f1db03c8
@@ -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.
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user