Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 63 additions & 15 deletions rewrite-javascript/rewrite/src/javascript/assertions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,26 +31,67 @@ export const sourceFileCache: Map<string, ts.SourceFile> = new Map();
setTemplateSourceFileCache(sourceFileCache);

export async function* npm(relativeTo: string, ...sourceSpecs: SourceSpec<any>[]): AsyncGenerator<SourceSpec<any>, void, unknown> {
for (const spec of sourceSpecs) {
if (spec.path === 'package.json') {
// Parse package.json to extract dependencies
const packageJsonContent = JSON.parse(spec.before!);
const dependencies = {
...packageJsonContent.dependencies,
...packageJsonContent.devDependencies
};

// Use DependencyWorkspace to create workspace in relativeTo directory
// This will check if it's already valid and skip npm install if so
if (Object.keys(dependencies).length > 0) {
await DependencyWorkspace.getOrCreateWorkspace(dependencies, relativeTo);
const fs = require('fs');
const path = require('path');

// Ensure the target directory exists
if (!fs.existsSync(relativeTo)) {
fs.mkdirSync(relativeTo, { recursive: true });
}

// Find package.json and package-lock.json specs
const packageJsonSpec = sourceSpecs.find(spec => spec.path === 'package.json');
const packageLockSpec = sourceSpecs.find(spec => spec.path === 'package-lock.json');

if (packageJsonSpec) {
// Parse package.json to check if there are dependencies
const packageJsonContent = JSON.parse(packageJsonSpec.before!);
const hasDependencies = Object.keys({
...packageJsonContent.dependencies,
...packageJsonContent.devDependencies
}).length > 0;

// Get or create cached workspace with node_modules
if (hasDependencies) {
const cachedWorkspace = await DependencyWorkspace.getOrCreateWorkspace({
packageJsonContent: packageJsonSpec.before!,
packageLockContent: packageLockSpec?.before ?? undefined
});

// Symlink node_modules from cached workspace to test directory
const cachedNodeModules = path.join(cachedWorkspace, 'node_modules');
const testNodeModules = path.join(relativeTo, 'node_modules');

// Remove existing node_modules if present
if (fs.existsSync(testNodeModules)) {
fs.rmSync(testNodeModules, { recursive: true, force: true });
}

yield spec;
// Create symlink
fs.symlinkSync(cachedNodeModules, testNodeModules, 'junction');
}

// Write the actual package.json from the test spec
fs.writeFileSync(
path.join(relativeTo, 'package.json'),
packageJsonSpec.before
);

yield packageJsonSpec;
}

if (packageLockSpec) {
// Write package-lock.json from the test spec
fs.writeFileSync(
path.join(relativeTo, 'package-lock.json'),
packageLockSpec.before
);

yield packageLockSpec;
}

for (const spec of sourceSpecs) {
if (spec.path !== 'package.json') {
if (spec.path !== 'package.json' && spec.path !== 'package-lock.json') {
if (spec.kind === JS.Kind.CompilationUnit) {
yield {
...spec,
Expand All @@ -70,6 +111,13 @@ export function packageJson(before: string, after?: AfterRecipeText): SourceSpec
};
}

export function packageLockJson(before: string, after?: AfterRecipeText): SourceSpec<Json.Document> {
return {
...json(before, after),
path: 'package-lock.json'
};
}

export function javascript(before: string | null, after?: AfterRecipeText): SourceSpec<JS.CompilationUnit> {
return {
kind: JS.Kind.CompilationUnit,
Expand Down
160 changes: 115 additions & 45 deletions rewrite-javascript/rewrite/src/javascript/dependency-workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,44 @@ import * as os from 'os';
import * as crypto from 'crypto';
import {execSync} from 'child_process';

interface BaseWorkspaceOptions {
/**
* Optional target directory. If provided, creates workspace in this directory
* instead of a hash-based temp directory. Caller is responsible for directory lifecycle.
*/
targetDir?: string;
}

interface DependenciesWorkspaceOptions extends BaseWorkspaceOptions {
/**
* NPM dependencies (package name to version mapping).
*/
dependencies: Record<string, string>;
packageJsonContent?: never;
packageLockContent?: never;
}

interface PackageJsonWorkspaceOptions extends BaseWorkspaceOptions {
/**
* package.json content as a string. Dependencies are extracted from it
* and the content is written to the workspace.
*/
packageJsonContent: string;
dependencies?: never;
/**
* Optional package-lock.json content. If provided:
* - The lock file content is used as the cache key (more precise than dependency hash)
* - `npm ci` is used instead of `npm install` (faster, deterministic)
*/
packageLockContent?: string;
}

/**
* Options for creating a dependency workspace.
* Provide either `dependencies` or `packageJsonContent`, but not both.
*/
export type WorkspaceOptions = DependenciesWorkspaceOptions | PackageJsonWorkspaceOptions;

/**
* Manages workspace directories for TypeScript compilation with dependencies.
* Creates temporary workspaces with package.json and installed node_modules
Expand All @@ -30,14 +68,70 @@ export class DependencyWorkspace {

/**
* Gets or creates a workspace directory for the given dependencies.
* Workspaces are cached by dependency hash to avoid repeated npm installs.
* Workspaces are cached by dependency hash (or lock file hash if provided) to avoid repeated npm installs.
*
* @param dependencies NPM dependencies (package name to version mapping)
* @param targetDir Optional target directory. If provided, creates workspace in this directory
* instead of a hash-based temp directory. Caller is responsible for directory lifecycle.
* @param options Workspace options including dependencies or package.json content
* @returns Path to the workspace directory
*/
static async getOrCreateWorkspace(dependencies: Record<string, string>, targetDir?: string): Promise<string> {
static async getOrCreateWorkspace(options: WorkspaceOptions): Promise<string> {
// Extract dependencies from package.json content if provided
let dependencies = options.dependencies;
if (options.packageJsonContent) {
const parsed = JSON.parse(options.packageJsonContent);
dependencies = {
...parsed.dependencies,
...parsed.devDependencies
};
}

if (!dependencies || Object.keys(dependencies).length === 0) {
throw new Error('No dependencies provided');
}

// Use the refactored internal method
return this.createWorkspace(dependencies, options.packageJsonContent, options.packageLockContent, options.targetDir);
}

/**
* Internal method that handles workspace creation.
*/
private static async createWorkspace(
dependencies: Record<string, string>,
packageJsonContent: string | undefined,
packageLockContent: string | undefined,
targetDir: string | undefined
): Promise<string> {
// Determine hash based on lock file (most precise) or dependencies
// Note: We always hash dependencies (not packageJsonContent) because whitespace/formatting
// differences in package.json shouldn't create different workspaces
const hash = packageLockContent
? this.hashContent(packageLockContent)
: this.hashDependencies(dependencies);

// Determine npm command: use `npm ci` when lock file is provided (faster, deterministic)
const npmCommand = packageLockContent ? 'npm ci --silent' : 'npm install --silent';

// Helper to write package files to a directory
const writePackageFiles = (dir: string) => {
// Write package.json (use provided content or generate)
if (packageJsonContent) {
fs.writeFileSync(path.join(dir, 'package.json'), packageJsonContent);
} else {
const packageJson = {
name: "openrewrite-template-workspace",
version: "1.0.0",
private: true,
dependencies: dependencies
};
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify(packageJson, null, 2));
}

// Write package-lock.json if provided
if (packageLockContent) {
fs.writeFileSync(path.join(dir, 'package-lock.json'), packageLockContent);
}
};

if (targetDir) {
// Use provided directory - check if it's already valid
if (this.isWorkspaceValid(targetDir, dependencies)) {
Expand All @@ -48,7 +142,6 @@ export class DependencyWorkspace {
fs.mkdirSync(targetDir, {recursive: true});

// Check if we can reuse a cached workspace by symlinking node_modules
const hash = this.hashDependencies(dependencies);
const cachedWorkspaceDir = path.join(this.WORKSPACE_BASE, hash);
const cachedNodeModules = path.join(cachedWorkspaceDir, 'node_modules');

Expand All @@ -65,17 +158,8 @@ export class DependencyWorkspace {
// Create symlink to cached node_modules
fs.symlinkSync(cachedNodeModules, targetNodeModules, 'dir');

// Write package.json
const packageJson = {
name: "openrewrite-template-workspace",
version: "1.0.0",
private: true,
dependencies: dependencies
};
fs.writeFileSync(
path.join(targetDir, 'package.json'),
JSON.stringify(packageJson, null, 2)
);
// Write package files
writePackageFiles(targetDir);

return targetDir;
} catch (symlinkError) {
Expand All @@ -84,20 +168,10 @@ export class DependencyWorkspace {
}

try {
const packageJson = {
name: "openrewrite-template-workspace",
version: "1.0.0",
private: true,
dependencies: dependencies
};

fs.writeFileSync(
path.join(targetDir, 'package.json'),
JSON.stringify(packageJson, null, 2)
);
writePackageFiles(targetDir);

// Run npm install
execSync('npm install --silent', {
// Run npm install or npm ci
execSync(npmCommand, {
cwd: targetDir,
stdio: 'pipe' // Suppress output
});
Expand All @@ -109,7 +183,6 @@ export class DependencyWorkspace {
}

// Use hash-based cached workspace
const hash = this.hashDependencies(dependencies);

// Check cache
const cached = this.cache.get(hash);
Expand Down Expand Up @@ -141,21 +214,11 @@ export class DependencyWorkspace {
// Create temporary workspace directory
fs.mkdirSync(tempWorkspaceDir, {recursive: true});

// Create package.json
const packageJson = {
name: "openrewrite-template-workspace",
version: "1.0.0",
private: true,
dependencies: dependencies
};

fs.writeFileSync(
path.join(tempWorkspaceDir, 'package.json'),
JSON.stringify(packageJson, null, 2)
);
// Write package files
writePackageFiles(tempWorkspaceDir);

// Run npm install
execSync('npm install --silent', {
// Run npm install or npm ci
execSync(npmCommand, {
cwd: tempWorkspaceDir,
stdio: 'pipe' // Suppress output
});
Expand Down Expand Up @@ -245,6 +308,13 @@ export class DependencyWorkspace {
// Sort keys for consistent hashing
const sorted = Object.keys(dependencies).sort();
const content = sorted.map(key => `${key}:${dependencies[key]}`).join(',');
return this.hashContent(content);
}

/**
* Generates a hash from arbitrary content for caching.
*/
private static hashContent(content: string): string {
return crypto.createHash('sha256').update(content).digest('hex').substring(0, 16);
}

Expand Down
1 change: 1 addition & 0 deletions rewrite-javascript/rewrite/src/javascript/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export * from "./assertions";
export * from "./parser";
export * from "./style";
export * from "./markers";
export * from "./node-project-marker";
export * from "./preconditions";
export * from "./templating/index";
export * from "./method-matcher";
Expand Down
Loading
Loading