From 63c3a42cb07fd84a56e83a7ebf9700f3a1745683 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 19:54:29 +0000 Subject: [PATCH 1/4] Initial plan From be23865cd8be6f4c6bed76262cb8c28c34a7449f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 20:11:48 +0000 Subject: [PATCH 2/4] Add wfctl docs generate command for Markdown + Mermaid documentation generation Implements the `wfctl docs generate` command that produces Markdown documentation with embedded Mermaid diagrams from workflow configuration files. Generated documentation includes: - README.md: Application overview, required plugins, capabilities, sidecars - modules.md: Module inventory, type breakdown, config details, dependency graph - pipelines.md: Pipeline definitions, step tables, workflow diagrams, compensation - workflows.md: HTTP routes with route diagrams, messaging, state machine diagrams - plugins.md: External plugin details (when -plugin-dir is specified) - architecture.md: Layered system architecture and plugin architecture diagrams All Mermaid node names are properly quoted/escaped to pass validation. Includes example config with GoCodeAlone/workflow-plugin-authz external plugin reference, comprehensive tests, and WFCTL.md documentation. Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --- cmd/wfctl/docs.go | 1069 +++++++++++++++++ cmd/wfctl/docs_test.go | 779 ++++++++++++ cmd/wfctl/main.go | 1 + cmd/wfctl/wfctl.yaml | 13 + docs/WFCTL.md | 37 + example/docs-with-plugins/README.md | 66 + .../plugins/workflow-plugin-authz/plugin.json | 63 + example/docs-with-plugins/workflow.yaml | 278 +++++ 8 files changed, 2306 insertions(+) create mode 100644 cmd/wfctl/docs.go create mode 100644 cmd/wfctl/docs_test.go create mode 100644 example/docs-with-plugins/README.md create mode 100644 example/docs-with-plugins/plugins/workflow-plugin-authz/plugin.json create mode 100644 example/docs-with-plugins/workflow.yaml diff --git a/cmd/wfctl/docs.go b/cmd/wfctl/docs.go new file mode 100644 index 00000000..ca0906b4 --- /dev/null +++ b/cmd/wfctl/docs.go @@ -0,0 +1,1069 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/GoCodeAlone/workflow/config" + "github.com/GoCodeAlone/workflow/plugin" +) + +func runDocs(args []string) error { + if len(args) < 1 { + return docsUsage() + } + switch args[0] { + case "generate": + return runDocsGenerate(args[1:]) + default: + return docsUsage() + } +} + +func docsUsage() error { + fmt.Fprintf(os.Stderr, `Usage: wfctl docs [options] + +Generate documentation from workflow configurations. + +Subcommands: + generate Generate Markdown documentation with Mermaid diagrams + +Examples: + wfctl docs generate workflow.yaml + wfctl docs generate -output ./docs/ workflow.yaml + wfctl docs generate -output ./docs/ -plugin-dir ./plugins/ workflow.yaml +`) + return fmt.Errorf("subcommand is required (generate)") +} + +func runDocsGenerate(args []string) error { + fs := flag.NewFlagSet("docs generate", flag.ContinueOnError) + output := fs.String("output", "./docs/generated/", "Output directory for generated documentation") + pluginDir := fs.String("plugin-dir", "", "Directory containing external plugin manifests (plugin.json)") + title := fs.String("title", "", "Application title (default: derived from config)") + + fs.Usage = func() { + fmt.Fprintf(fs.Output(), `Usage: wfctl docs generate [options] + +Generate Markdown documentation with Mermaid diagrams from a workflow +configuration file. If -plugin-dir is specified, external plugin manifests +(plugin.json) are loaded and described in the output. + +Options: +`) + fs.PrintDefaults() + } + + if err := fs.Parse(args); err != nil { + return err + } + + if fs.NArg() < 1 { + fs.Usage() + return fmt.Errorf("config file path is required") + } + + configFile := fs.Arg(0) + cfg, err := config.LoadFromFile(configFile) + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + // Load external plugin manifests if a plugin directory is specified. + var plugins []*plugin.PluginManifest + if *pluginDir != "" { + plugins, err = loadPluginManifests(*pluginDir) + if err != nil { + return fmt.Errorf("failed to load plugin manifests from %s: %w", *pluginDir, err) + } + } + + appTitle := *title + if appTitle == "" { + appTitle = deriveTitle(configFile) + } + + if err := os.MkdirAll(*output, 0750); err != nil { + return fmt.Errorf("failed to create output directory %s: %w", *output, err) + } + + gen := &docsGenerator{ + cfg: cfg, + plugins: plugins, + title: appTitle, + outputDir: *output, + } + + files, err := gen.generate() + if err != nil { + return fmt.Errorf("failed to generate documentation: %w", err) + } + + for _, f := range files { + fmt.Printf(" create %s\n", f) + } + fmt.Printf("\nGenerated %d documentation file(s) in %s\n", len(files), *output) + return nil +} + +// loadPluginManifests recursively walks a directory tree looking for +// plugin.json files and returns the parsed manifests. +func loadPluginManifests(dir string) ([]*plugin.PluginManifest, error) { + var manifests []*plugin.PluginManifest + err := filepath.Walk(dir, func(path string, info os.FileInfo, walkErr error) error { + if walkErr != nil { + return walkErr + } + if info.IsDir() || info.Name() != "plugin.json" { + return nil + } + m, loadErr := plugin.LoadManifest(path) + if loadErr != nil { + return nil //nolint:nilerr // intentionally skip invalid manifests + } + manifests = append(manifests, m) + return nil + }) + return manifests, err +} + +// deriveTitle creates a human-readable title from the config file path. +func deriveTitle(configFile string) string { + base := filepath.Base(configFile) + name := strings.TrimSuffix(base, filepath.Ext(base)) + name = strings.ReplaceAll(name, "-", " ") + name = strings.ReplaceAll(name, "_", " ") + return strings.Title(name) //nolint:staticcheck // strings.Title is adequate here +} + +// mermaidQuote wraps a string for safe use as a mermaid node label. +// If the string contains characters that could break mermaid syntax it is +// wrapped in double-quotes with internal quotes escaped. +func mermaidQuote(s string) string { + needsQuote := false + for _, c := range s { + switch c { + case ' ', '(', ')', '[', ']', '{', '}', '<', '>', '"', '\'', + '|', '#', '&', ';', ':', ',', '.', '/', '\\', '-', '+', + '=', '!', '?', '@', '$', '%', '^', '*', '~', '`': + needsQuote = true + } + if needsQuote { + break + } + } + if !needsQuote && s != "" { + return s + } + escaped := strings.ReplaceAll(s, `"`, `#quot;`) + return `"` + escaped + `"` +} + +// mermaidID generates a safe mermaid node identifier from an arbitrary string. +func mermaidID(s string) string { + var b strings.Builder + for _, c := range s { + if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' { + b.WriteRune(c) + } else { + b.WriteRune('_') + } + } + id := b.String() + if id == "" { + return "_empty" + } + return id +} + +// docsGenerator holds state for a single documentation generation run. +type docsGenerator struct { + cfg *config.WorkflowConfig + plugins []*plugin.PluginManifest + title string + outputDir string +} + +func (g *docsGenerator) generate() ([]string, error) { + var files []string + + // 1. README.md – overview + readmePath := filepath.Join(g.outputDir, "README.md") + if err := g.writeOverview(readmePath); err != nil { + return nil, fmt.Errorf("writing overview: %w", err) + } + files = append(files, readmePath) + + // 2. modules.md – module inventory + if len(g.cfg.Modules) > 0 { + modPath := filepath.Join(g.outputDir, "modules.md") + if err := g.writeModules(modPath); err != nil { + return nil, fmt.Errorf("writing modules: %w", err) + } + files = append(files, modPath) + } + + // 3. pipelines.md – pipeline details with mermaid diagrams + if len(g.cfg.Pipelines) > 0 { + pipPath := filepath.Join(g.outputDir, "pipelines.md") + if err := g.writePipelines(pipPath); err != nil { + return nil, fmt.Errorf("writing pipelines: %w", err) + } + files = append(files, pipPath) + } + + // 4. workflows.md – workflow details (HTTP routes, messaging, etc.) + if len(g.cfg.Workflows) > 0 { + wfPath := filepath.Join(g.outputDir, "workflows.md") + if err := g.writeWorkflows(wfPath); err != nil { + return nil, fmt.Errorf("writing workflows: %w", err) + } + files = append(files, wfPath) + } + + // 5. plugins.md – external plugin documentation + if len(g.plugins) > 0 { + plPath := filepath.Join(g.outputDir, "plugins.md") + if err := g.writePlugins(plPath); err != nil { + return nil, fmt.Errorf("writing plugins: %w", err) + } + files = append(files, plPath) + } + + // 6. architecture.md – system architecture diagram + archPath := filepath.Join(g.outputDir, "architecture.md") + if err := g.writeArchitecture(archPath); err != nil { + return nil, fmt.Errorf("writing architecture: %w", err) + } + files = append(files, archPath) + + return files, nil +} + +// ---------- Overview (README.md) ---------- + +func (g *docsGenerator) writeOverview(path string) error { + var b strings.Builder + + fmt.Fprintf(&b, "# %s\n\n", g.title) + b.WriteString("> Auto-generated documentation from workflow configuration.\n\n") + + // Quick stats + b.WriteString("## Overview\n\n") + fmt.Fprintf(&b, "| Metric | Count |\n") + fmt.Fprintf(&b, "|--------|-------|\n") + fmt.Fprintf(&b, "| Modules | %d |\n", len(g.cfg.Modules)) + fmt.Fprintf(&b, "| Workflows | %d |\n", len(g.cfg.Workflows)) + fmt.Fprintf(&b, "| Pipelines | %d |\n", len(g.cfg.Pipelines)) + if g.cfg.Requires != nil { + fmt.Fprintf(&b, "| Required Plugins | %d |\n", len(g.cfg.Requires.Plugins)) + fmt.Fprintf(&b, "| Required Capabilities | %d |\n", len(g.cfg.Requires.Capabilities)) + } + if len(g.plugins) > 0 { + fmt.Fprintf(&b, "| External Plugins (loaded) | %d |\n", len(g.plugins)) + } + b.WriteString("\n") + + // Required plugins + if g.cfg.Requires != nil && len(g.cfg.Requires.Plugins) > 0 { + b.WriteString("## Required Plugins\n\n") + b.WriteString("| Plugin | Version |\n") + b.WriteString("|--------|---------|\n") + for _, p := range g.cfg.Requires.Plugins { + ver := p.Version + if ver == "" { + ver = "*" + } + fmt.Fprintf(&b, "| `%s` | %s |\n", p.Name, ver) + } + b.WriteString("\n") + } + + // Required capabilities + if g.cfg.Requires != nil && len(g.cfg.Requires.Capabilities) > 0 { + b.WriteString("## Required Capabilities\n\n") + for _, c := range g.cfg.Requires.Capabilities { + fmt.Fprintf(&b, "- `%s`\n", c) + } + b.WriteString("\n") + } + + // Sidecars + if len(g.cfg.Sidecars) > 0 { + b.WriteString("## Sidecars\n\n") + b.WriteString("| Name | Type |\n") + b.WriteString("|------|------|\n") + for _, sc := range g.cfg.Sidecars { + fmt.Fprintf(&b, "| `%s` | `%s` |\n", sc.Name, sc.Type) + } + b.WriteString("\n") + } + + // Table of contents + b.WriteString("## Documentation Index\n\n") + if len(g.cfg.Modules) > 0 { + b.WriteString("- [Modules](modules.md) — Module inventory and dependency graph\n") + } + if len(g.cfg.Pipelines) > 0 { + b.WriteString("- [Pipelines](pipelines.md) — Pipeline definitions with workflow diagrams\n") + } + if len(g.cfg.Workflows) > 0 { + b.WriteString("- [Workflows](workflows.md) — HTTP routes, messaging, and workflow details\n") + } + if len(g.plugins) > 0 { + b.WriteString("- [Plugins](plugins.md) — External plugin details and capabilities\n") + } + b.WriteString("- [Architecture](architecture.md) — System architecture diagram\n") + b.WriteString("\n") + + return os.WriteFile(path, []byte(b.String()), 0600) +} + +// ---------- Modules (modules.md) ---------- + +func (g *docsGenerator) writeModules(path string) error { + var b strings.Builder + + b.WriteString("# Modules\n\n") + + // Module table + b.WriteString("## Module Inventory\n\n") + b.WriteString("| Name | Type | Dependencies |\n") + b.WriteString("|------|------|--------------|\n") + for _, mod := range g.cfg.Modules { + deps := "—" + if len(mod.DependsOn) > 0 { + parts := make([]string, len(mod.DependsOn)) + for i, d := range mod.DependsOn { + parts[i] = "`" + d + "`" + } + deps = strings.Join(parts, ", ") + } + fmt.Fprintf(&b, "| `%s` | `%s` | %s |\n", mod.Name, mod.Type, deps) + } + b.WriteString("\n") + + // Module type summary + typeCount := make(map[string]int) + for _, mod := range g.cfg.Modules { + typeCount[mod.Type]++ + } + b.WriteString("## Module Types\n\n") + b.WriteString("| Type | Count |\n") + b.WriteString("|------|-------|\n") + types := sortedKeys(typeCount) + for _, t := range types { + fmt.Fprintf(&b, "| `%s` | %d |\n", t, typeCount[t]) + } + b.WriteString("\n") + + // Module configurations + b.WriteString("## Module Configuration Details\n\n") + for _, mod := range g.cfg.Modules { + fmt.Fprintf(&b, "### `%s`\n\n", mod.Name) + fmt.Fprintf(&b, "- **Type:** `%s`\n", mod.Type) + if len(mod.DependsOn) > 0 { + fmt.Fprintf(&b, "- **Dependencies:** %s\n", strings.Join(mod.DependsOn, ", ")) + } + if len(mod.Config) > 0 { + b.WriteString("\n**Configuration:**\n\n```yaml\n") + writeConfigYAML(&b, mod.Config, "") + b.WriteString("```\n") + } + b.WriteString("\n") + } + + // Dependency graph + hasDeps := false + for _, mod := range g.cfg.Modules { + if len(mod.DependsOn) > 0 { + hasDeps = true + break + } + } + if hasDeps { + b.WriteString("## Dependency Graph\n\n") + b.WriteString("```mermaid\ngraph LR\n") + for _, mod := range g.cfg.Modules { + for _, dep := range mod.DependsOn { + fmt.Fprintf(&b, " %s[%s] --> %s[%s]\n", + mermaidID(mod.Name), mermaidQuote(mod.Name), + mermaidID(dep), mermaidQuote(dep)) + } + } + b.WriteString("```\n\n") + } + + return os.WriteFile(path, []byte(b.String()), 0600) +} + +// ---------- Pipelines (pipelines.md) ---------- + +func (g *docsGenerator) writePipelines(path string) error { + var b strings.Builder + + b.WriteString("# Pipelines\n\n") + + names := sortedMapKeys(g.cfg.Pipelines) + for _, name := range names { + pipelineRaw := g.cfg.Pipelines[name] + fmt.Fprintf(&b, "## %s\n\n", name) + + pMap, ok := pipelineRaw.(map[string]any) + if !ok { + b.WriteString("_Unable to parse pipeline configuration._\n\n") + continue + } + + // Trigger info + if trigRaw, ok := pMap["trigger"]; ok { + if trig, ok := trigRaw.(map[string]any); ok { + trigType, _ := trig["type"].(string) + fmt.Fprintf(&b, "**Trigger:** `%s`\n\n", trigType) + if trigCfg, ok := trig["config"].(map[string]any); ok { + if method, ok := trigCfg["method"].(string); ok { + fmt.Fprintf(&b, "- **Method:** `%s`\n", method) + } + if path, ok := trigCfg["path"].(string); ok { + fmt.Fprintf(&b, "- **Path:** `%s`\n", path) + } + if cmd, ok := trigCfg["command"].(string); ok { + fmt.Fprintf(&b, "- **Command:** `%s`\n", cmd) + } + } + b.WriteString("\n") + } + } + + // Timeout & on_error + if timeout, ok := pMap["timeout"].(string); ok { + fmt.Fprintf(&b, "**Timeout:** `%s`\n\n", timeout) + } + if onErr, ok := pMap["on_error"].(string); ok { + fmt.Fprintf(&b, "**On Error:** `%s`\n\n", onErr) + } + + // Steps table + steps := extractSteps(pMap, "steps") + if len(steps) > 0 { + b.WriteString("### Steps\n\n") + b.WriteString("| # | Name | Type |\n") + b.WriteString("|---|------|------|\n") + for i, step := range steps { + fmt.Fprintf(&b, "| %d | `%s` | `%s` |\n", i+1, step.name, step.typ) + } + b.WriteString("\n") + + // Mermaid workflow diagram + b.WriteString("### Workflow Diagram\n\n") + b.WriteString("```mermaid\ngraph TD\n") + fmt.Fprintf(&b, " trigger([%s]) --> %s[%s]\n", + mermaidQuote("trigger"), mermaidID(steps[0].name), mermaidQuote(steps[0].name)) + for i := 0; i < len(steps)-1; i++ { + fmt.Fprintf(&b, " %s[%s] --> %s[%s]\n", + mermaidID(steps[i].name), mermaidQuote(steps[i].name), + mermaidID(steps[i+1].name), mermaidQuote(steps[i+1].name)) + } + last := steps[len(steps)-1] + fmt.Fprintf(&b, " %s[%s] --> done([%s])\n", + mermaidID(last.name), mermaidQuote(last.name), mermaidQuote("done")) + b.WriteString("```\n\n") + } + + // Compensation steps + compSteps := extractSteps(pMap, "compensation") + if len(compSteps) > 0 { + b.WriteString("### Compensation Steps\n\n") + b.WriteString("| # | Name | Type |\n") + b.WriteString("|---|------|------|\n") + for i, step := range compSteps { + fmt.Fprintf(&b, "| %d | `%s` | `%s` |\n", i+1, step.name, step.typ) + } + b.WriteString("\n") + } + + b.WriteString("---\n\n") + } + + return os.WriteFile(path, []byte(b.String()), 0600) +} + +// ---------- Workflows (workflows.md) ---------- + +func (g *docsGenerator) writeWorkflows(path string) error { + var b strings.Builder + + b.WriteString("# Workflows\n\n") + + names := sortedMapKeys(g.cfg.Workflows) + for _, name := range names { + wfRaw := g.cfg.Workflows[name] + fmt.Fprintf(&b, "## %s\n\n", name) + + wfMap, ok := wfRaw.(map[string]any) + if !ok { + b.WriteString("_Unable to parse workflow configuration._\n\n") + continue + } + + switch name { + case "http": + g.writeHTTPWorkflow(&b, wfMap) + case "messaging": + g.writeMessagingWorkflow(&b, wfMap) + case "statemachine": + g.writeStateMachineWorkflow(&b, wfMap) + default: + // Generic workflow: dump keys + g.writeGenericWorkflow(&b, name, wfMap) + } + + b.WriteString("---\n\n") + } + + return os.WriteFile(path, []byte(b.String()), 0600) +} + +func (g *docsGenerator) writeHTTPWorkflow(b *strings.Builder, wf map[string]any) { + routesRaw, ok := wf["routes"] + if !ok { + return + } + routesList, ok := routesRaw.([]any) + if !ok { + return + } + + b.WriteString("### HTTP Routes\n\n") + b.WriteString("| Method | Path | Handler | Middlewares |\n") + b.WriteString("|--------|------|---------|-------------|\n") + + type routeInfo struct { + method, path, handler string + middlewares []string + } + var routes []routeInfo + + for _, rRaw := range routesList { + rMap, ok := rRaw.(map[string]any) + if !ok { + continue + } + ri := routeInfo{} + ri.method, _ = rMap["method"].(string) + ri.path, _ = rMap["path"].(string) + ri.handler, _ = rMap["handler"].(string) + if mws, ok := rMap["middlewares"].([]any); ok { + for _, mw := range mws { + if s, ok := mw.(string); ok { + ri.middlewares = append(ri.middlewares, s) + } + } + } + routes = append(routes, ri) + } + + for _, r := range routes { + mw := "—" + if len(r.middlewares) > 0 { + parts := make([]string, len(r.middlewares)) + for i, m := range r.middlewares { + parts[i] = "`" + m + "`" + } + mw = strings.Join(parts, ", ") + } + fmt.Fprintf(b, "| `%s` | `%s` | `%s` | %s |\n", r.method, r.path, r.handler, mw) + } + b.WriteString("\n") + + // Route diagram + if len(routes) > 0 { + b.WriteString("### Route Diagram\n\n") + b.WriteString("```mermaid\ngraph LR\n") + b.WriteString(" Client([Client])\n") + for i, r := range routes { + routeID := fmt.Sprintf("route%d", i) + label := fmt.Sprintf("%s %s", r.method, r.path) + fmt.Fprintf(b, " Client --> %s[%s]\n", routeID, mermaidQuote(label)) + if len(r.middlewares) > 0 { + prevID := routeID + for j, mw := range r.middlewares { + mwID := fmt.Sprintf("%s_mw%d", routeID, j) + fmt.Fprintf(b, " %s --> %s{{%s}}\n", prevID, mwID, mermaidQuote(mw)) + prevID = mwID + } + fmt.Fprintf(b, " %s --> %s[%s]\n", prevID, mermaidID(r.handler), mermaidQuote(r.handler)) + } else { + fmt.Fprintf(b, " %s --> %s[%s]\n", routeID, mermaidID(r.handler), mermaidQuote(r.handler)) + } + } + b.WriteString("```\n\n") + } +} + +func (g *docsGenerator) writeMessagingWorkflow(b *strings.Builder, wf map[string]any) { + // Subscriptions + if subsRaw, ok := wf["subscriptions"]; ok { + if subsList, ok := subsRaw.([]any); ok && len(subsList) > 0 { + b.WriteString("### Subscriptions\n\n") + b.WriteString("| Topic | Handler |\n") + b.WriteString("|-------|---------|\n") + + type sub struct{ topic, handler string } + var subs []sub + for _, sRaw := range subsList { + if sMap, ok := sRaw.(map[string]any); ok { + s := sub{} + s.topic, _ = sMap["topic"].(string) + s.handler, _ = sMap["handler"].(string) + subs = append(subs, s) + fmt.Fprintf(b, "| `%s` | `%s` |\n", s.topic, s.handler) + } + } + b.WriteString("\n") + + // Messaging diagram + if len(subs) > 0 { + b.WriteString("### Messaging Diagram\n\n") + b.WriteString("```mermaid\ngraph LR\n") + for _, s := range subs { + topicID := mermaidID("topic_" + s.topic) + fmt.Fprintf(b, " %s>%s] --> %s[%s]\n", + topicID, mermaidQuote(s.topic), + mermaidID(s.handler), mermaidQuote(s.handler)) + } + b.WriteString("```\n\n") + } + } + } + + // Producers + if prodsRaw, ok := wf["producers"]; ok { + if prodsList, ok := prodsRaw.([]any); ok && len(prodsList) > 0 { + b.WriteString("### Producers\n\n") + b.WriteString("| Producer | Publishes To |\n") + b.WriteString("|----------|--------------|\n") + for _, pRaw := range prodsList { + if pMap, ok := pRaw.(map[string]any); ok { + name, _ := pMap["name"].(string) + var topics []string + if fwdRaw, ok := pMap["forwardTo"].([]any); ok { + for _, t := range fwdRaw { + if ts, ok := t.(string); ok { + topics = append(topics, "`"+ts+"`") + } + } + } + fmt.Fprintf(b, "| `%s` | %s |\n", name, strings.Join(topics, ", ")) + } + } + b.WriteString("\n") + } + } +} + +func (g *docsGenerator) writeStateMachineWorkflow(b *strings.Builder, wf map[string]any) { + defsRaw, ok := wf["definitions"] + if !ok { + return + } + defsList, ok := defsRaw.([]any) + if !ok { + return + } + + for _, dRaw := range defsList { + dMap, ok := dRaw.(map[string]any) + if !ok { + continue + } + smName, _ := dMap["name"].(string) + smDesc, _ := dMap["description"].(string) + initial, _ := dMap["initialState"].(string) + + fmt.Fprintf(b, "### State Machine: %s\n\n", smName) + if smDesc != "" { + fmt.Fprintf(b, "%s\n\n", smDesc) + } + fmt.Fprintf(b, "**Initial State:** `%s`\n\n", initial) + + // States table + if statesRaw, ok := dMap["states"].(map[string]any); ok { + b.WriteString("#### States\n\n") + b.WriteString("| State | Description | Final | Error |\n") + b.WriteString("|-------|-------------|-------|-------|\n") + + stateNames := sortedMapKeys(statesRaw) + for _, sName := range stateNames { + sRaw := statesRaw[sName] + sMap, ok := sRaw.(map[string]any) + if !ok { + continue + } + desc, _ := sMap["description"].(string) + isFinal := toBool(sMap["isFinal"]) + isError := toBool(sMap["isError"]) + fmt.Fprintf(b, "| `%s` | %s | %v | %v |\n", sName, desc, isFinal, isError) + } + b.WriteString("\n") + } + + // Transitions table and diagram + if transRaw, ok := dMap["transitions"].(map[string]any); ok { + b.WriteString("#### Transitions\n\n") + b.WriteString("| Transition | From | To |\n") + b.WriteString("|------------|------|----|\n") + + type transition struct { + name, from, to string + } + var transitions []transition + transNames := sortedMapKeys(transRaw) + for _, tName := range transNames { + tRaw := transRaw[tName] + tMap, ok := tRaw.(map[string]any) + if !ok { + continue + } + from, _ := tMap["fromState"].(string) + to, _ := tMap["toState"].(string) + transitions = append(transitions, transition{tName, from, to}) + fmt.Fprintf(b, "| `%s` | `%s` | `%s` |\n", tName, from, to) + } + b.WriteString("\n") + + // State machine diagram + if len(transitions) > 0 { + b.WriteString("#### State Diagram\n\n") + b.WriteString("```mermaid\nstateDiagram-v2\n") + if initial != "" { + fmt.Fprintf(b, " [*] --> %s\n", mermaidID(initial)) + } + for _, t := range transitions { + fmt.Fprintf(b, " %s --> %s : %s\n", + mermaidID(t.from), mermaidID(t.to), mermaidQuote(t.name)) + } + // Mark final states + if statesRaw, ok := dMap["states"].(map[string]any); ok { + for sName, sRaw := range statesRaw { + if sMap, ok := sRaw.(map[string]any); ok { + if toBool(sMap["isFinal"]) { + fmt.Fprintf(b, " %s --> [*]\n", mermaidID(sName)) + } + } + } + } + b.WriteString("```\n\n") + } + } + } +} + +func (g *docsGenerator) writeGenericWorkflow(b *strings.Builder, name string, wf map[string]any) { + b.WriteString("### Configuration\n\n") + b.WriteString("```yaml\n") + writeConfigYAML(b, wf, "") + b.WriteString("```\n\n") +} + +// ---------- Plugins (plugins.md) ---------- + +func (g *docsGenerator) writePlugins(path string) error { + var b strings.Builder + + b.WriteString("# External Plugins\n\n") + + for _, p := range g.plugins { + fmt.Fprintf(&b, "## %s\n\n", p.Name) + fmt.Fprintf(&b, "- **Version:** `%s`\n", p.Version) + fmt.Fprintf(&b, "- **Author:** %s\n", p.Author) + if p.Description != "" { + fmt.Fprintf(&b, "- **Description:** %s\n", p.Description) + } + if p.License != "" { + fmt.Fprintf(&b, "- **License:** %s\n", p.License) + } + if p.Tier != "" { + fmt.Fprintf(&b, "- **Tier:** %s\n", string(p.Tier)) + } + if p.Repository != "" { + fmt.Fprintf(&b, "- **Repository:** [%s](%s)\n", p.Repository, p.Repository) + } + b.WriteString("\n") + + // Module types + if len(p.ModuleTypes) > 0 { + b.WriteString("### Module Types\n\n") + for _, mt := range p.ModuleTypes { + fmt.Fprintf(&b, "- `%s`\n", mt) + } + b.WriteString("\n") + } + + // Step types + if len(p.StepTypes) > 0 { + b.WriteString("### Step Types\n\n") + for _, st := range p.StepTypes { + fmt.Fprintf(&b, "- `%s`\n", st) + } + b.WriteString("\n") + } + + // Trigger types + if len(p.TriggerTypes) > 0 { + b.WriteString("### Trigger Types\n\n") + for _, tt := range p.TriggerTypes { + fmt.Fprintf(&b, "- `%s`\n", tt) + } + b.WriteString("\n") + } + + // Workflow types + if len(p.WorkflowTypes) > 0 { + b.WriteString("### Workflow Types\n\n") + for _, wt := range p.WorkflowTypes { + fmt.Fprintf(&b, "- `%s`\n", wt) + } + b.WriteString("\n") + } + + // Capabilities + if len(p.Capabilities) > 0 { + b.WriteString("### Capabilities\n\n") + b.WriteString("| Name | Role | Priority |\n") + b.WriteString("|------|------|----------|\n") + for _, cap := range p.Capabilities { + fmt.Fprintf(&b, "| `%s` | %s | %d |\n", cap.Name, cap.Role, cap.Priority) + } + b.WriteString("\n") + } + + // Dependencies + if len(p.Dependencies) > 0 { + b.WriteString("### Dependencies\n\n") + b.WriteString("| Plugin | Constraint |\n") + b.WriteString("|--------|------------|\n") + for _, dep := range p.Dependencies { + fmt.Fprintf(&b, "| `%s` | `%s` |\n", dep.Name, dep.Constraint) + } + b.WriteString("\n") + } + + // Tags + if len(p.Tags) > 0 { + b.WriteString("### Tags\n\n") + tagStrs := make([]string, len(p.Tags)) + for i, t := range p.Tags { + tagStrs[i] = "`" + t + "`" + } + fmt.Fprintf(&b, "%s\n\n", strings.Join(tagStrs, " ")) + } + + b.WriteString("---\n\n") + } + + return os.WriteFile(path, []byte(b.String()), 0600) +} + +// ---------- Architecture (architecture.md) ---------- + +func (g *docsGenerator) writeArchitecture(path string) error { + var b strings.Builder + + b.WriteString("# System Architecture\n\n") + + // Categorize modules by layer + layers := g.categorizeLayers() + + b.WriteString("## Architecture Diagram\n\n") + b.WriteString("```mermaid\ngraph TB\n") + + // Define subgraphs for each layer + layerOrder := []string{"HTTP", "Processing", "State Management", "Messaging", "Storage", "Observability", "Other"} + for _, layer := range layerOrder { + mods, ok := layers[layer] + if !ok || len(mods) == 0 { + continue + } + layerID := mermaidID("layer_" + layer) + fmt.Fprintf(&b, " subgraph %s[%s]\n", layerID, mermaidQuote(layer)) + for _, mod := range mods { + fmt.Fprintf(&b, " %s[%s]\n", mermaidID(mod.Name), mermaidQuote(mod.Name)) + } + b.WriteString(" end\n") + } + + // Draw dependency edges + for _, mod := range g.cfg.Modules { + for _, dep := range mod.DependsOn { + fmt.Fprintf(&b, " %s --> %s\n", mermaidID(mod.Name), mermaidID(dep)) + } + } + + // External systems + hasExternal := false + for _, mod := range g.cfg.Modules { + t := strings.ToLower(mod.Type) + if strings.Contains(t, "http.server") { + if !hasExternal { + b.WriteString(" Clients([External Clients])\n") + hasExternal = true + } + fmt.Fprintf(&b, " Clients --> %s\n", mermaidID(mod.Name)) + } + } + + b.WriteString("```\n\n") + + // Plugin architecture (if plugins are loaded) + if len(g.plugins) > 0 { + b.WriteString("## Plugin Architecture\n\n") + b.WriteString("```mermaid\ngraph LR\n") + b.WriteString(" Engine([Workflow Engine])\n") + for _, p := range g.plugins { + pID := mermaidID("plugin_" + p.Name) + fmt.Fprintf(&b, " Engine --> %s[%s]\n", pID, mermaidQuote(p.Name+" v"+p.Version)) + for _, mt := range p.ModuleTypes { + mtID := mermaidID("mt_" + p.Name + "_" + mt) + fmt.Fprintf(&b, " %s --> %s[%s]\n", pID, mtID, mermaidQuote(mt)) + } + for _, st := range p.StepTypes { + stID := mermaidID("st_" + p.Name + "_" + st) + fmt.Fprintf(&b, " %s --> %s[%s]\n", pID, stID, mermaidQuote(st)) + } + } + b.WriteString("```\n\n") + } + + return os.WriteFile(path, []byte(b.String()), 0600) +} + +// categorizeLayers groups modules by their architectural layer. +func (g *docsGenerator) categorizeLayers() map[string][]config.ModuleConfig { + layers := make(map[string][]config.ModuleConfig) + for _, mod := range g.cfg.Modules { + layer := classifyModuleLayer(mod.Type) + layers[layer] = append(layers[layer], mod) + } + return layers +} + +func classifyModuleLayer(modType string) string { + t := strings.ToLower(modType) + switch { + case strings.HasPrefix(t, "http.") || strings.HasPrefix(t, "static.") || strings.Contains(t, "reverseproxy"): + return "HTTP" + case strings.HasPrefix(t, "messaging.") || strings.HasPrefix(t, "event."): + return "Messaging" + case strings.HasPrefix(t, "state") || strings.HasPrefix(t, "statemachine"): + return "State Management" + case strings.HasPrefix(t, "storage.") || strings.HasPrefix(t, "database.") || + strings.Contains(t, "sqlite") || strings.Contains(t, "postgres"): + return "Storage" + case strings.HasPrefix(t, "metrics.") || strings.HasPrefix(t, "health.") || + strings.HasPrefix(t, "observability."): + return "Observability" + case strings.HasPrefix(t, "data.") || strings.HasPrefix(t, "auth.") || + strings.Contains(t, "transformer") || strings.Contains(t, "handler"): + return "Processing" + default: + return "Other" + } +} + +// ---------- Helpers ---------- + +type stepInfo struct { + name string + typ string +} + +func extractSteps(pMap map[string]any, key string) []stepInfo { + stepsRaw, ok := pMap[key] + if !ok { + return nil + } + stepsList, ok := stepsRaw.([]any) + if !ok { + return nil + } + var steps []stepInfo + for _, sRaw := range stepsList { + sMap, ok := sRaw.(map[string]any) + if !ok { + continue + } + si := stepInfo{} + si.name, _ = sMap["name"].(string) + si.typ, _ = sMap["type"].(string) + steps = append(steps, si) + } + return steps +} + +func sortedKeys(m map[string]int) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +func sortedMapKeys(m map[string]any) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +func writeConfigYAML(b *strings.Builder, m map[string]any, indent string) { + keys := sortedMapKeys(m) + for _, k := range keys { + v := m[k] + switch val := v.(type) { + case map[string]any: + fmt.Fprintf(b, "%s%s:\n", indent, k) + writeConfigYAML(b, val, indent+" ") + case []any: + fmt.Fprintf(b, "%s%s:\n", indent, k) + for _, item := range val { + if subMap, ok := item.(map[string]any); ok { + fmt.Fprintf(b, "%s -\n", indent) + writeConfigYAML(b, subMap, indent+" ") + } else { + fmt.Fprintf(b, "%s - %v\n", indent, item) + } + } + default: + fmt.Fprintf(b, "%s%s: %v\n", indent, k, v) + } + } +} + +func toBool(v any) bool { + switch b := v.(type) { + case bool: + return b + case string: + return strings.EqualFold(b, "true") + } + return false +} + +// writeJSON writes an indented JSON representation of v to the given path. +// This is used only by test helpers that need to create plugin.json fixtures. +func writeJSON(path string, v any) error { + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0600) +} diff --git a/cmd/wfctl/docs_test.go b/cmd/wfctl/docs_test.go new file mode 100644 index 00000000..46972219 --- /dev/null +++ b/cmd/wfctl/docs_test.go @@ -0,0 +1,779 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/GoCodeAlone/workflow/plugin" +) + +// --- test config strings --- + +const docsMinimalConfig = ` +modules: + - name: api-server + type: http.server + config: + address: ":8080" + - name: api-router + type: http.router + dependsOn: [api-server] + +workflows: + http: + routes: + - method: GET + path: /api/users + handler: users-handler + - method: POST + path: /api/users + handler: users-handler + +triggers: + http: + server: api-server +` + +const docsFullConfig = ` +requires: + plugins: + - name: workflow-plugin-http + - name: workflow-plugin-authz + version: ">=1.0.0" + capabilities: + - authorization + - http-serving + +modules: + - name: api-server + type: http.server + config: + address: ":8080" + - name: api-router + type: http.router + dependsOn: [api-server] + - name: auth-middleware + type: auth.jwt + dependsOn: [api-router] + config: + issuer: "https://auth.example.com" + - name: order-handler + type: http.handler + dependsOn: [api-router] + config: + contentType: application/json + - name: order-broker + type: messaging.broker + dependsOn: [order-handler] + - name: order-state + type: statemachine.engine + dependsOn: [order-broker] + - name: order-metrics + type: metrics.collector + - name: order-db + type: storage.sqlite + config: + path: data/orders.db + +workflows: + http: + routes: + - method: GET + path: /api/orders + handler: order-handler + middlewares: + - auth-middleware + - method: POST + path: /api/orders + handler: order-handler + middlewares: + - auth-middleware + - method: GET + path: /health + handler: health-handler + + messaging: + subscriptions: + - topic: order.created + handler: order-handler + - topic: order.completed + handler: notification-handler + producers: + - name: order-handler + forwardTo: + - order.created + - order.updated + + statemachine: + engine: order-state + definitions: + - name: order-lifecycle + description: "Manages order state transitions" + initialState: pending + states: + pending: + description: "Order received" + isFinal: false + isError: false + confirmed: + description: "Order confirmed" + isFinal: false + isError: false + shipped: + description: "Order shipped" + isFinal: false + isError: false + delivered: + description: "Order delivered" + isFinal: true + isError: false + cancelled: + description: "Order cancelled" + isFinal: true + isError: true + transitions: + confirm: + fromState: pending + toState: confirmed + ship: + fromState: confirmed + toState: shipped + deliver: + fromState: shipped + toState: delivered + cancel: + fromState: pending + toState: cancelled + +triggers: + http: + server: api-server + +pipelines: + validate-order: + trigger: + type: http + config: + path: /api/orders/validate + method: POST + steps: + - name: validate-payload + type: step.validate + config: + strategy: required_fields + required_fields: + - customer_id + - items + - name: check-inventory + type: step.http_call + config: + url: "http://inventory-service/check" + - name: respond + type: step.json_response + config: + status: 200 + on_error: stop + timeout: 30s + + process-payment: + trigger: + type: http + config: + path: /api/payments + method: POST + steps: + - name: validate + type: step.validate + config: + strategy: json_schema + - name: charge + type: step.http_call + config: + url: "http://payment-gateway/charge" + - name: record + type: step.log + config: + level: info + message: "Payment processed" + compensation: + - name: refund + type: step.http_call + config: + url: "http://payment-gateway/refund" + timeout: 60s + +sidecars: + - name: redis-cache + type: redis + config: + port: 6379 + - name: jaeger-agent + type: jaeger + config: + port: 6831 +` + +func writeDocsConfigFile(t *testing.T, dir, content string) string { + t.Helper() + path := filepath.Join(dir, "workflow.yaml") + if err := os.WriteFile(path, []byte(content), 0640); err != nil { + t.Fatalf("failed to write config: %v", err) + } + return path +} + +func writePluginJSON(t *testing.T, dir string, manifest *plugin.PluginManifest) string { + t.Helper() + pDir := filepath.Join(dir, manifest.Name) + if err := os.MkdirAll(pDir, 0750); err != nil { + t.Fatalf("failed to create plugin dir: %v", err) + } + path := filepath.Join(pDir, "plugin.json") + if err := writeJSON(path, manifest); err != nil { + t.Fatalf("failed to write plugin.json: %v", err) + } + return pDir +} + +// --- Command-level tests --- + +func TestRunDocsNoSubcommand(t *testing.T) { + err := runDocs([]string{}) + if err == nil { + t.Fatal("expected error when no subcommand given") + } +} + +func TestRunDocsUnknownSubcommand(t *testing.T) { + err := runDocs([]string{"unknown"}) + if err == nil { + t.Fatal("expected error for unknown subcommand") + } +} + +func TestRunDocsGenerateNoConfig(t *testing.T) { + err := runDocsGenerate([]string{}) + if err == nil { + t.Fatal("expected error when no config file given") + } +} + +func TestRunDocsGenerateMinimal(t *testing.T) { + dir := t.TempDir() + cfgPath := writeDocsConfigFile(t, dir, docsMinimalConfig) + outDir := filepath.Join(dir, "docs") + + err := runDocsGenerate([]string{"-output", outDir, cfgPath}) + if err != nil { + t.Fatalf("docs generate failed: %v", err) + } + + // Should create README.md, modules.md, workflows.md, architecture.md + for _, f := range []string{"README.md", "modules.md", "workflows.md", "architecture.md"} { + path := filepath.Join(outDir, f) + if _, err := os.Stat(path); os.IsNotExist(err) { + t.Errorf("expected %s to be created", f) + } + } + + // pipelines.md should NOT be created (no pipelines in config) + if _, err := os.Stat(filepath.Join(outDir, "pipelines.md")); err == nil { + t.Error("pipelines.md should not be created when no pipelines exist") + } + + // plugins.md should NOT be created (no plugin-dir) + if _, err := os.Stat(filepath.Join(outDir, "plugins.md")); err == nil { + t.Error("plugins.md should not be created without -plugin-dir") + } +} + +func TestRunDocsGenerateFull(t *testing.T) { + dir := t.TempDir() + cfgPath := writeDocsConfigFile(t, dir, docsFullConfig) + outDir := filepath.Join(dir, "docs") + + // Create plugin manifests + pluginDir := filepath.Join(dir, "plugins") + writePluginJSON(t, pluginDir, &plugin.PluginManifest{ + Name: "workflow-plugin-authz", + Version: "1.2.0", + Author: "GoCodeAlone", + Description: "Authorization plugin for workflow engine", + License: "MIT", + Repository: "https://github.com/GoCodeAlone/workflow-plugin-authz", + Tier: plugin.TierCommunity, + ModuleTypes: []string{"authz.policy", "authz.enforcer"}, + StepTypes: []string{"step.authz_check", "step.authz_grant"}, + Tags: []string{"authorization", "rbac", "security"}, + Dependencies: []plugin.Dependency{ + {Name: "workflow-plugin-http", Constraint: ">=1.0.0"}, + }, + Capabilities: []plugin.CapabilityDecl{ + {Name: "authorization", Role: "provider", Priority: 10}, + }, + }) + + err := runDocsGenerate([]string{"-output", outDir, "-plugin-dir", pluginDir, cfgPath}) + if err != nil { + t.Fatalf("docs generate (full) failed: %v", err) + } + + // All files should be created + for _, f := range []string{"README.md", "modules.md", "pipelines.md", "workflows.md", "plugins.md", "architecture.md"} { + path := filepath.Join(outDir, f) + if _, err := os.Stat(path); os.IsNotExist(err) { + t.Errorf("expected %s to be created", f) + } + } +} + +func TestDocsReadmeContent(t *testing.T) { + dir := t.TempDir() + cfgPath := writeDocsConfigFile(t, dir, docsFullConfig) + outDir := filepath.Join(dir, "docs") + + if err := runDocsGenerate([]string{"-output", outDir, "-title", "My Order Service", cfgPath}); err != nil { + t.Fatalf("docs generate failed: %v", err) + } + + data, err := os.ReadFile(filepath.Join(outDir, "README.md")) + if err != nil { + t.Fatalf("failed to read README.md: %v", err) + } + content := string(data) + + checks := []string{ + "# My Order Service", + "Modules", + "Workflows", + "Pipelines", + "workflow-plugin-http", + "workflow-plugin-authz", + "modules.md", + "pipelines.md", + "workflows.md", + "architecture.md", + "authorization", + "Sidecars", + "redis-cache", + "jaeger-agent", + } + for _, check := range checks { + if !strings.Contains(content, check) { + t.Errorf("README.md should contain %q", check) + } + } +} + +func TestDocsModulesContent(t *testing.T) { + dir := t.TempDir() + cfgPath := writeDocsConfigFile(t, dir, docsFullConfig) + outDir := filepath.Join(dir, "docs") + + if err := runDocsGenerate([]string{"-output", outDir, cfgPath}); err != nil { + t.Fatalf("docs generate failed: %v", err) + } + + data, err := os.ReadFile(filepath.Join(outDir, "modules.md")) + if err != nil { + t.Fatalf("failed to read modules.md: %v", err) + } + content := string(data) + + // Check module inventory + for _, mod := range []string{"api-server", "api-router", "auth-middleware", "order-handler", "order-broker", "order-state", "order-metrics", "order-db"} { + if !strings.Contains(content, mod) { + t.Errorf("modules.md should contain module %q", mod) + } + } + + // Check dependency graph with mermaid + if !strings.Contains(content, "```mermaid") { + t.Error("modules.md should contain a mermaid diagram") + } + if !strings.Contains(content, "graph LR") { + t.Error("modules.md should contain a graph LR diagram") + } +} + +func TestDocsPipelinesContent(t *testing.T) { + dir := t.TempDir() + cfgPath := writeDocsConfigFile(t, dir, docsFullConfig) + outDir := filepath.Join(dir, "docs") + + if err := runDocsGenerate([]string{"-output", outDir, cfgPath}); err != nil { + t.Fatalf("docs generate failed: %v", err) + } + + data, err := os.ReadFile(filepath.Join(outDir, "pipelines.md")) + if err != nil { + t.Fatalf("failed to read pipelines.md: %v", err) + } + content := string(data) + + // Check pipeline names + if !strings.Contains(content, "validate-order") { + t.Error("pipelines.md should contain validate-order pipeline") + } + if !strings.Contains(content, "process-payment") { + t.Error("pipelines.md should contain process-payment pipeline") + } + + // Check mermaid workflow diagram + if !strings.Contains(content, "```mermaid") { + t.Error("pipelines.md should contain mermaid diagrams") + } + if !strings.Contains(content, "graph TD") { + t.Error("pipelines.md should contain workflow diagrams") + } + + // Check steps + if !strings.Contains(content, "validate-payload") { + t.Error("pipelines.md should list step names") + } + if !strings.Contains(content, "step.validate") { + t.Error("pipelines.md should list step types") + } + + // Check compensation steps + if !strings.Contains(content, "Compensation") { + t.Error("pipelines.md should document compensation steps") + } +} + +func TestDocsWorkflowsContent(t *testing.T) { + dir := t.TempDir() + cfgPath := writeDocsConfigFile(t, dir, docsFullConfig) + outDir := filepath.Join(dir, "docs") + + if err := runDocsGenerate([]string{"-output", outDir, cfgPath}); err != nil { + t.Fatalf("docs generate failed: %v", err) + } + + data, err := os.ReadFile(filepath.Join(outDir, "workflows.md")) + if err != nil { + t.Fatalf("failed to read workflows.md: %v", err) + } + content := string(data) + + // HTTP routes + if !strings.Contains(content, "/api/orders") { + t.Error("workflows.md should contain HTTP routes") + } + if !strings.Contains(content, "GET") { + t.Error("workflows.md should contain HTTP methods") + } + if !strings.Contains(content, "auth-middleware") { + t.Error("workflows.md should show middlewares") + } + + // Messaging + if !strings.Contains(content, "order.created") { + t.Error("workflows.md should contain messaging topics") + } + + // State machine + if !strings.Contains(content, "stateDiagram-v2") { + t.Error("workflows.md should contain state diagram") + } + if !strings.Contains(content, "pending") { + t.Error("workflows.md should show state machine states") + } + if !strings.Contains(content, "delivered") { + t.Error("workflows.md should show final states") + } +} + +func TestDocsPluginsContent(t *testing.T) { + dir := t.TempDir() + cfgPath := writeDocsConfigFile(t, dir, docsMinimalConfig) + outDir := filepath.Join(dir, "docs") + pluginDir := filepath.Join(dir, "plugins") + + writePluginJSON(t, pluginDir, &plugin.PluginManifest{ + Name: "workflow-plugin-authz", + Version: "1.2.0", + Author: "GoCodeAlone", + Description: "Authorization plugin", + License: "MIT", + Repository: "https://github.com/GoCodeAlone/workflow-plugin-authz", + Tier: plugin.TierCommunity, + ModuleTypes: []string{"authz.policy"}, + StepTypes: []string{"step.authz_check"}, + Tags: []string{"authorization"}, + }) + + if err := runDocsGenerate([]string{"-output", outDir, "-plugin-dir", pluginDir, cfgPath}); err != nil { + t.Fatalf("docs generate failed: %v", err) + } + + data, err := os.ReadFile(filepath.Join(outDir, "plugins.md")) + if err != nil { + t.Fatalf("failed to read plugins.md: %v", err) + } + content := string(data) + + checks := []string{ + "workflow-plugin-authz", + "1.2.0", + "GoCodeAlone", + "Authorization plugin", + "MIT", + "authz.policy", + "step.authz_check", + "authorization", + } + for _, check := range checks { + if !strings.Contains(content, check) { + t.Errorf("plugins.md should contain %q", check) + } + } +} + +func TestDocsArchitectureContent(t *testing.T) { + dir := t.TempDir() + cfgPath := writeDocsConfigFile(t, dir, docsFullConfig) + outDir := filepath.Join(dir, "docs") + + pluginDir := filepath.Join(dir, "plugins") + writePluginJSON(t, pluginDir, &plugin.PluginManifest{ + Name: "test-plugin", + Version: "1.0.0", + Author: "test", + Description: "test plugin", + ModuleTypes: []string{"test.module"}, + StepTypes: []string{"step.test"}, + }) + + if err := runDocsGenerate([]string{"-output", outDir, "-plugin-dir", pluginDir, cfgPath}); err != nil { + t.Fatalf("docs generate failed: %v", err) + } + + data, err := os.ReadFile(filepath.Join(outDir, "architecture.md")) + if err != nil { + t.Fatalf("failed to read architecture.md: %v", err) + } + content := string(data) + + if !strings.Contains(content, "```mermaid") { + t.Error("architecture.md should contain mermaid diagrams") + } + if !strings.Contains(content, "graph TB") { + t.Error("architecture.md should contain architecture diagram") + } + if !strings.Contains(content, "subgraph") { + t.Error("architecture.md should use subgraphs for layers") + } + if !strings.Contains(content, "Plugin Architecture") { + t.Error("architecture.md should contain plugin architecture section when plugins loaded") + } +} + +// --- Mermaid quoting tests --- + +func TestMermaidQuote(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"simple", "simple"}, + {"has space", `"has space"`}, + {"has-dash", `"has-dash"`}, + {"has.dot", `"has.dot"`}, + {"has/slash", `"has/slash"`}, + {"(parens)", `"(parens)"`}, + {"special#chars", `"special#chars"`}, + {`has"quote`, `"has#quot;quote"`}, + {"", `""`}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := mermaidQuote(tt.input) + if got != tt.expected { + t.Errorf("mermaidQuote(%q) = %q, want %q", tt.input, got, tt.expected) + } + }) + } +} + +func TestMermaidID(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"simple", "simple"}, + {"has-dash", "has_dash"}, + {"has.dot", "has_dot"}, + {"has space", "has_space"}, + {"CamelCase", "CamelCase"}, + {"with_underscore", "with_underscore"}, + {"123numeric", "123numeric"}, + {"", "_empty"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := mermaidID(tt.input) + if got != tt.expected { + t.Errorf("mermaidID(%q) = %q, want %q", tt.input, got, tt.expected) + } + }) + } +} + +// --- Helper tests --- + +func TestDeriveTitle(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"api-server-config.yaml", "Api Server Config"}, + {"simple_workflow.yml", "Simple Workflow"}, + {"config.yaml", "Config"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := deriveTitle(tt.input) + if got != tt.expected { + t.Errorf("deriveTitle(%q) = %q, want %q", tt.input, got, tt.expected) + } + }) + } +} + +func TestClassifyModuleLayer(t *testing.T) { + tests := []struct { + modType string + expected string + }{ + {"http.server", "HTTP"}, + {"http.router", "HTTP"}, + {"http.handler", "HTTP"}, + {"messaging.broker", "Messaging"}, + {"event.processor", "Messaging"}, + {"statemachine.engine", "State Management"}, + {"state.tracker", "State Management"}, + {"storage.sqlite", "Storage"}, + {"metrics.collector", "Observability"}, + {"health.checker", "Observability"}, + {"data.transformer", "Processing"}, + {"auth.jwt", "Processing"}, + {"unknown.type", "Other"}, + } + + for _, tt := range tests { + t.Run(tt.modType, func(t *testing.T) { + got := classifyModuleLayer(tt.modType) + if got != tt.expected { + t.Errorf("classifyModuleLayer(%q) = %q, want %q", tt.modType, got, tt.expected) + } + }) + } +} + +func TestExtractSteps(t *testing.T) { + pMap := map[string]any{ + "steps": []any{ + map[string]any{"name": "step1", "type": "step.validate"}, + map[string]any{"name": "step2", "type": "step.log"}, + }, + } + + steps := extractSteps(pMap, "steps") + if len(steps) != 2 { + t.Fatalf("expected 2 steps, got %d", len(steps)) + } + if steps[0].name != "step1" || steps[0].typ != "step.validate" { + t.Errorf("unexpected step[0]: %+v", steps[0]) + } + if steps[1].name != "step2" || steps[1].typ != "step.log" { + t.Errorf("unexpected step[1]: %+v", steps[1]) + } +} + +func TestExtractStepsMissing(t *testing.T) { + pMap := map[string]any{} + steps := extractSteps(pMap, "steps") + if len(steps) != 0 { + t.Errorf("expected 0 steps from empty map, got %d", len(steps)) + } +} + +func TestToBool(t *testing.T) { + tests := []struct { + input any + expected bool + }{ + {true, true}, + {false, false}, + {"true", true}, + {"TRUE", true}, + {"false", false}, + {nil, false}, + {42, false}, + } + + for _, tt := range tests { + got := toBool(tt.input) + if got != tt.expected { + t.Errorf("toBool(%v) = %v, want %v", tt.input, got, tt.expected) + } + } +} + +func TestLoadPluginManifestsEmptyDir(t *testing.T) { + dir := t.TempDir() + manifests, err := loadPluginManifests(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(manifests) != 0 { + t.Errorf("expected 0 manifests from empty dir, got %d", len(manifests)) + } +} + +func TestLoadPluginManifestsWithPlugins(t *testing.T) { + dir := t.TempDir() + + writePluginJSON(t, dir, &plugin.PluginManifest{ + Name: "plugin-a", + Version: "1.0.0", + Author: "test", + }) + writePluginJSON(t, dir, &plugin.PluginManifest{ + Name: "plugin-b", + Version: "2.0.0", + Author: "test", + }) + + manifests, err := loadPluginManifests(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(manifests) != 2 { + t.Errorf("expected 2 manifests, got %d", len(manifests)) + } +} + +func TestDocsCustomTitle(t *testing.T) { + dir := t.TempDir() + cfgPath := writeDocsConfigFile(t, dir, docsMinimalConfig) + outDir := filepath.Join(dir, "docs") + + if err := runDocsGenerate([]string{"-output", outDir, "-title", "Custom App", cfgPath}); err != nil { + t.Fatalf("docs generate failed: %v", err) + } + + data, err := os.ReadFile(filepath.Join(outDir, "README.md")) + if err != nil { + t.Fatalf("failed to read README.md: %v", err) + } + if !strings.Contains(string(data), "# Custom App") { + t.Error("README.md should use custom title") + } +} diff --git a/cmd/wfctl/main.go b/cmd/wfctl/main.go index f0ee6a21..250c5794 100644 --- a/cmd/wfctl/main.go +++ b/cmd/wfctl/main.go @@ -58,6 +58,7 @@ var commands = map[string]func([]string) error{ "mcp": runMCP, "modernize": runModernize, "infra": runInfra, + "docs": runDocs, } func main() { diff --git a/cmd/wfctl/wfctl.yaml b/cmd/wfctl/wfctl.yaml index 5a7111d5..d26670d3 100644 --- a/cmd/wfctl/wfctl.yaml +++ b/cmd/wfctl/wfctl.yaml @@ -55,6 +55,8 @@ workflows: description: "Detect and fix known YAML config anti-patterns (dry-run by default)" - name: infra description: "Manage infrastructure lifecycle (plan, apply, status, drift, destroy)" + - name: docs + description: "Generate documentation from workflow configs (generate: produce Markdown + Mermaid diagrams)" # Each command is expressed as a workflow pipeline triggered by the CLI. # The pipeline delegates to the registered Go implementation via step.cli_invoke, @@ -348,3 +350,14 @@ pipelines: config: command: infra + cmd-docs: + trigger: + type: cli + config: + command: docs + steps: + - name: run + type: step.cli_invoke + config: + command: docs + diff --git a/docs/WFCTL.md b/docs/WFCTL.md index fac6e728..ddc902ca 100644 --- a/docs/WFCTL.md +++ b/docs/WFCTL.md @@ -119,6 +119,7 @@ graph TD | **Validation & Inspection** | `validate`, `inspect`, `schema`, `compat check`, `template validate` | | **API & Contract** | `api extract`, `contract test`, `diff` | | **Deployment** | `deploy docker/kubernetes/helm/cloud`, `build-ui`, `generate github-actions` | +| **Documentation** | `docs generate` | | **Plugin Management** | `plugin`, `registry`, `publish` | | **UI Generation** | `ui scaffold`, `build-ui` | | **Database Migrations** | `migrate status/diff/apply` | @@ -819,6 +820,42 @@ wfctl infra destroy --auto-approve infra.yaml --- +### `docs generate` + +Generate Markdown documentation with Mermaid diagrams from a workflow configuration file. Produces a set of `.md` files describing modules, pipelines, workflows, external plugins, and system architecture. + +``` +wfctl docs generate [options] +``` + +| Flag | Default | Description | +|------|---------|-------------| +| `-output` | `./docs/generated/` | Output directory for generated documentation | +| `-plugin-dir` | _(none)_ | Directory containing external plugin manifests (`plugin.json`) | +| `-title` | _(derived from config filename)_ | Application title used in the README | + +**Generated files:** + +| File | Description | +|------|-------------| +| `README.md` | Application overview with metrics, required plugins, and documentation index | +| `modules.md` | Module inventory table, type breakdown, configuration details, and dependency graph (Mermaid) | +| `pipelines.md` | Pipeline definitions with trigger info, step tables, workflow diagrams (Mermaid), and compensation steps | +| `workflows.md` | HTTP routes with route diagrams (Mermaid), messaging subscriptions/producers, and state machine diagrams (Mermaid) | +| `plugins.md` | External plugin details including version, capabilities, module/step types, and dependencies (only when `-plugin-dir` is provided) | +| `architecture.md` | System architecture diagram with layered subgraphs and plugin architecture (Mermaid) | + +**Examples:** + +```bash +wfctl docs generate workflow.yaml +wfctl docs generate -output ./docs/ workflow.yaml +wfctl docs generate -output ./docs/ -plugin-dir ./plugins/ workflow.yaml +wfctl docs generate -output ./docs/ -title "Order Service" workflow.yaml +``` + +--- + ### `api extract` Parse a workflow config file offline and output an OpenAPI 3.0 specification of all HTTP endpoints defined in the config. diff --git a/example/docs-with-plugins/README.md b/example/docs-with-plugins/README.md new file mode 100644 index 00000000..c9eff893 --- /dev/null +++ b/example/docs-with-plugins/README.md @@ -0,0 +1,66 @@ +# Documentation Generation Example + +This example demonstrates how to use `wfctl docs generate` to produce Markdown +documentation with Mermaid diagrams from a workflow configuration file. + +## Configuration + +The `workflow.yaml` file describes a **Secure Order Processing API** that uses: + +- HTTP server with routing and middleware +- JWT authentication and authorization (via `workflow-plugin-authz`) +- Messaging / event publishing +- A state machine for order lifecycle management +- Pipelines with validation, HTTP calls, and compensation steps +- Storage (SQLite) +- Observability (metrics, health checks) +- Sidecars (Redis cache, Jaeger tracing) + +## Generating Documentation + +```bash +# From this directory: +wfctl docs generate \ + -output ./docs/ \ + -plugin-dir ./plugins/ \ + workflow.yaml +``` + +This creates the following files in `./docs/`: + +| File | Description | +|------|-------------| +| `README.md` | Application overview with stats and index | +| `modules.md` | Module inventory, types, and dependency graph | +| `pipelines.md` | Pipeline definitions with workflow diagrams | +| `workflows.md` | HTTP routes, messaging, and state machine diagrams | +| `plugins.md` | External plugin details and capabilities | +| `architecture.md` | System architecture diagram | + +## External Plugins + +The `plugins/` directory contains a `plugin.json` manifest for +[GoCodeAlone/workflow-plugin-authz](https://github.com/GoCodeAlone/workflow-plugin-authz), +which provides authorization enforcement capabilities: + +- **Module types:** `authz.enforcer`, `authz.policy` +- **Step types:** `step.authz_check`, `step.authz_grant` +- **Capabilities:** `authorization` (provider) + +The documentation generator reads these manifests to produce a dedicated +plugins page describing each plugin's version, dependencies, module types, +step types, and capabilities. + +## Viewing on GitHub + +All generated `.md` files use standard Markdown. Mermaid diagrams are embedded +in fenced code blocks with the `mermaid` language tag: + +````markdown +```mermaid +graph LR + A --> B +``` +```` + +GitHub automatically renders these as SVG diagrams when viewed in the browser. diff --git a/example/docs-with-plugins/plugins/workflow-plugin-authz/plugin.json b/example/docs-with-plugins/plugins/workflow-plugin-authz/plugin.json new file mode 100644 index 00000000..418a32cd --- /dev/null +++ b/example/docs-with-plugins/plugins/workflow-plugin-authz/plugin.json @@ -0,0 +1,63 @@ +{ + "name": "workflow-plugin-authz", + "version": "1.2.0", + "author": "GoCodeAlone", + "description": "Authorization plugin for workflow engine providing RBAC, ABAC, and policy-based access control for HTTP endpoints and pipeline steps.", + "license": "MIT", + "repository": "https://github.com/GoCodeAlone/workflow-plugin-authz", + "tier": "community", + "tags": ["authorization", "rbac", "abac", "security", "access-control"], + "moduleTypes": ["authz.enforcer", "authz.policy"], + "stepTypes": ["step.authz_check", "step.authz_grant"], + "triggerTypes": [], + "workflowTypes": [], + "wiringHooks": ["authz-middleware-injection"], + "capabilities": [ + { + "name": "authorization", + "role": "provider", + "priority": 10 + } + ], + "dependencies": [ + { + "name": "workflow-plugin-http", + "constraint": ">=1.0.0" + } + ], + "stepSchemas": [ + { + "type": "step.authz_check", + "description": "Check if the current request is authorized for a given permission", + "configFields": [ + { + "key": "permission", + "label": "Permission", + "type": "string", + "description": "The permission string to check (e.g. 'orders:read')", + "required": true + } + ] + }, + { + "type": "step.authz_grant", + "description": "Grant a permission to a role or user", + "configFields": [ + { + "key": "role", + "label": "Role", + "type": "string", + "description": "Target role to grant permission to", + "required": true + }, + { + "key": "permission", + "label": "Permission", + "type": "string", + "description": "Permission to grant", + "required": true + } + ] + } + ] +} diff --git a/example/docs-with-plugins/workflow.yaml b/example/docs-with-plugins/workflow.yaml new file mode 100644 index 00000000..039b7a1d --- /dev/null +++ b/example/docs-with-plugins/workflow.yaml @@ -0,0 +1,278 @@ +# Secure Order Processing API +# +# An example application demonstrating documentation generation. +# This config uses multiple module types, pipelines, messaging, +# state machines, and references external plugins such as +# GoCodeAlone/workflow-plugin-authz for authorization enforcement. +# +# Generate docs: +# wfctl docs generate -output ./docs/ -plugin-dir ./plugins/ workflow.yaml + +requires: + plugins: + - name: workflow-plugin-http + - name: workflow-plugin-authz + version: ">=1.0.0" + capabilities: + - authorization + - http-serving + +modules: + # --- HTTP layer --- + - name: api-server + type: http.server + config: + address: ":8080" + + - name: api-router + type: http.router + dependsOn: + - api-server + + # --- Auth / Authz --- + - name: auth-jwt + type: auth.jwt + dependsOn: + - api-router + config: + issuer: "https://auth.example.com" + audience: "order-api" + + - name: authz-enforcer + type: authz.enforcer + dependsOn: + - auth-jwt + config: + policy: "rbac" + rules_path: "./policies/orders.csv" + + # --- Handlers --- + - name: orders-handler + type: http.handler + dependsOn: + - api-router + config: + contentType: application/json + + - name: health-handler + type: http.handler + config: + contentType: application/json + + # --- Processing --- + - name: order-transformer + type: data.transformer + dependsOn: + - orders-handler + config: + description: "Validates and normalizes incoming order data" + + # --- State machine --- + - name: order-state + type: statemachine.engine + dependsOn: + - order-transformer + + # --- Messaging --- + - name: event-broker + type: messaging.broker + + - name: notification-handler + type: messaging.handler + dependsOn: + - event-broker + config: + topic: "order.completed" + + # --- Storage --- + - name: order-db + type: storage.sqlite + config: + path: data/orders.db + + # --- Observability --- + - name: order-metrics + type: metrics.collector + + - name: health-check + type: health.checker + +workflows: + http: + server: api-server + router: api-router + routes: + - method: GET + path: /api/orders + handler: orders-handler + middlewares: + - auth-jwt + - authz-enforcer + - method: POST + path: /api/orders + handler: orders-handler + middlewares: + - auth-jwt + - authz-enforcer + - method: GET + path: /api/orders/:id + handler: orders-handler + middlewares: + - auth-jwt + - method: GET + path: /health + handler: health-handler + + messaging: + broker: event-broker + subscriptions: + - topic: order.created + handler: notification-handler + - topic: order.shipped + handler: notification-handler + - topic: order.completed + handler: notification-handler + producers: + - name: orders-handler + forwardTo: + - order.created + - order.updated + + statemachine: + engine: order-state + definitions: + - name: order-lifecycle + description: "Manages the lifecycle of an order from creation to delivery" + initialState: pending + states: + pending: + description: "Order has been received and is awaiting confirmation" + isFinal: false + isError: false + confirmed: + description: "Order has been confirmed and payment verified" + isFinal: false + isError: false + processing: + description: "Order is being prepared for shipment" + isFinal: false + isError: false + shipped: + description: "Order has been shipped to the customer" + isFinal: false + isError: false + delivered: + description: "Order has been delivered successfully" + isFinal: true + isError: false + cancelled: + description: "Order has been cancelled" + isFinal: true + isError: true + refunded: + description: "Order has been refunded" + isFinal: true + isError: false + transitions: + confirm_order: + fromState: pending + toState: confirmed + start_processing: + fromState: confirmed + toState: processing + ship_order: + fromState: processing + toState: shipped + deliver_order: + fromState: shipped + toState: delivered + cancel_order: + fromState: pending + toState: cancelled + refund_order: + fromState: delivered + toState: refunded + +triggers: + http: + server: api-server + +pipelines: + validate-order: + trigger: + type: http + config: + path: /api/orders/validate + method: POST + steps: + - name: authenticate + type: step.authz_check + config: + permission: "orders:validate" + - name: validate-payload + type: step.validate + config: + strategy: required_fields + required_fields: + - customer_id + - items + - shipping_address + - name: check-inventory + type: step.http_call + config: + url: "http://inventory-service:8081/api/check" + method: POST + - name: respond + type: step.json_response + config: + status: 200 + on_error: stop + timeout: 30s + + process-payment: + trigger: + type: http + config: + path: /api/payments + method: POST + steps: + - name: authorize + type: step.authz_check + config: + permission: "payments:process" + - name: validate + type: step.validate + config: + strategy: json_schema + - name: charge-card + type: step.http_call + config: + url: "http://payment-gateway:8082/charge" + method: POST + - name: log-payment + type: step.log + config: + level: info + message: "Payment processed for order {{ .order_id }}" + - name: publish-event + type: step.publish + config: + topic: payment.completed + compensation: + - name: refund-charge + type: step.http_call + config: + url: "http://payment-gateway:8082/refund" + method: POST + timeout: 60s + +sidecars: + - name: redis-cache + type: redis + config: + port: 6379 + - name: jaeger-agent + type: jaeger + config: + port: 6831 + endpoint: "http://jaeger-collector:14268" From fab830e6bf7bbe76bc6c3a1f50e84cd10eecd1d8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 20:56:42 +0000 Subject: [PATCH 3/4] Address code review: warn on bad plugin manifests, use yaml.Marshal, move writeJSON to test, fix CI validation failure - loadPluginManifests now emits a warning to stderr when a plugin.json cannot be parsed, rather than silently skipping it - writeConfigYAML replaced with yaml.Marshal for valid, properly-quoted YAML output in configuration blocks - writeJSON helper moved from docs.go to docs_test.go (test-only code) - Fix CI: add JWT_SECRET to auth.jwt in example config, add authz.enforcer and authz.policy as extra module types in TestExampleConfigsValidate Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --- cmd/wfctl/docs.go | 47 ++++++++----------------- cmd/wfctl/docs_test.go | 11 ++++++ example/docs-with-plugins/workflow.yaml | 1 + example_configs_test.go | 2 ++ 4 files changed, 28 insertions(+), 33 deletions(-) diff --git a/cmd/wfctl/docs.go b/cmd/wfctl/docs.go index ca0906b4..ad0b4578 100644 --- a/cmd/wfctl/docs.go +++ b/cmd/wfctl/docs.go @@ -1,7 +1,6 @@ package main import ( - "encoding/json" "flag" "fmt" "os" @@ -11,6 +10,7 @@ import ( "github.com/GoCodeAlone/workflow/config" "github.com/GoCodeAlone/workflow/plugin" + "gopkg.in/yaml.v3" ) func runDocs(args []string) error { @@ -112,7 +112,8 @@ Options: } // loadPluginManifests recursively walks a directory tree looking for -// plugin.json files and returns the parsed manifests. +// plugin.json files and returns the parsed manifests. Files that cannot +// be parsed are skipped with a warning printed to stderr. func loadPluginManifests(dir string) ([]*plugin.PluginManifest, error) { var manifests []*plugin.PluginManifest err := filepath.Walk(dir, func(path string, info os.FileInfo, walkErr error) error { @@ -124,7 +125,8 @@ func loadPluginManifests(dir string) ([]*plugin.PluginManifest, error) { } m, loadErr := plugin.LoadManifest(path) if loadErr != nil { - return nil //nolint:nilerr // intentionally skip invalid manifests + fmt.Fprintf(os.Stderr, "warning: skipping invalid plugin manifest %s: %v\n", path, loadErr) + return nil //nolint:nilerr // intentionally skip invalid manifests (already warned) } manifests = append(manifests, m) return nil @@ -1024,28 +1026,17 @@ func sortedMapKeys(m map[string]any) []string { return keys } -func writeConfigYAML(b *strings.Builder, m map[string]any, indent string) { - keys := sortedMapKeys(m) - for _, k := range keys { - v := m[k] - switch val := v.(type) { - case map[string]any: - fmt.Fprintf(b, "%s%s:\n", indent, k) - writeConfigYAML(b, val, indent+" ") - case []any: - fmt.Fprintf(b, "%s%s:\n", indent, k) - for _, item := range val { - if subMap, ok := item.(map[string]any); ok { - fmt.Fprintf(b, "%s -\n", indent) - writeConfigYAML(b, subMap, indent+" ") - } else { - fmt.Fprintf(b, "%s - %v\n", indent, item) - } - } - default: - fmt.Fprintf(b, "%s%s: %v\n", indent, k, v) +func writeConfigYAML(b *strings.Builder, m map[string]any, _ string) { + // Use yaml.Marshal for faithful, properly-quoted YAML output. + data, err := yaml.Marshal(m) + if err != nil { + // Fallback: best-effort key=value dump if marshaling fails. + for _, k := range sortedMapKeys(m) { + fmt.Fprintf(b, "%s: %v\n", k, m[k]) } + return } + b.Write(data) } func toBool(v any) bool { @@ -1057,13 +1048,3 @@ func toBool(v any) bool { } return false } - -// writeJSON writes an indented JSON representation of v to the given path. -// This is used only by test helpers that need to create plugin.json fixtures. -func writeJSON(path string, v any) error { - data, err := json.MarshalIndent(v, "", " ") - if err != nil { - return err - } - return os.WriteFile(path, data, 0600) -} diff --git a/cmd/wfctl/docs_test.go b/cmd/wfctl/docs_test.go index 46972219..8d71f687 100644 --- a/cmd/wfctl/docs_test.go +++ b/cmd/wfctl/docs_test.go @@ -1,6 +1,7 @@ package main import ( + "encoding/json" "os" "path/filepath" "strings" @@ -9,6 +10,16 @@ import ( "github.com/GoCodeAlone/workflow/plugin" ) +// writeJSON writes an indented JSON representation of v to the given path. +// Used only in tests to create plugin.json fixtures. +func writeJSON(path string, v any) error { + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0600) +} + // --- test config strings --- const docsMinimalConfig = ` diff --git a/example/docs-with-plugins/workflow.yaml b/example/docs-with-plugins/workflow.yaml index 039b7a1d..3a7cbc86 100644 --- a/example/docs-with-plugins/workflow.yaml +++ b/example/docs-with-plugins/workflow.yaml @@ -35,6 +35,7 @@ modules: dependsOn: - api-router config: + secret: "${JWT_SECRET}" issuer: "https://auth.example.com" audience: "order-api" diff --git a/example_configs_test.go b/example_configs_test.go index efe9f10b..9204f08a 100644 --- a/example_configs_test.go +++ b/example_configs_test.go @@ -125,6 +125,8 @@ func TestExampleConfigsValidate(t *testing.T) { schema.WithExtraWorkflowTypes("actors"), // Actor module types registered by the actors plugin schema.WithExtraModuleTypes("actor.system", "actor.pool"), + // Module types from the workflow-plugin-authz external plugin (docs-with-plugins example) + schema.WithExtraModuleTypes("authz.enforcer", "authz.policy"), // Pipeline trigger types schema.WithExtraTriggerTypes("mock"), // Many configs are sub-workflows or modular-style configs without explicit entry points From 6531a1c1096abbf2cd91f8e50d57f09b7db39ccc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 21:31:21 +0000 Subject: [PATCH 4/4] Fix TestExampleConfigsBuildFromConfig for docs-with-plugins example Add docs-with-plugins/workflow.yaml to the envIssues skip map in TestExampleConfigsBuildFromConfig since authz.enforcer requires the external GoCodeAlone/workflow-plugin-authz plugin not available in the test engine. Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --- example_configs_test.go | 2 ++ mcp/server.go | 2 +- modernize/manifest_rule_test.go | 8 ++++---- plugins/all/all.go | 2 +- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/example_configs_test.go b/example_configs_test.go index 9204f08a..e43bbbdf 100644 --- a/example_configs_test.go +++ b/example_configs_test.go @@ -171,6 +171,8 @@ func TestExampleConfigsBuildFromConfig(t *testing.T) { "feature-flag-workflow.yaml": "step.feature_flag requires featureflag.service module loaded before pipeline configuration", // actor-system config uses inline pipeline routes that require actor workflow handler wiring "actor-system-config.yaml": "actor workflow handler wires routes via plugin hooks, not traditional handler registration", + // docs-with-plugins example references authz.enforcer from an external plugin not available in test engine + "docs-with-plugins/workflow.yaml": "authz.enforcer module type requires GoCodeAlone/workflow-plugin-authz external plugin", } for _, cfgPath := range configs { diff --git a/mcp/server.go b/mcp/server.go index 61bdf5f4..4bb1c0d9 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -74,7 +74,7 @@ type Server struct { mcpServer *server.MCPServer pluginDir string registryDir string - documentationFile string // optional explicit path to DOCUMENTATION.md + documentationFile string // optional explicit path to DOCUMENTATION.md engine EngineProvider // optional; enables execution tools when set } diff --git a/modernize/manifest_rule_test.go b/modernize/manifest_rule_test.go index 3025ca82..bcc6a8ca 100644 --- a/modernize/manifest_rule_test.go +++ b/modernize/manifest_rule_test.go @@ -453,11 +453,11 @@ modules: // the Fix function does not create duplicate keys. func TestManifestRule_ModuleConfigKeyRename_Collision(t *testing.T) { mr := ManifestRule{ - ID: "test-collision-mod", + ID: "test-collision-mod", Description: "Rename old_key to new_key in my.module", - ModuleType: "my.module", - OldKey: "old_key", - NewKey: "new_key", + ModuleType: "my.module", + OldKey: "old_key", + NewKey: "new_key", } rule, err := mr.ToRule() if err != nil { diff --git a/plugins/all/all.go b/plugins/all/all.go index 9056a6d9..408cf5a2 100644 --- a/plugins/all/all.go +++ b/plugins/all/all.go @@ -46,9 +46,9 @@ import ( pluginpipeline "github.com/GoCodeAlone/workflow/plugins/pipelinesteps" pluginplatform "github.com/GoCodeAlone/workflow/plugins/platform" pluginpolicy "github.com/GoCodeAlone/workflow/plugins/policy" + pluginscanner "github.com/GoCodeAlone/workflow/plugins/scanner" pluginscheduler "github.com/GoCodeAlone/workflow/plugins/scheduler" pluginsecrets "github.com/GoCodeAlone/workflow/plugins/secrets" - pluginscanner "github.com/GoCodeAlone/workflow/plugins/scanner" pluginsm "github.com/GoCodeAlone/workflow/plugins/statemachine" pluginstorage "github.com/GoCodeAlone/workflow/plugins/storage" plugintimeline "github.com/GoCodeAlone/workflow/plugins/timeline"