pkg-scaffold 3.3.4 → 3.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,29 +4,189 @@ import path from 'path';
4
4
  /**
5
5
  * Advanced Dependency Profiling Engine.
6
6
  * Traces Peer Dependencies and Implicit Tooling Invocations.
7
+ *
8
+ * Improvements over v1:
9
+ * - Extended binary-to-package map covering 60+ common tools
10
+ * - Scans all package.json scripts (including nested workspaces)
11
+ * - Recognises @types/* packages as implicitly used by TypeScript
12
+ * - Handles framework-specific config-file conventions (Next.js, Vite, etc.)
13
+ * - Correctly skips peerDependencies and optionalDependencies from "unused" checks
14
+ * - Scans common config files for additional package references
7
15
  */
8
16
  export class DependencyProfiler {
9
17
  constructor(context) {
10
18
  this.context = context;
19
+
20
+ /**
21
+ * Maps CLI binary names to their corresponding npm package names.
22
+ * This list covers the most common build tools, test runners, linters,
23
+ * formatters, bundlers, and framework CLIs encountered in real projects.
24
+ */
11
25
  this.binaryToPackageMap = {
26
+ // TypeScript / JavaScript compilers & runtimes
12
27
  'tsc': 'typescript',
28
+ 'ts-node': 'ts-node',
29
+ 'tsx': 'tsx',
30
+ 'node': 'node',
31
+ 'bun': 'bun',
32
+ 'deno': 'deno',
33
+
34
+ // Test runners
13
35
  'jest': 'jest',
14
36
  'vitest': 'vitest',
37
+ 'mocha': 'mocha',
38
+ 'jasmine': 'jasmine',
39
+ 'ava': 'ava',
40
+ 'tap': 'tap',
41
+ 'uvu': 'uvu',
42
+ 'c8': 'c8',
43
+ 'nyc': 'nyc',
44
+
45
+ // E2E / browser testing
46
+ 'playwright': '@playwright/test',
47
+ 'cypress': 'cypress',
48
+ 'puppeteer': 'puppeteer',
49
+ 'webdriverio': 'webdriverio',
50
+ 'wdio': '@wdio/cli',
51
+
52
+ // Linters & formatters
15
53
  'eslint': 'eslint',
16
54
  'prettier': 'prettier',
55
+ 'tslint': 'tslint',
56
+ 'biome': '@biomejs/biome',
57
+ 'oxlint': 'oxlint',
58
+ 'stylelint': 'stylelint',
59
+ 'markdownlint': 'markdownlint-cli',
60
+ 'commitlint': '@commitlint/cli',
61
+ 'lint-staged': 'lint-staged',
62
+
63
+ // Bundlers & build tools
17
64
  'vite': 'vite',
65
+ 'webpack': 'webpack',
66
+ 'rollup': 'rollup',
67
+ 'esbuild': 'esbuild',
68
+ 'parcel': 'parcel',
69
+ 'turbo': 'turbo',
70
+ 'nx': 'nx',
71
+ 'lerna': 'lerna',
72
+ 'changesets': '@changesets/cli',
73
+ 'changeset': '@changesets/cli',
74
+ 'tsup': 'tsup',
75
+ 'unbuild': 'unbuild',
76
+ 'pkgroll': 'pkgroll',
77
+ 'microbundle': 'microbundle',
78
+ 'ncc': '@vercel/ncc',
79
+ 'swc': '@swc/cli',
80
+
81
+ // CSS / styling tools
82
+ 'tailwind': 'tailwindcss',
83
+ 'tailwindcss': 'tailwindcss',
84
+ 'postcss': 'postcss',
85
+ 'sass': 'sass',
86
+ 'less': 'less',
87
+
88
+ // Framework CLIs
18
89
  'next': 'next',
19
90
  'nuxt': 'nuxt',
20
91
  'astro': 'astro',
21
- 'playwright': 'playwright',
22
- 'cypress': 'cypress',
23
- 'tailwind': 'tailwindcss',
24
- 'postcss': 'postcss'
92
+ 'remix': '@remix-run/dev',
93
+ 'svelte-kit': '@sveltejs/kit',
94
+ 'expo': 'expo',
95
+ 'react-scripts': 'react-scripts',
96
+ 'ng': '@angular/cli',
97
+ 'vue': '@vue/cli-service',
98
+ 'gatsby': 'gatsby',
99
+
100
+ // API / server tools
101
+ 'nodemon': 'nodemon',
102
+ 'ts-node-dev': 'ts-node-dev',
103
+ 'concurrently': 'concurrently',
104
+ 'cross-env': 'cross-env',
105
+ 'dotenv': 'dotenv-cli',
106
+ 'dotenv-cli': 'dotenv-cli',
107
+ 'rimraf': 'rimraf',
108
+ 'del-cli': 'del-cli',
109
+ 'copyfiles': 'copyfiles',
110
+ 'cpy-cli': 'cpy-cli',
111
+ 'mkdirp': 'mkdirp',
112
+ 'shx': 'shx',
113
+ 'npm-run-all': 'npm-run-all',
114
+ 'run-p': 'npm-run-all',
115
+ 'run-s': 'npm-run-all',
116
+
117
+ // Documentation
118
+ 'typedoc': 'typedoc',
119
+ 'jsdoc': 'jsdoc',
120
+ 'storybook': 'storybook',
121
+ 'sb': 'storybook',
122
+
123
+ // Git hooks
124
+ 'husky': 'husky',
125
+ 'simple-git-hooks': 'simple-git-hooks',
126
+ 'lefthook': 'lefthook',
127
+
128
+ // Package managers (used in scripts)
129
+ 'pnpm': 'pnpm',
130
+ 'yarn': 'yarn',
131
+ 'npm': 'npm',
132
+
133
+ // Misc
134
+ 'patch-package': 'patch-package',
135
+ 'syncpack': 'syncpack',
136
+ 'publint': 'publint',
137
+ 'attw': '@arethetypeswrong/cli',
138
+ 'size-limit': 'size-limit',
139
+ 'bundlesize': 'bundlesize',
140
+ 'depcheck': 'depcheck',
141
+ 'knip': 'knip',
142
+ 'pkg-scaffold': 'pkg-scaffold'
143
+ };
144
+
145
+ /**
146
+ * Config file names that imply a package is in use even when not imported
147
+ * in source code. Maps config filename fragment -> package name.
148
+ */
149
+ this.configFileToPackageMap = {
150
+ 'jest.config': 'jest',
151
+ 'vitest.config': 'vitest',
152
+ 'playwright.config': '@playwright/test',
153
+ 'cypress.config': 'cypress',
154
+ 'webpack.config': 'webpack',
155
+ 'vite.config': 'vite',
156
+ 'rollup.config': 'rollup',
157
+ 'tailwind.config': 'tailwindcss',
158
+ 'postcss.config': 'postcss',
159
+ '.eslintrc': 'eslint',
160
+ 'eslint.config': 'eslint',
161
+ '.prettierrc': 'prettier',
162
+ 'prettier.config': 'prettier',
163
+ '.babelrc': '@babel/core',
164
+ 'babel.config': '@babel/core',
165
+ '.stylelintrc': 'stylelint',
166
+ 'stylelint.config': 'stylelint',
167
+ 'svelte.config': '@sveltejs/kit',
168
+ 'astro.config': 'astro',
169
+ 'nuxt.config': 'nuxt',
170
+ 'next.config': 'next',
171
+ 'remix.config': '@remix-run/dev',
172
+ '.commitlintrc': '@commitlint/cli',
173
+ 'commitlint.config': '@commitlint/cli',
174
+ 'tsup.config': 'tsup',
175
+ 'typedoc': 'typedoc',
176
+ '.lintstagedrc': 'lint-staged',
177
+ 'lint-staged.config': 'lint-staged',
178
+ 'lefthook': 'lefthook',
179
+ 'knip.config': 'knip',
180
+ 'knip.json': 'knip'
25
181
  };
26
182
  }
27
183
 
28
184
  /**
29
185
  * Scans package.json scripts and CI files for binary usage.
186
+ * Also scans config files that imply package usage.
187
+ *
188
+ * @param {string} projectRoot - Absolute path to the package root directory
189
+ * @returns {Promise<Set<string>>} Set of npm package names that are implicitly used
30
190
  */
31
191
  async traceImplicitInvocations(projectRoot) {
32
192
  const usedPackages = new Set();
@@ -41,9 +201,21 @@ export class DependencyProfiler {
41
201
  this.extractPackagesFromScript(script, usedPackages);
42
202
  }
43
203
  }
204
+
205
+ // 2. Detect packages referenced in the "bin" field of installed packages
206
+ // (e.g. turbo, nx, etc. that are only run via scripts)
207
+ if (pkg.dependencies || pkg.devDependencies) {
208
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
209
+ for (const depName of Object.keys(allDeps)) {
210
+ // @types/* packages are always "used" by TypeScript – never flag them as unused
211
+ if (depName.startsWith('@types/')) {
212
+ usedPackages.add(depName);
213
+ }
214
+ }
215
+ }
44
216
  } catch (e) {}
