Skip to content

fix(lsp): check a project-local pyright-langserver before PATH and bunx - #148

Merged
amondnet merged 3 commits into
mainfrom
amondnet/147-pyright-local-bin
Oct 23, 2026
Merged

amondnet merged 3 commits into
mainfrom
amondnet/147-pyright-local-bin

Conversation

@amondnet

@amondnet amondnet commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Summary

PyrightServer only looked at PATH and then the pinned bunx fallback, so a pyright-langserver installed in the project's node_modules was ignored. It now checks the project-local binary first, the same way the TypeScript server does.

  • pyright.ts resolves findPackageBin(root, 'pyright', 'pyright-langserver') ?? Bun.which('pyright-langserver', { PATH }), then the pinned bunx fallback. The lookup walks up from root, because a Python root (pyproject.toml and similar) often sits in a subdirectory below the repo's node_modules.
  • findPackageBin and resolvePackageBin move verbatim from typescript.ts into utils.ts and are exported; typescript.ts imports them. No behavior change for TypeScript.
  • Tests in packages/lsp/test/unit/server.test.ts: spawnServerWith gains an optional subdir param, plus three PyrightServer fallback tests (project-local wins over PATH and bunx, a node_modules in a parent of the root is found, a .bin/pyright-langserver shim the pyright package does not own is ignored).

Scope

The check is existence and ownership only. pyright-langserver has no --version, so there is no runsVersion probe. A broken mise shim on PATH is out of scope, since it needs a probe other than --version.

Verification

  • bun test test/unit in packages/lsp: 179 pass, 0 fail.
  • The two lookup tests fail against the old pyright.ts. The ownership test passes on the old code too, so it is a regression guard, not proof.
  • bunx tsc --noEmit -p tsconfig.json in packages/lsp: exit 0. eslint on the changed files: clean.

Related issue

Closes #147

Checklist

  • PR title follows Conventional Commits
  • Tests added or updated, and the suite passes (bun test test/unit in packages/lsp)
  • Lint/format pass (eslint on changed files)
  • Documentation updated if behavior changed (not applicable: no documented behavior changes)
  • No breaking change, or a BREAKING CHANGE: note is included

Summary by cubic

Fixes the Pyright server so a project-local pyright-langserver under node_modules is used before PATH and the pinned bunx run, making editor diagnostics match the version the project pins.

  • findPackageBin and resolvePackageBin move from typescript.ts into utils.ts and are shared; the TypeScript server behavior is unchanged, and .bin shims the pyright package doesn't own are ignored.
  • The lookup walks up from the root, so a Python project in a nested subdirectory still finds node_modules at the repository root.
  • On Windows, .cmd shims now run through a shell via the shared prepareSpawnCommand, and pnpm .bin wrappers are accepted by matching against the package's real path in the .pnpm store.

Written for commit 0486614. Summary will update on new commits.

Turn on auto-fix

Resolve pyright-langserver from node_modules/.bin, walking up from the
server root, before falling back to PATH and the pinned bunx run. This
makes the pyright server honor the version a project already installs.

Move findPackageBin and resolvePackageBin from typescript.ts into
utils.ts so both servers share them.

Closes #147
@codecov

codecov Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.75000% with 9 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
packages/lsp/src/server/utils.ts 87.32% 9 Missing ⚠️

📢 Thoughts on this report? Let us know!

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors the package binary resolution logic (resolvePackageBin and findPackageBin) from typescript.ts to utils.ts to make it reusable, and integrates it into pyright.ts to support resolving and spawning a project-local pyright-langserver. Feedback was provided regarding a Windows compatibility issue where spawning a .cmd shim resolved by findPackageBin on Windows will fail without shell: true, along with a recommendation to wrap the spawn call in a try/catch block to handle OS-level errors.

Comment thread packages/lsp/src/server/pyright.ts
@amondnet
amondnet marked this pull request as ready for review October 23, 2026 10:53
@greptile-apps

greptile-apps Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 5/5

[Medium impact] The PR appears safe to merge; the latest refactor preserves language-server startup behavior.

Summary

Pyright now prefers a project-local server, including one installed above a nested Python project.

  • Pyright starts a project’s own language server before global tools.

Reviews (3) · Last reviewed commit: "refactor(lsp): share Windows shim spawn ..." · Reviewed by Greptile

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cubic analysis

All reported issues were addressed across 4 files

Linked issue analysis

Linked issue: pleaseai/code-intelligence#147: fix(lsp): pyright server ignores a project-local pyright-langserver in node_modules/.bin

