OverviewFilesCommitsIssuesDocsReleasesPeers

codetree / lib / deps-crawler.js

177 lines · javascript ref:
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
}