@getmikk/diagram-generator 1.3.1 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/generators/capsule-diagram.ts +17 -2
- package/src/generators/dependency-matrix.ts +15 -1
- package/src/generators/flow-diagram.ts +3 -1
- package/src/generators/health-diagram.ts +30 -5
- package/src/generators/impact-diagram.ts +3 -2
- package/src/generators/main-diagram.ts +104 -2
- package/src/generators/module-diagram.ts +95 -13
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@getmikk/diagram-generator",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"dev": "tsc --watch"
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|
|
24
|
-
"@getmikk/core": "^1.
|
|
24
|
+
"@getmikk/core": "^1.5.0"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
27
27
|
"typescript": "^5.7.0",
|
|
@@ -16,6 +16,7 @@ export class CapsuleDiagramGenerator {
|
|
|
16
16
|
if (!module) return `%% Module "${moduleId}" not found`
|
|
17
17
|
|
|
18
18
|
const lines: string[] = []
|
|
19
|
+
lines.push('%%{init: {"theme": "base", "themeVariables": {"primaryColor": "#1e293b", "primaryTextColor": "#e2e8f0", "lineColor": "#64748b", "secondaryColor": "#334155", "tertiaryColor": "#475569", "background": "#0f172a", "mainBkg": "#1e293b", "nodeBorder": "#475569"}}}%%')
|
|
19
20
|
lines.push('graph LR')
|
|
20
21
|
lines.push('')
|
|
21
22
|
|
|
@@ -67,8 +68,22 @@ export class CapsuleDiagramGenerator {
|
|
|
67
68
|
}
|
|
68
69
|
|
|
69
70
|
lines.push('')
|
|
70
|
-
lines.push(' classDef publicApi fill:#
|
|
71
|
-
lines.push(' classDef internalApi fill:#
|
|
71
|
+
lines.push(' classDef publicApi fill:#22c55e,stroke:#4ade80,color:#f0fdf4')
|
|
72
|
+
lines.push(' classDef internalApi fill:#64748b,stroke:#94a3b8,color:#f1f5f9')
|
|
73
|
+
|
|
74
|
+
// Apply classes to nodes
|
|
75
|
+
for (const fn of exportedFns) {
|
|
76
|
+
lines.push(` class ${this.sanitizeId(fn.id)} publicApi`)
|
|
77
|
+
}
|
|
78
|
+
for (const fn of internalFns) {
|
|
79
|
+
if (internalFns.length <= 10) {
|
|
80
|
+
lines.push(` class ${this.sanitizeId(fn.id)} internalApi`)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// Apply to the summary node if used
|
|
84
|
+
if (internalFns.length > 10) {
|
|
85
|
+
lines.push(' class internal internalApi')
|
|
86
|
+
}
|
|
72
87
|
|
|
73
88
|
return lines.join('\n')
|
|
74
89
|
}
|
|
@@ -23,6 +23,7 @@ export class DependencyMatrixGenerator {
|
|
|
23
23
|
const matrix = this.computeMatrix()
|
|
24
24
|
const lines: string[] = []
|
|
25
25
|
|
|
26
|
+
lines.push('%%{init: {"theme": "base", "themeVariables": {"primaryColor": "#1e293b", "primaryTextColor": "#e2e8f0", "lineColor": "#64748b", "secondaryColor": "#334155", "tertiaryColor": "#475569", "background": "#0f172a", "mainBkg": "#1e293b", "nodeBorder": "#475569"}}}%%')
|
|
26
27
|
lines.push('graph LR')
|
|
27
28
|
lines.push('')
|
|
28
29
|
|
|
@@ -44,7 +45,7 @@ export class DependencyMatrixGenerator {
|
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
lines.push('')
|
|
47
|
-
lines.push(' classDef default fill:#
|
|
48
|
+
lines.push(' classDef default fill:#334155,stroke:#64748b,color:#e2e8f0')
|
|
48
49
|
|
|
49
50
|
return lines.join('\n')
|
|
50
51
|
}
|
|
@@ -78,6 +79,7 @@ export class DependencyMatrixGenerator {
|
|
|
78
79
|
private computeMatrix(): Map<string, number> {
|
|
79
80
|
const counts = new Map<string, number>()
|
|
80
81
|
|
|
82
|
+
// Count function-level cross-module calls
|
|
81
83
|
for (const fn of Object.values(this.lock.functions)) {
|
|
82
84
|
for (const callTarget of fn.calls) {
|
|
83
85
|
const targetFn = this.lock.functions[callTarget]
|
|
@@ -88,6 +90,18 @@ export class DependencyMatrixGenerator {
|
|
|
88
90
|
}
|
|
89
91
|
}
|
|
90
92
|
|
|
93
|
+
// Count file-level cross-module imports
|
|
94
|
+
for (const file of Object.values(this.lock.files)) {
|
|
95
|
+
if (!file.imports) continue
|
|
96
|
+
for (const importedPath of file.imports) {
|
|
97
|
+
const importedFile = this.lock.files[importedPath]
|
|
98
|
+
if (importedFile && file.moduleId !== importedFile.moduleId) {
|
|
99
|
+
const key = `${file.moduleId}|${importedFile.moduleId}`
|
|
100
|
+
counts.set(key, (counts.get(key) || 0) + 1)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
91
105
|
return counts
|
|
92
106
|
}
|
|
93
107
|
|
|
@@ -13,6 +13,7 @@ export class FlowDiagramGenerator {
|
|
|
13
13
|
/** Generate a sequence diagram starting from a function */
|
|
14
14
|
generate(startFunctionId: string, maxDepth: number = 5): string {
|
|
15
15
|
const lines: string[] = []
|
|
16
|
+
lines.push('%%{init: {"theme": "dark"}}%%')
|
|
16
17
|
lines.push('sequenceDiagram')
|
|
17
18
|
lines.push('')
|
|
18
19
|
|
|
@@ -29,6 +30,7 @@ export class FlowDiagramGenerator {
|
|
|
29
30
|
/** Generate a flow diagram showing all entry points grouped by module */
|
|
30
31
|
generateEntryPoints(): string {
|
|
31
32
|
const lines: string[] = []
|
|
33
|
+
lines.push('%%{init: {"theme": "base", "themeVariables": {"primaryColor": "#1e293b", "primaryTextColor": "#e2e8f0", "lineColor": "#64748b", "secondaryColor": "#334155", "tertiaryColor": "#475569", "background": "#0f172a", "mainBkg": "#1e293b", "nodeBorder": "#475569"}}}%%')
|
|
32
34
|
lines.push('graph TD')
|
|
33
35
|
lines.push('')
|
|
34
36
|
|
|
@@ -67,7 +69,7 @@ export class FlowDiagramGenerator {
|
|
|
67
69
|
}
|
|
68
70
|
|
|
69
71
|
lines.push('')
|
|
70
|
-
lines.push(' classDef default fill:#
|
|
72
|
+
lines.push(' classDef default fill:#334155,stroke:#64748b,color:#e2e8f0')
|
|
71
73
|
|
|
72
74
|
return lines.join('\n')
|
|
73
75
|
}
|
|
@@ -13,6 +13,7 @@ export class HealthDiagramGenerator {
|
|
|
13
13
|
|
|
14
14
|
generate(): string {
|
|
15
15
|
const lines: string[] = []
|
|
16
|
+
lines.push('%%{init: {"theme": "base", "themeVariables": {"primaryColor": "#1e293b", "primaryTextColor": "#e2e8f0", "lineColor": "#64748b", "secondaryColor": "#334155", "tertiaryColor": "#475569", "background": "#0f172a", "mainBkg": "#1e293b", "nodeBorder": "#475569"}}}%%')
|
|
16
17
|
lines.push('graph TD')
|
|
17
18
|
lines.push('')
|
|
18
19
|
|
|
@@ -28,7 +29,7 @@ export class HealthDiagramGenerator {
|
|
|
28
29
|
classAssignments.push(` class ${sid} ${healthClass}`)
|
|
29
30
|
}
|
|
30
31
|
|
|
31
|
-
// Add inter-module dependency edges for context
|
|
32
|
+
// Add inter-module dependency edges for context (function calls + file imports)
|
|
32
33
|
const moduleEdges = new Set<string>()
|
|
33
34
|
for (const fn of Object.values(this.lock.functions)) {
|
|
34
35
|
for (const callTarget of fn.calls) {
|
|
@@ -42,11 +43,24 @@ export class HealthDiagramGenerator {
|
|
|
42
43
|
}
|
|
43
44
|
}
|
|
44
45
|
}
|
|
46
|
+
for (const file of Object.values(this.lock.files)) {
|
|
47
|
+
if (!file.imports) continue
|
|
48
|
+
for (const importedPath of file.imports) {
|
|
49
|
+
const importedFile = this.lock.files[importedPath]
|
|
50
|
+
if (importedFile && file.moduleId !== importedFile.moduleId) {
|
|
51
|
+
const key = `${file.moduleId}|${importedFile.moduleId}`
|
|
52
|
+
if (!moduleEdges.has(key)) {
|
|
53
|
+
moduleEdges.add(key)
|
|
54
|
+
lines.push(` ${this.sanitizeId(file.moduleId)} -.-> ${this.sanitizeId(importedFile.moduleId)}`)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
45
59
|
|
|
46
60
|
lines.push('')
|
|
47
|
-
lines.push(' classDef healthy fill:#
|
|
48
|
-
lines.push(' classDef warning fill:#
|
|
49
|
-
lines.push(' classDef critical fill:#
|
|
61
|
+
lines.push(' classDef healthy fill:#22c55e,stroke:#4ade80,color:#f0fdf4')
|
|
62
|
+
lines.push(' classDef warning fill:#f59e0b,stroke:#fbbf24,color:#1e293b')
|
|
63
|
+
lines.push(' classDef critical fill:#ef4444,stroke:#f87171,color:#fef2f2')
|
|
50
64
|
lines.push('')
|
|
51
65
|
for (const assignment of classAssignments) {
|
|
52
66
|
lines.push(assignment)
|
|
@@ -57,9 +71,10 @@ export class HealthDiagramGenerator {
|
|
|
57
71
|
|
|
58
72
|
private computeMetrics(moduleId: string) {
|
|
59
73
|
const moduleFunctions = Object.values(this.lock.functions).filter(f => f.moduleId === moduleId)
|
|
74
|
+
const moduleFiles = Object.values(this.lock.files).filter(f => f.moduleId === moduleId)
|
|
60
75
|
const functionCount = moduleFunctions.length
|
|
61
76
|
|
|
62
|
-
// Coupling: count of external calls
|
|
77
|
+
// Coupling: count of external calls + external file imports
|
|
63
78
|
let externalCalls = 0
|
|
64
79
|
let internalCalls = 0
|
|
65
80
|
for (const fn of moduleFunctions) {
|
|
@@ -71,6 +86,16 @@ export class HealthDiagramGenerator {
|
|
|
71
86
|
}
|
|
72
87
|
}
|
|
73
88
|
}
|
|
89
|
+
for (const file of moduleFiles) {
|
|
90
|
+
if (!file.imports) continue
|
|
91
|
+
for (const importedPath of file.imports) {
|
|
92
|
+
const importedFile = this.lock.files[importedPath]
|
|
93
|
+
if (importedFile) {
|
|
94
|
+
if (importedFile.moduleId === moduleId) internalCalls++
|
|
95
|
+
else externalCalls++
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
74
99
|
|
|
75
100
|
// Cohesion: ratio of internal to total calls
|
|
76
101
|
const totalCalls = internalCalls + externalCalls
|
|
@@ -12,6 +12,7 @@ export class ImpactDiagramGenerator {
|
|
|
12
12
|
|
|
13
13
|
generate(changedNodeIds: string[], impactedNodeIds: string[]): string {
|
|
14
14
|
const lines: string[] = []
|
|
15
|
+
lines.push('%%{init: {"theme": "base", "themeVariables": {"primaryColor": "#1e293b", "primaryTextColor": "#e2e8f0", "lineColor": "#64748b", "secondaryColor": "#334155", "tertiaryColor": "#475569", "background": "#0f172a", "mainBkg": "#1e293b", "nodeBorder": "#475569"}}}%%')
|
|
15
16
|
lines.push('graph LR')
|
|
16
17
|
lines.push('')
|
|
17
18
|
|
|
@@ -46,8 +47,8 @@ export class ImpactDiagramGenerator {
|
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
lines.push('')
|
|
49
|
-
lines.push(' classDef changed fill:#
|
|
50
|
-
lines.push(' classDef impacted fill:#
|
|
50
|
+
lines.push(' classDef changed fill:#ef4444,stroke:#f87171,color:#fef2f2')
|
|
51
|
+
lines.push(' classDef impacted fill:#f59e0b,stroke:#fbbf24,color:#1e293b')
|
|
51
52
|
|
|
52
53
|
for (const id of changedNodeIds) {
|
|
53
54
|
lines.push(` class ${this.sanitizeId(id)} changed`)
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import * as path from 'node:path'
|
|
1
2
|
import type { MikkContract, MikkLock } from '@getmikk/core'
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* MainDiagramGenerator — generates the top-level Mermaid diagram
|
|
5
6
|
* showing all modules and their interconnections.
|
|
7
|
+
* For single-module projects, shows a file-level view instead.
|
|
6
8
|
* Outputs: .mikk/diagrams/main.mmd
|
|
7
9
|
*/
|
|
8
10
|
export class MainDiagramGenerator {
|
|
@@ -12,7 +14,20 @@ export class MainDiagramGenerator {
|
|
|
12
14
|
) { }
|
|
13
15
|
|
|
14
16
|
generate(): string {
|
|
17
|
+
const modules = this.contract.declared.modules
|
|
18
|
+
|
|
19
|
+
// Single module or no modules — show a file-level view instead of one useless node
|
|
20
|
+
if (modules.length <= 1) {
|
|
21
|
+
return this.generateFileLevelView()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return this.generateModuleLevelView()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Standard multi-module view — modules as nodes, inter-module calls as edges */
|
|
28
|
+
private generateModuleLevelView(): string {
|
|
15
29
|
const lines: string[] = []
|
|
30
|
+
lines.push('%%{init: {"theme": "base", "themeVariables": {"primaryColor": "#1e293b", "primaryTextColor": "#e2e8f0", "lineColor": "#64748b", "secondaryColor": "#334155", "tertiaryColor": "#475569", "background": "#0f172a", "mainBkg": "#1e293b", "nodeBorder": "#475569"}}}%%')
|
|
16
31
|
lines.push('graph TD')
|
|
17
32
|
lines.push('')
|
|
18
33
|
|
|
@@ -26,9 +41,10 @@ export class MainDiagramGenerator {
|
|
|
26
41
|
|
|
27
42
|
lines.push('')
|
|
28
43
|
|
|
29
|
-
// Add inter-module edges based on cross-module function calls
|
|
44
|
+
// Add inter-module edges based on cross-module function calls AND file imports
|
|
30
45
|
const moduleEdges = new Map<string, Set<string>>()
|
|
31
46
|
|
|
47
|
+
// Function-level cross-module calls
|
|
32
48
|
for (const fn of Object.values(this.lock.functions)) {
|
|
33
49
|
for (const callTarget of fn.calls) {
|
|
34
50
|
const targetFn = this.lock.functions[callTarget]
|
|
@@ -42,6 +58,23 @@ export class MainDiagramGenerator {
|
|
|
42
58
|
}
|
|
43
59
|
}
|
|
44
60
|
|
|
61
|
+
// File-level cross-module imports
|
|
62
|
+
for (const file of Object.values(this.lock.files)) {
|
|
63
|
+
if (!file.imports) continue
|
|
64
|
+
for (const importedPath of file.imports) {
|
|
65
|
+
const importedFile = this.lock.files[importedPath]
|
|
66
|
+
if (importedFile && file.moduleId !== importedFile.moduleId) {
|
|
67
|
+
const edgeKey = `${file.moduleId}→${importedFile.moduleId}`
|
|
68
|
+
if (!moduleEdges.has(edgeKey)) {
|
|
69
|
+
moduleEdges.set(edgeKey, new Set())
|
|
70
|
+
}
|
|
71
|
+
const fromName = file.path.split('/').pop() || file.path
|
|
72
|
+
const toName = importedFile.path.split('/').pop() || importedFile.path
|
|
73
|
+
moduleEdges.get(edgeKey)!.add(`${fromName}→${toName}`)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
45
78
|
for (const [edge, calls] of moduleEdges) {
|
|
46
79
|
const [from, to] = edge.split('→')
|
|
47
80
|
lines.push(` ${this.sanitizeId(from)} -->|${calls.size} calls| ${this.sanitizeId(to)}`)
|
|
@@ -49,7 +82,7 @@ export class MainDiagramGenerator {
|
|
|
49
82
|
|
|
50
83
|
// Style
|
|
51
84
|
lines.push('')
|
|
52
|
-
lines.push(' classDef module fill:#
|
|
85
|
+
lines.push(' classDef module fill:#3b82f6,stroke:#60a5fa,color:#f8fafc,stroke-width:2px')
|
|
53
86
|
for (const module of this.contract.declared.modules) {
|
|
54
87
|
lines.push(` class ${this.sanitizeId(module.id)} module`)
|
|
55
88
|
}
|
|
@@ -57,6 +90,75 @@ export class MainDiagramGenerator {
|
|
|
57
90
|
return lines.join('\n')
|
|
58
91
|
}
|
|
59
92
|
|
|
93
|
+
/**
|
|
94
|
+
* File-level view — for single-module or no-module projects.
|
|
95
|
+
* Shows the top files grouped by directory, with import edges between them.
|
|
96
|
+
* Caps at 25 files to keep the diagram readable.
|
|
97
|
+
*/
|
|
98
|
+
private generateFileLevelView(): string {
|
|
99
|
+
const MAX_FILES = 25
|
|
100
|
+
const allFiles = Object.values(this.lock.files)
|
|
101
|
+
|
|
102
|
+
// Sort by number of connections (imports + importedBy) — most connected first
|
|
103
|
+
const ranked = allFiles
|
|
104
|
+
.map(f => ({
|
|
105
|
+
...f,
|
|
106
|
+
connections: (f.imports?.length || 0) + (Object.values(this.lock.files).filter(other => other.imports?.includes(f.path)).length),
|
|
107
|
+
}))
|
|
108
|
+
.sort((a, b) => b.connections - a.connections)
|
|
109
|
+
|
|
110
|
+
const filesToShow = ranked.slice(0, MAX_FILES)
|
|
111
|
+
const shownPaths = new Set(filesToShow.map(f => f.path))
|
|
112
|
+
const collapsedCount = allFiles.length - filesToShow.length
|
|
113
|
+
|
|
114
|
+
const lines: string[] = []
|
|
115
|
+
lines.push('%%{init: {"theme": "base", "themeVariables": {"primaryColor": "#1e293b", "primaryTextColor": "#e2e8f0", "lineColor": "#64748b", "secondaryColor": "#334155", "tertiaryColor": "#475569", "background": "#0f172a", "mainBkg": "#1e293b", "nodeBorder": "#475569"}}}%%')
|
|
116
|
+
lines.push('graph TD')
|
|
117
|
+
lines.push('')
|
|
118
|
+
|
|
119
|
+
// Group files by top-level directory
|
|
120
|
+
const byDir = new Map<string, typeof filesToShow>()
|
|
121
|
+
for (const file of filesToShow) {
|
|
122
|
+
const parts = file.path.split('/')
|
|
123
|
+
const dir = parts.length > 1 ? parts.slice(0, -1).join('/') : '.'
|
|
124
|
+
if (!byDir.has(dir)) byDir.set(dir, [])
|
|
125
|
+
byDir.get(dir)!.push(file)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
for (const [dir, files] of byDir) {
|
|
129
|
+
const dirLabel = dir === '.' ? 'root' : dir
|
|
130
|
+
lines.push(` subgraph dir_${this.sanitizeId(dir)}["📂 ${dirLabel}"]`)
|
|
131
|
+
for (const file of files) {
|
|
132
|
+
const fileName = path.basename(file.path)
|
|
133
|
+
const fnCount = Object.values(this.lock.functions).filter(f => f.file === file.path).length
|
|
134
|
+
lines.push(` ${this.sanitizeId(file.path)}["📄 ${fileName}${fnCount > 0 ? `<br/>${fnCount} fn` : ''}"]`)
|
|
135
|
+
}
|
|
136
|
+
lines.push(' end')
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (collapsedCount > 0) {
|
|
140
|
+
lines.push(` collapsed["📁 +${collapsedCount} more files"]`)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
lines.push('')
|
|
144
|
+
|
|
145
|
+
// Add import edges between shown files
|
|
146
|
+
for (const file of filesToShow) {
|
|
147
|
+
if (!file.imports) continue
|
|
148
|
+
for (const imp of file.imports) {
|
|
149
|
+
if (shownPaths.has(imp)) {
|
|
150
|
+
lines.push(` ${this.sanitizeId(file.path)} --> ${this.sanitizeId(imp)}`)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Style
|
|
156
|
+
lines.push('')
|
|
157
|
+
lines.push(' classDef default fill:#334155,stroke:#64748b,color:#e2e8f0,stroke-width:1px')
|
|
158
|
+
|
|
159
|
+
return lines.join('\n')
|
|
160
|
+
}
|
|
161
|
+
|
|
60
162
|
private sanitizeId(id: string): string {
|
|
61
163
|
return id.replace(/[^a-zA-Z0-9_]/g, '_')
|
|
62
164
|
}
|
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
import * as path from 'node:path'
|
|
2
2
|
import type { MikkContract, MikkLock } from '@getmikk/core'
|
|
3
3
|
|
|
4
|
+
/** Maximum functions to show in a single module diagram before collapsing */
|
|
5
|
+
const MAX_FUNCTIONS_PER_DIAGRAM = 40
|
|
6
|
+
|
|
7
|
+
/** Maximum files to show as subgraphs before collapsing smallest ones */
|
|
8
|
+
const MAX_FILES_PER_DIAGRAM = 15
|
|
9
|
+
|
|
4
10
|
/**
|
|
5
11
|
* ModuleDiagramGenerator — generates a detailed Mermaid diagram for a
|
|
6
12
|
* single module, showing its internal files and function call graph.
|
|
13
|
+
* For large modules, collapses internal-only functions to keep diagrams readable.
|
|
7
14
|
* Outputs: .mikk/diagrams/modules/{moduleId}.mmd
|
|
8
15
|
*/
|
|
9
16
|
export class ModuleDiagramGenerator {
|
|
@@ -16,23 +23,97 @@ export class ModuleDiagramGenerator {
|
|
|
16
23
|
const module = this.contract.declared.modules.find(m => m.id === moduleId)
|
|
17
24
|
if (!module) return `%% Module "${moduleId}" not found`
|
|
18
25
|
|
|
26
|
+
const moduleFunctions = Object.values(this.lock.functions).filter(f => f.moduleId === moduleId)
|
|
27
|
+
|
|
28
|
+
// If the module is very large, only show "important" functions:
|
|
29
|
+
// - Exported functions (called by other modules)
|
|
30
|
+
// - Entry points (called by nothing internal)
|
|
31
|
+
// - Functions that call or are called by other modules
|
|
32
|
+
// - Top-N most-connected functions
|
|
33
|
+
let visibleFunctions = moduleFunctions
|
|
34
|
+
let collapsedCount = 0
|
|
35
|
+
|
|
36
|
+
if (moduleFunctions.length > MAX_FUNCTIONS_PER_DIAGRAM) {
|
|
37
|
+
const importantFns = new Set<string>()
|
|
38
|
+
|
|
39
|
+
for (const fn of moduleFunctions) {
|
|
40
|
+
// Entry points — nothing internal calls them
|
|
41
|
+
const isEntryPoint = fn.calledBy.length === 0
|
|
42
|
+
// Cross-module caller or callee
|
|
43
|
+
const hasCrossModuleCalls = fn.calls.some(id => {
|
|
44
|
+
const target = this.lock.functions[id]
|
|
45
|
+
return target && target.moduleId !== moduleId
|
|
46
|
+
})
|
|
47
|
+
const isCalledCrossModule = fn.calledBy.some(id => {
|
|
48
|
+
const caller = this.lock.functions[id]
|
|
49
|
+
return caller && caller.moduleId !== moduleId
|
|
50
|
+
})
|
|
51
|
+
// Exported
|
|
52
|
+
const isExported = fn.isExported
|
|
53
|
+
|
|
54
|
+
if (isEntryPoint || hasCrossModuleCalls || isCalledCrossModule || isExported) {
|
|
55
|
+
importantFns.add(fn.id)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// If still too many, pick the most connected ones
|
|
60
|
+
if (importantFns.size > MAX_FUNCTIONS_PER_DIAGRAM) {
|
|
61
|
+
const sorted = [...importantFns]
|
|
62
|
+
.map(id => ({ id, connections: (this.lock.functions[id]?.calls.length || 0) + (this.lock.functions[id]?.calledBy.length || 0) }))
|
|
63
|
+
.sort((a, b) => b.connections - a.connections)
|
|
64
|
+
.slice(0, MAX_FUNCTIONS_PER_DIAGRAM)
|
|
65
|
+
.map(x => x.id)
|
|
66
|
+
importantFns.clear()
|
|
67
|
+
for (const id of sorted) importantFns.add(id)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// If too few important functions, add the most connected ones
|
|
71
|
+
if (importantFns.size < Math.min(10, moduleFunctions.length)) {
|
|
72
|
+
const sorted = moduleFunctions
|
|
73
|
+
.filter(fn => !importantFns.has(fn.id))
|
|
74
|
+
.sort((a, b) => (b.calls.length + b.calledBy.length) - (a.calls.length + a.calledBy.length))
|
|
75
|
+
for (const fn of sorted) {
|
|
76
|
+
if (importantFns.size >= MAX_FUNCTIONS_PER_DIAGRAM) break
|
|
77
|
+
importantFns.add(fn.id)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
visibleFunctions = moduleFunctions.filter(fn => importantFns.has(fn.id))
|
|
82
|
+
collapsedCount = moduleFunctions.length - visibleFunctions.length
|
|
83
|
+
}
|
|
84
|
+
|
|
19
85
|
const lines: string[] = []
|
|
86
|
+
lines.push('%%{init: {"theme": "base", "themeVariables": {"primaryColor": "#1e293b", "primaryTextColor": "#e2e8f0", "lineColor": "#64748b", "secondaryColor": "#334155", "tertiaryColor": "#475569", "background": "#0f172a", "mainBkg": "#1e293b", "nodeBorder": "#475569"}}}%%')
|
|
20
87
|
lines.push(`graph TD`)
|
|
21
|
-
lines.push(` subgraph mod_${this.sanitizeId(moduleId)}["📦 Module: ${module.name}"]`)
|
|
22
|
-
|
|
23
|
-
const moduleFunctions = Object.values(this.lock.functions).filter(f => f.moduleId === moduleId)
|
|
88
|
+
lines.push(` subgraph mod_${this.sanitizeId(moduleId)}["📦 Module: ${module.name}${collapsedCount > 0 ? ` (${collapsedCount} internal functions collapsed)` : ''}"]`)
|
|
24
89
|
|
|
25
90
|
// Group functions by file
|
|
26
|
-
const functionsByFile = new Map<string, typeof
|
|
27
|
-
for (const fn of
|
|
91
|
+
const functionsByFile = new Map<string, typeof visibleFunctions>()
|
|
92
|
+
for (const fn of visibleFunctions) {
|
|
28
93
|
if (!functionsByFile.has(fn.file)) {
|
|
29
94
|
functionsByFile.set(fn.file, [])
|
|
30
95
|
}
|
|
31
96
|
functionsByFile.get(fn.file)!.push(fn)
|
|
32
97
|
}
|
|
33
98
|
|
|
99
|
+
// If too many files, collapse the smallest ones into a summary node
|
|
100
|
+
let filesToRender = [...functionsByFile.entries()]
|
|
101
|
+
let collapsedFiles = 0
|
|
102
|
+
if (filesToRender.length > MAX_FILES_PER_DIAGRAM) {
|
|
103
|
+
// Sort by function count, keep the largest files
|
|
104
|
+
filesToRender.sort((a, b) => b[1].length - a[1].length)
|
|
105
|
+
const kept = filesToRender.slice(0, MAX_FILES_PER_DIAGRAM)
|
|
106
|
+
const dropped = filesToRender.slice(MAX_FILES_PER_DIAGRAM)
|
|
107
|
+
collapsedFiles = dropped.length
|
|
108
|
+
const droppedFnCount = dropped.reduce((sum, [, fns]) => sum + fns.length, 0)
|
|
109
|
+
filesToRender = kept
|
|
110
|
+
|
|
111
|
+
// Add a summary node for collapsed files
|
|
112
|
+
lines.push(` collapsed_files["📁 +${collapsedFiles} more files (${droppedFnCount} functions)"]`)
|
|
113
|
+
}
|
|
114
|
+
|
|
34
115
|
// Add file subgraphs
|
|
35
|
-
for (const [filePath, fns] of
|
|
116
|
+
for (const [filePath, fns] of filesToRender) {
|
|
36
117
|
const fileName = path.basename(filePath)
|
|
37
118
|
lines.push(` subgraph file_${this.sanitizeId(filePath)}["📄 ${fileName}"]`)
|
|
38
119
|
for (const fn of fns) {
|
|
@@ -45,15 +126,16 @@ export class ModuleDiagramGenerator {
|
|
|
45
126
|
lines.push(' end')
|
|
46
127
|
lines.push('')
|
|
47
128
|
|
|
48
|
-
// Add call edges
|
|
49
|
-
|
|
129
|
+
// Add call edges (only for visible functions)
|
|
130
|
+
const visibleIds = new Set(visibleFunctions.map(fn => fn.id))
|
|
131
|
+
for (const fn of visibleFunctions) {
|
|
50
132
|
for (const callTarget of fn.calls) {
|
|
51
133
|
const targetFn = this.lock.functions[callTarget]
|
|
52
134
|
if (targetFn) {
|
|
53
|
-
if (targetFn.moduleId === moduleId) {
|
|
54
|
-
// Internal call
|
|
135
|
+
if (targetFn.moduleId === moduleId && visibleIds.has(callTarget)) {
|
|
136
|
+
// Internal call (both visible)
|
|
55
137
|
lines.push(` ${this.sanitizeId(fn.id)} --> ${this.sanitizeId(callTarget)}`)
|
|
56
|
-
} else {
|
|
138
|
+
} else if (targetFn.moduleId !== moduleId) {
|
|
57
139
|
// External call
|
|
58
140
|
const targetMod = targetFn.moduleId
|
|
59
141
|
lines.push(` ${this.sanitizeId(fn.id)} -.->|"calls"| ext_${this.sanitizeId(callTarget)}["🔗 ${targetFn.name}<br/>(${targetMod})"]`)
|
|
@@ -63,8 +145,8 @@ export class ModuleDiagramGenerator {
|
|
|
63
145
|
}
|
|
64
146
|
|
|
65
147
|
lines.push('')
|
|
66
|
-
lines.push(' classDef default fill:#
|
|
67
|
-
lines.push(' classDef external fill:#
|
|
148
|
+
lines.push(' classDef default fill:#334155,stroke:#64748b,color:#e2e8f0,stroke-width:1px')
|
|
149
|
+
lines.push(' classDef external fill:#1e293b,stroke:#475569,color:#94a3b8,stroke-dasharray: 5 5')
|
|
68
150
|
|
|
69
151
|
return lines.join('\n')
|
|
70
152
|
}
|