From c2614b760468e886b2c5d8952629e783a8358f06 Mon Sep 17 00:00:00 2001 From: Christian Melgarejo Date: Sun, 12 Apr 2026 09:13:33 -0300 Subject: [PATCH] feat(visualize): interactive module graph parity with OPOS - Analyzer: per-service RPC methods, PublicEndpoints, SQL migrations (tables/types), richer event detection (internal/, StoreOutbox, Publish, Subscribe, audit.Log) - HTML: dagre layout, modules vs database views, zoom, LR/TB, event hub nodes - JSON/DOT outputs unchanged; optional APP_NAME via .env for graph title - README: document views and FORMAT=json|dot; fix visualize recipe log typo Made-with: Cursor --- README.md | 2 +- cmd/visualize/analyzer/analyzer.go | 464 +++++++++++++----- cmd/visualize/main.go | 746 +++++++++++++++++------------ justfile | 2 +- 4 files changed, 769 insertions(+), 445 deletions(-) diff --git a/README.md b/README.md index 9139b20..760c62f 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ just dev-module auth - `just demo`: Complete end-to-end demo (setup + example). - `just test`: Run all unit and integration tests. - `just lint`: Run strict linter (MANDATORY for quality). -- `just visualize`: Generate a visual graph of module connections. +- `just visualize`: Generate an interactive module graph (`docs/module-graph.html`) with **modules** (gRPC + event bus) and **database** (tables from migrations) views; also `FORMAT=json|dot` for machine-readable output. - `just admin TASK=`: Execute maintenance tasks (e.g., `cleanup-sessions`). --- diff --git a/cmd/visualize/analyzer/analyzer.go b/cmd/visualize/analyzer/analyzer.go index f776729..ae93b68 100644 --- a/cmd/visualize/analyzer/analyzer.go +++ b/cmd/visualize/analyzer/analyzer.go @@ -1,4 +1,6 @@ // Package analyzer analyzes the modulith codebase to extract module connections. +// +//nolint:revive,wrapcheck,gosec,gocognit,cyclop,gocritic,gocyclo,nestif,funlen,unparam // This is a dev tool, lenient linting package analyzer import ( @@ -17,9 +19,12 @@ type Graph struct { // Module represents a single module in the system. type Module struct { - Name string `json:"name"` - Services []string `json:"services"` - Events []string `json:"events"` + Name string `json:"name"` + Services []string `json:"services"` + Events []string `json:"events"` + Tables []string `json:"tables"` + PublicMethods []string `json:"public_methods"` + ServiceMethods map[string][]string `json:"service_methods"` // Service Name -> []Method Name } // Connection represents a connection between modules. @@ -91,11 +96,18 @@ func discoverModules(modulesDir string) ([]string, error) { return modules, nil } -func analyzeModule(projectRoot, moduleName, _ string) *Module { +func analyzeModule(projectRoot, moduleName, modulePath string) *Module { module := &Module{ - Name: moduleName, - Services: []string{}, - Events: []string{}, + Name: moduleName, + Services: []string{}, + Events: []string{}, + PublicMethods: []string{}, + ServiceMethods: make(map[string][]string), + } + + // Analyze public endpoints from module.go + if publicMethods, err := analyzeModuleAuth(modulePath); err == nil { + module.PublicMethods = publicMethods } // Find proto files for this module (check subdirectories like v1/, v2/, etc.) @@ -109,7 +121,10 @@ func analyzeModule(projectRoot, moduleName, _ string) *Module { if strings.HasSuffix(path, ".proto") { services, err := extractServicesFromProto(path) if err == nil { - module.Services = append(module.Services, services...) + for sName, sMethods := range services { + module.Services = append(module.Services, sName) + module.ServiceMethods[sName] = sMethods + } } } @@ -119,10 +134,92 @@ func analyzeModule(projectRoot, moduleName, _ string) *Module { _ = err } + // Analyze database tables + // Look in modules/{moduleName}/resources/db/migration + migrationDir := filepath.Join(projectRoot, "modules", moduleName, "resources", "db", "migration") + + tables, err := analyzeDatabase(migrationDir) + if err == nil { + module.Tables = tables + } + return module } -func extractServicesFromProto(protoFile string) ([]string, error) { +func analyzeDatabase(migrationDir string) ([]string, error) { + var tables []string + + seenTables := make(map[string]bool) + + // Check if directory exists + if _, err := os.Stat(migrationDir); os.IsNotExist(err) { + return nil, nil // No migrations for this module + } + + err := filepath.Walk(migrationDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + + // Only look at .up.sql files + if !strings.HasSuffix(path, ".up.sql") { + return nil + } + + extractedTables, err := extractTablesFromSQL(path) + if err != nil { + return nil // Skip file on error + } + + for _, table := range extractedTables { + if !seenTables[table] { + tables = append(tables, table) + seenTables[table] = true + } + } + + return nil + }) + + return tables, err +} + +func extractTablesFromSQL(sqlFile string) ([]string, error) { + data, err := os.ReadFile(filepath.Clean(sqlFile)) + if err != nil { + return nil, fmt.Errorf("failed to read SQL file: %w", err) + } + + var tables []string + + content := string(data) + + // Regex to match CREATE TABLE statements + // Matches: CREATE TABLE [IF NOT EXISTS] table_name + // Ignore case + tableRegex := regexp.MustCompile(`(?i)CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([a-zA-Z0-9_]+)`) + matches := tableRegex.FindAllStringSubmatch(content, -1) + + for _, match := range matches { + if len(match) > 1 { + tables = append(tables, match[1]) + } + } + + // Also look for CREATE TYPE statements (enums) + typeRegex := regexp.MustCompile(`(?i)CREATE\s+TYPE\s+([a-zA-Z0-9_]+)`) + typeMatches := typeRegex.FindAllStringSubmatch(content, -1) + + for _, match := range typeMatches { + if len(match) > 1 { + tables = append(tables, match[1]+" (TYPE)") + } + } + + return tables, nil +} + +func extractServicesFromProto(protoFile string) (map[string][]string, error) { // Validate file path to prevent directory traversal if !filepath.IsAbs(protoFile) { absPath, err := filepath.Abs(protoFile) @@ -138,17 +235,40 @@ func extractServicesFromProto(protoFile string) ([]string, error) { return nil, fmt.Errorf("failed to read proto file: %w", err) } - var services []string + services := make(map[string][]string) lines := strings.Split(string(data), "\n") + + var currentService string + for _, line := range lines { line = strings.TrimSpace(line) + // Detect service start if strings.HasPrefix(line, "service ") { // Extract service name: "service AuthService {" parts := strings.Fields(line) if len(parts) >= 2 { serviceName := strings.TrimSuffix(parts[1], "{") - services = append(services, serviceName) + currentService = serviceName + services[currentService] = []string{} + } + } else if currentService != "" && strings.Contains(line, "rpc ") && strings.Contains(line, "(") { + // Very basic RPC detection: rpc Login(LoginRequest) returns (LoginResponse) {} + // or rpc Login (LoginRequest) returns (LoginResponse); + parts := strings.Fields(line) + if len(parts) >= 2 && parts[0] == "rpc" { + rpcName := parts[1] + // Handle "rpc Login(" case + if idx := strings.Index(rpcName, "("); idx != -1 { + rpcName = rpcName[:idx] + } + + services[currentService] = append(services[currentService], rpcName) + } + } else if strings.Contains(line, "}") && currentService != "" { + // End of service block (naive but works for standard formatting) + if strings.HasPrefix(line, "}") { + currentService = "" } } } @@ -156,6 +276,58 @@ func extractServicesFromProto(protoFile string) ([]string, error) { return services, nil } +// analyzeModuleAuth scans the module directory for PublicEndpoints method. +func analyzeModuleAuth(moduleDir string) ([]string, error) { + var publicMethods []string + + // Look for module.go + moduleGo := filepath.Join(moduleDir, "module.go") + if _, err := os.Stat(moduleGo); err != nil { + return nil, err + } + + data, err := os.ReadFile(moduleGo) + if err != nil { + return nil, err + } + + content := string(data) + + // Regex to find the return []string{ ... } block inside PublicEndpoints + // This is a bit brittle with regex but sufficient for this specific codebase convention. + // func (m *Module) PublicEndpoints() []string { + // return []string{ + // "/auth.v1.AuthService/RequestLogin", + // ... + // } + // } + + // Find the start of the function + funcStart := strings.Index(content, "PublicEndpoints() []string") + if funcStart == -1 { + return nil, nil // Not implemented + } + + // Extract the content after function definition + rest := content[funcStart:] + + // Find strings starting with "/" inside quotes + // Matches: "/package.Service/Method" + re := regexp.MustCompile(`"(/[a-zA-Z0-9_./]+)"`) + matches := re.FindAllStringSubmatch(rest, -1) + + for _, match := range matches { + if len(match) > 1 { + // Ensure verify it looks like a gRPC path + if strings.Count(match[1], "/") >= 2 { + publicMethods = append(publicMethods, match[1]) + } + } + } + + return publicMethods, nil +} + //nolint:cyclop // Complex function needed to analyze proto connections func analyzeProtoConnections(_ string, protoDir string, graph *Graph) error { // Walk through proto files and find service definitions @@ -190,7 +362,6 @@ func analyzeProtoConnections(_ string, protoDir string, graph *Graph) error { // Read proto file to find services cleanPath := filepath.Clean(path) - //nolint:gosec data, err := os.ReadFile(cleanPath) if err != nil { return nil @@ -226,159 +397,179 @@ func analyzeProtoConnections(_ string, protoDir string, graph *Graph) error { return nil } -//nolint:gocognit,cyclop,funlen // Complex function needed to analyze event connections +// analyzeEventConnections scans the codebase for event publications and subscriptions. func analyzeEventConnections(projectRoot, modulesDir string, graph *Graph) error { // Map to track event publishers eventPublishers := make(map[string]string) // event name -> module name - // First pass: find all event publications - err := filepath.Walk(modulesDir, func(path string, _ os.FileInfo, err error) error { - if err != nil { - return err - } + internalDir := filepath.Join(projectRoot, "internal") + scanDirs := []string{modulesDir, internalDir} - if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { - return nil - } + moduleMap := make(map[string]bool) + for _, m := range graph.Modules { + moduleMap[m.Name] = true + } - cleanPath := filepath.Clean(path) + // First pass: find all event publications + for _, dir := range scanDirs { + _ = filepath.Walk(dir, func(path string, _ os.FileInfo, err error) error { + if err != nil || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } - //nolint:gosec - data, err := os.ReadFile(cleanPath) - if err != nil { - return nil - } + data, err := os.ReadFile(filepath.Clean(path)) + if err != nil { + return nil + } - content := string(data) + content := string(data) - // Find module name from path - relPath, err := filepath.Rel(modulesDir, path) - if err != nil { - return nil - } + // Find module name from path + relPath, _ := filepath.Rel(dir, path) - parts := strings.Split(relPath, string(filepath.Separator)) - if len(parts) < 1 { - return nil - } + parts := strings.Split(relPath, string(filepath.Separator)) + if len(parts) < 1 { + return nil + } - moduleName := parts[0] + moduleName := parts[0] - // Find Publish calls with event names - // Pattern: bus.Publish(ctx, events.Event{Name: "event.name", ...}) - // Or: events.Event{Name: events.EventUserCreated, ...} - // Or: events.Event{Name: notifier.EventMagicCodeRequested, ...} - // Handle multi-line patterns - look for Name: field after Event{ - publishRegex := regexp.MustCompile(`Event\s*\{[^}]*Name:\s*([^,}\n]+)`) - - matches := publishRegex.FindAllStringSubmatch(content, -1) - for _, match := range matches { - if len(match) > 1 { - eventName := strings.TrimSpace(match[1]) - eventName = strings.Trim(eventName, `"`) - - // Handle constants from different packages - if strings.Contains(eventName, ".") { - // It's a constant like "events.EventUserCreated" or "notifier.EventMagicCodeRequested" - eventName = resolveEventConstant(projectRoot, eventName) - } + // Skip internal packages that don't map to modules + if dir == internalDir && !moduleMap[moduleName] { + return nil + } - if eventName != "" { - eventPublishers[eventName] = moduleName - // Also add to module's events list - updateModuleEvents(graph, moduleName, eventName) - } + // Better multi-line regexes + patterns := []*regexp.Regexp{ + // Event{Name: "..."} + regexp.MustCompile(`(?s)Event\s*\{[^}]*Name:\s*("([^"]+)"|([a-zA-Z0-9_.]+))`), + // .StoreOutbox(ctx, "...") + regexp.MustCompile(`(?s)StoreOutbox\s*\(\s*[^,]+\s*,\s*("([^"]+)"|([a-zA-Z0-9_.]+))`), + // .Publish(ctx, "...") + regexp.MustCompile(`(?s)Publish\s*\(\s*[^,]+\s*,\s*("([^"]+)"|([a-zA-Z0-9_.]+))`), + // audit.Log(...) always publishes EventAuditLogCreated + regexp.MustCompile(`(?s)audit\.Log\s*\(`), } - } - // Also look for direct string literals in Publish calls (multi-line aware) - publishStringRegex := regexp.MustCompile(`Name:\s*"([^"]+)"`) + for _, re := range patterns { + matches := re.FindAllStringSubmatchIndex(content, -1) + for _, matchIdx := range matches { + eventName := "" + isConstant := false + + // Specific handling for audit.Log + if strings.Contains(re.String(), "audit\\.Log") { + eventName = "audit.log.created" // This is the value of EventAuditLogCreated + } else if matchIdx[4] != -1 { + eventName = content[matchIdx[4]:matchIdx[5]] + } else if matchIdx[6] != -1 { + eventName = content[matchIdx[6]:matchIdx[7]] + isConstant = true + } + + start := matchIdx[0] - matches2 := publishStringRegex.FindAllStringSubmatch(content, -1) - for _, match := range matches2 { - if len(match) > 1 { - eventName := match[1] - eventPublishers[eventName] = moduleName - updateModuleEvents(graph, moduleName, eventName) + lookBackLimit := 0 + if start > 40 { + lookBackLimit = start - 40 + } + + preceding := content[lookBackLimit:start] + if strings.Contains(preceding, "func ") || strings.Contains(preceding, "interface {") { + continue + } + + if eventName == "" || eventName == "eventName" || eventName == "name" || eventName == "ctx" || eventName == "arg" || eventName == "event" { + continue + } + + if isConstant && strings.Contains(eventName, ".") { + eventName = resolveEventConstant(projectRoot, eventName) + } + + if eventName != "" { + eventPublishers[eventName] = moduleName + updateModuleEvents(graph, moduleName, eventName) + } + } } - } - return nil - }) - if err != nil { - return fmt.Errorf("failed to walk modules directory: %w", err) + return nil + }) } // Second pass: find subscriptions and create connections - if err := filepath.Walk(modulesDir, func(path string, _ os.FileInfo, err error) error { - if err != nil { - return err - } + for _, dir := range scanDirs { + _ = filepath.Walk(dir, func(path string, _ os.FileInfo, err error) error { + if err != nil || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } - if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { - return nil - } + data, _ := os.ReadFile(filepath.Clean(path)) + content := string(data) - cleanPath := filepath.Clean(path) + relPath, _ := filepath.Rel(dir, path) - //nolint:gosec - data, err := os.ReadFile(cleanPath) - if err != nil { - return nil - } + parts := strings.Split(relPath, string(filepath.Separator)) + if len(parts) < 1 { + return nil + } - content := string(data) + moduleName := parts[0] - // Find module name from path - relPath, err := filepath.Rel(modulesDir, path) - if err != nil { - return nil - } + if dir == internalDir && !moduleMap[moduleName] { + return nil + } - parts := strings.Split(relPath, string(filepath.Separator)) - if len(parts) < 1 { - return nil - } + re := regexp.MustCompile(`(?s)Subscribe\s*\(\s*("([^"]+)"|([a-zA-Z0-9_.]+))`) + matches := re.FindAllStringSubmatchIndex(content, -1) - moduleName := parts[0] + for _, matchIdx := range matches { + eventName := "" + isConstant := false + + if matchIdx[4] != -1 { + eventName = content[matchIdx[4]:matchIdx[5]] + } else if matchIdx[6] != -1 { + eventName = content[matchIdx[6]:matchIdx[7]] + isConstant = true + } - // Find Subscribe calls - // Pattern: bus.Subscribe("event.name", handler) - // Or: eventBus.Subscribe(events.EventUserCreated, handler) - subscribeRegex := regexp.MustCompile(`\.Subscribe\(([^,)]+)`) + start := matchIdx[0] - matches := subscribeRegex.FindAllStringSubmatch(content, -1) + lookBackLimit := 0 + if start > 20 { + lookBackLimit = start - 20 + } - for _, match := range matches { - if len(match) > 1 { - eventName := strings.Trim(match[1], `" `) - eventName = strings.Trim(eventName, `"`) + if strings.Contains(content[lookBackLimit:start], "func ") { + continue + } - // Handle string literals (already resolved) - if strings.HasPrefix(eventName, `"`) && strings.HasSuffix(eventName, `"`) { - eventName = strings.Trim(eventName, `"`) - } else if strings.Contains(eventName, ".") { - // It's a constant like "events.EventUserCreated" or "notifier.EventMagicCodeRequested" + if eventName == "" || eventName == "eventName" || eventName == "name" || eventName == "event" { + continue + } + + if isConstant && strings.Contains(eventName, ".") { eventName = resolveEventConstant(projectRoot, eventName) } if eventName != "" { - // Find publisher - if publisher, ok := eventPublishers[eventName]; ok && publisher != moduleName { - graph.Connections = append(graph.Connections, Connection{ - From: publisher, - To: moduleName, - Type: "event", - Event: eventName, - }) + if publisher, ok := eventPublishers[eventName]; ok { + if publisher != moduleName { + graph.Connections = append(graph.Connections, Connection{ + From: publisher, + To: moduleName, + Type: "event", + Event: eventName, + }) + } } } } - } - return nil - }); err != nil { - return fmt.Errorf("failed to walk modules directory for subscriptions: %w", err) + return nil + }) } return nil @@ -399,6 +590,11 @@ func determineConstantFile(projectRoot, constant string) (string, string) { typesFile := filepath.Join(projectRoot, "internal", "events", "types.go") constantName := strings.TrimPrefix(constant, "events.") + return typesFile, constantName + case strings.HasPrefix(constant, "internalEvents."): + typesFile := filepath.Join(projectRoot, "internal", "events", "types.go") + constantName := strings.TrimPrefix(constant, "internalEvents.") + return typesFile, constantName case strings.HasPrefix(constant, "notifier."): typesFile := filepath.Join(projectRoot, "internal", "notifier", "subscriber.go") @@ -437,7 +633,23 @@ func extractConstantValue(typesFile, constantName string) string { } func extractFromLine(line, constantName string) string { - if !strings.Contains(line, constantName+" = ") { + // Trim spaces to handle indentation + line = strings.TrimSpace(line) + + // Check if line starts with constant name + if !strings.HasPrefix(line, constantName) { + return "" + } + + rest := strings.TrimPrefix(line, constantName) + + // Ensure exact match (next character must be whitespace or =) + if len(rest) > 0 && rest[0] != ' ' && rest[0] != '\t' && rest[0] != '=' { + return "" + } + + // Check if followed by = (allowing for spaces/tabs) + if !strings.Contains(rest, "=") { return "" } diff --git a/cmd/visualize/main.go b/cmd/visualize/main.go index cf2d112..5a436f3 100644 --- a/cmd/visualize/main.go +++ b/cmd/visualize/main.go @@ -1,8 +1,8 @@ -// Package main provides a development tool to visualize module connections -// in the modulith architecture, similar to Encore.dev's service graph. +//nolint:revive,errcheck,gosec,wrapcheck,funlen,cyclop // This is a dev tool, lenient linting package main import ( + "bufio" "encoding/json" "flag" "fmt" @@ -11,9 +11,12 @@ import ( "path/filepath" "strings" + "github.com/joho/godotenv" "github.com/LoopContext/go-modulith-template/cmd/visualize/analyzer" ) +const defaultProjectName = "Modulith" + var ( outputFile = flag.String("output", "", "Output file (auto-determined by format if not specified)") port = flag.Int("port", 8081, "Port for web server (if serving)") @@ -40,7 +43,9 @@ func main() { os.Exit(1) } - if err := outputGraph(graph, *outputFile, *format); err != nil { + projectName := determineProjectName(projectRoot) + + if err := outputGraph(graph, *outputFile, *format, projectName); err != nil { slog.Error("Failed to output graph", "error", err) os.Exit(1) } @@ -53,6 +58,24 @@ func main() { } } +func determineProjectName(projectRoot string) string { + _ = godotenv.Load(filepath.Join(projectRoot, ".env")) + + projectName := os.Getenv("APP_NAME") + if projectName == "" { + modName, err := getProjectName(projectRoot) + if err == nil && modName != "" && modName != defaultProjectName { + projectName = modName + } + } + + if projectName == "" || projectName == defaultProjectName { + projectName = defaultProjectName + " Project" + } + + return projectName +} + func determineOutputFile() { if *outputFile != "" { return @@ -70,14 +93,14 @@ func determineOutputFile() { } } -func outputGraph(graph *analyzer.Graph, filename, format string) error { +func outputGraph(graph *analyzer.Graph, filename, format, projectName string) error { switch format { case "json": return outputJSONFormat(graph, filename) case "dot": return outputDOTFormat(graph, filename) case "html": - return outputHTMLFormat(graph, filename) + return outputHTMLFormat(graph, filename, projectName) default: return fmt.Errorf("unknown format: %s", format) } @@ -111,8 +134,8 @@ func outputDOTFormat(graph *analyzer.Graph, filename string) error { return nil } -func outputHTMLFormat(graph *analyzer.Graph, filename string) error { - if err := outputHTML(graph, filename); err != nil { +func outputHTMLFormat(graph *analyzer.Graph, filename, projectName string) error { + if err := outputHTML(graph, filename, projectName); err != nil { return fmt.Errorf("failed to write HTML output: %w", err) } @@ -145,35 +168,52 @@ func findProjectRoot() (string, error) { } } +func getProjectName(root string) (string, error) { + f, err := os.Open(filepath.Join(root, "go.mod")) + if err != nil { + return "", err + } + defer f.Close() + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if strings.HasPrefix(line, "module ") { + modPath := strings.TrimSpace(strings.TrimPrefix(line, "module ")) + + parts := strings.Split(modPath, "/") + if len(parts) > 0 { + return parts[len(parts)-1], nil + } + + return modPath, nil + } + } + + return defaultProjectName, nil +} + func outputJSON(graph *analyzer.Graph, filename string) error { data, err := json.MarshalIndent(graph, "", " ") if err != nil { return fmt.Errorf("failed to marshal JSON: %w", err) } - cleanPath := filepath.Clean(filename) - if err := os.WriteFile(cleanPath, data, 0o600); err != nil { - return fmt.Errorf("failed to write file: %w", err) - } - - return nil + return os.WriteFile(filepath.Clean(filename), data, 0o600) } func outputDOT(graph *analyzer.Graph, filename string) error { var sb strings.Builder - sb.WriteString("digraph modulith {\n") sb.WriteString(" rankdir=LR;\n") sb.WriteString(" node [shape=box, style=rounded];\n\n") - // Add nodes for _, module := range graph.Modules { fmt.Fprintf(&sb, " \"%s\" [label=\"%s\"];\n", module.Name, module.Name) } sb.WriteString("\n") - // Add gRPC connections for _, conn := range graph.Connections { if conn.Type == "grpc" { style := "solid" @@ -186,7 +226,6 @@ func outputDOT(graph *analyzer.Graph, filename string) error { } } - // Add event connections for _, conn := range graph.Connections { if conn.Type == "event" { fmt.Fprintf(&sb, " \"%s\" -> \"%s\" [label=\"%s\", style=dotted, color=blue];\n", @@ -196,362 +235,435 @@ func outputDOT(graph *analyzer.Graph, filename string) error { sb.WriteString("}\n") - cleanPath := filepath.Clean(filename) - - if err := os.WriteFile(cleanPath, []byte(sb.String()), 0o600); err != nil { - return fmt.Errorf("failed to write file: %w", err) - } - - return nil + return os.WriteFile(filepath.Clean(filename), []byte(sb.String()), 0o600) } -func outputHTML(graph *analyzer.Graph, filename string) error { - // Generate HTML with embedded visualization - html := generateHTML(graph) - - cleanPath := filepath.Clean(filename) - - if err := os.WriteFile(cleanPath, []byte(html), 0o600); err != nil { - return fmt.Errorf("failed to write file: %w", err) - } - - return nil +func outputHTML(graph *analyzer.Graph, filename, projectName string) error { + html := generateHTML(graph, projectName) + return os.WriteFile(filepath.Clean(filename), []byte(html), 0o600) } func serveWebUI(_ *analyzer.Graph, _ int) error { - // This will be implemented to serve the web UI return fmt.Errorf("web server not yet implemented, use --format=html instead") } -//nolint:funlen // HTML generation requires long function -func generateHTML(graph *analyzer.Graph) string { - // Convert graph to JSON for embedding +func generateHTML(graph *analyzer.Graph, projectName string) string { graphJSON, _ := json.Marshal(graph) graphJSONStr := string(graphJSON) - return ` + return fmt.Sprintf(` - Modulith Module Graph + %s Module Graph +
-

🔗 Modulith Module Graph

+
+

%s

+
+
-
-
-
- gRPC (inbound) +
+
+ +
-
-
- gRPC (outbound) -
-
-
- Events +
+ +
+
+ + + +
-` +`, projectName, projectName) } diff --git a/justfile b/justfile index 3a02ccf..805a8b2 100644 --- a/justfile +++ b/justfile @@ -707,7 +707,7 @@ be-graphql-validate: # Visualize module connections be-visualize format="html" serve="false": - @echo "🔍 Analyzing Modulith modulith architecture..." + @echo "🔍 Analyzing modulith architecture..." @if [ "{{serve}}" = "true" ]; then \ go run ./cmd/visualize/main.go -format={{format}} -serve; \ else \