From 3d1271336ba003c2bf10fa74ab4df41ee0f6cd5b Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 17 May 2026 22:17:23 +0800 Subject: [PATCH 1/5] docs(rfc): standardize Vite+ project detection for editor extensions Defines a portable rule the four oxc editor extensions (oxc-vscode, oxc-zed, oxc-intellij-plugin, coc-oxc) can use to decide whether to launch `vp lint --lsp` / `vp fmt --lsp` instead of plain oxlint/oxfmt: locate the `vp` binary via each extension's existing bin-resolution chain, falling back to a package.json deps check. Includes the canonical rule, a reference TypeScript implementation, per-extension migration notes, design decisions, and a conformance fixture table. Refs #1557 --- rfcs/detect-vite-plus-project.md | 440 +++++++++++++++++++++++++++++++ 1 file changed, 440 insertions(+) create mode 100644 rfcs/detect-vite-plus-project.md diff --git a/rfcs/detect-vite-plus-project.md b/rfcs/detect-vite-plus-project.md new file mode 100644 index 0000000000..2c4135f70e --- /dev/null +++ b/rfcs/detect-vite-plus-project.md @@ -0,0 +1,440 @@ +# RFC: Standardized "Is this a Vite+ project?" detection for editor extensions + +> Tracking issue: [#1557](https://github.com/voidzero-dev/vite-plus/issues/1557) +> Status: **Draft for discussion** — not yet a final design. + +## Summary + +Define a single, portable rule that the four oxc editor extensions — +`oxc-vscode`, `oxc-zed`, `oxc-intellij-plugin`, `coc-oxc` — can use to +answer: _"Given this workspace folder, is it part of a Vite+ project?"_ +The rule decides whether the extension should launch `vp lint --lsp` / +`vp fmt --lsp` (instead of plain `oxlint` / `oxfmt`) and which executable +path to spawn. + +**The rule, in one sentence:** +A workspace is a **Vite+ project** iff the `vp` binary can be located +using the same resolution logic the extension already uses to find +`oxlint` / `oxfmt`. If `vp` is not resolvable, fall back to a +declarative check of +`package.json#{dependencies,devDependencies}.vite-plus`. + +## Motivation + +Issue #1557 deprecates the per-package `bin/oxlint` and `bin/oxfmt` +wrappers that `vite-plus` ships today +(`packages/cli/bin/oxlint`, `packages/cli/bin/oxfmt`). Editor extensions +currently lean on those wrappers — the package manager installs them +into `node_modules/.bin/`, so the same `findBinary("oxlint")` code path +that works for a plain oxlint project automatically picks up the +`vite.config.ts`-aware wrapper for a Vite+ project. Once the wrappers +go away, that implicit handoff breaks: each extension must explicitly +notice "this is a Vite+ project" and launch `vp lint --lsp` / +`vp fmt --lsp` instead. + +Without a shared rule, each extension reinvents it. Today the four +extensions have four different stories: + +- `oxc-zed` (`src/lsp.rs:28`) loops over `[package_name, "vite-plus"]` + in `package.json` deps and, on match, points at + `node_modules/vite-plus/bin/oxlint` (the wrapper that #1557 + deprecates). +- `oxc-intellij-plugin` has a dedicated + `viteplus/VitePlusPackage.kt` that resolves `vite-plus` via + IntelliJ's Node package descriptor and returns + `/bin/oxlint`. +- `oxc-vscode` (`client/findBinary.ts:96, 208`) has comments + acknowledging the Vite+ case but no explicit detection; it relies on + `node_modules/.bin/oxlint` being the wrapper bin. +- `coc-oxc` (`src/common.ts:30`) has no Vite+ awareness at all. + +## Insight + +Each extension **already has a battle-tested function for resolving a +Node CLI binary in a workspace** — that's how `findBinary("oxlint")` +works today. If we point the same function at `"vp"`, the answer to +"is this a Vite+ project?" falls out for free, **and the call site gets +the resolved `vp` binary path it needed anyway** to launch +`vp lint --lsp`. + +This avoids inventing a new "vite-plus marker" concept. The `vp` binary, +which `vite-plus` publishes via its `package.json#bin.vp` field, is the +canonical marker. + +## How each extension resolves a CLI today + +The four extensions all converge on roughly the same pattern, with +different fallbacks. + +### `oxc-vscode` — `client/findBinary.ts` + +``` +1. settingsBinary (user-configured `oxc..binPath`) + → searchSettingsBin() +2. node_modules/.bin/ in every workspace folder + → searchProjectNodeModulesBin() → searchNodeModulesDefaultBinPath() +3. node_modules/.bin/ from every nested package.json found in the workspace (monorepo) +4. require.resolve() anchored at workspace folders, then walk up to package.json#bin + → replaceTargetFromMainToBin() +5. Yarn PnP: load `.pnp.cjs` / `.pnp.js`, call `resolveRequest(, …)` + → findPnpApi(), searchYarnPnpBin() +6. Global node_modules from `npm root -g`, `pnpm root -g`, `~/.bun/install/global/node_modules` + → searchGlobalNodeModulesBin() +7. $PATH + → searchEnvPath() +``` + +The whole chain returns a `BinarySearchResult` with `{path, loader, yarnPnpLoaderPath?}`. + +### `coc-oxc` — `src/common.ts:23` + +```ts +function findBinary(config: ClientConfig): Optional { + const cfg = workspace.getConfiguration(`oxc.${config.name}`); + let bin = cfg.get('binPath', ''); + if (bin && existsSync(bin)) return bin; + bin = join(workspace.root, 'node_modules', '.bin', config.name); + return existsSync(bin) ? bin : null; +} +``` + +User setting → workspace `node_modules/.bin/`. That's it. + +### `oxc-zed` — `src/lsp.rs` + +```rust +fn get_workspace_exe_path(&self, worktree: &Worktree) -> Result> { + let package_json = worktree.read_text_file("package.json") + .unwrap_or(String::from(r#"{}"#)); + let package_json: Option = from_str(&package_json).ok(); + let package_name = self.get_package_name(); // "oxlint" or "oxfmt" + let workspace_root = Path::new(worktree.root_path().as_str()); + + for package_dir in [package_name.as_str(), "vite-plus"] { + if package_json.as_ref().is_some_and(|p| package_exists(p, package_dir)) { + return self.get_exe_path_from(workspace_root, package_dir, package_name.as_str()).map(Some); + } + } + Ok(None) +} +``` + +Zed reads `package.json` at the worktree root (Zed's WASM API cannot +list arbitrary `node_modules` contents — see zed#10760), checks deps +for `oxlint`/`oxfmt` first then falls back to `vite-plus`, and +constructs `node_modules//bin/`. Crucially Zed +_avoids_ `node_modules/.bin` because pnpm stores shell-script shims +there (see `lsp.rs:47`). + +### `oxc-intellij-plugin` — `viteplus/VitePlusPackage.kt` + +```kotlin +fun getPackage(virtualFile: VirtualFile?): NodePackage? { + // NodePackageDescriptor("vite-plus").listAvailable(...) + // or .findUnambiguousDependencyPackage(project) + // or NodePackage.findDefaultPackage(...) +} +fun findOxlintExecutable(virtualFile: VirtualFile): String? { + val pkg = getPackage(virtualFile) ?: return null + val path = pkg.getAbsolutePackagePathToRequire(project) ?: return null + return Paths.get(path, "bin/oxlint").toString() +} +``` + +IntelliJ already has a dedicated `VitePlusPackage` class that locates +the `vite-plus` package via the IDE's Node descriptor and returns +`/bin/oxlint` or `/bin/oxfmt`. This is the +strongest existing precedent for the "vp binary as marker" model. + +### Common shape + +Despite the different surface areas, every extension's resolution chain +includes one or more of: + +- a **user-configured override** path (highest priority); +- a **workspace `node_modules` lookup** for the target package; +- an optional **`require.resolve` / IDE-package-descriptor** fallback; +- (some) **PnP / global / `$PATH`** fallbacks. + +What we standardize is **what target name** they look up, not _how_ +they look it up. + +## The canonical rule + +``` +fn detect_vite_plus_project(workspace_root: AbsolutePath) -> Option: + # Signal #1: locate the `vp` binary. + # Each extension plugs in its own existing bin-resolution chain, + # parameterized by the target name "vp" instead of "oxlint"/"oxfmt". + if let Some(vp) = find_binary("vp", workspace_root): + return Some({ + root: workspace_root, + vp_path: vp.path, + reason: "vp-binary-found", + }) + + # Signal #2: declarative fallback for pre-install / git-fresh clones, + # Yarn PnP without `node_modules`, and CI before `pnpm install`. + if walk_up_finds_vite_plus_in_deps(workspace_root, &mut root_out): + return Some({ + root: root_out, + vp_path: None, + reason: "declared-in-package-json", + }) + + return None +``` + +Where: + +- `find_binary("vp", root)` means **the extension's existing + `findBinary("oxlint", root)` code path, called with `"vp"` as the + target.** The extension keeps its own search order, its own PnP + support, its own user-setting handling — we standardize the target + name, nothing else. +- `walk_up_finds_vite_plus_in_deps` walks from `root` up to (and + including) the nearest workspace root (`pnpm-workspace.yaml`, + `package.json#workspaces`, or `lerna.json`), checking each + `package.json` for `vite-plus` in `dependencies` or + `devDependencies`. + +### Why this rule + +- **The `vp` binary is the strongest evidence Vite+ is actually present + and runnable.** No `vite.config.ts`, no `vite-task.json`, no + hand-maintained marker file required. +- **Every extension already has the lookup code.** Zero new + infrastructure in any of the four — they call their existing function + with a different argument. +- **It survives pnpm's shell-shim layout, npm hoisting, Yarn PnP, + monorepos, and global installs**, because each extension's + resolution chain was already designed for `oxlint`/`oxfmt` and + inherits the same robustness. +- **The fallback handles pre-install state** — `package.json` is the + source of truth before `node_modules` exists. This matters for CI + workflows that lint before `pnpm install`. + +### What we deliberately do **not** check + +- `vite.config.ts` / `vite-task.json` — exist in plain-Vite projects. +- `.oxlintrc.json` / `.oxfmtrc.json` — exist in plain-oxlint projects. +- `node_modules/.bin/oxlint` being the wrapper bin — #1557 deletes those. +- A globally-installed `vp` on `$PATH` alone — globally available `vp` + does not mean the workspace uses it. Whether to count `$PATH` as + positive detection is a per-extension call (see "Open questions"). + +## Reference TypeScript implementation + +`oxc-vscode` and `coc-oxc` can copy this directly into their codebase +and adapt it to their existing `findBinary` chains. ~50 lines, zero +non-stdlib dependencies. + +```ts +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +export interface DetectResult { + /** Absolute path of the ancestor that owns vite-plus. */ + root: string; + /** Absolute path to the resolved `vp` executable, if Signal #1 fired. */ + vpPath?: string; + reason: 'vp-binary-found' | 'declared-in-package-json'; +} + +function isWorkspaceRoot(dir: string): boolean { + if (existsSync(join(dir, 'pnpm-workspace.yaml'))) return true; + if (existsSync(join(dir, 'lerna.json'))) return true; + try { + const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')); + if (pkg.workspaces) return true; + } catch {} + return false; +} + +function readDeps(pkgPath: string): Record | null { + try { + const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')); + return { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) }; + } catch { + return null; + } +} + +export function detectVitePlusProjectSync(start: string): DetectResult | null { + // Walk up once; check both signals at each ancestor. + // Stop walking after the first workspace root we encounter. + let dir = start; + let stopAfterThis = false; + while (true) { + // Signal #1: real binary. + const vpPath = join(dir, 'node_modules', 'vite-plus', 'bin', 'vp'); + if (existsSync(vpPath)) { + return { root: dir, vpPath, reason: 'vp-binary-found' }; + } + // Signal #2: declared in package.json. + const deps = readDeps(join(dir, 'package.json')); + if (deps && deps['vite-plus']) { + return { root: dir, reason: 'declared-in-package-json' }; + } + if (stopAfterThis) return null; + if (isWorkspaceRoot(dir)) stopAfterThis = true; + const parent = dirname(dir); + if (parent === dir) return null; + dir = parent; + } +} +``` + +The async variant is the same algorithm with `fs.promises` — left as an +exercise for the consumer. + +## Per-extension migration plan + +Each extension keeps its existing bin-resolution code and adds a thin +"detect Vite+ first" pass on top. + +### `oxc-vscode` + +```ts +// Before each tool's startup: +// call findBinary("vp", workspaceFolders) through the existing chain. +// If found, launch ` lint --lsp` (oxlint case) / ` fmt --lsp` (oxfmt case). +// If not found, fall back to package.json deps check, then to the existing oxlint/oxfmt chain. +``` + +The new logic is roughly: one extra call to the existing `findBinary` +with `"vp"` as the target, plus a small `package.json` deps walk-up +for the pre-install fallback. ~30 lines total. + +### `coc-oxc` + +```ts +// In findBinary(), before the node_modules/.bin lookup: +// 1. Check workspace.root/node_modules/.bin/vp → exists? launch vp --lsp +// 2. Else, parse workspace.root/package.json → vite-plus declared? same. +// 3. Else, fall through to existing logic. +``` + +~15 lines added. + +### `oxc-zed` + +Zed cannot use Node packages anyway. The existing +`get_workspace_exe_path` loop already iterates +`[package_name, "vite-plus"]`. Change the `package_dir == "vite-plus"` +branch to return `/node_modules/vite-plus/bin/vp` and invoke it +with `["lint", "--lsp"]` (or `["fmt", "--lsp"]`). The detection class +doesn't need to be rewritten — just its target path and launch args. + +### `oxc-intellij-plugin` + +The existing `VitePlusPackage.kt` already resolves the `vite-plus` +package and returns `vite-plus/bin/oxlint`. After this RFC, it returns +`vite-plus/bin/vp` and is invoked with `lint --lsp` / `fmt --lsp`. + +## Decisions + +### "Find the `vp` binary" is the primary signal + +Locked. Replaces the earlier proposal of "stat +`node_modules/vite-plus/package.json`," which was functionally +equivalent but conceptually weaker — the binary's existence is what +actually matters for invocation, and every extension already has the +lookup machinery. + +### Hybrid two-signal algorithm + +Locked. Signal #1 (`vp` binary) + Signal #2 (declared in package.json). +Rejected alternatives: + +- **Signal #1 alone** — wrong answer on fresh clones / CI before install. +- **Signal #2 alone** — slower (always parses JSON) and ignores the + evidence that Vite+ is actually installed and runnable. +- **A new manifest file** (`vite-plus.json` / `.vite-plus`) — adds a + hand-maintained marker that can drift from the install state. + +### Workspace-wide granularity + +If any ancestor up to the workspace root resolves `vp` or declares +`vite-plus`, the entire workspace is Vite+. Editor LSPs operate at +workspace granularity; per-package granularity would surprise users by +toggling LSP behaviour as they move between folders. + +### Avoid `node_modules/.bin/vp` in the reference and in Zed + +Mirroring oxc-zed's choice (`lsp.rs:47`): point at +`/node_modules/vite-plus/bin/vp`, not `node_modules/.bin/vp`, +because pnpm stores shell-script shims in `.bin` that don't behave +like real Node binaries when invoked headlessly. Extensions whose own +chain (like oxc-vscode) does prefer `.bin` are free to keep it — they +resolve to the same underlying entry. + +### Yarn PnP deferred to v2 + +Berry with PnP has no `node_modules`. Signal #1 in the simple +walk-up fails; PnP users still detect correctly via Signal #2 (deps +check). Explicit `.pnp.cjs` lookup is deferred. **Note:** oxc-vscode +has its own PnP support in `searchYarnPnpBin` — when oxc-vscode calls +`findBinary("vp")` through its own chain it will get PnP for free. + +## Downstream coordination + +Each extension's own repo owns its PR and its own test fixtures. + +- `oxc-vscode` PR: extend the existing `findBinary` chain with `"vp"` + as a target; route through `vp lint --lsp` / `vp fmt --lsp` when + found. +- `coc-oxc` PR: add the ~15-line Vite+ check before the existing + `node_modules/.bin` lookup. +- `oxc-zed` PR: change the `package_dir == "vite-plus"` branch in + `lsp.rs:28` to target `bin/vp` with `--lsp` args plumbed through + `language_server_command`. +- `oxc-intellij-plugin` PR: keep `VitePlusPackage.kt`; change + `findOxlintExecutable` / `findOxfmtExecutable` to return `bin/vp` + and update the launch args. + +## Open questions + +1. **`$PATH` as positive evidence.** Some chains (oxc-vscode) fall back + to `$PATH`. If the only place `vp` exists is `$PATH` (i.e. globally + installed, not in the project), should that count as positive + detection? Proposal: **no** — defer to the package.json fallback. +2. **Caching policy** in editor extensions — documented best-practice + only, or also illustrated in the reference snippet (an opt-in + memoizing variant with a watcher-invalidation hook)? +3. **Zed launch args plumbing.** The `--lsp` switch is already there + for oxlint/oxfmt; for `vp` we need to pass `["lint", "--lsp"]` / + `["fmt", "--lsp"]`. The Zed extension API accepts this via + `Command { command, args, env }` — confirmed in `oxlint.rs:29-34`. +4. **Transitive-install false positives.** Someone could pull + `vite-plus` in transitively. Signal #1 still fires. Proposal: + accept it — `vp lint --lsp` degrades to plain oxlint behaviour + when no `vite.config.ts` is present. +5. **"Installed but not configured."** Should we additionally require + `vite.config.ts` to exist? Proposal: **no**. Presence of `vp` is + intent enough. + +## Conformance fixtures + +Every implementation must produce identical answers on the following +fixtures. Each extension replicates the set inside its own test suite. + +| Fixture | Tree | Expected `detectVitePlusProject` result | +| ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `pnpm-root-installed` | `pnpm-workspace.yaml` + root `package.json` + `node_modules/vite-plus/bin/vp` + `node_modules/vite-plus/package.json` + a `packages/app/package.json` subpackage | `{ root: "", vpPath: "/node_modules/vite-plus/bin/vp", reason: "vp-binary-found" }` regardless of whether detection starts from the root or from `packages/app/` | +| `pnpm-root-declared-no-install` | `pnpm-workspace.yaml` + root `package.json` declaring `vite-plus`, no `node_modules` | `{ root: "", reason: "declared-in-package-json" }` | +| `npm-package-installed` | Root `package.json` with `workspaces`, `node_modules/vite-plus/...` inside `packages/app/` (un-hoisted) | Detection from inside `packages/app/` returns `vp-binary-found` rooted at `packages/app` | +| `yarn1-workspaces` | yarn1-style hoisting, `node_modules/vite-plus/` at root | `vp-binary-found` rooted at the workspace root | +| `yarn4-pnp` | Berry/PnP, no `node_modules`, `vite-plus` declared in root `package.json` | `declared-in-package-json` rooted at the workspace root (Signal #2 fallback) | +| `plain-non-vite-plus` | A normal Node project, no Vite+ anywhere | `null` | +| `plain-vite-no-vp` | Uses Vite but not Vite+ (`vite` in deps, `vite.config.ts` present, no `vite-plus`) | `null` | +| `transitive-install` | `vite-plus` only present as a transitive dep (in `node_modules` but not declared in any walked-up `package.json`) | `vp-binary-found` — documents v1 behaviour; accepted as a false-positive trade | +| `bin-vp-without-package-json` | `node_modules/vite-plus/bin/vp` exists but `package.json` is missing or malformed | `null` | + +## Verification plan + +1. **Each downstream PR** replicates the fixture table above inside its + own test suite and asserts the expected detector result. +2. **Manual editor smoke test** before each downstream PR is merged: + point the extension at a real Vite+ project and at a plain-oxlint + project; verify correct LSP routing in both. From 7066cedbf6e063dc7df7c9e0d54dc2bef6de3c94 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 17 May 2026 22:25:01 +0800 Subject: [PATCH 2/5] docs(rfc): rename to editor-extension-vite-plus-detection.md Clarifies that the RFC's scope is the four oxc editor extensions, not detection across all Vite+ tooling. --- ...te-plus-project.md => editor-extension-vite-plus-detection.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename rfcs/{detect-vite-plus-project.md => editor-extension-vite-plus-detection.md} (100%) diff --git a/rfcs/detect-vite-plus-project.md b/rfcs/editor-extension-vite-plus-detection.md similarity index 100% rename from rfcs/detect-vite-plus-project.md rename to rfcs/editor-extension-vite-plus-detection.md From 382a0175c7dd6af2312637993ebf2101d8d89e58 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 17 May 2026 22:26:48 +0800 Subject: [PATCH 3/5] docs(rfc): retitle to "Vite+ Project Detection for Editor Extensions" Drops the quoted question from the H1 and parallels the noun-phrase style used by other RFCs (e.g. package-manager-detection.md). --- rfcs/editor-extension-vite-plus-detection.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rfcs/editor-extension-vite-plus-detection.md b/rfcs/editor-extension-vite-plus-detection.md index 2c4135f70e..7f38ebf88e 100644 --- a/rfcs/editor-extension-vite-plus-detection.md +++ b/rfcs/editor-extension-vite-plus-detection.md @@ -1,4 +1,4 @@ -# RFC: Standardized "Is this a Vite+ project?" detection for editor extensions +# RFC: Vite+ Project Detection for Editor Extensions > Tracking issue: [#1557](https://github.com/voidzero-dev/vite-plus/issues/1557) > Status: **Draft for discussion** — not yet a final design. From b6b19d130e66b9451024049e340e6fa72c2228fe Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 17 May 2026 22:45:17 +0800 Subject: [PATCH 4/5] docs(rfc): tighten Vite+ detection per adversarial review Three correctness fixes uncovered by adversarial review: 1. Signal #1 is now strictly project-scoped. User-configured binPath overrides, global node_modules, and $PATH are explicitly excluded so that a globally-installed `vp` or a `vp` on $PATH cannot classify an unrelated workspace as Vite+. Locked as a decision and reflected in every per-extension migration plan. 2. The walk-up now stops AT the workspace root, not after. The previous reference loop set a "stop after this" flag and still advanced to the parent, which would let a nested checkout inherit a vite-plus install from its outer parent directory. Added a `parent-vite-plus-nested-repo` conformance fixture. 3. Signal #1 now requires a valid `vite-plus` package at the resolved ancestor: `node_modules/vite-plus/package.json` must parse and have `name === "vite-plus"`. The conformance fixture `bin-vp-without-package-json` previously required `null` but the reference code returned `vp-binary-found`; both are now consistent. Added `bin-vp-with-malformed-package`, `global-vp-on-path`, and `user-binpath-override` fixtures. Refs #1557 --- rfcs/editor-extension-vite-plus-detection.md | 209 +++++++++++++------ 1 file changed, 150 insertions(+), 59 deletions(-) diff --git a/rfcs/editor-extension-vite-plus-detection.md b/rfcs/editor-extension-vite-plus-detection.md index 7f38ebf88e..ed6cf39bd7 100644 --- a/rfcs/editor-extension-vite-plus-detection.md +++ b/rfcs/editor-extension-vite-plus-detection.md @@ -187,16 +187,40 @@ fn detect_vite_plus_project(workspace_root: AbsolutePath) -> Option.binPath` — those settings target + `oxlint`/`oxfmt`, not `vp`, and reusing them with `"vp"` as the + target is meaningless. + +- Additionally, the install must be a real `vite-plus` package: at the + ancestor where `bin/vp` is found, `node_modules/vite-plus/package.json` + must parse and have `name === "vite-plus"`. This rules out orphan + files left behind by a partial uninstall or by hand-crafted + directories. + - `walk_up_finds_vite_plus_in_deps` walks from `root` up to (and including) the nearest workspace root (`pnpm-workspace.yaml`, `package.json#workspaces`, or `lerna.json`), checking each `package.json` for `vite-plus` in `dependencies` or - `devDependencies`. + `devDependencies`. **The walk stops at the workspace root** — it + does not cross into the parent of the workspace, even when no + ancestor declares `vite-plus`. A workspace whose grandparent + directory happens to have a `vite-plus` install is not itself a + Vite+ project. ### Why this rule @@ -219,22 +243,27 @@ Where: - `vite.config.ts` / `vite-task.json` — exist in plain-Vite projects. - `.oxlintrc.json` / `.oxfmtrc.json` — exist in plain-oxlint projects. - `node_modules/.bin/oxlint` being the wrapper bin — #1557 deletes those. -- A globally-installed `vp` on `$PATH` alone — globally available `vp` - does not mean the workspace uses it. Whether to count `$PATH` as - positive detection is a per-extension call (see "Open questions"). +- A globally-installed `vp` on `$PATH`, a `vp` in the user's global + `node_modules`, or a user-configured `oxc..binPath`. None of + these tell us anything about whether _this workspace_ uses Vite+. +- A `node_modules/vite-plus/` directory that doesn't actually contain + a valid `vite-plus` package (parseable `package.json` with + `name === "vite-plus"`). Orphan trees from partial uninstalls do not + count. +- Any ancestor above the workspace root. The walk stops there. ## Reference TypeScript implementation `oxc-vscode` and `coc-oxc` can copy this directly into their codebase -and adapt it to their existing `findBinary` chains. ~50 lines, zero -non-stdlib dependencies. +and adapt it to their existing `findBinary` chains. Pure stdlib, no +runtime dependencies. ```ts import { existsSync, readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; export interface DetectResult { - /** Absolute path of the ancestor that owns vite-plus. */ + /** Absolute path of the workspace ancestor that owns vite-plus. */ root: string; /** Absolute path to the resolved `vp` executable, if Signal #1 fired. */ vpPath?: string; @@ -260,15 +289,34 @@ function readDeps(pkgPath: string): Record | null { } } +/** + * Signal #1 acceptance check: `bin/vp` must exist AND + * `node_modules/vite-plus/package.json` must parse and identify itself + * as the `vite-plus` package. Rejects orphan directories left behind + * by partial uninstalls or hand-crafted trees. + */ +function isValidVitePlusInstall(dir: string): string | null { + const vpPath = join(dir, 'node_modules', 'vite-plus', 'bin', 'vp'); + if (!existsSync(vpPath)) return null; + try { + const pkg = JSON.parse( + readFileSync(join(dir, 'node_modules', 'vite-plus', 'package.json'), 'utf8'), + ); + if (pkg?.name !== 'vite-plus') return null; + } catch { + return null; + } + return vpPath; +} + export function detectVitePlusProjectSync(start: string): DetectResult | null { - // Walk up once; check both signals at each ancestor. - // Stop walking after the first workspace root we encounter. + // Walk up; check both signals at each ancestor. Stop AT the workspace + // root — do not cross into its parent. let dir = start; - let stopAfterThis = false; while (true) { - // Signal #1: real binary. - const vpPath = join(dir, 'node_modules', 'vite-plus', 'bin', 'vp'); - if (existsSync(vpPath)) { + // Signal #1: real, validated binary. + const vpPath = isValidVitePlusInstall(dir); + if (vpPath) { return { root: dir, vpPath, reason: 'vp-binary-found' }; } // Signal #2: declared in package.json. @@ -276,8 +324,8 @@ export function detectVitePlusProjectSync(start: string): DetectResult | null { if (deps && deps['vite-plus']) { return { root: dir, reason: 'declared-in-package-json' }; } - if (stopAfterThis) return null; - if (isWorkspaceRoot(dir)) stopAfterThis = true; + // Stop AT the workspace root, not after. + if (isWorkspaceRoot(dir)) return null; const parent = dirname(dir); if (parent === dir) return null; dir = parent; @@ -295,42 +343,59 @@ Each extension keeps its existing bin-resolution code and adds a thin ### `oxc-vscode` +Add a Vite+ detection pass that runs **before** the existing +oxlint/oxfmt `findBinary` chain. Detection must reuse only the +**project-scoped** parts of that chain — steps 2 through 5 in the +existing chain (workspace `node_modules/.bin`, monorepo +`node_modules/.bin`, workspace-anchored `require.resolve`, and Yarn +PnP loaded from a workspace `.pnp.cjs`). It must **not** consult +`searchSettingsBin`, `searchGlobalNodeModulesBin`, or `searchEnvPath` +when the target is `"vp"`. + ```ts -// Before each tool's startup: -// call findBinary("vp", workspaceFolders) through the existing chain. -// If found, launch ` lint --lsp` (oxlint case) / ` fmt --lsp` (oxfmt case). -// If not found, fall back to package.json deps check, then to the existing oxlint/oxfmt chain. +// On tool startup, for each workspaceFolder: +// 1. Run the project-scoped subset of findBinary with target "vp". +// Validate node_modules/vite-plus/package.json before accepting. +// 2. If not found, walk up from workspaceFolder to the workspace root +// checking package.json deps for "vite-plus". +// 3. If either signal fires, launch ` lint --lsp` (or `fmt --lsp`). +// Otherwise, fall through to the existing oxlint/oxfmt chain. ``` -The new logic is roughly: one extra call to the existing `findBinary` -with `"vp"` as the target, plus a small `package.json` deps walk-up -for the pre-install fallback. ~30 lines total. - ### `coc-oxc` +Add a check before the existing `node_modules/.bin` lookup. Reuse the +reference algorithm directly; **do not** consult `oxc..binPath` +when looking for `vp` (that setting targets oxlint/oxfmt). + ```ts // In findBinary(), before the node_modules/.bin lookup: -// 1. Check workspace.root/node_modules/.bin/vp → exists? launch vp --lsp -// 2. Else, parse workspace.root/package.json → vite-plus declared? same. +// 1. From workspace.root, run the reference detector: +// - check node_modules/vite-plus/bin/vp + validate package.json +// - walk up to the workspace root checking package.json deps +// 2. If positive, launch vp --lsp. // 3. Else, fall through to existing logic. ``` -~15 lines added. - ### `oxc-zed` -Zed cannot use Node packages anyway. The existing -`get_workspace_exe_path` loop already iterates +Zed's existing `get_workspace_exe_path` loop already iterates `[package_name, "vite-plus"]`. Change the `package_dir == "vite-plus"` branch to return `/node_modules/vite-plus/bin/vp` and invoke it -with `["lint", "--lsp"]` (or `["fmt", "--lsp"]`). The detection class -doesn't need to be rewritten — just its target path and launch args. +with `["lint", "--lsp"]` (or `["fmt", "--lsp"]`). Add the package.json +parse check (`name === "vite-plus"`) to reject orphan trees. Zed is +already project-scoped (it only reads the worktree root), so no +exclusion of `$PATH` is needed. ### `oxc-intellij-plugin` -The existing `VitePlusPackage.kt` already resolves the `vite-plus` -package and returns `vite-plus/bin/oxlint`. After this RFC, it returns -`vite-plus/bin/vp` and is invoked with `lint --lsp` / `fmt --lsp`. +The existing `VitePlusPackage.kt` resolves `vite-plus` through +IntelliJ's `NodePackageDescriptor`, which is naturally project-scoped +(it consults the project's interpreter and dependency graph). Change +`findOxlintExecutable` / `findOxfmtExecutable` to return +`vite-plus/bin/vp` and update the launch args to `lint --lsp` / +`fmt --lsp`. Continue using the IDE's package resolution rather than +walking the filesystem. ## Decisions @@ -375,7 +440,33 @@ Berry with PnP has no `node_modules`. Signal #1 in the simple walk-up fails; PnP users still detect correctly via Signal #2 (deps check). Explicit `.pnp.cjs` lookup is deferred. **Note:** oxc-vscode has its own PnP support in `searchYarnPnpBin` — when oxc-vscode calls -`findBinary("vp")` through its own chain it will get PnP for free. +the project-scoped subset of `findBinary("vp")` through its own +chain, the workspace-anchored PnP lookup is included. + +### Signal #1 is strictly project-scoped + +User-configured override paths (`oxc..binPath`), global +`node_modules` (`npm root -g`, `pnpm root -g`, bun global), and `$PATH` +are explicitly **excluded** from Signal #1. None of them say anything +about whether _this workspace_ uses Vite+. A globally installed `vp` +or a `vp` shim on `$PATH` does not turn an unrelated workspace into a +Vite+ project. + +### Signal #1 requires a valid `vite-plus` package, not just `bin/vp` + +At the ancestor where `bin/vp` is found, +`node_modules/vite-plus/package.json` must parse and have +`name === "vite-plus"`. Orphan `bin/vp` files (partial uninstall, hand +crafted directories, stale caches) do not count. + +### Walk stops at the workspace root + +Once the walk-up evaluates a directory that is itself a workspace root +(`pnpm-workspace.yaml`, `package.json#workspaces`, or `lerna.json`), +the walk terminates. We do not check the parent. Otherwise a nested +checkout placed under a parent that happens to have its own +`vite-plus` install would inherit Vite+ behaviour from a completely +unrelated workspace. ## Downstream coordination @@ -395,22 +486,18 @@ Each extension's own repo owns its PR and its own test fixtures. ## Open questions -1. **`$PATH` as positive evidence.** Some chains (oxc-vscode) fall back - to `$PATH`. If the only place `vp` exists is `$PATH` (i.e. globally - installed, not in the project), should that count as positive - detection? Proposal: **no** — defer to the package.json fallback. -2. **Caching policy** in editor extensions — documented best-practice +1. **Caching policy** in editor extensions — documented best-practice only, or also illustrated in the reference snippet (an opt-in memoizing variant with a watcher-invalidation hook)? -3. **Zed launch args plumbing.** The `--lsp` switch is already there +2. **Zed launch args plumbing.** The `--lsp` switch is already there for oxlint/oxfmt; for `vp` we need to pass `["lint", "--lsp"]` / `["fmt", "--lsp"]`. The Zed extension API accepts this via `Command { command, args, env }` — confirmed in `oxlint.rs:29-34`. -4. **Transitive-install false positives.** Someone could pull +3. **Transitive-install false positives.** Someone could pull `vite-plus` in transitively. Signal #1 still fires. Proposal: accept it — `vp lint --lsp` degrades to plain oxlint behaviour when no `vite.config.ts` is present. -5. **"Installed but not configured."** Should we additionally require +4. **"Installed but not configured."** Should we additionally require `vite.config.ts` to exist? Proposal: **no**. Presence of `vp` is intent enough. @@ -419,17 +506,21 @@ Each extension's own repo owns its PR and its own test fixtures. Every implementation must produce identical answers on the following fixtures. Each extension replicates the set inside its own test suite. -| Fixture | Tree | Expected `detectVitePlusProject` result | -| ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `pnpm-root-installed` | `pnpm-workspace.yaml` + root `package.json` + `node_modules/vite-plus/bin/vp` + `node_modules/vite-plus/package.json` + a `packages/app/package.json` subpackage | `{ root: "", vpPath: "/node_modules/vite-plus/bin/vp", reason: "vp-binary-found" }` regardless of whether detection starts from the root or from `packages/app/` | -| `pnpm-root-declared-no-install` | `pnpm-workspace.yaml` + root `package.json` declaring `vite-plus`, no `node_modules` | `{ root: "", reason: "declared-in-package-json" }` | -| `npm-package-installed` | Root `package.json` with `workspaces`, `node_modules/vite-plus/...` inside `packages/app/` (un-hoisted) | Detection from inside `packages/app/` returns `vp-binary-found` rooted at `packages/app` | -| `yarn1-workspaces` | yarn1-style hoisting, `node_modules/vite-plus/` at root | `vp-binary-found` rooted at the workspace root | -| `yarn4-pnp` | Berry/PnP, no `node_modules`, `vite-plus` declared in root `package.json` | `declared-in-package-json` rooted at the workspace root (Signal #2 fallback) | -| `plain-non-vite-plus` | A normal Node project, no Vite+ anywhere | `null` | -| `plain-vite-no-vp` | Uses Vite but not Vite+ (`vite` in deps, `vite.config.ts` present, no `vite-plus`) | `null` | -| `transitive-install` | `vite-plus` only present as a transitive dep (in `node_modules` but not declared in any walked-up `package.json`) | `vp-binary-found` — documents v1 behaviour; accepted as a false-positive trade | -| `bin-vp-without-package-json` | `node_modules/vite-plus/bin/vp` exists but `package.json` is missing or malformed | `null` | +| Fixture | Tree | Expected `detectVitePlusProject` result | +| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `pnpm-root-installed` | `pnpm-workspace.yaml` + root `package.json` + `node_modules/vite-plus/bin/vp` + `node_modules/vite-plus/package.json` + a `packages/app/package.json` subpackage | `{ root: "", vpPath: "/node_modules/vite-plus/bin/vp", reason: "vp-binary-found" }` regardless of whether detection starts from the root or from `packages/app/` | +| `pnpm-root-declared-no-install` | `pnpm-workspace.yaml` + root `package.json` declaring `vite-plus`, no `node_modules` | `{ root: "", reason: "declared-in-package-json" }` | +| `npm-package-installed` | Root `package.json` with `workspaces`, `node_modules/vite-plus/...` inside `packages/app/` (un-hoisted) | Detection from inside `packages/app/` returns `vp-binary-found` rooted at `packages/app` | +| `yarn1-workspaces` | yarn1-style hoisting, `node_modules/vite-plus/` at root | `vp-binary-found` rooted at the workspace root | +| `yarn4-pnp` | Berry/PnP, no `node_modules`, `vite-plus` declared in root `package.json` | `declared-in-package-json` rooted at the workspace root (Signal #2 fallback) | +| `plain-non-vite-plus` | A normal Node project, no Vite+ anywhere | `null` | +| `plain-vite-no-vp` | Uses Vite but not Vite+ (`vite` in deps, `vite.config.ts` present, no `vite-plus`) | `null` | +| `transitive-install` | `vite-plus` only present as a transitive dep (in `node_modules` but not declared in any walked-up `package.json`) | `vp-binary-found` — documents v1 behaviour; accepted as a false-positive trade | +| `bin-vp-without-package-json` | `node_modules/vite-plus/bin/vp` exists but `node_modules/vite-plus/package.json` is missing | `null` | +| `bin-vp-with-malformed-package` | `bin/vp` + `package.json` exists but `package.json` is unparseable or has `name !== "vite-plus"` | `null` | +| `parent-vite-plus-nested-repo` | Outer dir has `node_modules/vite-plus/...` + declares `vite-plus`; inner subdirectory is its own workspace root (own `pnpm-workspace.yaml`/`package.json#workspaces`) without `vite-plus` | Detection from inside the nested workspace returns `null` — the walk stops at the inner workspace root and does not see the outer install | +| `global-vp-on-path` | A plain Node project; `vp` is installed globally (on `$PATH` and/or in the user's global `node_modules`); no workspace-local `node_modules/vite-plus` | `null` — Signal #1 ignores `$PATH` and global installs | +| `user-binpath-override` | A plain Node project; `oxc.oxlint.binPath` is configured to point at a `vp` binary; no workspace-local `vite-plus` | `null` — Signal #1 ignores user-configured override paths | ## Verification plan From 64c02ba67b24fec8e78299bfe954a8e2bc51d99d Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 17 May 2026 22:48:41 +0800 Subject: [PATCH 5/5] docs(rfc): simplify detector reference impl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two cleanups from /simplify review: - Read package.json once per walked-up directory (workspace-root check and deps check now share a single parse), eliminating a double filesystem read per ancestor. - Drop the inline "Signal #1" / "Signal #2" / "Stop AT workspace root" comments — they narrate what the code already says via the variable names and control flow. No behavioural change. The doc comment on isValidVitePlusInstall is kept because it captures non-obvious intent (rejecting orphan trees). Refs #1557 --- rfcs/editor-extension-vite-plus-detection.md | 39 +++++++------------- 1 file changed, 14 insertions(+), 25 deletions(-) diff --git a/rfcs/editor-extension-vite-plus-detection.md b/rfcs/editor-extension-vite-plus-detection.md index ed6cf39bd7..fb09d24f68 100644 --- a/rfcs/editor-extension-vite-plus-detection.md +++ b/rfcs/editor-extension-vite-plus-detection.md @@ -270,30 +270,24 @@ export interface DetectResult { reason: 'vp-binary-found' | 'declared-in-package-json'; } -function isWorkspaceRoot(dir: string): boolean { - if (existsSync(join(dir, 'pnpm-workspace.yaml'))) return true; - if (existsSync(join(dir, 'lerna.json'))) return true; - try { - const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')); - if (pkg.workspaces) return true; - } catch {} - return false; -} - -function readDeps(pkgPath: string): Record | null { +function readPackageJson(dir: string): any | null { try { - const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')); - return { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) }; + return JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')); } catch { return null; } } +function isWorkspaceRoot(dir: string, pkg: any | null): boolean { + if (existsSync(join(dir, 'pnpm-workspace.yaml'))) return true; + if (existsSync(join(dir, 'lerna.json'))) return true; + return Boolean(pkg?.workspaces); +} + /** - * Signal #1 acceptance check: `bin/vp` must exist AND - * `node_modules/vite-plus/package.json` must parse and identify itself - * as the `vite-plus` package. Rejects orphan directories left behind - * by partial uninstalls or hand-crafted trees. + * `bin/vp` must exist AND `node_modules/vite-plus/package.json` must + * parse and identify itself as the `vite-plus` package. Rejects orphan + * directories left behind by partial uninstalls or hand-crafted trees. */ function isValidVitePlusInstall(dir: string): string | null { const vpPath = join(dir, 'node_modules', 'vite-plus', 'bin', 'vp'); @@ -310,22 +304,17 @@ function isValidVitePlusInstall(dir: string): string | null { } export function detectVitePlusProjectSync(start: string): DetectResult | null { - // Walk up; check both signals at each ancestor. Stop AT the workspace - // root — do not cross into its parent. let dir = start; while (true) { - // Signal #1: real, validated binary. const vpPath = isValidVitePlusInstall(dir); if (vpPath) { return { root: dir, vpPath, reason: 'vp-binary-found' }; } - // Signal #2: declared in package.json. - const deps = readDeps(join(dir, 'package.json')); - if (deps && deps['vite-plus']) { + const pkg = readPackageJson(dir); + if (pkg?.dependencies?.['vite-plus'] || pkg?.devDependencies?.['vite-plus']) { return { root: dir, reason: 'declared-in-package-json' }; } - // Stop AT the workspace root, not after. - if (isWorkspaceRoot(dir)) return null; + if (isWorkspaceRoot(dir, pkg)) return null; const parent = dirname(dir); if (parent === dir) return null; dir = parent;