From cda388d32f2140035bcf2fb02db0138a146311b4 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Wed, 18 Mar 2026 22:22:28 -0600 Subject: [PATCH 1/3] feat: resolve imports via package.json exports field Parse the exports field during bare-specifier resolution, supporting string shorthand, subpath maps, conditional exports (import/require/default), subpath wildcard patterns, and array fallbacks. Falls back to filesystem probing only when the exports field is absent. Also adds monorepo workspace resolution: detects workspace packages from pnpm-workspace.yaml, package.json workspaces, and lerna.json, resolves internal package imports (@myorg/utils) to source files, and assigns 0.95 confidence to workspace-resolved imports. Impact: 14 functions changed, 7 affected --- src/domain/graph/resolve.js | 314 +++++++++++++++++++++++++- tests/unit/resolve.test.js | 428 +++++++++++++++++++++++++++++++++++- 2 files changed, 739 insertions(+), 3 deletions(-) diff --git a/src/domain/graph/resolve.js b/src/domain/graph/resolve.js index 5a82a5c6..e88c4bf8 100644 --- a/src/domain/graph/resolve.js +++ b/src/domain/graph/resolve.js @@ -3,6 +3,304 @@ import path from 'node:path'; import { loadNative } from '../../infrastructure/native.js'; import { normalizePath } from '../../shared/constants.js'; +// ── package.json exports resolution ───────────────────────────────── + +/** Cache: packageDir → parsed exports field (or null) */ +const _exportsCache = new Map(); + +/** + * Parse a bare specifier into { packageName, subpath }. + * Scoped: "@scope/pkg/sub" → { packageName: "@scope/pkg", subpath: "./sub" } + * Plain: "pkg/sub" → { packageName: "pkg", subpath: "./sub" } + * No sub: "pkg" → { packageName: "pkg", subpath: "." } + */ +export function parseBareSpecifier(specifier) { + let packageName, rest; + if (specifier.startsWith('@')) { + const parts = specifier.split('/'); + if (parts.length < 2) return null; + packageName = `${parts[0]}/${parts[1]}`; + rest = parts.slice(2).join('/'); + } else { + const slashIdx = specifier.indexOf('/'); + if (slashIdx === -1) { + packageName = specifier; + rest = ''; + } else { + packageName = specifier.slice(0, slashIdx); + rest = specifier.slice(slashIdx + 1); + } + } + return { packageName, subpath: rest ? `./${rest}` : '.' }; +} + +/** + * Find the package directory for a given package name, starting from rootDir. + * Walks up node_modules directories. + */ +function findPackageDir(packageName, rootDir) { + let dir = rootDir; + while (true) { + const candidate = path.join(dir, 'node_modules', packageName); + if (fs.existsSync(path.join(candidate, 'package.json'))) return candidate; + const parent = path.dirname(dir); + if (parent === dir) return null; + dir = parent; + } +} + +/** + * Read and cache the exports field from a package's package.json. + * Returns the exports value or null. + */ +function getPackageExports(packageDir) { + if (_exportsCache.has(packageDir)) return _exportsCache.get(packageDir); + try { + const raw = fs.readFileSync(path.join(packageDir, 'package.json'), 'utf8'); + const pkg = JSON.parse(raw); + const exports = pkg.exports ?? null; + _exportsCache.set(packageDir, exports); + return exports; + } catch { + _exportsCache.set(packageDir, null); + return null; + } +} + +/** Condition names to try, in priority order. */ +const CONDITION_ORDER = ['import', 'require', 'default']; + +/** + * Resolve a conditional exports value (string, object with conditions, or array). + * Returns a string target or null. + */ +function resolveCondition(value) { + if (typeof value === 'string') return value; + if (Array.isArray(value)) { + for (const item of value) { + const r = resolveCondition(item); + if (r) return r; + } + return null; + } + if (value && typeof value === 'object') { + for (const cond of CONDITION_ORDER) { + if (cond in value) return resolveCondition(value[cond]); + } + return null; + } + return null; +} + +/** + * Match a subpath against an exports map key that uses a wildcard pattern. + * Key: "./lib/*" matches subpath "./lib/foo/bar" → substitution "foo/bar" + */ +function matchSubpathPattern(pattern, subpath) { + const starIdx = pattern.indexOf('*'); + if (starIdx === -1) return null; + const prefix = pattern.slice(0, starIdx); + const suffix = pattern.slice(starIdx + 1); + if (!subpath.startsWith(prefix)) return null; + if (suffix && !subpath.endsWith(suffix)) return null; + const matched = subpath.slice(prefix.length, suffix ? -suffix.length || undefined : undefined); + if (!suffix && subpath.length < prefix.length) return null; + return matched; +} + +/** + * Resolve a bare specifier through the package.json exports field. + * Returns an absolute path or null. + */ +export function resolveViaExports(specifier, rootDir) { + const parsed = parseBareSpecifier(specifier); + if (!parsed) return null; + + const packageDir = findPackageDir(parsed.packageName, rootDir); + if (!packageDir) return null; + + const exports = getPackageExports(packageDir); + if (exports == null) return null; + + const { subpath } = parsed; + + // Simple string exports: "exports": "./index.js" + if (typeof exports === 'string') { + if (subpath === '.') { + const resolved = path.resolve(packageDir, exports); + return fs.existsSync(resolved) ? resolved : null; + } + return null; + } + + // Array form at top level + if (Array.isArray(exports)) { + if (subpath === '.') { + const target = resolveCondition(exports); + if (target) { + const resolved = path.resolve(packageDir, target); + return fs.existsSync(resolved) ? resolved : null; + } + } + return null; + } + + if (typeof exports !== 'object') return null; + + // Determine if exports is a conditions object (no keys start with ".") + // or a subpath map (keys start with ".") + const keys = Object.keys(exports); + const isSubpathMap = keys.length > 0 && keys[0].startsWith('.'); + + if (!isSubpathMap) { + // Conditions object at top level → applies to "." subpath only + if (subpath === '.') { + const target = resolveCondition(exports); + if (target) { + const resolved = path.resolve(packageDir, target); + return fs.existsSync(resolved) ? resolved : null; + } + } + return null; + } + + // Subpath map: try exact match first, then pattern match + if (subpath in exports) { + const target = resolveCondition(exports[subpath]); + if (target) { + const resolved = path.resolve(packageDir, target); + return fs.existsSync(resolved) ? resolved : null; + } + } + + // Pattern matching (keys with *) + for (const [pattern, value] of Object.entries(exports)) { + if (!pattern.includes('*')) continue; + const matched = matchSubpathPattern(pattern, subpath); + if (matched == null) continue; + const rawTarget = resolveCondition(value); + if (!rawTarget) continue; + const target = rawTarget.replace(/\*/g, matched); + const resolved = path.resolve(packageDir, target); + if (fs.existsSync(resolved)) return resolved; + } + + return null; +} + +/** Clear the exports cache (for testing). */ +export function clearExportsCache() { + _exportsCache.clear(); +} + +// ── Monorepo workspace resolution ─────────────────────────────────── + +/** Cache: rootDir → Map */ +const _workspaceCache = new Map(); + +/** Set of resolved relative paths that came from workspace resolution. */ +const _workspaceResolvedPaths = new Set(); + +/** + * Set the workspace map for a given rootDir. + * Called by the build pipeline after detecting workspaces. + * @param {string} rootDir + * @param {Map} map + */ +export function setWorkspaces(rootDir, map) { + _workspaceCache.set(rootDir, map); +} + +/** + * Get workspace packages for a rootDir. Returns empty map if not set. + */ +function getWorkspaces(rootDir) { + return _workspaceCache.get(rootDir) || new Map(); +} + +/** + * Resolve a bare specifier through monorepo workspace packages. + * + * For "@myorg/utils" → finds the workspace package dir → resolves entry point. + * For "@myorg/utils/sub" → finds package dir → tries exports field → filesystem probe. + * + * @returns {string|null} Absolute path to resolved file, or null. + */ +export function resolveViaWorkspace(specifier, rootDir) { + const parsed = parseBareSpecifier(specifier); + if (!parsed) return null; + + const workspaces = getWorkspaces(rootDir); + if (workspaces.size === 0) return null; + + const info = workspaces.get(parsed.packageName); + if (!info) return null; + + // Root import ("@myorg/utils") — use the entry point + if (parsed.subpath === '.') { + // Try exports field first (reuses existing exports logic) + const exportsResult = resolveViaExports(specifier, rootDir); + if (exportsResult) return exportsResult; + // Fall back to workspace entry + return info.entry; + } + + // Subpath import ("@myorg/utils/helpers") — try exports, then filesystem probe + const exportsResult = resolveViaExports(specifier, rootDir); + if (exportsResult) return exportsResult; + + // Filesystem probe within the package directory + const subRel = parsed.subpath.slice(2); // strip "./" + const base = path.resolve(info.dir, subRel); + for (const ext of [ + '', + '.ts', + '.tsx', + '.js', + '.jsx', + '.mjs', + '/index.ts', + '/index.tsx', + '/index.js', + ]) { + const candidate = base + ext; + if (fs.existsSync(candidate)) return candidate; + } + + // Try src/ subdirectory (common monorepo convention) + const srcBase = path.resolve(info.dir, 'src', subRel); + for (const ext of [ + '', + '.ts', + '.tsx', + '.js', + '.jsx', + '.mjs', + '/index.ts', + '/index.tsx', + '/index.js', + ]) { + const candidate = srcBase + ext; + if (fs.existsSync(candidate)) return candidate; + } + + return null; +} + +/** + * Check if a resolved relative path was resolved via workspace detection. + * Used by computeConfidence to assign high confidence (0.95) to workspace imports. + */ +export function isWorkspaceResolved(resolvedPath) { + return _workspaceResolvedPaths.has(resolvedPath); +} + +/** Clear workspace caches (for testing). */ +export function clearWorkspaceCache() { + _workspaceCache.clear(); + _workspaceResolvedPaths.clear(); +} + // ── Alias format conversion ───────────────────────────────────────── /** @@ -60,7 +358,18 @@ function resolveImportPathJS(fromFile, importSource, rootDir, aliases) { const aliasResolved = resolveViaAlias(importSource, aliases, rootDir); if (aliasResolved) return normalizePath(path.relative(rootDir, aliasResolved)); } - if (!importSource.startsWith('.')) return importSource; + if (!importSource.startsWith('.')) { + // Workspace packages take priority over node_modules + const wsResolved = resolveViaWorkspace(importSource, rootDir); + if (wsResolved) { + const rel = normalizePath(path.relative(rootDir, wsResolved)); + _workspaceResolvedPaths.add(rel); + return rel; + } + const exportsResolved = resolveViaExports(importSource, rootDir); + if (exportsResolved) return normalizePath(path.relative(rootDir, exportsResolved)); + return importSource; + } const dir = path.dirname(fromFile); const resolved = path.resolve(dir, importSource); @@ -78,7 +387,6 @@ function resolveImportPathJS(fromFile, importSource, rootDir, aliases) { '.jsx', '.mjs', '.py', - '.pyi', '/index.ts', '/index.tsx', '/index.js', @@ -97,6 +405,8 @@ function computeConfidenceJS(callerFile, targetFile, importedFrom) { if (!targetFile || !callerFile) return 0.3; if (callerFile === targetFile) return 1.0; if (importedFrom === targetFile) return 1.0; + // Workspace-resolved imports get high confidence even across package boundaries + if (importedFrom && _workspaceResolvedPaths.has(importedFrom)) return 0.95; if (path.dirname(callerFile) === path.dirname(targetFile)) return 0.7; const callerParent = path.dirname(path.dirname(callerFile)); const targetParent = path.dirname(path.dirname(targetFile)); diff --git a/tests/unit/resolve.test.js b/tests/unit/resolve.test.js index d5e487b6..8f6227f8 100644 --- a/tests/unit/resolve.test.js +++ b/tests/unit/resolve.test.js @@ -7,13 +7,20 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; import { + clearExportsCache, + clearWorkspaceCache, computeConfidence, computeConfidenceJS, convertAliasesForNative, + isWorkspaceResolved, + parseBareSpecifier, resolveImportPathJS, resolveImportsBatch, + resolveViaExports, + resolveViaWorkspace, + setWorkspaces, } from '../../src/domain/graph/resolve.js'; // ─── Temp project setup ────────────────────────────────────────────── @@ -219,3 +226,422 @@ describe('resolveImportsBatch', () => { expect(result === null || result instanceof Map).toBe(true); }); }); + +// ─── parseBareSpecifier ────────────────────────────────────────────── + +describe('parseBareSpecifier', () => { + it('parses plain package with no subpath', () => { + expect(parseBareSpecifier('lodash')).toEqual({ packageName: 'lodash', subpath: '.' }); + }); + + it('parses plain package with subpath', () => { + expect(parseBareSpecifier('lodash/fp')).toEqual({ packageName: 'lodash', subpath: './fp' }); + }); + + it('parses scoped package with no subpath', () => { + expect(parseBareSpecifier('@scope/pkg')).toEqual({ packageName: '@scope/pkg', subpath: '.' }); + }); + + it('parses scoped package with subpath', () => { + expect(parseBareSpecifier('@scope/pkg/utils/deep')).toEqual({ + packageName: '@scope/pkg', + subpath: './utils/deep', + }); + }); + + it('returns null for bare @ with no slash', () => { + expect(parseBareSpecifier('@scope')).toBeNull(); + }); +}); + +// ─── resolveViaExports ─────────────────────────────────────────────── + +describe('resolveViaExports', () => { + let pkgRoot; + + beforeAll(() => { + clearExportsCache(); + // Create a fake node_modules structure inside tmpDir + pkgRoot = path.join(tmpDir, 'node_modules', 'test-pkg'); + fs.mkdirSync(path.join(pkgRoot, 'dist'), { recursive: true }); + fs.mkdirSync(path.join(pkgRoot, 'lib', 'utils'), { recursive: true }); + fs.writeFileSync(path.join(pkgRoot, 'dist', 'index.mjs'), 'export default 1;'); + fs.writeFileSync(path.join(pkgRoot, 'dist', 'index.cjs'), 'module.exports = 1;'); + fs.writeFileSync(path.join(pkgRoot, 'dist', 'helpers.mjs'), 'export const h = 1;'); + fs.writeFileSync(path.join(pkgRoot, 'lib', 'utils', 'deep.js'), 'export const d = 1;'); + }); + + afterEach(() => { + clearExportsCache(); + }); + + it('resolves string exports (shorthand)', () => { + fs.writeFileSync( + path.join(pkgRoot, 'package.json'), + JSON.stringify({ name: 'test-pkg', exports: './dist/index.mjs' }), + ); + const result = resolveViaExports('test-pkg', tmpDir); + expect(result).toBe(path.join(pkgRoot, 'dist', 'index.mjs')); + }); + + it('returns null for subpath when exports is a string', () => { + fs.writeFileSync( + path.join(pkgRoot, 'package.json'), + JSON.stringify({ name: 'test-pkg', exports: './dist/index.mjs' }), + ); + expect(resolveViaExports('test-pkg/helpers', tmpDir)).toBeNull(); + }); + + it('resolves conditional exports (import/require/default)', () => { + fs.writeFileSync( + path.join(pkgRoot, 'package.json'), + JSON.stringify({ + name: 'test-pkg', + exports: { + '.': { import: './dist/index.mjs', require: './dist/index.cjs' }, + }, + }), + ); + const result = resolveViaExports('test-pkg', tmpDir); + expect(result).toBe(path.join(pkgRoot, 'dist', 'index.mjs')); + }); + + it('falls back to require when import is absent', () => { + fs.writeFileSync( + path.join(pkgRoot, 'package.json'), + JSON.stringify({ + name: 'test-pkg', + exports: { + '.': { require: './dist/index.cjs' }, + }, + }), + ); + const result = resolveViaExports('test-pkg', tmpDir); + expect(result).toBe(path.join(pkgRoot, 'dist', 'index.cjs')); + }); + + it('resolves subpath exports', () => { + fs.writeFileSync( + path.join(pkgRoot, 'package.json'), + JSON.stringify({ + name: 'test-pkg', + exports: { + '.': './dist/index.mjs', + './helpers': './dist/helpers.mjs', + }, + }), + ); + const result = resolveViaExports('test-pkg/helpers', tmpDir); + expect(result).toBe(path.join(pkgRoot, 'dist', 'helpers.mjs')); + }); + + it('resolves subpath patterns with wildcard', () => { + fs.writeFileSync( + path.join(pkgRoot, 'package.json'), + JSON.stringify({ + name: 'test-pkg', + exports: { + '.': './dist/index.mjs', + './lib/*': './lib/*.js', + }, + }), + ); + const result = resolveViaExports('test-pkg/lib/utils/deep', tmpDir); + expect(result).toBe(path.join(pkgRoot, 'lib', 'utils', 'deep.js')); + }); + + it('resolves conditional subpath exports', () => { + fs.writeFileSync( + path.join(pkgRoot, 'package.json'), + JSON.stringify({ + name: 'test-pkg', + exports: { + './helpers': { import: './dist/helpers.mjs', default: './dist/helpers.mjs' }, + }, + }), + ); + const result = resolveViaExports('test-pkg/helpers', tmpDir); + expect(result).toBe(path.join(pkgRoot, 'dist', 'helpers.mjs')); + }); + + it('resolves top-level conditions object (no . keys)', () => { + fs.writeFileSync( + path.join(pkgRoot, 'package.json'), + JSON.stringify({ + name: 'test-pkg', + exports: { import: './dist/index.mjs', require: './dist/index.cjs' }, + }), + ); + const result = resolveViaExports('test-pkg', tmpDir); + expect(result).toBe(path.join(pkgRoot, 'dist', 'index.mjs')); + }); + + it('returns null when exports field is absent', () => { + fs.writeFileSync( + path.join(pkgRoot, 'package.json'), + JSON.stringify({ name: 'test-pkg', main: './dist/index.mjs' }), + ); + expect(resolveViaExports('test-pkg', tmpDir)).toBeNull(); + }); + + it('returns null when package is not in node_modules', () => { + expect(resolveViaExports('nonexistent-pkg', tmpDir)).toBeNull(); + }); +}); + +// ─── resolveImportPathJS with exports ──────────────────────────────── + +describe('resolveImportPathJS with package.json exports', () => { + let pkgRoot; + + beforeAll(() => { + clearExportsCache(); + pkgRoot = path.join(tmpDir, 'node_modules', 'exports-pkg'); + fs.mkdirSync(path.join(pkgRoot, 'dist'), { recursive: true }); + fs.writeFileSync(path.join(pkgRoot, 'dist', 'main.mjs'), 'export default 1;'); + fs.writeFileSync( + path.join(pkgRoot, 'package.json'), + JSON.stringify({ + name: 'exports-pkg', + exports: { '.': './dist/main.mjs' }, + }), + ); + }); + + afterEach(() => { + clearExportsCache(); + }); + + it('resolves bare specifier through exports field', () => { + const fromFile = path.join(tmpDir, 'src', 'index.js'); + const result = resolveImportPathJS(fromFile, 'exports-pkg', tmpDir, null); + expect(result).toContain('node_modules/exports-pkg/dist/main.mjs'); + }); + + it('still passes through bare specifiers without exports', () => { + const fromFile = path.join(tmpDir, 'src', 'index.js'); + const result = resolveImportPathJS(fromFile, 'lodash', tmpDir, null); + expect(result).toBe('lodash'); + }); +}); + +// ─── resolveViaWorkspace ───────────────────────────────────────────── + +describe('resolveViaWorkspace', () => { + let wsRoot; + + beforeAll(() => { + wsRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ws-')); + // Create a monorepo structure: + // packages/core/src/index.js + // packages/core/src/helpers.js + // packages/core/package.json { name: "@myorg/core", main: "./src/index.js" } + // packages/utils/src/index.ts + // packages/utils/package.json { name: "@myorg/utils" } + fs.mkdirSync(path.join(wsRoot, 'packages', 'core', 'src'), { recursive: true }); + fs.mkdirSync(path.join(wsRoot, 'packages', 'utils', 'src'), { recursive: true }); + fs.writeFileSync(path.join(wsRoot, 'packages', 'core', 'src', 'index.js'), 'export default 1;'); + fs.writeFileSync( + path.join(wsRoot, 'packages', 'core', 'src', 'helpers.js'), + 'export const h = 1;', + ); + fs.writeFileSync( + path.join(wsRoot, 'packages', 'core', 'package.json'), + JSON.stringify({ name: '@myorg/core', main: './src/index.js' }), + ); + fs.writeFileSync( + path.join(wsRoot, 'packages', 'utils', 'src', 'index.ts'), + 'export default 1;', + ); + fs.writeFileSync( + path.join(wsRoot, 'packages', 'utils', 'package.json'), + JSON.stringify({ name: '@myorg/utils' }), + ); + + // Register workspaces + setWorkspaces( + wsRoot, + new Map([ + [ + '@myorg/core', + { + dir: path.join(wsRoot, 'packages', 'core'), + entry: path.join(wsRoot, 'packages', 'core', 'src', 'index.js'), + }, + ], + [ + '@myorg/utils', + { + dir: path.join(wsRoot, 'packages', 'utils'), + entry: path.join(wsRoot, 'packages', 'utils', 'src', 'index.ts'), + }, + ], + ]), + ); + }); + + afterAll(() => { + clearWorkspaceCache(); + if (wsRoot) fs.rmSync(wsRoot, { recursive: true, force: true }); + }); + + afterEach(() => { + clearExportsCache(); + }); + + it('resolves root import to workspace entry point', () => { + const result = resolveViaWorkspace('@myorg/core', wsRoot); + expect(result).toBe(path.join(wsRoot, 'packages', 'core', 'src', 'index.js')); + }); + + it('resolves root import for package without main (index fallback)', () => { + const result = resolveViaWorkspace('@myorg/utils', wsRoot); + expect(result).toBe(path.join(wsRoot, 'packages', 'utils', 'src', 'index.ts')); + }); + + it('resolves subpath import via filesystem probe', () => { + const result = resolveViaWorkspace('@myorg/core/src/helpers', wsRoot); + expect(result).toBe(path.join(wsRoot, 'packages', 'core', 'src', 'helpers.js')); + }); + + it('resolves subpath import via src/ convention', () => { + const result = resolveViaWorkspace('@myorg/core/helpers', wsRoot); + expect(result).toBe(path.join(wsRoot, 'packages', 'core', 'src', 'helpers.js')); + }); + + it('returns null for unknown package', () => { + expect(resolveViaWorkspace('@myorg/unknown', wsRoot)).toBeNull(); + }); + + it('returns null for non-existent subpath', () => { + expect(resolveViaWorkspace('@myorg/core/nonexistent', wsRoot)).toBeNull(); + }); +}); + +// ─── resolveImportPathJS with workspaces ───────────────────────────── + +describe('resolveImportPathJS with workspace resolution', () => { + let wsRoot; + + beforeAll(() => { + wsRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ws-resolve-')); + fs.mkdirSync(path.join(wsRoot, 'packages', 'lib', 'src'), { recursive: true }); + fs.mkdirSync(path.join(wsRoot, 'apps', 'web', 'src'), { recursive: true }); + fs.writeFileSync( + path.join(wsRoot, 'packages', 'lib', 'src', 'index.js'), + 'export const add = (a, b) => a + b;', + ); + fs.writeFileSync( + path.join(wsRoot, 'packages', 'lib', 'package.json'), + JSON.stringify({ name: '@myorg/lib', main: './src/index.js' }), + ); + fs.writeFileSync( + path.join(wsRoot, 'apps', 'web', 'src', 'app.js'), + 'import { add } from "@myorg/lib";', + ); + + setWorkspaces( + wsRoot, + new Map([ + [ + '@myorg/lib', + { + dir: path.join(wsRoot, 'packages', 'lib'), + entry: path.join(wsRoot, 'packages', 'lib', 'src', 'index.js'), + }, + ], + ]), + ); + }); + + afterAll(() => { + clearWorkspaceCache(); + if (wsRoot) fs.rmSync(wsRoot, { recursive: true, force: true }); + }); + + it('resolves workspace package import to source file', () => { + const fromFile = path.join(wsRoot, 'apps', 'web', 'src', 'app.js'); + const result = resolveImportPathJS(fromFile, '@myorg/lib', wsRoot, null); + expect(result).toBe('packages/lib/src/index.js'); + }); + + it('marks workspace-resolved paths for confidence boost', () => { + const fromFile = path.join(wsRoot, 'apps', 'web', 'src', 'app.js'); + clearWorkspaceCache(); + setWorkspaces( + wsRoot, + new Map([ + [ + '@myorg/lib', + { + dir: path.join(wsRoot, 'packages', 'lib'), + entry: path.join(wsRoot, 'packages', 'lib', 'src', 'index.js'), + }, + ], + ]), + ); + resolveImportPathJS(fromFile, '@myorg/lib', wsRoot, null); + expect(isWorkspaceResolved('packages/lib/src/index.js')).toBe(true); + }); +}); + +// ─── computeConfidenceJS with workspace boost ──────────────────────── + +describe('computeConfidenceJS workspace confidence', () => { + let wsRoot; + + beforeAll(() => { + wsRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ws-conf-')); + fs.mkdirSync(path.join(wsRoot, 'packages', 'lib', 'src'), { recursive: true }); + fs.mkdirSync(path.join(wsRoot, 'apps', 'web', 'src'), { recursive: true }); + fs.writeFileSync( + path.join(wsRoot, 'packages', 'lib', 'src', 'index.js'), + 'export const x = 1;', + ); + fs.writeFileSync( + path.join(wsRoot, 'packages', 'lib', 'package.json'), + JSON.stringify({ name: '@myorg/lib', main: './src/index.js' }), + ); + fs.writeFileSync(path.join(wsRoot, 'apps', 'web', 'src', 'app.js'), 'import "@myorg/lib";'); + + setWorkspaces( + wsRoot, + new Map([ + [ + '@myorg/lib', + { + dir: path.join(wsRoot, 'packages', 'lib'), + entry: path.join(wsRoot, 'packages', 'lib', 'src', 'index.js'), + }, + ], + ]), + ); + + // Trigger resolution to populate _workspaceResolvedPaths + const fromFile = path.join(wsRoot, 'apps', 'web', 'src', 'app.js'); + resolveImportPathJS(fromFile, '@myorg/lib', wsRoot, null); + }); + + afterAll(() => { + clearWorkspaceCache(); + if (wsRoot) fs.rmSync(wsRoot, { recursive: true, force: true }); + }); + + it('returns 0.95 confidence for workspace-resolved imports', () => { + const conf = computeConfidenceJS( + 'apps/web/src/app.js', + 'packages/lib/src/utils.js', + 'packages/lib/src/index.js', + ); + expect(conf).toBe(0.95); + }); + + it('returns normal confidence for non-workspace imports', () => { + const conf = computeConfidenceJS( + 'apps/web/src/app.js', + 'some/distant/file.js', + 'some/other/import.js', + ); + expect(conf).toBeLessThan(0.95); + }); +}); From fc5732832818db28f0a7524d56e2d1e89315e2bd Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Wed, 18 Mar 2026 22:22:41 -0600 Subject: [PATCH 2/3] feat: detect monorepo workspaces and wire into build pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add detectWorkspaces() to config.js — reads pnpm-workspace.yaml, package.json workspaces (npm/yarn), and lerna.json to enumerate workspace packages with their entry points. Pipeline calls it during setup and registers the workspace map for import resolution. Impact: 5 functions changed, 3 affected --- src/domain/graph/builder/pipeline.js | 10 +- src/infrastructure/config.js | 176 +++++++++++++++++++++++++++ tests/unit/config.test.js | 128 +++++++++++++++++++ 3 files changed, 313 insertions(+), 1 deletion(-) diff --git a/src/domain/graph/builder/pipeline.js b/src/domain/graph/builder/pipeline.js index 963a0086..0343e255 100644 --- a/src/domain/graph/builder/pipeline.js +++ b/src/domain/graph/builder/pipeline.js @@ -7,9 +7,10 @@ import path from 'node:path'; import { performance } from 'node:perf_hooks'; import { closeDb, getBuildMeta, initSchema, MIGRATIONS, openDb } from '../../../db/index.js'; -import { loadConfig } from '../../../infrastructure/config.js'; +import { detectWorkspaces, loadConfig } from '../../../infrastructure/config.js'; import { info } from '../../../infrastructure/logger.js'; import { getActiveEngine } from '../../parser.js'; +import { setWorkspaces } from '../resolve.js'; import { PipelineContext } from './context.js'; import { loadPathAliases } from './helpers.js'; import { buildEdges } from './stages/build-edges.js'; @@ -86,6 +87,13 @@ function setupPipeline(ctx) { checkEngineSchemaMismatch(ctx); loadAliases(ctx); + // Workspace packages (monorepo) + const workspaces = detectWorkspaces(ctx.rootDir); + if (workspaces.size > 0) { + setWorkspaces(ctx.rootDir, workspaces); + info(`Detected ${workspaces.size} workspace packages`); + } + ctx.timing.setupMs = performance.now() - ctx.buildStart; } diff --git a/src/infrastructure/config.js b/src/infrastructure/config.js index 1ab75e47..49937fe2 100644 --- a/src/infrastructure/config.js +++ b/src/infrastructure/config.js @@ -120,6 +120,182 @@ export function resolveSecrets(config) { return config; } +// ── Monorepo workspace detection ───────────────────────────────────── + +/** + * Expand a workspace glob pattern into matching directories. + * Supports trailing `/*` or `/**` patterns (e.g. "packages/*"). + * Does not depend on an external glob library — uses fs.readdirSync. + */ +function expandWorkspaceGlob(pattern, rootDir) { + // Strip trailing /*, /**, or just * + const clean = pattern.replace(/\/?\*\*?$/, ''); + const baseDir = path.resolve(rootDir, clean); + if (!fs.existsSync(baseDir)) return []; + try { + const entries = fs.readdirSync(baseDir, { withFileTypes: true }); + return entries + .filter((e) => e.isDirectory()) + .map((e) => path.join(baseDir, e.name)) + .filter((d) => fs.existsSync(path.join(d, 'package.json'))); + } catch { + return []; + } +} + +/** + * Read a package.json and return its name field, or null. + */ +function readPackageName(pkgDir) { + try { + const raw = fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf-8'); + const pkg = JSON.parse(raw); + return pkg.name || null; + } catch { + return null; + } +} + +/** + * Resolve the entry-point source file for a workspace package. + * Checks exports → main → index file fallback. + */ +function resolveWorkspaceEntry(pkgDir) { + try { + const raw = fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf-8'); + const pkg = JSON.parse(raw); + + // Try "source" field first (common in monorepos for pre-built packages) + if (pkg.source) { + const s = path.resolve(pkgDir, pkg.source); + if (fs.existsSync(s)) return s; + } + + // Try "main" field + if (pkg.main) { + const m = path.resolve(pkgDir, pkg.main); + if (fs.existsSync(m)) return m; + } + + // Index file fallback + for (const idx of [ + 'index.ts', + 'index.tsx', + 'index.js', + 'index.mjs', + 'src/index.ts', + 'src/index.tsx', + 'src/index.js', + ]) { + const candidate = path.resolve(pkgDir, idx); + if (fs.existsSync(candidate)) return candidate; + } + } catch { + /* ignore */ + } + return null; +} + +/** + * Detect monorepo workspace packages from workspace configuration files. + * + * Checks (in order): + * 1. pnpm-workspace.yaml — `packages:` array + * 2. package.json — `workspaces` field (npm/yarn) + * 3. lerna.json — `packages` array + * + * @param {string} rootDir - Project root directory + * @returns {Map} + * Map of package name → { absolute dir, resolved entry file } + */ +export function detectWorkspaces(rootDir) { + const workspaces = new Map(); + const patterns = []; + + // 1. pnpm-workspace.yaml + const pnpmPath = path.join(rootDir, 'pnpm-workspace.yaml'); + if (fs.existsSync(pnpmPath)) { + try { + const raw = fs.readFileSync(pnpmPath, 'utf-8'); + // Simple YAML parse for `packages:` array — no dependency needed + const packagesMatch = raw.match(/^packages:\s*\n((?:\s+-\s+.+\n?)*)/m); + if (packagesMatch) { + const lines = packagesMatch[1].match(/^\s+-\s+['"]?([^'"#\n]+)['"]?\s*$/gm); + if (lines) { + for (const line of lines) { + const m = line.match(/^\s+-\s+['"]?([^'"#\n]+?)['"]?\s*$/); + if (m) patterns.push(m[1].trim()); + } + } + } + } catch { + /* ignore */ + } + } + + // 2. package.json workspaces (npm/yarn) + if (patterns.length === 0) { + const rootPkgPath = path.join(rootDir, 'package.json'); + if (fs.existsSync(rootPkgPath)) { + try { + const raw = fs.readFileSync(rootPkgPath, 'utf-8'); + const pkg = JSON.parse(raw); + const ws = pkg.workspaces; + if (Array.isArray(ws)) { + patterns.push(...ws); + } else if (ws && Array.isArray(ws.packages)) { + // Yarn classic format: { packages: [...], nohoist: [...] } + patterns.push(...ws.packages); + } + } catch { + /* ignore */ + } + } + } + + // 3. lerna.json + if (patterns.length === 0) { + const lernaPath = path.join(rootDir, 'lerna.json'); + if (fs.existsSync(lernaPath)) { + try { + const raw = fs.readFileSync(lernaPath, 'utf-8'); + const lerna = JSON.parse(raw); + if (Array.isArray(lerna.packages)) { + patterns.push(...lerna.packages); + } + } catch { + /* ignore */ + } + } + } + + if (patterns.length === 0) return workspaces; + + // Expand glob patterns and collect packages + for (const pattern of patterns) { + // Check if pattern is a direct path (no glob) or a glob + if (pattern.includes('*')) { + for (const dir of expandWorkspaceGlob(pattern, rootDir)) { + const name = readPackageName(dir); + if (name) workspaces.set(name, { dir, entry: resolveWorkspaceEntry(dir) }); + } + } else { + // Direct path like "packages/core" + const dir = path.resolve(rootDir, pattern); + if (fs.existsSync(path.join(dir, 'package.json'))) { + const name = readPackageName(dir); + if (name) workspaces.set(name, { dir, entry: resolveWorkspaceEntry(dir) }); + } + } + } + + if (workspaces.size > 0) { + debug(`Detected ${workspaces.size} workspace packages: ${[...workspaces.keys()].join(', ')}`); + } + + return workspaces; +} + function mergeConfig(defaults, overrides) { const result = { ...defaults }; for (const [key, value] of Object.entries(overrides)) { diff --git a/tests/unit/config.test.js b/tests/unit/config.test.js index 56685830..5d44ca05 100644 --- a/tests/unit/config.test.js +++ b/tests/unit/config.test.js @@ -10,6 +10,7 @@ import { applyEnvOverrides, CONFIG_FILES, DEFAULTS, + detectWorkspaces, loadConfig, resolveSecrets, } from '../../src/infrastructure/config.js'; @@ -424,3 +425,130 @@ describe('apiKeyCommand integration', () => { stderrSpy.mockRestore(); }); }); + +// ─── detectWorkspaces ──────────────────────────────────────────────── + +describe('detectWorkspaces', () => { + /** Helper: create a minimal workspace package */ + function makeWorkspacePackage(dir, name, entryContent = 'export default 1;') { + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'src', 'index.js'), entryContent); + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name })); + } + + it('returns empty map when no workspace config exists', () => { + const dir = fs.mkdtempSync(path.join(tmpDir, 'no-ws-')); + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'root' })); + const ws = detectWorkspaces(dir); + expect(ws).toBeInstanceOf(Map); + expect(ws.size).toBe(0); + }); + + it('detects npm workspaces from package.json', () => { + const dir = fs.mkdtempSync(path.join(tmpDir, 'npm-ws-')); + fs.mkdirSync(path.join(dir, 'packages', 'core'), { recursive: true }); + fs.mkdirSync(path.join(dir, 'packages', 'utils'), { recursive: true }); + makeWorkspacePackage(path.join(dir, 'packages', 'core'), '@myorg/core'); + makeWorkspacePackage(path.join(dir, 'packages', 'utils'), '@myorg/utils'); + fs.writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ name: 'root', workspaces: ['packages/*'] }), + ); + + const ws = detectWorkspaces(dir); + expect(ws.size).toBe(2); + expect(ws.has('@myorg/core')).toBe(true); + expect(ws.has('@myorg/utils')).toBe(true); + expect(ws.get('@myorg/core').dir).toBe(path.join(dir, 'packages', 'core')); + expect(ws.get('@myorg/core').entry).toContain('index.js'); + }); + + it('detects yarn classic workspaces format', () => { + const dir = fs.mkdtempSync(path.join(tmpDir, 'yarn-ws-')); + fs.mkdirSync(path.join(dir, 'packages', 'lib'), { recursive: true }); + makeWorkspacePackage(path.join(dir, 'packages', 'lib'), 'my-lib'); + fs.writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ name: 'root', workspaces: { packages: ['packages/*'] } }), + ); + + const ws = detectWorkspaces(dir); + expect(ws.size).toBe(1); + expect(ws.has('my-lib')).toBe(true); + }); + + it('detects pnpm workspaces from pnpm-workspace.yaml', () => { + const dir = fs.mkdtempSync(path.join(tmpDir, 'pnpm-ws-')); + fs.mkdirSync(path.join(dir, 'packages', 'shared'), { recursive: true }); + makeWorkspacePackage(path.join(dir, 'packages', 'shared'), '@myorg/shared'); + fs.writeFileSync(path.join(dir, 'pnpm-workspace.yaml'), 'packages:\n - "packages/*"\n'); + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'root' })); + + const ws = detectWorkspaces(dir); + expect(ws.size).toBe(1); + expect(ws.has('@myorg/shared')).toBe(true); + }); + + it('detects lerna workspaces from lerna.json', () => { + const dir = fs.mkdtempSync(path.join(tmpDir, 'lerna-ws-')); + fs.mkdirSync(path.join(dir, 'packages', 'app'), { recursive: true }); + makeWorkspacePackage(path.join(dir, 'packages', 'app'), '@myorg/app'); + fs.writeFileSync(path.join(dir, 'lerna.json'), JSON.stringify({ packages: ['packages/*'] })); + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'root' })); + + const ws = detectWorkspaces(dir); + expect(ws.size).toBe(1); + expect(ws.has('@myorg/app')).toBe(true); + }); + + it('resolves entry via main field', () => { + const dir = fs.mkdtempSync(path.join(tmpDir, 'main-entry-')); + fs.mkdirSync(path.join(dir, 'packages', 'lib', 'dist'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'packages', 'lib', 'dist', 'lib.js'), 'module.exports = 1;'); + fs.writeFileSync( + path.join(dir, 'packages', 'lib', 'package.json'), + JSON.stringify({ name: 'my-lib', main: './dist/lib.js' }), + ); + fs.writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ name: 'root', workspaces: ['packages/*'] }), + ); + + const ws = detectWorkspaces(dir); + expect(ws.get('my-lib').entry).toBe(path.join(dir, 'packages', 'lib', 'dist', 'lib.js')); + }); + + it('handles direct path patterns (no glob)', () => { + const dir = fs.mkdtempSync(path.join(tmpDir, 'direct-path-')); + fs.mkdirSync(path.join(dir, 'apps', 'web', 'src'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'apps', 'web', 'src', 'index.ts'), 'export default 1;'); + fs.writeFileSync( + path.join(dir, 'apps', 'web', 'package.json'), + JSON.stringify({ name: '@myorg/web' }), + ); + fs.writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ name: 'root', workspaces: ['apps/web'] }), + ); + + const ws = detectWorkspaces(dir); + expect(ws.size).toBe(1); + expect(ws.has('@myorg/web')).toBe(true); + }); + + it('skips directories without package.json', () => { + const dir = fs.mkdtempSync(path.join(tmpDir, 'no-pkg-')); + fs.mkdirSync(path.join(dir, 'packages', 'no-pkg'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'packages', 'no-pkg', 'index.js'), 'export default 1;'); + fs.mkdirSync(path.join(dir, 'packages', 'has-pkg', 'src'), { recursive: true }); + makeWorkspacePackage(path.join(dir, 'packages', 'has-pkg'), 'has-pkg'); + fs.writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ name: 'root', workspaces: ['packages/*'] }), + ); + + const ws = detectWorkspaces(dir); + expect(ws.size).toBe(1); + expect(ws.has('has-pkg')).toBe(true); + }); +}); From 80ea81986e34a0e98b442b9a0c124c449168078a Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Wed, 18 Mar 2026 22:48:49 -0600 Subject: [PATCH 3/3] fix: address review feedback in resolve.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - matchSubpathPattern: reject empty wildcard match (< → <=) per Node.js spec - isSubpathMap: check all keys with .some() instead of only first key - setWorkspaces: clear _workspaceResolvedPaths and _exportsCache on refresh - Restore .pyi extension in filesystem probe list (accidental removal) Impact: 4 functions changed, 4 affected --- src/domain/graph/resolve.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/domain/graph/resolve.js b/src/domain/graph/resolve.js index e88c4bf8..0eb5db8d 100644 --- a/src/domain/graph/resolve.js +++ b/src/domain/graph/resolve.js @@ -104,7 +104,7 @@ function matchSubpathPattern(pattern, subpath) { if (!subpath.startsWith(prefix)) return null; if (suffix && !subpath.endsWith(suffix)) return null; const matched = subpath.slice(prefix.length, suffix ? -suffix.length || undefined : undefined); - if (!suffix && subpath.length < prefix.length) return null; + if (!suffix && subpath.length <= prefix.length) return null; return matched; } @@ -150,7 +150,7 @@ export function resolveViaExports(specifier, rootDir) { // Determine if exports is a conditions object (no keys start with ".") // or a subpath map (keys start with ".") const keys = Object.keys(exports); - const isSubpathMap = keys.length > 0 && keys[0].startsWith('.'); + const isSubpathMap = keys.length > 0 && keys.some((k) => k.startsWith('.')); if (!isSubpathMap) { // Conditions object at top level → applies to "." subpath only @@ -209,6 +209,8 @@ const _workspaceResolvedPaths = new Set(); */ export function setWorkspaces(rootDir, map) { _workspaceCache.set(rootDir, map); + _workspaceResolvedPaths.clear(); + _exportsCache.clear(); } /** @@ -387,6 +389,7 @@ function resolveImportPathJS(fromFile, importSource, rootDir, aliases) { '.jsx', '.mjs', '.py', + '.pyi', '/index.ts', '/index.tsx', '/index.js',