1import * as _ from 'lodash-es' 5// Shared: build reverse adjacency map (child → Set<parents who import it>) 6type MadgeH = Record<string, string[]> 8export const buildReverseAdjH = (madgeH: MadgeH) => { 9 const reverseAdjH: Record<string, Set<string>> = {} 10 _.forEach(madgeH, (importsA, importer) => { 11 _.forEach(importsA, (imported) => { 12 reverseAdjH[imported] ||= new Set() 13 reverseAdjH[imported].add(importer) 19// Shared: render tree with ├─/└─ connectors 20export const renderTreeLines = ({rootNodes, getChildren, formatNode, visited = new Set<string>(), depth = 0, linesA = [] as string[], maxLines = Infinity}: {rootNodes: string[], getChildren: (n: string) => string[], formatNode: (n: string, d: number) => string, visited?: Set<string>, depth?: number, linesA?: string[], maxLines?: number}) => { 21 _.forEach(rootNodes, (node, idx) => { 22 if (linesA.length >= maxLines) return false 23 if (visited.has(node)) return 26 const isLast = idx === rootNodes.length - 1 27 const prefix = depth === 0 ? '' : ' '.repeat(depth - 1) + (isLast ? '└─ ' : '├─ ') 28 linesA.push(prefix + formatNode(node, depth)) 30 const children = getChildren(node) 31 if (children.length > 0) { 32 renderTreeLines({rootNodes: children, getChildren, formatNode, visited, depth: depth + 1, linesA, maxLines}) 38export const buildImportTreePaths = ({madgeH, epA, tgtPath, transitiveImporters}: {madgeH: MadgeH, epA: string[], tgtPath: string, transitiveImporters: Set<string>}) => { 39 const epPathsA: {epPath: string, paths: string[][]}[] = [] 41 _.forEach(epA, (epPath) => { 42 if (!transitiveImporters.has(epPath)) return 44 const pathsFromEp: string[][] = [] 45 const visited = new Set<string>() 47 const dfs = (currentPath: string, importChain: string[]) => { 48 if (visited.has(currentPath)) return 49 visited.add(currentPath) 51 if (currentPath === tgtPath) { 52 pathsFromEp.push([...importChain, currentPath]) 56 const importsA = madgeH[currentPath] || [] 57 _.forEach(importsA, (importedPath) => { 58 if (transitiveImporters.has(importedPath) || importedPath === tgtPath) { 59 dfs(importedPath, [...importChain, currentPath]) 63 visited.delete(currentPath) 68 if (pathsFromEp.length > 0) { 69 epPathsA.push({epPath, paths: pathsFromEp}) 76export const formatImportTree = ({epPathsA, tgtPath}: {epPathsA: {epPath: string, paths: string[][]}[], tgtPath: string}) => { 77 throwIf(() => epPathsA.length === 0, {tgtPath}) 79 const linesA: string[] = [] 81 _.forEach(epPathsA, ({epPath, paths}) => { 82 _.forEach(paths, (importChain: string[], chainIdx: number) => { 87 for (let i = 1; i < importChain.length; i++) { 88 const indent = ' '.repeat(i - 1) 89 linesA.push(`${indent} └─> ${importChain[i]}`) 92 if (chainIdx < paths.length - 1) { 99 return linesA.join('\n') 102// Reverse tree: target at top, fans out to show who imports it 103export const formatImportTreeReverse = ({epPathsA, tgtPath}: {epPathsA: {epPath: string, paths: string[][]}[], tgtPath: string}) => { 104 throwIf(() => epPathsA.length === 0, {tgtPath}) 106 const reverseAdjH: Record<string, Set<string>> = {} 107 const epSet = new Set<string>() 109 _.forEach(epPathsA, ({epPath, paths}) => { 111 _.forEach(paths, (importChain) => { 112 for (let i = 1; i < importChain.length; i++) { 113 const child = importChain[i] 114 const parent = importChain[i - 1] 115 reverseAdjH[child] ||= new Set() 116 reverseAdjH[child].add(parent) 121 const formatNode = (node: string) => { 122 const epMarker = epSet.has(node) ? ' ← entrypoint' : '' 123 return node + epMarker 126 const getChildren = (node: string) => [...(reverseAdjH[node] || [])] 128 const linesA = renderTreeLines({ 129 rootNodes: [tgtPath], 135 return linesA.join('\n') 138// Forward tree: show full import tree from entrypoints (for dockerfile) 139// Collapses leaf files onto parent line, marks shared deps 140export const formatFullImportTree = ({madgeH, epA, epLabel, pathsA, maxLines}: {madgeH: MadgeH, epA: string[], epLabel: string, pathsA: string[], maxLines: number}) => { 141 const allPathsSet = new Set(pathsA) 142 const reverseAdjH = buildReverseAdjH(madgeH) 144 // Count how many times each file is imported 145 const importCountH: Record<string, number> = {} 146 _.forEach(reverseAdjH, (importers, file) => { 147 importCountH[file] = importers.size 150 // Files imported 10+ times -> show in summary footer 151 const sharedThreshold = 10 152 const highlySharedA = _.chain(importCountH) 154 .filter(([, cnt]) => cnt >= sharedThreshold) 155 .sortBy(([, cnt]) => -cnt) 158 const highlySharedSet = new Set(highlySharedA) 160 // Leaf files: in pathsA but don't import anything (or only import highly-shared) 161 const isLeaf = (file: string) => { 162 const imports = madgeH[file] || [] 163 return imports.length === 0 || imports.every(imp => highlySharedSet.has(imp)) 166 // Direct imports of the entrypoints (roots of our tree) 167 const directImportsOfEp = _.chain(epA).map(ep => madgeH[ep] || []).flatten().uniq().value() 169 const visited = new Set() 170 const shownShared = new Set() 174 const fileCnt = pathsA.length 175 linesA.push(`<ep> ${epLabel} (${fileCnt} files)`) 177 const renderNode = (node: string, depth: number) => { 178 if (linesA.length >= maxLines - 2) return // reserve space for shared summary 179 if (!allPathsSet.has(node)) return 180 if (visited.has(node)) return 183 const imports = (madgeH[node] || []).filter(imp => allPathsSet.has(imp) && !highlySharedSet.has(imp)) 184 const leafImports = imports.filter(isLeaf) 185 const nonLeafImports = imports.filter(imp => !isLeaf(imp)) 187 // Check if this node was already shown (shared dep) 188 const isShared = (importCountH[node] || 0) > 1 && shownShared.has(node) 189 if (!isShared && importCountH[node] > 1) shownShared.add(node) 191 const prefix = ' '.repeat(depth) 192 let line = prefix + node 194 // Collapse leaf imports onto same line 195 if (leafImports.length > 0) { 196 const leafBasenames = leafImports.map((f: string) => path.basename(f).replace(/\.[^.]+$/, '')) 197 line += ' → ' + leafBasenames.join(' ') 198 leafImports.forEach((lf: string) => visited.add(lf)) 201 if (isShared) line += ' (shared)' 205 // Recurse into non-leaf imports 207 _.forEach(nonLeafImports, imp => renderNode(imp, depth + 1)) 211 // Render tree from each direct import 212 _.forEach(directImportsOfEp, node => renderNode(node, 1)) 214 // Footer: highly shared files 215 if (highlySharedA.length > 0) { 216 const sharedBasenames = highlySharedA.map(f => path.basename(f).replace(/\.[^.]+$/, '')) 217 linesA.push(`shared (${sharedThreshold}+): ${sharedBasenames.join(' ')}`)