Skip to content

Commit df60e72

Browse files
committed
fix(lsp): harden native TypeScript bin resolution
1 parent 9a15f53 commit df60e72

3 files changed

Lines changed: 61 additions & 7 deletions

File tree

packages/lsp/src/client.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ export async function createLSPClient(input: {
141141
finally {
142142
if (diagnosticPulls[normalizedPath] === pullID) {
143143
delete diagnosticPullTokens[normalizedPath]
144+
cancellation.cancel()
144145
cancellation.dispose()
145146
}
146147
}

packages/lsp/src/server/typescript.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ function findTypeScriptPackageRoot(start: string): string | undefined {
3333
}
3434
}
3535

36-
/** Resolve a package-owned bin only when the shared shim targets that package. */
36+
/** Resolve a package-owned bin declared by the package and exposed by its shared shim. */
3737
function resolvePackageBin(packageRoot: string, name: string): string | undefined {
3838
try {
3939
const pkg = JSON.parse(
@@ -45,18 +45,27 @@ function resolvePackageBin(packageRoot: string, name: string): string | undefine
4545
const target = path.resolve(packageRoot, relativeTarget)
4646
if (!fs.existsSync(target)) { return undefined }
4747
const ext = process.platform === 'win32' ? '.cmd' : ''
48-
const nodeModules = packageRoot.includes(`${path.sep}@`)
49-
? path.dirname(path.dirname(packageRoot))
50-
: path.dirname(packageRoot)
48+
const packageDir = path.dirname(packageRoot)
49+
const nodeModules = path.basename(packageDir).startsWith('@')
50+
? path.dirname(packageDir)
51+
: packageDir
5152
const shim = path.join(nodeModules, '.bin', `${name}${ext}`)
5253
if (!fs.existsSync(shim)) { return undefined }
5354

54-
if (process.platform === 'win32') {
55-
return fs.readFileSync(shim, 'utf8').includes(relativeTarget.replaceAll('/', '\\'))
55+
// npm-style symlinks and package-manager-generated wrapper scripts are both
56+
// valid shims. For wrappers, require a reference to the package's declared
57+
// target so a stale or unrelated shared shim is never selected.
58+
const stat = fs.lstatSync(shim)
59+
if (stat.isSymbolicLink()) {
60+
return path.resolve(path.dirname(shim), fs.readlinkSync(shim)) === target
5661
? shim
5762
: undefined
5863
}
59-
return fs.realpathSync(shim) === fs.realpathSync(target) ? shim : undefined
64+
const wrapper = fs.readFileSync(shim, 'utf8').replaceAll('\\', '/')
65+
const relativeFromBin = path.relative(path.dirname(shim), target).replaceAll('\\', '/')
66+
return wrapper.includes(relativeFromBin) || wrapper.includes(target.replaceAll('\\', '/'))
67+
? shim
68+
: undefined
6069
}
6170
catch {
6271
return undefined

packages/lsp/test/unit/typescript-native.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,50 @@ describe('resolveNativeTypeScriptServer', () => {
120120
}
121121
})
122122

123+
test('selects native tsc when the project path contains an @-prefixed directory', async () => {
124+
const base = await fs.mkdtemp(path.join(os.tmpdir(), 'ts7-at-path-'))
125+
const dir = path.join(base, '@workspace', 'app')
126+
try {
127+
await fs.mkdir(dir, { recursive: true })
128+
await writeTypeScriptPackage(dir, { version: '7.0.0', tsserver: false })
129+
const tsc = await makeBin(dir, 'tsc')
130+
expect(resolveNativeTypeScriptServer(dir)?.command).toBe(tsc)
131+
}
132+
finally {
133+
await fs.rm(base, { recursive: true, force: true })
134+
}
135+
})
136+
137+
test('selects native tsc through a package-manager wrapper shim', async () => {
138+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ts7-wrapper-'))
139+
try {
140+
await writeTypeScriptPackage(dir, { version: '7.0.0', tsserver: false })
141+
const tsc = await makeBin(dir, 'tsc')
142+
if (process.platform !== 'win32') {
143+
await fs.unlink(tsc)
144+
await fs.writeFile(tsc, '#!/bin/sh\nexec node "$(dirname "$0")/../typescript/bin/tsc.js" "$@"\n')
145+
}
146+
expect(resolveNativeTypeScriptServer(dir)?.command).toBe(tsc)
147+
}
148+
finally {
149+
await fs.rm(dir, { recursive: true, force: true })
150+
}
151+
})
152+
153+
test('rejects a shared shim that does not reference the package bin', async () => {
154+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ts7-unrelated-shim-'))
155+
try {
156+
await writeTypeScriptPackage(dir, { version: '7.0.0', tsserver: false })
157+
const tsc = await makeBin(dir, 'tsc')
158+
await fs.unlink(tsc)
159+
await fs.writeFile(tsc, '#!/bin/sh\nexec node /unrelated/tsc.js "$@"\n')
160+
expect(resolveNativeTypeScriptServer(dir)).toBeUndefined()
161+
}
162+
finally {
163+
await fs.rm(dir, { recursive: true, force: true })
164+
}
165+
})
166+
123167
test('returns undefined when native package exists but no .bin/tsc', async () => {
124168
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ts7-no-bin-'))
125169
try {

0 commit comments

Comments
 (0)