import { readFileSync, existsSync, statSync, readdirSync } from 'fs'
import { join, resolve, dirname, basename } from 'path'
/**
* Discover additional scan roots so dependency references (anproto,
* hyper-rooms, etc.) can resolve against real code instead of being
* flagged as missing or silenced via config.externalSymbols.
*
* Three sources, in order:
* 1. config.siblingDirs — explicit paths, always honored.
* 2. package.json "file:..." deps — always honored (the project has
* literally pointed at a path for this dep).
* 3. config.autoCrawl — walk up N ancestors looking for sibling
* directories that match bare-named deps by directory name or
* package.json name field.
*
* Returns:
* {
* roots: string[] — absolute paths of extra scan roots
* unresolved: string[] — dep names we tried to resolve but couldn't
* sources: { root: string } — which source located each root (for debug/UI)
* }
*
* Does NOT include the primary rootDir in the returned list; callers
* scan rootDir separately.
*/
export function crawlDeps (rootDir, config, opts = {}) {
const root = resolve(rootDir)
const crawl = {
roots: [],
unresolved: [],
sources: {}
}
// --- 1. Explicit siblingDirs ---
for (const raw of (config.siblingDirs || [])) {
const abs = resolve(root, raw)
if (!tryAddRoot(abs, crawl, 'siblingDirs')) continue
}
// --- 2. package.json file:... deps ---
const pkgPath = join(root, 'package.json')
let pkg = null
if (existsSync(pkgPath)) {
try {
pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
} catch {
pkg = null
}
}
if (pkg) {
const allDeps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) }
for (const [name, spec] of Object.entries(allDeps)) {
if (typeof spec !== 'string') continue
if (!spec.startsWith('file:')) continue
const relPath = spec.slice('file:'.length)
const abs = resolve(root, relPath)
tryAddRoot(abs, crawl, 'package.json file: ' + name)
}
}
// --- 3. autoCrawl: bare-name deps → walk up, check siblings ---
if (config.autoCrawl && pkg) {
const ancestors = opts.maxAncestors || 2
const excludeNpm = new Set(opts.excludeKnownNpm || [
// Things we know come from node_modules; no point crawling for them
'hypercore', 'hyperswarm', 'hyperbee', 'hyperdrive', 'hyperdht',
'corestore', 'protomux', 'compact-encoding', 'hypercore-crypto',
'b4a', 'brittle', 'safety-catch', 'bare', 'paparam',
// Placeholder for any bare-* npm:
])
const allDeps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) }
for (const [name, spec] of Object.entries(allDeps)) {
if (typeof spec !== 'string') continue
if (spec.startsWith('file:')) continue // already handled
if (excludeNpm.has(name)) continue
const found = findSiblingByName(root, name, ancestors)
if (found && tryAddRoot(found, crawl, 'autoCrawl: ' + name)) {
// Added.
} else {
crawl.unresolved.push(name)
}
}
}
return crawl
}
function tryAddRoot (abs, crawl, sourceLabel) {
if (!abs) return false
if (crawl.roots.includes(abs)) return false
// Must exist + be a directory
let stat
try { stat = statSync(abs) } catch { return false }
if (!stat.isDirectory()) return false
crawl.roots.push(abs)
if (!crawl.sources[abs]) crawl.sources[abs] = sourceLabel
return true
}
// System directories we never want to treat as a "siblings-of-project"
// location. Walking these is slow and semantically wrong — a dep would
// never live at `/tmp/hyper-rooms`.
const SYSTEM_DIRS = new Set([
'/', '/tmp', '/var', '/usr', '/etc', '/opt', '/srv', '/home', '/root',
'/bin', '/sbin', '/lib', '/lib64', '/dev', '/proc', '/sys', '/run',
'/mnt', '/media', '/Applications', '/Users', '/Volumes'
])
// Cap siblings we scan per directory. Real project-adjacent locations have
// a handful; /tmp has thousands. If a directory is over this cap it's almost
// certainly not a monorepo root.
const MAX_SIBLINGS_PER_DIR = 100
/**
* Walk up N ancestors from `start`. At each level, check every sibling
* directory for a match. Match if:
* - directory basename === name, OR
* - that dir's package.json "name" field === name
*
* Returns absolute path on first match, null otherwise.
*
* Skips system directories (/tmp, /usr, etc.) and directories with too
* many children — those aren't project-sibling locations.
*/
function findSiblingByName (start, name, maxAncestors) {
let current = start
for (let i = 0; i <= maxAncestors; i++) {
const parent = dirname(current)
if (parent === current) break // hit FS root
if (SYSTEM_DIRS.has(parent)) break // don't scan system locations
let siblings
try { siblings = readdirSync(parent) } catch { break }
// Skip if the directory looks like a non-project bucket (too many entries).
if (siblings.length > MAX_SIBLINGS_PER_DIR) {
current = parent
continue
}
for (const entry of siblings) {
// Skip obvious junk
if (entry.startsWith('.')) continue
if (entry === 'node_modules') continue
const candidate = join(parent, entry)
let stat
try { stat = statSync(candidate) } catch { continue }
if (!stat.isDirectory()) continue
// Match by basename
if (entry === name) return candidate
// Match by package.json "name"
const pkgPath = join(candidate, 'package.json')
if (existsSync(pkgPath)) {
try {
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
if (pkg && pkg.name === name) return candidate
} catch {
// skip malformed package.json
}
}
}
current = parent
}
return null
}