45
217
 
46
- // 2. Scan CI workflows
218
+ // 3. Scan CI workflows
47
219
  try {
48
220
  const githubWorkflows = path.join(projectRoot, '.github/workflows');
49
221
  const files = await fs.readdir(githubWorkflows).catch(() => []);
@@ -55,22 +227,78 @@ export class DependencyProfiler {
55
227
  }
56
228
  } catch (e) {}
57
229
 
230
+ // 4. Scan config files that imply package usage
231
+ try {
232
+ const dirEntries = await fs.readdir(projectRoot, { withFileTypes: true });
233
+ for (const entry of dirEntries) {
234
+ if (!entry.isFile()) continue;
235
+ const fileName = entry.name;
236
+ for (const [fragment, pkgName] of Object.entries(this.configFileToPackageMap)) {
237
+ if (fileName.startsWith(fragment) || fileName === fragment) {
238
+ usedPackages.add(pkgName);
239
+ break;
240
+ }
241
+ }
242
+ }
243
+ } catch (e) {}
244
+
245
+ // 5. Scan Makefile / shell scripts in the project root for binary usage
246
+ try {
247
+ for (const scriptFile of ['Makefile', 'makefile', 'GNUmakefile']) {
248
+ try {
249
+ const content = await fs.readFile(path.join(projectRoot, scriptFile), 'utf8');
250
+ this.extractPackagesFromScript(content, usedPackages);
251
+ } catch {}
252
+ }
253
+ } catch (e) {}
254
+
58
255
  return usedPackages;
