diff --git a/rewrite-javascript/src/integTest/java/org/openrewrite/javascript/DependencyWorkspaceTest.java b/rewrite-javascript/src/integTest/java/org/openrewrite/javascript/DependencyWorkspaceTest.java new file mode 100644 index 0000000000..7000a88b15 --- /dev/null +++ b/rewrite-javascript/src/integTest/java/org/openrewrite/javascript/DependencyWorkspaceTest.java @@ -0,0 +1,207 @@ +/* + * 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; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.openrewrite.Recipe; +import org.openrewrite.test.RewriteTest; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class DependencyWorkspaceTest implements RewriteTest { + + @BeforeEach + void setUp() { + DependencyWorkspace.clearCache(); + } + + @Test + void cachesSamePackageJson() { + String packageJson = """ + { + "name": "test", + "dependencies": { + "lodash": "^4.17.21" + } + } + """; + + Path workspace1 = DependencyWorkspace.getOrCreateWorkspace(packageJson); + Path workspace2 = DependencyWorkspace.getOrCreateWorkspace(packageJson); + + // Should return the same cached workspace + assertThat(workspace1).isEqualTo(workspace2); + assertThat(Files.exists(workspace1.resolve("node_modules"))).isTrue(); + assertThat(Files.exists(workspace1.resolve("package.json"))).isTrue(); + } + + @Test + void createsDifferentWorkspacesForDifferentPackageJson() { + String packageJson1 = """ + { + "name": "test1", + "dependencies": { + "lodash": "^4.17.21" + } + } + """; + + String packageJson2 = """ + { + "name": "test2", + "dependencies": { + "axios": "^1.0.0" + } + } + """; + + Path workspace1 = DependencyWorkspace.getOrCreateWorkspace(packageJson1); + Path workspace2 = DependencyWorkspace.getOrCreateWorkspace(packageJson2); + + // Should create different workspaces + assertThat(workspace1).isNotEqualTo(workspace2); + assertThat(Files.exists(workspace1.resolve("node_modules"))).isTrue(); + assertThat(Files.exists(workspace2.resolve("node_modules"))).isTrue(); + } + + @Test + void reusesWorkspaceAfterCacheClear() { + String packageJson = """ + { + "name": "test", + "dependencies": { + "lodash": "^4.17.21" + } + } + """; + + Path workspace1 = DependencyWorkspace.getOrCreateWorkspace(packageJson); + + // Clear in-memory cache + DependencyWorkspace.clearCache(); + + // Should still reuse the workspace from disk + Path workspace2 = DependencyWorkspace.getOrCreateWorkspace(packageJson); + + assertThat(workspace1).isEqualTo(workspace2); + assertThat(Files.exists(workspace2.resolve("node_modules"))).isTrue(); + } + + @Test + void npmIntegrationWithSymlink(@TempDir Path tempDir) throws Exception { + rewriteRun( + spec -> spec.recipe(new NoOpRecipe()), + Assertions.npm(tempDir, + Assertions.packageJson(""" + { + "name": "test-project", + "dependencies": { + "lodash": "^4.17.21" + } + } + """), + Assertions.javascript(""" + import _ from 'lodash'; + console.log(_.VERSION); + """) + ) + ); + + // Verify symlink was created + Path nodeModules = tempDir.resolve("node_modules"); + assertThat(Files.isSymbolicLink(nodeModules)).isTrue(); + assertThat(Files.exists(nodeModules)).isTrue(); + assertThat(Files.exists(nodeModules.resolve("lodash"))).isTrue(); + } + + @Test + void lruCacheEvictsOldEntries() { + // This test verifies LRU eviction, but we can't easily test with MAX_CACHE_SIZE (100) + // entries because npm install is slow. Instead, we verify the cache mechanism works + // by checking that recently used entries are retained. + + String packageJson1 = """ + { + "name": "test1", + "dependencies": { + "lodash": "^4.17.21" + } + } + """; + + String packageJson2 = """ + { + "name": "test2", + "dependencies": { + "axios": "^1.0.0" + } + } + """; + + // Create two workspaces + Path workspace1 = DependencyWorkspace.getOrCreateWorkspace(packageJson1); + Path workspace2 = DependencyWorkspace.getOrCreateWorkspace(packageJson2); + + // Access workspace1 again (should move it to most recently used) + Path workspace1Again = DependencyWorkspace.getOrCreateWorkspace(packageJson1); + + // Both should still be cached (we're well under MAX_CACHE_SIZE) + assertThat(workspace1).isEqualTo(workspace1Again); + assertThat(Files.exists(workspace1)).isTrue(); + assertThat(Files.exists(workspace2)).isTrue(); + } + + @Test + void initializesFromDiskOnStartup() { + String packageJson = """ + { + "name": "test-init", + "dependencies": { + "lodash": "^4.17.21" + } + } + """; + + // Create a workspace + Path workspace = DependencyWorkspace.getOrCreateWorkspace(packageJson); + assertThat(Files.exists(workspace.resolve("node_modules"))).isTrue(); + + // Clear the in-memory cache (simulating a JVM restart) + DependencyWorkspace.clearCache(); + + // The workspace should still be reused from disk + Path workspaceAfterClear = DependencyWorkspace.getOrCreateWorkspace(packageJson); + assertThat(workspaceAfterClear).isEqualTo(workspace); + assertThat(Files.exists(workspaceAfterClear.resolve("node_modules"))).isTrue(); + } + + private static class NoOpRecipe extends Recipe { + @Override + public String getDisplayName() { + return "No-op recipe"; + } + + @Override + public String getDescription() { + return "Does nothing, used for testing."; + } + } +} diff --git a/rewrite-javascript/src/main/java/org/openrewrite/javascript/Assertions.java b/rewrite-javascript/src/main/java/org/openrewrite/javascript/Assertions.java index 2d4944ea75..582e34a320 100644 --- a/rewrite-javascript/src/main/java/org/openrewrite/javascript/Assertions.java +++ b/rewrite-javascript/src/main/java/org/openrewrite/javascript/Assertions.java @@ -39,61 +39,38 @@ private Assertions() { } public static SourceSpecs npm(Path relativeTo, SourceSpecs... sources) { - // Second pass: run npm install if needed - boolean alreadyInstalled = false; + String packageJsonContent = null; - // First pass: write package.json files + // First pass: find package.json content and write it to relativeTo for (SourceSpecs multiSpec : sources) { if (multiSpec instanceof SourceSpec) { SourceSpec spec = (SourceSpec) multiSpec; Path sourcePath = spec.getSourcePath(); if (sourcePath != null && "package.json".equals(sourcePath.toFile().getName())) { + packageJsonContent = spec.getBefore(); try { Path packageJson = relativeTo.resolve(sourcePath); - if (Files.exists(packageJson)) { - // If relativeTo is a non-transient directory we can optimize not having - // to do npm install if the package.json hasn't changed. - if (new String(Files.readAllBytes(packageJson), StandardCharsets.UTF_8).equals(spec.getBefore())) { - alreadyInstalled = true; - continue; - } - } - Files.write(packageJson, requireNonNull(spec.getBefore()).getBytes(StandardCharsets.UTF_8)); + Files.write(packageJson, requireNonNull(packageJsonContent).getBytes(StandardCharsets.UTF_8)); } catch (IOException e) { throw new UncheckedIOException(e); } + break; } } } - for (SourceSpecs multiSpec : sources) { - if (multiSpec instanceof SourceSpec) { - SourceSpec spec = (SourceSpec) multiSpec; - if (!alreadyInstalled && spec.getParser() instanceof JavaScriptParser.Builder) { - // Execute npm install to ensure dependencies are available - // First check if package.json exists - Path packageJsonPath = relativeTo.resolve("package.json"); - if (!Files.exists(packageJsonPath)) { - // Skip npm install if no package.json exists - alreadyInstalled = true; - continue; - } - - try { - ProcessBuilder pb = new ProcessBuilder("npm", "install"); - pb.directory(relativeTo.toFile()); - pb.inheritIO(); - Process process = pb.start(); - int exitCode = process.waitFor(); - if (exitCode != 0) { - throw new RuntimeException("npm install failed with exit code: " + exitCode + " in directory: " + relativeTo.toFile().getAbsolutePath()); - } - } catch (IOException | InterruptedException e) { - throw new RuntimeException("Failed to run npm install in directory: " + relativeTo.toFile().getAbsolutePath(), e); - } + // Second pass: get or create cached workspace and symlink node_modules + if (packageJsonContent != null) { + Path workspaceDir = DependencyWorkspace.getOrCreateWorkspace(packageJsonContent); + Path nodeModulesSource = workspaceDir.resolve("node_modules"); + Path nodeModulesTarget = relativeTo.resolve("node_modules"); - alreadyInstalled = true; + try { + if (Files.exists(nodeModulesSource) && !Files.exists(nodeModulesTarget)) { + Files.createSymbolicLink(nodeModulesTarget, nodeModulesSource); } + } catch (IOException e) { + throw new UncheckedIOException("Failed to create symlink for node_modules", e); } } diff --git a/rewrite-javascript/src/main/java/org/openrewrite/javascript/DependencyWorkspace.java b/rewrite-javascript/src/main/java/org/openrewrite/javascript/DependencyWorkspace.java new file mode 100644 index 0000000000..77ccf54832 --- /dev/null +++ b/rewrite-javascript/src/main/java/org/openrewrite/javascript/DependencyWorkspace.java @@ -0,0 +1,229 @@ +/* + * 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; + +import lombok.experimental.UtilityClass; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.Map; + +import static java.util.Collections.synchronizedMap; + +/** + * Manages workspace directories for JavaScript/TypeScript compilation with dependencies. + * Creates cached workspaces with package.json and installed node_modules to enable + * proper type attribution and dependency resolution. + */ +@UtilityClass +class DependencyWorkspace { + private static final Path WORKSPACE_BASE = Paths.get( + System.getProperty("java.io.tmpdir"), + "openrewrite-js-workspaces" + ); + private static final int MAX_CACHE_SIZE = 100; + private static final Map cache = synchronizedMap( + new LinkedHashMap(16, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + if (size() > MAX_CACHE_SIZE) { + // Clean up the evicted workspace directory + cleanupDirectory(eldest.getValue()); + return true; + } + return false; + } + } + ); + + static { + // Pre-populate cache with existing workspaces from disk + initializeCacheFromDisk(); + } + + /** + * Gets or creates a workspace directory for the given package.json content. + * Workspaces are cached by content hash to avoid repeated npm installs. + * + * @param packageJsonContent The complete package.json file content + * @return Path to the workspace directory containing node_modules + */ + static Path getOrCreateWorkspace(String packageJsonContent) { + String hash = hashContent(packageJsonContent); + + // Check in-memory cache + Path cached = cache.get(hash); + if (cached != null && isWorkspaceValid(cached)) { + return cached; + } + + // Check disk cache (for cross-JVM reuse) + Path workspaceDir = WORKSPACE_BASE.resolve(hash); + if (isWorkspaceValid(workspaceDir)) { + cache.put(hash, workspaceDir); + return workspaceDir; + } + + // Create new workspace + try { + // Use temp directory for atomic creation + Path tempDir = Files.createTempDirectory(WORKSPACE_BASE, hash + ".tmp-"); + + try { + // Write package.json + Files.write( + tempDir.resolve("package.json"), + packageJsonContent.getBytes(StandardCharsets.UTF_8) + ); + + // Run npm install + ProcessBuilder pb = new ProcessBuilder("npm", "install", "--silent"); + pb.directory(tempDir.toFile()); + pb.inheritIO(); + Process process = pb.start(); + int exitCode = process.waitFor(); + + if (exitCode != 0) { + throw new RuntimeException("npm install failed with exit code: " + exitCode); + } + + // Move to final location (atomic on POSIX systems) + try { + Files.move(tempDir, workspaceDir); + } catch (IOException e) { + // If move fails, another thread might have created it + if (isWorkspaceValid(workspaceDir)) { + // Use the other thread's workspace + cleanupDirectory(tempDir); + } else { + throw e; + } + } + + cache.put(hash, workspaceDir); + return workspaceDir; + + } catch (Exception e) { + // Clean up temp directory on failure + cleanupDirectory(tempDir); + throw e; + } + + } catch (IOException e) { + throw new UncheckedIOException("Failed to create dependency workspace", e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("npm install was interrupted", e); + } + } + + /** + * Generates a hash from package.json content for caching. + */ + private static String hashContent(String content) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(content.getBytes(StandardCharsets.UTF_8)); + return Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(hash) + .substring(0, 16) + .replace('/', '_') + .replace('+', '-'); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("SHA-256 algorithm not available", e); + } + } + + /** + * Checks if a workspace is valid (has node_modules directory). + */ + private static boolean isWorkspaceValid(Path workspaceDir) { + return Files.exists(workspaceDir) && + Files.isDirectory(workspaceDir.resolve("node_modules")) && + Files.exists(workspaceDir.resolve("package.json")); + } + + /** + * Cleans up a directory, ignoring errors. + */ + private static void cleanupDirectory(Path dir) { + try { + if (Files.exists(dir)) { + Files.walk(dir) + .sorted(Comparator.reverseOrder()) // Delete files before directories + .forEach(path -> { + try { + Files.delete(path); + } catch (IOException e) { + // Ignore + } + }); + } + } catch (IOException e) { + // Ignore cleanup errors + } + } + + /** + * Clears the in-memory cache. Useful for testing. + */ + static void clearCache() { + cache.clear(); + } + + /** + * Initializes the cache by discovering existing valid workspaces from disk. + * This allows reuse of workspaces across JVM restarts and ensures proper + * LRU eviction even for pre-existing workspaces. + */ + private static void initializeCacheFromDisk() { + try { + if (!Files.exists(WORKSPACE_BASE)) { + return; + } + + Files.list(WORKSPACE_BASE) + .filter(Files::isDirectory) + .filter(dir -> !dir.getFileName().toString().contains(".tmp-")) // Skip temp dirs + .filter(DependencyWorkspace::isWorkspaceValid) + .sorted((a, b) -> { + // Sort by last modified time (oldest first) + // This way oldest workspaces will be evicted first when we hit the limit + try { + return Files.getLastModifiedTime(a).compareTo(Files.getLastModifiedTime(b)); + } catch (IOException e) { + return 0; + } + }) + .forEach(workspaceDir -> { + String hash = workspaceDir.getFileName().toString(); + cache.put(hash, workspaceDir); + }); + } catch (IOException e) { + // Ignore - cache will be empty and workspaces will be created as needed + } + } +}