From 931e110005a7977ff9c25bfcd0e97d82f7a2b9e3 Mon Sep 17 00:00:00 2001 From: Aparajith24 Date: Sun, 23 Aug 2026 23:55:47 +0530 Subject: [PATCH] feat: narrow outFiles to workspace dependencies in npm/yarn monorepos In a monorepo using npm/yarn `workspaces`, outFiles previously fell back to scanning the entire opened folder for sourcemaps, since the debugged program already lives inside it. Detect a `workspaces` root and narrow outFiles to the debugged package plus its workspace-local dependencies instead. Fixes #1730 --- .../nodeConfigurationProvider.test.ts | 62 ++++++ .../nodeDebugConfigurationResolver.ts | 193 +++++++++++++++++- 2 files changed, 253 insertions(+), 2 deletions(-) diff --git a/src/test/extension/nodeConfigurationProvider.test.ts b/src/test/extension/nodeConfigurationProvider.test.ts index 76b42172a..3f7a03456 100644 --- a/src/test/extension/nodeConfigurationProvider.test.ts +++ b/src/test/extension/nodeConfigurationProvider.test.ts @@ -433,6 +433,68 @@ describe('NodeDebugConfigurationProvider', () => { }); }); + describe('workspaces outFiles', () => { + it('narrows to the debugged package and its workspace deps', async () => { + createFileTree(testFixturesDir, { + 'package.json': JSON.stringify({ name: 'root', workspaces: ['packages/*'] }), + packages: { + a: { + 'index.js': '', + 'package.json': JSON.stringify({ name: 'pkg-a', dependencies: { 'pkg-b': '^1.0.0' } }), + }, + b: { + 'index.js': '', + 'package.json': JSON.stringify({ name: 'pkg-b' }), + }, + c: { + 'index.js': '', + 'package.json': JSON.stringify({ name: 'pkg-c' }), + }, + }, + }); + + const result = await provider.resolveDebugConfiguration(folder, { + type: DebugType.Node, + name: '', + request: 'launch', + program: 'packages/a/index.js', + }); + + expect(result?.outFiles).to.deep.equal([ + '${workspaceFolder}/**/*.(m|c|)js', + '!**/node_modules/**', + '${workspaceFolder}/packages/a/**/*.js', + '!${workspaceFolder}/packages/a/**/node_modules/**', + '${workspaceFolder}/packages/b/**/*.js', + '!${workspaceFolder}/packages/b/**/node_modules/**', + ]); + }); + + it('does not narrow when there is no workspaces field', async () => { + createFileTree(testFixturesDir, { + 'package.json': JSON.stringify({ name: 'root' }), + packages: { + a: { + 'index.js': '', + 'package.json': JSON.stringify({ name: 'pkg-a' }), + }, + }, + }); + + const result = await provider.resolveDebugConfiguration(folder, { + type: DebugType.Node, + name: '', + request: 'launch', + program: 'packages/a/index.js', + }); + + expect(result?.outFiles).to.deep.equal([ + '${workspaceFolder}/**/*.(m|c|)js', + '!**/node_modules/**', + ]); + }); + }); + describe('deno', () => { it('fills in default deno options', async () => { const result = (await provider.resolveDebugConfiguration(folder, { diff --git a/src/ui/configuration/nodeDebugConfigurationResolver.ts b/src/ui/configuration/nodeDebugConfigurationResolver.ts index 201e8458b..f09a4fa9f 100644 --- a/src/ui/configuration/nodeDebugConfigurationResolver.ts +++ b/src/ui/configuration/nodeDebugConfigurationResolver.ts @@ -3,7 +3,7 @@ *--------------------------------------------------------*/ import * as l10n from '@vscode/l10n'; -import { promises as fs } from 'fs'; +import { Dirent, promises as fs } from 'fs'; import { inject, injectable } from 'inversify'; import * as path from 'path'; import * as vscode from 'vscode'; @@ -266,6 +266,13 @@ function getAbsoluteLocation(folder: vscode.WorkspaceFolder | undefined, relpath * * This used to narrow (#326), but I think this is undesirable behavior for * most users (vscode#142641), so now it only widens the `outFiles`. + * + * As an exception, when the workspace folder itself is an npm/yarn + * workspaces root, we narrow `outFiles` down to the package being debugged + * plus its workspace-local dependencies. Without this, debugging any + * package in a large monorepo ends up scanning the whole repo for source + * maps, since the program's folder is already "inside" the workspace + * folder and the widen-only logic above never kicks in (#1730). */ async function guessOutFiles( fsUtils: LocalFsUtils, @@ -286,7 +293,15 @@ async function guessOutFiles( programLocation = getAbsoluteLocation(folder, config.cwd); } - if (!programLocation || isSubpathOrEqualTo(folder.uri.fsPath, programLocation)) { + if (!programLocation) { + return; + } + + if (await tryGuessWorkspaceOutFiles(fsUtils, folder, programLocation, config)) { + return; + } + + if (isSubpathOrEqualTo(folder.uri.fsPath, programLocation)) { return; } @@ -310,6 +325,180 @@ async function guessOutFiles( } } +interface IWorkspacesPackageJson { + name?: string; + workspaces?: string[] | { packages?: string[] }; + dependencies?: Record; + devDependencies?: Record; + peerDependencies?: Record; +} + +async function readPackageJsonAt(dir: string): Promise { + try { + return JSON.parse(await fs.readFile(path.join(dir, 'package.json'), 'utf8')); + } catch { + return undefined; + } +} + +function getWorkspacePatterns(pkg: IWorkspacesPackageJson | undefined): string[] { + if (!pkg?.workspaces) { + return []; + } + + return Array.isArray(pkg.workspaces) ? pkg.workspaces : pkg.workspaces.packages ?? []; +} + +/** + * Expands a single npm/yarn `workspaces` glob entry into the workspace + * package directories it matches. Only literal paths and a single trailing + * `*` wildcard segment (e.g. `packages/*`) are supported, which covers the + * overwhelming majority of real-world workspace configs; anything more + * exotic (`**`, mid-pattern wildcards) is skipped rather than mishandled. + */ +async function expandWorkspacePattern(workspaceRoot: string, pattern: string): Promise { + const segments = pattern.split('/').filter(Boolean); + const wildcardIndex = segments.indexOf('*'); + + if (wildcardIndex === -1) { + const dir = path.join(workspaceRoot, ...segments); + return (await existsInjected(fs, path.join(dir, 'package.json'))) ? [dir] : []; + } + + if (wildcardIndex !== segments.length - 1 || segments.includes('**')) { + return []; + } + + const base = path.join(workspaceRoot, ...segments.slice(0, wildcardIndex)); + let entries: Dirent[]; + try { + entries = await fs.readdir(base, { withFileTypes: true }); + } catch { + return []; + } + + const dirs: string[] = []; + for (const entry of entries) { + if (!entry.isDirectory() || entry.name === 'node_modules' || entry.name.startsWith('.')) { + continue; + } + const dir = path.join(base, entry.name); + if (await existsInjected(fs, path.join(dir, 'package.json'))) { + dirs.push(dir); + } + } + + return dirs; +} + +/** + * Given the workspace packages' directories, returns the directory of the + * package being debugged plus the directories of any workspace-local + * package it transitively depends on. + */ +async function collectWorkspaceDependencyDirs( + packageDirs: string[], + pkgDir: string, +): Promise { + const graph = new Map(); + const nameByDir = new Map(); + + await Promise.all( + packageDirs.map(async dir => { + const pkg = await readPackageJsonAt(dir); + if (!pkg?.name) { + return; + } + const deps = Object.keys({ + ...pkg.dependencies, + ...pkg.devDependencies, + ...pkg.peerDependencies, + }); + graph.set(pkg.name, { dir, deps }); + nameByDir.set(dir, pkg.name); + }), + ); + + const included = new Set([pkgDir]); + const startName = nameByDir.get(pkgDir); + const queue = startName ? [startName] : []; + const visited = new Set(queue); + + while (queue.length) { + const node = graph.get(queue.shift()!); + if (!node) { + continue; + } + + included.add(node.dir); + for (const dep of node.deps) { + if (graph.has(dep) && !visited.has(dep)) { + visited.add(dep); + queue.push(dep); + } + } + } + + return [...included]; +} + +/** + * If the workspace folder is an npm/yarn workspaces root and the program + * being debugged lives in one of its packages, narrows `outFiles` to that + * package plus its workspace-local dependencies. Returns whether it + * applied (successfully or not -- either way the caller should not fall + * back to the widen-only logic once a workspaces root is found, since + * scanning the whole monorepo is the exact problem being avoided). + */ +async function tryGuessWorkspaceOutFiles( + fsUtils: LocalFsUtils, + folder: vscode.WorkspaceFolder, + programLocation: string, + config: ResolvingNodeLaunchConfiguration, +): Promise { + const patterns = getWorkspacePatterns(await readPackageJsonAt(folder.uri.fsPath)); + if (!patterns.length) { + return false; + } + + const pkgDir = await nearestDirectoryWhere( + programLocation, + async p => + !p.includes('node_modules') && (await fsUtils.exists(path.join(p, 'package.json'))) + ? p + : undefined, + ); + + if (!pkgDir || !isSubpathOrEqualTo(folder.uri.fsPath, pkgDir)) { + return false; + } + + const packageDirs = ( + await Promise.all(patterns.map(pattern => expandWorkspacePattern(folder.uri.fsPath, pattern))) + ).flat(); + + const dirs = await collectWorkspaceDependencyDirs(packageDirs, pkgDir); + const outFiles = [...baseDefaults.outFiles]; + let any = false; + for (const dir of dirs) { + const rel = forceForwardSlashes(path.relative(folder.uri.fsPath, dir)); + if (!rel.length || rel.startsWith('..')) { + continue; + } + outFiles.push( + `\${workspaceFolder}/${rel}/**/*.js`, + `!\${workspaceFolder}/${rel}/**/node_modules/**`, + ); + any = true; + } + + if (any) { + config.outFiles = outFiles; + } + + return true; +} + interface ITSConfig { compilerOptions?: { outDir: string;