diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index caf41c7df..5a51f8180 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -1359,6 +1359,50 @@ func main() { expect(legacyCallers.some((c) => c.node.filePath === 'src/main.ts')).toBe(false); }); + it('does not match a wildcard alias when the import has no segment for `*` (prefix/suffix overlap)', async () => { + // Pattern `@x/*/index` -> prefix `@x/`, suffix `/index`. An import of + // `@x/index` has nothing in the `*` slot, so it must NOT resolve via + // this alias. Previously startsWith(prefix) && endsWith(suffix) both + // passed for the single shared `/`, producing a bogus mapped target. + fs.mkdirSync(path.join(tempDir, 'src/widgets/button'), { recursive: true }); + // The file the BOGUS rewrite would point at (`widgets/index`). If the + // alias wrongly matches `@x/index`, the call would attach here. + fs.writeFileSync( + path.join(tempDir, 'src/widgets/index.ts'), + `export function trap(): number { return 0; }\n` + ); + // A real relative-resolvable target for the bare import. + fs.writeFileSync( + path.join(tempDir, 'src/index.ts'), + `export function trap(): number { return 1; }\n` + ); + fs.writeFileSync( + path.join(tempDir, 'src/main.ts'), + `import { trap } from '@x/index';\nexport function go(): number { return trap(); }\n` + ); + fs.writeFileSync( + path.join(tempDir, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { + baseUrl: './src', + paths: { '@x/*/index': ['widgets/*/index'] }, + }, + }) + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + cg.resolveReferences(); + + // The widgets/index.ts `trap` must NOT receive a caller from main.ts + // via the overlapping-alias false match. + const widgetsTrap = cg + .getNodesByKind('function') + .find((n) => n.name === 'trap' && n.filePath === 'src/widgets/index.ts'); + expect(widgetsTrap).toBeDefined(); + const bogusCallers = cg.getCallers(widgetsTrap!.id); + expect(bogusCallers.some((c) => c.node.filePath === 'src/main.ts')).toBe(false); + }); + it('falls back gracefully when tsconfig is absent', async () => { fs.mkdirSync(path.join(tempDir, 'src'), { recursive: true }); fs.writeFileSync( diff --git a/src/resolution/path-aliases.ts b/src/resolution/path-aliases.ts index 362baac75..7f4270a96 100644 --- a/src/resolution/path-aliases.ts +++ b/src/resolution/path-aliases.ts @@ -219,6 +219,14 @@ export function applyAliases( let captured = ''; if (pat.hasWildcard) { + // The prefix and suffix must occupy DISJOINT regions of the import + // path. When they overlap (the import is shorter than prefix+suffix), + // `startsWith` and `endsWith` can both be satisfied by the same + // characters — e.g. pattern `src/*/index` (prefix `src/`, suffix + // `/index`) would otherwise falsely match `src/index`, whose single + // `/` is counted as both the prefix's and the suffix's slash. Such an + // import has no wildcard segment, so it must not match. + if (importPath.length < pat.prefix.length + pat.suffix.length) continue; captured = importPath.slice(pat.prefix.length, importPath.length - pat.suffix.length); } else if (importPath !== pat.prefix) { // Literal pattern must match exactly.