diff --git a/CHANGELOG.md b/CHANGELOG.md index 13ea23f13..524ea4444 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ Full release notes with details on each version: [GitHub Releases](https://githu ## 0.9.13 (unreleased) +- Fix: Ruby `.rake` files are now extracted and participate in Ruby cross-file resolution like `.rb` (#1784, thanks @krishnateja7). `.rake` is plain Ruby but the extension was gated out of seven places (classification, extractor dispatch, the language-name/family maps, the `ruby_member_calls` resolver's suffix set, both `.rb`-suffix filters in `ruby_resolution.py`, and the build repo-tag map), so every rake task was skipped and its calls were invisible. All seven now include `.rake`; `Widget.tally` from a `.rake` task resolves to its `.rb` definition. + - Fix: cross-module references to a function now resolve to its definition instead of dangling on a name-only stub (#1781, thanks @EmilNyg). `_rewire_unique_stub_nodes` gated merge targets through `_is_type_like_definition`, which rejects any label ending in `)` — so function/method defs could never absorb their reference stubs, and "who references this function" returned nothing on the definition node while a sourceless stub held all the edges. Top-level function defs are now eligible rewire targets when the label match is globally unique, gated by a language-family match with the referrers (a Python `get_db` reference can't bind to a unique Go `get_db()`) and excluding stubs used as a supertype (`inherits`/`implements`/`extends` — you don't inherit from a function). Types are unchanged. - Fix: live PostgreSQL introspection (`--postgres`) now emits foreign-key `references` edges under a read-only role (#1746, thanks @rithyKabir). The FK query read `information_schema.referential_constraints`, which is privilege-filtered — a role with only SELECT sees zero FK rows while tables/views/routines still appear, so every `references` edge silently vanished. It now reads the world-readable `pg_catalog.pg_constraint` (keyed by oid, which also fixes same-named constraints on sibling tables cross-matching in the old name-based joins), preserving composite-FK column order via `UNNEST ... WITH ORDINALITY`. diff --git a/graphify/analyze.py b/graphify/analyze.py index 0d4bbe4c9..6babbc030 100644 --- a/graphify/analyze.py +++ b/graphify/analyze.py @@ -28,7 +28,7 @@ _LANG_FAMILY: dict[str, str] = { **{e: "rust" for e in (".rs",)}, **{e: "jvm" for e in (".java", ".kt", ".kts", ".scala")}, **{e: "c" for e in (".c", ".h", ".cpp", ".cc", ".cxx", ".hpp")}, - **{e: "ruby" for e in (".rb",)}, + **{e: "ruby" for e in (".rb", ".rake")}, **{e: "swift" for e in (".swift",)}, **{e: "dotnet" for e in (".cs",)}, **{e: "php" for e in (".php",)}, diff --git a/graphify/build.py b/graphify/build.py index 1686d196e..66221b550 100644 --- a/graphify/build.py +++ b/graphify/build.py @@ -49,7 +49,7 @@ _EDGE_LANG_FAMILY: dict[str, str] = { ".c": "c", ".h": "c", ".cc": "c", ".cpp": "c", ".hpp": "c", ".cxx": "c", ".hh": "c", ".hxx": "c", ".cu": "c", ".cuh": "c", ".metal": "c", ".m": "c", ".mm": "c", - ".rb": "rb", ".php": "php", ".cs": "cs", ".swift": "swift", ".lua": "lua", + ".rb": "rb", ".rake": "rb", ".php": "php", ".cs": "cs", ".swift": "swift", ".lua": "lua", } diff --git a/graphify/detect.py b/graphify/detect.py index 8c0f64a8f..84dd5e1b6 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -27,7 +27,7 @@ class FileType(str, Enum): _MANIFEST_PATH = str(out_path("manifest.json")) -CODE_EXTENSIONS = {'.py', '.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.ejs', '.ets', '.go', '.rs', '.java', '.groovy', '.gradle', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.cu', '.cuh', '.metal', '.rb', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.luau', '.toc', '.zig', '.ps1', '.psm1', '.psd1', '.ex', '.exs', '.m', '.mm', '.jl', '.vue', '.svelte', '.astro', '.dart', '.v', '.sv', '.svh', '.sql', '.r', '.f', '.F', '.f90', '.F90', '.f95', '.F95', '.f03', '.F03', '.f08', '.F08', '.pas', '.pp', '.dpr', '.dpk', '.lpr', '.inc', '.dfm', '.lfm', '.lpk', '.sh', '.bash', '.json', '.tf', '.tfvars', '.hcl', '.dm', '.dme', '.dmi', '.dmm', '.dmf', '.sln', '.slnx', '.csproj', '.fsproj', '.vbproj', '.xaml', '.razor', '.cshtml', '.cls', '.trigger'} +CODE_EXTENSIONS = {'.py', '.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.ejs', '.ets', '.go', '.rs', '.java', '.groovy', '.gradle', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.cu', '.cuh', '.metal', '.rb', '.rake', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.luau', '.toc', '.zig', '.ps1', '.psm1', '.psd1', '.ex', '.exs', '.m', '.mm', '.jl', '.vue', '.svelte', '.astro', '.dart', '.v', '.sv', '.svh', '.sql', '.r', '.f', '.F', '.f90', '.F90', '.f95', '.F95', '.f03', '.F03', '.f08', '.F08', '.pas', '.pp', '.dpr', '.dpk', '.lpr', '.inc', '.dfm', '.lfm', '.lpk', '.sh', '.bash', '.json', '.tf', '.tfvars', '.hcl', '.dm', '.dme', '.dmi', '.dmm', '.dmf', '.sln', '.slnx', '.csproj', '.fsproj', '.vbproj', '.xaml', '.razor', '.cshtml', '.cls', '.trigger'} DOC_EXTENSIONS = {'.md', '.mdx', '.qmd', '.txt', '.rst', '.html', '.yaml', '.yml'} PAPER_EXTENSIONS = {'.pdf'} IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'} diff --git a/graphify/extract.py b/graphify/extract.py index c88d17154..59d4f8283 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -1778,7 +1778,7 @@ _LANG_FAMILY_BY_EXT: dict[str, str] = { ".py": "python", ".go": "go", ".rs": "rust", - ".rb": "ruby", + ".rb": "ruby", ".rake": "ruby", ".php": "php", ".phtml": "php", ".php3": "php", ".php4": "php", ".php5": "php", ".php7": "php", ".phps": "php", ".cs": "dotnet", ".razor": "dotnet", ".cshtml": "dotnet", ".xaml": "dotnet", @@ -2757,7 +2757,7 @@ register_language_resolver( # Ruby type-aware member-call resolution (Class.new + typed var.method). Lives in # graphify.ruby_resolution; registered here as a second consumer of the framework. register_language_resolver( - LanguageResolver("ruby_member_calls", frozenset({".rb"}), resolve_ruby_member_calls) + LanguageResolver("ruby_member_calls", frozenset({".rb", ".rake"}), resolve_ruby_member_calls) ) register_language_resolver( LanguageResolver("typescript_member_calls", frozenset({".ts", ".tsx", ".mts", ".cts", ".js", ".jsx"}), _resolve_typescript_member_calls) @@ -3813,7 +3813,7 @@ _DISPATCH: dict[str, Any] = { ".cu": extract_cpp, ".cuh": extract_cpp, ".metal": extract_cpp, - ".rb": extract_ruby, + ".rb": extract_ruby, ".rake": extract_ruby, ".cs": extract_csharp, ".kt": extract_kotlin, ".kts": extract_kotlin, diff --git a/graphify/ruby_resolution.py b/graphify/ruby_resolution.py index e344175e1..c7ed67e30 100644 --- a/graphify/ruby_resolution.py +++ b/graphify/ruby_resolution.py @@ -44,7 +44,7 @@ def _ruby_raw_calls(per_file: list[dict]) -> list[dict]: if not isinstance(rc, dict): continue sf = str(rc.get("source_file", "")) - if sf.endswith(".rb"): + if sf.endswith((".rb", ".rake")): calls.append(rc) return calls @@ -83,7 +83,7 @@ def resolve_ruby_member_calls( for n in all_nodes: nid = n.get("id") sf = str(n.get("source_file", "")) - if nid and sf.endswith(".rb") and _BARE_CONST_RE.match(str(n.get("label", ""))): + if nid and sf.endswith((".rb", ".rake")) and _BARE_CONST_RE.match(str(n.get("label", ""))): class_def_nids.setdefault(_key(n.get("label", "")), []).append(str(nid)) for k in list(class_def_nids): class_def_nids[k] = sorted(set(class_def_nids[k])) diff --git a/tests/test_ruby_resolution.py b/tests/test_ruby_resolution.py index 5cd1b1150..7d402524b 100644 --- a/tests/test_ruby_resolution.py +++ b/tests/test_ruby_resolution.py @@ -348,3 +348,21 @@ def test_mixin_is_not_emitted_as_calls_edge(tmp_path: Path) -> None: for e in g["edges"] if e.get("relation") == "calls"} assert ("K", "C") not in calls assert ("K", "C") in _mixes_in(g) + + +def test_rake_files_extract_and_resolve_like_rb(tmp_path): + """#1784: `.rake` files are plain Ruby and must route to the Ruby extractor + and participate in Ruby cross-file resolution exactly like `.rb`.""" + rake = _write(tmp_path, "ops.rake", + "class RakeHelper\n def self.run\n Widget.tally\n end\nend\n") + rb = _write(tmp_path, "widget.rb", + "class Widget\n def self.tally\n 42\n end\nend\n") + result = extract([rake, rb], cache_root=tmp_path / ".cache") + label = {n["id"]: n.get("label") for n in result["nodes"]} + labels = set(label.values()) + # the .rake file's symbols are extracted + assert "RakeHelper" in labels and ".run()" in labels + # and the cross-file member call resolves .rake -> .rb + calls = {(label.get(e["source"]), label.get(e["target"])) + for e in result["edges"] if e["relation"] == "calls"} + assert (".run()", ".tally()") in calls