Skip to content

Add Luau language plugin; fix import-graph and tool-output path handling - #734

Open
noahrepublic wants to merge 2 commits into
peteromallet:mainfrom
noahrepublic:feat/luau-language-plugin
Open

Add Luau language plugin; fix import-graph and tool-output path handling#734
noahrepublic wants to merge 2 commits into
peteromallet:mainfrom
noahrepublic:feat/luau-language-plugin

Conversation

@noahrepublic

Copy link
Copy Markdown

Problem

Scanning a Roblox/Luau project reports a clean bill of health that nothing backs up. .luau is not registered against any language, so auto-detection falls through to bash, every mechanical detector runs over zero files, and the objective score comes back 100/100:

$ desloppify scan --path .
  Capabilities: linting (shellcheck), ...
  [7/12] Security...
         security: clean (0 files scanned)
  [9/12] Test coverage...
         test coverage: clean (0 production files)
  Scores: overall 25.0/100  objective 100.0/100  strict 25.0/100

The project has 857 .luau files. --lang lua does not help — it reports No lua source files found.

Adding .luau to the existing lua plugin's extensions is not sufficient: the Lua grammar produces ERROR nodes for type annotations, generics and export type, so AST-backed detection would degrade silently on any ordinarily-typed Luau codebase. tree-sitter-language-pack already ships a dedicated luau grammar.

Fix

New luau plugin (desloppify/languages/luau/) on the luau grammar, with:

  • A resolve_luau_import for require-by-string: ./x, ../x, @self/x, .luaurc aliases, and host aliases such as Roblox's @game/.... Host aliases address a runtime instance tree via a build manifest (Rojo) rather than the filesystem, so their tails are matched against a module index — longest tail first, accepting only an unambiguous match or, when a tree is duplicated, the copy nearest the requiring file. Ambiguity yields no edge rather than a guessed one.
  • Both readings of a relative require inside an init file, since runtimes disagree on whether the base is the file or the directory it initialises. Only an existing file is accepted, so the ambiguity costs a stat rather than a wrong edge.
  • Zone rules for vendored package trees and .spec/.story files, and entry patterns for .client.luau/.server.luau (started by the runtime, never required) and Controllers//Services/ (bulk-required at boot — the Roblox counterpart of the /autoload/ entry in the GDScript plugin).
  • selene as the linter. stylua is deliberately not wired: --check emits a diff, not diagnostics.

Three framework bugs found while validating it, each affecting more than Luau:

  1. ts_build_dep_graph dropped every edge when the file list was relative. It normalised each resolved import to an absolute path, then tested membership against a file list that language file finders often return relative to the process cwd, so the check never matched. Edges are now matched on a normalised key that accepts either form, recorded under the original file-list spelling, and a self-import no longer makes a file its own importer. On the test project this went from 0 to ~1200 edges, which in turn un-broke orphan, single-use and coupling detection.

  2. External tool findings bypassed the scan's exclusion rules. Linters read their own config, not desloppify's, so running one at the project root reports on vendored trees the user excluded. On the test project 3154 of 4548 findings came from a Wally Packages/_Index/ directory that desloppify exclude had already ruled out. make_tool_phase now filters entries through the same exclusion patterns.

  3. run_tool_result decoded tool output with the locale codec. A non-ASCII byte raised UnicodeDecodeError inside the subprocess reader thread and killed it mid-scan:

    Exception in thread Thread-5 (_readerthread):
      File "encodings\cp1252.py", line 23, in decode
    UnicodeDecodeError: 'charmap' codec can't decode byte 0x9d in position 4870080
    

    Output is now decoded as UTF-8 with errors="replace".

Also adds .luau to the extension list the unused-import basename helper strips.

Result on the test project

before after
files scanned 0 808
import-graph edges 0 ~1200
findings 0 (fake 100/100 objective) 1522 real

Tests

18 new tests (test_luau_plugin.py, plus dep-graph and tool-exclusion regressions). Full suite run before and after on the same machine: 35 failures both times — all pre-existing and Windows-specific, none introduced — with passes going 5776 → 5794.

