From 6e5c199998f824b43a3f1f86e4e2fdfcc6498acc Mon Sep 17 00:00:00 2001 From: CyanoTex Date: Mon, 20 Apr 2026 20:28:06 +0200 Subject: [PATCH 1/4] feat(luau): add Luau/Roblox language support (#52) Adds a `luau` tier covering `.luau` and `.lua` files: LanguageSpec, LanguageTag, tree-sitter-luau grammar wiring, .scm captures for function/type/require, a dedicated import resolver, and unit tests. The resolver handles `require("rel/path")` and `script` / `script.Parent` relative instance paths. `game....` absolute instance paths currently register as external nodes; full Rojo-aware resolution via `default.project.json` is deferred to a follow-up and pinned by an `xfail` test documenting the expected end state. Replaces the existing git-blame-only `lua` spec at registry.py rather than leaving two sources of truth for the same extension set. Refs: https://github.com/repowise-dev/repowise/issues/52 Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/LANGUAGE_SUPPORT.md | 15 +- .../core/ingestion/languages/registry.py | 42 ++++- .../src/repowise/core/ingestion/models.py | 1 + .../src/repowise/core/ingestion/parser.py | 12 ++ .../repowise/core/ingestion/queries/luau.scm | 47 ++++++ .../core/ingestion/resolvers/__init__.py | 2 + .../repowise/core/ingestion/resolvers/luau.py | 148 ++++++++++++++++++ pyproject.toml | 1 + tests/unit/ingestion/test_luau_resolver.py | 87 ++++++++++ tests/unit/ingestion/test_parser.py | 39 ++++- uv.lock | 25 +++ 11 files changed, 413 insertions(+), 6 deletions(-) create mode 100644 packages/core/src/repowise/core/ingestion/queries/luau.scm create mode 100644 packages/core/src/repowise/core/ingestion/resolvers/luau.py create mode 100644 tests/unit/ingestion/test_luau_resolver.py diff --git a/docs/LANGUAGE_SUPPORT.md b/docs/LANGUAGE_SUPPORT.md index a3a9ebb6..6f8457cc 100644 --- a/docs/LANGUAGE_SUPPORT.md +++ b/docs/LANGUAGE_SUPPORT.md @@ -88,13 +88,26 @@ endpoints or targets where applicable. | **SQL** | `.sql` | -- | | **Shell** | `.sh` `.bash` `.zsh` | -- | +### Partial (Luau — Roblox) + +| Language | Extensions | Entry Points | Import Style | +|----------|-----------|-------------|-------------| +| **Luau** | `.luau` `.lua` | `init.luau` `init.lua` | `require(script.Parent.X)` / `require(script.X)` / `require(game.Service.Path)` / `require("rel/path")` | + +AST parsing, symbol extraction (functions, Luau type aliases), and +`require(...)` call capture are wired. Import resolution handles string +literals and `script`/`script.Parent` relative instance paths. Absolute +Roblox instance paths (`game....`) currently register as external +nodes and are the target of a follow-up that reads Rojo's +`default.project.json` tree mapping — see issue #52. + ### Git-Blame-Only These languages are tracked in git history (blame, hotspot analysis, co-change detection) but have no AST parsing or dedicated support. Files appear in the wiki as traversal-level entries. -Objective-C, Elixir, Erlang, Lua, R, Dart, Zig, Julia, Clojure, Elm, +Objective-C, Elixir, Erlang, R, Dart, Zig, Julia, Clojure, Elm, Haskell, OCaml, F#, Crystal, Nim, D --- diff --git a/packages/core/src/repowise/core/ingestion/languages/registry.py b/packages/core/src/repowise/core/ingestion/languages/registry.py index 56db45e3..933b904b 100644 --- a/packages/core/src/repowise/core/ingestion/languages/registry.py +++ b/packages/core/src/repowise/core/ingestion/languages/registry.py @@ -974,10 +974,44 @@ is_passthrough=True, ), LanguageSpec( - tag="lua", - display_name="Lua", - extensions=frozenset({".lua"}), - is_passthrough=True, + tag="luau", + display_name="Luau", + # Rojo treats both .lua and .luau as Luau modules. Luau's grammar is + # a superset of Lua 5.1, so vanilla Lua files parse cleanly too. + extensions=frozenset({".lua", ".luau"}), + grammar_package="tree_sitter_luau", + scm_file="luau.scm", + heritage_node_types=frozenset(), + entry_point_patterns=("init.luau", "init.lua"), + manifest_files=("default.project.json", "wally.toml", ".rojo.json"), + blocked_dirs=("Packages", "ServerPackages", "DevPackages"), + builtin_calls=frozenset( + { + "print", + "warn", + "error", + "assert", + "pcall", + "xpcall", + "select", + "type", + "typeof", + "tonumber", + "tostring", + "ipairs", + "pairs", + "next", + "rawget", + "rawset", + "rawequal", + "rawlen", + "setmetatable", + "getmetatable", + "unpack", + "require", + } + ), + color_hex="#00A2FF", ), LanguageSpec( tag="r", diff --git a/packages/core/src/repowise/core/ingestion/models.py b/packages/core/src/repowise/core/ingestion/models.py index 71691d14..ca7dc242 100644 --- a/packages/core/src/repowise/core/ingestion/models.py +++ b/packages/core/src/repowise/core/ingestion/models.py @@ -33,6 +33,7 @@ "swift", "kotlin", "scala", + "luau", "shell", "yaml", "json", diff --git a/packages/core/src/repowise/core/ingestion/parser.py b/packages/core/src/repowise/core/ingestion/parser.py index deb214d8..fde0f2fa 100644 --- a/packages/core/src/repowise/core/ingestion/parser.py +++ b/packages/core/src/repowise/core/ingestion/parser.py @@ -413,6 +413,18 @@ class LanguageConfig: ), entry_point_patterns=["index.php", "public/index.php"], ), + "luau": LanguageConfig( + symbol_node_types={ + "function_declaration": "function", + "type_definition": "type_alias", + }, + import_node_types=["function_call"], + export_node_types=[], + visibility_fn=public_by_default, + parent_extraction="none", + parent_class_types=frozenset(), + entry_point_patterns=["init.luau", "init.lua"], + ), } diff --git a/packages/core/src/repowise/core/ingestion/queries/luau.scm b/packages/core/src/repowise/core/ingestion/queries/luau.scm new file mode 100644 index 00000000..e7170382 --- /dev/null +++ b/packages/core/src/repowise/core/ingestion/queries/luau.scm @@ -0,0 +1,47 @@ +; ============================================================================= +; repowise — Luau symbol and import queries +; tree-sitter-luau (install separately if needed) +; +; Luau is a gradually-typed superset of Lua 5.1 used by the Roblox engine. +; Rojo maps filesystem layout to Roblox instance paths via default.project.json; +; `require()` accepts instance paths such as `script.Parent.Foo`, +; `script.Foo`, or `game.ReplicatedStorage.Shared.Foo`. +; +; Full Rojo-aware import resolution lives in resolvers/luau.py; this file +; only emits symbol defs and the raw require-argument text as @import.module. +; ============================================================================= + +; Global function: function foo.bar.baz() end +(function_declaration + name: (_) @symbol.name +) @symbol.def + +; Type alias: type Foo = ... (Luau-specific; Lua 5.1 has no `type`) +(type_definition + (identifier) @symbol.name +) @symbol.def + +; --------------------------------------------------------------------------- +; Imports — captured as raw argument text for the resolver to parse. +; +; Matches: +; require("some/string/path") +; require(script.Parent.Foo) +; require(game.ReplicatedStorage.Shared.Foo) +; +; The resolver is responsible for splitting out the instance path, consulting +; Rojo's default.project.json tree, and producing a filesystem path. +; --------------------------------------------------------------------------- +(function_call + (identifier) @_require_name + (arguments (_) @import.module) + (#eq? @_require_name "require") +) @import.statement + +; --------------------------------------------------------------------------- +; Calls +; --------------------------------------------------------------------------- +(function_call + (identifier) @call.target + (arguments) @call.arguments +) @call.site diff --git a/packages/core/src/repowise/core/ingestion/resolvers/__init__.py b/packages/core/src/repowise/core/ingestion/resolvers/__init__.py index 462492e1..142b7247 100644 --- a/packages/core/src/repowise/core/ingestion/resolvers/__init__.py +++ b/packages/core/src/repowise/core/ingestion/resolvers/__init__.py @@ -10,6 +10,7 @@ from .generic import resolve_generic_import from .go import resolve_go_import from .kotlin import resolve_kotlin_import +from .luau import resolve_luau_import from .php import resolve_php_import from .python import resolve_python_import from .ruby import resolve_ruby_import @@ -29,6 +30,7 @@ "cpp": resolve_cpp_import, "c": resolve_cpp_import, "kotlin": resolve_kotlin_import, + "luau": resolve_luau_import, "ruby": resolve_ruby_import, "csharp": resolve_csharp_import, "swift": resolve_swift_import, diff --git a/packages/core/src/repowise/core/ingestion/resolvers/luau.py b/packages/core/src/repowise/core/ingestion/resolvers/luau.py new file mode 100644 index 00000000..9a5d1cf4 --- /dev/null +++ b/packages/core/src/repowise/core/ingestion/resolvers/luau.py @@ -0,0 +1,148 @@ +"""Luau import resolution. + +Luau's ``require(...)`` accepts three kinds of argument: + +1. String literals — e.g. ``require("some/path")`` (Lemur / plain Lua style). +2. Relative instance paths — ``require(script.Parent.Foo)`` or + ``require(script.Foo)``. +3. Absolute Roblox instance paths — ``require(game.ReplicatedStorage.Foo)``, + where the leading service is resolved against a Rojo project's ``tree`` + mapping in ``default.project.json``. + +This resolver handles (1) and (2) directly. (3) requires reading the Rojo +project JSON to map a service subtree (e.g. ``ReplicatedStorage.Shared``) back +to a filesystem directory; that will be layered in via +``core/ingestion/dynamic_hints/rojo.py`` in a follow-up (issue #52). + +Unresolved paths are intentionally *not* silently matched by filename — a +wrong edge is worse than no edge when the downstream graph feeds docs and +dead-code detection. They fall through to ``add_external_node`` so they +still appear in the graph as external references. +""" + +from __future__ import annotations + +import re +from pathlib import PurePosixPath + +from .context import ResolverContext + +# `script.Parent.Foo.Bar` / `script.Foo` — capture everything after the leading +# `script` so we can walk up/down from the importer. +_SCRIPT_RELATIVE = re.compile(r"^\s*script\s*((?:\.\s*\w+\s*)+)\s*$") + +# `game.....` — capture the service and the remainder. +_GAME_ABSOLUTE = re.compile(r"^\s*game\s*\.\s*(\w+)\s*((?:\.\s*\w+\s*)*)$") + +_LUAU_SUFFIXES: tuple[str, ...] = (".luau", ".lua") + + +def resolve_luau_import( + module_path: str, + importer_path: str, + ctx: ResolverContext, +) -> str | None: + """Resolve a Luau ``require(...)`` argument to a repo-relative file path. + + ``module_path`` is the raw argument text captured by ``luau.scm`` — it may + be a string literal (with surrounding quotes) or a Luau expression such as + ``script.Parent.Foo``. + """ + arg = module_path.strip() + + # String literal: require("some/path") + if (arg.startswith('"') and arg.endswith('"')) or (arg.startswith("'") and arg.endswith("'")): + literal = arg[1:-1] + resolved = _resolve_literal(literal, importer_path, ctx) + if resolved is not None: + return resolved + return ctx.add_external_node(literal) + + # Relative: script[.Parent]*.Name[.Name]* + m = _SCRIPT_RELATIVE.match(arg) + if m: + parts = [p.strip() for p in m.group(1).split(".") if p.strip()] + resolved = _resolve_script_relative(parts, importer_path, ctx) + if resolved is not None: + return resolved + return ctx.add_external_node(arg) + + # Absolute: game..Path... + # Full Rojo-tree resolution is out of scope for this skeleton PR — fall + # through to an external node so the graph still records the reference. + m = _GAME_ABSOLUTE.match(arg) + if m: + return ctx.add_external_node(arg) + + # Unknown expression shape — record as external, don't guess. + return ctx.add_external_node(arg) + + +def _resolve_literal(literal: str, importer_path: str, ctx: ResolverContext) -> str | None: + """Resolve a plain string require — relative or stem match.""" + importer_dir = PurePosixPath(importer_path).parent + candidate = (importer_dir / literal).as_posix() + for suffix in _LUAU_SUFFIXES: + full = f"{candidate}{suffix}" + if full in ctx.path_set: + return full + if literal in ctx.path_set: + return literal + + stem = PurePosixPath(literal).stem.lower().replace("-", "_") + result = ctx.stem_lookup(stem) + if result and any(result.endswith(s) for s in _LUAU_SUFFIXES): + return result + return None + + +def _resolve_script_relative( + parts: list[str], importer_path: str, ctx: ResolverContext +) -> str | None: + """Walk ``Parent``/name segments relative to the importing file. + + Roblox semantics: ``script`` is the importing module instance; its + ``script.Parent`` is the *container* that holds it. For Rojo-synced + code, a ``.luau``/``.lua`` file lives inside its container directory, + so ``script.Parent`` is that directory. This means the *first* + ``Parent`` segment is an identity (we're already there); each + subsequent ``Parent`` walks one more level up. + + After the leading ``Parent`` run, any remaining identifiers descend + into child instances by name. The terminal segment resolves to either + ``.luau``/``.lua`` or a directory with + ``init.luau``/``init.lua``. + """ + here = PurePosixPath(importer_path).parent + i = 0 + # First "Parent" is a no-op — `here` already represents script.Parent. + if i < len(parts) and parts[i] == "Parent": + i += 1 + # Each subsequent "Parent" walks up one level. + while i < len(parts) and parts[i] == "Parent": + here = here.parent + i += 1 + + remainder = parts[i:] + if not remainder: + return None + + base = here + for seg in remainder[:-1]: + base = base / seg + + name = remainder[-1] + + # Module-as-file: /.luau|.lua + for suffix in _LUAU_SUFFIXES: + candidate = (base / f"{name}{suffix}").as_posix() + if candidate in ctx.path_set: + return candidate + + # Module-as-directory: //init.luau|.lua + for suffix in _LUAU_SUFFIXES: + candidate = (base / name / f"init{suffix}").as_posix() + if candidate in ctx.path_set: + return candidate + + return None diff --git a/pyproject.toml b/pyproject.toml index 61a85af7..8be3e356 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,7 @@ dependencies = [ "tree-sitter-swift>=0.0.1", "tree-sitter-scala>=0.23,<1", "tree-sitter-php>=0.23,<1", + "tree-sitter-luau>=1.2,<2", # Dependency graph "networkx>=3.3,<4", "scipy>=1.11,<2", diff --git a/tests/unit/ingestion/test_luau_resolver.py b/tests/unit/ingestion/test_luau_resolver.py new file mode 100644 index 00000000..310d0759 --- /dev/null +++ b/tests/unit/ingestion/test_luau_resolver.py @@ -0,0 +1,87 @@ +"""Unit tests for the Luau import resolver. + +Covers the two resolution modes implemented in this PR: + +- String-literal requires (``require("relative/path")``) +- ``script`` / ``script.Parent`` relative instance paths + +The ``game..Path`` absolute form requires Rojo project JSON and is +explicitly deferred to a follow-up (issue #52); see the xfail case. +""" + +from __future__ import annotations + +import networkx as nx +import pytest + +from repowise.core.ingestion.resolvers.context import ResolverContext +from repowise.core.ingestion.resolvers.luau import resolve_luau_import + + +def _ctx(paths: set[str]) -> ResolverContext: + stem_map: dict[str, list[str]] = {} + for p in paths: + stem = p.rsplit("/", 1)[-1].rsplit(".", 1)[0].lower() + stem_map.setdefault(stem, []).append(p) + return ResolverContext( + path_set=paths, + stem_map=stem_map, + graph=nx.DiGraph(), + ) + + +class TestScriptRelative: + def test_sibling_via_parent(self) -> None: + ctx = _ctx({"src/shared/Signal.luau", "src/client/main.luau"}) + got = resolve_luau_import("script.Parent.Signal", "src/client/main.luau", ctx) + # script.Parent == src/client, so the sibling is src/client/Signal.luau + # -- this case has no match. script.Parent.Parent.shared.Signal would. + # The resolver should return external rather than wrong-file match. + assert got == "external:script.Parent.Signal" + + def test_child_module(self) -> None: + ctx = _ctx({"src/shared/util/init.luau", "src/shared/util/Signal.luau"}) + got = resolve_luau_import("script.Signal", "src/shared/util/init.luau", ctx) + assert got == "src/shared/util/Signal.luau" + + def test_parent_walks_up(self) -> None: + ctx = _ctx({"src/shared/Signal.luau", "src/client/controllers/main.luau"}) + got = resolve_luau_import( + "script.Parent.Parent.Parent.shared.Signal", + "src/client/controllers/main.luau", + ctx, + ) + assert got == "src/shared/Signal.luau" + + def test_module_as_directory(self) -> None: + ctx = _ctx({"src/shared/util/init.lua", "src/shared/main.luau"}) + got = resolve_luau_import("script.Parent.util", "src/shared/main.luau", ctx) + assert got == "src/shared/util/init.lua" + + +class TestStringLiteral: + def test_relative_string(self) -> None: + ctx = _ctx({"src/shared/helper.luau", "src/shared/main.luau"}) + got = resolve_luau_import('"./helper"', "src/shared/main.luau", ctx) + assert got == "src/shared/helper.luau" + + def test_unresolved_string_goes_external(self) -> None: + ctx = _ctx(set()) + got = resolve_luau_import('"nowhere"', "src/a.luau", ctx) + assert got == "external:nowhere" + + +class TestAbsoluteInstancePath: + @pytest.mark.xfail( + reason="Rojo default.project.json-aware resolution — issue #52 follow-up.", + strict=True, + ) + def test_game_replicated_storage_resolves_via_rojo_tree(self) -> None: + """Expected end state: given a Rojo project whose tree maps + ``ReplicatedStorage.Shared`` to ``src/shared``, a require of + ``game.ReplicatedStorage.Shared.Util`` resolves to + ``src/shared/Util.luau``. + """ + ctx = _ctx({"src/shared/Util.luau"}) + got = resolve_luau_import("game.ReplicatedStorage.Shared.Util", "src/client/main.luau", ctx) + assert got == "src/shared/Util.luau" diff --git a/tests/unit/ingestion/test_parser.py b/tests/unit/ingestion/test_parser.py index 3462b8d0..60b8b096 100644 --- a/tests/unit/ingestion/test_parser.py +++ b/tests/unit/ingestion/test_parser.py @@ -1011,12 +1011,49 @@ def test_no_parse_errors(self, parser: ASTParser) -> None: assert result.parse_errors == [] +# --------------------------------------------------------------------------- +# Luau (issue #52 — Roblox/Rojo support) +# --------------------------------------------------------------------------- + +LUAU_SOURCE = b"""-- Module docstring placeholder. + +local Signal = require(script.Parent.Signal) +local Shared = require(game.ReplicatedStorage.Shared.Util) + +type Callback = (value: number) -> () + +function greet(name: string): string + return "hello " .. name +end + +function Calculator:add(x: number, y: number): number + return x + y +end +""" + + +class TestLuauParser: + def test_finds_top_level_function(self, parser: ASTParser) -> None: + fi = _make_file_info("luau_pkg/init.luau", "luau") + result = parser.parse_file(fi, LUAU_SOURCE) + names = [s.name for s in result.symbols] + assert "greet" in names + + def test_parses_require_imports(self, parser: ASTParser) -> None: + fi = _make_file_info("luau_pkg/init.luau", "luau") + result = parser.parse_file(fi, LUAU_SOURCE) + raw = [i.module_path for i in result.imports] + # The .scm emits the raw argument text; the resolver interprets it. + assert any("Signal" in m for m in raw) + assert any("ReplicatedStorage" in m for m in raw) + + class TestLanguageConfigs: def test_all_supported_languages_have_config(self) -> None: expected = { "python", "typescript", "javascript", "go", "rust", "java", "cpp", "c", "kotlin", "ruby", "csharp", "swift", - "scala", "php", + "scala", "php", "luau", } for lang in expected: assert lang in LANGUAGE_CONFIGS, f"Missing config for {lang}" diff --git a/uv.lock b/uv.lock index e4bb592d..9c61fcb5 100644 --- a/uv.lock +++ b/uv.lock @@ -3044,6 +3044,7 @@ dependencies = [ { name = "tree-sitter-java" }, { name = "tree-sitter-javascript" }, { name = "tree-sitter-kotlin" }, + { name = "tree-sitter-luau" }, { name = "tree-sitter-php" }, { name = "tree-sitter-python" }, { name = "tree-sitter-ruby" }, @@ -3132,6 +3133,7 @@ requires-dist = [ { name = "tree-sitter-java", specifier = ">=0.23,<1" }, { name = "tree-sitter-javascript", specifier = ">=0.23,<1" }, { name = "tree-sitter-kotlin", specifier = ">=1,<2" }, + { name = "tree-sitter-luau", specifier = ">=1.2,<2" }, { name = "tree-sitter-php", specifier = ">=0.23,<1" }, { name = "tree-sitter-python", specifier = ">=0.23,<1" }, { name = "tree-sitter-ruby", specifier = ">=0.23,<1" }, @@ -3906,6 +3908,14 @@ version = "0.23.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/22/85/a61c782afbb706a47d990eaee6977e7c2bd013771c5bf5c81c617684f286/tree_sitter_c_sharp-0.23.1.tar.gz", hash = "sha256:322e2cfd3a547a840375276b2aea3335fa6458aeac082f6c60fec3f745c967eb", size = 1317728, upload-time = "2024-11-11T05:25:32.535Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/dc/d4a0ad9e466263728f80f9dac399609473af01c1aba2ea3ea8879ce56276/tree_sitter_c_sharp-0.23.1-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:e87be7572991552606a3155d2f6c2045ded8bce94bfd9f74bf521d949c219a1c", size = 333661, upload-time = "2026-04-14T15:11:14.227Z" }, + { url = "https://files.pythonhosted.org/packages/61/7a/5c862770460a2e27079e725585ad2718100373c09448c14e36934ef44414/tree_sitter_c_sharp-0.23.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:86c2fdf178c66474a1be2965602818d30780e4e3ed890e3c206931f65d9a154c", size = 376295, upload-time = "2026-04-14T15:11:15.346Z" }, + { url = "https://files.pythonhosted.org/packages/67/18/0571a3a34c0feda60a9c37cf6dd5edfdbc24f8fcb1e48b6b6eb0f324ad2a/tree_sitter_c_sharp-0.23.1-cp310-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:035d259e64c41d02cc45afc3b8b46388b232e7d16d84734d851cca7334761da5", size = 358331, upload-time = "2026-04-14T15:11:16.418Z" }, + { url = "https://files.pythonhosted.org/packages/44/65/0f7e1f50f6365338eb700f01710da0adc49a49fa9a8443e5a90ea4f29491/tree_sitter_c_sharp-0.23.1-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fa472cb9de7e14fee9408e144f29f68384cd8e9c677dff0002da19f361a59bdf", size = 359444, upload-time = "2026-04-14T15:11:17.509Z" }, + { url = "https://files.pythonhosted.org/packages/98/60/129bd56d5ef22b4ae254940a09b6d3ed873093218868a3f9635d571d514e/tree_sitter_c_sharp-0.23.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1a0ea86eccff74e85ab4a2cf77c813fad7c84162962ce242dff0c51601028832", size = 358143, upload-time = "2026-04-14T15:11:18.755Z" }, + { url = "https://files.pythonhosted.org/packages/7c/cd/e12cdca47e0c56151cb4b156d48091b7bc1d968e072c1656cf6b73fe7218/tree_sitter_c_sharp-0.23.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8ab26dc998bbd4b4287b129f67c10ca715deb402ed77d0645674490ea509097e", size = 357524, upload-time = "2026-04-14T15:11:19.717Z" }, + { url = "https://files.pythonhosted.org/packages/6a/2c/f742d60f818cba83760f4975c7158d1c96c36b5807e95a843db7fb8c64b7/tree_sitter_c_sharp-0.23.1-cp310-abi3-win_amd64.whl", hash = "sha256:d4486653feaff3314ef45534dcb6f9ea8ab3aa160896287c6473788f88eb38be", size = 338755, upload-time = "2026-04-14T15:11:20.883Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e4/8a8642b9bba86248ac2facc81ffb187c06c6768efa56c79d61fab70d736b/tree_sitter_c_sharp-0.23.1-cp310-abi3-win_arm64.whl", hash = "sha256:e7a14b76ec23cc8386cf662d5ea602d81331376c93ca6299a97b174047790345", size = 337261, upload-time = "2026-04-14T15:11:22.111Z" }, { url = "https://files.pythonhosted.org/packages/58/04/f6c2df4c53a588ccd88d50851155945cff8cd887bd70c175e00aaade7edf/tree_sitter_c_sharp-0.23.1-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2b612a6e5bd17bb7fa2aab4bb6fc1fba45c94f09cb034ab332e45603b86e32fd", size = 372235, upload-time = "2024-11-11T05:25:19.424Z" }, { url = "https://files.pythonhosted.org/packages/99/10/1aa9486f1e28fc22810fa92cbdc54e1051e7f5536a5e5b5e9695f609b31e/tree_sitter_c_sharp-0.23.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a8b98f62bc53efcd4d971151950c9b9cd5cbe3bacdb0cd69fdccac63350d83e", size = 419046, upload-time = "2024-11-11T05:25:20.679Z" }, { url = "https://files.pythonhosted.org/packages/0f/21/13df29f8fcb9ba9f209b7b413a4764b673dfd58989a0dd67e9c7e19e9c2e/tree_sitter_c_sharp-0.23.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:986e93d845a438ec3c4416401aa98e6a6f6631d644bbbc2e43fcb915c51d255d", size = 415999, upload-time = "2024-11-11T05:25:22.359Z" }, @@ -3992,6 +4002,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/b9/12fa97f63d2b7517c6f5d16938f0c5bfe84d925c652c75ff1c5e29bf6a44/tree_sitter_kotlin-1.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:e030f127a7d07952907adb9070248bd42fb86dc76fd92744727551b50e131ee7", size = 310414, upload-time = "2025-01-09T19:02:16.23Z" }, ] +[[package]] +name = "tree-sitter-luau" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/21/f7e870dd4d6357f5eea5a473a327c535951b5f32c408939b247cbd8ad388/tree_sitter_luau-1.2.0.tar.gz", hash = "sha256:025f473379a2895f17bc4d24325b3f3e5c90ed908f6642d15e78edb494a60486", size = 46995, upload-time = "2024-12-22T22:42:47.581Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/c4/9e723f69ec3698aebe241ca89b07a8751c35f8713c56e448c4abcc11c6b8/tree_sitter_luau-1.2.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:c365dd21a8056046378c6a006cf7149291facb143ca9e00bc51c5c1f1fcdc344", size = 30689, upload-time = "2024-12-22T22:42:34.22Z" }, + { url = "https://files.pythonhosted.org/packages/1b/2b/1746da1b669398b86454cb3e99bd5d3aa51a11ac8d3e99ab6f6cb592ae45/tree_sitter_luau-1.2.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:b699edbbe4bbe4b3a9a1da42acb3b4c98c760a6e3b5ceedaf0d44487dc1a26cc", size = 31727, upload-time = "2024-12-22T22:42:36.55Z" }, + { url = "https://files.pythonhosted.org/packages/fc/95/bc169e4f5879e3b37b95d1ee753744e9408912ffde87c409c23e99bcff62/tree_sitter_luau-1.2.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf311778d884ae744571c12344edbcfe0a6351c482a7710c58fedeae4a2c6dfd", size = 53654, upload-time = "2024-12-22T22:42:38.492Z" }, + { url = "https://files.pythonhosted.org/packages/f4/b3/d99f00230812ea2c1c4b53a68de4c38f3c8f7097e898d26cb2e76525d4bb/tree_sitter_luau-1.2.0-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb35eb808d6a51d30aabfa2845bcb1cce7bce63599bdd4c1faba4df5e2dfb4f", size = 52722, upload-time = "2024-12-22T22:42:40.556Z" }, + { url = "https://files.pythonhosted.org/packages/ac/53/33b3ceb6c51757db94a2f12764bef2dfca003593f3c5663d46f5b0bc12bc/tree_sitter_luau-1.2.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8e2a35014902028c312dff6d45439fbdb8255583104a9e1c0f286162527629b2", size = 51127, upload-time = "2024-12-22T22:42:41.68Z" }, + { url = "https://files.pythonhosted.org/packages/8b/7e/5379a5502835f7bf39084cefd6dca6e5b7ed12a9c0ef692a57962e33ea38/tree_sitter_luau-1.2.0-cp39-abi3-win_amd64.whl", hash = "sha256:c8f86b022fe6d1a784e26fa2e898f1459a3f4d31555bed31bf6dda9c72e46f6d", size = 32942, upload-time = "2024-12-22T22:42:43.676Z" }, + { url = "https://files.pythonhosted.org/packages/1a/df/db574405f592f1f4f039dc83db8545d634b68481ce0c457bbab26e155a4a/tree_sitter_luau-1.2.0-cp39-abi3-win_arm64.whl", hash = "sha256:37e9a2e2dd9795800c98c160eabcfed9a90a25a80e2f207658902b0444a40440", size = 31300, upload-time = "2024-12-22T22:42:45.64Z" }, +] + [[package]] name = "tree-sitter-php" version = "0.24.1" From 54b394a8f337649727d01f3ad7a6edf333b28a9d Mon Sep 17 00:00:00 2001 From: CyanoTex Date: Fri, 24 Apr 2026 20:01:34 +0200 Subject: [PATCH 2/4] fix(luau): string-literal requires never entered the resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parser.py:705 normalizes the captured @import.module text with .strip("\"'` ") before handing it to resolve_luau_import, but the resolver's string-literal branch identified literals by checking arg.startswith('"') — so every require("./path") landed on the external-node fallback in production. The unit tests masked this by passing explicitly-quoted strings, which the production parser never emits. Surfaced by running `repowise init --index-only` against the upstream luau-lang/luau repo (per maintainer's request to test against live systems): 146/146 string-literal requires — including 107 instances of require("../bench_support") — went to external when they should have resolved internally. Fix: - Drop the unreachable quote-detection branch. Detect literal paths by elimination after the script./game. regex checks. - Update the test class to pass *unquoted* input mirroring what the parser actually emits. Add cases for ../ relative paths and stem matching so the production handoff is exercised end-to-end. - Document the parser contract in both the resolver and test module docstrings so the next reader doesn't reintroduce the mismatch. The @alias (.luaurc) branch is deferred alongside the existing Rojo follow-up and records the reference as external for now. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../repowise/core/ingestion/resolvers/luau.py | 68 ++++++++++++------- tests/unit/ingestion/test_luau_resolver.py | 36 +++++++++- 2 files changed, 76 insertions(+), 28 deletions(-) diff --git a/packages/core/src/repowise/core/ingestion/resolvers/luau.py b/packages/core/src/repowise/core/ingestion/resolvers/luau.py index 9a5d1cf4..5839b391 100644 --- a/packages/core/src/repowise/core/ingestion/resolvers/luau.py +++ b/packages/core/src/repowise/core/ingestion/resolvers/luau.py @@ -1,23 +1,36 @@ """Luau import resolution. -Luau's ``require(...)`` accepts three kinds of argument: +Luau's ``require(...)`` accepts four kinds of argument: -1. String literals — e.g. ``require("some/path")`` (Lemur / plain Lua style). -2. Relative instance paths — ``require(script.Parent.Foo)`` or - ``require(script.Foo)``. +1. String literals — ``require("./helper")`` or ``require("some/path")`` + (plain Lua style + Luau's new require-by-string). +2. Relative instance paths — ``require(script.Parent.Foo)``, + ``require(script.Foo)``, or the Rojo-safe variant + ``require(script.Parent:WaitForChild("Foo"))``. 3. Absolute Roblox instance paths — ``require(game.ReplicatedStorage.Foo)``, where the leading service is resolved against a Rojo project's ``tree`` mapping in ``default.project.json``. +4. ``.luaurc``-aliased requires — ``require("@dep")``, resolved by reading + ``.luaurc`` files along the directory hierarchy for an ``aliases`` map. -This resolver handles (1) and (2) directly. (3) requires reading the Rojo -project JSON to map a service subtree (e.g. ``ReplicatedStorage.Shared``) back -to a filesystem directory; that will be layered in via -``core/ingestion/dynamic_hints/rojo.py`` in a follow-up (issue #52). +This resolver handles (1) and (2). (3) requires a Rojo ``default.project.json`` +reader layered in via ``core/ingestion/dynamic_hints/rojo.py``; (4) requires a +``.luaurc`` reader analogous to the tsconfig resolver. Both are deferred to +follow-ups — see the xfail tests in ``test_luau_resolver.py``. Unresolved paths are intentionally *not* silently matched by filename — a wrong edge is worse than no edge when the downstream graph feeds docs and dead-code detection. They fall through to ``add_external_node`` so they still appear in the graph as external references. + +Parser contract note +-------------------- +The tree-sitter query in ``queries/luau.scm`` captures the raw argument node; +``parser.py`` then normalizes the captured text with ``.strip("\"'` ")`` +before calling this function. String-literal requires therefore arrive here +*without* their surrounding quotes — e.g. ``require("./helper")`` reaches this +function as ``./helper``, not ``"./helper"``. We identify the literal branch +by process of elimination (doesn't parse as ``script.X`` or ``game.X``). """ from __future__ import annotations @@ -44,21 +57,14 @@ def resolve_luau_import( ) -> str | None: """Resolve a Luau ``require(...)`` argument to a repo-relative file path. - ``module_path`` is the raw argument text captured by ``luau.scm`` — it may - be a string literal (with surrounding quotes) or a Luau expression such as - ``script.Parent.Foo``. + ``module_path`` is the argument text captured by ``luau.scm`` after the + parser's quote-strip pass (see module docstring). It may be a bare + filesystem path (from a string-literal require), an instance-path + expression such as ``script.Parent.Foo``, or an ``@alias`` reference. """ arg = module_path.strip() - # String literal: require("some/path") - if (arg.startswith('"') and arg.endswith('"')) or (arg.startswith("'") and arg.endswith("'")): - literal = arg[1:-1] - resolved = _resolve_literal(literal, importer_path, ctx) - if resolved is not None: - return resolved - return ctx.add_external_node(literal) - - # Relative: script[.Parent]*.Name[.Name]* + # Relative instance path: script[.Parent]*.Name[.Name]* m = _SCRIPT_RELATIVE.match(arg) if m: parts = [p.strip() for p in m.group(1).split(".") if p.strip()] @@ -67,14 +73,24 @@ def resolve_luau_import( return resolved return ctx.add_external_node(arg) - # Absolute: game..Path... - # Full Rojo-tree resolution is out of scope for this skeleton PR — fall - # through to an external node so the graph still records the reference. - m = _GAME_ABSOLUTE.match(arg) - if m: + # Absolute instance path: game..Path... + # Full Rojo-tree resolution is deferred to the Rojo follow-up; fall through + # to an external node so the graph still records the reference. + if _GAME_ABSOLUTE.match(arg): + return ctx.add_external_node(arg) + + # `.luaurc` alias: require("@dep"). Needs a luaurc reader; deferred. + if arg.startswith("@"): return ctx.add_external_node(arg) - # Unknown expression shape — record as external, don't guess. + # Everything else is a string-literal path. The parser has already + # stripped surrounding quotes, so `arg` is e.g. `./helper` or + # `some/path`. `_resolve_literal` handles both relative and stem-match + # resolution; unresolved literals fall through to an external node + # without any silent filename guess. + resolved = _resolve_literal(arg, importer_path, ctx) + if resolved is not None: + return resolved return ctx.add_external_node(arg) diff --git a/tests/unit/ingestion/test_luau_resolver.py b/tests/unit/ingestion/test_luau_resolver.py index 310d0759..52cf24b6 100644 --- a/tests/unit/ingestion/test_luau_resolver.py +++ b/tests/unit/ingestion/test_luau_resolver.py @@ -7,6 +7,15 @@ The ``game..Path`` absolute form requires Rojo project JSON and is explicitly deferred to a follow-up (issue #52); see the xfail case. + +Parser contract +--------------- +The arguments passed to ``resolve_luau_import`` mirror what the production +parser emits: ``parser.py`` strips surrounding quotes from the captured +``@import.module`` text (``.strip("\"'` ")``) before calling the resolver. +String-literal tests therefore pass *unquoted* paths (``"./helper"`` becomes +``./helper``) — passing a quoted string here would not reflect the real +production handoff. """ from __future__ import annotations @@ -60,16 +69,39 @@ def test_module_as_directory(self) -> None: class TestStringLiteral: + # The parser strips quotes at parser.py:705 before the resolver runs, + # so every input below is unquoted — matching production. An earlier + # version of this test class passed *quoted* strings, which masked a + # real bug: the resolver's string-literal branch was unreachable in + # production and every `require("…")` landed on the external fallback. + def test_relative_string(self) -> None: ctx = _ctx({"src/shared/helper.luau", "src/shared/main.luau"}) - got = resolve_luau_import('"./helper"', "src/shared/main.luau", ctx) + got = resolve_luau_import("./helper", "src/shared/main.luau", ctx) + assert got == "src/shared/helper.luau" + + def test_parent_relative_string(self) -> None: + ctx = _ctx({"bench/bench_support.lua", "bench/gc/test_foo.lua"}) + got = resolve_luau_import("../bench_support", "bench/gc/test_foo.lua", ctx) + assert got == "bench/bench_support.lua" + + def test_stem_match_bare_path(self) -> None: + ctx = _ctx({"src/shared/helper.luau", "src/main.luau"}) + got = resolve_luau_import("helper", "src/main.luau", ctx) assert got == "src/shared/helper.luau" def test_unresolved_string_goes_external(self) -> None: ctx = _ctx(set()) - got = resolve_luau_import('"nowhere"', "src/a.luau", ctx) + got = resolve_luau_import("nowhere", "src/a.luau", ctx) assert got == "external:nowhere" + def test_luaurc_alias_is_external_until_follow_up(self) -> None: + # `.luaurc` `@alias` resolution is deferred — see + # TestLuaurcAlias.test_alias_resolves_via_luaurc_follow_up. + ctx = _ctx({"src/dependency.luau"}) + got = resolve_luau_import("@dep", "src/main.luau", ctx) + assert got == "external:@dep" + class TestAbsoluteInstancePath: @pytest.mark.xfail( From 512d6e8e819febd0b27794be3078df823d0378e3 Mon Sep 17 00:00:00 2001 From: CyanoTex Date: Fri, 24 Apr 2026 20:02:56 +0200 Subject: [PATCH 3/4] feat(luau): resolve :WaitForChild / :FindFirstChild require chains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `script.Parent:WaitForChild("Foo")` and `script.X:FindFirstChild("Foo")` idioms are how shipping Rojo code loads modules — they guard against the sync-order race between Rojo's filesystem stream and `require()`. On OSRPS they account for **451 of 485** `require(...)` calls (93%); the bare-dot-chain form that the previous resolver matched accounts for only 13 (2.7%). Everything else fell through to the external-node fallback, leaving the graph's Luau edges almost entirely unresolved. Fix: normalize `:WaitForChild("Name")` / `:FindFirstChild("Name")` into `.Name` before the `_SCRIPT_RELATIVE` regex runs. Both forms have identical name-resolution semantics (child lookup by string on the preceding instance), so the path-walking logic in `_resolve_script_relative` is reused unchanged. Design notes: - `WaitForChild`'s optional second `timeoutSeconds` argument is discarded by the regex — the timeout is runtime behavior and has no bearing on the target module's name. - External-node labels use the *original* pre-normalization text, so the graph shows what was written at the call site rather than a rewritten dot-chain form. - Method chains like `...:WaitForChild("shared"):WaitForChild("Signal")` normalize to `.shared.Signal` and resolve through the same code path. Variable-aliased chains (e.g. `SharedFolder:WaitForChild("X")` where `SharedFolder = ReplicatedStorage:WaitForChild("OSRPS")`) are a separate problem — they need lightweight flow tracking to resolve the alias and are deliberately out of scope here. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../repowise/core/ingestion/resolvers/luau.py | 48 +++++++++++++++---- tests/unit/ingestion/test_luau_resolver.py | 42 ++++++++++++++++ 2 files changed, 81 insertions(+), 9 deletions(-) diff --git a/packages/core/src/repowise/core/ingestion/resolvers/luau.py b/packages/core/src/repowise/core/ingestion/resolvers/luau.py index 5839b391..de6ce8dd 100644 --- a/packages/core/src/repowise/core/ingestion/resolvers/luau.py +++ b/packages/core/src/repowise/core/ingestion/resolvers/luau.py @@ -47,9 +47,33 @@ # `game.....` — capture the service and the remainder. _GAME_ABSOLUTE = re.compile(r"^\s*game\s*\.\s*(\w+)\s*((?:\.\s*\w+\s*)*)$") +# Roblox name-lookup method calls: `:WaitForChild("Foo")` / `:FindFirstChild("Foo")`. +# These are the race-safe idioms actual Rojo code uses in place of the bare +# `.Foo` field access — on OSRPS they account for ~93% of all `require(...)` +# arguments. The name-resolution semantics are identical (look up a child of +# the preceding instance by string name), so we normalize both forms to the +# dot-chain shape before the `_SCRIPT_RELATIVE` regex runs. Optional second +# argument (timeout) is swallowed. +_INSTANCE_METHOD_CALL = re.compile( + r":\s*(?:WaitForChild|FindFirstChild)\s*\(\s*[\"']([A-Za-z_]\w*)[\"']\s*(?:,\s*[^)]+)?\)" +) + _LUAU_SUFFIXES: tuple[str, ...] = (".luau", ".lua") +def _normalize_instance_methods(arg: str) -> str: + """Rewrite `:WaitForChild("Foo")` / `:FindFirstChild("Foo")` as `.Foo`. + + Roblox relative-require idioms: ``script.Parent:WaitForChild("Foo")`` + is semantically equivalent to ``script.Parent.Foo`` for module lookup + purposes (both resolve to the child instance named ``Foo``), but only + the dot form was matched by ``_SCRIPT_RELATIVE``. Normalizing up + front keeps a single regex for both shapes and avoids duplicating the + path-walking logic in ``_resolve_script_relative``. + """ + return _INSTANCE_METHOD_CALL.sub(lambda m: f".{m.group(1)}", arg) + + def resolve_luau_import( module_path: str, importer_path: str, @@ -60,38 +84,44 @@ def resolve_luau_import( ``module_path`` is the argument text captured by ``luau.scm`` after the parser's quote-strip pass (see module docstring). It may be a bare filesystem path (from a string-literal require), an instance-path - expression such as ``script.Parent.Foo``, or an ``@alias`` reference. + expression such as ``script.Parent.Foo`` or + ``script.Parent:WaitForChild("Foo")``, or an ``@alias`` reference. """ - arg = module_path.strip() + raw = module_path.strip() + arg = _normalize_instance_methods(raw) # Relative instance path: script[.Parent]*.Name[.Name]* + # Matched against the normalized form so `:WaitForChild("Foo")` chains + # resolve the same as `.Foo` chains. Unresolved paths fall through with + # the *original* text so external-node labels reflect what was actually + # written at the call site. m = _SCRIPT_RELATIVE.match(arg) if m: parts = [p.strip() for p in m.group(1).split(".") if p.strip()] resolved = _resolve_script_relative(parts, importer_path, ctx) if resolved is not None: return resolved - return ctx.add_external_node(arg) + return ctx.add_external_node(raw) # Absolute instance path: game..Path... # Full Rojo-tree resolution is deferred to the Rojo follow-up; fall through # to an external node so the graph still records the reference. if _GAME_ABSOLUTE.match(arg): - return ctx.add_external_node(arg) + return ctx.add_external_node(raw) # `.luaurc` alias: require("@dep"). Needs a luaurc reader; deferred. - if arg.startswith("@"): - return ctx.add_external_node(arg) + if raw.startswith("@"): + return ctx.add_external_node(raw) # Everything else is a string-literal path. The parser has already - # stripped surrounding quotes, so `arg` is e.g. `./helper` or + # stripped surrounding quotes, so `raw` is e.g. `./helper` or # `some/path`. `_resolve_literal` handles both relative and stem-match # resolution; unresolved literals fall through to an external node # without any silent filename guess. - resolved = _resolve_literal(arg, importer_path, ctx) + resolved = _resolve_literal(raw, importer_path, ctx) if resolved is not None: return resolved - return ctx.add_external_node(arg) + return ctx.add_external_node(raw) def _resolve_literal(literal: str, importer_path: str, ctx: ResolverContext) -> str | None: diff --git a/tests/unit/ingestion/test_luau_resolver.py b/tests/unit/ingestion/test_luau_resolver.py index 52cf24b6..6d659cf6 100644 --- a/tests/unit/ingestion/test_luau_resolver.py +++ b/tests/unit/ingestion/test_luau_resolver.py @@ -68,6 +68,48 @@ def test_module_as_directory(self) -> None: assert got == "src/shared/util/init.lua" +class TestScriptRelativeWithInstanceMethods: + # Rojo-safe idioms — on OSRPS these account for ~93% of `require(...)`. + # They must resolve identically to the dot-chain forms in TestScriptRelative. + + def test_wait_for_child_child_module(self) -> None: + ctx = _ctx({"src/shared/util/init.luau", "src/shared/util/Signal.luau"}) + got = resolve_luau_import('script:WaitForChild("Signal")', "src/shared/util/init.luau", ctx) + assert got == "src/shared/util/Signal.luau" + + def test_wait_for_child_mixed_with_parent(self) -> None: + ctx = _ctx({"src/shared/Signal.luau", "src/client/main.luau"}) + got = resolve_luau_import( + 'script.Parent.Parent:WaitForChild("shared"):WaitForChild("Signal")', + "src/client/main.luau", + ctx, + ) + assert got == "src/shared/Signal.luau" + + def test_find_first_child_sibling(self) -> None: + ctx = _ctx({"src/shared/util/init.luau", "src/shared/util/Signal.luau"}) + got = resolve_luau_import( + 'script:FindFirstChild("Signal")', "src/shared/util/init.luau", ctx + ) + assert got == "src/shared/util/Signal.luau" + + def test_wait_for_child_with_timeout_arg(self) -> None: + # Roblox `WaitForChild(name, timeoutSeconds)` — timeout is discarded. + ctx = _ctx({"src/shared/util/init.luau", "src/shared/util/Signal.luau"}) + got = resolve_luau_import( + 'script:WaitForChild("Signal", 5)', "src/shared/util/init.luau", ctx + ) + assert got == "src/shared/util/Signal.luau" + + def test_unresolved_wait_for_child_preserves_original_text(self) -> None: + # The graph's external-node label should match what the user wrote, + # not the post-normalization form — readers shouldn't see a rewritten + # `.Foo` when their code says `:WaitForChild("Foo")`. + ctx = _ctx(set()) + got = resolve_luau_import('script.Parent:WaitForChild("Missing")', "src/a.luau", ctx) + assert got == 'external:script.Parent:WaitForChild("Missing")' + + class TestStringLiteral: # The parser strips quotes at parser.py:705 before the resolver runs, # so every input below is unquoted — matching production. An earlier From a10e1b323cc5d92520a91db4e1f9a89b0dbaa977 Mon Sep 17 00:00:00 2001 From: CyanoTex Date: Fri, 24 Apr 2026 20:03:34 +0200 Subject: [PATCH 4/4] test(luau): pin .luaurc @alias resolution as a follow-up (xfail) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Luau's official require-by-string alias mechanism: a `.luaurc` file in the directory hierarchy declares `{"aliases": {"dep": "./dependency"}}`, and `require("@dep")` resolves through that map. Parallel in shape to tsconfig/jsconfig path aliases (already handled by the TS resolver) and to the Rojo follow-up this PR already pins. Counts from `repowise init` against upstream luau-lang/luau: 24 of the 170 string-literal-shaped requires are `@alias` references — every one currently lands on the external-node fallback. Implementation needs a `.luaurc` reader layered in via `core/ingestion/dynamic_hints/luaurc.py`, with: - parent-directory walk to find the nearest `.luaurc` - child `.luaurc` overriding parent aliases - alias values resolved as relative paths from the `.luaurc`'s dir Out of scope for this PR — recorded here as a strict xfail so the expected end state is versioned alongside the existing Rojo xfail. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/unit/ingestion/test_luau_resolver.py | 23 ++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/unit/ingestion/test_luau_resolver.py b/tests/unit/ingestion/test_luau_resolver.py index 6d659cf6..78807c33 100644 --- a/tests/unit/ingestion/test_luau_resolver.py +++ b/tests/unit/ingestion/test_luau_resolver.py @@ -159,3 +159,26 @@ def test_game_replicated_storage_resolves_via_rojo_tree(self) -> None: ctx = _ctx({"src/shared/Util.luau"}) got = resolve_luau_import("game.ReplicatedStorage.Shared.Util", "src/client/main.luau", ctx) assert got == "src/shared/Util.luau" + + +class TestLuaurcAlias: + @pytest.mark.xfail( + reason=".luaurc alias-aware resolution — second Luau follow-up, parallel to Rojo.", + strict=True, + ) + def test_alias_resolves_via_luaurc_follow_up(self) -> None: + """Expected end state: given a ``.luaurc`` alongside the importer + declaring ``{"aliases": {"dep": "./dependency"}}``, a require of + ``@dep`` resolves to ``src/dependency.luau``. + + Counts from running `repowise init` against the upstream + luau-lang/luau repo: 24 `@alias` requires — every one of them + currently lands on the external-node fallback. Implementing this + needs a ``.luaurc`` reader layered in via + ``core/ingestion/dynamic_hints/luaurc.py`` (analogous to the Rojo + reader), with lookup walking parent directories and child + ``.luaurc`` files overriding parent aliases. + """ + ctx = _ctx({"src/dependency.luau"}) + got = resolve_luau_import("@dep", "src/main.luau", ctx) + assert got == "src/dependency.luau"