From ba5d08d8a1d00748dbeccc1c8011ed797712112c Mon Sep 17 00:00:00 2001 From: wangyiming Date: Mon, 27 Jul 2026 10:39:05 +0800 Subject: [PATCH 1/6] feat: add configurable dependency trace root --- .changeset/quiet-roots-trace.md | 8 ++ README.md | 20 +++++ src/index.ts | 46 ++++++++++- src/utils.ts | 2 +- tests/nde.test.ts | 133 ++++++++++++++++++++++++++++++++ tests/utils.test.ts | 55 +++++++++++++ 6 files changed, 261 insertions(+), 3 deletions(-) create mode 100644 .changeset/quiet-roots-trace.md diff --git a/.changeset/quiet-roots-trace.md b/.changeset/quiet-roots-trace.md new file mode 100644 index 0000000..b0c387c --- /dev/null +++ b/.changeset/quiet-roots-trace.md @@ -0,0 +1,8 @@ +--- +'ndepe': patch +--- + +Add a configurable dependency trace root and keep nft tracing and path +restoration aligned to the same boundary. +`traceOptions.base`, `traceOptions.processCwd`, and `traceOptions.cache` can no +longer override ndepe-managed values. diff --git a/README.md b/README.md index 7739fe8..ba320ce 100644 --- a/README.md +++ b/README.md @@ -21,3 +21,23 @@ nodeDepEmit({ ``` +### Trace root + +Use `traceRoot` to set the `@vercel/nft` dependency tracing boundary: + +```js +nodeDepEmit({ + appDir: appDirectory, + sourceDir: sourceDirectory, + traceRoot: '../..', +}) +``` + +Relative paths are resolved from `appDir`; absolute paths are used directly. +An empty string resolves to `appDir`, and omitting `traceRoot` keeps the +existing `/` default. + +The root must contain `sourceDir`, every entry file, and all runtime workspace +packages and dependencies. It is an analysis boundary, not a security sandbox. +Ndepe manages `traceOptions.base`, `processCwd`, and `cache`; other nft options +and filesystem hooks are forwarded. diff --git a/src/index.ts b/src/index.ts index 0264f73..478017b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,6 +28,16 @@ export type { NodeFileTraceOptions } from "@vercel/nft"; export type { TransformPackageJsonHook } from "./utils"; export { nodeFileTrace } from "@vercel/nft"; +const isPathInsideOrEqual = (parentPath: string, childPath: string) => { + const relativePath = path.relative(parentPath, childPath); + return ( + relativePath === "" || + (!path.isAbsolute(relativePath) && + relativePath !== ".." && + !relativePath.startsWith(`..${path.sep}`)) + ); +}; + export const nodeDepEmit = async ({ appDir, sourceDir, @@ -43,6 +53,7 @@ export const nodeDepEmit = async ({ fileCache: false, symlinkCache: false, }, + traceRoot, traceOptions, }: { /** @@ -69,14 +80,45 @@ export const nodeDepEmit = async ({ transformPackageJson?: TransformPackageJsonHook; copyWholePackage?: (pkgName: string, pkgJSON: PackageJson) => boolean; cacheOptions?: CacheOptions; + /** + * Boundary used by node file tracing. Relative paths are resolved from appDir. + * Defaults to the filesystem root for backward compatibility. + */ + traceRoot?: string; + /** + * Options forwarded to nodeFileTrace. base, processCwd, and cache are + * managed by ndepe and cannot be overridden here. + */ traceOptions?: NodeFileTraceOptions; }) => { - const base = "/"; + const base = traceRoot === undefined ? "/" : path.resolve(appDir, traceRoot); + if (traceRoot !== undefined) { + const resolvedSourceDir = path.resolve(sourceDir); + if (!isPathInsideOrEqual(base, resolvedSourceDir)) { + throw new Error( + `The trace root "${base}" must contain sourceDir "${resolvedSourceDir}".`, + ); + } + } + const entryFiles = await findEntryFiles(sourceDir, entryFilter); + const allEntryFiles = entryFiles.concat(includeEntries || []); + if (traceRoot !== undefined) { + const outsideEntryFiles = allEntryFiles + .map((entryFile) => path.resolve(entryFile)) + .filter((entryFile) => !isPathInsideOrEqual(base, entryFile)); + if (outsideEntryFiles.length > 0) { + throw new Error( + `The trace root "${base}" must contain every entry file. Outside entries:\n${outsideEntryFiles + .map((entryFile) => `- "${entryFile}"`) + .join("\n")}`, + ); + } + } debug("trace files start"); const fileTrace = await traceFiles({ - entryFiles: entryFiles.concat(includeEntries || []), + entryFiles: allEntryFiles, sourceDir, cacheOptions: { ...cacheOptions, diff --git a/src/utils.ts b/src/utils.ts index 2997c45..e33a163 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -311,10 +311,10 @@ export const traceFiles = async ({ }; const res = await nodeFileTrace(entryFiles, { + ...traceOptions, base, processCwd: sourceDir, cache, - ...traceOptions, }); if (analysisCache || fileCache || symlinkCache) { diff --git a/tests/nde.test.ts b/tests/nde.test.ts index 71e5f1c..6746df3 100644 --- a/tests/nde.test.ts +++ b/tests/nde.test.ts @@ -1,8 +1,31 @@ import { expect, describe, it, afterEach } from 'vitest' +import os from 'node:os' import path from 'node:path' +import type { NodeFileTraceResult } from '@vercel/nft'; import fse from 'fs-extra' import { nodeDepEmit } from '../src'; +import { traceFiles as defaultTraceFiles } from '../src/utils'; + +const emptyTraceResult: NodeFileTraceResult = { + fileList: new Set(), + esmFileList: new Set(), + warnings: new Set(), + reasons: new Map(), +}; + +const withTempDir = async ( + run: (tempDir: string) => Promise, +): Promise => { + const tempDir = await fse.realpath( + await fse.mkdtemp(path.join(os.tmpdir(), 'nde-trace-root-')), + ); + try { + await run(tempDir); + } finally { + await fse.remove(tempDir); + } +}; describe('handle dependencies', () => { const project1Dir = path.join(__dirname, 'fixtures/project1'); @@ -51,4 +74,114 @@ describe('handle workspace packages with directory entry points', () => { }) }) +describe('trace root', () => { + it('supports default, empty, relative, and absolute trace roots', async () => { + await withTempDir(async tempDir => { + const appDir = path.join(tempDir, 'app'); + const sourceDir = path.join(appDir, 'dist'); + const tracedBases: string[] = []; + await fse.outputFile( + path.join(sourceDir, 'index.js'), + 'module.exports = 1;', + ); + + for (const traceRoot of [undefined, '', tempDir]) { + await nodeDepEmit({ + appDir, + sourceDir, + traceRoot, + traceFiles: async options => { + tracedBases.push(options.base || ''); + return emptyTraceResult; + }, + }); + } + + expect(tracedBases).toEqual(['/', appDir, tempDir]); + }); + }); + + it('uses the same relative trace root for tracing and dependency copying', async () => { + await withTempDir(async traceRoot => { + const appDir = path.join(traceRoot, 'apps/app'); + const sourceDir = path.join(appDir, 'dist'); + const dependencyDir = path.join(traceRoot, 'node_modules/test-dependency'); + let tracedBase: string | undefined; + + await fse.outputJSON(path.join(dependencyDir, 'package.json'), { + name: 'test-dependency', + version: '1.0.0', + main: 'index.js', + }); + await fse.outputFile( + path.join(dependencyDir, 'index.js'), + 'module.exports = "test";', + ); + await fse.outputFile( + path.join(sourceDir, 'index.js'), + 'module.exports = require("test-dependency");', + ); + + await nodeDepEmit({ + appDir, + sourceDir, + traceRoot: '../..', + traceFiles: async options => { + tracedBase = options.base; + return defaultTraceFiles(options); + }, + }); + + expect(tracedBase).toBe(traceRoot); + await expect( + fse.readFile( + path.join(sourceDir, 'node_modules/test-dependency/index.js'), + 'utf8', + ), + ).resolves.toBe('module.exports = "test";'); + }); + }); + + it('rejects source directories and include entries outside the trace root', async () => { + await withTempDir(async tempDir => { + const appDir = path.join(tempDir, 'app'); + const sourceDir = path.join(appDir, 'dist'); + const missingSourceDir = path.join(tempDir, 'missing-source'); + const outsideEntry = path.join(tempDir, 'outside.js'); + const traceFiles = async () => { + throw new Error('traceFiles should not be called'); + }; + + await fse.ensureDir(appDir); + await expect( + nodeDepEmit({ + appDir, + sourceDir: missingSourceDir, + traceRoot: '', + traceFiles, + }), + ).rejects.toThrow( + `The trace root "${appDir}" must contain sourceDir "${missingSourceDir}".`, + ); + + await fse.outputFile( + path.join(sourceDir, 'index.js'), + 'module.exports = 1;', + ); + await fse.outputFile(outsideEntry, 'module.exports = 2;'); + + await expect( + nodeDepEmit({ + appDir, + sourceDir, + traceRoot: '', + includeEntries: [outsideEntry], + traceFiles, + }), + ).rejects.toThrow( + `The trace root "${appDir}" must contain every entry file. Outside entries:\n- "${outsideEntry}"`, + ); + }); + }); +}); diff --git a/tests/utils.test.ts b/tests/utils.test.ts index 39f21c9..e48f080 100644 --- a/tests/utils.test.ts +++ b/tests/utils.test.ts @@ -8,8 +8,17 @@ import { isFile, findEntryFiles, isSubPath, + traceFiles, } from '../src/utils'; +const { nodeFileTraceMock } = vi.hoisted(() => ({ + nodeFileTraceMock: vi.fn(), +})); + +vi.mock('@vercel/nft', () => ({ + nodeFileTrace: nodeFileTraceMock, +})); + vi.mock('fs-extra', () => { const actual = vi.importActual('fs-extra'); return { @@ -131,4 +140,50 @@ describe('utils', () => { expect(isSubPath('/parent', '/parent2/sibling')).toBe(false); }); }); + + describe('traceFiles', () => { + it('keeps ownership of base, processCwd, and cache while forwarding other trace options', async () => { + const readFile = vi.fn(); + nodeFileTraceMock.mockResolvedValue({ + fileList: new Set(), + esmFileList: new Set(), + reasons: new Map(), + warnings: new Set(), + }); + + await traceFiles({ + entryFiles: ['/app/dist/index.js'], + sourceDir: '/app/dist', + base: '/trace-root', + cacheOptions: { + cacheDir: '/cache', + analysisCache: false, + fileCache: false, + symlinkCache: false, + }, + traceOptions: { + base: '/overridden-root', + processCwd: '/overridden-cwd', + cache: { custom: true }, + analysis: false, + readFile, + }, + }); + + expect(nodeFileTraceMock).toHaveBeenCalledWith( + ['/app/dist/index.js'], + expect.objectContaining({ + base: '/trace-root', + processCwd: '/app/dist', + cache: { + analysisCache: undefined, + fileCache: undefined, + symlinkCache: undefined, + }, + analysis: false, + readFile, + }), + ); + }); + }); }); From 3ee0cd8c189feb70cb871a4e7e9d1a9e0ebca9d8 Mon Sep 17 00:00:00 2001 From: wangyiming Date: Mon, 27 Jul 2026 11:20:19 +0800 Subject: [PATCH 2/6] fix: canonicalize configured trace roots --- README.md | 6 +++--- src/index.ts | 41 +++++++++++++++++++++++++++++------------ tests/nde.test.ts | 10 +++++++--- 3 files changed, 39 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index ba320ce..edbde48 100644 --- a/README.md +++ b/README.md @@ -33,9 +33,9 @@ nodeDepEmit({ }) ``` -Relative paths are resolved from `appDir`; absolute paths are used directly. -An empty string resolves to `appDir`, and omitting `traceRoot` keeps the -existing `/` default. +Relative paths are resolved from `appDir`; absolute paths are used as the root +candidate. The resulting root is canonicalized before tracing. An empty string +resolves to `appDir`, and omitting `traceRoot` keeps the existing `/` default. The root must contain `sourceDir`, every entry file, and all runtime workspace packages and dependencies. It is an analysis boundary, not a security sandbox. diff --git a/src/index.ts b/src/index.ts index 478017b..45631fc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -91,12 +91,15 @@ export const nodeDepEmit = async ({ */ traceOptions?: NodeFileTraceOptions; }) => { - const base = traceRoot === undefined ? "/" : path.resolve(appDir, traceRoot); + const traceBoundary = + traceRoot === undefined ? "/" : path.resolve(appDir, traceRoot); + const base = + traceRoot === undefined ? traceBoundary : await fse.realpath(traceBoundary); if (traceRoot !== undefined) { const resolvedSourceDir = path.resolve(sourceDir); - if (!isPathInsideOrEqual(base, resolvedSourceDir)) { + if (!isPathInsideOrEqual(traceBoundary, resolvedSourceDir)) { throw new Error( - `The trace root "${base}" must contain sourceDir "${resolvedSourceDir}".`, + `The trace root "${traceBoundary}" must contain sourceDir "${resolvedSourceDir}".`, ); } } @@ -106,20 +109,31 @@ export const nodeDepEmit = async ({ if (traceRoot !== undefined) { const outsideEntryFiles = allEntryFiles .map((entryFile) => path.resolve(entryFile)) - .filter((entryFile) => !isPathInsideOrEqual(base, entryFile)); + .filter((entryFile) => !isPathInsideOrEqual(traceBoundary, entryFile)); if (outsideEntryFiles.length > 0) { throw new Error( - `The trace root "${base}" must contain every entry file. Outside entries:\n${outsideEntryFiles + `The trace root "${traceBoundary}" must contain every entry file. Outside entries:\n${outsideEntryFiles .map((entryFile) => `- "${entryFile}"`) .join("\n")}`, ); } } + let tracingAppDir = appDir; + let tracingSourceDir = sourceDir; + let tracingEntryFiles = allEntryFiles; + if (traceRoot !== undefined) { + [tracingAppDir, tracingSourceDir, tracingEntryFiles] = await Promise.all([ + fse.realpath(appDir), + fse.realpath(sourceDir), + Promise.all(allEntryFiles.map((entryFile) => fse.realpath(entryFile))), + ]); + } + debug("trace files start"); const fileTrace = await traceFiles({ - entryFiles: allEntryFiles, - sourceDir, + entryFiles: tracingEntryFiles, + sourceDir: tracingSourceDir, cacheOptions: { ...cacheOptions, cacheDir: path.resolve(appDir, cacheOptions.cacheDir), @@ -128,9 +142,12 @@ export const nodeDepEmit = async ({ traceOptions, }); debug("trace files end"); - const currentProjectModules = path.join(appDir, "node_modules"); + const currentProjectModules = path.join(tracingAppDir, "node_modules"); // Because vercel/nft may find inaccurately, we limit the range of query of dependencies - const dependencySearchRoot = path.resolve(appDir, "../../../../../../"); + const dependencySearchRoot = path.resolve( + tracingAppDir, + "../../../../../../", + ); const packageJsonCache = new Map(); @@ -143,8 +160,8 @@ export const nodeDepEmit = async ({ const filePath = await resolveTracedPath(base, _path); if ( - isSubPath(sourceDir, filePath) || - (isSubPath(appDir, filePath) && + isSubPath(tracingSourceDir, filePath) || + (isSubPath(tracingAppDir, filePath) && !isSubPath(currentProjectModules, filePath)) ) { return; @@ -213,7 +230,7 @@ export const nodeDepEmit = async ({ parents, isDirectDep: parents.some((parent) => { return ( - isSubPath(appDir, parent) && + isSubPath(tracingAppDir, parent) && !isSubPath(currentProjectModules, parent) ); }), diff --git a/tests/nde.test.ts b/tests/nde.test.ts index 6746df3..eca030f 100644 --- a/tests/nde.test.ts +++ b/tests/nde.test.ts @@ -101,13 +101,17 @@ describe('trace root', () => { }); }); - it('uses the same relative trace root for tracing and dependency copying', async () => { - await withTempDir(async traceRoot => { - const appDir = path.join(traceRoot, 'apps/app'); + it('uses the same canonical trace root for tracing and dependency copying', async () => { + await withTempDir(async tempDir => { + const traceRoot = path.join(tempDir, 'real-root'); + const linkedTraceRoot = path.join(tempDir, 'linked-root'); + const appDir = path.join(linkedTraceRoot, 'apps/app'); const sourceDir = path.join(appDir, 'dist'); const dependencyDir = path.join(traceRoot, 'node_modules/test-dependency'); let tracedBase: string | undefined; + await fse.ensureDir(traceRoot); + await fse.symlink(traceRoot, linkedTraceRoot, 'dir'); await fse.outputJSON(path.join(dependencyDir, 'package.json'), { name: 'test-dependency', version: '1.0.0', From 4a75c72e58569d42b3e88854326e7237f087bb79 Mon Sep 17 00:00:00 2001 From: wangyiming Date: Mon, 27 Jul 2026 14:11:51 +0800 Subject: [PATCH 3/6] fix: preserve entry file handling semantics --- src/index.ts | 26 +++++++++----------------- tests/nde.test.ts | 22 +--------------------- 2 files changed, 10 insertions(+), 38 deletions(-) diff --git a/src/index.ts b/src/index.ts index 45631fc..ece4623 100644 --- a/src/index.ts +++ b/src/index.ts @@ -106,28 +106,20 @@ export const nodeDepEmit = async ({ const entryFiles = await findEntryFiles(sourceDir, entryFilter); const allEntryFiles = entryFiles.concat(includeEntries || []); - if (traceRoot !== undefined) { - const outsideEntryFiles = allEntryFiles - .map((entryFile) => path.resolve(entryFile)) - .filter((entryFile) => !isPathInsideOrEqual(traceBoundary, entryFile)); - if (outsideEntryFiles.length > 0) { - throw new Error( - `The trace root "${traceBoundary}" must contain every entry file. Outside entries:\n${outsideEntryFiles - .map((entryFile) => `- "${entryFile}"`) - .join("\n")}`, - ); - } - } let tracingAppDir = appDir; let tracingSourceDir = sourceDir; let tracingEntryFiles = allEntryFiles; if (traceRoot !== undefined) { - [tracingAppDir, tracingSourceDir, tracingEntryFiles] = await Promise.all([ - fse.realpath(appDir), - fse.realpath(sourceDir), - Promise.all(allEntryFiles.map((entryFile) => fse.realpath(entryFile))), - ]); + const resolvedAppDir = path.resolve(appDir); + tracingAppDir = await fse.realpath(resolvedAppDir); + const resolveTracingPath = (filePath: string) => + path.resolve( + tracingAppDir, + path.relative(resolvedAppDir, path.resolve(filePath)), + ); + tracingSourceDir = resolveTracingPath(sourceDir); + tracingEntryFiles = allEntryFiles.map(resolveTracingPath); } debug("trace files start"); diff --git a/tests/nde.test.ts b/tests/nde.test.ts index eca030f..38e7fe3 100644 --- a/tests/nde.test.ts +++ b/tests/nde.test.ts @@ -146,12 +146,10 @@ describe('trace root', () => { }); }); - it('rejects source directories and include entries outside the trace root', async () => { + it('rejects source directories outside the trace root', async () => { await withTempDir(async tempDir => { const appDir = path.join(tempDir, 'app'); - const sourceDir = path.join(appDir, 'dist'); const missingSourceDir = path.join(tempDir, 'missing-source'); - const outsideEntry = path.join(tempDir, 'outside.js'); const traceFiles = async () => { throw new Error('traceFiles should not be called'); }; @@ -167,24 +165,6 @@ describe('trace root', () => { ).rejects.toThrow( `The trace root "${appDir}" must contain sourceDir "${missingSourceDir}".`, ); - - await fse.outputFile( - path.join(sourceDir, 'index.js'), - 'module.exports = 1;', - ); - await fse.outputFile(outsideEntry, 'module.exports = 2;'); - - await expect( - nodeDepEmit({ - appDir, - sourceDir, - traceRoot: '', - includeEntries: [outsideEntry], - traceFiles, - }), - ).rejects.toThrow( - `The trace root "${appDir}" must contain every entry file. Outside entries:\n- "${outsideEntry}"`, - ); }); }); From 6039479ae3ee37f7263a47ac73bd35046cadf964 Mon Sep 17 00:00:00 2001 From: wangyiming Date: Mon, 27 Jul 2026 14:38:59 +0800 Subject: [PATCH 4/6] refactor: narrow trace root implementation --- README.md | 16 +++++++------- src/index.ts | 56 +++++++---------------------------------------- tests/nde.test.ts | 32 +++------------------------ 3 files changed, 19 insertions(+), 85 deletions(-) diff --git a/README.md b/README.md index edbde48..26c566b 100644 --- a/README.md +++ b/README.md @@ -33,11 +33,11 @@ nodeDepEmit({ }) ``` -Relative paths are resolved from `appDir`; absolute paths are used as the root -candidate. The resulting root is canonicalized before tracing. An empty string -resolves to `appDir`, and omitting `traceRoot` keeps the existing `/` default. - -The root must contain `sourceDir`, every entry file, and all runtime workspace -packages and dependencies. It is an analysis boundary, not a security sandbox. -Ndepe manages `traceOptions.base`, `processCwd`, and `cache`; other nft options -and filesystem hooks are forwarded. +Relative paths are resolved from `appDir`; absolute paths are used directly. +An empty string resolves to `appDir`, and omitting `traceRoot` keeps the +existing `/` default. + +The root must contain the application output and all runtime workspace packages +and dependencies. It is an analysis boundary, not a security sandbox. Ndepe +manages `traceOptions.base`, `processCwd`, and `cache`; other nft options and +filesystem hooks are forwarded. diff --git a/src/index.ts b/src/index.ts index ece4623..79a36d2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,16 +28,6 @@ export type { NodeFileTraceOptions } from "@vercel/nft"; export type { TransformPackageJsonHook } from "./utils"; export { nodeFileTrace } from "@vercel/nft"; -const isPathInsideOrEqual = (parentPath: string, childPath: string) => { - const relativePath = path.relative(parentPath, childPath); - return ( - relativePath === "" || - (!path.isAbsolute(relativePath) && - relativePath !== ".." && - !relativePath.startsWith(`..${path.sep}`)) - ); -}; - export const nodeDepEmit = async ({ appDir, sourceDir, @@ -91,41 +81,14 @@ export const nodeDepEmit = async ({ */ traceOptions?: NodeFileTraceOptions; }) => { - const traceBoundary = - traceRoot === undefined ? "/" : path.resolve(appDir, traceRoot); - const base = - traceRoot === undefined ? traceBoundary : await fse.realpath(traceBoundary); - if (traceRoot !== undefined) { - const resolvedSourceDir = path.resolve(sourceDir); - if (!isPathInsideOrEqual(traceBoundary, resolvedSourceDir)) { - throw new Error( - `The trace root "${traceBoundary}" must contain sourceDir "${resolvedSourceDir}".`, - ); - } - } - + const base = traceRoot === undefined ? "/" : path.resolve(appDir, traceRoot); const entryFiles = await findEntryFiles(sourceDir, entryFilter); const allEntryFiles = entryFiles.concat(includeEntries || []); - let tracingAppDir = appDir; - let tracingSourceDir = sourceDir; - let tracingEntryFiles = allEntryFiles; - if (traceRoot !== undefined) { - const resolvedAppDir = path.resolve(appDir); - tracingAppDir = await fse.realpath(resolvedAppDir); - const resolveTracingPath = (filePath: string) => - path.resolve( - tracingAppDir, - path.relative(resolvedAppDir, path.resolve(filePath)), - ); - tracingSourceDir = resolveTracingPath(sourceDir); - tracingEntryFiles = allEntryFiles.map(resolveTracingPath); - } - debug("trace files start"); const fileTrace = await traceFiles({ - entryFiles: tracingEntryFiles, - sourceDir: tracingSourceDir, + entryFiles: allEntryFiles, + sourceDir, cacheOptions: { ...cacheOptions, cacheDir: path.resolve(appDir, cacheOptions.cacheDir), @@ -134,12 +97,9 @@ export const nodeDepEmit = async ({ traceOptions, }); debug("trace files end"); - const currentProjectModules = path.join(tracingAppDir, "node_modules"); + const currentProjectModules = path.join(appDir, "node_modules"); // Because vercel/nft may find inaccurately, we limit the range of query of dependencies - const dependencySearchRoot = path.resolve( - tracingAppDir, - "../../../../../../", - ); + const dependencySearchRoot = path.resolve(appDir, "../../../../../../"); const packageJsonCache = new Map(); @@ -152,8 +112,8 @@ export const nodeDepEmit = async ({ const filePath = await resolveTracedPath(base, _path); if ( - isSubPath(tracingSourceDir, filePath) || - (isSubPath(tracingAppDir, filePath) && + isSubPath(sourceDir, filePath) || + (isSubPath(appDir, filePath) && !isSubPath(currentProjectModules, filePath)) ) { return; @@ -222,7 +182,7 @@ export const nodeDepEmit = async ({ parents, isDirectDep: parents.some((parent) => { return ( - isSubPath(tracingAppDir, parent) && + isSubPath(appDir, parent) && !isSubPath(currentProjectModules, parent) ); }), diff --git a/tests/nde.test.ts b/tests/nde.test.ts index 38e7fe3..927db4b 100644 --- a/tests/nde.test.ts +++ b/tests/nde.test.ts @@ -101,17 +101,13 @@ describe('trace root', () => { }); }); - it('uses the same canonical trace root for tracing and dependency copying', async () => { - await withTempDir(async tempDir => { - const traceRoot = path.join(tempDir, 'real-root'); - const linkedTraceRoot = path.join(tempDir, 'linked-root'); - const appDir = path.join(linkedTraceRoot, 'apps/app'); + it('uses the same relative trace root for tracing and dependency copying', async () => { + await withTempDir(async traceRoot => { + const appDir = path.join(traceRoot, 'apps/app'); const sourceDir = path.join(appDir, 'dist'); const dependencyDir = path.join(traceRoot, 'node_modules/test-dependency'); let tracedBase: string | undefined; - await fse.ensureDir(traceRoot); - await fse.symlink(traceRoot, linkedTraceRoot, 'dir'); await fse.outputJSON(path.join(dependencyDir, 'package.json'), { name: 'test-dependency', version: '1.0.0', @@ -146,26 +142,4 @@ describe('trace root', () => { }); }); - it('rejects source directories outside the trace root', async () => { - await withTempDir(async tempDir => { - const appDir = path.join(tempDir, 'app'); - const missingSourceDir = path.join(tempDir, 'missing-source'); - const traceFiles = async () => { - throw new Error('traceFiles should not be called'); - }; - - await fse.ensureDir(appDir); - await expect( - nodeDepEmit({ - appDir, - sourceDir: missingSourceDir, - traceRoot: '', - traceFiles, - }), - ).rejects.toThrow( - `The trace root "${appDir}" must contain sourceDir "${missingSourceDir}".`, - ); - }); - }); - }); From c4786af30e341d7481cf3519492b5b7d4caaee8c Mon Sep 17 00:00:00 2001 From: wangyiming Date: Mon, 27 Jul 2026 17:02:54 +0800 Subject: [PATCH 5/6] fix: reject entries outside trace root --- src/index.ts | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/index.ts b/src/index.ts index 79a36d2..9c46a01 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,6 +28,16 @@ export type { NodeFileTraceOptions } from "@vercel/nft"; export type { TransformPackageJsonHook } from "./utils"; export { nodeFileTrace } from "@vercel/nft"; +const isPathInsideOrEqual = (parentPath: string, childPath: string) => { + const relativePath = path.relative(parentPath, childPath); + return ( + relativePath === "" || + (!path.isAbsolute(relativePath) && + relativePath !== ".." && + !relativePath.startsWith(`..${path.sep}`)) + ); +}; + export const nodeDepEmit = async ({ appDir, sourceDir, @@ -82,8 +92,29 @@ export const nodeDepEmit = async ({ traceOptions?: NodeFileTraceOptions; }) => { const base = traceRoot === undefined ? "/" : path.resolve(appDir, traceRoot); + if ( + traceRoot !== undefined && + !isPathInsideOrEqual(base, path.resolve(sourceDir)) + ) { + throw new Error( + `The trace root "${base}" must contain sourceDir "${path.resolve(sourceDir)}".`, + ); + } + const entryFiles = await findEntryFiles(sourceDir, entryFilter); const allEntryFiles = entryFiles.concat(includeEntries || []); + if (traceRoot !== undefined) { + const outsideEntryFiles = allEntryFiles + .map((entryFile) => path.resolve(entryFile)) + .filter((entryFile) => !isPathInsideOrEqual(base, entryFile)); + if (outsideEntryFiles.length > 0) { + throw new Error( + `The trace root "${base}" must contain every entry file. Outside entries:\n${outsideEntryFiles + .map((entryFile) => `- "${entryFile}"`) + .join("\n")}`, + ); + } + } debug("trace files start"); const fileTrace = await traceFiles({ From b13437208f8f7f88f0f03bfa8992ea32b843f469 Mon Sep 17 00:00:00 2001 From: wangyiming Date: Mon, 27 Jul 2026 19:57:37 +0800 Subject: [PATCH 6/6] fix: canonicalize dependency trace paths Keep tracing, path restoration, and dependency classification on one canonical root while preserving non-base nft option overrides. --- .changeset/quiet-roots-trace.md | 7 ++- README.md | 10 ++--- src/index.ts | 79 ++++++++++++++++++++++++--------- src/utils.ts | 6 +-- 4 files changed, 69 insertions(+), 33 deletions(-) diff --git a/.changeset/quiet-roots-trace.md b/.changeset/quiet-roots-trace.md index b0c387c..6ec676f 100644 --- a/.changeset/quiet-roots-trace.md +++ b/.changeset/quiet-roots-trace.md @@ -2,7 +2,6 @@ 'ndepe': patch --- -Add a configurable dependency trace root and keep nft tracing and path -restoration aligned to the same boundary. -`traceOptions.base`, `traceOptions.processCwd`, and `traceOptions.cache` can no -longer override ndepe-managed values. +Add a configurable, canonical dependency trace root and keep nft tracing and +path restoration aligned to the same boundary. +`traceOptions.base` can no longer override the ndepe-managed trace root. diff --git a/README.md b/README.md index 26c566b..5da3c55 100644 --- a/README.md +++ b/README.md @@ -33,11 +33,11 @@ nodeDepEmit({ }) ``` -Relative paths are resolved from `appDir`; absolute paths are used directly. -An empty string resolves to `appDir`, and omitting `traceRoot` keeps the -existing `/` default. +Relative paths are resolved from `appDir`; absolute paths are used as the root +candidate. Configured roots are canonicalized before tracing. An empty string +resolves to `appDir`, and omitting `traceRoot` keeps the existing `/` default. The root must contain the application output and all runtime workspace packages and dependencies. It is an analysis boundary, not a security sandbox. Ndepe -manages `traceOptions.base`, `processCwd`, and `cache`; other nft options and -filesystem hooks are forwarded. +manages `traceOptions.base`; other nft options and filesystem hooks are +forwarded. diff --git a/src/index.ts b/src/index.ts index 9c46a01..54f17d7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -38,6 +38,22 @@ const isPathInsideOrEqual = (parentPath: string, childPath: string) => { ); }; +const resolveTracingPath = async ( + filePath: string, + traceBoundary: string, + base: string, +) => { + const resolvedPath = path.resolve(filePath); + return fse.realpath(resolvedPath).catch((error) => { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + return isPathInsideOrEqual(traceBoundary, resolvedPath) + ? path.resolve(base, path.relative(traceBoundary, resolvedPath)) + : resolvedPath; + }); +}; + export const nodeDepEmit = async ({ appDir, sourceDir, @@ -86,30 +102,48 @@ export const nodeDepEmit = async ({ */ traceRoot?: string; /** - * Options forwarded to nodeFileTrace. base, processCwd, and cache are - * managed by ndepe and cannot be overridden here. + * Options forwarded to nodeFileTrace. base is managed by ndepe and cannot + * be overridden here. */ - traceOptions?: NodeFileTraceOptions; + traceOptions?: Omit; }) => { - const base = traceRoot === undefined ? "/" : path.resolve(appDir, traceRoot); - if ( - traceRoot !== undefined && - !isPathInsideOrEqual(base, path.resolve(sourceDir)) - ) { - throw new Error( - `The trace root "${base}" must contain sourceDir "${path.resolve(sourceDir)}".`, + const traceBoundary = + traceRoot === undefined ? "/" : path.resolve(appDir, traceRoot); + + let base = traceBoundary; + let tracingAppDir = appDir; + let tracingSourceDir = sourceDir; + if (traceRoot !== undefined) { + base = await fse.realpath(traceBoundary); + [tracingAppDir, tracingSourceDir] = await Promise.all( + [appDir, sourceDir].map((filePath) => + resolveTracingPath(filePath, traceBoundary, base), + ), ); + + if (!isPathInsideOrEqual(base, tracingSourceDir)) { + throw new Error( + `The trace root "${traceBoundary}" must contain sourceDir "${path.resolve(sourceDir)}".`, + ); + } } const entryFiles = await findEntryFiles(sourceDir, entryFilter); const allEntryFiles = entryFiles.concat(includeEntries || []); + let tracingEntryFiles = allEntryFiles; if (traceRoot !== undefined) { - const outsideEntryFiles = allEntryFiles - .map((entryFile) => path.resolve(entryFile)) - .filter((entryFile) => !isPathInsideOrEqual(base, entryFile)); + tracingEntryFiles = await Promise.all( + allEntryFiles.map((entryFile) => + resolveTracingPath(entryFile, traceBoundary, base), + ), + ); + + const outsideEntryFiles = tracingEntryFiles.filter( + (entryFile) => !isPathInsideOrEqual(base, entryFile), + ); if (outsideEntryFiles.length > 0) { throw new Error( - `The trace root "${base}" must contain every entry file. Outside entries:\n${outsideEntryFiles + `The trace root "${traceBoundary}" must contain every entry file. Outside entries:\n${outsideEntryFiles .map((entryFile) => `- "${entryFile}"`) .join("\n")}`, ); @@ -118,8 +152,8 @@ export const nodeDepEmit = async ({ debug("trace files start"); const fileTrace = await traceFiles({ - entryFiles: allEntryFiles, - sourceDir, + entryFiles: tracingEntryFiles, + sourceDir: tracingSourceDir, cacheOptions: { ...cacheOptions, cacheDir: path.resolve(appDir, cacheOptions.cacheDir), @@ -128,9 +162,12 @@ export const nodeDepEmit = async ({ traceOptions, }); debug("trace files end"); - const currentProjectModules = path.join(appDir, "node_modules"); + const currentProjectModules = path.join(tracingAppDir, "node_modules"); // Because vercel/nft may find inaccurately, we limit the range of query of dependencies - const dependencySearchRoot = path.resolve(appDir, "../../../../../../"); + const dependencySearchRoot = path.resolve( + tracingAppDir, + "../../../../../../", + ); const packageJsonCache = new Map(); @@ -143,8 +180,8 @@ export const nodeDepEmit = async ({ const filePath = await resolveTracedPath(base, _path); if ( - isSubPath(sourceDir, filePath) || - (isSubPath(appDir, filePath) && + isSubPath(tracingSourceDir, filePath) || + (isSubPath(tracingAppDir, filePath) && !isSubPath(currentProjectModules, filePath)) ) { return; @@ -213,7 +250,7 @@ export const nodeDepEmit = async ({ parents, isDirectDep: parents.some((parent) => { return ( - isSubPath(appDir, parent) && + isSubPath(tracingAppDir, parent) && !isSubPath(currentProjectModules, parent) ); }), diff --git a/src/utils.ts b/src/utils.ts index e33a163..929ba4b 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -297,7 +297,7 @@ export const traceFiles = async ({ sourceDir: string; base?: string; cacheOptions: CacheOptions; - traceOptions?: NodeFileTraceOptions; + traceOptions?: Omit; }) => { const { cacheDir, fileCache, analysisCache, symlinkCache } = cacheOptions; const analysisCacheFile = path.join(cacheDir, "analysis-cache.json"); @@ -311,10 +311,10 @@ export const traceFiles = async ({ }; const res = await nodeFileTrace(entryFiles, { - ...traceOptions, - base, processCwd: sourceDir, cache, + ...traceOptions, + base, }); if (analysisCache || fileCache || symlinkCache) {