OverviewFilesCommitsIssuesDocsReleasesPeers

codetree / test / deps-crawler.test.js

314 lines · javascript ref:
import { test } from 'brittle'
import { mkdirSync, writeFileSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'

import { crawlDeps } from '../lib/deps-crawler.js'
import { getDefaults } from '../lib/config.js'
import { scan } from '../lib/scanner.js'

/**
 * Phase 0b — cross-filesystem dep crawling.
 * Crawler takes (rootDir, config) → extra scan roots from three sources:
 *   1. config.siblingDirs (explicit)
 *   2. package.json "file:..." deps
 *   3. autoCrawl: walk ancestors looking for sibling dirs matching dep names
 */

function mkFixture (name) {
  const dir = join(tmpdir(), 'codetree-crawler-' + name + '-' + Date.now())
  mkdirSync(dir, { recursive: true })
  return dir
}

function cleanup (dir) {
  rmSync(dir, { recursive: true, force: true })
}

// --- source 1: explicit siblingDirs ---

test('crawler: explicit siblingDirs are resolved', (t) => {
  const root = mkFixture('s1-explicit')
  try {
    const sibling = join(root, '..', 'sibling-' + Date.now())
    mkdirSync(sibling, { recursive: true })

    const config = { ...getDefaults(), siblingDirs: [sibling] }
    const result = crawlDeps(root, config)

    t.ok(result.roots.includes(sibling), 'explicit sibling in roots')
    t.ok(result.sources[sibling].startsWith('siblingDirs'), 'source labeled siblingDirs')

    cleanup(sibling)
  } finally {
    cleanup(root)
  }
})

test('crawler: nonexistent siblingDirs are silently dropped', (t) => {
  const root = mkFixture('s1-missing')
  try {
    const config = {
      ...getDefaults(),
      siblingDirs: [join(root, '..', 'does-not-exist-' + Date.now())]
    }
    const result = crawlDeps(root, config)
    t.is(result.roots.length, 0, 'nonexistent path not added')
  } finally {
    cleanup(root)
  }
})

// --- source 2: package.json file: deps ---

test('crawler: file:../foo deps in package.json resolve to absolute paths', (t) => {
  const root = mkFixture('s2-file')
  try {
    const dep = join(root, '..', 's2-dep-' + Date.now())
    mkdirSync(dep, { recursive: true })

    writeFileSync(join(root, 'package.json'), JSON.stringify({
      name: 'test', version: '0.1.0',
      dependencies: {
        'local-dep': 'file:' + dep
      }
    }))

    const config = getDefaults()
    const result = crawlDeps(root, config)

    t.ok(result.roots.includes(dep), 'file: dep resolved')
    t.ok(result.sources[dep].startsWith('package.json file:'), 'source labeled')

    cleanup(dep)
  } finally {
    cleanup(root)
  }
})

test('crawler: file: with relative path resolves correctly', (t) => {
  const root = mkFixture('s2-relative')
  try {
    const dep = join(root, 'local-dep')
    mkdirSync(dep, { recursive: true })

    writeFileSync(join(root, 'package.json'), JSON.stringify({
      name: 'test', version: '0.1.0',
      dependencies: {
        'local-dep': 'file:./local-dep'
      }
    }))

    const result = crawlDeps(root, getDefaults())
    t.ok(result.roots.includes(dep), 'relative file: resolved')
  } finally {
    cleanup(root)
  }
})

// --- source 3: autoCrawl ---

test('crawler: autoCrawl finds sibling dir by basename', (t) => {
  // Parent dir with:
  //   parent/
  //     root/            ← scanning this, depends on "shared"
  //     shared/          ← should be found
  const parent = mkFixture('s3-parent')
  try {
    const root = join(parent, 'root')
    const shared = join(parent, 'shared')
    mkdirSync(root, { recursive: true })
    mkdirSync(shared, { recursive: true })

    writeFileSync(join(root, 'package.json'), JSON.stringify({
      name: 'test', version: '0.1.0',
      dependencies: { 'shared': '^1.0.0' }
    }))
    writeFileSync(join(shared, 'package.json'), JSON.stringify({
      name: 'shared', version: '1.0.0'
    }))

    const config = { ...getDefaults(), autoCrawl: true }
    const result = crawlDeps(root, config)

    t.ok(result.roots.includes(shared), 'sibling dir found by basename')
    t.ok(result.sources[shared].startsWith('autoCrawl'), 'source labeled autoCrawl')
  } finally {
    cleanup(parent)
  }
})

test('crawler: autoCrawl finds sibling by package.json name field', (t) => {
  const parent = mkFixture('s3-pkgname')
  try {
    const root = join(parent, 'root')
    // Directory basename differs from package name
    const sharedDir = join(parent, 'my-shared-lib')
    mkdirSync(root, { recursive: true })
    mkdirSync(sharedDir, { recursive: true })

    writeFileSync(join(root, 'package.json'), JSON.stringify({
      name: 'test', version: '0.1.0',
      dependencies: { '@scope/shared': '^1.0.0' }
    }))
    writeFileSync(join(sharedDir, 'package.json'), JSON.stringify({
      name: '@scope/shared', version: '1.0.0'
    }))

    const config = { ...getDefaults(), autoCrawl: true }
    const result = crawlDeps(root, config)

    t.ok(result.roots.includes(sharedDir), 'sibling dir found by package.json name')
  } finally {
    cleanup(parent)
  }
})

test('crawler: autoCrawl disabled by default', (t) => {
  const parent = mkFixture('s3-disabled')
  try {
    const root = join(parent, 'root')
    const shared = join(parent, 'shared')
    mkdirSync(root, { recursive: true })
    mkdirSync(shared, { recursive: true })

    writeFileSync(join(root, 'package.json'), JSON.stringify({
      name: 'test', version: '0.1.0',
      dependencies: { 'shared': '^1.0.0' }
    }))
    writeFileSync(join(shared, 'package.json'), JSON.stringify({
      name: 'shared', version: '1.0.0'
    }))

    // Default autoCrawl: false
    const result = crawlDeps(root, getDefaults())
    t.not(result.roots.includes(shared), 'sibling NOT found when autoCrawl disabled')
  } finally {
    cleanup(parent)
  }
})

test('crawler: autoCrawl reports unresolvable deps', (t) => {
  const parent = mkFixture('s3-unresolved')
  try {
    const root = join(parent, 'root')
    mkdirSync(root, { recursive: true })

    writeFileSync(join(root, 'package.json'), JSON.stringify({
      name: 'test', version: '0.1.0',
      dependencies: { 'does-not-exist': '^1.0.0' }
    }))

    const config = { ...getDefaults(), autoCrawl: true }
    const result = crawlDeps(root, config)
    t.ok(result.unresolved.includes('does-not-exist'), 'unresolvable dep reported')
  } finally {
    cleanup(parent)
  }
})

test('crawler: known npm packages are skipped during autoCrawl', (t) => {
  const parent = mkFixture('s3-npm')
  try {
    const root = join(parent, 'root')
    mkdirSync(root, { recursive: true })

    writeFileSync(join(root, 'package.json'), JSON.stringify({
      name: 'test', version: '0.1.0',
      dependencies: {
        'hypercore': '^10.0.0',
        'b4a': '^1.0.0'
      }
    }))

    const config = { ...getDefaults(), autoCrawl: true }
    const result = crawlDeps(root, config)
    // Known npm packages shouldn't even appear in unresolved — they're skipped outright
    t.not(result.unresolved.includes('hypercore'), 'hypercore skipped (known npm)')
    t.not(result.unresolved.includes('b4a'), 'b4a skipped (known npm)')
  } finally {
    cleanup(parent)
  }
})

// --- end-to-end with scanner ---

test('scanner: extra-root modules are merged into tree with _external flag', async (t) => {
  const parent = mkFixture('e2e-merge')
  try {
    const root = join(parent, 'root')
    const sibling = join(parent, 'sibling-module')
    mkdirSync(root, { recursive: true })
    mkdirSync(sibling, { recursive: true })

    writeFileSync(join(root, 'package.json'), JSON.stringify({
      name: 'myproject', version: '0.1.0', main: 'index.js'
    }))
    writeFileSync(join(root, 'index.js'), 'export function local () {}\n')

    writeFileSync(join(sibling, 'package.json'), JSON.stringify({
      name: 'sibling-module', version: '0.1.0', main: 'index.js'
    }))
    writeFileSync(join(sibling, 'index.js'), 'export function helloFromSibling () {}\n')

    writeFileSync(join(root, '.codetree.json'), JSON.stringify({
      siblingDirs: [sibling]
    }))

    const tree = await scan(root)
    t.ok(tree.modules.myproject, 'primary module present')
    t.ok(tree.modules['sibling-module'], 'sibling module merged in')

    t.ok(tree.crawl, 'crawl metadata attached')
    t.ok(tree.crawl.roots.includes(sibling), 'sibling in crawl.roots')
    t.ok(tree.crawl.addedModuleCount >= 1, 'added module count tracked')
  } finally {
    cleanup(parent)
  }
})

test('scanner: refs to sibling exports resolve as valid, not missing', async (t) => {
  const parent = mkFixture('e2e-refs')
  try {
    const root = join(parent, 'root')
    const sibling = join(parent, 'utils-lib')
    mkdirSync(root, { recursive: true })
    mkdirSync(sibling, { recursive: true })

    writeFileSync(join(root, 'package.json'), JSON.stringify({
      name: 'main', version: '0.1.0', main: 'index.js'
    }))
    writeFileSync(join(root, 'index.js'), 'export function main () {}\n')

    writeFileSync(join(sibling, 'package.json'), JSON.stringify({
      name: 'utils-lib', version: '0.1.0', main: 'index.js'
    }))
    writeFileSync(join(sibling, 'index.js'),
      'export class UtilsHelper {\n  doStuff () {}\n}\nexport function formatDate () {}\n'
    )

    writeFileSync(join(root, '.codetree.json'), JSON.stringify({
      siblingDirs: [sibling]
    }))

    writeFileSync(join(root, 'README.md'),
      '# main\n\n' +
      'Uses `UtilsHelper` and `formatDate()` from utils-lib.\n'
    )

    const tree = await scan(root)
    const readme = tree.docCruft.docs.find(d => d.path === 'README.md')
    t.ok(readme, 'README.md found')

    const helperDetail = readme.references.details.find(d => d.ref === 'UtilsHelper')
    t.ok(helperDetail, 'UtilsHelper reference extracted')
    t.is(helperDetail.status, 'valid', 'UtilsHelper resolves as valid via sibling')

    const formatDetail = readme.references.details.find(d => d.ref === 'formatDate()')
    t.ok(formatDetail, 'formatDate() reference extracted')
    t.is(formatDetail.status, 'valid', 'formatDate() resolves as valid via sibling')
  } finally {
    cleanup(parent)
  }
})