Three test doubles in test_next_lint.py had signatures pinned to the exact kwargs of subprocess.run and were widened with **_kwargs.

🤖 Generated with Claude Code

noahrepublic and others added 2 commits September 7, 2026 14:14
… handling

Scanning a Roblox/Luau project produced a score with no evidence behind it:
.luau matched no language, so every mechanical detector ran over zero files
and reported a clean 100/100. Adding the extension to the existing `lua`
plugin is not enough — the Lua grammar emits ERROR nodes for type
annotations and `export type`, which silently degrades AST-backed detection
on any ordinarily-typed Luau codebase.

Adds a `luau` plugin on the dedicated tree-sitter luau grammar, with a
resolver for require-by-string (`./x`, `../x`, `@self/x`, `.luaurc` aliases,
and host aliases such as Roblox's `@game` matched against a module index,
longest tail first, accepting only an unambiguous or nearest match). selene
is wired as the linter; stylua is not, because `--check` emits a diff rather
than diagnostics.

Three framework bugs surfaced while validating it, each affecting languages
beyond Luau:

* `ts_build_dep_graph` normalised resolved imports to absolute paths and then
  compared them against a file list that is often cwd-relative, so every edge
  was dropped. Edges are now matched on a normalised key that accepts either
  form, and a self-import no longer makes a file its own importer. On the
  test project this took the graph from 0 to ~1200 edges, which in turn
  un-broke orphan, single-use and coupling detection.
* External tool findings were scored without passing through the scan's
  exclusion rules. Linters read their own config, not desloppify's, so
  running one at the project root reports on vendored trees the user
  excluded — 3154 of 4548 findings on the test project came from a Wally
  package directory.
* `run_tool_result` decoded tool output with the locale codec. A non-ASCII
  byte raised UnicodeDecodeError inside the subprocess reader thread and
  killed it mid-scan (cp1252 on Windows). Output is now decoded as UTF-8.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The unused-import detector took each import's name from its module path
basename. That holds where the statement dictates the binding (Python's
`import foo`, Go's import block) but not where an import is an ordinary
expression assigned to an ordinary variable, which is the norm in Lua and
Luau:

    local TeamClass = require("Tools/Team")   -- binds TeamClass, not Team
    local MODULES = { require("Entities/awp") } -- binds nothing at all

Both were reported unused: the first because "Team" appears nowhere, the
second because "awp" appears nowhere. On the test project all 31 findings
were false, 22 of them from a single table of inline requires.

JavaScript already had a binding-aware path for exactly this reason. This
generalises the idea as an optional `import_binding` spec hook returning the
name an import introduces plus the statement that declares it, and wires it
up for Luau. The statement matters: the captured import node covers only the
call, so excluding it alone leaves `local X = ` in the searched text, where
the name matches itself and nothing ever looks unused.

A binding whose name starts with an underscore is skipped — the Lua
convention for a name kept deliberately and knowingly unreferenced, which
selene's unused_variable rule reads the same way.

Verified against a matrix of five cases: a genuinely unused import is still
reported, a used alias is not, an unused alias is reported under its alias,
an inline require is skipped, and an underscore binding is skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@noahrepublic

Copy link
Copy Markdown
Author

Pushed a fourth fix found while validating the plugin against a real project.

Unused-import names came from the module path, not the binding. That holds where the statement dictates the binding (import foo), but not where an import is an ordinary expression assigned to an ordinary variable — the norm in Lua/Luau:

local TeamClass = require("Tools/Team")      -- binds TeamClass, not Team
local MODULES = { require("Entities/awp") }  -- binds nothing at all

Both were reported unused. On the test project all 31 unused-import findings were false, 22 from one table of inline requires.

JavaScript already had a binding-aware path for this reason; this generalises it as an optional import_binding spec hook and wires it up for Luau. The hook returns the declaring statement as well as the name — the captured import node covers only the call, so excluding it alone leaves local X = in the searched text, where the name matches itself and nothing ever looks unused.

Underscore-prefixed bindings are skipped, matching the Lua convention selene's unused_variable rule already honours.

Suite still at 35 pre-existing failures; passes now 5776 → 5799 (23 new tests).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant