diff --git a/packages/eve/src/internal/nitro/dev-runtime-artifacts.integration.test.ts b/packages/eve/src/internal/nitro/dev-runtime-artifacts.integration.test.ts index 480d7bff9..2928f3e86 100644 --- a/packages/eve/src/internal/nitro/dev-runtime-artifacts.integration.test.ts +++ b/packages/eve/src/internal/nitro/dev-runtime-artifacts.integration.test.ts @@ -408,6 +408,179 @@ describe("development runtime artifact snapshots", () => { expect(acceptableMountLocations.some((location) => existsSync(location))).toBe(true); }); + // https://github.com/vercel/eve/issues/1242 — withEve bare-agent-dir + // expression: the agent root has no package.json or tsconfig.json of its + // own; the parent Next.js-style package owns both, and its tsconfig path + // aliases must keep resolving from dev runtime snapshots exactly like in + // production builds. + it("resolves parent package tsconfig path aliases from dev runtime snapshots", async () => { + const workspaceRoot = await createScratchDirectory("eve-dev-runtime-parent-tsconfig-paths-"); + const hostRoot = join(workspaceRoot, "apps", "nextjs"); + const appRoot = join(hostRoot, "agents", "app-agent"); + const compileDirectoryPath = join(appRoot, ".eve", "compile"); + + await mkdir(join(appRoot, "tools"), { recursive: true }); + await mkdir(join(hostRoot, "lib"), { recursive: true }); + await mkdir(compileDirectoryPath, { recursive: true }); + await writeFile(join(workspaceRoot, "pnpm-workspace.yaml"), "packages:\n - apps/*\n"); + await writeFile(join(workspaceRoot, "package.json"), '{"type":"module"}\n'); + await writeFile(join(hostRoot, "package.json"), '{"name":"nextjs-host","type":"module"}\n'); + await writeFile( + join(hostRoot, "tsconfig.json"), + `${JSON.stringify( + { + compilerOptions: { + baseUrl: ".", + paths: { + "@/*": ["./*"], + }, + }, + }, + null, + 2, + )}\n`, + ); + await writeFile( + join(hostRoot, "lib", "utils.ts"), + 'export const findFirstOrUndefined = "parent-lib-utils";\n', + ); + await writeFile( + join(appRoot, "tools", "query-data.ts"), + [ + 'import { findFirstOrUndefined } from "@/lib/utils";', + "", + "export const resolved = findFirstOrUndefined;", + "", + ].join("\n"), + ); + await writeFile( + join(compileDirectoryPath, "compiled-agent-manifest.json"), + `${JSON.stringify({ agentRoot: appRoot, appRoot }, null, 2)}\n`, + ); + + const snapshot = await stageDevelopmentRuntimeArtifactsSnapshot({ + paths: { compileDirectoryPath }, + project: { appRoot }, + } as CompileAgentResult); + const snapshotHostRoot = join(snapshot.snapshotSourceRoot, "apps", "nextjs"); + + // The owning package's tsconfig and alias-target sources are part of the + // snapshot. + expect(existsSync(join(snapshotHostRoot, "tsconfig.json"))).toBe(true); + expect(existsSync(join(snapshotHostRoot, "lib", "utils.ts"))).toBe(true); + // No synthetic package.json shadows the owning package boundary that + // production bundling resolves the tsconfig through. + expect(existsSync(join(snapshotHostRoot, "package.json"))).toBe(true); + expect(existsSync(join(snapshot.runtimeAppRoot, "package.json"))).toBe(false); + + const moduleNamespace = await loadAuthoredModuleNamespace( + join(snapshot.runtimeAppRoot, "tools", "query-data.ts"), + ); + + expect(moduleNamespace.resolved).toBe("parent-lib-utils"); + }); + + // https://github.com/vercel/eve/issues/1242 — end-to-end expression: the + // materialized dev generation must hydrate an agent module that imports + // parent application code through the parent tsconfig's path aliases. + it("hydrates parent tsconfig alias imports from materialized dev generations", async () => { + const workspaceRoot = await createScratchDirectory("eve-dev-runtime-parent-alias-hydrate-"); + const hostRoot = join(workspaceRoot, "apps", "nextjs"); + const appRoot = join(hostRoot, "agents", "app-agent"); + const compileDirectoryPath = join(appRoot, ".eve", "compile"); + const manifestPath = join(compileDirectoryPath, "compiled-agent-manifest.json"); + const moduleMapPath = join(compileDirectoryPath, "module-map.mjs"); + + await mkdir(appRoot, { recursive: true }); + await mkdir(join(hostRoot, "lib"), { recursive: true }); + await mkdir(compileDirectoryPath, { recursive: true }); + await writeFile(join(workspaceRoot, "pnpm-workspace.yaml"), "packages:\n - apps/*\n"); + await writeFile(join(workspaceRoot, "package.json"), '{"type":"module"}\n'); + await writeFile(join(hostRoot, "package.json"), '{"name":"nextjs-host","type":"module"}\n'); + await writeFile( + join(hostRoot, "tsconfig.json"), + `${JSON.stringify( + { + compilerOptions: { + baseUrl: ".", + paths: { + "@/*": ["./*"], + }, + }, + }, + null, + 2, + )}\n`, + ); + await writeFile( + join(hostRoot, "lib", "auth.ts"), + 'export const eveAuth = "parent-auth-config";\n', + ); + await writeFile( + join(appRoot, "agent.ts"), + [ + 'import { eveAuth } from "@/lib/auth";', + "", + "export const routed = `router:${eveAuth}`;", + "", + ].join("\n"), + ); + + const manifest = createCompiledAgentManifest({ + agentRoot: appRoot, + appRoot, + config: { + model: { + id: "openai/gpt-5.4-mini", + routing: { + kind: "gateway", + target: "openai/gpt-5.4-mini", + }, + }, + name: "Parent Alias Agent", + source: { + logicalPath: "agent.ts", + sourceId: "agent.ts", + sourceKind: "module", + }, + }, + instructions: { + logicalPath: "instructions.md", + markdown: "Use the routed model.", + name: "instructions", + sourceId: "instructions.md", + sourceKind: "markdown", + }, + }); + + await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + await writeFile( + moduleMapPath, + createCompiledModuleMapSource({ + manifest, + moduleMapPath, + }), + ); + + await publishDevelopmentGeneration({ + paths: { compileDirectoryPath }, + project: { appRoot }, + } as CompileAgentResult); + + await withRuntimeSession(createRuntimeSession("parent-alias-imports-regression"), async () => { + const bundle = await getCompiledRuntimeAgentBundle({ + compiledArtifactsSource: resolveNitroCompiledArtifactsSource( + createDevelopmentNitroArtifactsConfig({ appRoot }), + ), + }); + const agentModule = bundle.moduleMap.nodes[ROOT_COMPILED_AGENT_NODE_ID]?.modules["agent.ts"]; + + expect(agentModule).toMatchObject({ + routed: "router:parent-auth-config", + }); + }); + }); + it("stops copying at nested git repository boundaries", async () => { const appRoot = await createScratchDirectory("eve-dev-runtime-nested-git-"); const agentRoot = join(appRoot, "agent"); diff --git a/packages/eve/src/internal/nitro/dev-runtime-source-snapshot-copy.ts b/packages/eve/src/internal/nitro/dev-runtime-source-snapshot-copy.ts index 9e4975606..844fb02fa 100644 --- a/packages/eve/src/internal/nitro/dev-runtime-source-snapshot-copy.ts +++ b/packages/eve/src/internal/nitro/dev-runtime-source-snapshot-copy.ts @@ -99,6 +99,20 @@ async function copySnapshotDirectory(input: { continue; } + // `cp` refuses to copy a directory that contains its own destination, + // even when the filter excludes that subtree. A copy root above the app + // root (an owning package with tsconfig path aliases, vercel/eve#1242) + // contains the snapshot inside the app root's `.eve` directory, so + // recurse manually until the skipped `.eve` subtree is filtered out. + if (entry.isDirectory() && isPathInsideOrEqual(input.plan.snapshotRoot, sourcePath)) { + await copySnapshotDirectory({ + plan: input.plan, + sourcePath, + targetPath: join(input.targetPath, entry.name), + }); + continue; + } + await cp(sourcePath, join(input.targetPath, entry.name), { filter: (source) => !shouldSkipSnapshotSource(input.plan, source), mode: SNAPSHOT_COPY_MODE, @@ -310,12 +324,18 @@ async function createSnapshotDependencyMounts(plan: DevelopmentSourceSnapshotPla } async function ensureRuntimePackageJson(plan: DevelopmentSourceSnapshotPlan): Promise { - const runtimePackageJsonPath = join(plan.runtimeAppRoot, "package.json"); - - if (existsSync(runtimePackageJsonPath)) { + // A synthetic package.json below an owning package that made it into the + // snapshot would move the package boundary that authored-module bundling + // (`resolveAuthoredPackageRoot`) and Node resolution walk up to, hiding the + // owning package's tsconfig path aliases (vercel/eve#1242). Only generate + // one when the snapshot carries no owning package.json at or above the + // runtime app root. + if (findNearestSnapshotPackageJsonPath(plan) !== undefined) { return; } + const runtimePackageJsonPath = join(plan.runtimeAppRoot, "package.json"); + await mkdir(plan.runtimeAppRoot, { recursive: true }); await writeFile( runtimePackageJsonPath, @@ -333,11 +353,9 @@ async function ensureRuntimePackageJson(plan: DevelopmentSourceSnapshotPlan): Pr async function validateDevelopmentSourceSnapshot( plan: DevelopmentSourceSnapshotPlan, ): Promise { - const runtimePackageJsonPath = join(plan.runtimeAppRoot, "package.json"); - - if (!existsSync(runtimePackageJsonPath)) { + if (findNearestSnapshotPackageJsonPath(plan) === undefined) { throw new DevelopmentRuntimeSourceSnapshotError( - `Development runtime source snapshot is missing the runtime app package.json at "${runtimePackageJsonPath}".`, + `Development runtime source snapshot is missing a package.json at or above the runtime app root "${plan.runtimeAppRoot}".`, ); } @@ -378,6 +396,35 @@ async function validateSnapshotTsConfigExtends(configPath: string): Promise { + let currentDirectory = resolve(path); + + try { + const stats = await lstat(currentDirectory); + + if (!stats.isDirectory()) { + currentDirectory = dirname(currentDirectory); + } + } catch { + currentDirectory = dirname(currentDirectory); + } + + while (isAuthoredSourcePath(currentDirectory, sourceRoot)) { + if (existsSync(join(currentDirectory, "package.json"))) { + return currentDirectory; + } + + const parentDirectory = dirname(currentDirectory); + + if (parentDirectory === currentDirectory) { + return undefined; + } + + currentDirectory = parentDirectory; + } + + return undefined; +} + +/** Returns whether a tsconfig/jsconfig file declares at least one `paths` alias. */ +export async function tsConfigDefinesPathAliases(configPath: string): Promise { + const source = await readTextFileIfExists(configPath); + + if (source === undefined) { + return false; + } + + const parsedConfig = parseTsConfigObject(source); + const compilerOptions = isObjectRecord(parsedConfig?.compilerOptions) + ? parsedConfig.compilerOptions + : undefined; + const paths = isObjectRecord(compilerOptions?.paths) ? compilerOptions.paths : undefined; + + return paths !== undefined && Object.keys(paths).length > 0; +} + +/** + * Resolves the local source roots a tsconfig's `paths` aliases can target so + * a development source snapshot includes them. + */ +export async function resolveLocalTsConfigPathTargetRoots(input: { + readonly configPath: string; + readonly sourceRoot: string; +}): Promise { + const source = await readTextFileIfExists(input.configPath); + + if (source === undefined) { + return []; + } + + const parsedConfig = parseTsConfigObject(source); + const compilerOptions = isObjectRecord(parsedConfig?.compilerOptions) + ? parsedConfig.compilerOptions + : undefined; + const paths = isObjectRecord(compilerOptions?.paths) ? compilerOptions.paths : undefined; + + if (compilerOptions === undefined || paths === undefined) { + return []; + } + + const baseDirectory = + typeof compilerOptions.baseUrl === "string" + ? resolve(dirname(input.configPath), compilerOptions.baseUrl) + : dirname(input.configPath); + const localRoots = new Set(); + + for (const targets of Object.values(paths)) { + if (!Array.isArray(targets)) { + continue; + } + + for (const target of targets) { + if (typeof target !== "string" || target.length === 0) { + continue; + } + + const localRoot = await resolveLocalTsConfigPathTargetRoot({ + baseDirectory, + sourceRoot: input.sourceRoot, + target, + }); + + if (localRoot !== undefined) { + localRoots.add(localRoot); + } + } + } + + return [...localRoots].sort((left, right) => left.localeCompare(right)); +} + +async function resolveLocalTsConfigPathTargetRoot(input: { + readonly baseDirectory: string; + readonly sourceRoot: string; + readonly target: string; +}): Promise { + const hasWildcard = input.target.includes("*"); + const targetPrefix = hasWildcard + ? input.target.slice(0, input.target.indexOf("*")) + : input.target; + + if (targetPrefix.length === 0 || targetPrefix === "." || targetPrefix === "./") { + return undefined; + } + + const resolvedTarget = resolve(input.baseDirectory, targetPrefix); + + if (!isAuthoredSourcePath(resolvedTarget, input.sourceRoot)) { + return undefined; + } + + const existingTarget = await resolveExistingPathOrAncestor({ + path: resolvedTarget, + stopDirectory: input.sourceRoot, + }); + + if (existingTarget === undefined) { + return undefined; + } + + const packageRoot = await resolveNearestPackageRoot(existingTarget, input.sourceRoot); + + if (packageRoot !== undefined && packageRoot !== input.sourceRoot) { + return packageRoot; + } + + if (hasWildcard) { + return undefined; + } + + return existingTarget === input.sourceRoot ? undefined : existingTarget; +} + +async function resolveExistingPathOrAncestor(input: { + readonly path: string; + readonly stopDirectory: string; +}): Promise { + let currentPath = resolve(input.path); + + while (isAuthoredSourcePath(currentPath, input.stopDirectory)) { + if (existsSync(currentPath)) { + return currentPath; + } + + const parentPath = dirname(currentPath); + + if (parentPath === currentPath) { + return undefined; + } + + currentPath = parentPath; + } + + return undefined; +} + +function isObjectRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/packages/eve/src/internal/nitro/dev-runtime-source-snapshot.ts b/packages/eve/src/internal/nitro/dev-runtime-source-snapshot.ts index 2421563fb..8650aef0f 100644 --- a/packages/eve/src/internal/nitro/dev-runtime-source-snapshot.ts +++ b/packages/eve/src/internal/nitro/dev-runtime-source-snapshot.ts @@ -1,12 +1,20 @@ import { existsSync, readFileSync } from "node:fs"; import { lstat, readlink, realpath } from "node:fs/promises"; -import { dirname, join, relative, resolve, sep } from "node:path"; +import { dirname, join, relative, resolve } from "node:path"; import { - parseTsConfigObject, readTextFileIfExists, resolveTsConfigDependencyPaths, } from "#internal/application/tsconfig-dependencies.js"; +import { + isAuthoredSourcePath, + isPathInsideOrEqual, + resolveLocalTsConfigPathTargetRoots, + resolveNearestPackageRoot, + tsConfigDefinesPathAliases, +} from "#internal/nitro/dev-runtime-source-snapshot-tsconfig-paths.js"; + +export { isAuthoredSourcePath } from "#internal/nitro/dev-runtime-source-snapshot-tsconfig-paths.js"; export const DEV_RUNTIME_SOURCE_DIRECTORY = "source"; @@ -204,7 +212,36 @@ async function addTsConfigDependenciesForRoot( state: SnapshotPlanState, packageRoot: string, ): Promise { - const tsconfigPaths = await resolveTsConfigDependencyPaths(packageRoot); + await addTsConfigDependenciesForConfigRoot(state, packageRoot); + + // An app root without its own `package.json` (a bare `withEve` agent + // directory) is bundled with the nearest owning package's tsconfig in + // production, so its path aliases must resolve inside the snapshot too + // (vercel/eve#1242). + const declarationRoot = resolveDependencyDeclarationRoot(packageRoot, state.sourceRoot); + + if (declarationRoot === packageRoot) { + return; + } + + const declaresPathAliases = await addTsConfigDependenciesForConfigRoot(state, declarationRoot); + + // Alias targets such as `"@/*": ["./*"]` resolve against the owning + // package root, so that whole package must be part of the snapshot for + // dev bundling to match production resolution (vercel/eve#1242). The + // source root itself is never copied wholesale, matching + // `resolveLocalTsConfigPathTargetRoot`. + if (declaresPathAliases && declarationRoot !== state.sourceRoot) { + enqueueLocalRoot(state, declarationRoot); + } +} + +async function addTsConfigDependenciesForConfigRoot( + state: SnapshotPlanState, + configRoot: string, +): Promise { + const tsconfigPaths = await resolveTsConfigDependencyPaths(configRoot); + let declaresPathAliases = false; for (const tsconfigPath of tsconfigPaths) { if (!isPathInsideOrEqual(tsconfigPath, state.sourceRoot)) { @@ -214,6 +251,10 @@ async function addTsConfigDependenciesForRoot( state.tsconfigPaths.add(tsconfigPath); state.copyFiles.add(tsconfigPath); + if (await tsConfigDefinesPathAliases(tsconfigPath)) { + declaresPathAliases = true; + } + for (const localRoot of await resolveLocalTsConfigPathTargetRoots({ configPath: tsconfigPath, sourceRoot: state.sourceRoot, @@ -221,6 +262,8 @@ async function addTsConfigDependenciesForRoot( enqueueLocalRoot(state, localRoot); } } + + return declaresPathAliases; } async function addDependencyMountsForRoot( @@ -450,155 +493,6 @@ async function readPackageDependencyNames(packageRoot: string): Promise left.localeCompare(right)); } -async function resolveLocalTsConfigPathTargetRoots(input: { - readonly configPath: string; - readonly sourceRoot: string; -}): Promise { - const source = await readTextFileIfExists(input.configPath); - - if (source === undefined) { - return []; - } - - const parsedConfig = parseTsConfigObject(source); - const compilerOptions = isObjectRecord(parsedConfig?.compilerOptions) - ? parsedConfig.compilerOptions - : undefined; - const paths = isObjectRecord(compilerOptions?.paths) ? compilerOptions.paths : undefined; - - if (compilerOptions === undefined || paths === undefined) { - return []; - } - - const baseDirectory = - typeof compilerOptions.baseUrl === "string" - ? resolve(dirname(input.configPath), compilerOptions.baseUrl) - : dirname(input.configPath); - const localRoots = new Set(); - - for (const targets of Object.values(paths)) { - if (!Array.isArray(targets)) { - continue; - } - - for (const target of targets) { - if (typeof target !== "string" || target.length === 0) { - continue; - } - - const localRoot = await resolveLocalTsConfigPathTargetRoot({ - baseDirectory, - sourceRoot: input.sourceRoot, - target, - }); - - if (localRoot !== undefined) { - localRoots.add(localRoot); - } - } - } - - return [...localRoots].sort((left, right) => left.localeCompare(right)); -} - -async function resolveLocalTsConfigPathTargetRoot(input: { - readonly baseDirectory: string; - readonly sourceRoot: string; - readonly target: string; -}): Promise { - const hasWildcard = input.target.includes("*"); - const targetPrefix = hasWildcard - ? input.target.slice(0, input.target.indexOf("*")) - : input.target; - - if (targetPrefix.length === 0 || targetPrefix === "." || targetPrefix === "./") { - return undefined; - } - - const resolvedTarget = resolve(input.baseDirectory, targetPrefix); - - if (!isAuthoredSourcePath(resolvedTarget, input.sourceRoot)) { - return undefined; - } - - const existingTarget = await resolveExistingPathOrAncestor({ - path: resolvedTarget, - stopDirectory: input.sourceRoot, - }); - - if (existingTarget === undefined) { - return undefined; - } - - const packageRoot = await resolveNearestPackageRoot(existingTarget, input.sourceRoot); - - if (packageRoot !== undefined && packageRoot !== input.sourceRoot) { - return packageRoot; - } - - if (hasWildcard) { - return undefined; - } - - return existingTarget === input.sourceRoot ? undefined : existingTarget; -} - -async function resolveExistingPathOrAncestor(input: { - readonly path: string; - readonly stopDirectory: string; -}): Promise { - let currentPath = resolve(input.path); - - while (isAuthoredSourcePath(currentPath, input.stopDirectory)) { - if (existsSync(currentPath)) { - return currentPath; - } - - const parentPath = dirname(currentPath); - - if (parentPath === currentPath) { - return undefined; - } - - currentPath = parentPath; - } - - return undefined; -} - -async function resolveNearestPackageRoot( - path: string, - sourceRoot: string, -): Promise { - let currentDirectory = resolve(path); - - try { - const stats = await lstat(currentDirectory); - - if (!stats.isDirectory()) { - currentDirectory = dirname(currentDirectory); - } - } catch { - currentDirectory = dirname(currentDirectory); - } - - while (isAuthoredSourcePath(currentDirectory, sourceRoot)) { - if (existsSync(join(currentDirectory, "package.json"))) { - return currentDirectory; - } - - const parentDirectory = dirname(currentDirectory); - - if (parentDirectory === currentDirectory) { - return undefined; - } - - currentDirectory = parentDirectory; - } - - return undefined; -} - function normalizeCopyRoots(copyRoots: readonly string[]): string[] { const sortedRoots = [...new Set(copyRoots.map((path) => resolve(path)))].sort((left, right) => { const lengthDifference = left.length - right.length; @@ -674,26 +568,6 @@ function toSnapshotPath(input: { return join(input.snapshotSourceRoot, relative(input.sourceRoot, input.sourcePath)); } -function isPathInsideOrEqual(path: string, directory: string): boolean { - const resolvedPath = resolve(path); - const resolvedDirectory = resolve(directory); - - return ( - resolvedPath === resolvedDirectory || resolvedPath.startsWith(`${resolvedDirectory}${sep}`) - ); -} - -/** Returns whether a path is authored workspace source rather than installed dependency data. */ -export function isAuthoredSourcePath(path: string, sourceRoot: string): boolean { - if (!isPathInsideOrEqual(path, sourceRoot)) { - return false; - } - - const relativePath = relative(sourceRoot, path); - - return !relativePath.split(/[\\/]/).includes("node_modules"); -} - function isObjectRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); }