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)
}
})