From cb2f26fbc9b361658ab651bf9308a4ec32deb091 Mon Sep 17 00:00:00 2001 From: ChouUn Date: Fri, 27 Feb 2026 05:24:13 +0800 Subject: [PATCH 1/2] fix(bundle): deduplicate symlinked dependencies in luaBundle output When using pnpm/yarn workspaces, the same physical package can be referenced through different symlink paths (e.g. node_modules/pkg-a and node_modules/pkg-b/node_modules/pkg-a both pointing to the same directory). TSTL's existing deduplication used string-based path comparison, causing the same module to be bundled multiple times under different module keys. Add canonicalizeDependencyPath() which uses fs.realpathSync() to detect when different symlink paths resolve to the same physical file, ensuring each file appears only once in the bundle output. Co-Authored-By: Claude Opus 4.6 --- src/transpilation/resolve.ts | 32 +++++++++++++-- test/transpile/module-resolution.spec.ts | 41 +++++++++++++++++++ .../project-with-symlinked-dependency/main.ts | 5 +++ .../shared-lib/index.d.ts | 2 + .../shared-lib/index.lua | 5 +++ .../tsconfig.json | 10 +++++ 6 files changed, 91 insertions(+), 4 deletions(-) create mode 100644 test/transpile/module-resolution/project-with-symlinked-dependency/main.ts create mode 100644 test/transpile/module-resolution/project-with-symlinked-dependency/shared-lib/index.d.ts create mode 100644 test/transpile/module-resolution/project-with-symlinked-dependency/shared-lib/index.lua create mode 100644 test/transpile/module-resolution/project-with-symlinked-dependency/tsconfig.json diff --git a/src/transpilation/resolve.ts b/src/transpilation/resolve.ts index bd16773e9..a64a24b14 100644 --- a/src/transpilation/resolve.ts +++ b/src/transpilation/resolve.ts @@ -31,6 +31,7 @@ class ResolutionContext { public diagnostics: ts.Diagnostic[] = []; public resolvedFiles = new Map(); + private realPathMap = new Map(); constructor( public readonly program: ts.Program, @@ -88,19 +89,42 @@ class ResolutionContext { if (!dependencyPath) return this.couldNotResolveImport(required, file); + // Canonicalize symlink paths to avoid bundling the same physical file multiple times + const canonicalPath = this.canonicalizeDependencyPath(dependencyPath); + if (this.options.tstlVerbose) { - console.log(`Resolved ${required.requirePath} to ${normalizeSlashes(dependencyPath)}`); + console.log(`Resolved ${required.requirePath} to ${normalizeSlashes(canonicalPath)}`); } - this.processDependency(dependencyPath); + this.processDependency(canonicalPath); // Figure out resolved require path and dependency output path - if (shouldRewriteRequires(dependencyPath, this.program)) { - const resolvedRequire = getEmitPathRelativeToOutDir(dependencyPath, this.program); + if (shouldRewriteRequires(canonicalPath, this.program)) { + const resolvedRequire = getEmitPathRelativeToOutDir(canonicalPath, this.program); replaceRequireInCode(file, required, resolvedRequire, this.options.extension); replaceRequireInSourceMap(file, required, resolvedRequire, this.options.extension); } } + /** + * Canonicalize dependency path by resolving symlinks. + * If the same physical file was already seen under a different path, + * return the first-seen path to ensure consistent module keys in bundles. + */ + private canonicalizeDependencyPath(dependencyPath: string): string { + let realPath: string; + try { + realPath = fs.realpathSync(dependencyPath); + } catch { + return dependencyPath; + } + + const existing = this.realPathMap.get(realPath); + if (existing !== undefined) return existing; + + this.realPathMap.set(realPath, dependencyPath); + return dependencyPath; + } + private resolveDependencyPathsWithPlugins(requiringFile: ProcessedFile, dependency: string) { const requiredFromLuaFile = requiringFile.fileName.endsWith(".lua"); for (const plugin of this.plugins) { diff --git a/test/transpile/module-resolution.spec.ts b/test/transpile/module-resolution.spec.ts index ff29ac9b5..9b255fc8e 100644 --- a/test/transpile/module-resolution.spec.ts +++ b/test/transpile/module-resolution.spec.ts @@ -1,4 +1,5 @@ import * as path from "path"; +import * as fs from "fs"; import * as tstl from "../../src"; import * as util from "../util"; import * as ts from "typescript"; @@ -383,6 +384,46 @@ describe("dependency with complicated inner structure", () => { }); }); +describe("symlinked dependency deduplication", () => { + const projectPath = path.resolve(__dirname, "module-resolution", "project-with-symlinked-dependency"); + const tsConfigPath = path.join(projectPath, "tsconfig.json"); + const mainFilePath = path.join(projectPath, "main.ts"); + const symlinkPath = path.join(projectPath, "node_modules", "shared-lib"); + + beforeAll(() => { + // Dynamically create symlink to avoid git symlink portability issues + if (!fs.existsSync(symlinkPath)) { + fs.symlinkSync(path.join(projectPath, "shared-lib"), symlinkPath, "junction"); + } + }); + + afterAll(() => { + if (fs.lstatSync(symlinkPath).isSymbolicLink()) { + fs.unlinkSync(symlinkPath); + } + }); + + test("bundle should not contain duplicate files from symlinked dependencies", () => { + const mainFile = path.join(projectPath, "main.ts"); + const { transpiledFiles } = util + .testProject(tsConfigPath) + .setMainFileName(mainFilePath) + .setOptions({ luaBundle: "bundle.lua", luaBundleEntry: mainFile }) + .expectToEqual({ + directResult: "Hello, World", + indirectResult: "Hello, World, welcome!", + }) + .getLuaResult(); + + expect(transpiledFiles).toHaveLength(1); + const lua = transpiledFiles[0].lua!; + // shared-lib is used by both main.ts (directly) and consumer (indirectly via symlink), + // but should only appear once in the bundle + const moduleEntries = (lua.match(/\["[^"]*shared.lib[^"]*"\]\s*=\s*function/g) ?? []).length; + expect(moduleEntries).toBe(1); + }); +}); + test("module resolution should not try to resolve @noResolution annotation", () => { util.testModule` import * as json from "json"; diff --git a/test/transpile/module-resolution/project-with-symlinked-dependency/main.ts b/test/transpile/module-resolution/project-with-symlinked-dependency/main.ts new file mode 100644 index 000000000..917ceda3f --- /dev/null +++ b/test/transpile/module-resolution/project-with-symlinked-dependency/main.ts @@ -0,0 +1,5 @@ +import * as sharedLib from "shared-lib"; +import * as consumer from "consumer"; + +export const directResult = sharedLib.greet("World"); +export const indirectResult = consumer.welcome("World"); diff --git a/test/transpile/module-resolution/project-with-symlinked-dependency/shared-lib/index.d.ts b/test/transpile/module-resolution/project-with-symlinked-dependency/shared-lib/index.d.ts new file mode 100644 index 000000000..e32864ade --- /dev/null +++ b/test/transpile/module-resolution/project-with-symlinked-dependency/shared-lib/index.d.ts @@ -0,0 +1,2 @@ +/** @noSelfInFile */ +export declare function greet(name: string): string; diff --git a/test/transpile/module-resolution/project-with-symlinked-dependency/shared-lib/index.lua b/test/transpile/module-resolution/project-with-symlinked-dependency/shared-lib/index.lua new file mode 100644 index 000000000..8769ba351 --- /dev/null +++ b/test/transpile/module-resolution/project-with-symlinked-dependency/shared-lib/index.lua @@ -0,0 +1,5 @@ +return { + greet = function(name) + return "Hello, " .. name + end +} diff --git a/test/transpile/module-resolution/project-with-symlinked-dependency/tsconfig.json b/test/transpile/module-resolution/project-with-symlinked-dependency/tsconfig.json new file mode 100644 index 000000000..b76533290 --- /dev/null +++ b/test/transpile/module-resolution/project-with-symlinked-dependency/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "strict": true, + "moduleResolution": "Node", + "target": "esnext", + "lib": ["esnext"], + "types": [], + "rootDir": "." + } +} From f8d223b7f89331299f7c6bd3938170a72e7cafb9 Mon Sep 17 00:00:00 2001 From: ChouUn Date: Fri, 27 Feb 2026 22:07:36 +0800 Subject: [PATCH 2/2] fix(test): address review feedback on symlink dedup test - Add fs.mkdirSync safety check before symlink creation - Add fs.existsSync guard before lstatSync in afterAll cleanup - Use literal hyphen in regex instead of unescaped dot Co-Authored-By: Claude Opus 4.6 --- test/transpile/module-resolution.spec.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/test/transpile/module-resolution.spec.ts b/test/transpile/module-resolution.spec.ts index 9b255fc8e..4b9090674 100644 --- a/test/transpile/module-resolution.spec.ts +++ b/test/transpile/module-resolution.spec.ts @@ -392,13 +392,17 @@ describe("symlinked dependency deduplication", () => { beforeAll(() => { // Dynamically create symlink to avoid git symlink portability issues + const symlinkDir = path.dirname(symlinkPath); + if (!fs.existsSync(symlinkDir)) { + fs.mkdirSync(symlinkDir, { recursive: true }); + } if (!fs.existsSync(symlinkPath)) { fs.symlinkSync(path.join(projectPath, "shared-lib"), symlinkPath, "junction"); } }); afterAll(() => { - if (fs.lstatSync(symlinkPath).isSymbolicLink()) { + if (fs.existsSync(symlinkPath) && fs.lstatSync(symlinkPath).isSymbolicLink()) { fs.unlinkSync(symlinkPath); } }); @@ -419,7 +423,7 @@ describe("symlinked dependency deduplication", () => { const lua = transpiledFiles[0].lua!; // shared-lib is used by both main.ts (directly) and consumer (indirectly via symlink), // but should only appear once in the bundle - const moduleEntries = (lua.match(/\["[^"]*shared.lib[^"]*"\]\s*=\s*function/g) ?? []).length; + const moduleEntries = (lua.match(/\["[^"]*shared-lib[^"]*"\]\s*=\s*function/g) ?? []).length; expect(moduleEntries).toBe(1); }); });