(
graph: ImportGraph,
seeds: string[],
opts: { hops?: number; maxFiles?: number; hubDamping?: boolean } = {},
)
| 214 | * since "how does X work / why is X broken" usually lives in what X uses. |
| 215 | */ |
| 216 | export function expandViaGraph( |
| 217 | graph: ImportGraph, |
| 218 | seeds: string[], |
| 219 | opts: { hops?: number; maxFiles?: number; hubDamping?: boolean } = {}, |
| 220 | ): Array<{ file: string; distance: number; weight: number }> { |
| 221 | const hops = opts.hops ?? 1; |
| 222 | const maxFiles = opts.maxFiles ?? 12; |
| 223 | const hubDamping = opts.hubDamping !== false; |
| 224 | |
| 225 | // distance + which direction we first reached it (down=dependency, up=dependent) |
| 226 | const dist = new Map<string, number>(); |
| 227 | const viaDownstream = new Map<string, boolean>(); |
| 228 | for (const s of seeds) if (graph.files.has(s)) { dist.set(s, 0); viaDownstream.set(s, true); } |
| 229 | |
| 230 | let frontier = [...dist.keys()]; |
| 231 | for (let h = 1; h <= hops; h++) { |
| 232 | const next: string[] = []; |
| 233 | for (const f of frontier) { |
| 234 | const downstream = graph.out.get(f) ?? new Set<string>(); |
| 235 | const upstream = graph.in.get(f) ?? new Set<string>(); |
| 236 | for (const nbr of downstream) { |
| 237 | if (!dist.has(nbr)) { dist.set(nbr, h); viaDownstream.set(nbr, true); next.push(nbr); } |
| 238 | } |
| 239 | for (const nbr of upstream) { |
| 240 | if (!dist.has(nbr)) { dist.set(nbr, h); viaDownstream.set(nbr, false); next.push(nbr); } |
| 241 | } |
| 242 | } |
| 243 | frontier = next; |
| 244 | } |
| 245 | |
| 246 | const inDegreeOf = (f: string): number => (graph.in.get(f)?.size ?? 0); |
| 247 | |
| 248 | const scored = [...dist.entries()].map(([file, distance]) => { |
| 249 | // Base relevance decays with distance; seeds are 1.0. |
| 250 | let weight = distance === 0 ? 1.0 : 1.0 / (distance + 1); |
| 251 | // Downstream (dependencies) are more relevant than upstream (dependents). |
| 252 | if (distance > 0 && !viaDownstream.get(file)) weight *= 0.6; |
| 253 | // Hub damping: demote files imported by many others (low query specificity). |
| 254 | if (hubDamping && distance > 0) { |
| 255 | const deg = inDegreeOf(file); |
| 256 | weight *= 1 / Math.log2(2 + deg); |
| 257 | } |
| 258 | return { file, distance, weight }; |
| 259 | }); |
| 260 | |
| 261 | // Seeds always kept; non-seeds ranked by weight, then taken up to the cap. |
| 262 | const seedRows = scored.filter(s => s.distance === 0); |
| 263 | const rest = scored.filter(s => s.distance > 0).sort((a, b) => b.weight - a.weight); |
| 264 | return [...seedRows, ...rest].slice(0, maxFiles); |
| 265 | } |
no test coverage detected