523 lines
17 KiB
JavaScript
523 lines
17 KiB
JavaScript
// Resolve workspace-package imports to actual source files. Sub-agents need this when the route file is a thin shell that re-exports from a workspace package.
|
|
//
|
|
// Bounded expansion keeps the brief allowlist small: package export resolution, pure-barrel
|
|
// traversal, and suffix fan-out for likely data-loading modules. This stays string-based
|
|
// and falls through ("couldn't resolve") on shapes that need a full TS resolver.
|
|
|
|
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
import { dirname, join, resolve as pathResolve } from 'node:path';
|
|
|
|
const DEFAULT_RESOLVE_OPTIONS = {
|
|
pureBarrelDepth: 3,
|
|
suffixFanoutDepth: 2,
|
|
perSpecifierCap: 3,
|
|
};
|
|
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']);
|
|
const EXTENSIONS = ['', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'];
|
|
const INDEX_FILES = ['index.ts', 'index.tsx', 'index.js', 'index.jsx', 'index.mjs'];
|
|
const SUFFIX_FANOUT_RE = /(^|\/)(content|data|loader|fetch|service|metadata|actions)\.tsx?$/;
|
|
const EXPORT_FORWARD_RE = /export\s+(?:type\s+)?(?:\*|\*\s+as\s+[A-Za-z_$][\w$]*|\{[^}]*\})\s+from\s+['"][^'"\n]+['"]\s*;?/gs;
|
|
|
|
export async function detectMonorepoRoot(startDir) {
|
|
let dir = pathResolve(startDir);
|
|
for (let depth = 0; depth < 15; depth++) {
|
|
if (await fileExists(join(dir, 'pnpm-workspace.yaml'))) return dir;
|
|
const pkg = await tryReadJson(join(dir, 'package.json'));
|
|
if (pkg && (Array.isArray(pkg.workspaces) || Array.isArray(pkg.workspaces?.packages))) {
|
|
return dir;
|
|
}
|
|
const parent = dirname(dir);
|
|
if (parent === dir) return null;
|
|
dir = parent;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Zero-dependency — pnpm-workspace.yaml shape is predictable; not pulling in js-yaml.
|
|
export async function readWorkspaceGlobs(monorepoRoot) {
|
|
const pnpmPath = join(monorepoRoot, 'pnpm-workspace.yaml');
|
|
if (await fileExists(pnpmPath)) {
|
|
const text = await readFile(pnpmPath, 'utf-8');
|
|
return parsePnpmWorkspaceYaml(text);
|
|
}
|
|
const pkg = await tryReadJson(join(monorepoRoot, 'package.json'));
|
|
if (Array.isArray(pkg?.workspaces)) return pkg.workspaces;
|
|
if (Array.isArray(pkg?.workspaces?.packages)) return pkg.workspaces.packages;
|
|
return [];
|
|
}
|
|
|
|
// Handles `packages:` block with `- glob` entries. Not full YAML grammar.
|
|
export function parsePnpmWorkspaceYaml(text) {
|
|
const out = [];
|
|
let inPackages = false;
|
|
for (const rawLine of text.split('\n')) {
|
|
const line = rawLine.replace(/#.*$/, '').trimEnd();
|
|
if (!line.trim()) continue;
|
|
if (/^packages\s*:/.test(line)) { inPackages = true; continue; }
|
|
if (!inPackages) continue;
|
|
if (!/^\s/.test(line)) { inPackages = false; continue; }
|
|
const m = line.match(/^\s*-\s+['"]?([^'"\s]+)['"]?\s*$/);
|
|
if (m) out.push(m[1]);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export async function listWorkspacePackages(monorepoRoot) {
|
|
const globs = await readWorkspaceGlobs(monorepoRoot);
|
|
const dirs = new Set();
|
|
for (const g of globs) {
|
|
const expanded = await expandWorkspaceGlob(monorepoRoot, g);
|
|
for (const d of expanded) dirs.add(d);
|
|
}
|
|
const out = [];
|
|
for (const dir of dirs) {
|
|
const pkg = await tryReadJson(join(dir, 'package.json'));
|
|
if (pkg?.name) out.push({ name: pkg.name, dir, pkg });
|
|
}
|
|
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
}
|
|
|
|
// Handles workspace-shape globs only. `**` collapses to one level — npm/pnpm don't document deep `**`.
|
|
async function expandWorkspaceGlob(root, glob) {
|
|
const parts = glob.replace(/\\/g, '/').split('/');
|
|
return await expandParts(root, parts);
|
|
}
|
|
|
|
async function expandParts(currentDir, parts) {
|
|
if (parts.length === 0) return [currentDir];
|
|
const [head, ...rest] = parts;
|
|
if (head === '' || head === '.') return await expandParts(currentDir, rest);
|
|
if (head === '*' || head === '**') {
|
|
let entries = [];
|
|
try {
|
|
entries = await readdir(currentDir, { withFileTypes: true });
|
|
} catch { return []; }
|
|
const childDirs = entries.filter((e) => e.isDirectory()).map((e) => join(currentDir, e.name));
|
|
const out = [];
|
|
for (const d of childDirs) {
|
|
const more = await expandParts(d, rest);
|
|
out.push(...more);
|
|
}
|
|
return out;
|
|
}
|
|
const next = join(currentDir, head);
|
|
try {
|
|
const s = await stat(next);
|
|
if (!s.isDirectory()) return [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
return await expandParts(next, rest);
|
|
}
|
|
|
|
export function buildResolver(packages) {
|
|
const byName = new Map();
|
|
for (const p of packages) {
|
|
byName.set(p.name, buildPackageLookup(p));
|
|
}
|
|
return function resolveSpecifier(specifier) {
|
|
if (typeof specifier !== 'string' || !specifier.length) return null;
|
|
// Longest-name match first so `@vercel/foo-bar` wins over `@vercel/foo`.
|
|
const candidates = [...byName.keys()]
|
|
.filter((name) => specifier === name || specifier.startsWith(name + '/'))
|
|
.sort((a, b) => b.length - a.length);
|
|
if (candidates.length === 0) return null;
|
|
const pkgName = candidates[0];
|
|
const subpath = specifier === pkgName ? '.' : './' + specifier.slice(pkgName.length + 1);
|
|
const lookup = byName.get(pkgName);
|
|
return lookup.resolveSubpath(subpath);
|
|
};
|
|
}
|
|
|
|
// Node spec: pattern key has exactly one `*`; target may have one or zero.
|
|
function buildPackageLookup(p) {
|
|
const exact = new Map();
|
|
const wildcards = [];
|
|
const exports = p.pkg.exports;
|
|
if (exports && typeof exports === 'object' && !Array.isArray(exports)) {
|
|
for (const [key, value] of Object.entries(exports)) {
|
|
const target = pickConditionalTarget(value);
|
|
if (typeof target !== 'string') continue;
|
|
if (key.includes('*')) {
|
|
const keyStarIdx = key.indexOf('*');
|
|
if (keyStarIdx !== key.lastIndexOf('*')) continue;
|
|
if ((target.match(/\*/g) ?? []).length > 1) continue;
|
|
wildcards.push({
|
|
keyPrefix: key.slice(0, keyStarIdx),
|
|
keySuffix: key.slice(keyStarIdx + 1),
|
|
valueTemplate: target,
|
|
});
|
|
} else {
|
|
exact.set(key, target);
|
|
}
|
|
}
|
|
}
|
|
return {
|
|
resolveSubpath(subpath) {
|
|
const exactHit = exact.get(subpath);
|
|
if (exactHit) return joinPackagePath(p.dir, exactHit);
|
|
for (const w of wildcards) {
|
|
if (subpath.startsWith(w.keyPrefix) && subpath.endsWith(w.keySuffix)) {
|
|
const star = subpath.slice(w.keyPrefix.length, subpath.length - w.keySuffix.length);
|
|
if (!star) continue;
|
|
const target = w.valueTemplate.replaceAll('*', star);
|
|
return joinPackagePath(p.dir, target);
|
|
}
|
|
}
|
|
// Unsafe to guess when no exports declared.
|
|
if (exact.size === 0 && wildcards.length === 0 && subpath !== '.') {
|
|
return null;
|
|
}
|
|
return null;
|
|
},
|
|
};
|
|
}
|
|
|
|
// Condition order matches what Next.js / Vite / esbuild would resolve.
|
|
function pickConditionalTarget(value) {
|
|
if (typeof value === 'string') return value;
|
|
if (Array.isArray(value)) {
|
|
for (const item of value) {
|
|
const target = pickConditionalTarget(item);
|
|
if (typeof target === 'string') return target;
|
|
}
|
|
return null;
|
|
}
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
for (const cond of ['default', 'import', 'node', 'browser', 'require', 'types']) {
|
|
const v = value[cond];
|
|
if (typeof v === 'string') return v;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export async function resolveWorkspaceImports(sourceFilePath, resolver, options = {}) {
|
|
let text;
|
|
try {
|
|
text = await readFile(sourceFilePath, 'utf-8');
|
|
} catch {
|
|
return [];
|
|
}
|
|
const opts = { ...DEFAULT_RESOLVE_OPTIONS, ...options };
|
|
const refs = extractModuleReferences(text);
|
|
const out = [];
|
|
const seen = new Set();
|
|
for (const ref of refs) {
|
|
const resolved = await resolveModuleSpecifier(sourceFilePath, ref.specifier, resolver);
|
|
if (!resolved) continue;
|
|
const expanded = await expandResolvedSpecifier(resolved, ref.importedNames, resolver, opts);
|
|
for (const file of expanded) {
|
|
if (seen.has(file)) continue;
|
|
seen.add(file);
|
|
out.push(file);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// Skips CommonJS `require('foo')` and template-literal dynamic imports (statically unresolvable).
|
|
export function extractImportSpecifiers(text) {
|
|
return [...new Set(extractModuleReferences(text).map((ref) => ref.specifier))];
|
|
}
|
|
|
|
function joinPackagePath(packageDir, relativeTarget) {
|
|
return join(packageDir, relativeTarget.replace(/^\.\//, ''));
|
|
}
|
|
|
|
async function expandResolvedSpecifier(startFile, importedNames, resolver, opts) {
|
|
const out = [];
|
|
const seen = new Set();
|
|
const barrelVisited = new Set();
|
|
const fanoutVisited = new Set();
|
|
|
|
const add = (file) => {
|
|
if (seen.has(file)) return false;
|
|
if (out.length > 0 && out.length - 1 >= opts.perSpecifierCap) return false;
|
|
seen.add(file);
|
|
out.push(file);
|
|
return true;
|
|
};
|
|
|
|
add(startFile);
|
|
await expandPureBarrel(startFile, importedNames, 0);
|
|
const fanoutSeeds = out.slice();
|
|
for (const file of fanoutSeeds) {
|
|
await expandSuffixFanout(file, 0);
|
|
}
|
|
return out;
|
|
|
|
async function expandPureBarrel(file, requestedNames, depth) {
|
|
if (depth >= opts.pureBarrelDepth) return;
|
|
if (barrelVisited.has(file)) return;
|
|
barrelVisited.add(file);
|
|
const text = await tryReadText(file);
|
|
if (text == null || !isPureBarrel(text)) return;
|
|
const refs = await selectRelevantForwards(file, extractExportForwardRefs(text), requestedNames, resolver);
|
|
for (const { ref, next } of refs) {
|
|
if (!add(next)) return;
|
|
await expandPureBarrel(next, requestedNamesForForward(ref, requestedNames), depth + 1);
|
|
}
|
|
}
|
|
|
|
async function expandSuffixFanout(file, depth) {
|
|
if (depth >= opts.suffixFanoutDepth) return;
|
|
if (!isSuffixFanoutFile(file)) return;
|
|
const visitKey = `${file}:${depth}`;
|
|
if (fanoutVisited.has(visitKey)) return;
|
|
fanoutVisited.add(visitKey);
|
|
const text = await tryReadText(file);
|
|
if (text == null) return;
|
|
for (const ref of extractModuleReferences(text)) {
|
|
const next = await resolveModuleSpecifier(file, ref.specifier, resolver);
|
|
if (!next) continue;
|
|
if (!add(next)) return;
|
|
if (isSuffixFanoutFile(next)) await expandSuffixFanout(next, depth + 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function selectRelevantForwards(fromFile, refs, requestedNames, resolver) {
|
|
const resolved = [];
|
|
for (const [index, ref] of refs.entries()) {
|
|
const next = await resolveModuleSpecifier(fromFile, ref.specifier, resolver);
|
|
if (!next) continue;
|
|
let score = requestedNames && requestedNames.size > 0
|
|
? forwardRelevanceScore(ref, requestedNames, refs.length)
|
|
: 1;
|
|
if (requestedNames && requestedNames.size > 0 && await fileExportsAnyName(next, requestedNames)) {
|
|
score = Math.max(score, 75);
|
|
}
|
|
resolved.push({ ref, next, index, score });
|
|
}
|
|
if (!requestedNames || requestedNames.size === 0) return resolved;
|
|
const ranked = resolved
|
|
.filter((x) => x.score > 0)
|
|
.sort((a, b) => b.score - a.score || a.index - b.index);
|
|
return ranked.length > 0 ? ranked : resolved;
|
|
}
|
|
|
|
function forwardRelevanceScore(ref, requestedNames, siblingCount) {
|
|
if (!requestedNames || requestedNames.size === 0) return 1;
|
|
if (ref.exportedNames) {
|
|
for (const name of requestedNames) {
|
|
if (ref.exportedNames.has(name)) return 100;
|
|
}
|
|
}
|
|
if (specifierMatchesNames(ref.specifier, requestedNames)) return 50;
|
|
return siblingCount === 1 ? 1 : 0;
|
|
}
|
|
|
|
function requestedNamesForForward(ref, requestedNames) {
|
|
if (!requestedNames || requestedNames.size === 0) return null;
|
|
if (ref.star) return requestedNames;
|
|
const out = new Set();
|
|
for (const name of requestedNames) {
|
|
const source = ref.sourceNamesByExported?.get(name);
|
|
if (source) out.add(source);
|
|
}
|
|
return out.size > 0 ? out : requestedNames;
|
|
}
|
|
|
|
async function resolveModuleSpecifier(fromFile, specifier, resolver) {
|
|
const raw = specifier.startsWith('.')
|
|
? join(dirname(fromFile), specifier)
|
|
: resolver(specifier);
|
|
if (!raw) return null;
|
|
return await resolveExistingPath(raw);
|
|
}
|
|
|
|
async function resolveExistingPath(basePath) {
|
|
for (const ext of EXTENSIONS) {
|
|
const candidate = ext === '' ? basePath : basePath + ext;
|
|
if (!isSourcePath(candidate)) continue;
|
|
if (await isFile(candidate)) return candidate;
|
|
}
|
|
for (const indexFile of INDEX_FILES) {
|
|
const candidate = join(basePath, indexFile);
|
|
if (await isFile(candidate)) return candidate;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function extractModuleReferences(text) {
|
|
return [
|
|
...extractImportReferences(text),
|
|
...extractExportForwardRefs(text).map((ref) => ({
|
|
specifier: ref.specifier,
|
|
importedNames: ref.star ? null : ref.exportedNames,
|
|
})),
|
|
...extractDynamicImportReferences(text),
|
|
];
|
|
}
|
|
|
|
function extractImportReferences(text) {
|
|
const out = [];
|
|
const fromRe = /import\s+(?:type\s+)?([\s\S]*?)\s+from\s+['"]([^'"\n]+)['"]/g;
|
|
let m;
|
|
while ((m = fromRe.exec(text)) !== null) {
|
|
out.push({ specifier: m[2], importedNames: parseImportNames(m[1]) });
|
|
}
|
|
const sideEffectRe = /import\s+['"]([^'"\n]+)['"]/g;
|
|
while ((m = sideEffectRe.exec(text)) !== null) {
|
|
out.push({ specifier: m[1], importedNames: null });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function extractDynamicImportReferences(text) {
|
|
const out = [];
|
|
const re = /import\s*\(\s*['"]([^'"\n]+)['"]\s*\)/g;
|
|
let m;
|
|
while ((m = re.exec(text)) !== null) {
|
|
out.push({ specifier: m[1], importedNames: null });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function extractExportForwardRefs(text) {
|
|
const out = [];
|
|
const re = /export\s+(?:type\s+)?(\*|\*\s+as\s+[A-Za-z_$][\w$]*|\{[^}]*\})\s+from\s+['"]([^'"\n]+)['"]\s*;?/g;
|
|
let m;
|
|
while ((m = re.exec(text)) !== null) {
|
|
const clause = m[1].trim();
|
|
const star = clause.startsWith('*');
|
|
const names = star ? null : parseExportNames(clause);
|
|
out.push({
|
|
specifier: m[2],
|
|
star,
|
|
exportedNames: names?.exportedNames ?? null,
|
|
sourceNamesByExported: names?.sourceNamesByExported ?? null,
|
|
});
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function parseImportNames(clause) {
|
|
const names = new Set();
|
|
const trimmed = clause.trim();
|
|
if (!trimmed) return null;
|
|
const named = /\{([^}]+)\}/s.exec(trimmed);
|
|
if (named) {
|
|
for (const part of splitImportList(named[1])) {
|
|
const cleaned = part.replace(/^type\s+/, '').trim();
|
|
if (!cleaned) continue;
|
|
const [source] = cleaned.split(/\s+as\s+/i);
|
|
if (source?.trim()) names.add(source.trim());
|
|
}
|
|
}
|
|
const withoutNamed = trimmed.replace(/\{[^}]*\}/s, '').replace(/,\s*$/, '').trim();
|
|
if (withoutNamed && !withoutNamed.startsWith('*')) names.add('default');
|
|
return names.size > 0 ? names : null;
|
|
}
|
|
|
|
function parseExportNames(clause) {
|
|
const body = clause.replace(/^\{|\}$/g, '');
|
|
const exportedNames = new Set();
|
|
const sourceNamesByExported = new Map();
|
|
for (const part of splitImportList(body)) {
|
|
const cleaned = part.replace(/^type\s+/, '').trim();
|
|
if (!cleaned) continue;
|
|
const [sourceRaw, exportedRaw] = cleaned.split(/\s+as\s+/i);
|
|
const source = sourceRaw.trim();
|
|
const exported = (exportedRaw ?? sourceRaw).trim();
|
|
if (!source || !exported) continue;
|
|
exportedNames.add(exported);
|
|
sourceNamesByExported.set(exported, source);
|
|
}
|
|
return { exportedNames, sourceNamesByExported };
|
|
}
|
|
|
|
function splitImportList(value) {
|
|
return value.split(',').map((part) => part.trim()).filter(Boolean);
|
|
}
|
|
|
|
function isPureBarrel(text) {
|
|
const refs = extractExportForwardRefs(text);
|
|
if (refs.length === 0) return false;
|
|
const withoutComments = text
|
|
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
.replace(/^\s*\/\/.*$/gm, '');
|
|
return withoutComments.replace(EXPORT_FORWARD_RE, '').trim() === '';
|
|
}
|
|
|
|
function specifierMatchesNames(specifier, names) {
|
|
const normalizedSpecifier = normalizeName(specifier.split('/').at(-1) ?? specifier);
|
|
for (const name of names) {
|
|
const normalizedName = normalizeName(name);
|
|
if (normalizedSpecifier === normalizedName || normalizedSpecifier.endsWith(normalizedName)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function normalizeName(value) {
|
|
return String(value ?? '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
}
|
|
|
|
function isSuffixFanoutFile(file) {
|
|
return SUFFIX_FANOUT_RE.test(file.replace(/\\/g, '/'));
|
|
}
|
|
|
|
async function fileExportsAnyName(file, names) {
|
|
const text = await tryReadText(file);
|
|
if (text == null) return false;
|
|
for (const name of names) {
|
|
if (textExportsName(text, name)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function textExportsName(text, name) {
|
|
const escaped = escapeRegExp(name);
|
|
const declaration = new RegExp(`export\\s+(?:async\\s+)?(?:function|const|let|var|class|interface|type)\\s+${escaped}\\b`);
|
|
if (declaration.test(text)) return true;
|
|
const listRe = /export\s+\{([^}]+)\}(?!\s+from\b)/gs;
|
|
let m;
|
|
while ((m = listRe.exec(text)) !== null) {
|
|
const names = parseExportNames(`{${m[1]}}`).exportedNames;
|
|
if (names.has(name)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function isSourcePath(path) {
|
|
const match = /\.([A-Za-z0-9]+)$/.exec(path);
|
|
if (!match) return true;
|
|
return SOURCE_EXTENSIONS.has('.' + match[1]);
|
|
}
|
|
|
|
function escapeRegExp(value) {
|
|
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
}
|
|
|
|
async function tryReadText(path) {
|
|
try {
|
|
return await readFile(path, 'utf-8');
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function fileExists(p) {
|
|
try { await stat(p); return true; } catch { return false; }
|
|
}
|
|
|
|
async function isFile(p) {
|
|
try {
|
|
const s = await stat(p);
|
|
return s.isFile();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function tryReadJson(path) {
|
|
try {
|
|
const text = await readFile(path, 'utf-8');
|
|
return JSON.parse(text);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|