Status Acceptance criteria Notes
✅ Prefer the project-local Pyright binary before PATH and bunx PyrightServer calls findPackageBin first, then checks PATH, leaving bunx as the fallback.
✅ Find ancestor node_modules when the Python root is nested findPackageBin walks from the supplied root to its ancestors, and a test covers a nested backend root.
✅ Accept only a shim owned by the pyright package resolvePackageBin verifies the shim against the package target, and a test confirms stray shims are ignored.
✅ Share package-bin resolution helpers from utils.ts Both helpers are exported from utils.ts and imported by TypeScript and Pyright servers.
✅ Do not probe pyright-langserver with a version command Pyright uses package lookup and PATH selection directly, with no version probe before spawning.
Architecture diagram
sequenceDiagram
    participant Editor as Editor Client
    participant PyrightSrv as PyrightServer.spawn(root)
    participant Utils as utils.ts
    participant FSBin as findPackageBin(root, 'pyright', 'pyright-langserver')
    participant FSResolve as resolvePackageBin(packageRoot, name)
    participant FSPath as node:fs
    participant PATH as PATH lookup
    participant BunX as bunx fallback
    participant Proc as Spawned process

    Note over PyrightSrv,BunX: Binary resolution precedence for pyright-langserver

    Editor->>PyrightSrv: spawn(root)

    PyrightSrv->>FSBin: findPackageBin(root, 'pyright', 'pyright-langserver')
    activate FSBin

    loop Walk up from root to filesystem root
        FSBin->>FSBin: Check node_modules/pyright/ in current dir
        FSBin->>FSResolve: resolvePackageBin(packageRoot, 'pyright-langserver')
        activate FSResolve

        FSResolve->>FSPath: read package.json
        FSPath-->>FSResolve: bin field

        alt package declares pyright-langserver bin
            FSResolve->>FSPath: Check target exists
            FSResolve->>FSPath: Check .bin/pyright-langserver shim exists
            alt Shim is symlink
                FSResolve->>FSPath: Resolve shim target
                alt Resolved target matches package target
                    FSResolve-->>FSBin: Return shim path
                else Mismatch - stale/unrelated shim
                    FSResolve-->>FSBin: Return undefined
                end
            else Shim is wrapper script
                FSResolve->>FSPath: Read wrapper content
                alt Wrapper references package target
                    FSResolve-->>FSBin: Return shim path
                else Unrelated wrapper
                    FSResolve-->>FSBin: Return undefined
                end
            end
        else No matching bin declared
            FSResolve-->>FSBin: Return undefined
        end
        deactivate FSResolve

        alt Bin found in node_modules/.bin
            FSBin-->>PyrightSrv: Return project-local binary path
        else Not found - continue up
            FSBin->>FSBin: Move to parent directory
        end
    end
    deactivate FSBin

    alt Project-local binary found
        PyrightSrv->>Proc: spawn(local-pyright-langserver, --stdio)
    else No project-local binary
        PyrightSrv->>PATH: Bun.which('pyright-langserver', { PATH })
        alt Found on PATH
            PyrightSrv->>Proc: spawn(path-pyright-langserver, --stdio)
        else Not on PATH
            PyrightSrv->>BunX: bunx pyright-langserver --stdio
            BunX->>Proc: Spawn via bunx
        end
    end

    Proc-->>PyrightSrv: LSP process
    PyrightSrv-->>Editor: LSP server handle
Loading

Reply with feedback, questions, or to request a fix.

Turn on auto-fix | Re-trigger cubic

Comment thread packages/lsp/src/server/utils.ts Outdated
Comment thread packages/lsp/src/server/pyright.ts
…m shims

On Windows a project-local or npm-global pyright-langserver is a .cmd shim,
which spawn can only run through a shell, so reuse nativeServerSpawnArgs as
the classic typescript path does.

pnpm .bin wrappers reference the package's real path in the .pnpm store, so
resolvePackageBin also matches the wrapper against the realpath of the target.
@amondnet

Copy link
Copy Markdown
Contributor Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors how language server binaries are located and spawned, moving the resolvePackageBin and findPackageBin utilities to a shared utils.ts module and adding support for pnpm wrapper shims. It also updates the Pyright server to use these utilities and adds corresponding unit tests. The review feedback highlights a tight coupling issue where pyright.ts directly imports nativeServerSpawnArgs from the TypeScript module. To resolve this dependency disorder, the reviewer suggests extracting the Windows command preparation logic into a shared prepareSpawnCommand utility in utils.ts.

Comment thread packages/lsp/src/server/pyright.ts Outdated
Comment thread packages/lsp/src/server/pyright.ts Outdated
Comment thread packages/lsp/src/server/utils.ts
greptile-apps[bot]
greptile-apps Bot previously approved these changes Oct 23, 2026
pyright no longer imports nativeServerSpawnArgs from the typescript server.
Both servers use prepareSpawnCommand from utils, and the classic typescript
fallback calls it directly since it never used the native --lsp args.
@greptile-apps
greptile-apps Bot dismissed their stale review October 23, 2026 11:06

Dismissed because a newer commit was pushed; Greptile will re-review the current head.

@amondnet

Copy link
Copy Markdown
Contributor Author

/gemini review

@sonarqubecloud

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors LSP server binary resolution by moving shared utility functions like 'resolvePackageBin' and 'findPackageBin' from 'typescript.ts' to 'utils.ts', and introduces 'prepareSpawnCommand' to handle Windows shell execution consistently. It also adds support for resolving pnpm wrapper shims pointing to the '.pnpm' store, along with comprehensive unit tests for Pyright fallback scenarios. Feedback on the changes suggests extracting the inline helper 'normalizeShimPath' inside 'resolvePackageBin' to the module level to reduce cognitive load and comply with the organization's 50 lines-of-code limit for functions.

