diff --git a/CHANGELOG.md b/CHANGELOG.md index 1815c4150..c2c05c4f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -133,6 +133,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- Local JavaScript and TypeScript calls stay connected through linked packages and imports configured by a nested `baseUrl` (#1715). + #### Screens, links and navigation - **Where the app goes after login is a fork, not two always-es.** A navigation whose destination comes back from a helper — `router.replace(await resolvePostLoginRoute())` over `return (await hasSeenWelcome(…)) ? '/home/' : '/welcome/'` — drew both screens with no condition, reading as if the welcome screen always shows. The two arms share a line, and only a column can tell them apart; each synthesized edge now carries its literal's own position, so the guard reader says which arm it is: `WHEN await hasSeenWelcome(…)` → home, and its negation → welcome. And the scan starts at the helper's body, so a literal-union return type — `Promise<'/welcome/' | '/home/'>`, whose routes are string literals too, written first — no longer stands in for the navigation itself. Re-index after upgrading to pick the positions up. @@ -201,6 +203,10 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). #### Symbols, tests and the viewer +- **A name imported from a package no longer fuzzy-matches a project symbol.** `import type { EvaluatedModules } from 'vite/module-runner'` names a symbol that is not in the graph at all, but the fuzzy fallback matched it by name anyway — and the match is case-insensitive, so on vitest it landed on the unrelated method `VitestMocker::evaluatedModules`. Fuzzy matching now declines when the call site's binding is a bare specifier (a builtin or an npm package); relative, alias, workspace and `link:`/`file:` imports still fall through, and only JS/TS is affected, since elsewhere a project's own modules are imported by absolute name too. Across vitest, svelte, vite and rollup this removed 46 wrong edges and added none. Re-index after upgrading. + +- **A name imported from a package no longer exact-matches a project symbol either.** `import { test } from 'vitest'` used to link every `test(...)` in a spec to whichever project file defined a function called `test` — on vite, a fixture, 1,747 times — and `import { resolve } from 'node:path'` to a plugin container's `resolve` method. The exact-name strategy now applies the same rule as the fuzzy one: a name bound to a builtin or an npm package binds to no other file's symbol. A definition in the same file still wins, as a local declaration shadows the import; and a name imported through an alias the resolver cannot see (`~utils`, `#types/x`, `$lib`) still reaches its local target by name, as before. Re-index after upgrading. + - **Files under an `e2e/` directory count as tests.** Their calls no longer appear as production callers in Steps, dead-code and test badges. - **Production code under a `samples` or `examples` package path is no longer treated as test code.** A Kotlin or Java project whose package path runs through `com/google/samples/…` (Now in Android, for one) had nearly every file counted as a fixture, so the Map opened on `build-logic`, the entry points hid the app, and dead-code and test badges were wrong. Only the project layout above a `src/` folder decides now; the package path below it never does. diff --git a/__tests__/exact-match-bare-import-binding.test.ts b/__tests__/exact-match-bare-import-binding.test.ts new file mode 100644 index 000000000..bafc43288 --- /dev/null +++ b/__tests__/exact-match-bare-import-binding.test.ts @@ -0,0 +1,112 @@ +/** + * The exact-name strategy has the same single-survivor trap the fuzzy one had + * (#1713): when a call site's own name comes from a bare import — `test` from + * `vitest`, `resolve` from `node:path` — the real target is not in the graph, + * and the one project symbol with that name must not inherit the reference. + * + * These drive the whole pipeline over source fixtures: a bare-import binding + * with exactly one same-named project definition routes through + * matchByExactName, which is the strategy under test. The alias cases pin the + * other half of the rule — a binding the resolver cannot follow is not thereby + * external, so its name match must survive. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import CodeGraph from '../src/index'; + +let tempDir: string; +let cg: CodeGraph | null = null; + +function project(files: Record): void { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-bare-exact-')); + for (const [rel, content] of Object.entries(files)) { + const abs = path.join(tempDir, rel); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, content); + } +} + +async function callTargets(caller: string): Promise { + cg = await CodeGraph.init(tempDir, { index: true }); + cg.resolveReferences(); + const from = cg.getNodesByKind('function').find((n) => n.name === caller)!; + expect(from).toBeDefined(); + return cg.getOutgoingEdges(from.id).filter((e) => e.kind === 'calls').map((e) => e.target); +} + +afterEach(() => { + cg?.close(); + cg = null; + fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +describe('exact-name matching declines a name bound to a bare import', () => { + it.each([ + ["import { resolve } from 'node:path';", 'resolve'], + ["import { resolve } from 'path';", 'resolve'], + ["import { resolve as joinPath } from 'node:path';", 'joinPath'], + ["import resolve from 'external-resolver';", 'resolve'], + ["import { resolve } from '@scope/external-resolver/deep';", 'resolve'], + ])('%s does not bind to the only project symbol of that name', async (declaration, name) => { + project({ + 'plugin.ts': `export function ${name}() { return 'plugin'; }`, + // A root directory must not turn the Node builtin path into a local import. + 'path/marker.ts': 'export const marker = true;', + 'config.ts': `${declaration}\nexport function configure() { return ${name}('src'); }`, + }); + const targets = await callTargets('configure'); + const wrong = cg!.getNodesByKind('function').find((n) => n.filePath === 'plugin.ts')!; + expect(wrong).toBeDefined(); + expect(targets).not.toContain(wrong.id); + const importEdges = cg!.getNodesByKind('file') + .concat(cg!.getNodesByKind('import')) + .filter((n) => n.filePath === 'config.ts') + .flatMap((n) => cg!.getOutgoingEdges(n.id)) + .filter((e) => e.target === wrong.id); + expect(importEdges).toEqual([]); + }); + + it('a same-file definition still shadows the import', async () => { + project({ + 'config.ts': + "import { resolve } from 'node:path';\n" + + "export function configure() {\n function resolve() { return 'local'; }\n return resolve();\n}", + }); + const targets = await callTargets('configure'); + const local = cg!.getNodesByKind('function').find((n) => n.name === 'resolve')!; + expect(local).toBeDefined(); + expect(targets).toContain(local.id); + }); +}); + +describe('a binding the resolver cannot follow is not thereby external', () => { + // `~utils` is a tsconfig `paths` alias in vite's playground/tsconfig.json — + // a nested tsconfig the alias loader never reads; `#lib/utils` a package.json + // `imports` subpath; `$lib` SvelteKit's. Each reaches its target by name only. + it.each(['~utils', '#lib/utils', '@/lib/utils', '$lib/utils', 'src/lib/utils', './generated/utils'])( + 'keeps the name match for a name imported from %s', + async (specifier) => { + project({ + 'lib/utils.ts': 'export function resolve(value: string) { return value; }', + 'config.ts': `import { resolve } from '${specifier}';\nexport function configure() { return resolve('src'); }`, + }); + const targets = await callTargets('configure'); + const target = cg!.getNodesByKind('function').find((n) => n.filePath === 'lib/utils.ts')!; + expect(target).toBeDefined(); + expect(targets).toContain(target.id); + } + ); + + it('keeps a name bound by no import at all', async () => { + project({ + 'lib/utils.ts': 'export function resolve(value: string) { return value; }', + 'config.ts': "export function configure() { return resolve('src'); }", + }); + const targets = await callTargets('configure'); + const target = cg!.getNodesByKind('function').find((n) => n.filePath === 'lib/utils.ts')!; + expect(targets).toContain(target.id); + }); +}); diff --git a/__tests__/fuzzy-bare-import-binding.test.ts b/__tests__/fuzzy-bare-import-binding.test.ts new file mode 100644 index 000000000..ff9ea17ae --- /dev/null +++ b/__tests__/fuzzy-bare-import-binding.test.ts @@ -0,0 +1,131 @@ +/** + * Filtering candidates until one survives does not make that survivor the + * target. When the call site's own name comes from a bare import — `resolve` + * from `node:path` — the real target is external and absent from the graph, so + * the last project symbol standing must not inherit the call. + * + * matchFuzzy is driven directly. Which strategy reaches a given ref depends on + * how many same-named symbols the repo holds and on what the earlier stages of + * matchReference make of them, so a source fixture pins the pipeline rather + * than this guard. On real trees the shape routes through fuzzy as a + * case-insensitive match — `EvaluatedModules` from `vite/module-runner` onto + * vitest's `VitestMocker::evaluatedModules`, `Bundle` from `magic-string` onto + * a svelte build script's `bundle`. + */ + +import { describe, it, expect } from 'vitest'; +import { matchFuzzy } from '../src/resolution/name-matcher'; +import type { Node } from '../src/types'; +import type { ImportMapping, ResolutionContext, UnresolvedRef } from '../src/resolution/types'; + +/** vite's `pluginContainer.ts:resolve` — the sole survivor of the filters. */ +const SURVIVOR: Node = { + id: 'm:resolve', + kind: 'method', + name: 'resolve', + qualifiedName: 'PluginContainer::resolve', + filePath: 'packages/vite/src/node/server/pluginContainer.ts', + language: 'typescript', + startLine: 10, + endLine: 20, + startColumn: 0, + endColumn: 0, + updatedAt: 0, +}; + +function contextWith( + imports: ImportMapping[], + candidate = SURVIVOR, + localLinkNames?: Set +): ResolutionContext { + return { + getWorkspacePackages: () => (localLinkNames ? { byName: new Map(), localLinkNames } : null), + getNodesInFile: () => [], + getNodesByName: () => [candidate], + getNodesByLowerName: () => [candidate], + getNodesByQualifiedName: () => [], + getNodesByKind: () => [], + fileExists: () => false, + readFile: () => null, + getFileLines: () => [], + getProjectRoot: () => '', + getAllFiles: () => [], + getImportMappings: () => imports, + } as unknown as ResolutionContext; +} + +const imported = (source: string): ImportMapping[] => [ + { localName: 'resolve', exportedName: 'resolve', source, isDefault: false, isNamespace: false }, +]; + +const callTo = (language: UnresolvedRef['language']): UnresolvedRef => ({ + fromNodeId: 'f:outDir', + referenceName: 'resolve', + referenceKind: 'calls', + line: 4, + column: 2, + filePath: 'playground/css/vite.config.js', + language, +}); + +describe('matchFuzzy declines a lone survivor bound to a bare import', () => { + it('declines a node: builtin', () => { + expect(matchFuzzy(callTo('javascript'), contextWith(imported('node:path')))).toBeNull(); + }); + + it('declines a bare npm specifier', () => { + expect(matchFuzzy(callTo('javascript'), contextWith(imported('rollup')))).toBeNull(); + }); + + it('still matches when the binding is a relative import the resolver could not follow', () => { + const res = matchFuzzy(callTo('javascript'), contextWith(imported('./generated/chunks'))); + expect(res?.targetNodeId).toBe('m:resolve'); + expect(res?.resolvedBy).toBe('fuzzy'); + }); + + // vite's playground/tsconfig.json declares `"paths": { "~utils": [...] }` — + // a nested tsconfig the alias loader never reads — so shape is the only + // signal that these are local. npm names cannot start with any of them. + it.each(['~utils', '~/utils', '#types/hmrPayload', '$lib/stores'])( + 'still matches a local specifier with no slash after its prefix: %s', + (source) => { + const res = matchFuzzy(callTo('javascript'), contextWith(imported(source))); + expect(res?.targetNodeId).toBe('m:resolve'); + expect(res?.resolvedBy).toBe('fuzzy'); + }, + ); + + // vitest's `test/browser/package.json` declares `"@vitest/bundled-lib": + // "link:./bundled-lib"`, a directory its `test/*` workspace globs do not + // reach, so the workspace map cannot vouch for the name and only the + // dependency protocol shows it is local. + it('still matches a link: dependency, which is local despite its package spelling', () => { + const linked = new Set(['@vitest/bundled-lib']); + const res = matchFuzzy( + callTo('javascript'), + contextWith(imported('@vitest/bundled-lib'), SURVIVOR, linked) + ); + expect(res?.targetNodeId).toBe('m:resolve'); + }); + + it('declines a scoped package that is not linked into the project', () => { + const linked = new Set(['@vitest/bundled-lib']); + expect( + matchFuzzy(callTo('javascript'), contextWith(imported('@vitest/mocker'), SURVIVOR, linked)) + ).toBeNull(); + }); + + it('still matches when the name is bound by no import at all', () => { + const res = matchFuzzy(callTo('javascript'), contextWith([])); + expect(res?.targetNodeId).toBe('m:resolve'); + }); + + it('leaves languages whose own modules are imported by absolute name alone', () => { + // `from os import path` and `from myapp.util import path` are the same + // shape, so the bare test cannot tell external from internal here. + const pythonRef = { ...callTo('python'), filePath: 'app/main.py' }; + const pythonNode = { ...SURVIVOR, language: 'python' as const }; + const res = matchFuzzy(pythonRef, contextWith(imported('os'), pythonNode)); + expect(res?.targetNodeId).toBe('m:resolve'); + }); +}); diff --git a/__tests__/local-import-bindings.test.ts b/__tests__/local-import-bindings.test.ts new file mode 100644 index 000000000..e333c41ff --- /dev/null +++ b/__tests__/local-import-bindings.test.ts @@ -0,0 +1,61 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; + +let root: string | undefined; +let cg: CodeGraph | undefined; +afterEach(() => { + cg?.close(); + cg = undefined; + if (root) fs.rmSync(root, { recursive: true, force: true }); + root = undefined; +}); + +async function expectLocalCall(files: Record) { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-local-binding-')); + for (const [file, source] of Object.entries(files)) { + const absolute = path.join(root, file); + fs.mkdirSync(path.dirname(absolute), { recursive: true }); + fs.writeFileSync(absolute, source); + } + cg = await CodeGraph.init(root, { index: true }); + cg.resolveReferences(); + const functions = cg.getNodesByKind('function'); + const caller = functions.find(n => n.name === 'configure')!; + const target = functions.find(n => n.name === 'localHelper')!; + expect(caller).toBeDefined(); + expect(target).toBeDefined(); + expect(cg.getOutgoingEdges(caller.id).filter(e => e.kind === 'calls').map(e => e.target)) + .toContain(target.id); +} + +describe('local import bindings survive external-package guards', () => { + it.each(['link:', 'file:'])('keeps a %s dependency outside workspace globs', async protocol => { + await expectLocalCall({ + 'package.json': JSON.stringify({ private: true, workspaces: ['packages/*'] }), + 'packages/app/package.json': JSON.stringify({ name: 'app', dependencies: { '@demo/local': `${protocol}./linked` } }), + 'packages/app/linked/package.json': JSON.stringify({ name: '@demo/local' }), + 'packages/app/linked/index.ts': 'export function localHelper() { return 1; }', + 'packages/app/main.ts': "import { localHelper } from '@demo/local'; export function configure() { return localHelper(); }", + }); + }); + + it.each(['link:', 'file:'])('keeps a root %s dependency subpath without workspaces', async protocol => { + await expectLocalCall({ + 'package.json': JSON.stringify({ dependencies: { '@demo/local': `${protocol}./linked` } }), + 'linked/package.json': JSON.stringify({ name: '@demo/local' }), + 'linked/utils.ts': 'export function localHelper() { return 1; }', + 'main.ts': "import { localHelper } from '@demo/local/utils'; export function configure() { return localHelper(); }", + }); + }); + + it('keeps a root directory import described by a nested baseUrl', async () => { + await expectLocalCall({ + 'lib/utils.ts': 'export function localHelper() { return 1; }', + 'consumer/tsconfig.json': JSON.stringify({ compilerOptions: { baseUrl: '..' } }), + 'consumer/main.ts': "import { localHelper } from 'lib/utils'; export function configure() { return localHelper(); }", + }); + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index c74d8f272..319e2c854 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -4,8 +4,10 @@ * Handles symbol name matching for reference resolution. */ +import { builtinModules } from 'module'; import { Language, Node } from '../types'; import { UnresolvedRef, ResolvedRef, ResolutionContext } from './types'; +import { resolveWorkspaceImport } from './workspace-packages'; /** * Ceiling on how many same-named definitions a FUZZY name-match strategy will @@ -388,6 +390,91 @@ function isLexicallyReachable( ); } +const NODE_BUILTIN_SPECIFIERS = new Set(builtinModules); +const ROOT_IMPORT_PATHS = new WeakMap>(); + +/** + * Whether the call site's own name is bound by an import of a BARE specifier — + * a Node builtin or an npm package. Such a binding names a symbol that is not + * in the graph at all, so no project node is the right target for it, however + * few candidates are left standing. That is the trap the name-based strategies + * fall into: filtering narrows a crowd of same-named symbols but says nothing + * about whether the true target was ever in the crowd, so when one survives it + * inherits the call. `import { resolve } from 'node:path'` is the case that + * matters — a common name, many project definitions, and the real target + * external. + * + * Relative, alias, and workspace imports are deliberately not treated this way: + * those point at project files, so a name match is a reasonable recovery when + * the import resolver could not follow the path. + * + * Only the JS/TS family is checked. There, a project-internal import is + * distinguishable by shape — it is relative, aliased, or a workspace member — + * so "bare" really does mean external. In Java, Kotlin, Go and Python a + * project's own modules are imported by absolute name too, and the same test + * would reject the internal case along with the external one. + */ +export function isBoundToBareImport(ref: UnresolvedRef, context: ResolutionContext): boolean { + if ( + ref.language !== 'typescript' && + ref.language !== 'javascript' && + ref.language !== 'tsx' && + ref.language !== 'jsx' && + ref.language !== 'arkts' + ) { + return false; + } + // Optional-called: a minimal context (tests, embedders) may not carry + // import mappings, and without them nothing is known to be bare. + const source = context + .getImportMappings?.(ref.filePath, ref.language) + ?.find((i) => i.localName === ref.referenceName)?.source; + if (source === undefined) return false; + if (source.startsWith('.') || source.startsWith('/')) return false; + // `~`, `#` and `$` cannot begin an npm package name, so the prefix alone + // proves a local binding and no resolver lookup is needed: `~utils` (a + // tsconfig `paths` entry, which a nested tsconfig the alias loader never + // reads still declares), `#types/hmrPayload` (a package.json `imports` + // subpath), `$lib/...` (SvelteKit). Matching only `~/` classed the slashless + // spellings as bare and sent real project edges out with the wrong ones. + if (source.startsWith('~') || source.startsWith('#') || source.startsWith('$')) return false; + if (source.startsWith('@/') || source.startsWith('src/')) return false; + const aliases = context.getProjectAliases?.(); + if (aliases?.patterns.some((p) => source.startsWith(p.prefix))) return false; + const workspaces = context.getWorkspacePackages?.(); + if (workspaces && resolveWorkspaceImport(source, workspaces)) return false; + // A `link:` / `file:` dependency is a directory in the project that no + // workspace glob need cover, so the workspace map above cannot see it: + // vitest imports `@vitest/bundled-lib` from `test/browser/bundled-lib`, + // which its `test/*` globs stop short of. The name is local even though it + // is spelled exactly like a scoped registry package. + if (workspaces?.localLinkNames?.has(packageNameOf(source))) return false; + // A nested tsconfig may define baseUrl while the project-root alias map + // knows nothing about it. A root path such as lib/utils is still local. + // Builtins keep their meaning even when a same-named directory exists. + if (!source.startsWith('node:') && !NODE_BUILTIN_SPECIFIERS.has(source)) { + const head = packageNameOf(source); + let memo = ROOT_IMPORT_PATHS.get(context); + if (!memo) { memo = new Map(); ROOT_IMPORT_PATHS.set(context, memo); } + let local = memo.get(head); + if (local === undefined) { + local = context.fileExists(head); + memo.set(head, local); + } + if (local) return false; + } + return true; +} + +/** + * The package a specifier names, without its subpath: `@scope/pkg/sub` → + * `@scope/pkg`, `pkg/sub` → `pkg`. Scoped names keep two segments. + */ +function packageNameOf(source: string): string { + const parts = source.split('/'); + return source.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]!; +} + /** * Try to resolve a reference by exact name match */ @@ -404,11 +491,22 @@ export function matchByExactName( // unresolved import refs each scored K same-named import candidates through // findBestMatch — O(K²) per package, the dominant cost of "Resolving refs" on // large import-heavy (front-end + back-end) repos (#915). - const candidates = applyLanguageGate(context.getNodesByName(ref.referenceName), ref) + let candidates = applyLanguageGate(context.getNodesByName(ref.referenceName), ref) .filter((n) => n.kind !== 'import') // Nested locals are only reachable from inside their container (#1230). .filter((n) => isLexicallyReachable(n, ref, context)); + // A name bound to a bare import (`import { test } from 'vitest'`) has its + // target outside the graph: no other file's `test` is it, however unique. + // A same-file definition stays eligible — a local declaration shadows the + // file-level import, and that is what the reference then means. + if ( + candidates.some((n) => n.filePath !== ref.filePath) && + isBoundToBareImport(ref, context) + ) { + candidates = candidates.filter((n) => n.filePath === ref.filePath); + } + if (candidates.length === 0) { return null; } @@ -1299,6 +1397,7 @@ function getInferScanStates(context: ResolutionContext): Map RegExp[]): RegExp[] { @@ -2405,6 +2504,7 @@ export function matchFuzzy( ref: UnresolvedRef, context: ResolutionContext ): ResolvedRef | null { + if (isBoundToBareImport(ref, context)) return null; const lowerName = ref.referenceName.toLowerCase(); // Use pre-built lowercase index for O(1) lookup instead of scanning all nodes diff --git a/src/resolution/workspace-packages.ts b/src/resolution/workspace-packages.ts index 386c3e45e..8f1a4362a 100644 --- a/src/resolution/workspace-packages.ts +++ b/src/resolution/workspace-packages.ts @@ -41,6 +41,15 @@ export interface WorkspacePackages { * list). Absent for npm/pnpm members (their index conventions cover it). */ entryByName?: Map; + /** + * Package names declared with a `link:` or `file:` specifier in the root or + * any member manifest (`"@vitest/bundled-lib": "link:./bundled-lib"`). Such + * a package lives in the project but need not sit under a workspace glob, + * so {@link resolveWorkspaceImport} cannot see it — this set exists only so + * a caller can tell that the NAME is project-local, and deliberately carries + * no directory, since resolving these is a separate change. + */ + localLinkNames?: Set; } /** @@ -55,15 +64,26 @@ export interface WorkspacePackages { export function loadWorkspacePackages(projectRoot: string): WorkspacePackages | null { const byName = new Map(); + const memberDirs: string[] = []; const patterns = readWorkspaceGlobs(projectRoot); for (const pattern of patterns) { for (const dir of expandWorkspaceGlob(projectRoot, pattern)) { + memberDirs.push(dir); const pkgName = readPackageName(path.join(projectRoot, dir)); // First declaration wins — workspace patterns are tried in order. if (pkgName && !byName.has(pkgName)) byName.set(pkgName, dir); } } + // A member may depend on a package that is inside the project but outside + // every workspace glob (vitest's `test/browser` declares `"@vitest/ + // bundled-lib": "link:./bundled-lib"`, and the globs stop at `test/*`). + // Reading each manifest we already opened for its name costs nothing more. + const localLinkNames = new Set(); + for (const dir of ['', ...memberDirs]) { + for (const dep of readLinkDepNames(path.join(projectRoot, dir))) localLinkNames.add(dep); + } + // HarmonyOS/OpenHarmony (ArkTS) modular projects: every module's // oh-package.json5 declares its local siblings as `"data": "file:../../ // core/data"` dependencies, and code then imports the bare name @@ -77,10 +97,14 @@ export function loadWorkspacePackages(projectRoot: string): WorkspacePackages | if (entry) entryByName.set(name, entry); } - if (byName.size === 0) return null; + if (byName.size === 0 && localLinkNames.size === 0) return null; - logDebug('workspace packages loaded', { count: byName.size }); - return { byName, entryByName: entryByName.size > 0 ? entryByName : undefined }; + logDebug('workspace packages loaded', { count: byName.size, linked: localLinkNames.size }); + return { + byName, + entryByName: entryByName.size > 0 ? entryByName : undefined, + localLinkNames: localLinkNames.size > 0 ? localLinkNames : undefined, + }; } /** @@ -313,6 +337,32 @@ function expandWorkspaceGlob(projectRoot: string, pattern: string): string[] { return out; } +/** + * Dependency names this manifest declares with a `link:` or `file:` specifier + * — the two protocols npm, yarn, pnpm and bun all read as "this package is a + * directory in the project", so the name is local however much it looks like + * a registry package. A missing or malformed manifest contributes nothing. + */ +function readLinkDepNames(dirAbs: string): string[] { + let pkg: Record; + try { + pkg = JSON.parse(fs.readFileSync(path.join(dirAbs, 'package.json'), 'utf-8')); + } catch { + return []; + } + const names: string[] = []; + for (const field of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) { + const deps = pkg?.[field]; + if (!deps || typeof deps !== 'object') continue; + for (const [name, spec] of Object.entries(deps as Record)) { + if (typeof spec === 'string' && (spec.startsWith('link:') || spec.startsWith('file:'))) { + names.push(name); + } + } + } + return names; +} + /** Read the `name` field from a member directory's package.json. */ function readPackageName(dirAbs: string): string | null { try {