diff --git a/internal/visualize/emitter.go b/internal/visualize/emitter.go new file mode 100644 index 00000000..8ccf2a73 --- /dev/null +++ b/internal/visualize/emitter.go @@ -0,0 +1,58 @@ +package visualize + +// Theme carries presentation choices an emitter may honor. It is a stub in this +// iteration (a default value is threaded through every emit path); later work +// fills it with concrete styling slots. Keeping it in the signature now means +// adding fields later is additive, never a breaking change. +type Theme struct { + // Name identifies the theme. The zero value selects the emitter's built-in + // default, so callers that do not care about theming pass DefaultTheme. + Name string +} + +// DefaultTheme is the neutral theme used when a caller does not supply one. +var DefaultTheme = Theme{Name: "default"} + +// Options holds optional emit behavior. It is populated by the functional +// Option tail rather than constructed directly, so new knobs are additive. The +// zero value is valid and selects each emitter's defaults. +type Options struct { + // Title, when set, is rendered as a diagram heading by emitters that + // support one. An empty title emits no heading. + Title string +} + +// Option configures emit behavior. Following the repo convention, required +// inputs are positional on Emit and optional behavior arrives as a variadic +// Option tail, so capabilities can be added without changing the signature. +type Option func(*Options) + +// WithTitle sets a diagram heading. Emitters that have no notion of a heading +// ignore it. +func WithTitle(title string) Option { + return func(o *Options) { o.Title = title } +} + +// applyOptions folds a variadic Option tail onto a zero Options value and +// returns the result, so each Emit call starts from a clean, default state. +func applyOptions(opts []Option) Options { + var o Options + for _, fn := range opts { + if fn != nil { + fn(&o) + } + } + return o +} + +// Emitter turns a render-agnostic ViewModel into diagram source. It is the +// pluggable seam of the package: Mermaid is the first implementation, and a +// future renderer is added by writing another Emitter without touching the view +// model. Required inputs (the model and a theme) are positional; optional +// behavior arrives as a variadic Option tail. +type Emitter interface { + // Emit renders vm using theme and any options, returning the diagram source. + // It returns an error only when the model cannot be expressed as valid + // source; a well-formed model always emits successfully. + Emit(vm ViewModel, theme Theme, opts ...Option) (string, error) +} diff --git a/internal/visualize/mermaid.go b/internal/visualize/mermaid.go new file mode 100644 index 00000000..4b721602 --- /dev/null +++ b/internal/visualize/mermaid.go @@ -0,0 +1,100 @@ +package visualize + +import ( + "fmt" + "strings" +) + +// MermaidEmitter renders a ViewModel as Mermaid flowchart source that GitHub +// renders natively in Markdown. It is the first concrete Emitter. Output is +// deterministic: it ranges the model's ordered slices and never iterates a map, +// so the same model always yields byte-identical source. +type MermaidEmitter struct{} + +// NewMermaidEmitter returns a ready MermaidEmitter. The type is stateless, so +// the constructor exists only to give callers a stable construction point. +func NewMermaidEmitter() *MermaidEmitter { return &MermaidEmitter{} } + +// compile-time assertion that MermaidEmitter satisfies the Emitter seam. +var _ Emitter = (*MermaidEmitter)(nil) + +// Emit renders vm as a top-down Mermaid flowchart. Nodes are declared first in +// model order with class-tagged shapes per kind, then hard edges (solid arrows) +// and optional edges (dotted arrows) in model order, so the two dependency +// kinds are visually distinct. theme is accepted for interface conformance and +// future styling; the current output does not vary by theme. A title option, if +// set, is emitted as a Mermaid title in the frontmatter block. +func (MermaidEmitter) Emit(vm ViewModel, _ Theme, opts ...Option) (string, error) { + o := applyOptions(opts) + + var b strings.Builder + + if o.Title != "" { + // Mermaid reads a title from a YAML frontmatter block. Quote it so + // punctuation in the title cannot break the parse. + b.WriteString("---\n") + fmt.Fprintf(&b, "title: %q\n", o.Title) + b.WriteString("---\n") + } + + b.WriteString("flowchart TD\n") + + for _, n := range vm.Nodes { + // Node shape per kind: stadium for validate, rectangle for build, + // subroutine for deploy. The label is bracket-escaped so a display name + // with brackets cannot terminate the node early. + open, close := nodeBrackets(n.Kind) + fmt.Fprintf(&b, " %s%s%s%s\n", mermaidID(n.ID), open, mermaidLabel(n.Label), close) + } + + for _, e := range vm.Edges { + switch e.Kind { + case EdgeOptional: + // Dotted arrow marks an ordering-only optional dependency. + fmt.Fprintf(&b, " %s -.-> %s\n", mermaidID(e.From), mermaidID(e.To)) + case EdgeHard: + fmt.Fprintf(&b, " %s --> %s\n", mermaidID(e.From), mermaidID(e.To)) + default: + return "", fmt.Errorf("visualize: mermaid: unknown edge kind %q", e.Kind) + } + } + + return b.String(), nil +} + +// nodeBrackets returns the opening and closing Mermaid shape delimiters for a +// node kind. Distinct shapes let a reader tell validate, build, and deploy jobs +// apart at a glance. +func nodeBrackets(kind NodeKind) (string, string) { + switch kind { + case NodeValidate: + return "([", "])" // stadium + case NodeDeploy: + return "[[", "]]" // subroutine + case NodeBuild: + return "[", "]" // rectangle + default: + return "[", "]" + } +} + +// mermaidID sanitizes a job ID into a Mermaid node identifier. Cascade job IDs +// are already prefixed slugs (validate, build-app), so only the hyphen needs +// folding to an underscore to stay inside Mermaid's identifier rules. +func mermaidID(id string) string { + return strings.ReplaceAll(id, "-", "_") +} + +// mermaidLabel escapes a display label for use inside a Mermaid node shape. +// Brackets would otherwise close the shape early and quotes would confuse the +// parser, so both are replaced with HTML entities Mermaid renders verbatim. +func mermaidLabel(label string) string { + r := strings.NewReplacer( + "[", "[", + "]", "]", + "(", "(", + ")", ")", + `"`, """, + ) + return r.Replace(label) +} diff --git a/internal/visualize/testdata/representative.mmd b/internal/visualize/testdata/representative.mmd new file mode 100644 index 00000000..ef72c706 --- /dev/null +++ b/internal/visualize/testdata/representative.mmd @@ -0,0 +1,17 @@ +--- +title: "pipeline" +--- +flowchart TD + validate([Validate (validate)]) + build_api[Build (api)] + build_web[Build (web)] + deploy_staging[[Deploy (staging)]] + deploy_prod[[Deploy (prod)]] + build_api --> validate + build_web --> validate + build_web -.-> build_api + deploy_staging --> validate + deploy_staging --> build_api + deploy_staging --> build_web + deploy_prod --> validate + deploy_prod --> deploy_staging diff --git a/internal/visualize/viewmodel.go b/internal/visualize/viewmodel.go new file mode 100644 index 00000000..90196964 --- /dev/null +++ b/internal/visualize/viewmodel.go @@ -0,0 +1,131 @@ +// Package visualize builds a render-agnostic view of a cascade pipeline and +// emits it as a diagram. The view model carries no diagram syntax; concrete +// Emitter implementations (Mermaid is the first) turn it into renderer-specific +// source. The separation keeps the projection independently testable and lets a +// richer renderer be added later without reshaping the model. +package visualize + +import ( + "fmt" + + "github.com/stablekernel/cascade/internal/generate" +) + +// NodeKind classifies a pipeline node for rendering. The set mirrors the +// callback types cascade already models (validate, build, deploy). It is a +// rendering hint only; an emitter may map several kinds to one visual style. +type NodeKind string + +// Node kinds, one per cascade callback type. +const ( + NodeValidate NodeKind = "validate" + NodeBuild NodeKind = "build" + NodeDeploy NodeKind = "deploy" +) + +// EdgeKind classifies a dependency edge. Hard edges come from Edges (they both +// order a job and skip-gate it); optional edges come from OptionalEdges (they +// order only). Emitters render the two with visually distinct styling so a +// reader can tell a blocking dependency from an ordering-only one. +type EdgeKind string + +// Edge kinds. +const ( + EdgeHard EdgeKind = "hard" + EdgeOptional EdgeKind = "optional" +) + +// Node is one pipeline job in the view. ID is the stable, prefixed job ID +// (validate, build-app, deploy-app) used as the diagram node identity. Label is +// the human-facing display name. Kind drives styling. +type Node struct { + ID string + Label string + Kind NodeKind +} + +// Edge is one dependency from a job to one of its dependencies. From is the +// dependent job ID, To is the job it depends on, and Kind separates hard from +// optional dependencies. +type Edge struct { + From string + To string + Kind EdgeKind +} + +// ViewModel is the deterministic, render-agnostic description of a pipeline's +// job DAG. Nodes follow manifest declaration order (the graph's Order seed) and +// edges follow node order then dependency-list order, so two builds of the same +// manifest produce byte-identical emitter output. The model holds no diagram +// syntax. +type ViewModel struct { + Nodes []Node + Edges []Edge +} + +// BuildViewModel projects a generated DependencyGraph into a render-agnostic +// ViewModel. It walks the graph in Order (the same deterministic seed +// TopologicalSort uses) so node and edge slices are stable run to run, and it +// rejects a cyclic graph by surfacing the cycle TopologicalSort detects rather +// than emitting a malformed diagram. The returned model depends only on the +// cascade model and carries no Mermaid (or other) syntax. +func BuildViewModel(g *generate.DependencyGraph) (ViewModel, error) { + if g == nil { + return ViewModel{}, fmt.Errorf("visualize: nil dependency graph") + } + + // A cyclic DAG has no valid diagram; reuse the generator's cycle detection + // so the failure surfaces here at projection time, never as a panic or a + // misleading partial diagram downstream. + if _, err := g.TopologicalSort(); err != nil { + return ViewModel{}, fmt.Errorf("visualize: %w", err) + } + + vm := ViewModel{ + Nodes: make([]Node, 0, len(g.Order)), + } + + for _, id := range g.Order { + info, ok := g.Nodes[id] + if !ok { + // Order is built alongside Nodes, so a missing entry signals a + // corrupt graph rather than a recoverable state. + return ViewModel{}, fmt.Errorf("visualize: order references unknown node %q", id) + } + vm.Nodes = append(vm.Nodes, Node{ + ID: info.JobID, + Label: info.DisplayName, + Kind: nodeKind(info.Type), + }) + } + + // Emit edges grouped by dependent (in Order), then in dependency-list order + // within each group, so the slice is deterministic. Hard edges precede + // optional edges for the same node to keep the visual reading order stable. + for _, id := range g.Order { + for _, dep := range g.Edges[id] { + vm.Edges = append(vm.Edges, Edge{From: id, To: dep, Kind: EdgeHard}) + } + for _, dep := range g.OptionalEdges[id] { + vm.Edges = append(vm.Edges, Edge{From: id, To: dep, Kind: EdgeOptional}) + } + } + + return vm, nil +} + +// nodeKind maps a cascade callback type string to a view NodeKind. An unknown +// type falls back to NodeBuild so rendering degrades gracefully rather than +// dropping the node. +func nodeKind(callbackType string) NodeKind { + switch callbackType { + case "validate": + return NodeValidate + case "deploy": + return NodeDeploy + case "build": + return NodeBuild + default: + return NodeBuild + } +} diff --git a/internal/visualize/visualize_test.go b/internal/visualize/visualize_test.go new file mode 100644 index 00000000..20242008 --- /dev/null +++ b/internal/visualize/visualize_test.go @@ -0,0 +1,201 @@ +package visualize + +import ( + "flag" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/stablekernel/cascade/internal/config" + "github.com/stablekernel/cascade/internal/generate" +) + +// update regenerates the golden files when set. Run: go test -run Mermaid -update. +var update = flag.Bool("update", false, "update golden files") + +// representativeConfig returns a manifest exercising the issue's scenario: +// a validate, two builds, two deploys, with one optional dependency. It is the +// fixture behind the golden-file and determinism assertions. +func representativeConfig() *config.TrunkConfig { + return &config.TrunkConfig{ + Validate: &config.ValidateConfig{ + Workflow: ".github/workflows/validate.yaml", + }, + Builds: []config.BuildConfig{ + {Name: "api", Workflow: ".github/workflows/build-api.yaml"}, + { + Name: "web", + Workflow: ".github/workflows/build-web.yaml", + OptionalDependsOn: []string{"api"}, + }, + }, + Deploys: []config.DeployConfig{ + { + Name: "staging", + Workflow: ".github/workflows/deploy.yaml", + DependsOn: []string{"build:api", "build:web"}, + }, + { + Name: "prod", + Workflow: ".github/workflows/deploy.yaml", + DependsOn: []string{"deploy:staging"}, + }, + }, + } +} + +func buildVM(t *testing.T, cfg *config.TrunkConfig) ViewModel { + t.Helper() + g := generate.BuildDependencyGraph(cfg) + vm, err := BuildViewModel(g) + if err != nil { + t.Fatalf("BuildViewModel: %v", err) + } + return vm +} + +func TestBuildViewModel_IsRenderAgnostic(t *testing.T) { + vm := buildVM(t, representativeConfig()) + + if len(vm.Nodes) != 5 { + t.Fatalf("expected 5 nodes, got %d: %+v", len(vm.Nodes), vm.Nodes) + } + // First node is validate, in declaration order. + if vm.Nodes[0].ID != "validate" || vm.Nodes[0].Kind != NodeValidate { + t.Errorf("expected validate first, got %+v", vm.Nodes[0]) + } + // No Mermaid (or other) syntax may leak into the model's labels/ids. + for _, n := range vm.Nodes { + if strings.ContainsAny(n.ID+n.Label, "[]()-->") { + // hyphen is allowed in IDs; check only diagram-syntax tokens. + if strings.Contains(n.ID, "-->") || strings.Contains(n.Label, "-->") { + t.Errorf("diagram syntax leaked into view model: %+v", n) + } + } + } + // The web build carries an optional edge to api. + var sawOptional bool + for _, e := range vm.Edges { + if e.Kind == EdgeOptional && e.From == "build-web" && e.To == "build-api" { + sawOptional = true + } + } + if !sawOptional { + t.Errorf("expected optional edge build-web -> build-api, edges: %+v", vm.Edges) + } +} + +func TestMermaidEmitter_Golden(t *testing.T) { + vm := buildVM(t, representativeConfig()) + + got, err := NewMermaidEmitter().Emit(vm, DefaultTheme, WithTitle("pipeline")) + if err != nil { + t.Fatalf("Emit: %v", err) + } + + golden := filepath.Join("testdata", "representative.mmd") + if *update { + if err := os.WriteFile(golden, []byte(got), 0o644); err != nil { + t.Fatalf("write golden: %v", err) + } + } + want, err := os.ReadFile(golden) + if err != nil { + t.Fatalf("read golden (run with -update to create): %v", err) + } + if got != string(want) { + t.Errorf("emitted Mermaid does not match golden.\n--- got ---\n%s\n--- want ---\n%s", got, want) + } +} + +func TestMermaidEmitter_Deterministic(t *testing.T) { + cfg := representativeConfig() + + first, err := NewMermaidEmitter().Emit(buildVM(t, cfg), DefaultTheme) + if err != nil { + t.Fatalf("first emit: %v", err) + } + for i := 0; i < 10; i++ { + next, err := NewMermaidEmitter().Emit(buildVM(t, cfg), DefaultTheme) + if err != nil { + t.Fatalf("emit %d: %v", i, err) + } + if next != first { + t.Fatalf("emit %d differs from first run:\n%s\nvs\n%s", i, next, first) + } + } +} + +// flowchartHeaderRe matches the required Mermaid flowchart header. +var flowchartHeaderRe = regexp.MustCompile(`(?m)^flowchart TD$`) + +// edgeRe captures both arrow forms (solid and dotted) and their endpoints. +var edgeRe = regexp.MustCompile(`(?m)^\s+(\w+) -\.?-> (\w+)$`) + +// nodeDeclRe captures a node declaration's identifier (any shape bracket). +var nodeDeclRe = regexp.MustCompile(`(?m)^\s+(\w+)[\(\[]`) + +func TestMermaidEmitter_StructurallyValid(t *testing.T) { + out, err := NewMermaidEmitter().Emit(buildVM(t, representativeConfig()), DefaultTheme) + if err != nil { + t.Fatalf("Emit: %v", err) + } + + if !flowchartHeaderRe.MatchString(out) { + t.Fatalf("missing flowchart header in:\n%s", out) + } + + declared := map[string]bool{} + for _, m := range nodeDeclRe.FindAllStringSubmatch(out, -1) { + declared[m[1]] = true + } + if len(declared) == 0 { + t.Fatalf("no node declarations found in:\n%s", out) + } + + // Every edge endpoint must reference a declared node. + edges := edgeRe.FindAllStringSubmatch(out, -1) + if len(edges) == 0 { + t.Fatalf("no edges found in:\n%s", out) + } + for _, e := range edges { + from, to := e[1], e[2] + if !declared[from] { + t.Errorf("edge references undeclared source %q", from) + } + if !declared[to] { + t.Errorf("edge references undeclared target %q", to) + } + } +} + +func TestBuildViewModel_CyclicGraphErrors(t *testing.T) { + g := &generate.DependencyGraph{ + Nodes: map[string]generate.CallbackInfo{ + "build-a": {JobID: "build-a", DisplayName: "Build (a)", Type: "build"}, + "build-b": {JobID: "build-b", DisplayName: "Build (b)", Type: "build"}, + }, + Edges: map[string][]string{ + "build-a": {"build-b"}, + "build-b": {"build-a"}, + }, + OptionalEdges: map[string][]string{}, + Order: []string{"build-a", "build-b"}, + } + + _, err := BuildViewModel(g) + if err == nil { + t.Fatal("expected error for cyclic graph, got nil") + } + if !strings.Contains(err.Error(), "cycle") { + t.Errorf("expected cycle error, got: %v", err) + } +} + +func TestBuildViewModel_NilGraphErrors(t *testing.T) { + if _, err := BuildViewModel(nil); err == nil { + t.Fatal("expected error for nil graph, got nil") + } +}