Comment on lines +112 to +162
export function resolvePackageBin(packageRoot: string, name: string): string | undefined {
try {
const pkg = JSON.parse(
readFileSync(path.join(packageRoot, 'package.json'), 'utf8'),
) as { bin?: string | Record<string, string> }
const relativeTarget = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.[name]
if (!relativeTarget) { return undefined }

const target = path.resolve(packageRoot, relativeTarget)
if (!existsSync(target)) { return undefined }
const ext = process.platform === 'win32' ? '.cmd' : ''
const packageDir = path.dirname(packageRoot)
const nodeModules = path.basename(packageDir).startsWith('@')
? path.dirname(packageDir)
: packageDir
const shim = path.join(nodeModules, '.bin', `${name}${ext}`)
if (!existsSync(shim)) { return undefined }

// npm-style symlinks and package-manager-generated wrapper scripts are both
// valid shims. For wrappers, require a reference to the package's declared
// target so a stale or unrelated shared shim is never selected.
const stat = lstatSync(shim)
if (stat.isSymbolicLink()) {
const resolvedShim = path.resolve(path.dirname(shim), readlinkSync(shim))
return existsSync(resolvedShim)
&& realpathSync(resolvedShim) === realpathSync(target)
? shim
: undefined
}
const normalizeShimPath = (value: string): string => {
const normalized = value.replaceAll('\\', '/')
return process.platform === 'win32' ? normalized.toLowerCase() : normalized
}
const wrapper = normalizeShimPath(readFileSync(shim, 'utf8'))
// pnpm wrappers reference the package's real path in its .pnpm store, not the node_modules link.
const binDir = path.dirname(shim)
const realTarget = realpathSync(target)
const references = [
path.relative(binDir, target),
target,
path.relative(realpathSync(binDir), realTarget),
realTarget,
].map(normalizeShimPath)
return references.some(reference => wrapper.includes(reference))
? shim
: undefined
}
catch {
return undefined
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

[MEDIUM] Cognitive Overload: Extract inline helper to module level

Symptom — The resolvePackageBin function is 51 lines long, exceeding the organization's function length limit of 50 lines, and contains an inline helper function normalizeShimPath.
Source — Fowler — Refactoring (Long Method) / Organization Style Guide (Section 2, Code Limits).
Consequence — Having inline helper functions inside a relatively long method increases cognitive load, making the code harder to read, maintain, and test in isolation.
Remedy — Extract normalizeShimPath to the module level, which reduces the length of resolvePackageBin to 46 lines, keeping it under the 50 LOC limit.

const normalizeShimPath = (value: string): string => {
  const normalized = value.replaceAll('\\', '/')
  return process.platform === 'win32' ? normalized.toLowerCase() : normalized
}

/** Resolve a package-owned bin declared by the package and exposed by its shared shim. */
export function resolvePackageBin(packageRoot: string, name: string): string | undefined {
  try {
    const pkg = JSON.parse(
      readFileSync(path.join(packageRoot, 'package.json'), 'utf8'),
    ) as { bin?: string | Record<string, string> }
    const relativeTarget = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.[name]
    if (!relativeTarget) { return undefined }

    const target = path.resolve(packageRoot, relativeTarget)
    if (!existsSync(target)) { return undefined }
    const ext = process.platform === 'win32' ? '.cmd' : ''
    const packageDir = path.dirname(packageRoot)
    const nodeModules = path.basename(packageDir).startsWith('@')
      ? path.dirname(packageDir)
      : packageDir
    const shim = path.join(nodeModules, '.bin', `${name}${ext}`)
    if (!existsSync(shim)) { return undefined }

    // npm-style symlinks and package-manager-generated wrapper scripts are both
    // valid shims. For wrappers, require a reference to the package's declared
    // target so a stale or unrelated shared shim is never selected.
    const stat = lstatSync(shim)
    if (stat.isSymbolicLink()) {
      const resolvedShim = path.resolve(path.dirname(shim), readlinkSync(shim))
      return existsSync(resolvedShim)
        && realpathSync(resolvedShim) === realpathSync(target)
        ? shim
        : undefined
    }
    const wrapper = normalizeShimPath(readFileSync(shim, 'utf8'))
    // pnpm wrappers reference the package's real path in its .pnpm store, not the node_modules link.
    const binDir = path.dirname(shim)
    const realTarget = realpathSync(target)
    const references = [
      path.relative(binDir, target),
      target,
      path.relative(realpathSync(binDir), realTarget),
      realTarget,
    ].map(normalizeShimPath)
    return references.some(reference => wrapper.includes(reference))
      ? shim
      : undefined
  }
  catch {
    return undefined
  }
}
References
  1. Function length must be less than or equal to 50 lines of code. Extract helper functions to adhere to this limit. (link)

@amondnet
amondnet merged commit d4c6a15 into main Oct 23, 2026
10 checks passed
@amondnet
amondnet deleted the amondnet/147-pyright-local-bin branch October 23, 2026 11:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(lsp): pyright server ignores a project-local pyright-langserver in node_modules/.bin

1 participant