Просмотр исходного кода

feat(impact): resolve Lua/Luau require() to module files

A Lua `require("a.b.c")` dotted module path (→ a/b/c.lua or a/b/c/init.lua) and a
Roblox/Luau instance-path require (`require(script.Parent.Signal)`, where only the
leaf survives extraction) now resolve to the module file by path-suffix match —
the module root (lua/, src/, …) is project-specific, so among suffix matches the
one sharing the longest directory prefix with the requiring file wins. There was
no Lua/Luau module resolver, so requires resolved to nothing. Confidence 0.9 so
the deterministic path match wins over name-matching, which otherwise self-matches
the require to its own import node.

telescope.nvim 31.6% -> 84.2%, Fusion (Luau) 12.5% -> 92.2%. Gated to lua/luau.
Regression test (dotted Lua + instance-path Luau) fails without the fix; suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Colby McHenry 2 недель назад
Родитель
Сommit
415560986f
3 измененных файлов с 89 добавлено и 0 удалено
  1. 1 0
      CHANGELOG.md
  2. 41 0
      __tests__/extraction.test.ts
  3. 47 0
      src/resolution/import-resolver.ts

+ 1 - 0
CHANGELOG.md

@@ -30,6 +30,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 - Rocket route handlers now connect to where they're mounted. A handler registered in a `routes![a::b::handler, …]` or `catchers![…]` macro used to be invisible — the macro body is a raw token tree, so the handler looked like it had no caller (Rocket mounts it at runtime) and its file showed no dependents. The handler paths are now read out of the macro and linked to the `mount`/`register` call, so editing a Rocket handler surfaces its route registration and a routes module is no longer reported as unused. (Rust, Rocket)
 - SvelteKit pages now connect to their server `load` functions. SvelteKit wires a `+page.server.js` / `+page.js` `load` (and form `actions`) to the sibling `+page.svelte`'s `data` by file path, with no import between them — so editing a `load` previously showed no impact on the page it feeds. Each page is now linked to the `load`/`actions` in its own route directory (and likewise for `+layout`), so editing a loader surfaces the page that renders its data, and tracing a page reaches its server-side data source. (Svelte, SvelteKit)
 - Nuxt nested components are now connected to where they're used. Nuxt auto-imports a component in a subdirectory by a directory-prefixed name — `components/media/Card.vue` is used in templates as `<MediaCard/>` — but it was tracked by its file name (`Card`), so the usage didn't resolve and the component looked unused. PascalCase component tags (`<MediaCard>`, `<NavBar>`) in a `.vue` template are now matched, falling back to the Nuxt directory-prefixed name, so editing a nested component surfaces every page and component that renders it. (Vue, Nuxt)
+- Lua and Luau `require` calls now connect to their module files. A dotted module path (`require("telescope.config")` → `telescope/config.lua` or `.../config/init.lua`) and a Roblox/Luau instance-path require (`require(script.Parent.Signal)` → the `Signal` module) now link to the file they load, so editing a module surfaces every file that requires it. Previously requires resolved to nothing, so a Lua/Luau module looked like it had no dependents. (Lua, Luau)
 - Swift property wrappers and attributes are now connected. A `@Argument` / `@Published` / `@State` / custom `@propertyWrapper` on a property — and attributes on types, methods, and functions (`@objc`, `@MainActor`, …) — now record a dependency on the wrapper/attribute type. Previously these were dropped entirely (Swift attributes parse differently from other languages, and stored properties weren't being inspected), so the wrapper type looked unused and the file using it depended on nothing — a big gap for SwiftUI and argument-parser-style code.
 - Swift Fluent relationship models are no longer orphaned. A type referenced only through a property-wrapper *argument* — `@Siblings(through: AcronymCategoryPivot.self, …)`, the many-to-many pivot/join model — now records a dependency on that type. Previously only the wrapper itself (`Siblings`) and the property's declared type were captured, so a pivot model reached solely through the relationship looked like nothing depended on it and editing it surfaced no impact. (Swift, Vapor/Fluent)
 - Java annotations are now connected. Annotation definitions (`@interface Foo`) are indexed as types, and every `@Foo` usage on a class, method, or field is recorded as a dependency on it. Previously neither side was captured — annotation usages were dropped (they live inside the declaration's modifiers) and `@interface` types weren't indexed at all — so annotation-driven code (Spring `@GetMapping`, JPA `@Entity`, Gson `@SerializedName`, …) showed the annotation as having no users and the annotated class as not depending on it.

+ 41 - 0
__tests__/extraction.test.ts

@@ -4210,6 +4210,47 @@ describe('Same-directory include + KMP import resolution', () => {
   });
 });
 
