Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion src/domain/graph/builder/pipeline.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
}

Expand Down
315 changes: 314 additions & 1 deletion src/domain/graph/resolve.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,306 @@ 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();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 _exportsCache not invalidated during watch mode

_exportsCache is populated lazily per packageDir and is never cleared between incremental builds (only clearExportsCache() clears it, which is only called in tests). In watch mode, if a dependency's package.json is edited between builds (e.g., the exports field changes), the cached stale value is used for the entire lifetime of the process.

Consider clearing (or at minimum exposing) the exports cache alongside _workspaceResolvedPaths when setWorkspaces is called, or document the limitation so future contributors know to invalidate it when package.json change events are observed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — \ is now cleared alongside \ whenever \ is called, so stale package.json exports data won't persist across incremental rebuilds.


/**
* 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.some((k) => k.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<packageName, { dir, entry }> */
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<string, { dir: string, entry: string|null }>} map
*/
export function setWorkspaces(rootDir, map) {
_workspaceCache.set(rootDir, map);
_workspaceResolvedPaths.clear();
_exportsCache.clear();
}

/**
* 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 ─────────────────────────────────────────

/**
Expand Down Expand Up @@ -60,7 +360,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);

Expand Down Expand Up @@ -97,6 +408,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));
Expand Down
Loading
Loading