@getmikk/diagram-generator 1.2.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 +26 -0
- package/src/generators/capsule-diagram.ts +79 -0
- package/src/generators/dependency-matrix.ts +97 -0
- package/src/generators/flow-diagram.ts +108 -0
- package/src/generators/health-diagram.ts +88 -0
- package/src/generators/impact-diagram.ts +65 -0
- package/src/generators/main-diagram.ts +63 -0
- package/src/generators/module-diagram.ts +75 -0
- package/src/index.ts +8 -0
- package/src/orchestrator.ts +76 -0
- package/tests/smoke.test.ts +5 -0
- package/tsconfig.json +15 -0
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@getmikk/diagram-generator",
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"types": "./dist/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"import": "./dist/index.js",
|
|
10
|
+
"types": "./dist/index.d.ts"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsc",
|
|
15
|
+
"test": "bun test",
|
|
16
|
+
"publish": "npm publish --access public",
|
|
17
|
+
"dev": "tsc --watch"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@getmikk/core": "workspace:*"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"typescript": "^5.7.0",
|
|
24
|
+
"@types/node": "^22.0.0"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { MikkContract, MikkLock } from '@getmikk/core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* CapsuleDiagramGenerator — generates capsule diagrams that show
|
|
5
|
+
* the public API surface of a module (what it exports to the world).
|
|
6
|
+
* Outputs: .mikk/diagrams/capsules/{moduleId}.mmd
|
|
7
|
+
*/
|
|
8
|
+
export class CapsuleDiagramGenerator {
|
|
9
|
+
constructor(
|
|
10
|
+
private contract: MikkContract,
|
|
11
|
+
private lock: MikkLock
|
|
12
|
+
) { }
|
|
13
|
+
|
|
14
|
+
generate(moduleId: string): string {
|
|
15
|
+
const module = this.contract.declared.modules.find(m => m.id === moduleId)
|
|
16
|
+
if (!module) return `%% Module "${moduleId}" not found`
|
|
17
|
+
|
|
18
|
+
const lines: string[] = []
|
|
19
|
+
lines.push('graph LR')
|
|
20
|
+
lines.push('')
|
|
21
|
+
|
|
22
|
+
// Module capsule (subgraph)
|
|
23
|
+
lines.push(` subgraph ${this.sanitizeId(moduleId)}["📦 ${module.name}"]`)
|
|
24
|
+
lines.push(` direction TB`)
|
|
25
|
+
|
|
26
|
+
// Find exported functions (those called by functions in other modules)
|
|
27
|
+
const moduleFunctions = Object.values(this.lock.functions).filter(f => f.moduleId === moduleId)
|
|
28
|
+
const exportedFns = moduleFunctions.filter(fn =>
|
|
29
|
+
fn.calledBy.some(callerId => {
|
|
30
|
+
const caller = this.lock.functions[callerId]
|
|
31
|
+
return caller && caller.moduleId !== moduleId
|
|
32
|
+
})
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
// Internal functions
|
|
36
|
+
const internalFns = moduleFunctions.filter(fn => !exportedFns.includes(fn))
|
|
37
|
+
|
|
38
|
+
if (exportedFns.length > 0) {
|
|
39
|
+
lines.push(` subgraph public["🔓 Public API"]`)
|
|
40
|
+
for (const fn of exportedFns) {
|
|
41
|
+
lines.push(` ${this.sanitizeId(fn.id)}["${fn.name}"]`)
|
|
42
|
+
}
|
|
43
|
+
lines.push(' end')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (internalFns.length > 0 && internalFns.length <= 10) {
|
|
47
|
+
lines.push(` subgraph internal["🔒 Internal"]`)
|
|
48
|
+
for (const fn of internalFns) {
|
|
49
|
+
lines.push(` ${this.sanitizeId(fn.id)}["${fn.name}"]`)
|
|
50
|
+
}
|
|
51
|
+
lines.push(' end')
|
|
52
|
+
} else if (internalFns.length > 10) {
|
|
53
|
+
lines.push(` internal["🔒 ${internalFns.length} internal functions"]`)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
lines.push(' end')
|
|
57
|
+
lines.push('')
|
|
58
|
+
|
|
59
|
+
// Show external consumers
|
|
60
|
+
for (const fn of exportedFns) {
|
|
61
|
+
for (const callerId of fn.calledBy) {
|
|
62
|
+
const caller = this.lock.functions[callerId]
|
|
63
|
+
if (caller && caller.moduleId !== moduleId) {
|
|
64
|
+
lines.push(` ext_${this.sanitizeId(callerId)}["${caller.name}<br/>(${caller.moduleId})"] --> ${this.sanitizeId(fn.id)}`)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
lines.push('')
|
|
70
|
+
lines.push(' classDef publicApi fill:#27ae60,stroke:#2c3e50,color:#fff')
|
|
71
|
+
lines.push(' classDef internalApi fill:#7f8c8d,stroke:#2c3e50,color:#fff')
|
|
72
|
+
|
|
73
|
+
return lines.join('\n')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private sanitizeId(id: string): string {
|
|
77
|
+
return id.replace(/[^a-zA-Z0-9_]/g, '_')
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { MikkContract, MikkLock } from '@getmikk/core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* DependencyMatrixGenerator — generates an N×N dependency matrix
|
|
5
|
+
* showing how many cross-module calls exist between each pair of modules.
|
|
6
|
+
* Useful for spotting hidden coupling between modules.
|
|
7
|
+
*
|
|
8
|
+
* Output: a Mermaid block diagram with a matrix-like layout or
|
|
9
|
+
* a structured text table that can be rendered in documentation.
|
|
10
|
+
*/
|
|
11
|
+
export class DependencyMatrixGenerator {
|
|
12
|
+
constructor(
|
|
13
|
+
private contract: MikkContract,
|
|
14
|
+
private lock: MikkLock
|
|
15
|
+
) { }
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Generate a Mermaid diagram showing the dependency matrix.
|
|
19
|
+
* Uses a graph with weighted edges between all module pairs.
|
|
20
|
+
*/
|
|
21
|
+
generate(): string {
|
|
22
|
+
const modules = this.contract.declared.modules
|
|
23
|
+
const matrix = this.computeMatrix()
|
|
24
|
+
const lines: string[] = []
|
|
25
|
+
|
|
26
|
+
lines.push('graph LR')
|
|
27
|
+
lines.push('')
|
|
28
|
+
|
|
29
|
+
// Module nodes
|
|
30
|
+
for (const mod of modules) {
|
|
31
|
+
const fnCount = Object.values(this.lock.functions)
|
|
32
|
+
.filter(f => f.moduleId === mod.id).length
|
|
33
|
+
lines.push(` ${this.sanitizeId(mod.id)}["${mod.name}<br/>${fnCount} fn"]`)
|
|
34
|
+
}
|
|
35
|
+
lines.push('')
|
|
36
|
+
|
|
37
|
+
// Weighted edges
|
|
38
|
+
for (const [key, count] of matrix) {
|
|
39
|
+
const [fromId, toId] = key.split('|')
|
|
40
|
+
if (count > 0) {
|
|
41
|
+
const thickness = count > 10 ? '==>' : count > 3 ? '-->' : '-.->'
|
|
42
|
+
lines.push(` ${this.sanitizeId(fromId)} ${thickness}|${count}| ${this.sanitizeId(toId)}`)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
lines.push('')
|
|
47
|
+
lines.push(' classDef default fill:#ecf0f1,stroke:#34495e,color:#2c3e50')
|
|
48
|
+
|
|
49
|
+
return lines.join('\n')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Generate a markdown table showing the N×N dependency matrix.
|
|
54
|
+
* Useful for claude.md or documentation.
|
|
55
|
+
*/
|
|
56
|
+
generateTable(): string {
|
|
57
|
+
const modules = this.contract.declared.modules
|
|
58
|
+
const matrix = this.computeMatrix()
|
|
59
|
+
const lines: string[] = []
|
|
60
|
+
|
|
61
|
+
// Header
|
|
62
|
+
lines.push('| From \\ To | ' + modules.map(m => m.name).join(' | ') + ' |')
|
|
63
|
+
lines.push('| --- | ' + modules.map(() => '---').join(' | ') + ' |')
|
|
64
|
+
|
|
65
|
+
// Rows
|
|
66
|
+
for (const fromMod of modules) {
|
|
67
|
+
const cells = modules.map(toMod => {
|
|
68
|
+
if (fromMod.id === toMod.id) return '-'
|
|
69
|
+
const count = matrix.get(`${fromMod.id}|${toMod.id}`) || 0
|
|
70
|
+
return count > 0 ? String(count) : '0'
|
|
71
|
+
})
|
|
72
|
+
lines.push(`| **${fromMod.name}** | ${cells.join(' | ')} |`)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return lines.join('\n')
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private computeMatrix(): Map<string, number> {
|
|
79
|
+
const counts = new Map<string, number>()
|
|
80
|
+
|
|
81
|
+
for (const fn of Object.values(this.lock.functions)) {
|
|
82
|
+
for (const callTarget of fn.calls) {
|
|
83
|
+
const targetFn = this.lock.functions[callTarget]
|
|
84
|
+
if (targetFn && fn.moduleId !== targetFn.moduleId) {
|
|
85
|
+
const key = `${fn.moduleId}|${targetFn.moduleId}`
|
|
86
|
+
counts.set(key, (counts.get(key) || 0) + 1)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return counts
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private sanitizeId(id: string): string {
|
|
95
|
+
return id.replace(/[^a-zA-Z0-9_]/g, '_')
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { MikkContract, MikkLock } from '@getmikk/core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* FlowDiagramGenerator — generates sequence diagrams for specific
|
|
5
|
+
* function call flows (e.g., "show me the HTTP request → response flow").
|
|
6
|
+
* Outputs: .mikk/diagrams/flows/{flowName}.mmd
|
|
7
|
+
*/
|
|
8
|
+
export class FlowDiagramGenerator {
|
|
9
|
+
constructor(
|
|
10
|
+
private lock: MikkLock
|
|
11
|
+
) { }
|
|
12
|
+
|
|
13
|
+
/** Generate a sequence diagram starting from a function */
|
|
14
|
+
generate(startFunctionId: string, maxDepth: number = 5): string {
|
|
15
|
+
const lines: string[] = []
|
|
16
|
+
lines.push('sequenceDiagram')
|
|
17
|
+
lines.push('')
|
|
18
|
+
|
|
19
|
+
const visited = new Set<string>()
|
|
20
|
+
this.traceFlow(startFunctionId, lines, visited, 0, maxDepth)
|
|
21
|
+
|
|
22
|
+
if (lines.length <= 2) {
|
|
23
|
+
lines.push(` Note over ${this.sanitizeId(startFunctionId)}: No outgoing calls found`)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return lines.join('\n')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Generate a flow diagram showing all entry points grouped by module */
|
|
30
|
+
generateEntryPoints(): string {
|
|
31
|
+
const lines: string[] = []
|
|
32
|
+
lines.push('graph TD')
|
|
33
|
+
lines.push('')
|
|
34
|
+
|
|
35
|
+
// Find functions with no callers (entry points)
|
|
36
|
+
const allFunctions = Object.values(this.lock.functions)
|
|
37
|
+
const entryPoints = allFunctions.filter(fn => fn.calledBy.length === 0)
|
|
38
|
+
|
|
39
|
+
// Group entry points by module
|
|
40
|
+
const entryByModule = new Map<string, typeof entryPoints>()
|
|
41
|
+
for (const fn of entryPoints) {
|
|
42
|
+
if (!entryByModule.has(fn.moduleId)) {
|
|
43
|
+
entryByModule.set(fn.moduleId, [])
|
|
44
|
+
}
|
|
45
|
+
entryByModule.get(fn.moduleId)!.push(fn)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
for (const [modId, fns] of entryByModule) {
|
|
49
|
+
lines.push(` subgraph mod_${this.sanitizeId(modId)}["📦 ${modId}"]`)
|
|
50
|
+
for (const fn of fns) {
|
|
51
|
+
lines.push(` ${this.sanitizeId(fn.id)}["🚀 ${fn.name}<br/>(Entry)"]`)
|
|
52
|
+
}
|
|
53
|
+
lines.push(' end')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
lines.push('')
|
|
57
|
+
|
|
58
|
+
// Show first-level calls from entry points
|
|
59
|
+
for (const fn of entryPoints) {
|
|
60
|
+
const outEdges = fn.calls
|
|
61
|
+
for (const targetId of outEdges) {
|
|
62
|
+
const targetFn = this.lock.functions[targetId]
|
|
63
|
+
if (targetFn) {
|
|
64
|
+
lines.push(` ${this.sanitizeId(fn.id)} --> ${this.sanitizeId(targetId)}["${targetFn.name}"]`)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
lines.push('')
|
|
70
|
+
lines.push(' classDef default fill:#f9f9f9,stroke:#333')
|
|
71
|
+
|
|
72
|
+
return lines.join('\n')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private traceFlow(
|
|
76
|
+
fnId: string,
|
|
77
|
+
lines: string[],
|
|
78
|
+
visited: Set<string>,
|
|
79
|
+
depth: number,
|
|
80
|
+
maxDepth: number
|
|
81
|
+
): void {
|
|
82
|
+
if (depth >= maxDepth || visited.has(fnId)) return
|
|
83
|
+
visited.add(fnId)
|
|
84
|
+
|
|
85
|
+
const fn = this.lock.functions[fnId]
|
|
86
|
+
if (!fn) return
|
|
87
|
+
|
|
88
|
+
const participant = this.getParticipantName(fn.moduleId, fn.name)
|
|
89
|
+
|
|
90
|
+
for (const callTarget of fn.calls) {
|
|
91
|
+
const targetFn = this.lock.functions[callTarget]
|
|
92
|
+
if (!targetFn) continue
|
|
93
|
+
|
|
94
|
+
const targetParticipant = this.getParticipantName(targetFn.moduleId, targetFn.name)
|
|
95
|
+
lines.push(` ${participant}->>+${targetParticipant}: ${fn.name}() → ${targetFn.name}()`)
|
|
96
|
+
this.traceFlow(callTarget, lines, visited, depth + 1, maxDepth)
|
|
97
|
+
lines.push(` ${targetParticipant}-->>-${participant}: return`)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private getParticipantName(moduleId: string, fnName: string): string {
|
|
102
|
+
return this.sanitizeId(`${moduleId}_${fnName}`)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private sanitizeId(id: string): string {
|
|
106
|
+
return id.replace(/[^a-zA-Z0-9_]/g, '_')
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { MikkContract, MikkLock } from '@getmikk/core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* HealthDiagramGenerator — generates a module health dashboard showing
|
|
5
|
+
* coupling, cohesion, and complexity metrics.
|
|
6
|
+
* Outputs: .mikk/diagrams/health.mmd
|
|
7
|
+
*/
|
|
8
|
+
export class HealthDiagramGenerator {
|
|
9
|
+
constructor(
|
|
10
|
+
private contract: MikkContract,
|
|
11
|
+
private lock: MikkLock
|
|
12
|
+
) { }
|
|
13
|
+
|
|
14
|
+
generate(): string {
|
|
15
|
+
const lines: string[] = []
|
|
16
|
+
lines.push('graph TD')
|
|
17
|
+
lines.push('')
|
|
18
|
+
|
|
19
|
+
const classAssignments: string[] = []
|
|
20
|
+
|
|
21
|
+
for (const module of this.contract.declared.modules) {
|
|
22
|
+
const metrics = this.computeMetrics(module.id)
|
|
23
|
+
const healthIcon = metrics.health > 0.7 ? '🟢' : metrics.health > 0.4 ? '🟡' : '🔴'
|
|
24
|
+
const healthClass = metrics.health > 0.7 ? 'healthy' : metrics.health > 0.4 ? 'warning' : 'critical'
|
|
25
|
+
const sid = this.sanitizeId(module.id)
|
|
26
|
+
|
|
27
|
+
lines.push(` ${sid}["${healthIcon} ${module.name}<br/>Cohesion: ${(metrics.cohesion * 100).toFixed(0)}%<br/>Coupling: ${metrics.coupling}<br/>Functions: ${metrics.functionCount}"]`)
|
|
28
|
+
classAssignments.push(` class ${sid} ${healthClass}`)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Add inter-module dependency edges for context
|
|
32
|
+
const moduleEdges = new Set<string>()
|
|
33
|
+
for (const fn of Object.values(this.lock.functions)) {
|
|
34
|
+
for (const callTarget of fn.calls) {
|
|
35
|
+
const targetFn = this.lock.functions[callTarget]
|
|
36
|
+
if (targetFn && fn.moduleId !== targetFn.moduleId) {
|
|
37
|
+
const key = `${fn.moduleId}|${targetFn.moduleId}`
|
|
38
|
+
if (!moduleEdges.has(key)) {
|
|
39
|
+
moduleEdges.add(key)
|
|
40
|
+
lines.push(` ${this.sanitizeId(fn.moduleId)} -.-> ${this.sanitizeId(targetFn.moduleId)}`)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
lines.push('')
|
|
47
|
+
lines.push(' classDef healthy fill:#27ae60,stroke:#2c3e50,color:#fff')
|
|
48
|
+
lines.push(' classDef warning fill:#f39c12,stroke:#2c3e50,color:#fff')
|
|
49
|
+
lines.push(' classDef critical fill:#e74c3c,stroke:#2c3e50,color:#fff')
|
|
50
|
+
lines.push('')
|
|
51
|
+
for (const assignment of classAssignments) {
|
|
52
|
+
lines.push(assignment)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return lines.join('\n')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private computeMetrics(moduleId: string) {
|
|
59
|
+
const moduleFunctions = Object.values(this.lock.functions).filter(f => f.moduleId === moduleId)
|
|
60
|
+
const functionCount = moduleFunctions.length
|
|
61
|
+
|
|
62
|
+
// Coupling: count of external calls
|
|
63
|
+
let externalCalls = 0
|
|
64
|
+
let internalCalls = 0
|
|
65
|
+
for (const fn of moduleFunctions) {
|
|
66
|
+
for (const call of fn.calls) {
|
|
67
|
+
const target = this.lock.functions[call]
|
|
68
|
+
if (target) {
|
|
69
|
+
if (target.moduleId === moduleId) internalCalls++
|
|
70
|
+
else externalCalls++
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Cohesion: ratio of internal to total calls
|
|
76
|
+
const totalCalls = internalCalls + externalCalls
|
|
77
|
+
const cohesion = totalCalls === 0 ? 0.5 : internalCalls / totalCalls
|
|
78
|
+
|
|
79
|
+
// Health: weighted score
|
|
80
|
+
const health = cohesion * 0.6 + (functionCount > 0 ? Math.min(1, 10 / functionCount) * 0.4 : 0.4)
|
|
81
|
+
|
|
82
|
+
return { cohesion, coupling: externalCalls, functionCount, health }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private sanitizeId(id: string): string {
|
|
86
|
+
return id.replace(/[^a-zA-Z0-9_]/g, '_')
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { MikkContract, MikkLock } from '@getmikk/core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ImpactDiagramGenerator — generates a Mermaid diagram showing the
|
|
5
|
+
* impact of changes in specific files/functions.
|
|
6
|
+
* Outputs: .mikk/diagrams/impact/{filename}.mmd
|
|
7
|
+
*/
|
|
8
|
+
export class ImpactDiagramGenerator {
|
|
9
|
+
constructor(
|
|
10
|
+
private lock: MikkLock
|
|
11
|
+
) { }
|
|
12
|
+
|
|
13
|
+
generate(changedNodeIds: string[], impactedNodeIds: string[]): string {
|
|
14
|
+
const lines: string[] = []
|
|
15
|
+
lines.push('graph LR')
|
|
16
|
+
lines.push('')
|
|
17
|
+
|
|
18
|
+
// Changed nodes (red)
|
|
19
|
+
for (const id of changedNodeIds) {
|
|
20
|
+
const fn = this.lock.functions[id]
|
|
21
|
+
if (fn) {
|
|
22
|
+
lines.push(` ${this.sanitizeId(id)}["🔴 ${fn.name}<br/>${fn.file}"]`)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Impacted nodes (orange)
|
|
27
|
+
for (const id of impactedNodeIds) {
|
|
28
|
+
const fn = this.lock.functions[id]
|
|
29
|
+
if (fn) {
|
|
30
|
+
lines.push(` ${this.sanitizeId(id)}["🟠${fn.name}<br/>${fn.file}"]`)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
lines.push('')
|
|
35
|
+
|
|
36
|
+
// Draw edges showing the impact chain
|
|
37
|
+
const allIds = new Set([...changedNodeIds, ...impactedNodeIds])
|
|
38
|
+
for (const id of allIds) {
|
|
39
|
+
const fn = this.lock.functions[id]
|
|
40
|
+
if (!fn) continue
|
|
41
|
+
for (const callerId of fn.calledBy) {
|
|
42
|
+
if (allIds.has(callerId)) {
|
|
43
|
+
lines.push(` ${this.sanitizeId(callerId)} --> ${this.sanitizeId(id)}`)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
lines.push('')
|
|
49
|
+
lines.push(' classDef changed fill:#e74c3c,stroke:#c0392b,color:#fff')
|
|
50
|
+
lines.push(' classDef impacted fill:#e67e22,stroke:#d35400,color:#fff')
|
|
51
|
+
|
|
52
|
+
for (const id of changedNodeIds) {
|
|
53
|
+
lines.push(` class ${this.sanitizeId(id)} changed`)
|
|
54
|
+
}
|
|
55
|
+
for (const id of impactedNodeIds) {
|
|
56
|
+
lines.push(` class ${this.sanitizeId(id)} impacted`)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return lines.join('\n')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private sanitizeId(id: string): string {
|
|
63
|
+
return id.replace(/[^a-zA-Z0-9_]/g, '_')
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { MikkContract, MikkLock } from '@getmikk/core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* MainDiagramGenerator — generates the top-level Mermaid diagram
|
|
5
|
+
* showing all modules and their interconnections.
|
|
6
|
+
* Outputs: .mikk/diagrams/main.mmd
|
|
7
|
+
*/
|
|
8
|
+
export class MainDiagramGenerator {
|
|
9
|
+
constructor(
|
|
10
|
+
private contract: MikkContract,
|
|
11
|
+
private lock: MikkLock
|
|
12
|
+
) { }
|
|
13
|
+
|
|
14
|
+
generate(): string {
|
|
15
|
+
const lines: string[] = []
|
|
16
|
+
lines.push('graph TD')
|
|
17
|
+
lines.push('')
|
|
18
|
+
|
|
19
|
+
// Add module nodes
|
|
20
|
+
for (const module of this.contract.declared.modules) {
|
|
21
|
+
const lockModule = this.lock.modules[module.id]
|
|
22
|
+
const fileCount = lockModule?.files.length || 0
|
|
23
|
+
const fnCount = Object.values(this.lock.functions).filter(f => f.moduleId === module.id).length
|
|
24
|
+
lines.push(` ${this.sanitizeId(module.id)}["📦 ${module.name}<br/>${fileCount} files, ${fnCount} functions"]`)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
lines.push('')
|
|
28
|
+
|
|
29
|
+
// Add inter-module edges based on cross-module function calls
|
|
30
|
+
const moduleEdges = new Map<string, Set<string>>()
|
|
31
|
+
|
|
32
|
+
for (const fn of Object.values(this.lock.functions)) {
|
|
33
|
+
for (const callTarget of fn.calls) {
|
|
34
|
+
const targetFn = this.lock.functions[callTarget]
|
|
35
|
+
if (targetFn && fn.moduleId !== targetFn.moduleId) {
|
|
36
|
+
const edgeKey = `${fn.moduleId}→${targetFn.moduleId}`
|
|
37
|
+
if (!moduleEdges.has(edgeKey)) {
|
|
38
|
+
moduleEdges.set(edgeKey, new Set())
|
|
39
|
+
}
|
|
40
|
+
moduleEdges.get(edgeKey)!.add(`${fn.name}→${targetFn.name}`)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
for (const [edge, calls] of moduleEdges) {
|
|
46
|
+
const [from, to] = edge.split('→')
|
|
47
|
+
lines.push(` ${this.sanitizeId(from)} -->|${calls.size} calls| ${this.sanitizeId(to)}`)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Style
|
|
51
|
+
lines.push('')
|
|
52
|
+
lines.push(' classDef module fill:#4a90d9,stroke:#2c3e50,color:#fff,stroke-width:2px')
|
|
53
|
+
for (const module of this.contract.declared.modules) {
|
|
54
|
+
lines.push(` class ${this.sanitizeId(module.id)} module`)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return lines.join('\n')
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private sanitizeId(id: string): string {
|
|
61
|
+
return id.replace(/[^a-zA-Z0-9_]/g, '_')
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import * as path from 'node:path'
|
|
2
|
+
import type { MikkContract, MikkLock } from '@getmikk/core'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ModuleDiagramGenerator — generates a detailed Mermaid diagram for a
|
|
6
|
+
* single module, showing its internal files and function call graph.
|
|
7
|
+
* Outputs: .mikk/diagrams/modules/{moduleId}.mmd
|
|
8
|
+
*/
|
|
9
|
+
export class ModuleDiagramGenerator {
|
|
10
|
+
constructor(
|
|
11
|
+
private contract: MikkContract,
|
|
12
|
+
private lock: MikkLock
|
|
13
|
+
) { }
|
|
14
|
+
|
|
15
|
+
generate(moduleId: string): string {
|
|
16
|
+
const module = this.contract.declared.modules.find(m => m.id === moduleId)
|
|
17
|
+
if (!module) return `%% Module "${moduleId}" not found`
|
|
18
|
+
|
|
19
|
+
const lines: string[] = []
|
|
20
|
+
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)
|
|
24
|
+
|
|
25
|
+
// Group functions by file
|
|
26
|
+
const functionsByFile = new Map<string, typeof moduleFunctions>()
|
|
27
|
+
for (const fn of moduleFunctions) {
|
|
28
|
+
if (!functionsByFile.has(fn.file)) {
|
|
29
|
+
functionsByFile.set(fn.file, [])
|
|
30
|
+
}
|
|
31
|
+
functionsByFile.get(fn.file)!.push(fn)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Add file subgraphs
|
|
35
|
+
for (const [filePath, fns] of functionsByFile) {
|
|
36
|
+
const fileName = path.basename(filePath)
|
|
37
|
+
lines.push(` subgraph file_${this.sanitizeId(filePath)}["📄 ${fileName}"]`)
|
|
38
|
+
for (const fn of fns) {
|
|
39
|
+
const icon = fn.calledBy.length === 0 ? '⚡' : 'λ' // ⚡ for entry points (called by nothing internal)
|
|
40
|
+
lines.push(` ${this.sanitizeId(fn.id)}["${icon} ${fn.name}"]`)
|
|
41
|
+
}
|
|
42
|
+
lines.push(' end')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
lines.push(' end')
|
|
46
|
+
lines.push('')
|
|
47
|
+
|
|
48
|
+
// Add call edges
|
|
49
|
+
for (const fn of moduleFunctions) {
|
|
50
|
+
for (const callTarget of fn.calls) {
|
|
51
|
+
const targetFn = this.lock.functions[callTarget]
|
|
52
|
+
if (targetFn) {
|
|
53
|
+
if (targetFn.moduleId === moduleId) {
|
|
54
|
+
// Internal call
|
|
55
|
+
lines.push(` ${this.sanitizeId(fn.id)} --> ${this.sanitizeId(callTarget)}`)
|
|
56
|
+
} else {
|
|
57
|
+
// External call
|
|
58
|
+
const targetMod = targetFn.moduleId
|
|
59
|
+
lines.push(` ${this.sanitizeId(fn.id)} -.->|"calls"| ext_${this.sanitizeId(callTarget)}["🔗 ${targetFn.name}<br/>(${targetMod})"]`)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
lines.push('')
|
|
66
|
+
lines.push(' classDef default fill:#f9f9f9,stroke:#333,stroke-width:1px')
|
|
67
|
+
lines.push(' classDef external fill:#ecf0f1,stroke:#bdc3c7,stroke-dasharray: 5 5')
|
|
68
|
+
|
|
69
|
+
return lines.join('\n')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private sanitizeId(id: string): string {
|
|
73
|
+
return id.replace(/[^a-zA-Z0-9_]/g, '_')
|
|
74
|
+
}
|
|
75
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { DiagramOrchestrator } from './orchestrator.js'
|
|
2
|
+
export { MainDiagramGenerator } from './generators/main-diagram.js'
|
|
3
|
+
export { ModuleDiagramGenerator } from './generators/module-diagram.js'
|
|
4
|
+
export { ImpactDiagramGenerator } from './generators/impact-diagram.js'
|
|
5
|
+
export { HealthDiagramGenerator } from './generators/health-diagram.js'
|
|
6
|
+
export { FlowDiagramGenerator } from './generators/flow-diagram.js'
|
|
7
|
+
export { CapsuleDiagramGenerator } from './generators/capsule-diagram.js'
|
|
8
|
+
export { DependencyMatrixGenerator } from './generators/dependency-matrix.js'
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import * as path from 'node:path'
|
|
2
|
+
import * as fs from 'node:fs/promises'
|
|
3
|
+
import type { MikkContract, MikkLock } from '@getmikk/core'
|
|
4
|
+
import { MainDiagramGenerator } from './generators/main-diagram.js'
|
|
5
|
+
import { ModuleDiagramGenerator } from './generators/module-diagram.js'
|
|
6
|
+
import { ImpactDiagramGenerator } from './generators/impact-diagram.js'
|
|
7
|
+
import { HealthDiagramGenerator } from './generators/health-diagram.js'
|
|
8
|
+
import { FlowDiagramGenerator } from './generators/flow-diagram.js'
|
|
9
|
+
import { CapsuleDiagramGenerator } from './generators/capsule-diagram.js'
|
|
10
|
+
import { DependencyMatrixGenerator } from './generators/dependency-matrix.js'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* DiagramOrchestrator — generates all diagram types and writes them to
|
|
14
|
+
* the .mikk/diagrams/ directory structure.
|
|
15
|
+
*/
|
|
16
|
+
export class DiagramOrchestrator {
|
|
17
|
+
constructor(
|
|
18
|
+
private contract: MikkContract,
|
|
19
|
+
private lock: MikkLock,
|
|
20
|
+
private projectRoot: string
|
|
21
|
+
) { }
|
|
22
|
+
|
|
23
|
+
/** Generate all diagrams */
|
|
24
|
+
async generateAll(): Promise<{ generated: string[] }> {
|
|
25
|
+
const generated: string[] = []
|
|
26
|
+
|
|
27
|
+
// Main diagram
|
|
28
|
+
const mainGen = new MainDiagramGenerator(this.contract, this.lock)
|
|
29
|
+
await this.writeDiagram('diagrams/main.mmd', mainGen.generate())
|
|
30
|
+
generated.push('diagrams/main.mmd')
|
|
31
|
+
|
|
32
|
+
// Health diagram
|
|
33
|
+
const healthGen = new HealthDiagramGenerator(this.contract, this.lock)
|
|
34
|
+
await this.writeDiagram('diagrams/health.mmd', healthGen.generate())
|
|
35
|
+
generated.push('diagrams/health.mmd')
|
|
36
|
+
|
|
37
|
+
// Flow diagram (entry points)
|
|
38
|
+
const flowGen = new FlowDiagramGenerator(this.lock)
|
|
39
|
+
await this.writeDiagram('diagrams/flows/entry-points.mmd', flowGen.generateEntryPoints())
|
|
40
|
+
generated.push('diagrams/flows/entry-points.mmd')
|
|
41
|
+
|
|
42
|
+
// Dependency matrix
|
|
43
|
+
const matrixGen = new DependencyMatrixGenerator(this.contract, this.lock)
|
|
44
|
+
await this.writeDiagram('diagrams/dependency-matrix.mmd', matrixGen.generate())
|
|
45
|
+
generated.push('diagrams/dependency-matrix.mmd')
|
|
46
|
+
|
|
47
|
+
// Per-module diagrams
|
|
48
|
+
for (const module of this.contract.declared.modules) {
|
|
49
|
+
const moduleGen = new ModuleDiagramGenerator(this.contract, this.lock)
|
|
50
|
+
await this.writeDiagram(`diagrams/modules/${module.id}.mmd`, moduleGen.generate(module.id))
|
|
51
|
+
generated.push(`diagrams/modules/${module.id}.mmd`)
|
|
52
|
+
|
|
53
|
+
const capsuleGen = new CapsuleDiagramGenerator(this.contract, this.lock)
|
|
54
|
+
await this.writeDiagram(`diagrams/capsules/${module.id}.mmd`, capsuleGen.generate(module.id))
|
|
55
|
+
generated.push(`diagrams/capsules/${module.id}.mmd`)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return { generated }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Generate impact diagram for specific changes */
|
|
62
|
+
async generateImpact(changedIds: string[], impactedIds: string[]): Promise<string> {
|
|
63
|
+
const impactGen = new ImpactDiagramGenerator(this.lock)
|
|
64
|
+
const diagram = impactGen.generate(changedIds, impactedIds)
|
|
65
|
+
const timestamp = Date.now()
|
|
66
|
+
const filename = `diagrams/impact/impact-${timestamp}.mmd`
|
|
67
|
+
await this.writeDiagram(filename, diagram)
|
|
68
|
+
return filename
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private async writeDiagram(relativePath: string, content: string): Promise<void> {
|
|
72
|
+
const fullPath = path.join(this.projectRoot, '.mikk', relativePath)
|
|
73
|
+
await fs.mkdir(path.dirname(fullPath), { recursive: true })
|
|
74
|
+
await fs.writeFile(fullPath, content, 'utf-8')
|
|
75
|
+
}
|
|
76
|
+
}
|