Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu
- Fix: when duplicate nodes merge, the richer (more complete) node is now kept as the survivor and the losers' non-empty fields are folded in, instead of a shorter-id passing mention winning and dropping content (#3372, thanks @abhay-codes07).
- Fix: a C# generic call site with explicit type arguments — `Get<int>(...)`, unqualified or through `this` — now resolves to the method definition instead of capturing `Get<int>` as the callee and failing to match (#3406, thanks @abhay-codes07).
- Fix: `this.X = function` / `this.X = () => …` members are now captured in every enclosing-function form (function expressions, arrows, IIFEs, callbacks), not just function declarations (#3408, thanks @abhay-codes07).
- Fix: a bare Lua `require("m")` statement — the idiom every Neovim `init.lua` is written in — now produces an `imports` edge; only the assigned form `local x = require("m")` was recognized, so a config built from bare requires had no import edges at all (#3320, thanks @artbylmz).

## 0.9.56 (2026-09-07)

Expand Down
74 changes: 54 additions & 20 deletions graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -1046,27 +1046,57 @@ def _ruby_sanitize_method_name(name: str) -> str:
)


def _lua_bare_require_module(node, source: bytes) -> str:
"""Return the module name of a bare Lua ``require`` call, or ``""``.

A `require("m")` statement with no assignment is a plain `function_call`, not
a `variable_declaration` — the idiom every Neovim `init.lua` is written in
(#3320). Matching on the call's own callee (rather than regex-scanning the
node text) keeps a nested form like `require("a").setup(require("b"))` from
reporting the inner module twice: the outer call's callee is an index
expression, so only the two genuine `require` calls match, each once.
"""
callee = node.child_by_field_name("name")
if callee is None or callee.type != "identifier":
return ""
if _read_text(callee, source) != "require":
return ""
args = node.child_by_field_name("arguments")
if args is None:
return ""
# `require("m")`, `require "m"` and `require [[m]]` all reach the module name
# through a string node; read its content child so quotes stay out of the id.
for child in args.children if args.type == "arguments" else ():
if child.type == "string":
for part in child.children:
if part.type == "string_content":
return _read_text(part, source).strip()
return _read_text(child, source).strip("\"'[] ")
return ""


def _import_lua(node, source: bytes, file_nid: str, stem: str, edges: list, str_path: str, scope_stack: list[str] | None = None) -> None:
"""Extract require('module') from Lua variable_declaration nodes."""
text = _read_text(node, source)
import re
m = re.search(r"""require\s*[\('"]\s*['"]?([^'")\s]+)""", text)
if m:
raw_module = m.group(1)
if raw_module:
tgt_nid = _resolve_lua_import_target(raw_module, str_path)
if tgt_nid:
edges.append({
"source": file_nid,
"target": tgt_nid,
"relation": "imports",
"context": "import",
"confidence": "EXTRACTED",
"confidence_score": 1.0,
"source_file": str_path,
"source_location": str(node.start_point[0] + 1),
"weight": 1.0,
})
"""Extract require('module') from Lua variable_declaration / bare-call nodes."""
if node.type == "function_call":
raw_module = _lua_bare_require_module(node, source)
else:
import re
m = re.search(r"""require\s*[\('"]\s*['"]?([^'")\s]+)""", _read_text(node, source))
raw_module = m.group(1) if m else ""
if raw_module:
tgt_nid = _resolve_lua_import_target(raw_module, str_path)
if tgt_nid:
edges.append({
"source": file_nid,
"target": tgt_nid,
"relation": "imports",
"context": "import",
"confidence": "EXTRACTED",
"confidence_score": 1.0,
"source_file": str_path,
"source_location": str(node.start_point[0] + 1),
"weight": 1.0,
})


_LUA_CONFIG = LanguageConfig(
Expand All @@ -1075,6 +1105,10 @@ def _import_lua(node, source: bytes, file_nid: str, stem: str, edges: list, str_
class_types=frozenset(),
function_types=frozenset({"function_declaration"}),
import_types=frozenset({"variable_declaration"}),
# A bare `require("m")` statement carries an import but is also an ordinary
# call node, so it goes through import_call_types (no early return) rather
# than import_types (#3320).
import_call_types=frozenset({"function_call"}),
call_types=frozenset({"function_call"}),
call_function_field="name",
call_accessor_node_types=frozenset({"method_index_expression"}),
Expand Down
14 changes: 14 additions & 0 deletions graphify/extractors/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -3274,6 +3274,14 @@ def ensure_named_node(name: str, line: int) -> str:
def walk(node, parent_class_nid: str | None = None) -> None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressionwalk()

fans out to 58 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressionwalk()

fans out to 58 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

t = node.type

# Import-carrying call statements (Lua `require("m")` with no assignment).
# Unlike import_types this does not return: the node is an ordinary call
# expression, so class/function/call dispatch below still has to see it
# and its children (#3320). The handler decides whether the call is
# actually an import.
if t in config.import_call_types and config.import_handler:
config.import_handler(node, source, file_nid, stem, edges, str_path, scope_stack)

# Import types
if t in config.import_types:
if config.import_handler:
Expand Down Expand Up @@ -5302,6 +5310,12 @@ def walk_calls(
and node.type in ("lexical_declaration", "variable_declaration")):
_require_imports_js(node, source, caller_nid, stem, edges, str_path)

# Lua's twin of the block above: a bare `require("m")` is valid at any
# depth, and lazy requires inside a function body are as common in Neovim
# config as the top-level form the module-level walk covers (#3320).
if node.type in config.import_call_types and config.import_handler:
config.import_handler(node, source, file_nid, stem, edges, str_path, scope_stack)

if node.type in config.call_types:
# JS/TS dynamic imports: await import('./foo.js')
if config.ts_module in ("tree_sitter_javascript", "tree_sitter_typescript"):
Expand Down
6 changes: 6 additions & 0 deletions graphify/extractors/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ class LanguageConfig:
class_types: frozenset = frozenset()
function_types: frozenset = frozenset()
import_types: frozenset = frozenset()
# Node types that may *also* carry an import (Lua's bare `require("m")`
# statement is a plain `function_call`), dispatched to import_handler but
# without the `return` that import_types performs — the node is still a call
# expression that may contain declarations and nested calls, so the walk has
# to continue into it (#3320).
import_call_types: frozenset = frozenset()
call_types: frozenset = frozenset()
static_prop_types: frozenset = frozenset()
helper_fn_names: frozenset = frozenset()
Expand Down
125 changes: 125 additions & 0 deletions tests/test_lua_bare_require.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
"""Regression tests for bare Lua `require("m")` statements (#3320).

`_import_lua` only ran for `variable_declaration` nodes, so an import edge came
out of `local x = require("m")` but not out of the far more common Neovim idiom
— a `require("m")` statement with no assignment, which tree-sitter-lua parses as
a plain `function_call`. Every `init.lua` written as a list of bare requires
therefore produced zero import edges. The fix routes those call nodes through
the same handler via `import_call_types`, which (unlike `import_types`) does not
stop the walk, so Lua call extraction is unaffected.
"""
from __future__ import annotations

from pathlib import Path

from graphify.extract import extract


def _write(path: Path, text: str) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
return path


def _import_targets(result: dict, source: str) -> list[str]:
return [
edge["target"]
for edge in result["edges"]
if edge["relation"] == "imports" and edge["source"] == source
]


def test_bare_require_emits_import_edge(tmp_path: Path):
entry = _write(tmp_path / "init.lua", 'require("lazy")\n')
module = _write(tmp_path / "lazy.lua", "local M = {}\nreturn M\n")

result = extract([entry, module], cache_root=tmp_path)

assert _import_targets(result, "init") == ["lazy"]


def test_bare_require_matches_the_assigned_form(tmp_path: Path):
"""The two spellings of the same dependency must produce the same edge."""
module = _write(tmp_path / "lazy.lua", "local M = {}\nreturn M\n")
bare = _write(tmp_path / "bare.lua", 'require("lazy")\n')
assigned = _write(tmp_path / "assigned.lua", 'local ok = require("lazy")\n')

result = extract([module, bare, assigned], cache_root=tmp_path)

assert _import_targets(result, "bare") == _import_targets(result, "assigned")


def test_bare_require_without_parentheses(tmp_path: Path):
entry = _write(tmp_path / "init.lua", 'require "lazy"\n')
module = _write(tmp_path / "lazy.lua", "local M = {}\nreturn M\n")

result = extract([entry, module], cache_root=tmp_path)

assert _import_targets(result, "init") == ["lazy"]


def test_every_bare_require_in_a_list_is_reported(tmp_path: Path):
entry = _write(
tmp_path / "init.lua",
'require("options")\nrequire("keymaps")\nrequire("plugins")\n',
)
modules = [
_write(tmp_path / f"{name}.lua", "-- module\n")
for name in ("options", "keymaps", "plugins")
]

result = extract([entry, *modules], cache_root=tmp_path)

assert _import_targets(result, "init") == ["options", "keymaps", "plugins"]


def test_lazy_require_inside_a_function_body(tmp_path: Path):
"""A require at any lexical depth is still a dependency of the file."""
entry = _write(
tmp_path / "init.lua",
"function setup()\n require(\"lazy\")\nend\n",
)
module = _write(tmp_path / "lazy.lua", "-- module\n")

result = extract([entry, module], cache_root=tmp_path)

assert _import_targets(result, "init") == ["lazy"]


def test_a_non_require_call_produces_no_import_edge(tmp_path: Path):
entry = _write(tmp_path / "init.lua", "local M = {}\nfunction M.go() end\nM.go()\n")

result = extract([entry], cache_root=tmp_path)

assert _import_targets(result, "init") == []


def test_bare_require_does_not_suppress_call_extraction(tmp_path: Path):
"""`import_call_types` must not swallow the call node the way imports do."""
entry = _write(
tmp_path / "init.lua",
'require("lazy")\n'
"function helper() end\n"
"function setup()\n helper()\nend\n",
)
module = _write(tmp_path / "lazy.lua", "-- module\n")

result = extract([entry, module], cache_root=tmp_path)

calls = {
(edge["source"], edge["target"])
for edge in result["edges"]
if edge["relation"] == "calls"
}
assert ("init_setup", "init_helper") in calls
assert _import_targets(result, "init") == ["lazy"]


def test_bare_require_is_reported_once(tmp_path: Path):
"""The module-level walk and the call walk must not both emit the edge."""
entry = _write(tmp_path / "init.lua", 'require("lazy")\n')
module = _write(tmp_path / "lazy.lua", "-- module\n")

result = extract([entry, module], cache_root=tmp_path)

assert len(_import_targets(result, "init")) == 1