From d15d388fbf84495274ed756e5889c29c69c5cee3 Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 18 May 2026 10:54:44 +0800 Subject: [PATCH 1/4] docs(rfc): add "Vite+ Project Detection for Editor Extensions" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defines a portable rule the four oxc editor extensions (oxc-vscode, oxc-zed, oxc-intellij-plugin, coc-oxc) use to decide whether to launch `vp lint --lsp` / `vp fmt --lsp` instead of plain oxlint / oxfmt, and to resolve which executable to spawn. Core algorithm (two phases, both bounded by the workspace root): 1. Phase 1 — find the package.json that DIRECTLY declares vite-plus in dependencies or devDependencies. A transitively hoisted `node_modules/vite-plus/` does not count. 2. Phase 2 — walk up from the declaring ancestor looking for a real `node_modules/vite-plus/bin/vp` with a sibling `package.json` that parses and has `name === "vite-plus"`. Result is a tri-state: `null` (not Vite+), `{ root, vpPath }` (Vite+ and runnable), or `{ root }` (declared but not yet installed — editors fall back to plain oxlint/oxfmt and never launch a bare vp). Distribution: for the Node-capable extensions (oxc-vscode, coc-oxc) the detector ships as a shared bundled-devDependency package (@voidzero-dev/detect-vite-plus, name TBD) at `packages/detect-vite-plus/`. oxc-zed (Rust) and oxc-intellij-plugin (Kotlin) port the same algorithm against the shared conformance fixtures. The RFC also documents: - The four extensions' existing bin-resolution chains with code excerpts and file:line citations. - A Mermaid flowchart of the two-phase algorithm for cross-language porters. - The workspace-root marker set (`pnpm-workspace.yaml`, `package.json#workspaces`, `lerna.json`) and a known parity gap with vite-task that this RFC does not block on. - Per-extension migration plans and a conformance fixture table that pins down behaviour across every implementation. Refs #1557 --- rfcs/editor-extension-vite-plus-detection.md | 726 +++++++++++++++++++ 1 file changed, 726 insertions(+) create mode 100644 rfcs/editor-extension-vite-plus-detection.md diff --git a/rfcs/editor-extension-vite-plus-detection.md b/rfcs/editor-extension-vite-plus-detection.md new file mode 100644 index 0000000000..12f65d7a9d --- /dev/null +++ b/rfcs/editor-extension-vite-plus-detection.md @@ -0,0 +1,726 @@ +# 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. + +## 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 some walked-up `package.json` +declares `vite-plus` directly in `dependencies` or `devDependencies`, +up to the workspace root. The binary path to spawn (`vp`) is then +resolved separately by walking up looking for a real +`node_modules/vite-plus/bin/vp` install inside the workspace. + +**Distribution.** For the two Node-capable consumers (`oxc-vscode` and +`coc-oxc`), the detector ships as a published npm package +(`@voidzero-dev/detect-vite-plus`, name TBD) so both extensions consume +one tested implementation. `oxc-zed` (Rust/WASM) and +`oxc-intellij-plugin` (Kotlin) cannot import an npm package; they port +the same algorithm against the shared conformance fixtures. + +## 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 + +The strongest signal of "this project uses Vite+" is a **direct +dependency declaration** in a walked-up `package.json`. Everything +else — a `node_modules/vite-plus/` directory, a `vp` on `$PATH`, a +configured `binPath` setting — is either ambiguous (transitive +hoisting from an unrelated dependency tree) or unrelated to this +workspace's intent. + +Once intent is established, locating the runnable `vp` binary is a +separate concern. Each extension already knows how to walk +`node_modules` from a workspace root for `oxlint`/`oxfmt`; the same +machinery, restricted to the workspace boundary and validated against +`vite-plus`'s own `package.json`, produces the launchable executable. + +Splitting identity from launchability also gives editors a clean way +to handle freshly cloned, not-yet-installed projects: the detector +reports "Vite+ but not yet runnable" and the editor falls back to +plain oxlint/oxfmt instead of guessing at a global `vp`. + +## 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 + +The detector answers two separable questions: + +1. **Is this a Vite+ project?** — answered solely by **direct + declaration** of `vite-plus` in some walked-up `package.json`'s + `dependencies` or `devDependencies`, up to and including the + workspace root. Nothing else qualifies. The presence of a + `node_modules/vite-plus/` directory alone does not — that could be + a transitive install from an unrelated dependency. +2. **Where do I spawn `vp` from?** — answered only after question 1 + is positive, by walking up from the declaring `package.json` and + looking for a real `node_modules/vite-plus/bin/vp` at any ancestor + inside the workspace boundary. + +``` +fn detect_vite_plus_project(start: AbsolutePath) -> Option: + # Phase 1: find the owning package.json that DIRECTLY declares vite-plus. + declaration_root = walk_up_until_workspace_root(start, |dir, pkg|: + if pkg?.dependencies?.["vite-plus"] || pkg?.devDependencies?.["vite-plus"]: + return Some(dir) + else: + return None + ) + if declaration_root is None: + return None # Not a Vite+ project. + + # Phase 2: resolve the runnable binary, scoped to the workspace. + vp_path = walk_up_until_workspace_root(declaration_root, |dir, _|: + if is_valid_vite_plus_install(dir): + return Some(dir / "node_modules" / "vite-plus" / "bin" / "vp") + else: + return None + ) + + return Some({ root: declaration_root, vp_path }) # vp_path may be None +``` + +The walk-up in both phases stops AT the workspace root +(`pnpm-workspace.yaml`, `package.json#workspaces`, or `lerna.json`) +and never crosses into its parent. + +### Workspace root markers + +A directory is a workspace root if **any** of the following is true: + +1. It contains a `pnpm-workspace.yaml` file. +2. It contains a `package.json` whose top-level `workspaces` field is + present (covers npm, Yarn classic, Yarn Berry, and Bun workspaces, + all of which encode their workspace globs through this field). +3. It contains a `lerna.json` file. + +This set mirrors `findWorkspaceRoot` in +`packages/cli/src/resolve-vite-config.ts:45` of the `vite-plus` +TypeScript codebase, which is the canonical reference for the editor +extensions. + +**Known parity gap with `vite-task`.** The Rust implementation at +`vite-task/crates/vite_workspace/src/package_manager.rs:135` +(`find_workspace_root`) currently recognizes only the first two +markers and carries a `TODO(@fengmk2): other package manager support` +for Lerna. We leave the broader set in this RFC because the editor +extensions follow the TS convention; aligning `vite-task` is a +follow-up that does not block this RFC. Lerna projects that hit this +gap today already exercise the same `vp` runtime behaviour, so no +user-visible regression is introduced by the editor extensions +adopting the broader set. + +**Deliberately not in v1**: `deno.json` workspaces, `.git` directory, +ad-hoc `.vp-root` marker files. If a future deliverable needs them, +update this list and the conformance fixtures together. + +### Algorithm diagram + +Renders natively on GitHub; useful as a language-agnostic reference +for the Rust and Kotlin ports. + +```mermaid +flowchart TD + Start([start path
file or workspace folder]) + + Start --> P1Begin[/"PHASE 1
find direct declaration"/] + P1Begin --> P1Read["read package.json at dir"] + P1Read --> P1Q{"vite-plus in
dependencies or
devDependencies?"} + P1Q -- yes --> P1Found["root = dir"] + P1Q -- no --> P1Bound{"dir is workspace root
or filesystem root?"} + P1Bound -- no --> P1Up["dir = parent(dir)"] + P1Up --> P1Read + P1Bound -- yes --> ResultNull(["return null
not a Vite+ project"]) + + P1Found --> P2Begin[/"PHASE 2
find runnable binary
probe = root"/] + P2Begin --> P2Check["check probe/node_modules/vite-plus/bin/vp"] + P2Check --> P2Exists{"binary exists?"} + P2Exists -- yes --> P2Valid{"node_modules/vite-plus/package.json
parses with name = 'vite-plus'?"} + P2Valid -- yes --> ResultRunnable(["return { root, vpPath }
Vite+ project, runnable"]) + P2Valid -- no, orphan --> P2Bound + P2Exists -- no --> P2Bound{"probe is workspace root
or filesystem root?"} + P2Bound -- no --> P2Up["probe = parent(probe)"] + P2Up --> P2Check + P2Bound -- yes --> ResultDeclared(["return { root }
declared but not installed"]) +``` + +Reading the diagram: + +- The two phases run in sequence; Phase 2 only starts after Phase 1 + produces a `root`. +- Each phase is a bounded walk-up. The bound is the workspace root — + whichever ancestor first satisfies one of `pnpm-workspace.yaml`, + `package.json#workspaces`, or `lerna.json`. The walk evaluates that + directory once and then stops; it does not cross into the parent. +- The three terminal nodes (rounded) are the three observable + outcomes the conformance fixtures pin down. + +### Result shape + +``` +DetectResult { root: AbsolutePath, vp_path: Option } +``` + +Three outcomes: + +- **`None`** — no walked-up `package.json` declares `vite-plus`. Not a + Vite+ project. Editor uses plain `oxlint` / `oxfmt`. +- **`Some({ root, vp_path: Some(...) })`** — Vite+ project, runnable. + Editor launches ` lint --lsp` / ` fmt --lsp`. +- **`Some({ root, vp_path: None })`** — Vite+ project declared but + not installed (fresh clone, pre-`pnpm install`, Berry PnP without + `node_modules`, or a broken install). Editor should **not** launch + `vp` — there is no project-scoped binary to spawn, and falling back + to a bare `vp` from `$PATH` would re-introduce the global-leakage + hole. Recommended UX: fall back to plain `oxlint` / `oxfmt`, + optionally surface a hint like "Vite+ detected — run `pnpm install` + to enable Vite+ LSP." + +### Validity check on the install + +When Phase 2 finds a `bin/vp`, it also requires +`node_modules/vite-plus/package.json` to parse and have +`name === "vite-plus"`. This rejects orphan trees left by partial +uninstalls. A directory with a half-corrupt `package.json` is treated +as "not installed" — Phase 2 keeps walking up. + +### Why this rule + +- **Direct declaration is unambiguous user intent.** A `dependencies` + or `devDependencies` entry is the only filesystem artifact that says + _"this project chose to use vite-plus."_ Everything else + (`node_modules/vite-plus/` from hoisting, `vp` on `$PATH`, global + installs, user settings, orphan files) is incidental and can lie. +- **Transitive installs are rejected by construction.** Phase 1 only + reads `package.json` of walked-up ancestors; a `vite-plus` package + hoisted into a node_modules from someone else's dependency tree + never gets checked. +- **The workspace boundary is the trust boundary.** Both phases stop + at the workspace root marker, so a nested checkout cannot inherit a + Vite+ install from its outer parent directory. +- **Launchability is reported separately from project identity.** The + editor knows precisely when to launch `vp` and when to fall back, + without conflating "no Vite+" with "Vite+ but not yet installed." + +### 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`, 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+. +- `require.resolve("vite-plus")` — Node's resolution algorithm walks + past the workspace root and can find an unrelated parent install. + We use direct directory probes that are explicitly bounded. +- A `node_modules/vite-plus/` directory that doesn't itself appear as + a direct dep in some walked-up `package.json`. Transitive installs + do not count. +- A `node_modules/vite-plus/` directory whose `package.json` is + missing, unparseable, or has `name !== "vite-plus"`. Orphan trees + do not count. +- Any ancestor above the workspace root. The walk stops there. + +## TypeScript helper package + +`oxc-vscode` and `coc-oxc` consume a published npm package +(proposed name **`@voidzero-dev/detect-vite-plus`**) rather than +hand-rolling the two-phase walk. One source of truth, one set of +tests, one bug fix flows to both extensions. + +**Why a package, not vendored snippets.** The detector's logic is now +non-trivial: two phases, tri-state result, workspace-root detection, +install validation, walk-up bounds. Two independent copies would +drift on edge cases (validation, error handling, walk-up +termination). The cost of a shared dependency is lower than the cost +of two implementations subtly diverging. + +**Package constraints:** + +- Lives at `packages/detect-vite-plus/` inside this monorepo; + independent versioning via the existing changesets workflow. +- Zero runtime dependencies — Node built-ins only (`node:fs`, + `node:path`). +- Dual ESM + CJS publish so both `oxc-vscode` and `coc-oxc` + toolchains can consume it. +- No process spawns, no network, no NAPI binding — pure JS. +- Stable, semver-versioned API. Once 1.0 ships, breaking changes + require a major bump because two editor extensions pin it. +- Target install footprint: under 10 KB unpacked. + +**Public API** (sync + async): + +```ts +export interface DetectResult { + root: string; + vpPath?: string; +} +export function detectVitePlusProject(start: string): Promise; +export function detectVitePlusProjectSync(start: string): DetectResult | null; +``` + +`oxc-vscode` prefers the async variant to avoid extension-host +stalls; `coc-oxc`'s startup path uses the sync variant. The package +exposes both. + +**The reference implementation below is the package source.** It is +also the spec for `oxc-zed` (Rust port) and `oxc-intellij-plugin` +(Kotlin port), which cannot consume the npm package. The conformance +fixture table at the end of this RFC binds all three implementations +to the same observable behaviour. + +The snippet uses the sync variant for readability. The async variant +is the same algorithm with `fs.promises`. + +```ts +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +export interface DetectResult { + /** Workspace ancestor whose package.json directly declares vite-plus. */ + root: string; + /** + * Absolute path to a runnable, project-scoped vp binary, when one + * is installed inside the workspace. Undefined when vite-plus is + * declared but not yet installed (pre-`pnpm install`, Berry PnP + * without node_modules, broken install). Callers MUST NOT launch + * `vp` when this is undefined. + */ + vpPath?: string; +} + +function readPackageJson(dir: string): any | null { + try { + 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); +} + +function declaresVitePlus(pkg: any | null): boolean { + return Boolean(pkg?.dependencies?.['vite-plus'] || pkg?.devDependencies?.['vite-plus']); +} + +/** + * `bin/vp` must exist AND `node_modules/vite-plus/package.json` must + * parse and identify itself as the `vite-plus` package. Rejects orphan + * trees left behind by partial uninstalls. + */ +function resolveVpAt(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 { + // Phase 1: find the package.json that directly declares vite-plus. + let dir = start; + let root: string | null = null; + let rootPkg: any | null = null; + while (true) { + const pkg = readPackageJson(dir); + if (declaresVitePlus(pkg)) { + root = dir; + rootPkg = pkg; + break; + } + if (isWorkspaceRoot(dir, pkg)) break; + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + if (!root) return null; + + // Phase 2: walk up from the declaring root looking for a real install, + // bounded by the workspace root. Reuses Phase 1's package.json read at + // `root` so the boundary check on the first iteration doesn't repeat I/O. + let probe: string | null = root; + let pkg = rootPkg; + while (probe) { + const vpPath = resolveVpAt(probe); + if (vpPath) return { root, vpPath }; + if (isWorkspaceRoot(probe, pkg)) break; + const parent = dirname(probe); + if (parent === probe) break; + probe = parent; + pkg = readPackageJson(probe); + } + + return { root }; +} +``` + +## Per-extension migration plan + +Each extension runs the two-phase detector **before** its existing +oxlint/oxfmt lookup. The detector replaces, rather than extends, any +generic bin-resolution chain when the target is `"vp"`: + +- `null` → fall through to the existing oxlint/oxfmt chain. +- `{ root, vpPath }` → launch ` lint --lsp` (or `fmt --lsp`). +- `{ root, vpPath: undefined }` → fall through to the existing + oxlint/oxfmt chain; optionally surface a "Vite+ detected — run + install to enable LSP" hint. Do **not** launch a bare `vp`. + +### `oxc-vscode` + +Add `@voidzero-dev/detect-vite-plus` as a **devDependency** — the +extension bundles it via its existing build step into the shipped +`.vsix`, so it does not appear at runtime in the extension's +`node_modules`. Call `detectVitePlusProject(workspaceFolder.fsPath)` +before invoking the existing `findBinary("oxlint" | "oxfmt", ...)` +chain. The existing chain is unchanged and only consulted when the +detector returns `null` or a declared-but-not-installed result. Do +**not** parameterize the existing `findBinary` with `"vp"` as a +target — the chain's `searchSettingsBin`, +`searchGlobalNodeModulesBin`, `searchEnvPath`, and `require.resolve` +paths can escape the workspace boundary or consult settings meant for +oxlint/oxfmt. + +### `coc-oxc` + +Add `@voidzero-dev/detect-vite-plus` as a **devDependency** — bundled +into the published artifact (coc-oxc already builds via Vite per its +existing `vite.config.ts`), not present at runtime in the user's +`node_modules`. Call `detectVitePlusProjectSync(workspace.root)` from +`findBinary()` before the `node_modules/.bin` lookup. Skip +`oxc..binPath` for `vp` (that setting targets oxlint/oxfmt). + +### `oxc-zed` + +Zed already reads the worktree's `package.json` at +`get_workspace_exe_path` (`src/lsp.rs:19-40`); it just needs the +two-phase logic ported into Rust: + +- **Phase 1** (declaration): the existing + `[package_name, "vite-plus"]` loop becomes a single + `package_exists(p, "vite-plus")` check on `dependencies` / + `devDependencies`. If absent, return `Ok(None)` and Zed's existing + oxlint/oxfmt path runs unchanged. +- **Phase 2** (binary): when declared, probe + `/node_modules/vite-plus/bin/vp` and validate + `/node_modules/vite-plus/package.json` (`name === +"vite-plus"`). If valid, return the path; otherwise return `None` + for "declared but not installed." + +Update `language_server_command` to pass `["lint", "--lsp"]` / +`["fmt", "--lsp"]` when launching `vp`. Zed's WASM API today only +reads the worktree root, so deeper walk-up isn't currently +expressible — that's a known limitation worth noting in the Zed PR +but doesn't block this RFC. + +### `oxc-intellij-plugin` + +`VitePlusPackage.kt` already locates `vite-plus` via IntelliJ's +`NodePackageDescriptor`, which is project-scoped. Tighten it to +require `vite-plus` to appear as a **direct** dependency of the +project's `package.json` (IntelliJ's package descriptor exposes +direct vs. transitive). Change the returned path from +`/bin/oxlint` to `/bin/vp` and update launch +args to `lint --lsp` / `fmt --lsp`. When the descriptor finds the +declaration but no installed package, fall back to plain +oxlint/oxfmt; do not launch a bare `vp`. + +## Decisions + +### Publish a shared TypeScript helper for the Node consumers + +Locked. The detector ships as +`@voidzero-dev/detect-vite-plus` (name TBD) and the two Node-capable +extensions (`oxc-vscode`, `coc-oxc`) depend on it directly. Two +benefits over vendored copies: bug fixes flow to both extensions +through a normal version bump, and the conformance fixtures only need +to be exercised once in this repo's CI to guarantee parity. `oxc-zed` +(Rust) and `oxc-intellij-plugin` (Kotlin) cannot consume an npm +package; they port the same algorithm against the shared conformance +fixtures, which is reasonable because each is already non-trivial +work in those repos. + +### Direct declaration in `package.json` is the only project-identity signal + +Locked. A project is Vite+ iff some walked-up `package.json` lists +`vite-plus` in `dependencies` or `devDependencies` directly. The +binary's mere presence in `node_modules` does **not** qualify — a +transitive install hoisted by a package manager would otherwise +misclassify unrelated projects. Replaces an earlier "hybrid +two-signal" design that treated `bin/vp` existence as an independent +positive signal. + +### Binary resolution is a separate, project-scoped question + +Locked. Once declared, we look for `node_modules/vite-plus/bin/vp` by +walking up from the declaring ancestor and never crossing the +workspace root. We never call `require.resolve` (Node's resolution +algorithm walks past the workspace root and would re-open the +nested-repo leakage hole). We do not consult `$PATH`, the user's +global `node_modules`, or `oxc..binPath` (which targets +oxlint/oxfmt, not vp). + +### Tri-state result, not boolean + +Locked. The "declared but not installed" state is reported +separately from "not Vite+" so editors can distinguish a fresh clone +from a non-Vite+ project — and so they never launch a bare `vp` they +would have to find on `$PATH`. + +### Valid `vite-plus` install required before accepting `bin/vp` + +Locked. `node_modules/vite-plus/package.json` must parse and have +`name === "vite-plus"`. Orphan `bin/vp` files (partial uninstall, +hand-crafted directories, stale caches) are treated as "not +installed" and Phase 2 keeps walking up. + +### Workspace-wide granularity + +If any ancestor up to the workspace root 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. + +### Yarn PnP + +Berry with PnP has no `node_modules`. Phase 1 still finds the +declaration in `package.json`. Phase 2 fails to resolve a binary → +the detector returns `{ root }` (declared but not installed from the +detector's perspective). For v1, the editor falls back to plain +oxlint/oxfmt in this case. PnP-aware binary resolution is a v2 +extension that would plug into Phase 2 only. + +### Walk stops at the workspace root + +Locked. A nested checkout placed under a parent directory that +happens to have its own `vite-plus` install must not inherit Vite+ +behaviour from that unrelated workspace. + +## Downstream coordination + +**In this repo (lands first):** + +- `packages/detect-vite-plus/` — publish + `@voidzero-dev/detect-vite-plus` (sync + async API, ESM + CJS, + zero deps). The reference snippet above is the package source. +- Conformance test suite running the package against every fixture + in the table below. + +**Downstream PRs** (each extension owns its own repo and test +fixtures): + +- `oxc-vscode` PR: add `@voidzero-dev/detect-vite-plus` as a + devDependency (bundled into the `.vsix`); call it ahead of the + existing `findBinary("oxlint" | "oxfmt")` chain; launch + `vp lint --lsp` / `vp fmt --lsp` only when `vpPath` is set, + otherwise fall through. +- `coc-oxc` PR: add `@voidzero-dev/detect-vite-plus` as a devDependency + (bundled into the published artifact); call it from `findBinary()` + before the `node_modules/.bin` lookup; same launch rule. +- `oxc-zed` PR: replace the `[package_name, "vite-plus"]` loop in + `lsp.rs:28` with the two-phase check ported into Rust; return the + `vp` path with args `["lint" | "fmt", "--lsp"]`. Fall back to + oxlint/oxfmt when declared-but-not-installed. Replicate the + conformance fixtures in the Zed PR's tests. +- `oxc-intellij-plugin` PR: keep `VitePlusPackage.kt`, tighten it to + require `vite-plus` as a direct dep, change the returned path to + `bin/vp`, and update launch args. Fall back to oxlint/oxfmt when + declared but no installed package is found. Replicate the + conformance fixtures in the IntelliJ PR's tests. + +## Open questions + +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)? +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`. +3. **"Declared but not installed" UX.** Should editors silently fall + back to plain oxlint/oxfmt, or surface a notification prompting + `pnpm install`? Proposal: silent fallback in v1, leave the UX + decision to each extension. +4. **"Installed but not configured."** Should we additionally require + `vite.config.ts` to exist? Proposal: **no**. A direct dep + declaration 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. + +`DetectResult` shape: `{ root: string, vpPath?: string }`; `null` +means not a Vite+ project. + +| Fixture | Tree | Expected result | +| --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| `root-declared-and-installed` | Root `package.json` declares `vite-plus` + `node_modules/vite-plus/bin/vp` + valid `node_modules/vite-plus/package.json` | `{ root: "", vpPath: "/node_modules/vite-plus/bin/vp" }` | +| `pnpm-subpackage-declared-root-hoisted` | `pnpm-workspace.yaml` at ``, root `package.json` does **not** declare `vite-plus`, `packages/app/package.json` declares it, install is hoisted to `/node_modules/vite-plus/` | From inside `packages/app/`: `{ root: "/packages/app", vpPath: "/node_modules/vite-plus/bin/vp" }` | +| `root-declared-no-install` | Root `package.json` declares `vite-plus`, no `node_modules` (fresh clone) | `{ root: "" }` — vpPath absent | +| `npm-package-installed-direct-dep` | Root `package.json` with `workspaces`, `packages/app/package.json` declares `vite-plus`, install inside `packages/app/node_modules/vite-plus/` (un-hoisted) | From inside `packages/app/`: `{ root: "/packages/app", vpPath: "/packages/app/node_modules/vite-plus/bin/vp" }` | +| `plain-non-vite-plus` | Normal Node project, no `vite-plus` anywhere | `null` | +| `plain-vite-no-vp` | Uses Vite (`vite` declared, `vite.config.ts` present) but does not declare `vite-plus` | `null` | +| `transitive-install` | No walked-up `package.json` declares `vite-plus`, but `node_modules/vite-plus/` exists as a transitive dep (pulled in by some other package) | `null` — Phase 1 fails: no direct declaration | +| `bin-vp-orphan-no-package-json` | Declared in root `package.json`, but `node_modules/vite-plus/bin/vp` exists with no sibling `package.json` | `{ root: "" }` — install rejected as orphan, vpPath absent | +| `bin-vp-orphan-wrong-name` | Declared in root `package.json`, `bin/vp` + `package.json` exist but `package.json` has `name !== "vite-plus"` or is unparseable | `{ root: "" }` — install rejected, vpPath absent | +| `parent-vite-plus-nested-repo` | Outer dir declares `vite-plus` and has the install; inner subdirectory is its own workspace root (own `pnpm-workspace.yaml`/`package.json#workspaces`) and does not declare `vite-plus` | From inside the nested workspace: `null` — Phase 1 stops at the inner workspace root | +| `global-vp-on-path` | Plain Node project, no declaration; `vp` is on `$PATH` and/or in the user's global `node_modules` | `null` | +| `user-binpath-override` | Plain Node project, no declaration; `oxc.oxlint.binPath` configured to a `vp` binary | `null` | +| `yarn4-pnp` | Berry/PnP, no `node_modules`, root `package.json` declares `vite-plus` | `{ root: "" }` — declared, install not resolvable via plain filesystem walk | + +## 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 8e7586c9aa82490c81152f6af3b078e1f829c39b Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 18 May 2026 11:02:11 +0800 Subject: [PATCH 2/4] =?UTF-8?q?docs(rfc):=20correct=20marker=20non-targets?= =?UTF-8?q?=20=E2=80=94=20.vite-hooks=20is=20real,=20.vp-root=20isn't?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .vp-root was a fabricated example. Replace with .vite-hooks, which is the real vite-plus hooks directory (packages/cli/src/config/hooks.ts:67) and a more useful illustration of "incidental vite-plus artifact that must not stop the walk-up." Refs #1557 --- rfcs/editor-extension-vite-plus-detection.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/rfcs/editor-extension-vite-plus-detection.md b/rfcs/editor-extension-vite-plus-detection.md index 12f65d7a9d..f3c385244c 100644 --- a/rfcs/editor-extension-vite-plus-detection.md +++ b/rfcs/editor-extension-vite-plus-detection.md @@ -242,8 +242,11 @@ user-visible regression is introduced by the editor extensions adopting the broader set. **Deliberately not in v1**: `deno.json` workspaces, `.git` directory, -ad-hoc `.vp-root` marker files. If a future deliverable needs them, -update this list and the conformance fixtures together. +and incidental vite-plus artifacts such as the `.vite-hooks` directory +(`packages/cli/src/config/hooks.ts:67`) — these are not workspace +roots and the walk must continue past them. If a future deliverable +adds a new marker, update this list and the conformance fixtures +together. ### Algorithm diagram From 351309b0b9d2a7e5e268012dc07cad7d849ada3e Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 18 May 2026 11:16:53 +0800 Subject: [PATCH 3/4] docs(rfc): expose installed vite-plus version for LSP gating The detector now reads node_modules/vite-plus/package.json#version alongside name validation and returns it in DetectResult as vpVersion. Editors compare against MIN_VP_VERSION_FOR_LSP (a constant exported by the shared detector package, filled in when #1557's wrapper removal lands) to choose between launching `vp lint --lsp` and falling through to the legacy bin/oxlint wrapper chain shipped by older vite-plus versions. Three benefits: - Smooth rollout: workspaces pinned to older vite-plus keep working via the existing wrapper, no breakage on upgrade of the editor extension alone. - Upgrade hint: editors can surface "upgrade vite-plus to enable native LSP" when the installed version is too old. - Forward compatibility: future LSP-only features can raise the threshold by bumping the detector package, not by changing every consumer. Changes: - DetectResult gains optional vpVersion (set whenever vpPath is set). - resolveVpAt() rejects installs whose package.json has no string version, treating them as orphan. - Public API adds MIN_VP_VERSION_FOR_LSP constant and supportsLsp() helper for both consumers to use. - Per-extension migration plan gains a fourth branch for the "installed but version too old" case. - New conformance fixtures: installed-but-version-too-old, installed-no-version-field. - New open question: the concrete value of MIN_VP_VERSION_FOR_LSP (TBD until #1557 ships). --- rfcs/editor-extension-vite-plus-detection.md | 201 ++++++++++++++----- 1 file changed, 149 insertions(+), 52 deletions(-) diff --git a/rfcs/editor-extension-vite-plus-detection.md b/rfcs/editor-extension-vite-plus-detection.md index f3c385244c..dbc0288935 100644 --- a/rfcs/editor-extension-vite-plus-detection.md +++ b/rfcs/editor-extension-vite-plus-detection.md @@ -15,9 +15,12 @@ path to spawn. **The rule, in one sentence:** A workspace is a **Vite+ project** iff some walked-up `package.json` declares `vite-plus` directly in `dependencies` or `devDependencies`, -up to the workspace root. The binary path to spawn (`vp`) is then -resolved separately by walking up looking for a real -`node_modules/vite-plus/bin/vp` install inside the workspace. +up to the workspace root. The binary path to spawn (`vp`) and the +installed `vite-plus` version are then resolved separately by walking +up looking for a real, validated `node_modules/vite-plus/` install +inside the workspace; the version lets editors gate `vp lint --lsp` +behind a minimum release and keep the legacy `bin/oxlint` wrapper +path working for users still on older `vite-plus`. **Distribution.** For the two Node-capable consumers (`oxc-vscode` and `coc-oxc`), the detector ships as a published npm package @@ -292,31 +295,81 @@ Reading the diagram: ### Result shape ``` -DetectResult { root: AbsolutePath, vp_path: Option } +DetectResult { + root: AbsolutePath, + vp_path: Option, + vp_version: Option, // set iff vp_path is set +} ``` Three outcomes: - **`None`** — no walked-up `package.json` declares `vite-plus`. Not a Vite+ project. Editor uses plain `oxlint` / `oxfmt`. -- **`Some({ root, vp_path: Some(...) })`** — Vite+ project, runnable. - Editor launches ` lint --lsp` / ` fmt --lsp`. -- **`Some({ root, vp_path: None })`** — Vite+ project declared but - not installed (fresh clone, pre-`pnpm install`, Berry PnP without - `node_modules`, or a broken install). Editor should **not** launch - `vp` — there is no project-scoped binary to spawn, and falling back - to a bare `vp` from `$PATH` would re-introduce the global-leakage - hole. Recommended UX: fall back to plain `oxlint` / `oxfmt`, - optionally surface a hint like "Vite+ detected — run `pnpm install` - to enable Vite+ LSP." +- **`Some({ root, vp_path: Some(...), vp_version: Some(...) })`** — + Vite+ project, installed. The editor compares `vp_version` against + a minimum known to support `vp lint --lsp` (see "Version-gated LSP + support" below) and either launches ` lint --lsp` / + ` fmt --lsp` or falls back to the legacy + `bin/oxlint`/`bin/oxfmt` wrapper path. +- **`Some({ root, vp_path: None, vp_version: None })`** — Vite+ + project declared but not installed (fresh clone, pre-`pnpm install`, + Berry PnP without `node_modules`, or a broken install). Editor + should **not** launch `vp` — there is no project-scoped binary to + spawn, and falling back to a bare `vp` from `$PATH` would + re-introduce the global-leakage hole. Recommended UX: fall back to + plain `oxlint` / `oxfmt`, optionally surface a hint like "Vite+ + detected — run `pnpm install` to enable Vite+ LSP." ### Validity check on the install When Phase 2 finds a `bin/vp`, it also requires -`node_modules/vite-plus/package.json` to parse and have -`name === "vite-plus"`. This rejects orphan trees left by partial -uninstalls. A directory with a half-corrupt `package.json` is treated -as "not installed" — Phase 2 keeps walking up. +`node_modules/vite-plus/package.json` to parse, have +`name === "vite-plus"`, and carry a string `version`. The version +string is returned in the result; orphan trees (missing +`package.json`, wrong name, missing or non-string version) are +treated as "not installed" and Phase 2 keeps walking up. + +### Version-gated LSP support + +`vp lint --lsp` and `vp fmt --lsp` only exist on `vite-plus` versions +that ship after the `bin/oxlint` / `bin/oxfmt` wrappers are +deprecated (#1557). Older installed versions still rely on the +wrappers. The detector returns `vp_version` so each consumer can +decide which mode to use: + +``` +if vp_version is None: + use plain oxlint/oxfmt (declared-but-not-installed case) +elif vp_version >= MIN_VP_VERSION_FOR_LSP: + launch vp lint --lsp / vp fmt --lsp +else: + fall through to the existing findBinary(oxlint/oxfmt) chain, + which picks up the legacy bin/oxlint wrapper. + Optionally surface a "Vite+ detected — upgrade vite-plus to + enable native LSP" hint. +``` + +`MIN_VP_VERSION_FOR_LSP` is the first `vite-plus` release that ships +`vp lint --lsp` / `vp fmt --lsp` and drops the `bin/oxlint` / +`bin/oxfmt` wrappers. The exact value is **TBD** — it gets filled in +when #1557 lands. The shared TypeScript package exports it as a +constant plus a `supportsLsp(version)` helper so both consumers +agree. + +This rollout is robust in both directions: + +- **Old vite-plus + new editor extension** → detector reports an old + version → editor falls through → `findBinary("oxlint")` resolves + the legacy wrapper bin → user gets Vite-config-aware linting via + the wrapper, as today. +- **New vite-plus + new editor extension** → detector reports a + supported version → editor launches `vp lint --lsp` directly. +- **New vite-plus + old editor extension (no detector yet)** → the + old extension keeps probing `bin/oxlint`; if the new vite-plus has + removed the wrappers, the extension falls back to whatever its + global oxlint path resolves to. Not ideal, but the upgrade hint + drives users to upgrade their extension. ### Why this rule @@ -388,9 +441,17 @@ of two implementations subtly diverging. export interface DetectResult { root: string; vpPath?: string; + vpVersion?: string; } export function detectVitePlusProject(start: string): Promise; export function detectVitePlusProjectSync(start: string): DetectResult | null; + +/** + * First vite-plus version that ships `vp lint --lsp` and drops the + * legacy `bin/oxlint` / `bin/oxfmt` wrappers. Updated when #1557 lands. + */ +export const MIN_VP_VERSION_FOR_LSP: string; +export function supportsLsp(version: string | undefined): boolean; ``` `oxc-vscode` prefers the async variant to avoid extension-host @@ -421,6 +482,13 @@ export interface DetectResult { * `vp` when this is undefined. */ vpPath?: string; + /** + * The installed vite-plus's `package.json#version`. Set whenever + * vpPath is set. Compare against MIN_VP_VERSION_FOR_LSP (or use + * `supportsLsp`) to decide between `vp lint --lsp` and the legacy + * bin/oxlint wrapper path. + */ + vpVersion?: string; } function readPackageJson(dir: string): any | null { @@ -443,21 +511,21 @@ function declaresVitePlus(pkg: any | null): boolean { /** * `bin/vp` must exist AND `node_modules/vite-plus/package.json` must - * parse and identify itself as the `vite-plus` package. Rejects orphan - * trees left behind by partial uninstalls. + * parse, identify itself as `vite-plus`, and carry a string version. + * Rejects orphan trees left behind by partial uninstalls. */ -function resolveVpAt(dir: string): string | null { +function resolveVpAt(dir: string): { vpPath: string; vpVersion: 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; + if (pkg?.name !== 'vite-plus' || typeof pkg?.version !== 'string') return null; + return { vpPath, vpVersion: pkg.version }; } catch { return null; } - return vpPath; } export function detectVitePlusProjectSync(start: string): DetectResult | null { @@ -485,8 +553,8 @@ export function detectVitePlusProjectSync(start: string): DetectResult | null { let probe: string | null = root; let pkg = rootPkg; while (probe) { - const vpPath = resolveVpAt(probe); - if (vpPath) return { root, vpPath }; + const installed = resolveVpAt(probe); + if (installed) return { root, ...installed }; if (isWorkspaceRoot(probe, pkg)) break; const parent = dirname(probe); if (parent === probe) break; @@ -505,7 +573,12 @@ oxlint/oxfmt lookup. The detector replaces, rather than extends, any generic bin-resolution chain when the target is `"vp"`: - `null` → fall through to the existing oxlint/oxfmt chain. -- `{ root, vpPath }` → launch ` lint --lsp` (or `fmt --lsp`). +- `{ root, vpPath, vpVersion }` with `supportsLsp(vpVersion)` → + launch ` lint --lsp` (or `fmt --lsp`). +- `{ root, vpPath, vpVersion }` with version too old → fall through + to the existing chain (which resolves the legacy + `bin/oxlint`/`bin/oxfmt` wrapper still shipped by that vite-plus + version). Optionally surface an upgrade hint. - `{ root, vpPath: undefined }` → fall through to the existing oxlint/oxfmt chain; optionally surface a "Vite+ detected — run install to enable LSP" hint. Do **not** launch a bare `vp`. @@ -613,10 +686,23 @@ would have to find on `$PATH`. ### Valid `vite-plus` install required before accepting `bin/vp` -Locked. `node_modules/vite-plus/package.json` must parse and have -`name === "vite-plus"`. Orphan `bin/vp` files (partial uninstall, -hand-crafted directories, stale caches) are treated as "not -installed" and Phase 2 keeps walking up. +Locked. `node_modules/vite-plus/package.json` must parse, have +`name === "vite-plus"`, and carry a string `version`. Orphan `bin/vp` +files (partial uninstall, hand-crafted directories, stale caches) are +treated as "not installed" and Phase 2 keeps walking up. + +### Report the installed vite-plus version + +Locked. The detector returns `vpVersion` from the installed +`node_modules/vite-plus/package.json` so consumers can gate +`vp lint --lsp` behind a minimum supported version. This keeps the +rollout backwards-compatible: editors upgraded to use this detector +correctly handle workspaces still pinned to older `vite-plus` +versions that ship the `bin/oxlint` wrapper, and can surface an +"upgrade `vite-plus` to enable native LSP" hint when appropriate. +The threshold (`MIN_VP_VERSION_FOR_LSP`) lives in the shared +detector package and is updated when #1557's removal of the +wrappers lands. ### Workspace-wide granularity @@ -681,18 +767,25 @@ fixtures): ## Open questions -1. **Caching policy** in editor extensions — documented best-practice +1. **`MIN_VP_VERSION_FOR_LSP`**. The concrete version of `vite-plus` + at which the wrappers go away and `vp lint --lsp` becomes the + supported entry point is TBD — it gets set when the corresponding + `vite-plus` release ships. The detector package exports this as a + constant; both consumers depend on the package picking up the + value, so a version bump of the detector is what enables LSP for + new users. +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)? -2. **Zed launch args plumbing.** The `--lsp` switch is already there +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`. -3. **"Declared but not installed" UX.** Should editors silently fall +4. **"Declared but not installed" UX.** Should editors silently fall back to plain oxlint/oxfmt, or surface a notification prompting `pnpm install`? Proposal: silent fallback in v1, leave the UX decision to each extension. -4. **"Installed but not configured."** Should we additionally require +5. **"Installed but not configured."** Should we additionally require `vite.config.ts` to exist? Proposal: **no**. A direct dep declaration is intent enough. @@ -701,24 +794,28 @@ fixtures): Every implementation must produce identical answers on the following fixtures. Each extension replicates the set inside its own test suite. -`DetectResult` shape: `{ root: string, vpPath?: string }`; `null` -means not a Vite+ project. - -| Fixture | Tree | Expected result | -| --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| `root-declared-and-installed` | Root `package.json` declares `vite-plus` + `node_modules/vite-plus/bin/vp` + valid `node_modules/vite-plus/package.json` | `{ root: "", vpPath: "/node_modules/vite-plus/bin/vp" }` | -| `pnpm-subpackage-declared-root-hoisted` | `pnpm-workspace.yaml` at ``, root `package.json` does **not** declare `vite-plus`, `packages/app/package.json` declares it, install is hoisted to `/node_modules/vite-plus/` | From inside `packages/app/`: `{ root: "/packages/app", vpPath: "/node_modules/vite-plus/bin/vp" }` | -| `root-declared-no-install` | Root `package.json` declares `vite-plus`, no `node_modules` (fresh clone) | `{ root: "" }` — vpPath absent | -| `npm-package-installed-direct-dep` | Root `package.json` with `workspaces`, `packages/app/package.json` declares `vite-plus`, install inside `packages/app/node_modules/vite-plus/` (un-hoisted) | From inside `packages/app/`: `{ root: "/packages/app", vpPath: "/packages/app/node_modules/vite-plus/bin/vp" }` | -| `plain-non-vite-plus` | Normal Node project, no `vite-plus` anywhere | `null` | -| `plain-vite-no-vp` | Uses Vite (`vite` declared, `vite.config.ts` present) but does not declare `vite-plus` | `null` | -| `transitive-install` | No walked-up `package.json` declares `vite-plus`, but `node_modules/vite-plus/` exists as a transitive dep (pulled in by some other package) | `null` — Phase 1 fails: no direct declaration | -| `bin-vp-orphan-no-package-json` | Declared in root `package.json`, but `node_modules/vite-plus/bin/vp` exists with no sibling `package.json` | `{ root: "" }` — install rejected as orphan, vpPath absent | -| `bin-vp-orphan-wrong-name` | Declared in root `package.json`, `bin/vp` + `package.json` exist but `package.json` has `name !== "vite-plus"` or is unparseable | `{ root: "" }` — install rejected, vpPath absent | -| `parent-vite-plus-nested-repo` | Outer dir declares `vite-plus` and has the install; inner subdirectory is its own workspace root (own `pnpm-workspace.yaml`/`package.json#workspaces`) and does not declare `vite-plus` | From inside the nested workspace: `null` — Phase 1 stops at the inner workspace root | -| `global-vp-on-path` | Plain Node project, no declaration; `vp` is on `$PATH` and/or in the user's global `node_modules` | `null` | -| `user-binpath-override` | Plain Node project, no declaration; `oxc.oxlint.binPath` configured to a `vp` binary | `null` | -| `yarn4-pnp` | Berry/PnP, no `node_modules`, root `package.json` declares `vite-plus` | `{ root: "" }` — declared, install not resolvable via plain filesystem walk | +`DetectResult` shape: `{ root: string, vpPath?: string, vpVersion?: string }`; +`null` means not a Vite+ project. Every fixture below also has an +implicit assertion: when `vpPath` is set, `vpVersion` must equal the +fixture's installed `node_modules/vite-plus/package.json#version`. + +| Fixture | Tree | Expected result | +| --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `root-declared-and-installed` | Root `package.json` declares `vite-plus` + `node_modules/vite-plus/bin/vp` + valid `node_modules/vite-plus/package.json` (`version: ""`) | `{ root: "", vpPath: "/node_modules/vite-plus/bin/vp", vpVersion: "" }`; `supportsLsp(vpVersion) === true` | +| `installed-but-version-too-old` | Same tree as `root-declared-and-installed` but with an older `vite-plus` version that pre-dates `vp lint --lsp` | `{ root, vpPath, vpVersion: "" }`; `supportsLsp(vpVersion) === false` — editor must fall through to legacy `bin/oxlint` chain | +| `installed-no-version-field` | Declared + `bin/vp` exists; `node_modules/vite-plus/package.json` is missing `version` or has it as a non-string | `{ root: "" }` — install rejected, vpPath absent | +| `pnpm-subpackage-declared-root-hoisted` | `pnpm-workspace.yaml` at ``, root `package.json` does **not** declare `vite-plus`, `packages/app/package.json` declares it, install is hoisted to `/node_modules/vite-plus/` | From inside `packages/app/`: `{ root: "/packages/app", vpPath: "/node_modules/vite-plus/bin/vp", vpVersion: "" }` | +| `root-declared-no-install` | Root `package.json` declares `vite-plus`, no `node_modules` (fresh clone) | `{ root: "" }` — vpPath and vpVersion absent | +| `npm-package-installed-direct-dep` | Root `package.json` with `workspaces`, `packages/app/package.json` declares `vite-plus`, install inside `packages/app/node_modules/vite-plus/` (un-hoisted) | From inside `packages/app/`: `{ root: "/packages/app", vpPath: "/packages/app/node_modules/vite-plus/bin/vp", vpVersion: "" }` | +| `plain-non-vite-plus` | Normal Node project, no `vite-plus` anywhere | `null` | +| `plain-vite-no-vp` | Uses Vite (`vite` declared, `vite.config.ts` present) but does not declare `vite-plus` | `null` | +| `transitive-install` | No walked-up `package.json` declares `vite-plus`, but `node_modules/vite-plus/` exists as a transitive dep (pulled in by some other package) | `null` — Phase 1 fails: no direct declaration | +| `bin-vp-orphan-no-package-json` | Declared in root `package.json`, but `node_modules/vite-plus/bin/vp` exists with no sibling `package.json` | `{ root: "" }` — install rejected as orphan | +| `bin-vp-orphan-wrong-name` | Declared in root `package.json`, `bin/vp` + `package.json` exist but `package.json` has `name !== "vite-plus"` or is unparseable | `{ root: "" }` — install rejected | +| `parent-vite-plus-nested-repo` | Outer dir declares `vite-plus` and has the install; inner subdirectory is its own workspace root (own `pnpm-workspace.yaml`/`package.json#workspaces`) and does not declare `vite-plus` | From inside the nested workspace: `null` — Phase 1 stops at the inner workspace root | +| `global-vp-on-path` | Plain Node project, no declaration; `vp` is on `$PATH` and/or in the user's global `node_modules` | `null` | +| `user-binpath-override` | Plain Node project, no declaration; `oxc.oxlint.binPath` configured to a `vp` binary | `null` | +| `yarn4-pnp` | Berry/PnP, no `node_modules`, root `package.json` declares `vite-plus` | `{ root: "" }` — declared, install not resolvable via plain filesystem walk | ## Verification plan From 8a66ee0ae0f9d2689e39c40b19ec5afd9c9ecb65 Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 18 May 2026 11:53:10 +0800 Subject: [PATCH 4/4] =?UTF-8?q?docs(rfc):=20address=20reviewer=20feedback?= =?UTF-8?q?=20=E2=80=94=20tighten=20and=20drop=20version=20gating?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 826 lines → 302 lines. Reviewer flagged repetition and pushed back on the version-gating mechanism. Changes: - Drop MIN_VP_VERSION_FOR_LSP / supportsLsp / vpVersion entirely. Editors just attempt `vp lint --lsp`; if it fails on an old vp, the editor surfaces an upgrade hint at that point. - Change "declared but not installed" UX: surface an install hint instead of silently falling back to plain oxlint. The reviewer is right that plain oxlint without VP_VERSION isn't Vite+-aware anyway, so silent fallback misleads more than it helps. - Heavy trim of repetition: drop "Insight", drop the long bin-resolution code excerpts (kept brief file:line refs), fold "Why this rule" into the rule section, collapse the Decisions section, drop "Downstream coordination" (overlapped with the migration plan), drop the "Verification plan" subheading. - Move "publish as a shared npm package?" from a locked Decision to an Open Question — the reviewer asked us to make the call; making it visibly open instead of locked invites the reviewer to weigh in. - Shrink the conformance fixture table: drop entries that document non-features (global-vp-on-path, user-binpath-override) now that the rule plainly says we don't check $PATH or user settings, and drop the version-related fixtures. --- rfcs/editor-extension-vite-plus-detection.md | 858 ++++--------------- 1 file changed, 167 insertions(+), 691 deletions(-) diff --git a/rfcs/editor-extension-vite-plus-detection.md b/rfcs/editor-extension-vite-plus-detection.md index dbc0288935..132efda3bc 100644 --- a/rfcs/editor-extension-vite-plus-detection.md +++ b/rfcs/editor-extension-vite-plus-detection.md @@ -1,264 +1,96 @@ # 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. +> Status: **Draft for discussion** ## 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 some walked-up `package.json` -declares `vite-plus` directly in `dependencies` or `devDependencies`, -up to the workspace root. The binary path to spawn (`vp`) and the -installed `vite-plus` version are then resolved separately by walking -up looking for a real, validated `node_modules/vite-plus/` install -inside the workspace; the version lets editors gate `vp lint --lsp` -behind a minimum release and keep the legacy `bin/oxlint` wrapper -path working for users still on older `vite-plus`. - -**Distribution.** For the two Node-capable consumers (`oxc-vscode` and -`coc-oxc`), the detector ships as a published npm package -(`@voidzero-dev/detect-vite-plus`, name TBD) so both extensions consume -one tested implementation. `oxc-zed` (Rust/WASM) and -`oxc-intellij-plugin` (Kotlin) cannot import an npm package; they port -the same algorithm against the shared conformance fixtures. +Define a portable rule the four oxc editor extensions +(`oxc-vscode`, `oxc-zed`, `oxc-intellij-plugin`, `coc-oxc`) use to +decide whether to launch `vp lint --lsp` / `vp fmt --lsp` instead of +plain `oxlint` / `oxfmt`, and to locate the `vp` binary to spawn. ## 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 - -The strongest signal of "this project uses Vite+" is a **direct -dependency declaration** in a walked-up `package.json`. Everything -else — a `node_modules/vite-plus/` directory, a `vp` on `$PATH`, a -configured `binPath` setting — is either ambiguous (transitive -hoisting from an unrelated dependency tree) or unrelated to this -workspace's intent. - -Once intent is established, locating the runnable `vp` binary is a -separate concern. Each extension already knows how to walk -`node_modules` from a workspace root for `oxlint`/`oxfmt`; the same -machinery, restricted to the workspace boundary and validated against -`vite-plus`'s own `package.json`, produces the launchable executable. - -Splitting identity from launchability also gives editors a clean way -to handle freshly cloned, not-yet-installed projects: the detector -reports "Vite+ but not yet runnable" and the editor falls back to -plain oxlint/oxfmt instead of guessing at a global `vp`. - -## 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` +#1557 removes the `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 being installed into `node_modules/.bin/` — 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. + +Today each extension's idea of "is Vite+" differs — Zed checks +`package.json` deps and points at the wrapper bin +(`oxc-zed/src/lsp.rs:28`); IntelliJ has a dedicated +`VitePlusPackage.kt`; oxc-vscode and coc-oxc have no explicit +detection. The goal of this RFC is one rule, four implementations. + +## The rule + +A workspace is **Vite+** iff some `package.json` between the start +path and the workspace root declares `vite-plus` directly in +`dependencies` or `devDependencies`. A `node_modules/vite-plus/` +directory on its own does not qualify — that could be a transitive +install hoisted from an unrelated dependency tree. + +The runnable `vp` binary is resolved separately: walk up from the +declaring ancestor for `node_modules/vite-plus/bin/vp` with a sibling +`package.json` that parses and has `name === "vite-plus"`, bounded by +the workspace root. ``` -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 - -The detector answers two separable questions: - -1. **Is this a Vite+ project?** — answered solely by **direct - declaration** of `vite-plus` in some walked-up `package.json`'s - `dependencies` or `devDependencies`, up to and including the - workspace root. Nothing else qualifies. The presence of a - `node_modules/vite-plus/` directory alone does not — that could be - a transitive install from an unrelated dependency. -2. **Where do I spawn `vp` from?** — answered only after question 1 - is positive, by walking up from the declaring `package.json` and - looking for a real `node_modules/vite-plus/bin/vp` at any ancestor - inside the workspace boundary. - -``` -fn detect_vite_plus_project(start: AbsolutePath) -> Option: - # Phase 1: find the owning package.json that DIRECTLY declares vite-plus. - declaration_root = walk_up_until_workspace_root(start, |dir, pkg|: - if pkg?.dependencies?.["vite-plus"] || pkg?.devDependencies?.["vite-plus"]: +fn detect_vite_plus_project(start: AbsolutePath) -> Option: + # Phase 1: find the package.json that DIRECTLY declares vite-plus. + root = walk_up_to_workspace_root(start, |dir, pkg|: + if "vite-plus" in pkg.dependencies | pkg.devDependencies: return Some(dir) else: return None ) - if declaration_root is None: - return None # Not a Vite+ project. + if root is None: + return None # not a Vite+ project # Phase 2: resolve the runnable binary, scoped to the workspace. - vp_path = walk_up_until_workspace_root(declaration_root, |dir, _|: - if is_valid_vite_plus_install(dir): - return Some(dir / "node_modules" / "vite-plus" / "bin" / "vp") - else: - return None + vp_path = walk_up_to_workspace_root(root, |dir, _|: + return valid_vite_plus_install_at(dir) # bin/vp + sibling package.json with correct name ) - return Some({ root: declaration_root, vp_path }) # vp_path may be None + return Some({ root, vp_path }) # vp_path may be None ``` -The walk-up in both phases stops AT the workspace root -(`pnpm-workspace.yaml`, `package.json#workspaces`, or `lerna.json`) -and never crosses into its parent. +Both phases stop AT the workspace root and never cross into its +parent. The walk-up bound is what prevents a nested checkout from +inheriting an unrelated parent's Vite+ install. -### Workspace root markers +### Result -A directory is a workspace root if **any** of the following is true: - -1. It contains a `pnpm-workspace.yaml` file. -2. It contains a `package.json` whose top-level `workspaces` field is - present (covers npm, Yarn classic, Yarn Berry, and Bun workspaces, - all of which encode their workspace globs through this field). -3. It contains a `lerna.json` file. - -This set mirrors `findWorkspaceRoot` in -`packages/cli/src/resolve-vite-config.ts:45` of the `vite-plus` -TypeScript codebase, which is the canonical reference for the editor -extensions. - -**Known parity gap with `vite-task`.** The Rust implementation at -`vite-task/crates/vite_workspace/src/package_manager.rs:135` -(`find_workspace_root`) currently recognizes only the first two -markers and carries a `TODO(@fengmk2): other package manager support` -for Lerna. We leave the broader set in this RFC because the editor -extensions follow the TS convention; aligning `vite-task` is a -follow-up that does not block this RFC. Lerna projects that hit this -gap today already exercise the same `vp` runtime behaviour, so no -user-visible regression is introduced by the editor extensions -adopting the broader set. - -**Deliberately not in v1**: `deno.json` workspaces, `.git` directory, -and incidental vite-plus artifacts such as the `.vite-hooks` directory -(`packages/cli/src/config/hooks.ts:67`) — these are not workspace -roots and the walk must continue past them. If a future deliverable -adds a new marker, update this list and the conformance fixtures -together. +```ts +{ root: string; vpPath?: string } | null +``` -### Algorithm diagram +- **`null`** — not a Vite+ project. Editor uses plain `oxlint` / + `oxfmt`. +- **`{ root, vpPath }`** — Vite+ and runnable. Editor launches + ` lint --lsp` / ` fmt --lsp`. If launching errors + (e.g. a very old `vite-plus` whose `vp` doesn't yet recognize + `--lsp`), surface an "upgrade vite-plus" hint at that point. +- **`{ root }`** — declared but not installed (fresh clone, + pre-`pnpm install`, Berry PnP without `node_modules`, broken + install). Editor surfaces an install hint such as + _"Vite+ detected — run `pnpm install` to enable LSP"_ and does + **not** launch anything. Plain `oxlint`/`oxfmt` won't be + Vite+-aware without the wrapper's `VP_VERSION` environment + variable, so falling through silently would lose Vite+ behaviour + rather than approximate it. -Renders natively on GitHub; useful as a language-agnostic reference -for the Rust and Kotlin ports. +### Algorithm diagram ```mermaid flowchart TD - Start([start path
file or workspace folder]) + Start([start path]) Start --> P1Begin[/"PHASE 1
find direct declaration"/] P1Begin --> P1Read["read package.json at dir"] @@ -267,205 +99,52 @@ flowchart TD P1Q -- no --> P1Bound{"dir is workspace root
or filesystem root?"} P1Bound -- no --> P1Up["dir = parent(dir)"] P1Up --> P1Read - P1Bound -- yes --> ResultNull(["return null
not a Vite+ project"]) + P1Bound -- yes --> ResultNull(["return null"]) P1Found --> P2Begin[/"PHASE 2
find runnable binary
probe = root"/] P2Begin --> P2Check["check probe/node_modules/vite-plus/bin/vp"] P2Check --> P2Exists{"binary exists?"} P2Exists -- yes --> P2Valid{"node_modules/vite-plus/package.json
parses with name = 'vite-plus'?"} - P2Valid -- yes --> ResultRunnable(["return { root, vpPath }
Vite+ project, runnable"]) + P2Valid -- yes --> ResultRunnable(["return { root, vpPath }"]) P2Valid -- no, orphan --> P2Bound P2Exists -- no --> P2Bound{"probe is workspace root
or filesystem root?"} P2Bound -- no --> P2Up["probe = parent(probe)"] P2Up --> P2Check - P2Bound -- yes --> ResultDeclared(["return { root }
declared but not installed"]) + P2Bound -- yes --> ResultDeclared(["return { root }"]) ``` -Reading the diagram: +### Workspace root markers -- The two phases run in sequence; Phase 2 only starts after Phase 1 - produces a `root`. -- Each phase is a bounded walk-up. The bound is the workspace root — - whichever ancestor first satisfies one of `pnpm-workspace.yaml`, - `package.json#workspaces`, or `lerna.json`. The walk evaluates that - directory once and then stops; it does not cross into the parent. -- The three terminal nodes (rounded) are the three observable - outcomes the conformance fixtures pin down. +A directory is a workspace root if any of the following is true: -### Result shape +- it contains a `pnpm-workspace.yaml`; +- it contains a `package.json` with a top-level `workspaces` field + (npm, Yarn classic, Yarn Berry, and Bun all encode workspace + globs here); +- it contains a `lerna.json`. -``` -DetectResult { - root: AbsolutePath, - vp_path: Option, - vp_version: Option, // set iff vp_path is set -} -``` +This mirrors `findWorkspaceRoot` in +`packages/cli/src/resolve-vite-config.ts:45`. -Three outcomes: - -- **`None`** — no walked-up `package.json` declares `vite-plus`. Not a - Vite+ project. Editor uses plain `oxlint` / `oxfmt`. -- **`Some({ root, vp_path: Some(...), vp_version: Some(...) })`** — - Vite+ project, installed. The editor compares `vp_version` against - a minimum known to support `vp lint --lsp` (see "Version-gated LSP - support" below) and either launches ` lint --lsp` / - ` fmt --lsp` or falls back to the legacy - `bin/oxlint`/`bin/oxfmt` wrapper path. -- **`Some({ root, vp_path: None, vp_version: None })`** — Vite+ - project declared but not installed (fresh clone, pre-`pnpm install`, - Berry PnP without `node_modules`, or a broken install). Editor - should **not** launch `vp` — there is no project-scoped binary to - spawn, and falling back to a bare `vp` from `$PATH` would - re-introduce the global-leakage hole. Recommended UX: fall back to - plain `oxlint` / `oxfmt`, optionally surface a hint like "Vite+ - detected — run `pnpm install` to enable Vite+ LSP." - -### Validity check on the install - -When Phase 2 finds a `bin/vp`, it also requires -`node_modules/vite-plus/package.json` to parse, have -`name === "vite-plus"`, and carry a string `version`. The version -string is returned in the result; orphan trees (missing -`package.json`, wrong name, missing or non-string version) are -treated as "not installed" and Phase 2 keeps walking up. - -### Version-gated LSP support - -`vp lint --lsp` and `vp fmt --lsp` only exist on `vite-plus` versions -that ship after the `bin/oxlint` / `bin/oxfmt` wrappers are -deprecated (#1557). Older installed versions still rely on the -wrappers. The detector returns `vp_version` so each consumer can -decide which mode to use: +**Parity note.** `vite-task`'s Rust `find_workspace_root` +(`crates/vite_workspace/src/package_manager.rs:135`) only recognizes +the first two and carries a `TODO(@fengmk2)` for Lerna. The RFC +deliberately keeps the broader set; aligning vite-task is a known +follow-up that does not block this RFC. -``` -if vp_version is None: - use plain oxlint/oxfmt (declared-but-not-installed case) -elif vp_version >= MIN_VP_VERSION_FOR_LSP: - launch vp lint --lsp / vp fmt --lsp -else: - fall through to the existing findBinary(oxlint/oxfmt) chain, - which picks up the legacy bin/oxlint wrapper. - Optionally surface a "Vite+ detected — upgrade vite-plus to - enable native LSP" hint. -``` +### What we deliberately do not check -`MIN_VP_VERSION_FOR_LSP` is the first `vite-plus` release that ships -`vp lint --lsp` / `vp fmt --lsp` and drops the `bin/oxlint` / -`bin/oxfmt` wrappers. The exact value is **TBD** — it gets filled in -when #1557 lands. The shared TypeScript package exports it as a -constant plus a `supportsLsp(version)` helper so both consumers -agree. - -This rollout is robust in both directions: - -- **Old vite-plus + new editor extension** → detector reports an old - version → editor falls through → `findBinary("oxlint")` resolves - the legacy wrapper bin → user gets Vite-config-aware linting via - the wrapper, as today. -- **New vite-plus + new editor extension** → detector reports a - supported version → editor launches `vp lint --lsp` directly. -- **New vite-plus + old editor extension (no detector yet)** → the - old extension keeps probing `bin/oxlint`; if the new vite-plus has - removed the wrappers, the extension falls back to whatever its - global oxlint path resolves to. Not ideal, but the upgrade hint - drives users to upgrade their extension. - -### Why this rule - -- **Direct declaration is unambiguous user intent.** A `dependencies` - or `devDependencies` entry is the only filesystem artifact that says - _"this project chose to use vite-plus."_ Everything else - (`node_modules/vite-plus/` from hoisting, `vp` on `$PATH`, global - installs, user settings, orphan files) is incidental and can lie. -- **Transitive installs are rejected by construction.** Phase 1 only - reads `package.json` of walked-up ancestors; a `vite-plus` package - hoisted into a node_modules from someone else's dependency tree - never gets checked. -- **The workspace boundary is the trust boundary.** Both phases stop - at the workspace root marker, so a nested checkout cannot inherit a - Vite+ install from its outer parent directory. -- **Launchability is reported separately from project identity.** The - editor knows precisely when to launch `vp` and when to fall back, - without conflating "no Vite+" with "Vite+ but not yet installed." - -### 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`, 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+. -- `require.resolve("vite-plus")` — Node's resolution algorithm walks - past the workspace root and can find an unrelated parent install. - We use direct directory probes that are explicitly bounded. -- A `node_modules/vite-plus/` directory that doesn't itself appear as - a direct dep in some walked-up `package.json`. Transitive installs - do not count. -- A `node_modules/vite-plus/` directory whose `package.json` is - missing, unparseable, or has `name !== "vite-plus"`. Orphan trees - do not count. -- Any ancestor above the workspace root. The walk stops there. - -## TypeScript helper package - -`oxc-vscode` and `coc-oxc` consume a published npm package -(proposed name **`@voidzero-dev/detect-vite-plus`**) rather than -hand-rolling the two-phase walk. One source of truth, one set of -tests, one bug fix flows to both extensions. - -**Why a package, not vendored snippets.** The detector's logic is now -non-trivial: two phases, tri-state result, workspace-root detection, -install validation, walk-up bounds. Two independent copies would -drift on edge cases (validation, error handling, walk-up -termination). The cost of a shared dependency is lower than the cost -of two implementations subtly diverging. - -**Package constraints:** - -- Lives at `packages/detect-vite-plus/` inside this monorepo; - independent versioning via the existing changesets workflow. -- Zero runtime dependencies — Node built-ins only (`node:fs`, - `node:path`). -- Dual ESM + CJS publish so both `oxc-vscode` and `coc-oxc` - toolchains can consume it. -- No process spawns, no network, no NAPI binding — pure JS. -- Stable, semver-versioned API. Once 1.0 ships, breaking changes - require a major bump because two editor extensions pin it. -- Target install footprint: under 10 KB unpacked. - -**Public API** (sync + async): +- `$PATH`, user's global `node_modules`, or + `oxc..binPath` settings (the last is for oxlint/oxfmt, not + `vp`). +- `require.resolve("vite-plus")` — Node's resolution algorithm can + escape the workspace root. +- A `node_modules/vite-plus/` without a direct dep declaration (a + transitive install). +- A `node_modules/vite-plus/` whose `package.json` is missing, + unparseable, or has the wrong `name` (orphan). -```ts -export interface DetectResult { - root: string; - vpPath?: string; - vpVersion?: string; -} -export function detectVitePlusProject(start: string): Promise; -export function detectVitePlusProjectSync(start: string): DetectResult | null; - -/** - * First vite-plus version that ships `vp lint --lsp` and drops the - * legacy `bin/oxlint` / `bin/oxfmt` wrappers. Updated when #1557 lands. - */ -export const MIN_VP_VERSION_FOR_LSP: string; -export function supportsLsp(version: string | undefined): boolean; -``` - -`oxc-vscode` prefers the async variant to avoid extension-host -stalls; `coc-oxc`'s startup path uses the sync variant. The package -exposes both. - -**The reference implementation below is the package source.** It is -also the spec for `oxc-zed` (Rust port) and `oxc-intellij-plugin` -(Kotlin port), which cannot consume the npm package. The conformance -fixture table at the end of this RFC binds all three implementations -to the same observable behaviour. - -The snippet uses the sync variant for readability. The async variant -is the same algorithm with `fs.promises`. +## Reference TypeScript implementation ```ts import { existsSync, readFileSync } from 'node:fs'; @@ -477,18 +156,9 @@ export interface DetectResult { /** * Absolute path to a runnable, project-scoped vp binary, when one * is installed inside the workspace. Undefined when vite-plus is - * declared but not yet installed (pre-`pnpm install`, Berry PnP - * without node_modules, broken install). Callers MUST NOT launch - * `vp` when this is undefined. + * declared but not yet installed. */ vpPath?: string; - /** - * The installed vite-plus's `package.json#version`. Set whenever - * vpPath is set. Compare against MIN_VP_VERSION_FOR_LSP (or use - * `supportsLsp`) to decide between `vp lint --lsp` and the legacy - * bin/oxlint wrapper path. - */ - vpVersion?: string; } function readPackageJson(dir: string): any | null { @@ -509,23 +179,19 @@ function declaresVitePlus(pkg: any | null): boolean { return Boolean(pkg?.dependencies?.['vite-plus'] || pkg?.devDependencies?.['vite-plus']); } -/** - * `bin/vp` must exist AND `node_modules/vite-plus/package.json` must - * parse, identify itself as `vite-plus`, and carry a string version. - * Rejects orphan trees left behind by partial uninstalls. - */ -function resolveVpAt(dir: string): { vpPath: string; vpVersion: string } | null { +/** `bin/vp` exists AND the sibling package.json identifies as vite-plus. */ +function resolveVpAt(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' || typeof pkg?.version !== 'string') return null; - return { vpPath, vpVersion: pkg.version }; + if (pkg?.name !== 'vite-plus') return null; } catch { return null; } + return vpPath; } export function detectVitePlusProjectSync(start: string): DetectResult | null { @@ -547,14 +213,13 @@ export function detectVitePlusProjectSync(start: string): DetectResult | null { } if (!root) return null; - // Phase 2: walk up from the declaring root looking for a real install, - // bounded by the workspace root. Reuses Phase 1's package.json read at - // `root` so the boundary check on the first iteration doesn't repeat I/O. + // Phase 2: walk up from root looking for a real install, bounded by + // the workspace root. Reuses Phase 1's package.json read at `root`. let probe: string | null = root; let pkg = rootPkg; while (probe) { - const installed = resolveVpAt(probe); - if (installed) return { root, ...installed }; + const vpPath = resolveVpAt(probe); + if (vpPath) return { root, vpPath }; if (isWorkspaceRoot(probe, pkg)) break; const parent = dirname(probe); if (parent === probe) break; @@ -566,261 +231,72 @@ export function detectVitePlusProjectSync(start: string): DetectResult | null { } ``` -## Per-extension migration plan - -Each extension runs the two-phase detector **before** its existing -oxlint/oxfmt lookup. The detector replaces, rather than extends, any -generic bin-resolution chain when the target is `"vp"`: - -- `null` → fall through to the existing oxlint/oxfmt chain. -- `{ root, vpPath, vpVersion }` with `supportsLsp(vpVersion)` → - launch ` lint --lsp` (or `fmt --lsp`). -- `{ root, vpPath, vpVersion }` with version too old → fall through - to the existing chain (which resolves the legacy - `bin/oxlint`/`bin/oxfmt` wrapper still shipped by that vite-plus - version). Optionally surface an upgrade hint. -- `{ root, vpPath: undefined }` → fall through to the existing - oxlint/oxfmt chain; optionally surface a "Vite+ detected — run - install to enable LSP" hint. Do **not** launch a bare `vp`. - -### `oxc-vscode` - -Add `@voidzero-dev/detect-vite-plus` as a **devDependency** — the -extension bundles it via its existing build step into the shipped -`.vsix`, so it does not appear at runtime in the extension's -`node_modules`. Call `detectVitePlusProject(workspaceFolder.fsPath)` -before invoking the existing `findBinary("oxlint" | "oxfmt", ...)` -chain. The existing chain is unchanged and only consulted when the -detector returns `null` or a declared-but-not-installed result. Do -**not** parameterize the existing `findBinary` with `"vp"` as a -target — the chain's `searchSettingsBin`, -`searchGlobalNodeModulesBin`, `searchEnvPath`, and `require.resolve` -paths can escape the workspace boundary or consult settings meant for -oxlint/oxfmt. - -### `coc-oxc` - -Add `@voidzero-dev/detect-vite-plus` as a **devDependency** — bundled -into the published artifact (coc-oxc already builds via Vite per its -existing `vite.config.ts`), not present at runtime in the user's -`node_modules`. Call `detectVitePlusProjectSync(workspace.root)` from -`findBinary()` before the `node_modules/.bin` lookup. Skip -`oxc..binPath` for `vp` (that setting targets oxlint/oxfmt). - -### `oxc-zed` - -Zed already reads the worktree's `package.json` at -`get_workspace_exe_path` (`src/lsp.rs:19-40`); it just needs the -two-phase logic ported into Rust: - -- **Phase 1** (declaration): the existing - `[package_name, "vite-plus"]` loop becomes a single - `package_exists(p, "vite-plus")` check on `dependencies` / - `devDependencies`. If absent, return `Ok(None)` and Zed's existing - oxlint/oxfmt path runs unchanged. -- **Phase 2** (binary): when declared, probe - `/node_modules/vite-plus/bin/vp` and validate - `/node_modules/vite-plus/package.json` (`name === -"vite-plus"`). If valid, return the path; otherwise return `None` - for "declared but not installed." - -Update `language_server_command` to pass `["lint", "--lsp"]` / -`["fmt", "--lsp"]` when launching `vp`. Zed's WASM API today only -reads the worktree root, so deeper walk-up isn't currently -expressible — that's a known limitation worth noting in the Zed PR -but doesn't block this RFC. - -### `oxc-intellij-plugin` - -`VitePlusPackage.kt` already locates `vite-plus` via IntelliJ's -`NodePackageDescriptor`, which is project-scoped. Tighten it to -require `vite-plus` to appear as a **direct** dependency of the -project's `package.json` (IntelliJ's package descriptor exposes -direct vs. transitive). Change the returned path from -`/bin/oxlint` to `/bin/vp` and update launch -args to `lint --lsp` / `fmt --lsp`. When the descriptor finds the -declaration but no installed package, fall back to plain -oxlint/oxfmt; do not launch a bare `vp`. - -## Decisions - -### Publish a shared TypeScript helper for the Node consumers - -Locked. The detector ships as -`@voidzero-dev/detect-vite-plus` (name TBD) and the two Node-capable -extensions (`oxc-vscode`, `coc-oxc`) depend on it directly. Two -benefits over vendored copies: bug fixes flow to both extensions -through a normal version bump, and the conformance fixtures only need -to be exercised once in this repo's CI to guarantee parity. `oxc-zed` -(Rust) and `oxc-intellij-plugin` (Kotlin) cannot consume an npm -package; they port the same algorithm against the shared conformance -fixtures, which is reasonable because each is already non-trivial -work in those repos. - -### Direct declaration in `package.json` is the only project-identity signal - -Locked. A project is Vite+ iff some walked-up `package.json` lists -`vite-plus` in `dependencies` or `devDependencies` directly. The -binary's mere presence in `node_modules` does **not** qualify — a -transitive install hoisted by a package manager would otherwise -misclassify unrelated projects. Replaces an earlier "hybrid -two-signal" design that treated `bin/vp` existence as an independent -positive signal. - -### Binary resolution is a separate, project-scoped question - -Locked. Once declared, we look for `node_modules/vite-plus/bin/vp` by -walking up from the declaring ancestor and never crossing the -workspace root. We never call `require.resolve` (Node's resolution -algorithm walks past the workspace root and would re-open the -nested-repo leakage hole). We do not consult `$PATH`, the user's -global `node_modules`, or `oxc..binPath` (which targets -oxlint/oxfmt, not vp). - -### Tri-state result, not boolean - -Locked. The "declared but not installed" state is reported -separately from "not Vite+" so editors can distinguish a fresh clone -from a non-Vite+ project — and so they never launch a bare `vp` they -would have to find on `$PATH`. - -### Valid `vite-plus` install required before accepting `bin/vp` - -Locked. `node_modules/vite-plus/package.json` must parse, have -`name === "vite-plus"`, and carry a string `version`. Orphan `bin/vp` -files (partial uninstall, hand-crafted directories, stale caches) are -treated as "not installed" and Phase 2 keeps walking up. - -### Report the installed vite-plus version - -Locked. The detector returns `vpVersion` from the installed -`node_modules/vite-plus/package.json` so consumers can gate -`vp lint --lsp` behind a minimum supported version. This keeps the -rollout backwards-compatible: editors upgraded to use this detector -correctly handle workspaces still pinned to older `vite-plus` -versions that ship the `bin/oxlint` wrapper, and can surface an -"upgrade `vite-plus` to enable native LSP" hint when appropriate. -The threshold (`MIN_VP_VERSION_FOR_LSP`) lives in the shared -detector package and is updated when #1557's removal of the -wrappers lands. - -### Workspace-wide granularity - -If any ancestor up to the workspace root 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. - -### Yarn PnP - -Berry with PnP has no `node_modules`. Phase 1 still finds the -declaration in `package.json`. Phase 2 fails to resolve a binary → -the detector returns `{ root }` (declared but not installed from the -detector's perspective). For v1, the editor falls back to plain -oxlint/oxfmt in this case. PnP-aware binary resolution is a v2 -extension that would plug into Phase 2 only. - -### Walk stops at the workspace root - -Locked. A nested checkout placed under a parent directory that -happens to have its own `vite-plus` install must not inherit Vite+ -behaviour from that unrelated workspace. - -## Downstream coordination - -**In this repo (lands first):** - -- `packages/detect-vite-plus/` — publish - `@voidzero-dev/detect-vite-plus` (sync + async API, ESM + CJS, - zero deps). The reference snippet above is the package source. -- Conformance test suite running the package against every fixture - in the table below. - -**Downstream PRs** (each extension owns its own repo and test -fixtures): - -- `oxc-vscode` PR: add `@voidzero-dev/detect-vite-plus` as a - devDependency (bundled into the `.vsix`); call it ahead of the - existing `findBinary("oxlint" | "oxfmt")` chain; launch - `vp lint --lsp` / `vp fmt --lsp` only when `vpPath` is set, - otherwise fall through. -- `coc-oxc` PR: add `@voidzero-dev/detect-vite-plus` as a devDependency - (bundled into the published artifact); call it from `findBinary()` - before the `node_modules/.bin` lookup; same launch rule. -- `oxc-zed` PR: replace the `[package_name, "vite-plus"]` loop in - `lsp.rs:28` with the two-phase check ported into Rust; return the - `vp` path with args `["lint" | "fmt", "--lsp"]`. Fall back to - oxlint/oxfmt when declared-but-not-installed. Replicate the - conformance fixtures in the Zed PR's tests. -- `oxc-intellij-plugin` PR: keep `VitePlusPackage.kt`, tighten it to - require `vite-plus` as a direct dep, change the returned path to - `bin/vp`, and update launch args. Fall back to oxlint/oxfmt when - declared but no installed package is found. Replicate the - conformance fixtures in the IntelliJ PR's tests. +The async variant is the same algorithm with `fs.promises`. + +## Per-extension migration + +All four extensions run the detector first, then: + +- `null` → fall through to the existing oxlint/oxfmt resolution. +- `{ root, vpPath }` → launch ` lint --lsp` / `fmt --lsp`. On + launch failure, surface an upgrade hint. +- `{ root }` → surface an install hint; do not launch anything Vite+. + +Specifics: + +- **`oxc-vscode`, `coc-oxc`** — call the detector before the existing + `findBinary("oxlint" | "oxfmt", ...)` chain. Do **not** parameterize + the existing chain with `"vp"` as a target — its + `searchSettingsBin`, `searchGlobalNodeModulesBin`, `searchEnvPath`, + and `require.resolve` paths can escape the workspace boundary or + consult settings meant for oxlint/oxfmt. +- **`oxc-zed`** — replace the `[package_name, "vite-plus"]` loop at + `lsp.rs:28` with the two-phase check ported into Rust. Update + `language_server_command` to pass `["lint", "--lsp"]` / + `["fmt", "--lsp"]` when launching `vp`. Zed's WASM API only reads + the worktree root, so deeper walk-up is a known limitation worth + noting in the Zed PR. +- **`oxc-intellij-plugin`** — `VitePlusPackage.kt` already locates + `vite-plus` via IntelliJ's `NodePackageDescriptor`. Tighten it to + require a direct dep, change the returned path from + `/bin/oxlint` to `/bin/vp`, update launch + args. -## Open questions +## Conformance fixtures -1. **`MIN_VP_VERSION_FOR_LSP`**. The concrete version of `vite-plus` - at which the wrappers go away and `vp lint --lsp` becomes the - supported entry point is TBD — it gets set when the corresponding - `vite-plus` release ships. The detector package exports this as a - constant; both consumers depend on the package picking up the - value, so a version bump of the detector is what enables LSP for - new users. -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. **"Declared but not installed" UX.** Should editors silently fall - back to plain oxlint/oxfmt, or surface a notification prompting - `pnpm install`? Proposal: silent fallback in v1, leave the UX - decision to each extension. -5. **"Installed but not configured."** Should we additionally require - `vite.config.ts` to exist? Proposal: **no**. A direct dep - declaration is intent enough. +Every implementation must produce identical answers on these +fixtures. Each extension replicates the set in its own test suite. + +| Fixture | Tree | Expected result | +| --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| `root-declared-and-installed` | Root `package.json` declares `vite-plus` + valid `node_modules/vite-plus/` install | `{ root: "", vpPath: "/node_modules/vite-plus/bin/vp" }` | +| `pnpm-subpackage-declared-root-hoisted` | `pnpm-workspace.yaml` at ``, `packages/app/package.json` declares `vite-plus`, install hoisted to `/node_modules/vite-plus/` | From `packages/app/`: `{ root: "/packages/app", vpPath: "/node_modules/vite-plus/bin/vp" }` | +| `npm-subpackage-direct-dep-unhoisted` | Root `package.json` with `workspaces`, `packages/app/package.json` declares `vite-plus`, install inside `packages/app/node_modules/vite-plus/` | From `packages/app/`: `{ root: "/packages/app", vpPath: "/packages/app/node_modules/vite-plus/bin/vp" }` | +| `root-declared-no-install` | Root `package.json` declares `vite-plus`, no `node_modules` (fresh clone) | `{ root: "" }` — install hint | +| `transitive-install` | No walked-up `package.json` declares `vite-plus`, but `node_modules/vite-plus/` exists as a transitive dep | `null` — no direct declaration | +| `bin-vp-orphan` | Declared in root `package.json`, but `node_modules/vite-plus/` is broken (missing `package.json`, wrong `name`, or unparseable) | `{ root: "" }` — install rejected as orphan | +| `parent-vite-plus-nested-repo` | Outer dir declares + installs `vite-plus`; inner subdir is its own workspace root and does not | From inside the nested workspace: `null` | +| `plain-non-vite-plus` | A normal Node project, no `vite-plus` anywhere | `null` | +| `yarn4-pnp` | Berry/PnP, no `node_modules`, root `package.json` declares `vite-plus` | `{ root: "" }` — install hint | -## Conformance fixtures +## Open questions -Every implementation must produce identical answers on the following -fixtures. Each extension replicates the set inside its own test suite. - -`DetectResult` shape: `{ root: string, vpPath?: string, vpVersion?: string }`; -`null` means not a Vite+ project. Every fixture below also has an -implicit assertion: when `vpPath` is set, `vpVersion` must equal the -fixture's installed `node_modules/vite-plus/package.json#version`. - -| Fixture | Tree | Expected result | -| --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | -| `root-declared-and-installed` | Root `package.json` declares `vite-plus` + `node_modules/vite-plus/bin/vp` + valid `node_modules/vite-plus/package.json` (`version: ""`) | `{ root: "", vpPath: "/node_modules/vite-plus/bin/vp", vpVersion: "" }`; `supportsLsp(vpVersion) === true` | -| `installed-but-version-too-old` | Same tree as `root-declared-and-installed` but with an older `vite-plus` version that pre-dates `vp lint --lsp` | `{ root, vpPath, vpVersion: "" }`; `supportsLsp(vpVersion) === false` — editor must fall through to legacy `bin/oxlint` chain | -| `installed-no-version-field` | Declared + `bin/vp` exists; `node_modules/vite-plus/package.json` is missing `version` or has it as a non-string | `{ root: "" }` — install rejected, vpPath absent | -| `pnpm-subpackage-declared-root-hoisted` | `pnpm-workspace.yaml` at ``, root `package.json` does **not** declare `vite-plus`, `packages/app/package.json` declares it, install is hoisted to `/node_modules/vite-plus/` | From inside `packages/app/`: `{ root: "/packages/app", vpPath: "/node_modules/vite-plus/bin/vp", vpVersion: "" }` | -| `root-declared-no-install` | Root `package.json` declares `vite-plus`, no `node_modules` (fresh clone) | `{ root: "" }` — vpPath and vpVersion absent | -| `npm-package-installed-direct-dep` | Root `package.json` with `workspaces`, `packages/app/package.json` declares `vite-plus`, install inside `packages/app/node_modules/vite-plus/` (un-hoisted) | From inside `packages/app/`: `{ root: "/packages/app", vpPath: "/packages/app/node_modules/vite-plus/bin/vp", vpVersion: "" }` | -| `plain-non-vite-plus` | Normal Node project, no `vite-plus` anywhere | `null` | -| `plain-vite-no-vp` | Uses Vite (`vite` declared, `vite.config.ts` present) but does not declare `vite-plus` | `null` | -| `transitive-install` | No walked-up `package.json` declares `vite-plus`, but `node_modules/vite-plus/` exists as a transitive dep (pulled in by some other package) | `null` — Phase 1 fails: no direct declaration | -| `bin-vp-orphan-no-package-json` | Declared in root `package.json`, but `node_modules/vite-plus/bin/vp` exists with no sibling `package.json` | `{ root: "" }` — install rejected as orphan | -| `bin-vp-orphan-wrong-name` | Declared in root `package.json`, `bin/vp` + `package.json` exist but `package.json` has `name !== "vite-plus"` or is unparseable | `{ root: "" }` — install rejected | -| `parent-vite-plus-nested-repo` | Outer dir declares `vite-plus` and has the install; inner subdirectory is its own workspace root (own `pnpm-workspace.yaml`/`package.json#workspaces`) and does not declare `vite-plus` | From inside the nested workspace: `null` — Phase 1 stops at the inner workspace root | -| `global-vp-on-path` | Plain Node project, no declaration; `vp` is on `$PATH` and/or in the user's global `node_modules` | `null` | -| `user-binpath-override` | Plain Node project, no declaration; `oxc.oxlint.binPath` configured to a `vp` binary | `null` | -| `yarn4-pnp` | Berry/PnP, no `node_modules`, root `package.json` declares `vite-plus` | `{ root: "" }` — declared, install not resolvable via plain filesystem walk | - -## 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. +1. **Publish the detector as a shared npm package?** The current + proposal is `@voidzero-dev/detect-vite-plus` at + `packages/detect-vite-plus/`, consumed as a bundled devDependency + by `oxc-vscode` and `coc-oxc`. The alternative is to let each + Node-capable extension copy the ~50-line snippet directly. + Decision deferred to the maintainers. +2. **Final package name** if published. +3. **"Declared but not installed" UX** — silent fallback to plain + oxlint vs. install notification. This RFC proposes a notification + (silent fallback loses Vite+-aware behaviour anyway because the + wrapper's `VP_VERSION` env var isn't set), but the specific + message and presentation is per-extension. + +## Verification + +Each downstream PR replicates the fixture table inside its own test +suite. Before merging, do a manual smoke test against a real Vite+ +project and a plain-oxlint project, in both fresh-clone and +post-install states.