OverviewFilesCommitsIssuesDocsReleasesPeers

codetree commits / 18ff382

Phase 0b — cross-filesystem dep crawling
Makes 'codetree docs <project>' resolve refs to sibling-dir deps against real code instead of flagging them missing or silencing via the externals registry. New lib/deps-crawler.js: - crawlDeps(rootDir, config) produces { roots, unresolved, sources } - Three sources merged in order: 1. config.siblingDirs — explicit 2. package.json "file:..." deps — always honored 3. config.autoCrawl + bare-name deps — walk up to 2 ancestors for sibling dirs matching by basename OR package.json "name" field - Guards: skip /tmp,/var,/usr,/etc,...; skip dirs with >100 entries (those aren't monorepo roots); skip known-npm packages during autoCrawl scanner.js wires crawler in: - Primary scan unchanged - Each crawler-discovered root gets its own independent scan - Modules merged into primary graph (primary wins on collision) - Extra modules flagged with _external: true, _externalRoot - tree.crawl exposes { roots, unresolved, sources, addedModuleCount } CLI flags in index.js: --auto-crawl Enable crawler (overrides config) --no-auto-crawl Disable crawler --sibling-dirs a,b Explicit extra roots (comma-sep, relative to root) Impact on pear-git-v3's doc-cruft report (with .codetree.json shipped in that repo this commit): Before: 38 valid / 633 missing / 25 stale After: 69 valid / 552 missing / 15 stale Remaining drift is genuine and will be reconciled in Phase 1. 84/84 tests, 442/442 asserts pass (up from 73/419). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
18ff382f46b14dbc333cebc625f9c0d3f71ee8ae
by plankatron <fleeky@gmail.com> · 2026-04-16T16:39:53.000Z

Changed files (4) +562 -1

index.js +18 -0
……@@ -46,6 +46,19 @@ if (args.includes('--no-watch')) {
4646 opts.watch = false
4747}
4848
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).
52const cli = {}
53if (args.includes('--auto-crawl')) cli.autoCrawl = true
54if (args.includes('--no-auto-crawl')) cli.autoCrawl = false
55if (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}
60if (Object.keys(cli).length > 0) opts.cli = cli
61
4962async function main () {
5063 switch (command) {
5164 case 'scan': return cmdScan()
……@@ -438,6 +451,11 @@ Options:
438451 --ignore <patterns> Extra ignore patterns, comma-separated, gitignore-style
439452 --shallow Only parse each module's entry file (no cross-file analysis).
440453 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
441459
442460Ignore sources (merged, in this order):
443461 built-in defaults (node_modules, .git, vendor, .pear-git, .claude, build, dist)
lib/deps-crawler.js +176 -0
……@@ -0,0 +1,176 @@
1import { readFileSync, existsSync, statSync, readdirSync } from 'fs'
2import { 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 */
27export 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
93function 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`.
110const 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.
119const 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 */
132function 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}
lib/scanner.js +55 -1
……@@ -9,6 +9,7 @@ import { validate } from './validator.js'
99import { detectDocCruft } from './doc-cruft.js'
1010import { buildIgnoreMatcher } from './ignore.js'
1111import { loadConfig, applyCliOverrides } from './config.js'
12import { crawlDeps } from './deps-crawler.js'
1213
1314// Re-export for consumers that want the push-based source directly
1415export { scanStream } from './scan-stream.js'
……@@ -40,7 +41,7 @@ export async function scan (rootDir, opts = {}) {
4041 const baseConfig = loadConfig(rootDir)
4142 const config = opts.cli ? applyCliOverrides(baseConfig, opts.cli) : baseConfig
4243
43 // Drive the event source and reduce events into a graph.
44 // Drive the primary scan.
4445 const graph = await new Promise((resolve, reject) => {
4546 const source = scanStream(rootDir, { ...opts, ignore, shallow })
4647 const tree = new TreeReducer().attach(source)
……@@ -48,6 +49,51 @@ export async function scan (rootDir, opts = {}) {
4849 source.once('error', reject)
4950 })
5051
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
5197 const tree = buildTree(graph)
5298
5399 // Mark the tree so consumers know whether data is complete or shallow.
……@@ -58,6 +104,14 @@ export async function scan (rootDir, opts = {}) {
58104 // but JSON serialization ignores it.
59105 Object.defineProperty(tree, '__graph', { value: graph, enumerable: false })
60106
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
61115 // Doc cruft walks every .md file and cross-references against the tree.
62116 // Skip in shallow mode — it's one of the most expensive steps and the
63117 // tree doesn't have the structural detail needed for meaningful checks.
test/deps-crawler.test.js +313 -0
……@@ -0,0 +1,313 @@
1import { test } from 'brittle'
2import { mkdirSync, writeFileSync, rmSync } from 'fs'
3import { tmpdir } from 'os'
4import { join } from 'path'
5
6import { crawlDeps } from '../lib/deps-crawler.js'
7import { getDefaults } from '../lib/config.js'
8import { 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
18function mkFixture (name) {
19 const dir = join(tmpdir(), 'codetree-crawler-' + name + '-' + Date.now())
20 mkdirSync(dir, { recursive: true })
21 return dir
22}
23
24function cleanup (dir) {
25 rmSync(dir, { recursive: true, force: true })
26}
27
28// --- source 1: explicit siblingDirs ---
29
30test('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
48test('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
64test('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
89test('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
111test('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
141test('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
167test('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
191test('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
210test('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
236test('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
270test('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})