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 "@"}) into a + * {@link PackageManager}. Yarn Classic vs Berry is determined by the major version. + */ +function parsePackageManagerSpec(spec: string): PackageManager | undefined { + const at = spec.indexOf("@"); + const name = (at >= 0 ? spec.slice(0, at) : spec).trim().toLowerCase(); + switch (name) { + case "npm": + return PackageManager.Npm; + case "pnpm": + return PackageManager.Pnpm; + case "bun": + return PackageManager.Bun; + case "yarn": + return parseInt(spec.slice(at + 1), 10) >= 2 ? PackageManager.YarnBerry : PackageManager.YarnClassic; + default: + return undefined; + } +} + +/** + * Assigns each file to the project whose directory is its longest-matching ancestor. + * Files outside every project directory are dropped. + */ +function assignFilesToProjects(projectDirs: string[], files: string[]): Map { + // Deepest project dir first so the most specific (nested) project wins. + const byDepth = [...projectDirs].sort((a, b) => b.length - a.length); + const result = new Map(projectDirs.map(d => [d, []])); + for (const file of files) { + for (const dir of byDepth) { + if (file === dir || file.startsWith(dir + path.sep)) { + result.get(dir)!.push(file); + break; + } + } + } + return result; +} + +/** + * Finds the nearest tsconfig.json at or above {@code projectDir} (bounded by {@code root}), + * returned relative to {@code relativeTo} and "/"-normalized, or undefined if none. + */ +function findNearestTsconfig(projectDir: string, root: string, relativeTo: string): string | undefined { + let dir = projectDir; + while (true) { + const candidate = path.join(dir, "tsconfig.json"); + if (fs.existsSync(candidate)) { + return relativeNormalized(relativeTo, candidate); + } + if (dir === root) { + return undefined; + } + const parent = path.dirname(dir); + if (parent === dir) { + return undefined; + } + dir = parent; + } +} + +/** + * Collects the tsconfig.json at {@code tsconfigAbs} plus every local file in its + * {@code extends} chain and {@code references}, into {@code collected}. Only relative + * (local) extends/references are followed; npm-package extends (e.g. "@tsconfig/node20") + * are skipped — they're pinned by the already-watched lock file (Decision #4). Bounded + * to files within {@code root}; cycle-safe via {@code collected}. + */ +function collectTsconfigChain(tsconfigAbs: string, root: string, collected: Set): void { + if (collected.has(tsconfigAbs) || !isWithin(root, tsconfigAbs) || !fs.existsSync(tsconfigAbs)) { + return; + } + collected.add(tsconfigAbs); + + const {config} = ts.readConfigFile(tsconfigAbs, p => ts.sys.readFile(p)); + if (!config) { + return; + } + const dir = path.dirname(tsconfigAbs); + + const extendsField: unknown = config.extends; + const extendsList = Array.isArray(extendsField) ? extendsField : extendsField != null ? [extendsField] : []; + for (const ext of extendsList) { + if (typeof ext === "string" && (ext.startsWith("./") || ext.startsWith("../"))) { + let target = path.resolve(dir, ext); + if (!target.endsWith(".json")) { + target += ".json"; + } + collectTsconfigChain(target, root, collected); + } + } + + for (const ref of (Array.isArray(config.references) ? config.references : [])) { + const refPath: unknown = ref?.path; + if (typeof refPath === "string") { + // TypeScript's rule: a reference path ending in ".json" is the config file; + // otherwise it names a directory whose tsconfig.json is the target. + let target = path.resolve(dir, refPath); + if (!target.endsWith(".json")) { + target = path.join(target, "tsconfig.json"); + } + collectTsconfigChain(target, root, collected); + } + } +} + +/** True when {@code target} is {@code root} or nested below it. */ +function isWithin(root: string, target: string): boolean { + return target === root || target.startsWith(root + path.sep); +} + +/** + * Resolves the config-input watch-set for a project: its package.json, the (precomputed) + * nearest lock file, the local tsconfig extends chain + references, and any + * prettier/jest/vitest config present. Files that don't exist are omitted. Returned + * relative to {@code relativeTo}, "/"-normalized and deduped. + */ +function resolveConfigInputs(projectDir: string, root: string, relativeTo: string, lockFile: string | undefined): string[] { + const abs = new Set(); + + const packageJson = path.join(projectDir, "package.json"); + if (fs.existsSync(packageJson)) { + abs.add(packageJson); + } + + if (lockFile) { + abs.add(lockFile); + } + + collectTsconfigChain(path.join(projectDir, "tsconfig.json"), root, abs); + + for (const name of [...PRETTIER_CONFIG_FILES, ...JEST_CONFIG_FILES, ...VITEST_CONFIG_FILES]) { + const candidate = path.join(projectDir, name); + if (fs.existsSync(candidate)) { + abs.add(candidate); + } + } + + return [...abs].map(p => relativeNormalized(relativeTo, p)); +} + +/** Finds the nearest lock file at or above {@code projectDir} (bounded by {@code root}). */ +function findNearestLockFile(projectDir: string, root: string): string | undefined { + const lockFileNames = getAllLockFileNames(); + let dir = projectDir; + while (true) { + for (const name of lockFileNames) { + const candidate = path.join(dir, name); + if (fs.existsSync(candidate)) { + return candidate; + } + } + if (dir === root) { + return undefined; + } + const parent = path.dirname(dir); + if (parent === dir) { + return undefined; + } + dir = parent; + } +} + +/** + * Partitions a project's source files into a "main" set and, when test files are + * present by convention, a "test" set. The two sets share v1 {@code parserSettings}; + * the list shape lets prod/test settings diverge additively later (Decision #6). + */ +function partitionSourceSets( + projectDir: string, + projectFiles: string[], + parserSettings: ParserSettings +): SourceSetDescriptor[] { + const hasTests = projectFiles.some(file => isTestFile(relativeNormalized(projectDir, file))); + + if (!hasTests) { + return [{name: "main", parserSettings}]; + } + return [ + {name: "main", excludes: [...TEST_GLOBS], parserSettings}, + {name: "test", includes: [...TEST_GLOBS], parserSettings} + ]; +} + +/** + * Discovers the independent projects in a repository (or partition) for the Prebuild + * RPC: workspace-aware project roots, their main/test source sets, the config-input + * watch-set, and the parser settings each set needs. + * + * v1 is read-only — no dependency install, no NodeResolutionResult attachment. + */ +export async function discoverProjects( + repositoryRoot: string, + options: WorkspaceDiscoveryOptions = {} +): Promise { + const root = path.resolve(repositoryRoot); + const relativeTo = options.relativeTo ? path.resolve(options.relativeTo) : root; + + const parser = new ProjectParser(root, {exclusions: options.exclusions}); + const discovered = await parser.discoverFiles(); + + const projectDirs = determineProjectDirs(root, discovered.packageJsonFiles); + const filesByProject = assignFilesToProjects(projectDirs, discovered.jsFiles); + + const projects: ProjectDescriptor[] = []; + for (const projectDir of projectDirs) { + const lockFile = findNearestLockFile(projectDir, root); + const tsconfigPath = findNearestTsconfig(projectDir, root, relativeTo); + const parserSettings: ParserSettings = tsconfigPath ? {tsconfigPath} : {}; + projects.push({ + path: relativeNormalized(relativeTo, projectDir), + packageManager: resolvePackageManager(projectDir, root, lockFile), + configInputs: resolveConfigInputs(projectDir, root, relativeTo, lockFile), + sourceSets: partitionSourceSets(projectDir, filesByProject.get(projectDir) ?? [], parserSettings), + resolution: null + }); + } + return projects; +} diff --git a/rewrite-javascript/rewrite/src/rpc/request/index.ts b/rewrite-javascript/rewrite/src/rpc/request/index.ts index fe1858e6638..ec89a8bd767 100644 --- a/rewrite-javascript/rewrite/src/rpc/request/index.ts +++ b/rewrite-javascript/rewrite/src/rpc/request/index.ts @@ -18,6 +18,7 @@ export * from "./get-object"; export * from "./get-marketplace"; export * from "./parse"; export * from "./parse-project"; +export * from "./prebuild"; export * from "./prepare-recipe"; export * from "./print"; export * from "./visit"; diff --git a/rewrite-javascript/rewrite/src/rpc/request/prebuild.ts b/rewrite-javascript/rewrite/src/rpc/request/prebuild.ts new file mode 100644 index 00000000000..c26ca21fb8d --- /dev/null +++ b/rewrite-javascript/rewrite/src/rpc/request/prebuild.ts @@ -0,0 +1,81 @@ +/* + * 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 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 + ) {} + + static handle(connection: rpc.MessageConnection, metricsCsv?: string): void { + connection.onRequest( + new rpc.RequestType("Prebuild"), + withMetrics( + "Prebuild", + metricsCsv, + (context) => async (request) => { + context.target = request.repositoryRoot; + + // Dynamic import to break circular dependency (cf. parse-project.ts). + const {discoverProjects} = await import("../../javascript/index.js"); + + const repositoryRoot = path.resolve(request.repositoryRoot); + const relativeTo = request.relativeTo ? path.resolve(request.relativeTo) : repositoryRoot; + + const projects = await discoverProjects(repositoryRoot, { + exclusions: request.exclusions, + relativeTo + }); + + return {projects}; + } + ) + ); + } +} diff --git a/rewrite-javascript/rewrite/src/rpc/rewrite-rpc.ts b/rewrite-javascript/rewrite/src/rpc/rewrite-rpc.ts index 5b6d8183f32..6c4b11d5466 100644 --- a/rewrite-javascript/rewrite/src/rpc/rewrite-rpc.ts +++ b/rewrite-javascript/rewrite/src/rpc/rewrite-rpc.ts @@ -27,6 +27,7 @@ import { toMarketplace, Parse, ParseProject, + Prebuild, PrepareRecipe, PrepareRecipeResponse, Print, @@ -97,6 +98,7 @@ export class RewriteRpc { PrepareRecipe.handle(this.connection, marketplace, preparedRecipes, options.metricsCsv); Parse.handle(this.connection, this.localObjects, options.metricsCsv); ParseProject.handle(this.connection, this.localObjects, options.metricsCsv); + Prebuild.handle(this.connection, options.metricsCsv); Print.handle(this.connection, getObject, options.logger, options.metricsCsv); InstallRecipes.handle(this.connection, options.recipeInstallDir ?? ".rewrite", marketplace, options.logger, options.metricsCsv); diff --git a/rewrite-javascript/rewrite/test/javascript/workspace-discovery.test.ts b/rewrite-javascript/rewrite/test/javascript/workspace-discovery.test.ts new file mode 100644 index 00000000000..429797fa180 --- /dev/null +++ b/rewrite-javascript/rewrite/test/javascript/workspace-discovery.test.ts @@ -0,0 +1,244 @@ +/* + * 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 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 exclusions, @Nullable Path relativeTo) { + return send("Prebuild", new Prebuild(repositoryRoot, exclusions, relativeTo), PrebuildResult.class); + } + public static Builder builder() { return new Builder(); } diff --git a/rewrite-javascript/src/main/java/org/openrewrite/javascript/rpc/Prebuild.java b/rewrite-javascript/src/main/java/org/openrewrite/javascript/rpc/Prebuild.java new file mode 100644 index 00000000000..a6b4da8953d --- /dev/null +++ b/rewrite-javascript/src/main/java/org/openrewrite/javascript/rpc/Prebuild.java @@ -0,0 +1,79 @@ +/* + * 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. + */ +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 exclusions; + + /** + * Optional base for all returned paths. If not specified, paths are relative to + * {@link #repositoryRoot}. Use when analyzing a subdirectory but wanting paths + * relative to the repository root. + */ + @Nullable + Path relativeTo; + + /** + * Forward-compat carrier for future options (e.g. stateful-session hints). Unused in v1. + */ + @Nullable + Map options; + + Prebuild(Path repositoryRoot) { + this(repositoryRoot, null, null, null); + } + + Prebuild(Path repositoryRoot, @Nullable List exclusions) { + this(repositoryRoot, exclusions, null, null); + } + + Prebuild(Path repositoryRoot, @Nullable List exclusions, @Nullable Path relativeTo) { + this(repositoryRoot, exclusions, relativeTo, null); + } + + Prebuild(Path repositoryRoot, @Nullable List exclusions, @Nullable Path relativeTo, @Nullable Map options) { + this.repositoryRoot = repositoryRoot; + this.exclusions = exclusions; + this.relativeTo = relativeTo; + this.options = options; + } +} diff --git a/rewrite-javascript/src/main/java/org/openrewrite/javascript/rpc/PrebuildResult.java b/rewrite-javascript/src/main/java/org/openrewrite/javascript/rpc/PrebuildResult.java new file mode 100644 index 00000000000..0725800fe19 --- /dev/null +++ b/rewrite-javascript/src/main/java/org/openrewrite/javascript/rpc/PrebuildResult.java @@ -0,0 +1,107 @@ +/* + * 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. + */ +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 projects; + + /** + * An independent JavaScript/TypeScript project (a package.json root). + */ + @Value + public static class ProjectDescriptor { + /** + * package.json directory, relative to the request's {@code relativeTo}, "/"-normalized. + */ + String path; + + /** + * Detected package manager. Yarn Classic and Berry are kept distinct, consistent + * with {@link NodeResolutionResult.PackageManager}. + */ + NodeResolutionResult.PackageManager packageManager; + + /** + * Watch-set: files whose change forces a full re-parse (package.json, lock file, + * tsconfig extends chain + references, prettier/jest/vitest config). Relative, + * "/"-normalized, deduped. + */ + List configInputs; + + /** + * Source sets ("main"/"test"). v1 usually has one ("main") shared parser settings; + * the list is the forward-compat hook for divergent prod/test settings. + */ + List sourceSets; + + /** + * Resolved dependency data. Populated only when install ownership moves into the + * server; always null in v1. + */ + @Nullable + Object resolution; + } + + /** + * One source set within a project. Modeled as a list element (not a boolean) so + * divergent prod/test parser settings are additive later. + */ + @Value + public static class SourceSetDescriptor { + /** + * "main" or "test". + */ + String name; + + /** + * Globs (relative to the project path) selecting this set's files, or null. + */ + @Nullable + List includes; + + @Nullable + List excludes; + + ParserSettings parserSettings; + } + + /** + * Resolution-relevant compiler configuration the parser needs. v1 carries only the + * path to the nearest tsconfig; the type stays open for additive fields. + */ + @Value + public static class ParserSettings { + /** + * Relative, "/"-normalized path to the nearest tsconfig.json, or null. + */ + @Nullable + String tsconfigPath; + } +}