Sfoglia il codice sorgente

fix(resolution): gate the Lua/Luau annotation pattern against method-call self-match (#1124) (#1131)

Lua method-call syntax (lg:Log()) is byte-identical to the Luau type-annotation
shape (lg: Logger), and the receiver-type scan starts on the call's own line —
so any PascalCase method call self-matched as "type = Log" before the scan
reached the real declaration, silently dropping the calls edge whenever two or
more classes shared a method name.

The annotation pattern now rejects a capture followed by any of Lua's three
call forms; its leading [\w.] lookahead alternative prevents backtracking from
shrinking the capture to dodge the gate. Gated rather than dropped: the pattern
is the only type source for Luau typed params and annotated locals whose
initializer isn't T.new().

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Colby Mchenry 13 ore fa
parent
commit
e53968cae8
3 ha cambiato i file con 55 aggiunte e 1 eliminazioni
  1. 3 0
      CHANGELOG.md
  2. 41 0
      __tests__/resolution.test.ts
  3. 11 1
      src/resolution/name-matcher.ts

+ 3 - 0
CHANGELOG.md

@@ -9,6 +9,9 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ## [Unreleased]
 
+### Fixes
+
+- Lua and Luau method calls with capitalized names (`obj:Method()` — the standard Roblox convention) now link to the right method. Because Lua's method-call syntax looks identical to a Luau type annotation, a capitalized call like `lg:Log()` was misread as declaring the variable's type, so whenever two or more classes shared a method name (`Init`, `Update`, `Destroy`, …) the call was silently dropped from callers, impact/blast-radius, and flow traces. Lowercase method names were unaffected. Thanks @inth3shadows for the precise root-cause analysis and repro. (#1124)
 
 ## [1.2.0] - 2026-07-02
 

+ 41 - 0
__tests__/resolution.test.ts

@@ -1820,6 +1820,47 @@ func main() {
         expect(otherCallers, `${c.lang}: Other callers`).not.toContain(c.callerA);
       });
     }
+
+    // Lua/Luau: a PascalCase method call (`lg:Log()`, the Roblox convention)
+    // is the identical `receiver:Name` shape as a Luau type annotation, so it
+    // self-matched the annotation pattern on the call's own line and inferred
+    // "type = Log" (#1124). Two things are load-bearing in these fixtures:
+    // the declaration sits on an EARLIER line than the call (on one line,
+    // pattern order resolves it — the `.new` pattern wins first), and TWO
+    // classes share the method name (a single class resolves via the
+    // same-name fallback even when inference misfires). Luau's `useLogger`
+    // takes a typed param instead of calling `.new()`, pinning that the
+    // gated pattern still matches a genuine annotation.
+    const pascalMethodCases: Array<{ lang: string; file: string; src: string }> = [
+      { lang: 'Lua', file: 'svc.lua',
+        src: `local Logger = {}\nLogger.__index = Logger\nfunction Logger.new() return setmetatable({}, Logger) end\nfunction Logger:Log() return 1 end\n\nlocal Other = {}\nOther.__index = Other\nfunction Other.new() return setmetatable({}, Other) end\nfunction Other:Log() return 2 end\n\nlocal function useLogger()\n\tlocal lg = Logger.new()\n\treturn lg:Log()\nend\n\nlocal function useOther()\n\tlocal o = Other.new()\n\treturn o:Log()\nend\n\nreturn useLogger, useOther\n` },
+      { lang: 'Luau', file: 'svc.luau',
+        src: `local Logger = {}\nLogger.__index = Logger\nfunction Logger.new() return setmetatable({}, Logger) end\nfunction Logger:Log(): number return 1 end\n\nlocal Other = {}\nOther.__index = Other\nfunction Other.new() return setmetatable({}, Other) end\nfunction Other:Log(): number return 2 end\n\nlocal function useLogger(lg: Logger): number\n\treturn lg:Log()\nend\n\nlocal function useOther(): number\n\tlocal o = Other.new()\n\treturn o:Log()\nend\n\nreturn useLogger, useOther\n` },
+    ];
+
+    for (const c of pascalMethodCases) {
+      it(`resolves a PascalCase method call without self-matching the annotation pattern — ${c.lang} (#1124)`, async () => {
+        fs.writeFileSync(path.join(tempDir, c.file), c.src);
+        cg = await CodeGraph.init(tempDir, { index: true });
+        cg.resolveReferences();
+
+        const methods = cg.getNodesByKind('method').filter((n) => n.name === 'Log');
+        expect(methods.length, `${c.lang}: both Log methods indexed`).toBe(2);
+
+        const loggerLog = methods.find((m) => /Logger/.test(m.qualifiedName ?? ''));
+        const otherLog = methods.find((m) => /Other/.test(m.qualifiedName ?? ''));
+        expect(loggerLog, `${c.lang}: Logger's Log`).toBeDefined();
+        expect(otherLog, `${c.lang}: Other's Log`).toBeDefined();
+
+        const loggerCallers = cg.getCallers(loggerLog!.id).map((x) => x.node.name);
+        const otherCallers = cg.getCallers(otherLog!.id).map((x) => x.node.name);
+
+        expect(loggerCallers, `${c.lang}: Logger callers`).toContain('useLogger');
+        expect(loggerCallers, `${c.lang}: Logger callers`).not.toContain('useOther');
+        expect(otherCallers, `${c.lang}: Other callers`).toContain('useOther');
+        expect(otherCallers, `${c.lang}: Other callers`).not.toContain('useLogger');
+      });
+    }
   });
 
   describe('Name Matcher: kind bias for new ref kinds', () => {

+ 11 - 1
src/resolution/name-matcher.ts

@@ -1148,7 +1148,17 @@ function localReceiverTypePatterns(language: Language, r: string): RegExp[] {
       return [
         new RegExp(`\\b${r}\\b\\s*=\\s*([A-Z][\\w]*)\\.new\\b`), // local lg = Logger.new()
         new RegExp(`\\b${r}\\b\\s*=\\s*([A-Z][\\w]*)\\s*\\(`), // local lg = Logger(...)  (callable table)
-        new RegExp(`\\b${r}\\b\\s*:\\s*([A-Z][\\w.]*)`), // Luau: local lg: Logger  / typed param
+        // Luau annotation (`local lg: Logger`) / typed param — but Lua's
+        // method-call syntax is the IDENTICAL `receiver:Name` shape, and the
+        // backward scan starts on the call's own line, so without a gate any
+        // PascalCase method call (`lg:Log()`, the Roblox convention)
+        // self-matches as "type = Log" before the scan reaches the real
+        // declaration (#1124). The lookahead rejects a capture followed by
+        // any of Lua's three call forms — `(args)`, `"s"`/`'s'`/`[[s]]`,
+        // `{t}` — and its leading `[\w.]` alternative stops backtracking from
+        // shrinking the capture to dodge the gate (`lg:Log()` would otherwise
+        // still match, as `Lo`).
+        new RegExp(`\\b${r}\\b\\s*:\\s*([A-Z][\\w.]*)(?![\\w.]|\\s*[({"'\\[])`), // local lg: Logger  / typed param
       ];
     case 'r':
       return [