Skip to content
Open
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
104 changes: 99 additions & 5 deletions src/npm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,106 @@ async function checkNPM(cancellationToken?: CancellationToken): Promise<void> {
}
}

async function mapSymlinkedDependencies(cwd: string, deps: string[]): Promise<string[]> {
// Build two maps:
// 1. resolved target → symlink path (for direct symlinks)
// 2. resolved target → symlink path (for prefix matching nested deps)
const symlinkMap = new Map<string, string>();
const targetMap = new Map<string, string>(); // for prefix-based replacement
const nodeModulesBase = path.join(cwd, 'node_modules');

try {
// Scan for symlinks at the top level
const entries = await fs.promises.readdir(nodeModulesBase, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(nodeModulesBase, entry.name);

// Check scoped packages
if (entry.isDirectory() && entry.name.startsWith('@')) {
try {
const scopedEntries = await fs.promises.readdir(fullPath, { withFileTypes: true });
for (const scopedEntry of scopedEntries) {
const scopedPath = path.join(fullPath, scopedEntry.name);
if (scopedEntry.isSymbolicLink()) {
try {
const target = await fs.promises.realpath(scopedPath);
symlinkMap.set(target, scopedPath);
targetMap.set(target, scopedPath);
} catch (e) {
// ignore broken symlinks
}
}
}
} catch (e) {
// ignore unreadable scoped dirs
}
} else if (entry.isSymbolicLink()) {
try {
const target = await fs.promises.realpath(fullPath);
symlinkMap.set(target, fullPath);
targetMap.set(target, fullPath);
} catch (e) {
// ignore broken symlinks
}
}
}
} catch (e) {
// ignore if node_modules doesn't exist
}

// Map each dependency: check direct symlink match, or prefix match for nested deps
const mapped = deps.map(dep => {
// Direct match
if (symlinkMap.has(dep)) {
return symlinkMap.get(dep)!;
}

// Prefix match: if dep is inside a known symlink target, replace the prefix
for (const [target, symlink] of targetMap) {
if (dep.startsWith(target + path.sep)) {
const suffix = dep.slice((target + path.sep).length);
return path.join(symlink, suffix);
}
}

return dep;
});

// Deduplicate: remove dependencies that are nested within another dependency's node_modules
// Only apply deduplication if we found symlinks, otherwise return mapped deps as-is
if (symlinkMap.size === 0) {
return mapped;
}

const sorted = mapped.sort();
const result = [];
for (const dep of sorted) {
// Check if this dep is inside any already-added dependency's node_modules
let isNested = false;
for (const existing of result) {
// Only consider it nested if the existing dep is a package (contains /node_modules/)
// and dep is inside that package's node_modules
if (existing.includes('/node_modules/') || existing.includes(path.sep + 'node_modules' + path.sep)) {
const nestedPath = path.join(existing, 'node_modules');
if (dep.startsWith(nestedPath + path.sep)) {
isNested = true;
break;
}
}
}
if (!isNested) {
result.push(dep);
}
}

return result;
}

function getNpmDependencies(cwd: string): Promise<string[]> {
return checkNPM()
.then(() =>
exec('npm list --production --parseable --depth=99999 --loglevel=error', { cwd, maxBuffer: 5000 * 1024 })
)
.then(({ stdout }) => stdout.split(/[\r\n]/).filter(dir => path.isAbsolute(dir)));
.then(() => exec('npm list --production --parseable --depth=99999 --loglevel=error', { cwd, maxBuffer: 5000 * 1024 }))
.then(({ stdout }) => stdout.split(/[\r\n]/).filter(dir => path.isAbsolute(dir)))
.then(deps => mapSymlinkedDependencies(cwd, deps));
}

interface YarnTreeNode {
Expand Down Expand Up @@ -192,7 +286,7 @@ async function getYarnDependencies(cwd: string, packagedDependencies?: string[])
};
deps.forEach(flatten);

return [...result];
return mapSymlinkedDependencies(cwd, [...result]);
}

export async function detectYarn(cwd: string): Promise<boolean> {
Expand Down
10 changes: 6 additions & 4 deletions src/package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1672,11 +1672,13 @@ async function collectAllFiles(
followSymlinks: boolean = true
): Promise<string[]> {
const deps = await getDependencies(cwd, dependencies, dependencyEntryPoints);
const promises = deps.map(dep =>
glob('**', { cwd: dep, nodir: true, follow: followSymlinks, dot: true, ignore: 'node_modules/**' }).then(files =>
const promises = deps.map((dep, index) => {
// Always follow symlinks for dependencies (e.g., npm link), but respect
// the followSymlinks option for the root package (index 0).
return glob('**', { cwd: dep, nodir: true, follow: index > 0 || followSymlinks, dot: true, ignore: 'node_modules/**' }).then(files =>
files.map(f => path.relative(cwd, path.join(dep, f))).map(f => f.replace(/\\/g, '/'))
)
);
);
});

return Promise.all(promises).then(util.flatten);
}
Expand Down