| … | … | @@ -46,6 +46,19 @@ if (args.includes('--no-watch')) { |
| 46 | 46 | opts.watch = false |
| 47 | 47 | } |
| 48 | 48 | |
| 49 | // Cross-filesystem dep crawling (Phase 0b). CLI flags override .codetree.json. | |
| 50 | // --auto-crawl enables, --no-auto-crawl disables, --sibling-dirs adds explicit | |
| 51 | // extra scan roots (comma-separated). | |
| 52 | const cli = {} | |
| 53 | if (args.includes('--auto-crawl')) cli.autoCrawl = true | |
| 54 | if (args.includes('--no-auto-crawl')) cli.autoCrawl = false | |
| 55 | if (args.includes('--sibling-dirs')) { | |
| 56 | const idx = args.indexOf('--sibling-dirs') | |
| 57 | const raw = args[idx + 1] || '' | |
| 58 | cli.siblingDirs = raw.split(',').map(s => s.trim()).filter(Boolean) | |
| 59 | } | |
| 60 | if (Object.keys(cli).length > 0) opts.cli = cli | |
| 61 | ||
| 49 | 62 | async function main () { |
| 50 | 63 | switch (command) { |
| 51 | 64 | case 'scan': return cmdScan() |
| … | … | @@ -438,6 +451,11 @@ Options: |
| 438 | 451 | --ignore <patterns> Extra ignore patterns, comma-separated, gitignore-style |
| 439 | 452 | --shallow Only parse each module's entry file (no cross-file analysis). |
| 440 | 453 | 10-100x faster for multi-project/recursive scans. |
| 454 | --auto-crawl Enable cross-filesystem dep crawling (follows package.json | |
| 455 | file:../foo deps + walks up 2 ancestors for sibling deps) | |
| 456 | --no-auto-crawl Disable auto-crawl (overrides .codetree.json setting) | |
| 457 | --sibling-dirs a,b Explicit extra scan roots, comma-separated, resolved | |
| 458 | relative to the scan root | |
| 441 | 459 | |
| 442 | 460 | Ignore sources (merged, in this order): |
| 443 | 461 | built-in defaults (node_modules, .git, vendor, .pear-git, .claude, build, dist) |
| … | … | @@ -0,0 +1,176 @@ |
| 1 | import { readFileSync, existsSync, statSync, readdirSync } from 'fs' | |
| 2 | import { join, resolve, dirname, basename } from 'path' | |
| 3 | ||
| 4 | /** | |
| 5 | * Discover additional scan roots so dependency references (anproto, | |
| 6 | * hyper-rooms, etc.) can resolve against real code instead of being | |
| 7 | * flagged as missing or silenced via config.externalSymbols. | |
| 8 | * | |
| 9 | * Three sources, in order: | |
| 10 | * 1. config.siblingDirs — explicit paths, always honored. | |
| 11 | * 2. package.json "file:..." deps — always honored (the project has | |
| 12 | * literally pointed at a path for this dep). | |
| 13 | * 3. config.autoCrawl — walk up N ancestors looking for sibling | |
| 14 | * directories that match bare-named deps by directory name or | |
| 15 | * package.json name field. | |
| 16 | * | |
| 17 | * Returns: | |
| 18 | * { | |
| 19 | * roots: string[] — absolute paths of extra scan roots | |
| 20 | * unresolved: string[] — dep names we tried to resolve but couldn't | |
| 21 | * sources: { root: string } — which source located each root (for debug/UI) | |
| 22 | * } | |
| 23 | * | |
| 24 | * Does NOT include the primary rootDir in the returned list; callers | |
| 25 | * scan rootDir separately. | |
| 26 | */ | |
| 27 | export function crawlDeps (rootDir, config, opts = {}) { | |
| 28 | const root = resolve(rootDir) | |
| 29 | const crawl = { | |
| 30 | roots: [], | |
| 31 | unresolved: [], | |
| 32 | sources: {} | |
| 33 | } | |
| 34 | ||
| 35 | // --- 1. Explicit siblingDirs --- | |
| 36 | for (const raw of (config.siblingDirs || [])) { | |
| 37 | const abs = resolve(root, raw) | |
| 38 | if (!tryAddRoot(abs, crawl, 'siblingDirs')) continue | |
| 39 | } | |
| 40 | ||
| 41 | // --- 2. package.json file:... deps --- | |
| 42 | const pkgPath = join(root, 'package.json') | |
| 43 | let pkg = null | |
| 44 | if (existsSync(pkgPath)) { | |
| 45 | try { | |
| 46 | pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) | |
| 47 | } catch { | |
| 48 | pkg = null | |
| 49 | } | |
| 50 | } | |
| 51 | ||
| 52 | if (pkg) { | |
| 53 | const allDeps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) } | |
| 54 | for (const [name, spec] of Object.entries(allDeps)) { | |
| 55 | if (typeof spec !== 'string') continue | |
| 56 | if (!spec.startsWith('file:')) continue | |
| 57 | ||
| 58 | const relPath = spec.slice('file:'.length) | |
| 59 | const abs = resolve(root, relPath) | |
| 60 | tryAddRoot(abs, crawl, 'package.json file: ' + name) | |
| 61 | } | |
| 62 | } | |
| 63 | ||
| 64 | // --- 3. autoCrawl: bare-name deps → walk up, check siblings --- | |
| 65 | if (config.autoCrawl && pkg) { | |
| 66 | const ancestors = opts.maxAncestors || 2 | |
| 67 | const excludeNpm = new Set(opts.excludeKnownNpm || [ | |
| 68 | // Things we know come from node_modules; no point crawling for them | |
| 69 | 'hypercore', 'hyperswarm', 'hyperbee', 'hyperdrive', 'hyperdht', | |
| 70 | 'corestore', 'protomux', 'compact-encoding', 'hypercore-crypto', | |
| 71 | 'b4a', 'brittle', 'safety-catch', 'bare', 'paparam', | |
| 72 | // Placeholder for any bare-* npm: | |
| 73 | ]) | |
| 74 | ||
| 75 | const allDeps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) } | |
| 76 | for (const [name, spec] of Object.entries(allDeps)) { | |
| 77 | if (typeof spec !== 'string') continue | |
| 78 | if (spec.startsWith('file:')) continue // already handled | |
| 79 | if (excludeNpm.has(name)) continue | |
| 80 | ||
| 81 | const found = findSiblingByName(root, name, ancestors) | |
| 82 | if (found && tryAddRoot(found, crawl, 'autoCrawl: ' + name)) { | |
| 83 | // Added. | |
| 84 | } else { | |
| 85 | crawl.unresolved.push(name) | |
| 86 | } | |
| 87 | } | |
| 88 | } | |
| 89 | ||
| 90 | return crawl | |
| 91 | } | |
| 92 | ||
| 93 | function tryAddRoot (abs, crawl, sourceLabel) { | |
| 94 | if (!abs) return false | |
| 95 | if (crawl.roots.includes(abs)) return false | |
| 96 | ||
| 97 | // Must exist + be a directory | |
| 98 | let stat | |
| 99 | try { stat = statSync(abs) } catch { return false } | |
| 100 | if (!stat.isDirectory()) return false | |
| 101 | ||
| 102 | crawl.roots.push(abs) | |
| 103 | if (!crawl.sources[abs]) crawl.sources[abs] = sourceLabel | |
| 104 | return true | |
| 105 | } | |
| 106 | ||
| 107 | // System directories we never want to treat as a "siblings-of-project" | |
| 108 | // location. Walking these is slow and semantically wrong — a dep would | |
| 109 | // never live at `/tmp/hyper-rooms`. | |
| 110 | const SYSTEM_DIRS = new Set([ | |
| 111 | '/', '/tmp', '/var', '/usr', '/etc', '/opt', '/srv', '/home', '/root', | |
| 112 | '/bin', '/sbin', '/lib', '/lib64', '/dev', '/proc', '/sys', '/run', | |
| 113 | '/mnt', '/media', '/Applications', '/Users', '/Volumes' | |
| 114 | ]) | |
| 115 | ||
| 116 | // Cap siblings we scan per directory. Real project-adjacent locations have | |
| 117 | // a handful; /tmp has thousands. If a directory is over this cap it's almost | |
| 118 | // certainly not a monorepo root. | |
| 119 | const MAX_SIBLINGS_PER_DIR = 100 | |
| 120 | ||
| 121 | /** | |
| 122 | * Walk up N ancestors from `start`. At each level, check every sibling | |
| 123 | * directory for a match. Match if: | |
| 124 | * - directory basename === name, OR | |
| 125 | * - that dir's package.json "name" field === name | |
| 126 | * | |
| 127 | * Returns absolute path on first match, null otherwise. | |
| 128 | * | |
| 129 | * Skips system directories (/tmp, /usr, etc.) and directories with too | |
| 130 | * many children — those aren't project-sibling locations. | |
| 131 | */ | |
| 132 | function findSiblingByName (start, name, maxAncestors) { | |
| 133 | let current = start | |
| 134 | for (let i = 0; i <= maxAncestors; i++) { | |
| 135 | const parent = dirname(current) | |
| 136 | if (parent === current) break // hit FS root | |
| 137 | if (SYSTEM_DIRS.has(parent)) break // don't scan system locations | |
| 138 | ||
| 139 | let siblings | |
| 140 | try { siblings = readdirSync(parent) } catch { break } | |
| 141 | ||
| 142 | // Skip if the directory looks like a non-project bucket (too many entries). | |
| 143 | if (siblings.length > MAX_SIBLINGS_PER_DIR) { | |
| 144 | current = parent | |
| 145 | continue | |
| 146 | } | |
| 147 | ||
| 148 | for (const entry of siblings) { | |
| 149 | // Skip obvious junk | |
| 150 | if (entry.startsWith('.')) continue | |
| 151 | if (entry === 'node_modules') continue | |
| 152 | ||
| 153 | const candidate = join(parent, entry) | |
| 154 | let stat | |
| 155 | try { stat = statSync(candidate) } catch { continue } | |
| 156 | if (!stat.isDirectory()) continue | |
| 157 | ||
| 158 | // Match by basename | |
| 159 | if (entry === name) return candidate | |
| 160 | ||
| 161 | // Match by package.json "name" | |
| 162 | const pkgPath = join(candidate, 'package.json') | |
| 163 | if (existsSync(pkgPath)) { | |
| 164 | try { | |
| 165 | const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) | |
| 166 | if (pkg && pkg.name === name) return candidate | |
| 167 | } catch { | |
| 168 | // skip malformed package.json | |
| 169 | } | |
| 170 | } | |
| 171 | } | |
| 172 | ||
| 173 | current = parent | |
| 174 | } | |
| 175 | return null | |
| 176 | } |
| … | … | @@ -9,6 +9,7 @@ import { validate } from './validator.js' |
| 9 | 9 | import { detectDocCruft } from './doc-cruft.js' |
| 10 | 10 | import { buildIgnoreMatcher } from './ignore.js' |
| 11 | 11 | import { loadConfig, applyCliOverrides } from './config.js' |
| 12 | import { crawlDeps } from './deps-crawler.js' | |
| 12 | 13 | |
| 13 | 14 | // Re-export for consumers that want the push-based source directly |
| 14 | 15 | export { scanStream } from './scan-stream.js' |
| … | … | @@ -40,7 +41,7 @@ export async function scan (rootDir, opts = {}) { |
| 40 | 41 | const baseConfig = loadConfig(rootDir) |
| 41 | 42 | const config = opts.cli ? applyCliOverrides(baseConfig, opts.cli) : baseConfig |
| 42 | 43 | |
| 43 | // Drive the event source and reduce events into a graph. | |
| 44 | // Drive the primary scan. | |
| 44 | 45 | const graph = await new Promise((resolve, reject) => { |
| 45 | 46 | const source = scanStream(rootDir, { ...opts, ignore, shallow }) |
| 46 | 47 | const tree = new TreeReducer().attach(source) |
| … | … | @@ -48,6 +49,51 @@ export async function scan (rootDir, opts = {}) { |
| 48 | 49 | source.once('error', reject) |
| 49 | 50 | }) |
| 50 | 51 | |
| 52 | // Cross-filesystem dep crawling. Produces extra roots (explicit siblings | |
| 53 | // + package.json file: deps + optional autoCrawl via sibling search). | |
| 54 | // Each extra root is scanned independently, modules merged by name into | |
| 55 | // the primary graph. The primary root, files list, and dirTree stay | |
| 56 | // anchored to the primary — only the `modules` map grows. | |
| 57 | const extraScans = [] | |
| 58 | const crawl = crawlDeps(rootDir, config, {}) | |
| 59 | if (crawl.roots.length > 0) { | |
| 60 | for (const extraRoot of crawl.roots) { | |
| 61 | try { | |
| 62 | const extraGraph = await new Promise((res, rej) => { | |
| 63 | const extraIgnore = buildIgnoreMatcher(extraRoot, opts.ignore || []) | |
| 64 | const src = scanStream(extraRoot, { ...opts, ignore: extraIgnore, shallow }) | |
| 65 | const r = new TreeReducer().attach(src) | |
| 66 | src.once('scan-end', () => res(r.finalize())) | |
| 67 | src.once('error', rej) | |
| 68 | }) | |
| 69 | extraScans.push({ root: extraRoot, graph: extraGraph, source: crawl.sources[extraRoot] }) | |
| 70 | } catch (err) { | |
| 71 | // Extra roots shouldn't break the primary scan. Record but continue. | |
| 72 | // eslint-disable-next-line no-console | |
| 73 | console.warn('[codetree] extra-root scan failed for ' + extraRoot + ': ' + err.message) | |
| 74 | } | |
| 75 | } | |
| 76 | } | |
| 77 | ||
| 78 | // Merge each extra graph's modules into the primary graph. Modules already | |
| 79 | // present (same name) in the primary are NOT overridden — primary wins. | |
| 80 | // graph.modules is a Map (per TreeReducer.finalize). Flag extra-root modules | |
| 81 | // so downstream consumers (UI, validator) can distinguish them. | |
| 82 | let extraModulesAdded = 0 | |
| 83 | for (const extra of extraScans) { | |
| 84 | if (!extra.graph.modules) continue | |
| 85 | for (const [modName, modData] of extra.graph.modules) { | |
| 86 | if (!graph.modules.has(modName)) { | |
| 87 | graph.modules.set(modName, { | |
| 88 | ...modData, | |
| 89 | _external: true, | |
| 90 | _externalRoot: extra.root | |
| 91 | }) | |
| 92 | extraModulesAdded++ | |
| 93 | } | |
| 94 | } | |
| 95 | } | |
| 96 | ||
| 51 | 97 | const tree = buildTree(graph) |
| 52 | 98 | |
| 53 | 99 | // Mark the tree so consumers know whether data is complete or shallow. |
| … | … | @@ -58,6 +104,14 @@ export async function scan (rootDir, opts = {}) { |
| 58 | 104 | // but JSON serialization ignores it. |
| 59 | 105 | Object.defineProperty(tree, '__graph', { value: graph, enumerable: false }) |
| 60 | 106 | |
| 107 | // Record what crawling found so consumers can display or diagnose it. | |
| 108 | tree.crawl = { | |
| 109 | roots: crawl.roots, | |
| 110 | unresolved: crawl.unresolved, | |
| 111 | sources: crawl.sources, | |
| 112 | addedModuleCount: extraModulesAdded | |
| 113 | } | |
| 114 | ||
| 61 | 115 | // Doc cruft walks every .md file and cross-references against the tree. |
| 62 | 116 | // Skip in shallow mode — it's one of the most expensive steps and the |
| 63 | 117 | // tree doesn't have the structural detail needed for meaningful checks. |
| … | … | @@ -0,0 +1,313 @@ |
| 1 | import { test } from 'brittle' | |
| 2 | import { mkdirSync, writeFileSync, rmSync } from 'fs' | |
| 3 | import { tmpdir } from 'os' | |
| 4 | import { join } from 'path' | |
| 5 | ||
| 6 | import { crawlDeps } from '../lib/deps-crawler.js' | |
| 7 | import { getDefaults } from '../lib/config.js' | |
| 8 | import { scan } from '../lib/scanner.js' | |
| 9 | ||
| 10 | /** | |
| 11 | * Phase 0b — cross-filesystem dep crawling. | |
| 12 | * Crawler takes (rootDir, config) → extra scan roots from three sources: | |
| 13 | * 1. config.siblingDirs (explicit) | |
| 14 | * 2. package.json "file:..." deps | |
| 15 | * 3. autoCrawl: walk ancestors looking for sibling dirs matching dep names | |
| 16 | */ | |
| 17 | ||
| 18 | function mkFixture (name) { | |
| 19 | const dir = join(tmpdir(), 'codetree-crawler-' + name + '-' + Date.now()) | |
| 20 | mkdirSync(dir, { recursive: true }) | |
| 21 | return dir | |
| 22 | } | |
| 23 | ||
| 24 | function cleanup (dir) { | |
| 25 | rmSync(dir, { recursive: true, force: true }) | |
| 26 | } | |
| 27 | ||
| 28 | // --- source 1: explicit siblingDirs --- | |
| 29 | ||
| 30 | test('crawler: explicit siblingDirs are resolved', (t) => { | |
| 31 | const root = mkFixture('s1-explicit') | |
| 32 | try { | |
| 33 | const sibling = join(root, '..', 'sibling-' + Date.now()) | |
| 34 | mkdirSync(sibling, { recursive: true }) | |
| 35 | ||
| 36 | const config = { ...getDefaults(), siblingDirs: [sibling] } | |
| 37 | const result = crawlDeps(root, config) | |
| 38 | ||
| 39 | t.ok(result.roots.includes(sibling), 'explicit sibling in roots') | |
| 40 | t.ok(result.sources[sibling].startsWith('siblingDirs'), 'source labeled siblingDirs') | |
| 41 | ||
| 42 | cleanup(sibling) | |
| 43 | } finally { | |
| 44 | cleanup(root) | |
| 45 | } | |
| 46 | }) | |
| 47 | ||
| 48 | test('crawler: nonexistent siblingDirs are silently dropped', (t) => { | |
| 49 | const root = mkFixture('s1-missing') | |
| 50 | try { | |
| 51 | const config = { | |
| 52 | ...getDefaults(), | |
| 53 | siblingDirs: [join(root, '..', 'does-not-exist-' + Date.now())] | |
| 54 | } | |
| 55 | const result = crawlDeps(root, config) | |
| 56 | t.is(result.roots.length, 0, 'nonexistent path not added') | |
| 57 | } finally { | |
| 58 | cleanup(root) | |
| 59 | } | |
| 60 | }) | |
| 61 | ||
| 62 | // --- source 2: package.json file: deps --- | |
| 63 | ||
| 64 | test('crawler: file:../foo deps in package.json resolve to absolute paths', (t) => { | |
| 65 | const root = mkFixture('s2-file') | |
| 66 | try { | |
| 67 | const dep = join(root, '..', 's2-dep-' + Date.now()) | |
| 68 | mkdirSync(dep, { recursive: true }) | |
| 69 | ||
| 70 | writeFileSync(join(root, 'package.json'), JSON.stringify({ | |
| 71 | name: 'test', version: '0.1.0', | |
| 72 | dependencies: { | |
| 73 | 'local-dep': 'file:' + dep | |
| 74 | } | |
| 75 | })) | |
| 76 | ||
| 77 | const config = getDefaults() | |
| 78 | const result = crawlDeps(root, config) | |
| 79 | ||
| 80 | t.ok(result.roots.includes(dep), 'file: dep resolved') | |
| 81 | t.ok(result.sources[dep].startsWith('package.json file:'), 'source labeled') | |
| 82 | ||
| 83 | cleanup(dep) | |
| 84 | } finally { | |
| 85 | cleanup(root) | |
| 86 | } | |
| 87 | }) | |
| 88 | ||
| 89 | test('crawler: file: with relative path resolves correctly', (t) => { | |
| 90 | const root = mkFixture('s2-relative') | |
| 91 | try { | |
| 92 | const dep = join(root, 'local-dep') | |
| 93 | mkdirSync(dep, { recursive: true }) | |
| 94 | ||
| 95 | writeFileSync(join(root, 'package.json'), JSON.stringify({ | |
| 96 | name: 'test', version: '0.1.0', | |
| 97 | dependencies: { | |
| 98 | 'local-dep': 'file:./local-dep' | |
| 99 | } | |
| 100 | })) | |
| 101 | ||
| 102 | const result = crawlDeps(root, getDefaults()) | |
| 103 | t.ok(result.roots.includes(dep), 'relative file: resolved') | |
| 104 | } finally { | |
| 105 | cleanup(root) | |
| 106 | } | |
| 107 | }) | |
| 108 | ||
| 109 | // --- source 3: autoCrawl --- | |
| 110 | ||
| 111 | test('crawler: autoCrawl finds sibling dir by basename', (t) => { | |
| 112 | // Parent dir with: | |
| 113 | // parent/ | |
| 114 | // root/ ← scanning this, depends on "shared" | |
| 115 | // shared/ ← should be found | |
| 116 | const parent = mkFixture('s3-parent') | |
| 117 | try { | |
| 118 | const root = join(parent, 'root') | |
| 119 | const shared = join(parent, 'shared') | |
| 120 | mkdirSync(root, { recursive: true }) | |
| 121 | mkdirSync(shared, { recursive: true }) | |
| 122 | ||
| 123 | writeFileSync(join(root, 'package.json'), JSON.stringify({ | |
| 124 | name: 'test', version: '0.1.0', | |
| 125 | dependencies: { 'shared': '^1.0.0' } | |
| 126 | })) | |
| 127 | writeFileSync(join(shared, 'package.json'), JSON.stringify({ | |
| 128 | name: 'shared', version: '1.0.0' | |
| 129 | })) | |
| 130 | ||
| 131 | const config = { ...getDefaults(), autoCrawl: true } | |
| 132 | const result = crawlDeps(root, config) | |
| 133 | ||
| 134 | t.ok(result.roots.includes(shared), 'sibling dir found by basename') | |
| 135 | t.ok(result.sources[shared].startsWith('autoCrawl'), 'source labeled autoCrawl') | |
| 136 | } finally { | |
| 137 | cleanup(parent) | |
| 138 | } | |
| 139 | }) | |
| 140 | ||
| 141 | test('crawler: autoCrawl finds sibling by package.json name field', (t) => { | |
| 142 | const parent = mkFixture('s3-pkgname') | |
| 143 | try { | |
| 144 | const root = join(parent, 'root') | |
| 145 | // Directory basename differs from package name | |
| 146 | const sharedDir = join(parent, 'my-shared-lib') | |
| 147 | mkdirSync(root, { recursive: true }) | |
| 148 | mkdirSync(sharedDir, { recursive: true }) | |
| 149 | ||
| 150 | writeFileSync(join(root, 'package.json'), JSON.stringify({ | |
| 151 | name: 'test', version: '0.1.0', | |
| 152 | dependencies: { '@scope/shared': '^1.0.0' } | |
| 153 | })) | |
| 154 | writeFileSync(join(sharedDir, 'package.json'), JSON.stringify({ | |
| 155 | name: '@scope/shared', version: '1.0.0' | |
| 156 | })) | |
| 157 | ||
| 158 | const config = { ...getDefaults(), autoCrawl: true } | |
| 159 | const result = crawlDeps(root, config) | |
| 160 | ||
| 161 | t.ok(result.roots.includes(sharedDir), 'sibling dir found by package.json name') | |
| 162 | } finally { | |
| 163 | cleanup(parent) | |
| 164 | } | |
| 165 | }) | |
| 166 | ||
| 167 | test('crawler: autoCrawl disabled by default', (t) => { | |
| 168 | const parent = mkFixture('s3-disabled') | |
| 169 | try { | |
| 170 | const root = join(parent, 'root') | |
| 171 | const shared = join(parent, 'shared') | |
| 172 | mkdirSync(root, { recursive: true }) | |
| 173 | mkdirSync(shared, { recursive: true }) | |
| 174 | ||
| 175 | writeFileSync(join(root, 'package.json'), JSON.stringify({ | |
| 176 | name: 'test', version: '0.1.0', | |
| 177 | dependencies: { 'shared': '^1.0.0' } | |
| 178 | })) | |
| 179 | writeFileSync(join(shared, 'package.json'), JSON.stringify({ | |
| 180 | name: 'shared', version: '1.0.0' | |
| 181 | })) | |
| 182 | ||
| 183 | // Default autoCrawl: false | |
| 184 | const result = crawlDeps(root, getDefaults()) | |
| 185 | t.not(result.roots.includes(shared), 'sibling NOT found when autoCrawl disabled') | |
| 186 | } finally { | |
| 187 | cleanup(parent) | |
| 188 | } | |
| 189 | }) | |
| 190 | ||
| 191 | test('crawler: autoCrawl reports unresolvable deps', (t) => { | |
| 192 | const parent = mkFixture('s3-unresolved') | |
| 193 | try { | |
| 194 | const root = join(parent, 'root') | |
| 195 | mkdirSync(root, { recursive: true }) | |
| 196 | ||
| 197 | writeFileSync(join(root, 'package.json'), JSON.stringify({ | |
| 198 | name: 'test', version: '0.1.0', | |
| 199 | dependencies: { 'does-not-exist': '^1.0.0' } | |
| 200 | })) | |
| 201 | ||
| 202 | const config = { ...getDefaults(), autoCrawl: true } | |
| 203 | const result = crawlDeps(root, config) | |
| 204 | t.ok(result.unresolved.includes('does-not-exist'), 'unresolvable dep reported') | |
| 205 | } finally { | |
| 206 | cleanup(parent) | |
| 207 | } | |
| 208 | }) | |
| 209 | ||
| 210 | test('crawler: known npm packages are skipped during autoCrawl', (t) => { | |
| 211 | const parent = mkFixture('s3-npm') | |
| 212 | try { | |
| 213 | const root = join(parent, 'root') | |
| 214 | mkdirSync(root, { recursive: true }) | |
| 215 | ||
| 216 | writeFileSync(join(root, 'package.json'), JSON.stringify({ | |
| 217 | name: 'test', version: '0.1.0', | |
| 218 | dependencies: { | |
| 219 | 'hypercore': '^10.0.0', | |
| 220 | 'b4a': '^1.0.0' | |
| 221 | } | |
| 222 | })) | |
| 223 | ||
| 224 | const config = { ...getDefaults(), autoCrawl: true } | |
| 225 | const result = crawlDeps(root, config) | |
| 226 | // Known npm packages shouldn't even appear in unresolved — they're skipped outright | |
| 227 | t.not(result.unresolved.includes('hypercore'), 'hypercore skipped (known npm)') | |
| 228 | t.not(result.unresolved.includes('b4a'), 'b4a skipped (known npm)') | |
| 229 | } finally { | |
| 230 | cleanup(parent) | |
| 231 | } | |
| 232 | }) | |
| 233 | ||
| 234 | // --- end-to-end with scanner --- | |
| 235 | ||
| 236 | test('scanner: extra-root modules are merged into tree with _external flag', async (t) => { | |
| 237 | const parent = mkFixture('e2e-merge') | |
| 238 | try { | |
| 239 | const root = join(parent, 'root') | |
| 240 | const sibling = join(parent, 'sibling-module') | |
| 241 | mkdirSync(root, { recursive: true }) | |
| 242 | mkdirSync(sibling, { recursive: true }) | |
| 243 | ||
| 244 | writeFileSync(join(root, 'package.json'), JSON.stringify({ | |
| 245 | name: 'myproject', version: '0.1.0', main: 'index.js' | |
| 246 | })) | |
| 247 | writeFileSync(join(root, 'index.js'), 'export function local () {}\n') | |
| 248 | ||
| 249 | writeFileSync(join(sibling, 'package.json'), JSON.stringify({ | |
| 250 | name: 'sibling-module', version: '0.1.0', main: 'index.js' | |
| 251 | })) | |
| 252 | writeFileSync(join(sibling, 'index.js'), 'export function helloFromSibling () {}\n') | |
| 253 | ||
| 254 | writeFileSync(join(root, '.codetree.json'), JSON.stringify({ | |
| 255 | siblingDirs: [sibling] | |
| 256 | })) | |
| 257 | ||
| 258 | const tree = await scan(root) | |
| 259 | t.ok(tree.modules.myproject, 'primary module present') | |
| 260 | t.ok(tree.modules['sibling-module'], 'sibling module merged in') | |
| 261 | ||
| 262 | t.ok(tree.crawl, 'crawl metadata attached') | |
| 263 | t.ok(tree.crawl.roots.includes(sibling), 'sibling in crawl.roots') | |
| 264 | t.ok(tree.crawl.addedModuleCount >= 1, 'added module count tracked') | |
| 265 | } finally { | |
| 266 | cleanup(parent) | |
| 267 | } | |
| 268 | }) | |
| 269 | ||
| 270 | test('scanner: refs to sibling exports resolve as valid, not missing', async (t) => { | |
| 271 | const parent = mkFixture('e2e-refs') | |
| 272 | try { | |
| 273 | const root = join(parent, 'root') | |
| 274 | const sibling = join(parent, 'utils-lib') | |
| 275 | mkdirSync(root, { recursive: true }) | |
| 276 | mkdirSync(sibling, { recursive: true }) | |
| 277 | ||
| 278 | writeFileSync(join(root, 'package.json'), JSON.stringify({ | |
| 279 | name: 'main', version: '0.1.0', main: 'index.js' | |
| 280 | })) | |
| 281 | writeFileSync(join(root, 'index.js'), 'export function main () {}\n') | |
| 282 | ||
| 283 | writeFileSync(join(sibling, 'package.json'), JSON.stringify({ | |
| 284 | name: 'utils-lib', version: '0.1.0', main: 'index.js' | |
| 285 | })) | |
| 286 | writeFileSync(join(sibling, 'index.js'), | |
| 287 | 'export class UtilsHelper {\n doStuff () {}\n}\nexport function formatDate () {}\n' | |
| 288 | ) | |
| 289 | ||
| 290 | writeFileSync(join(root, '.codetree.json'), JSON.stringify({ | |
| 291 | siblingDirs: [sibling] | |
| 292 | })) | |
| 293 | ||
| 294 | writeFileSync(join(root, 'README.md'), | |
| 295 | '# main\n\n' + | |
| 296 | 'Uses `UtilsHelper` and `formatDate()` from utils-lib.\n' | |
| 297 | ) | |
| 298 | ||
| 299 | const tree = await scan(root) | |
| 300 | const readme = tree.docCruft.docs.find(d => d.path === 'README.md') | |
| 301 | t.ok(readme, 'README.md found') | |
| 302 | ||
| 303 | const helperDetail = readme.references.details.find(d => d.ref === 'UtilsHelper') | |
| 304 | t.ok(helperDetail, 'UtilsHelper reference extracted') | |
| 305 | t.is(helperDetail.status, 'valid', 'UtilsHelper resolves as valid via sibling') | |
| 306 | ||
| 307 | const formatDetail = readme.references.details.find(d => d.ref === 'formatDate()') | |
| 308 | t.ok(formatDetail, 'formatDate() reference extracted') | |
| 309 | t.is(formatDetail.status, 'valid', 'formatDate() resolves as valid via sibling') | |
| 310 | } finally { | |
| 311 | cleanup(parent) | |
| 312 | } | |
| 313 | }) |