diff --git a/.changeset/blue-mice-behave.md b/.changeset/blue-mice-behave.md new file mode 100644 index 0000000000..bd68ae5215 --- /dev/null +++ b/.changeset/blue-mice-behave.md @@ -0,0 +1,5 @@ +--- +"@workflow/builders": patch +--- + +Fix getImportPath package handling diff --git a/packages/builders/src/module-specifier.test.ts b/packages/builders/src/module-specifier.test.ts new file mode 100644 index 0000000000..580c6fb89b --- /dev/null +++ b/packages/builders/src/module-specifier.test.ts @@ -0,0 +1,211 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + clearModuleSpecifierCache, + getImportPath, +} from './module-specifier.js'; + +function writeJson(path: string, value: unknown): void { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, JSON.stringify(value, null, 2), 'utf-8'); +} + +function writeFile(path: string, contents = ''): void { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, contents, 'utf-8'); +} + +describe('getImportPath', () => { + let testRoot: string; + + beforeEach(() => { + clearModuleSpecifierCache(); + testRoot = mkdtempSync(join(tmpdir(), 'workflow-module-specifier-')); + }); + + afterEach(() => { + clearModuleSpecifierCache(); + rmSync(testRoot, { recursive: true, force: true }); + }); + + it('uses package subpath import when file matches an export subpath', () => { + const projectRoot = join(testRoot, 'apps/chat'); + const workspacePkgDir = join(testRoot, 'packages/agent'); + const filePath = join(workspacePkgDir, 'src/server.ts'); + + writeJson(join(projectRoot, 'package.json'), { + name: 'chat', + dependencies: { '@internal/agent': 'workspace:*' }, + }); + + writeJson(join(workspacePkgDir, 'package.json'), { + name: '@internal/agent', + version: '1.0.0', + exports: { + './server': './src/server.ts', + }, + }); + + writeFile(filePath, `'use step';\n`); + + expect(getImportPath(filePath, projectRoot)).toEqual({ + importPath: '@internal/agent/server', + isPackage: true, + }); + }); + + it('falls back to relative import when package has no root export', () => { + const projectRoot = join(testRoot, 'apps/chat'); + const workspacePkgDir = join(testRoot, 'packages/agent'); + const filePath = join(workspacePkgDir, 'src/server.ts'); + + writeJson(join(projectRoot, 'package.json'), { + name: 'chat', + dependencies: { '@internal/agent': 'workspace:*' }, + }); + + writeJson(join(workspacePkgDir, 'package.json'), { + name: '@internal/agent', + version: '1.0.0', + exports: { + './server': './dist/server.js', + }, + }); + + writeFile(filePath, `'use step';\n`); + + expect(getImportPath(filePath, projectRoot)).toEqual({ + importPath: '../../packages/agent/src/server.ts', + isPackage: false, + }); + }); + + it('uses package root import for root exports', () => { + const projectRoot = join(testRoot, 'apps/chat'); + const workspacePkgDir = join(testRoot, 'packages/agent'); + const filePath = join(workspacePkgDir, 'src/index.ts'); + + writeJson(join(projectRoot, 'package.json'), { + name: 'chat', + dependencies: { '@internal/agent': 'workspace:*' }, + }); + + writeJson(join(workspacePkgDir, 'package.json'), { + name: '@internal/agent', + version: '1.0.0', + exports: { + '.': './src/index.ts', + }, + }); + + writeFile(filePath, `'use workflow';\n`); + + expect(getImportPath(filePath, projectRoot)).toEqual({ + importPath: '@internal/agent', + isPackage: true, + }); + }); + + it('uses package root import when package module points to file', () => { + const projectRoot = join(testRoot, 'apps/chat'); + const workspacePkgDir = join(testRoot, 'packages/agent'); + const filePath = join(workspacePkgDir, 'src/index.mjs'); + + writeJson(join(projectRoot, 'package.json'), { + name: 'chat', + dependencies: { '@internal/agent': 'workspace:*' }, + }); + + writeJson(join(workspacePkgDir, 'package.json'), { + name: '@internal/agent', + version: '1.0.0', + module: './src/index.mjs', + main: './dist/index.cjs', + }); + + writeFile(filePath, `'use workflow';\n`); + + expect(getImportPath(filePath, projectRoot)).toEqual({ + importPath: '@internal/agent', + isPackage: true, + }); + }); + + it('uses package root import for conditional root exports', () => { + const projectRoot = join(testRoot, 'apps/chat'); + const workspacePkgDir = join(testRoot, 'packages/agent'); + const filePath = join(workspacePkgDir, 'src/index.js'); + + writeJson(join(projectRoot, 'package.json'), { + name: 'chat', + dependencies: { '@internal/agent': 'workspace:*' }, + }); + + writeJson(join(workspacePkgDir, 'package.json'), { + name: '@internal/agent', + version: '1.0.0', + exports: { + '.': { + import: './src/index.mjs', + default: './src/index.js', + }, + }, + }); + + writeFile(filePath, `'use workflow';\n`); + + expect(getImportPath(filePath, projectRoot)).toEqual({ + importPath: '@internal/agent', + isPackage: true, + }); + }); + + it('falls back to relative import for deep files in packages without exports', () => { + const projectRoot = join(testRoot, 'apps/chat'); + const workspacePkgDir = join(testRoot, 'packages/agent'); + const filePath = join(workspacePkgDir, 'lib/tools/dynamic/workflow.ts'); + + writeJson(join(projectRoot, 'package.json'), { + name: 'chat', + dependencies: { '@internal/agent': 'workspace:*' }, + }); + + writeJson(join(workspacePkgDir, 'package.json'), { + name: '@internal/agent', + version: '1.0.0', + }); + + writeFile(filePath, `'use workflow';\n`); + + expect(getImportPath(filePath, projectRoot)).toEqual({ + importPath: '../../packages/agent/lib/tools/dynamic/workflow.ts', + isPackage: false, + }); + }); + + it('uses package root import when package main points to file', () => { + const projectRoot = join(testRoot, 'apps/chat'); + const workspacePkgDir = join(testRoot, 'packages/agent'); + const filePath = join(workspacePkgDir, 'src/index.ts'); + + writeJson(join(projectRoot, 'package.json'), { + name: 'chat', + dependencies: { '@internal/agent': 'workspace:*' }, + }); + + writeJson(join(workspacePkgDir, 'package.json'), { + name: '@internal/agent', + version: '1.0.0', + main: './src/index.ts', + }); + + writeFile(filePath, `'use step';\n`); + + expect(getImportPath(filePath, projectRoot)).toEqual({ + importPath: '@internal/agent', + isPackage: true, + }); + }); +}); diff --git a/packages/builders/src/module-specifier.ts b/packages/builders/src/module-specifier.ts index a6b255b278..ae6b806f63 100644 --- a/packages/builders/src/module-specifier.ts +++ b/packages/builders/src/module-specifier.ts @@ -22,6 +22,8 @@ interface PackageInfo { version: string; dir: string; exports?: Record; + main?: string; + module?: string; } /** @@ -65,6 +67,8 @@ function findPackageJson(filePath: string): PackageInfo | null { version: parsed.version, dir, exports: parsed.exports, + main: parsed.main, + module: parsed.module, }; // Cache the result for this directory and all visited directories packageJsonCache.set(dir, result); @@ -333,6 +337,126 @@ export function clearModuleSpecifierCache(): void { projectDepsCache.clear(); } +/** + * Convert a file path to a relative import path from project root. + */ +function toRelativeImportPath(filePath: string, projectRoot: string): string { + const normalizedProjectRoot = projectRoot.replace(/\\/g, '/'); + const normalizedFilePath = filePath.replace(/\\/g, '/'); + + let relativePath: string; + if (normalizedFilePath.startsWith(normalizedProjectRoot + '/')) { + relativePath = normalizedFilePath.substring( + normalizedProjectRoot.length + 1 + ); + } else { + // File is outside project root, use the full path segments after common ancestor + relativePath = relative(projectRoot, filePath).replace(/\\/g, '/'); + } + + // Ensure relative paths start with ./ + if (!relativePath.startsWith('.')) { + relativePath = `./${relativePath}`; + } + + return relativePath; +} + +/** + * Returns true when package exports include a root entry ("."). + * String/array/conditional object exports are all considered root exports. + */ +function hasRootExport(exportsField: unknown): boolean { + if (typeof exportsField === 'string' || Array.isArray(exportsField)) { + return true; + } + + if (!exportsField || typeof exportsField !== 'object') { + return false; + } + + const keys = Object.keys(exportsField as Record); + // Conditional exports object (e.g. { "import": "...", "default": "..." }) + // represents the root export. + if (keys.length > 0 && keys.every((key) => !key.startsWith('.'))) { + return true; + } + + return '.' in (exportsField as Record); +} + +/** + * Normalize a package target path to a comparable package-relative path. + * Returns null for invalid/unsupported paths. + */ +function normalizePackageTargetPath(path: string): string | null { + const normalized = path.replace(/\\/g, '/'); + if (normalized.startsWith('./')) { + return normalized.substring(2); + } + if (normalized.startsWith('/')) { + return normalized.substring(1); + } + return normalized; +} + +/** + * Returns true if filePath is the package root entrypoint. + * This checks root exports first, then main/module/index fallbacks when exports are absent. + */ +function isRootEntrypointFile(filePath: string, pkg: PackageInfo): boolean { + const normalizedFilePath = filePath.replace(/\\/g, '/'); + const normalizedPkgDir = pkg.dir.replace(/\\/g, '/'); + + if (!normalizedFilePath.startsWith(normalizedPkgDir + '/')) { + return false; + } + + const relativeFilePath = normalizedFilePath.substring( + normalizedPkgDir.length + 1 + ); + + if (pkg.exports) { + let rootTarget: unknown; + + if ( + typeof pkg.exports === 'object' && + !Array.isArray(pkg.exports) && + '.' in pkg.exports + ) { + rootTarget = (pkg.exports as Record)['.']; + } else if (hasRootExport(pkg.exports)) { + rootTarget = pkg.exports; + } else { + return false; + } + + const resolvedTarget = resolveExportTarget(rootTarget); + if (!resolvedTarget) { + return false; + } + + const normalizedTarget = normalizePackageTargetPath(resolvedTarget); + return normalizedTarget === relativeFilePath; + } + + const rootCandidates = [ + pkg.module, + pkg.main, + 'index.js', + 'index.mjs', + 'index.cjs', + 'index.ts', + 'index.mts', + 'index.cts', + ] + .filter((candidate): candidate is string => typeof candidate === 'string') + .map((candidate) => normalizePackageTargetPath(candidate)) + .filter((candidate): candidate is string => candidate !== null); + + return rootCandidates.includes(relativeFilePath); +} + /** * Result of resolving an import path for a file. */ @@ -387,6 +511,30 @@ export function getImportPath( // Find the package.json for this file const pkg = findPackageJson(filePath); if (pkg) { + // Prefer a package subpath import when this file maps to an export. + // This preserves the exact module being bundled while still respecting + // package export conditions. + // Note: resolveExportSubpath returns "" for both root "." matches and + // no match; root entrypoints are intentionally handled below via + // isRootEntrypointFile(). + const subpath = resolveExportSubpath(filePath, pkg); + if (subpath) { + return { + importPath: `${pkg.name}${subpath}`, + isPackage: true, + }; + } + + // Only import package root when this file is the root entrypoint. + // For deep/internal files, fall back to direct relative imports so we + // don't accidentally import a non-existent or different module. + if (!isRootEntrypointFile(filePath, pkg)) { + return { + importPath: toRelativeImportPath(filePath, projectRoot), + isPackage: false, + }; + } + return { importPath: pkg.name, isPackage: true, @@ -395,26 +543,8 @@ export function getImportPath( } // Local app file - use relative path - const normalizedProjectRoot = projectRoot.replace(/\\/g, '/'); - const normalizedFilePath = filePath.replace(/\\/g, '/'); - - let relativePath: string; - if (normalizedFilePath.startsWith(normalizedProjectRoot + '/')) { - relativePath = normalizedFilePath.substring( - normalizedProjectRoot.length + 1 - ); - } else { - // File is outside project root, use the full path segments after common ancestor - relativePath = relative(projectRoot, filePath).replace(/\\/g, '/'); - } - - // Ensure relative paths start with ./ - if (!relativePath.startsWith('.')) { - relativePath = `./${relativePath}`; - } - return { - importPath: relativePath, + importPath: toRelativeImportPath(filePath, projectRoot), isPackage: false, }; }