diff --git a/rewrite-javascript/rewrite/src/javascript/index.ts b/rewrite-javascript/rewrite/src/javascript/index.ts index a4bc48e4f12..d2bd603daf5 100644 --- a/rewrite-javascript/rewrite/src/javascript/index.ts +++ b/rewrite-javascript/rewrite/src/javascript/index.ts @@ -30,6 +30,7 @@ export * from "./format"; export * from "./autodetect"; export * from "./tree-debug"; export * from "./project-parser"; +export * from "./workspace-discovery"; export * from "./add-import"; export * from "./remove-import"; diff --git a/rewrite-javascript/rewrite/src/javascript/workspace-discovery.ts b/rewrite-javascript/rewrite/src/javascript/workspace-discovery.ts new file mode 100644 index 00000000000..dae510dd2d7 --- /dev/null +++ b/rewrite-javascript/rewrite/src/javascript/workspace-discovery.ts @@ -0,0 +1,458 @@ +/* + * Copyright 2025 the original author or authors. + *
+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *
+ * https://docs.moderne.io/licensing/moderne-source-available-license + *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import * as path from "path";
+import * as fs from "fs";
+import * as ts from "typescript";
+import * as YAML from "yaml";
+import picomatch from "picomatch";
+import {PackageManager} from "./node-resolution-result";
+import {detectPackageManager, getAllLockFileNames} from "./package-manager";
+import {ProjectParser} from "./project-parser";
+
+/**
+ * Prettier config file names checked per project (presence-only; not parsed). The
+ * package.json "prettier" key is covered by package.json itself already being watched.
+ */
+const PRETTIER_CONFIG_FILES = [
+ ".prettierrc", ".prettierrc.json", ".prettierrc.json5", ".prettierrc.yaml", ".prettierrc.yml",
+ ".prettierrc.toml", ".prettierrc.js", ".prettierrc.cjs", ".prettierrc.mjs", ".prettierrc.ts",
+ "prettier.config.js", "prettier.config.cjs", "prettier.config.mjs", "prettier.config.ts"
+];
+
+/** Jest config file names checked per project (presence-only; not parsed). */
+const JEST_CONFIG_FILES = [
+ "jest.config.js", "jest.config.cjs", "jest.config.mjs", "jest.config.ts", "jest.config.json"
+];
+
+/** Vitest (and the Vite config it reads) file names checked per project (presence-only; not parsed). */
+const VITEST_CONFIG_FILES = [
+ "vitest.config.ts", "vitest.config.js", "vitest.config.mts", "vitest.config.mjs", "vitest.config.cts", "vitest.config.cjs",
+ "vite.config.ts", "vite.config.js", "vite.config.mts", "vite.config.mjs", "vite.config.cts", "vite.config.cjs"
+];
+
+/**
+ * Globs (relative to a project path) identifying test sources by convention. These
+ * are emitted verbatim as the test set's {@code includes} and the main set's
+ * {@code excludes}, and are also used to decide whether a project has any test files.
+ */
+const TEST_GLOBS = ["**/*.test.*", "**/*.spec.*", "**/__tests__/**", "test/**"];
+
+/** Matcher for {@link TEST_GLOBS}, compiled once and reused across all projects. */
+const isTestFile = picomatch(TEST_GLOBS);
+
+/**
+ * Resolution-relevant compiler configuration the parser needs. v1 carries only the
+ * path to the nearest tsconfig; the object stays open so resolved fields (paths,
+ * baseUrl, jsx, moduleResolution, ...) can be added additively without a reshape.
+ */
+export interface ParserSettings {
+ /** Relative, "/"-normalized path to the nearest tsconfig.json, if any. */
+ tsconfigPath?: string;
+}
+
+/**
+ * One source set ("main"/"test") within a project. Modeled as a list element (not a
+ * boolean) so divergent prod/test parser settings are additive later (Decision #6).
+ */
+export interface SourceSetDescriptor {
+ name: "main" | "test";
+ /** Globs (relative to the project path) selecting this set's files. */
+ includes?: string[];
+ excludes?: string[];
+ parserSettings: ParserSettings;
+}
+
+/**
+ * An independent JavaScript/TypeScript project (a package.json root, workspace-aware).
+ */
+export interface ProjectDescriptor {
+ /** package.json directory, relative to {@link WorkspaceDiscoveryOptions.relativeTo}, "/"-normalized. */
+ path: string;
+ packageManager: PackageManager;
+ /** Watch-set: files whose change forces a full re-parse. Relative, "/"-normalized, deduped. */
+ configInputs: string[];
+ sourceSets: SourceSetDescriptor[];
+ /** Populated only when install ownership moves into the server (Decision #5/#8); always null in v1. */
+ resolution: null;
+}
+
+export interface WorkspaceDiscoveryOptions {
+ /** Glob patterns to exclude from discovery. */
+ exclusions?: string[];
+ /** Base directory for the returned relative paths (default: repositoryRoot). */
+ relativeTo?: string;
+}
+
+/** Normalizes an absolute path to one relative to {@code base}, using "/" separators. */
+function relativeNormalized(base: string, target: string): string {
+ return path.relative(base, target).split(path.sep).join("/");
+}
+
+/**
+ * Reads the workspace member globs declared at the repository root, if any:
+ * npm/yarn/bun via root package.json {@code workspaces} (array or {@code {packages}}),
+ * pnpm via {@code pnpm-workspace.yaml}'s {@code packages}. Returns an empty array when
+ * the repo is not a workspace.
+ */
+function readWorkspaceGlobs(root: string): string[] {
+ const globs: string[] = [];
+
+ const rootPackageJson = path.join(root, "package.json");
+ if (fs.existsSync(rootPackageJson)) {
+ try {
+ const pkg = JSON.parse(fs.readFileSync(rootPackageJson, "utf-8"));
+ const ws = pkg.workspaces;
+ const list = Array.isArray(ws) ? ws : Array.isArray(ws?.packages) ? ws.packages : [];
+ for (const g of list) {
+ if (typeof g === "string") {
+ globs.push(g);
+ }
+ }
+ } catch {
+ // Malformed root package.json — treat as non-workspace.
+ }
+ }
+
+ const pnpmWorkspace = path.join(root, "pnpm-workspace.yaml");
+ if (fs.existsSync(pnpmWorkspace)) {
+ try {
+ const doc = YAML.parse(fs.readFileSync(pnpmWorkspace, "utf-8"));
+ for (const g of (Array.isArray(doc?.packages) ? doc.packages : [])) {
+ if (typeof g === "string") {
+ globs.push(g);
+ }
+ }
+ } catch {
+ // Malformed pnpm-workspace.yaml — ignore.
+ }
+ }
+
+ return globs;
+}
+
+/**
+ * Determines the project roots: when the repo is a workspace, the member directories
+ * matching its globs (the root manager is excluded unless it is itself a member);
+ * otherwise the shallowest package.json per branch (a nested package.json under another
+ * project's directory is pruned). Returns absolute directories.
+ */
+function determineProjectDirs(root: string, packageJsonFiles: string[]): string[] {
+ const allDirs = packageJsonFiles.map(f => path.dirname(f));
+ const globs = readWorkspaceGlobs(root);
+
+ if (globs.length > 0) {
+ // Honor npm/yarn/pnpm semantics: "!"-prefixed entries subtract members, and a
+ // trailing slash on a directory glob (e.g. "packages/*/") still matches the dir.
+ const normalize = (g: string) => g.replace(/\/$/, "");
+ const includes = globs.filter(g => !g.startsWith("!")).map(normalize);
+ const excludes = globs.filter(g => g.startsWith("!")).map(g => normalize(g.slice(1)));
+ const isIncluded = includes.length > 0 ? picomatch(includes) : () => false;
+ const isExcluded = excludes.length > 0 ? picomatch(excludes) : () => false;
+ return allDirs.filter(dir => {
+ if (dir === root) {
+ return false; // root manager is not itself a member
+ }
+ const rel = relativeNormalized(root, dir);
+ return isIncluded(rel) && !isExcluded(rel);
+ });
+ }
+
+ // Non-workspace: keep only dirs with no ancestor package.json dir in the set.
+ const dirSet = new Set(allDirs);
+ return allDirs.filter(dir => {
+ let parent = path.dirname(dir);
+ while (parent !== dir) {
+ if (dirSet.has(parent)) {
+ return false;
+ }
+ const next = path.dirname(parent);
+ if (next === parent) {
+ break;
+ }
+ dir = parent;
+ parent = next;
+ }
+ return true;
+ });
+}
+
+/**
+ * Resolves a project's package manager: from the nearest lock file if present, else from
+ * the corepack {@code packageManager} field in the nearest package.json (e.g.
+ * {@code "pnpm@8.15.0"}), else Npm. {@code lockFile} is the precomputed nearest lock file.
+ */
+function resolvePackageManager(projectDir: string, root: string, lockFile: string | undefined): PackageManager {
+ if (lockFile) {
+ const fromLock = detectPackageManager(path.dirname(lockFile));
+ if (fromLock) {
+ return fromLock;
+ }
+ }
+ return readCorepackPackageManager(projectDir, root) ?? PackageManager.Npm;
+}
+
+/**
+ * Reads the corepack {@code packageManager} field from the nearest package.json at or
+ * above {@code projectDir} (bounded by {@code root}) and maps it to a {@link PackageManager}.
+ */
+function readCorepackPackageManager(projectDir: string, root: string): PackageManager | undefined {
+ let dir = projectDir;
+ while (true) {
+ const packageJson = path.join(dir, "package.json");
+ if (fs.existsSync(packageJson)) {
+ try {
+ const spec = JSON.parse(fs.readFileSync(packageJson, "utf-8")).packageManager;
+ if (typeof spec === "string") {
+ const pm = parsePackageManagerSpec(spec);
+ if (pm) {
+ return pm;
+ }
+ }
+ } catch {
+ // Malformed package.json — keep walking up.
+ }
+ }
+ if (dir === root) {
+ return undefined;
+ }
+ const parent = path.dirname(dir);
+ if (parent === dir) {
+ return undefined;
+ }
+ dir = parent;
+ }
+}
+
+/**
+ * Parses a corepack {@code packageManager} spec ({@code "
+ * Licensed under the Moderne Source Available License (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://docs.moderne.io/licensing/moderne-source-available-license
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import * as rpc from "vscode-jsonrpc/node";
+import * as path from "path";
+import {withMetrics} from "./metrics";
+import type {ProjectDescriptor} from "../../javascript/workspace-discovery";
+
+/**
+ * Result of the Prebuild RPC: the independent projects discovered in the repository
+ * (workspace-aware), each with its source sets, config-input watch-set, and parser
+ * settings. The JavaScript analog of "invoke the build tool" — it produces the build
+ * descriptor a consumer uses to drive builds; it does not parse sources.
+ *
+ * v1 is read-only: no dependency install, and {@link ProjectDescriptor.resolution} is
+ * always null.
+ */
+export interface PrebuildResult {
+ projects: ProjectDescriptor[];
+}
+
+/**
+ * RPC request to compute the build descriptor for a repository (or partition) root.
+ *
+ * Mirrors {@link ParseProject}'s shape: optional {@code exclusions}, a {@code relativeTo}
+ * base for returned paths, and a forward-compat {@code options} carrier (unused in v1).
+ */
+export class Prebuild {
+ constructor(
+ private readonly repositoryRoot: string,
+ private readonly exclusions?: string[],
+ /**
+ * Optional base for all returned paths. Defaults to {@code repositoryRoot}.
+ * Use when prebuilding a subdirectory but wanting paths relative to the repo root.
+ */
+ private readonly relativeTo?: string,
+ /**
+ * Forward-compat carrier for future options (e.g. stateful-session hints).
+ * Unused in v1.
+ */
+ private readonly options?: Record
+ * Licensed under the Moderne Source Available License (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://docs.moderne.io/licensing/moderne-source-available-license
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import * as fs from "fs";
+import * as path from "path";
+import {dir, DirectoryResult} from "tmp-promise";
+import {discoverProjects} from "../../src/javascript/workspace-discovery";
+
+describe("workspace-discovery", () => {
+ let tmp: DirectoryResult;
+ let root: string;
+
+ beforeEach(async () => {
+ tmp = await dir({unsafeCleanup: true});
+ root = fs.realpathSync(tmp.path);
+ });
+
+ afterEach(async () => {
+ await tmp.cleanup();
+ });
+
+ function write(rel: string, content: string): void {
+ const abs = path.join(root, rel);
+ fs.mkdirSync(path.dirname(abs), {recursive: true});
+ fs.writeFileSync(abs, content);
+ }
+
+ it("returns one project for a standalone package.json", async () => {
+ write("package.json", JSON.stringify({name: "app", version: "1.0.0"}));
+ write("src/index.ts", "export const x = 1;");
+
+ const projects = await discoverProjects(root);
+
+ expect(projects).toHaveLength(1);
+ expect(projects[0].path).toBe("");
+ // No lockfile → default Npm
+ expect(projects[0].packageManager).toBe("Npm");
+ expect(projects[0].resolution).toBeNull();
+ });
+
+ it("emits only a main source set when there are no test files", async () => {
+ write("package.json", JSON.stringify({name: "app"}));
+ write("src/index.ts", "export const x = 1;");
+
+ const [project] = await discoverProjects(root);
+
+ expect(project.sourceSets).toHaveLength(1);
+ expect(project.sourceSets[0].name).toBe("main");
+ expect(project.sourceSets[0].includes).toBeUndefined();
+ expect(project.sourceSets[0].excludes).toBeUndefined();
+ });
+
+ it("splits main/test by convention and sets tsconfigPath on both sets", async () => {
+ write("package.json", JSON.stringify({name: "app"}));
+ write("tsconfig.json", JSON.stringify({compilerOptions: {strict: true}}));
+ write("src/index.ts", "export const x = 1;");
+ write("src/foo.test.ts", "test('x', () => {});");
+
+ const [project] = await discoverProjects(root);
+
+ const names = project.sourceSets.map(s => s.name);
+ expect(names).toEqual(["main", "test"]);
+
+ const main = project.sourceSets.find(s => s.name === "main")!;
+ const test = project.sourceSets.find(s => s.name === "test")!;
+ // Test set selects the conventional globs; main excludes them.
+ expect(test.includes).toContain("**/*.test.*");
+ expect(main.excludes).toContain("**/*.test.*");
+
+ expect(main.parserSettings.tsconfigPath).toBe("tsconfig.json");
+ expect(test.parserSettings.tsconfigPath).toBe("tsconfig.json");
+ });
+
+ it("resolves the configInputs watch-set including the local tsconfig extends chain and references", async () => {
+ write("package.json", JSON.stringify({name: "app"}));
+ write("package-lock.json", "{}");
+ write("tsconfig.json", JSON.stringify({
+ extends: "./tsconfig.base.json",
+ references: [{path: "./tsconfig.node.json"}]
+ }));
+ write("tsconfig.base.json", JSON.stringify({compilerOptions: {strict: true}}));
+ write("tsconfig.node.json", JSON.stringify({compilerOptions: {}}));
+ write(".prettierrc", "{}");
+ write("vitest.config.ts", "export default {};");
+ write("src/index.ts", "export const x = 1;");
+
+ const [project] = await discoverProjects(root);
+
+ expect(new Set(project.configInputs)).toEqual(new Set([
+ "package.json",
+ "package-lock.json",
+ "tsconfig.json",
+ "tsconfig.base.json",
+ "tsconfig.node.json",
+ ".prettierrc",
+ "vitest.config.ts"
+ ]));
+ });
+
+ it("resolves a tsconfig reference that points at a directory to its tsconfig.json", async () => {
+ write("package.json", JSON.stringify({name: "app"}));
+ write("tsconfig.json", JSON.stringify({references: [{path: "./sub"}]}));
+ write("sub/tsconfig.json", JSON.stringify({compilerOptions: {}}));
+ write("src/index.ts", "export const x = 1;");
+
+ const [project] = await discoverProjects(root);
+
+ expect(project.configInputs).toContain("sub/tsconfig.json");
+ });
+
+ it("skips npm-package tsconfig extends (not a watchable local file)", async () => {
+ write("package.json", JSON.stringify({name: "app"}));
+ write("tsconfig.json", JSON.stringify({extends: "@tsconfig/node20/tsconfig.json"}));
+ write("src/index.ts", "export const x = 1;");
+
+ const [project] = await discoverProjects(root);
+
+ expect(project.configInputs).toContain("tsconfig.json");
+ expect(project.configInputs.some(p => p.includes("node_modules") || p.includes("@tsconfig"))).toBe(false);
+ });
+
+ it("fans out npm workspaces to one project per member, excluding the root manager", async () => {
+ write("package.json", JSON.stringify({name: "root", private: true, workspaces: ["packages/*"]}));
+ write("package-lock.json", "{}");
+ write("packages/a/package.json", JSON.stringify({name: "a"}));
+ write("packages/a/src/index.ts", "export const a = 1;");
+ write("packages/b/package.json", JSON.stringify({name: "b"}));
+ write("packages/b/src/index.ts", "export const b = 2;");
+
+ const projects = await discoverProjects(root);
+
+ expect(projects.map(p => p.path).sort()).toEqual(["packages/a", "packages/b"]);
+ // The shared root lock file is each member's nearest lock file.
+ for (const project of projects) {
+ expect(project.packageManager).toBe("Npm");
+ expect(project.configInputs).toContain("package-lock.json");
+ }
+ const a = projects.find(p => p.path === "packages/a")!;
+ expect(a.configInputs).toContain("packages/a/package.json");
+ });
+
+ it("fans out a pnpm workspace and detects pnpm from the root lock file", async () => {
+ write("pnpm-workspace.yaml", "packages:\n - 'packages/*'\n");
+ write("package.json", JSON.stringify({name: "root"}));
+ write("pnpm-lock.yaml", "lockfileVersion: '6.0'\n");
+ write("packages/a/package.json", JSON.stringify({name: "a"}));
+ write("packages/a/index.ts", "export const a = 1;");
+
+ const projects = await discoverProjects(root);
+
+ expect(projects.map(p => p.path)).toEqual(["packages/a"]);
+ expect(projects[0].packageManager).toBe("Pnpm");
+ });
+
+ it("distinguishes yarn Berry from Classic via the lock file content", async () => {
+ write("package.json", JSON.stringify({name: "berry"}));
+ write("yarn.lock", "__metadata:\n version: 6\n");
+ write("index.ts", "export const x = 1;");
+
+ const [berry] = await discoverProjects(root);
+ expect(berry.packageManager).toBe("YarnBerry");
+ });
+
+ it("prunes nested package.json that is not a workspace member", async () => {
+ write("package.json", JSON.stringify({name: "root"}));
+ write("tools/sub/package.json", JSON.stringify({name: "sub"}));
+ write("index.ts", "export const x = 1;");
+
+ const projects = await discoverProjects(root);
+
+ expect(projects.map(p => p.path)).toEqual([""]);
+ });
+
+ it("honors workspace negation (!) exclusion patterns", async () => {
+ write("package.json", JSON.stringify({
+ name: "root", private: true, workspaces: ["packages/*", "!packages/excluded"]
+ }));
+ write("packages/keep/package.json", JSON.stringify({name: "keep"}));
+ write("packages/keep/index.ts", "export const k = 1;");
+ write("packages/excluded/package.json", JSON.stringify({name: "excluded"}));
+ write("packages/excluded/index.ts", "export const e = 1;");
+
+ const projects = await discoverProjects(root);
+
+ expect(projects.map(p => p.path)).toEqual(["packages/keep"]);
+ });
+
+ it("matches workspace globs written with a trailing slash", async () => {
+ write("package.json", JSON.stringify({name: "root", workspaces: ["packages/*/"]}));
+ write("packages/a/package.json", JSON.stringify({name: "a"}));
+ write("packages/a/index.ts", "export const a = 1;");
+
+ const projects = await discoverProjects(root);
+
+ expect(projects.map(p => p.path)).toEqual(["packages/a"]);
+ });
+
+ it("expands brace workspace globs", async () => {
+ write("package.json", JSON.stringify({name: "root", workspaces: ["{apps,libs}/*"]}));
+ write("apps/web/package.json", JSON.stringify({name: "web"}));
+ write("apps/web/index.ts", "export const w = 1;");
+ write("libs/ui/package.json", JSON.stringify({name: "ui"}));
+ write("libs/ui/index.ts", "export const u = 1;");
+ write("other/x/package.json", JSON.stringify({name: "x"}));
+ write("other/x/index.ts", "export const x = 1;");
+
+ const projects = await discoverProjects(root);
+
+ expect(projects.map(p => p.path).sort()).toEqual(["apps/web", "libs/ui"]);
+ });
+
+ it("falls back to the corepack packageManager field when there is no lock file", async () => {
+ write("package.json", JSON.stringify({name: "app", packageManager: "pnpm@8.15.0"}));
+ write("index.ts", "export const x = 1;");
+
+ const [project] = await discoverProjects(root);
+
+ expect(project.packageManager).toBe("Pnpm");
+ });
+
+ it("infers yarn Berry vs Classic from the corepack packageManager version", async () => {
+ write("a/package.json", JSON.stringify({name: "a", packageManager: "yarn@1.22.19"}));
+ write("a/index.ts", "export const a = 1;");
+ write("b/package.json", JSON.stringify({name: "b", packageManager: "yarn@4.1.0"}));
+ write("b/index.ts", "export const b = 1;");
+
+ const projects = await discoverProjects(root);
+
+ expect(projects.find(p => p.path === "a")!.packageManager).toBe("YarnClassic");
+ expect(projects.find(p => p.path === "b")!.packageManager).toBe("YarnBerry");
+ });
+});
diff --git a/rewrite-javascript/src/integTest/java/org/openrewrite/javascript/rpc/JavaScriptRewriteRpcTest.java b/rewrite-javascript/src/integTest/java/org/openrewrite/javascript/rpc/JavaScriptRewriteRpcTest.java
index 2deca89a7d0..48c8aaebc01 100644
--- a/rewrite-javascript/src/integTest/java/org/openrewrite/javascript/rpc/JavaScriptRewriteRpcTest.java
+++ b/rewrite-javascript/src/integTest/java/org/openrewrite/javascript/rpc/JavaScriptRewriteRpcTest.java
@@ -29,6 +29,7 @@
import org.openrewrite.java.tree.JavaType;
import org.openrewrite.javascript.JavaScriptIsoVisitor;
import org.openrewrite.javascript.JavaScriptParser;
+import org.openrewrite.javascript.marker.NodeResolutionResult;
import org.openrewrite.javascript.style.Autodetect;
import org.openrewrite.marker.Markup;
import org.openrewrite.marketplace.RecipeBundle;
@@ -434,6 +435,57 @@ void parseProjectWithExclusions(@TempDir Path projectDir) throws Exception {
.noneMatch(p -> p.contains("vendor"));
}
+ @Test
+ void prebuild(@TempDir Path repoDir) throws Exception {
+ // npm workspace root with a shared base tsconfig and lock file
+ Files.writeString(repoDir.resolve("package.json"), """
+ {"name": "root", "private": true, "workspaces": ["packages/*"]}
+ """);
+ Files.writeString(repoDir.resolve("package-lock.json"), "{}");
+ Files.writeString(repoDir.resolve("tsconfig.base.json"), """
+ {"compilerOptions": {"strict": true}}
+ """);
+
+ // workspace member "app" with a tsconfig extending the root base + main/test files
+ Files.createDirectories(repoDir.resolve("packages/app/src"));
+ Files.writeString(repoDir.resolve("packages/app/package.json"), """
+ {"name": "app"}
+ """);
+ Files.writeString(repoDir.resolve("packages/app/tsconfig.json"), """
+ {"extends": "../../tsconfig.base.json"}
+ """);
+ Files.writeString(repoDir.resolve("packages/app/src/index.ts"), "export const x = 1;");
+ Files.writeString(repoDir.resolve("packages/app/src/index.test.ts"), "test('x', () => {});");
+
+ PrebuildResult result = client().prebuild(repoDir);
+
+ // One project: the workspace member, not the root manager
+ assertThat(result.getProjects()).hasSize(1);
+ PrebuildResult.ProjectDescriptor app = result.getProjects().get(0);
+ assertThat(app.getPath()).isEqualTo("packages/app");
+ assertThat(app.getPackageManager()).isEqualTo(NodeResolutionResult.PackageManager.Npm);
+ assertThat(app.getResolution()).isNull();
+
+ // Watch-set: own package.json, the shared root lock file, tsconfig + its extended base
+ assertThat(app.getConfigInputs()).contains(
+ "packages/app/package.json",
+ "package-lock.json",
+ "packages/app/tsconfig.json",
+ "tsconfig.base.json");
+
+ // main + test source sets, both carrying the project's tsconfig path
+ assertThat(app.getSourceSets())
+ .extracting(PrebuildResult.SourceSetDescriptor::getName)
+ .containsExactly("main", "test");
+ for (PrebuildResult.SourceSetDescriptor sourceSet : app.getSourceSets()) {
+ assertThat(sourceSet.getParserSettings().getTsconfigPath())
+ .isEqualTo("packages/app/tsconfig.json");
+ }
+ PrebuildResult.SourceSetDescriptor test = app.getSourceSets().stream()
+ .filter(s -> s.getName().equals("test")).findFirst().orElseThrow();
+ assertThat(test.getIncludes()).contains("**/*.test.*");
+ }
+
@Test
void parseProjectWithVariousYamlStructures(@TempDir Path projectDir) throws Exception {
Files.writeString(projectDir.resolve("package.json"), """
diff --git a/rewrite-javascript/src/main/java/org/openrewrite/javascript/rpc/JavaScriptRewriteRpc.java b/rewrite-javascript/src/main/java/org/openrewrite/javascript/rpc/JavaScriptRewriteRpc.java
index 49cc1a20392..5dd1bb57667 100644
--- a/rewrite-javascript/src/main/java/org/openrewrite/javascript/rpc/JavaScriptRewriteRpc.java
+++ b/rewrite-javascript/src/main/java/org/openrewrite/javascript/rpc/JavaScriptRewriteRpc.java
@@ -243,6 +243,33 @@ public int characteristics() {
}, false);
}
+ /**
+ * Computes the build descriptor for a repository (or partition) root: the independent
+ * projects (workspace-aware), their source sets, config-input watch-set, and parser
+ * settings. The JavaScript analog of "invoke the build tool"; it does not parse sources.
+ *
+ * v1 is read-only — no dependency install is performed.
+ *
+ * @param repositoryRoot Path to the repository (or partition) root to analyze
+ * @return The discovered projects and their descriptors
+ */
+ public PrebuildResult prebuild(Path repositoryRoot) {
+ return prebuild(repositoryRoot, null, null);
+ }
+
+ /**
+ * Computes the build descriptor for a repository (or partition) root.
+ *
+ * @param repositoryRoot Path to the repository (or partition) root to analyze
+ * @param exclusions Optional glob patterns to exclude from discovery
+ * @param relativeTo Optional base for all returned paths. If not specified, paths are
+ * relative to {@code repositoryRoot}.
+ * @return The discovered projects and their descriptors
+ */
+ public PrebuildResult prebuild(Path repositoryRoot, @Nullable List
+ * Licensed under the Moderne Source Available License (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://docs.moderne.io/licensing/moderne-source-available-license
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.openrewrite.javascript.rpc;
+
+import lombok.Value;
+import org.jspecify.annotations.Nullable;
+import org.openrewrite.rpc.request.RpcRequest;
+
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * RPC request to compute the build descriptor for a repository (or partition) root:
+ * the independent projects (workspace-aware), their source sets, config-input watch-set,
+ * and parser settings. The JavaScript analog of "invoke the build tool".
+ *
+ * v1 is read-only — no dependency install.
+ */
+@Value
+class Prebuild implements RpcRequest {
+ /**
+ * Path to the repository (or partition) root to analyze.
+ */
+ Path repositoryRoot;
+
+ /**
+ * Optional glob patterns to exclude from discovery.
+ * If not provided, default exclusions (node_modules, dist, etc.) will be used.
+ */
+ @Nullable
+ List
+ * Licensed under the Moderne Source Available License (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://docs.moderne.io/licensing/moderne-source-available-license
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.openrewrite.javascript.rpc;
+
+import lombok.Value;
+import org.jspecify.annotations.Nullable;
+import org.openrewrite.javascript.marker.NodeResolutionResult;
+
+import java.util.List;
+
+/**
+ * Result of the {@link Prebuild} RPC: the independent projects discovered in the
+ * repository (workspace-aware), each with its source sets, config-input watch-set, and
+ * parser settings.
+ *
+ * v1 is read-only: {@link ProjectDescriptor#resolution} is always null.
+ */
+@Value
+public class PrebuildResult {
+ List