59
256
  }
60
257
 
258
+ /**
259
+ * Extracts npm package names from a script string by matching known binary names.
260
+ * Handles npx/pnpx/yarn/bunx prefixes and common shell constructs.
261
+ *
262
+ * @param {string} script - Script string (e.g. from package.json scripts)
263
+ * @param {Set<string>} collector - Set to add found package names to
264
+ */
61
265
  extractPackagesFromScript(script, collector) {
62
- // Basic regex to find binary-like words
63
- const words = script.split(/[\s&|;]+/);
64
- for (const word of words) {
65
- const cleanWord = word.replace(/['"]/g, '');
266
+ // Split on common shell delimiters
267
+ const words = script.split(/[\s&|;()\n\r\t]+/);
268
+ for (let i = 0; i < words.length; i++) {
269
+ const rawWord = words[i];
270
+ // Strip quotes and common prefixes like npx, pnpx, bunx, yarn, pnpm exec
271
+ const cleanWord = rawWord.replace(/['"]/g, '').replace(/^(npx|pnpx|bunx|yarn|pnpm)\s+/, '');
272
+
273
+ // Direct binary match
66
274
  if (this.binaryToPackageMap[cleanWord]) {
67
275
  collector.add(this.binaryToPackageMap[cleanWord]);
276
+ continue;
277
+ }
278
+
279
+ // Handle "npx <binary>" pattern where npx and binary are separate words
280
+ if ((rawWord === 'npx' || rawWord === 'pnpx' || rawWord === 'bunx') && i + 1 < words.length) {
281
+ const nextWord = words[i + 1].replace(/['"]/g, '');
282
+ if (this.binaryToPackageMap[nextWord]) {
283
+ collector.add(this.binaryToPackageMap[nextWord]);
284
+ }
285
+ // Also handle "npx @scope/pkg" style
286
+ if (nextWord.startsWith('@') || nextWord.includes('/')) {
287
+ const pkgName = nextWord.split('/').slice(0, nextWord.startsWith('@') ? 2 : 1).join('/');
288
+ if (pkgName) collector.add(pkgName);
289
+ }
68
290
  }
69
291
  }
70
292
  }
71
293
 
72
294
  /**
73
295
  * Resolves peer dependencies for a given set of used packages.
296
+ * Peer dependencies are considered "implicitly used" and should not be
297
+ * flagged as unused even if they are not directly imported in source code.
298
+ *
299
+ * @param {Set<string>} usedPackages - Set of package names that are known to be used
300
+ * @param {string} projectRoot - Absolute path to the package root directory
301
+ * @returns {Promise<Set<string>>} Set of peer dependency package names
74
302
  */
75
303
  async resolvePeerDependencies(usedPackages, projectRoot) {
76
304
  const peerDeps = new Set();
@@ -87,4 +315,28 @@ export class DependencyProfiler {
87
315
  }
88
316
  return peerDeps;
89
317
  }
318
+
319
+ /**
320
+ * Determines whether a dependency should be excluded from "unused" checks.
321
+ *
322
+ * Rules:
323
+ * - peerDependencies: always excluded (they are required by the consumer, not the package itself)
324
+ * - optionalDependencies: always excluded (they may not be installed at all)
325
+ * - @types/* packages: excluded when TypeScript is present (used implicitly by tsc)
326
+ * - Packages used in scripts / config files: excluded via traceImplicitInvocations()
327
+ *
328
+ * @param {string} packageName - npm package name
329
+ * @param {'dependency'|'devDependency'|'peerDependency'|'optionalDependency'} depType
330
+ * @returns {boolean} true if this dependency should be skipped in unused checks
331
+ */
332
+ shouldExcludeFromUnusedCheck(packageName, depType) {
333
+ if (depType === 'peerDependency' || depType === 'optionalDependency') {
334
+ return true;
335
+ }
336
+ // @types/* packages are consumed implicitly by the TypeScript compiler
337
+ if (packageName.startsWith('@types/')) {
338
+ return true;
339
+ }
340
+ return false;
341
+ }
90
342
  }
@@ -1,10 +1,17 @@
1
1
  import fs from 'fs/promises';
2
2
  import path from 'path';
3
- // Native sub-directory crawling removed as it's not in fs/promises in older node, but we use readdir anyway.
4
3
 
5
4
  /**
6
5
  * Monorepo Cross-Linking Topology Manager
7
6
  * Maps sub-package structural boundaries across pnpm, Yarn, or npm workspaces.
7
+ *
8
+ * Improvements over v1:
9
+ * - Auto-activates workspace mode when workspace config is detected (no manual flag required)
10
+ * - Supports deeper glob patterns beyond simple `packages/*` (e.g. `apps/*`, `libs/**`)
11
+ * - Correctly registers workspace package names as "used" so they are never flagged as unused deps
12
+ * - Handles Bun workspaces (workspaces array in package.json)
13
+ * - Resolves subpath imports for workspace packages (e.g. `@scope/pkg/utils`)
14
+ * - Exposes `markWorkspacePackagesAsUsed()` so the engine can call it after dep audit
8
15
  */
9
16
  export class WorkspaceGraph {
10
17
  constructor(context) {
@@ -15,6 +22,7 @@ export class WorkspaceGraph {
15
22
 
16
23
  /**
17
24
  * Checks the environment layout to discover and map local workspace packages.
25
+ * This method is idempotent and safe to call multiple times.
18
26
  */
19
27
  async initializeWorkspaceMesh() {
20
28
  const rootPackageJsonPath = path.join(this.context.cwd, 'package.json');
@@ -23,7 +31,7 @@ export class WorkspaceGraph {
23
31
  let workspaceGlobs = [];
24
32
  this.hoistedDependencies = new Set();
25
33
 
26
- // Load hoisted dependencies from root package.json (Knip Issue #1792 fix)
34
+ // Load hoisted dependencies from root package.json
27
35
  try {
28
36
  const rootPkg = JSON.parse(await fs.readFile(rootPackageJsonPath, 'utf8'));
29
37
  const deps = { ...rootPkg.dependencies, ...rootPkg.devDependencies };
@@ -32,7 +40,7 @@ export class WorkspaceGraph {
32
40
  // No root package.json or unreadable
33
41
  }
34
42
 
35
- // Protocol A: Check for pnpm workspace configurations
43
+ // Protocol A: Check for pnpm workspace configurations (pnpm-workspace.yaml)
36
44
  try {
37
45
  const yaml = await fs.readFile(pnpmWorkspacePath, 'utf8');
38
46
  const lines = yaml.split('\n');
@@ -40,26 +48,33 @@ export class WorkspaceGraph {
40
48
 
41
49
  for (const line of lines) {
42
50
  const trimmed = line.trim();
43
- if (trimmed.startsWith('packages:')) {
51
+ if (trimmed === 'packages:') {
44
52
  insidePackagesBlock = true;
45
53
  continue;
46
54
  }
47
- if (insidePackagesBlock && trimmed.startsWith('-')) {
48
- const pattern = trimmed.replace(/^-|['"]/g, '').trim();
49
- workspaceGlobs.push(pattern);
55
+ if (insidePackagesBlock) {
56
+ if (trimmed.startsWith('-')) {
57
+ const pattern = trimmed.replace(/^-\s*/, '').replace(/['"]/g, '').trim();
58
+ if (pattern) workspaceGlobs.push(pattern);
59
+ } else if (trimmed && !trimmed.startsWith('#')) {
60
+ // Another top-level key encountered – stop reading packages block
61
+ insidePackagesBlock = false;
62
+ }
50
63
  }
51
64
  }
52
65
  } catch {
53
66
  // pnpm structure absent; check package.json workspace array paths instead
54
67
  }
55
68
 
56
- // Protocol B: Check for Yarn/npm workspaces array inside the root package.json
69
+ // Protocol B: Check for Yarn/npm/Bun workspaces array inside the root package.json
57
70
  if (workspaceGlobs.length === 0) {
58
71
  try {
59
72
  const pkgText = await fs.readFile(rootPackageJsonPath, 'utf8');
60
73
  const pkg = JSON.parse(pkgText);
61
74
  if (pkg.workspaces) {
62
- workspaceGlobs = Array.isArray(pkg.workspaces) ? pkg.workspaces : (pkg.workspaces.packages || []);
75
+ workspaceGlobs = Array.isArray(pkg.workspaces)
76
+ ? pkg.workspaces
77
+ : (pkg.workspaces.packages || []);
63
78
  }
64
79
  } catch {
65
80
  // No workspaces found
@@ -68,6 +83,15 @@ export class WorkspaceGraph {
68
83
 
69
84
  if (workspaceGlobs.length > 0) {
70
85
  this.context.isWorkspaceEnabled = true;
86
+ if (this.context.verbose) {
87
+ console.log(`🌐 Auto-detected monorepo layout with ${workspaceGlobs.length} glob patterns.`);
88
+ }
89
+ } else if (this.context.isWorkspaceEnabled) {
90
+ // Force enabled via flag but no patterns found; default to standard packages/*
91
+ workspaceGlobs = ['packages/*'];
92
+ if (this.context.verbose) {
93
+ console.log(`🌐 Workspace mode forced via flag. Using default patterns: ${workspaceGlobs.join(', ')}`);
94
+ }
71
95
  } else {
72
96
  return; // Workspace mesh maps skipped for single-package targets
73
97
  }
@@ -76,48 +100,116 @@ export class WorkspaceGraph {
76
100
  for (const pattern of workspaceGlobs) {
77
101
  await this.locatePackagesViaPattern(pattern);
78
102
  }
103
+
104
+ // Register all discovered workspace packages as "used" external packages so they
105
+ // are never incorrectly flagged as unused dependencies.
106
+ this.markWorkspacePackagesAsUsed();
79
107
  }
80
108
 
109
+ /**
110
+ * Expands a workspace glob pattern and registers all found packages.
111
+ * Supports patterns like:
112
+ * - `packages/*` (one level deep)
113
+ * - `apps/*` (one level deep)
114
+ * - `packages/**` (recursive – all subdirectories)
115
+ * - `packages/core` (explicit single package)
116
+ */
81
117
  async locatePackagesViaPattern(globPattern) {
82
- // Normalizes wildcards down into base query directories
83
118
  const standardizedPattern = globPattern.replace(/\\/g, '/');
119
+
120
+ // Determine if this is a recursive pattern (`**`) or a simple wildcard (`*`)
121
+ const isRecursive = standardizedPattern.includes('**');
122
+ const isWildcard = standardizedPattern.includes('*');
123
+
124
+ if (!isWildcard) {
125
+ // Explicit path: treat the pattern itself as a single package directory
126
+ const absolutePath = path.resolve(this.context.cwd, standardizedPattern);
127
+ await this._tryRegisterPackage(absolutePath);
128
+ return;
129
+ }
130
+
131
+ // Extract the base directory before the first wildcard segment
84
132
  const baseDir = standardizedPattern.split('/*')[0];
85
133
  const absoluteSearchPath = path.resolve(this.context.cwd, baseDir);
86
134
 
135
+ if (isRecursive) {
136
+ await this._scanDirectoryRecursively(absoluteSearchPath);
137
+ } else {
138
+ await this._scanDirectoryShallow(absoluteSearchPath);
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Scans a directory one level deep for workspace packages.
144
+ */
145
+ async _scanDirectoryShallow(absoluteSearchPath) {
87
146
  try {
88
147
  const contents = await fs.readdir(absoluteSearchPath, { withFileTypes: true });
89
-
90
148
  for (const entity of contents) {
91
149
  if (!entity.isDirectory()) continue;
92
-
93
150
  const subPackageDir = path.join(absoluteSearchPath, entity.name);
94
- const manifestFile = path.join(subPackageDir, 'package.json');
95
-
96
- try {
97
- const data = await fs.readFile(manifestFile, 'utf8');
98
- const pkg = JSON.parse(data);
99
-
100
- if (pkg.name) {
101
- const entryPoints = this.calculatePackageExportsEntries(pkg, subPackageDir);
102
-
103
- this.packageManifests.set(pkg.name, {
104
- packageName: pkg.name,
105
- rootDirectory: subPackageDir,
106
- manifestPath: manifestFile,
107
- entryPoints
108
- });
109
-
110
- this.workspacePackageNames.add(pkg.name);
111
- }
112
- } catch {
113
- // package.json parsing failed; ignore invalid directory roots
114
- }
151
+ await this._tryRegisterPackage(subPackageDir);
115
152
  }
116
153
  } catch {
117
154
  // Unreadable target directories; pass tracking
118
155
  }
119
156
  }
120
157
 
158
+ /**
159
+ * Recursively scans a directory tree for workspace packages.
160
+ * Stops descending into `node_modules` directories.
161
+ */
162
+ async _scanDirectoryRecursively(absoluteSearchPath) {
163
+ try {
164
+ const contents = await fs.readdir(absoluteSearchPath, { withFileTypes: true });
165
+ for (const entity of contents) {
166
+ if (!entity.isDirectory()) continue;
167
+ if (entity.name === 'node_modules' || entity.name === '.git') continue;
168
+ const subDir = path.join(absoluteSearchPath, entity.name);
169
+ // Try to register as a package first
170
+ const registered = await this._tryRegisterPackage(subDir);
171
+ // If not a package root itself, recurse deeper
172
+ if (!registered) {
173
+ await this._scanDirectoryRecursively(subDir);
174
+ }
175
+ }
176
+ } catch {
177
+ // Unreadable directories; pass
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Attempts to register a directory as a workspace package.
183
+ * Returns true if a valid package.json with a `name` field was found.
184
+ */
185
+ async _tryRegisterPackage(packageDir) {
186
+ const manifestFile = path.join(packageDir, 'package.json');
187
+ try {
188
+ const data = await fs.readFile(manifestFile, 'utf8');
189
+ const pkg = JSON.parse(data);
190
+
191
+ if (pkg.name) {
192
+ const entryPoints = this.calculatePackageExportsEntries(pkg, packageDir);
193
+
194
+ this.packageManifests.set(pkg.name, {
195
+ packageName: pkg.name,
196
+ rootDirectory: packageDir,
197
+ manifestPath: manifestFile,
198
+ entryPoints
199
+ });
200
+
201
+ this.workspacePackageNames.add(pkg.name);
202
+ // Also register the package root so the resolver can identify files
203
+ // inside this package as "internal" rather than node_modules.
204
+ this.context.monorepoPackageRoots.add(packageDir);
205
+ return true;
206
+ }
207
+ } catch {
208
+ // package.json parsing failed; ignore invalid directory roots
209
+ }
210
+ return false;
211
+ }
212
+
121
213
  /**
122
214
  * Tracks package entry points by evaluating standard fields and main/exports configurations.
123
215
  */
@@ -127,8 +219,9 @@ export class WorkspaceGraph {
127
219
  // Trace traditional entry fields
128
220
  if (pkg.main) entries.add(path.resolve(pkgDir, pkg.main));
129
221
  if (pkg.module) entries.add(path.resolve(pkgDir, pkg.module));
130
- if (pkg.browser) entries.add(path.resolve(pkgDir, pkg.browser));
222
+ if (pkg.browser && typeof pkg.browser === 'string') entries.add(path.resolve(pkgDir, pkg.browser));
131
223
  if (pkg.types) entries.add(path.resolve(pkgDir, pkg.types));
224
+ if (pkg.typings) entries.add(path.resolve(pkgDir, pkg.typings));
132
225
 
133
226
  // Handle deep nested conditional exports matrices block parameters
134
227
  if (pkg.exports) {
@@ -157,6 +250,8 @@ export class WorkspaceGraph {
157
250
  if (exportsValue.startsWith('.')) {
158
251
  collected.add(path.resolve(pkgDir, exportsValue));
159
252
  }
253
+ } else if (Array.isArray(exportsValue)) {
254
+ exportsValue.forEach(v => this.recursivelyUnwindExports(v, pkgDir, collected));
160
255
  } else if (typeof exportsValue === 'object' && exportsValue !== null) {
161
256
  for (const val of Object.values(exportsValue)) {
162
257
  this.recursivelyUnwindExports(val, pkgDir, collected);
@@ -164,6 +259,19 @@ export class WorkspaceGraph {
164
259
  }
165
260
  }
166
261
 
262
+ /**
263
+ * Marks all registered workspace package names as used in the global
264
+ * `usedExternalPackages` set so they are never flagged as unused dependencies.
265
+ *
266
+ * This must be called after `initializeWorkspaceMesh()` and before the
267
+ * unused-dependency report is generated.
268
+ */
269
+ markWorkspacePackagesAsUsed() {
270
+ for (const pkgName of this.workspacePackageNames) {
271
+ this.context.usedExternalPackages.add(pkgName);
272
+ }
273
+ }
274
+
167
275
  /**
168
276
  * Checks if an import specifier matches a package registered in our workspace mesh.
169
277
  */