🌳
pt0/deployF/ptDeployActions/buildImportTreeAI.mts
1import * as _ from 'lodash-es'
3import path from 'path'
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)
14 })
15 })
16 return reverseAdjH
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
24 visited.add(node)
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})
33 }
34 })
35 return linesA
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])
53 return
54 }
56 const importsA = madgeH[currentPath] || []
57 _.forEach(importsA, (importedPath) => {
58 if (transitiveImporters.has(importedPath) || importedPath === tgtPath) {
59 dfs(importedPath, [...importChain, currentPath])
60 }
61 })
63 visited.delete(currentPath)
64 }
66 dfs(epPath, [])
68 if (pathsFromEp.length > 0) {
69 epPathsA.push({epPath, paths: pathsFromEp})
70 }
71 })
73 return epPathsA
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) => {
83 if (chainIdx === 0) {
84 linesA.push(epPath)
85 }
87 for (let i = 1; i < importChain.length; i++) {
88 const indent = ' '.repeat(i - 1)
89 linesA.push(`${indent} └─> ${importChain[i]}`)
90 }
92 if (chainIdx < paths.length - 1) {
93 linesA.push('')
94 }
95 })
96 linesA.push('')
97 })
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}) => {
110 epSet.add(epPath)
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)
117 }
118 })
119 })
121 const formatNode = (node: string) => {
122 const epMarker = epSet.has(node) ? ' ← entrypoint' : ''
123 return node + epMarker
124 }
126 const getChildren = (node: string) => [...(reverseAdjH[node] || [])]
128 const linesA = renderTreeLines({
129 rootNodes: [tgtPath],
130 getChildren,
131 formatNode,
132 visited: new Set(),
133 })
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
148 })
150 // Files imported 10+ times -> show in summary footer
151 const sharedThreshold = 10
152 const highlySharedA = _.chain(importCountH)
153 .toPairs()
154 .filter(([, cnt]) => cnt >= sharedThreshold)
155 .sortBy(([, cnt]) => -cnt)
156 .map(0)
157 .value()
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))
164 }
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()
171 const linesA = []
173 // Header
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
181 visited.add(node)
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))
199 }
201 if (isShared) line += ' (shared)'
203 linesA.push(line)
205 // Recurse into non-leaf imports
206 if (!isShared) {
207 _.forEach(nonLeafImports, imp => renderNode(imp, depth + 1))
208 }
209 }
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(' ')}`)
218 }
220 return linesA