+describe('Lua/Luau require resolution', () => {
+  let tempDir: string;
+  let cg: CodeGraph;
+
+  beforeEach(() => {
+    tempDir = createTempDir();
+  });
+
+  afterEach(() => {
+    if (cg) cg.close();
+    if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
+  });
+
+  it('resolves a dotted Lua require and an instance-path Luau require to their module files', async () => {
+    // The require is the ONLY link (no method call), so coverage here proves the
+    // require resolver specifically, not method-call name-matching.
+    // Lua dotted module path: require("myapp.config") → lua/myapp/config.lua.
+    fs.mkdirSync(path.join(tempDir, 'lua/myapp'), { recursive: true });
+    fs.writeFileSync(path.join(tempDir, 'lua/myapp/config.lua'), `local M = {}\nfunction M.setup() end\nreturn M\n`);
+    fs.writeFileSync(path.join(tempDir, 'lua/myapp/init.lua'), `local config = require("myapp.config")\nreturn config\n`);
+    // Luau Roblox instance-path require (only the leaf survives extraction):
+    // require(script.Util.helper) → src/Util/helper.luau.
+    fs.mkdirSync(path.join(tempDir, 'src/Util'), { recursive: true });
+    fs.writeFileSync(path.join(tempDir, 'src/Util/helper.luau'), `local H = {}\nfunction H.go() end\nreturn H\n`);
+    fs.writeFileSync(path.join(tempDir, 'src/init.luau'), `local helper = require(script.Util.helper)\nreturn helper\n`);
+
+    cg = CodeGraph.initSync(tempDir);
+    await cg.indexAll();
+    cg.resolveReferences();
+
+    const config = cg.getNodesByKind('file').find((n) => n.filePath.endsWith('myapp/config.lua'));
+    const helper = cg.getNodesByKind('file').find((n) => n.filePath.endsWith('Util/helper.luau'));
+    expect(config, 'config.lua file node').toBeDefined();
+    expect(helper, 'helper.luau file node').toBeDefined();
+    const cfgDeps = cg.getFileDependents(config!.filePath);
+    const helpDeps = cg.getFileDependents(helper!.filePath);
+    expect(cfgDeps.some((p) => p.endsWith('myapp/init.lua')), 'dotted Lua require resolves to the module').toBe(true);
+    expect(helpDeps.some((p) => p.endsWith('src/init.luau')), 'instance-path Luau require resolves to the module').toBe(true);
+  });
+});
+
 describe('Rust module-path call resolution', () => {
   let tempDir: string;
   let cg: CodeGraph;

+ 47 - 0
src/resolution/import-resolver.ts

@@ -1171,6 +1171,16 @@ export function resolveViaImport(
     if (rustResult) return rustResult;
   }
 
+  // Lua / Luau `require(...)`: a dotted module path (`a.b.c` from
+  // `require("a.b.c")`) or an instance-path leaf (`Signal` from
+  // `require(script.Parent.Signal)`) — map it to a module file. There's no static
+  // import statement, so the generic path-matcher can't bridge the dot↔slash /
+  // leaf↔basename gap; resolve it explicitly to the module file.
+  if ((ref.language === 'lua' || ref.language === 'luau') && ref.referenceKind === 'imports') {
+    const luaResult = resolveLuaRequire(ref, context);
+    if (luaResult) return luaResult;
+  }
+
   // Whole-module / namespace imports → link the importing file to the module
   // file. Python `from . import certs` / `import mod`, and TS/JS `import * as ns
   // from './x'` (so a namespace touched only via a value-member read still
@@ -1305,6 +1315,43 @@ function resolvePythonModuleMember(
  * real file. A NAMED TS/JS import (`import { widget }`) is not a module, so it
  * returns null and normal symbol resolution handles it.
  */
+/**
+ * Resolve a Lua/Luau `require(...)` to its module file. The reference name is
+ * either a dotted module path (`telescope.config` → `telescope/config.lua`) or a
+ * Roblox instance-path leaf (`Signal` from `require(script.Parent.Signal)` →
+ * `Signal.luau`). We try `<path>.lua|.luau` and `<path>/init.lua|.luau`, matched
+ * by path suffix (the module root — `lua/`, `src/`, … — is project-specific).
+ * Among suffix matches, the one sharing the longest directory prefix with the
+ * requiring file wins (instance-path requires resolve within the same package).
+ */
+function resolveLuaRequire(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
+  const name = ref.referenceName;
+  if (!name) return null;
+  const base = name.includes('.') ? name.replace(/\./g, '/') : name;
+  const suffixes = [`${base}.lua`, `${base}.luau`, `${base}/init.lua`, `${base}/init.luau`];
+  const files = context.getAllFiles();
+  const shared = (a: string, b: string): number => {
+    let i = 0;
+    while (i < a.length && i < b.length && a[i] === b[i]) i++;
+    return i;
+  };
+  for (const suffix of suffixes) {
+    const matches = files.filter((f) => f === suffix || f.endsWith('/' + suffix));
+    if (matches.length === 0) continue;
+    matches.sort((x, y) => shared(y, ref.filePath) - shared(x, ref.filePath));
+    const best = matches[0]!;
+    if (best === ref.filePath) continue;
+    const fileNode = context.getNodesInFile(best).find((n) => n.kind === 'file');
+    if (fileNode) {
+      // Confidence ≥ 0.9 so this deterministic path/suffix match wins over
+      // name-matching, which otherwise resolves the require to the import node
+      // itself (a same-name self-match).
+      return { original: ref, targetNodeId: fileNode.id, confidence: 0.9, resolvedBy: 'import' };
+    }
+  }
+  return null;
+}
+
 function resolveModuleImportToFile(
   ref: UnresolvedRef,
   imports: ImportMapping[],