diff --git a/cmd/cascade/main_test.go b/cmd/cascade/main_test.go index eae4f956..4773369c 100644 --- a/cmd/cascade/main_test.go +++ b/cmd/cascade/main_test.go @@ -318,6 +318,67 @@ func TestGraphCommand_MissingManifest(t *testing.T) { } } +// graphEnvManifestContent is a two-environment manifest whose dev env state +// tracks a hotfix integration branch, so the env projection must render the +// promotion state machine with a divergence branch that rejoins at prod. +const graphEnvManifestContent = `ci: + config: + trunk_branch: main + environments: + - dev + - prod + state: + dev: + ref: hotfix/patch + prod: {} +` + +func writeGraphEnvManifest(t *testing.T) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "manifest.yaml") + if err := os.WriteFile(path, []byte(graphEnvManifestContent), 0644); err != nil { + t.Fatalf("Failed to write manifest: %v", err) + } + return path +} + +func TestGraphCommand_EnvGranularityEmitsStateMachine(t *testing.T) { + manifest := writeGraphEnvManifest(t) + stdout, stderr, err := runCLI("graph", "--config", manifest, "--granularity", "env") + if err != nil { + t.Fatalf("graph --granularity env failed: %v\nstderr: %s", err, stderr) + } + for _, want := range []string{ + "stateDiagram-v2", + "[*] --> dev", + "dev --> prod : promote", + "dev --> dev_hotfix : diverge", + "dev_hotfix --> prod : rejoin", + } { + if !contains(stdout, want) { + t.Errorf("expected %q in env projection, got:\n%s", want, stdout) + } + } +} + +func TestGraphCommand_StagesGranularityEmitsFlowchart(t *testing.T) { + manifest := writeGraphManifest(t) + stdout, stderr, err := runCLI("graph", "--config", manifest, "--granularity", "stages") + if err != nil { + t.Fatalf("graph --granularity stages failed: %v\nstderr: %s", err, stderr) + } + for _, want := range []string{"flowchart TD", "trunk", "build", "deploy", "promote", "release"} { + if !contains(stdout, want) { + t.Errorf("expected %q in stages projection, got:\n%s", want, stdout) + } + } + // The stage rollup must not carry per-callback job nodes. + if contains(stdout, "build_app") || contains(stdout, "deploy_app") { + t.Errorf("stages projection leaked per-callback nodes:\n%s", stdout) + } +} + // -------- status command integration tests -------- // fixtureManifestPath returns the path to the on-disk status fixture. diff --git a/internal/graph/command.go b/internal/graph/command.go index bc252b2e..39c252b1 100644 --- a/internal/graph/command.go +++ b/internal/graph/command.go @@ -21,11 +21,13 @@ func NewCommand() *cobra.Command { Short: "Render the generated pipeline as a Mermaid diagram", Long: `Render the manifest's generated pipeline as a diagram on stdout. -graph loads the manifest, builds the same job dependency graph the generator -uses, and emits it as Mermaid that GitHub renders natively in Markdown. Pipe the -output into a file or paste it into a README or pull request to show how the -pipeline's jobs depend on one another. Hard dependencies render as solid arrows -and optional ordering-only dependencies as dotted arrows. +graph loads the manifest and emits Mermaid that GitHub renders natively in +Markdown. Pipe the output into a file or paste it into a README or pull request. +The --granularity flag chooses the projection: jobs renders the full job +dependency graph (hard dependencies as solid arrows, optional ordering-only ones +as dotted arrows); stages renders the coarse lifecycle flow from trunk through +build, deploy, and promote to release; env renders the promotion state machine, +including any hotfix divergence and rejoin. graph is read-only: it never writes files, runs git, or modifies the repo. A missing or invalid manifest is reported as an error.`, @@ -38,7 +40,7 @@ missing or invalid manifest is reported as an error.`, cmd.Flags().StringVarP(&o.ConfigPath, "config", "c", "", "Path to config file (default: auto-detect .github/manifest.yaml)") cmd.Flags().StringVar(&o.ManifestKey, "manifest-key", config.DefaultManifestKey, "Key in manifest file containing CI config") - cmd.Flags().StringVar(&o.Granularity, "granularity", string(GranularityJobs), "Pipeline projection to render; supported value: jobs") + cmd.Flags().StringVar(&o.Granularity, "granularity", string(GranularityJobs), "Pipeline projection to render; supported values: jobs, stages, env") cmd.Flags().StringVar(&o.Format, "format", formatMermaid, "Diagram output format; supported value: mermaid") cmd.Flags().StringVar(&o.Theme, "theme", defaultThemeName, "Diagram theme; supported value: default") diff --git a/internal/graph/graph.go b/internal/graph/graph.go index 5feac227..78b32f88 100644 --- a/internal/graph/graph.go +++ b/internal/graph/graph.go @@ -16,13 +16,13 @@ import ( "github.com/stablekernel/cascade/internal/visualize" ) -// Granularity selects which projection of the pipeline the graph renders. The -// job DAG is the only projection that exists today; env and stage rollups are -// recognized values that Run rejects with a clear message until their -// projections land, so the flag contract is stable as the projections grow. +// Granularity selects which projection of the pipeline the graph renders. Each +// value maps to a distinct view-model build feeding the same emitter: jobs is +// the full job DAG, stages is the coarse lifecycle flow, and env is the +// promotion state machine with its hotfix divergence and rejoin. type Granularity string -// Granularity values. Only GranularityJobs produces a diagram today. +// Granularity values. const ( GranularityJobs Granularity = "jobs" GranularityStages Granularity = "stages" @@ -79,12 +79,11 @@ func Run(o Options, stdout io.Writer) error { granularity = string(GranularityJobs) } switch Granularity(granularity) { - case GranularityJobs: - // The job DAG is the implemented projection. - case GranularityStages, GranularityEnv: - return fmt.Errorf("granularity %q is not yet available: only %q is supported", granularity, GranularityJobs) + case GranularityJobs, GranularityStages, GranularityEnv: + // Each granularity maps to a supported projection. default: - return fmt.Errorf("unknown granularity %q: supported value is %q", granularity, GranularityJobs) + return fmt.Errorf("unknown granularity %q: supported values are %q, %q, and %q", + granularity, GranularityJobs, GranularityStages, GranularityEnv) } theme := o.Theme @@ -104,14 +103,9 @@ func Run(o Options, stdout io.Writer) error { key = config.DefaultManifestKey } - cfg, err := config.ParseWithKey(configPath, key) + vm, err := buildView(Granularity(granularity), configPath, key) if err != nil { - return fmt.Errorf("loading manifest: %w", err) - } - - vm, err := visualize.BuildViewModel(generate.BuildDependencyGraph(cfg)) - if err != nil { - return fmt.Errorf("building graph view: %w", err) + return err } diagram, err := visualize.NewMermaidEmitter().Emit(vm, visualize.DefaultTheme) @@ -131,6 +125,46 @@ func Run(o Options, stdout io.Writer) error { return nil } +// buildView loads the manifest and projects it into the view model the chosen +// granularity calls for. The jobs and stages projections need only the pipeline +// config; the env projection also needs the per-environment state so it can draw +// hotfix divergence, so it loads the full manifest file rather than just the +// config section. +func buildView(granularity Granularity, configPath, key string) (visualize.ViewModel, error) { + switch granularity { + case GranularityEnv: + file, err := config.ParseManifestFile(configPath, key) + if err != nil { + return visualize.ViewModel{}, fmt.Errorf("loading manifest: %w", err) + } + vm, err := visualize.BuildEnvViewModel(file.Config, file.State) + if err != nil { + return visualize.ViewModel{}, fmt.Errorf("building env view: %w", err) + } + return vm, nil + case GranularityStages: + cfg, err := config.ParseWithKey(configPath, key) + if err != nil { + return visualize.ViewModel{}, fmt.Errorf("loading manifest: %w", err) + } + vm, err := visualize.BuildStagesViewModel(cfg) + if err != nil { + return visualize.ViewModel{}, fmt.Errorf("building stages view: %w", err) + } + return vm, nil + default: + cfg, err := config.ParseWithKey(configPath, key) + if err != nil { + return visualize.ViewModel{}, fmt.Errorf("loading manifest: %w", err) + } + vm, err := visualize.BuildViewModel(generate.BuildDependencyGraph(cfg)) + if err != nil { + return visualize.ViewModel{}, fmt.Errorf("building graph view: %w", err) + } + return vm, nil + } +} + // writeJSON emits the diagram wrapped in a structured envelope so a caller can // consume the format, granularity, theme, and diagram source together. func writeJSON(stdout io.Writer, format, granularity, theme, diagram string) error { diff --git a/internal/graph/graph_test.go b/internal/graph/graph_test.go index 1fe35e2a..d16004b8 100644 --- a/internal/graph/graph_test.go +++ b/internal/graph/graph_test.go @@ -43,6 +43,34 @@ func writeManifest(t *testing.T) string { return path } +// writeDivergedManifest lays down a two-environment manifest whose dev env state +// tracks a hotfix integration branch, so the env projection must draw a +// divergence branch off dev that rejoins at prod. +func writeDivergedManifest(t *testing.T) string { + t.Helper() + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".github"), 0o755)) + + cfg := &config.TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"dev", "prod"}, + } + manifest := map[string]any{ + config.DefaultManifestKey: config.CICDFile{ + Config: cfg, + State: map[string]*config.EnvState{ + "dev": {Ref: "hotfix/patch"}, + "prod": {}, + }, + }, + } + body, err := yaml.Marshal(manifest) + require.NoError(t, err) + path := filepath.Join(dir, ".github", "manifest.yaml") + require.NoError(t, os.WriteFile(path, body, 0o644)) + return path +} + // baseOptions returns valid options pointed at the given manifest, so each test // can override the one field under exercise. func baseOptions(manifestPath string) Options { @@ -82,27 +110,41 @@ func TestRun_UnknownFormat_Errors(t *testing.T) { require.Empty(t, out.String()) } -func TestRun_StagesGranularity_NotYetSupported(t *testing.T) { +func TestRun_StagesGranularity_EmitsFlowchart(t *testing.T) { path := writeManifest(t) o := baseOptions(path) o.Granularity = string(GranularityStages) var out bytes.Buffer - err := Run(o, &out) - require.Error(t, err) - require.Contains(t, err.Error(), "stages") - require.Contains(t, err.Error(), "jobs") + require.NoError(t, Run(o, &out)) + + got := out.String() + require.Contains(t, got, "flowchart TD") + // The stage rollup shows coarse stages, not the per-callback job nodes. + require.Contains(t, got, "trunk") + require.Contains(t, got, "build") + require.Contains(t, got, "deploy") + require.Contains(t, got, "promote") + require.Contains(t, got, "release") + require.NotContains(t, got, "build_app") + require.NotContains(t, got, "deploy_app") } -func TestRun_EnvGranularity_NotYetSupported(t *testing.T) { - path := writeManifest(t) +func TestRun_EnvGranularity_EmitsStateMachine(t *testing.T) { + path := writeDivergedManifest(t) o := baseOptions(path) o.Granularity = string(GranularityEnv) var out bytes.Buffer - err := Run(o, &out) - require.Error(t, err) - require.Contains(t, err.Error(), "env") + require.NoError(t, Run(o, &out)) + + got := out.String() + require.Contains(t, got, "stateDiagram-v2") + require.Contains(t, got, "[*] --> dev") + require.Contains(t, got, "dev --> prod : promote") + // The diverged dev env draws a hotfix branch that rejoins downstream. + require.Contains(t, got, "dev --> dev_hotfix : diverge") + require.Contains(t, got, "dev_hotfix --> prod : rejoin") } func TestRun_UnknownGranularity_Errors(t *testing.T) { diff --git a/internal/visualize/env.go b/internal/visualize/env.go new file mode 100644 index 00000000..67c22f44 --- /dev/null +++ b/internal/visualize/env.go @@ -0,0 +1,94 @@ +package visualize + +import ( + "fmt" + + "github.com/stablekernel/cascade/internal/config" +) + +// startNodeID and endNodeID are the reserved identities of the state machine's +// entry and terminal pseudo-states. They never collide with an environment name +// because cascade env names are simple slugs; the emitter renders nodes of kind +// NodeStart and NodeEnd as the renderer's initial and final markers, so these +// IDs are model-internal and never appear in the diagram text. +const ( + startNodeID = "__start__" + endNodeID = "__end__" +) + +// BuildEnvViewModel projects the promotion ladder into a render-agnostic state +// machine. The configured environments become a linear chain of env states from +// the first environment to the last, bracketed by a start and an end marker. +// Each environment whose state has diverged onto an integration branch (a hotfix +// Ref or applied patches) gains a hotfix state that branches off that +// environment and rejoins the ladder at the next environment downstream, or at +// the terminal when the diverged environment is last. The model carries no +// diagram syntax; the emitter turns it into a state diagram. +func BuildEnvViewModel(cfg *config.TrunkConfig, state map[string]*config.EnvState) (ViewModel, error) { + if cfg == nil { + return ViewModel{}, fmt.Errorf("visualize: nil config") + } + if len(cfg.Environments) == 0 { + return ViewModel{}, fmt.Errorf("visualize: config declares no environments to render") + } + + vm := ViewModel{Kind: DiagramState} + + // Bookend markers frame the ladder. They are declared first so the start + // transition reads before the env states in the model. + vm.Nodes = append(vm.Nodes, + Node{ID: startNodeID, Kind: NodeStart}, + Node{ID: endNodeID, Kind: NodeEnd}, + ) + + for _, env := range cfg.Environments { + vm.Nodes = append(vm.Nodes, Node{ID: env, Label: env, Kind: NodeEnv}) + } + + // The entry transition lands on the first environment; the exit transition + // leaves the last one. + first := cfg.Environments[0] + last := cfg.Environments[len(cfg.Environments)-1] + vm.Edges = append(vm.Edges, Edge{From: startNodeID, To: first, Kind: EdgeTransition}) + + // Promotion transitions chain each environment to the next in order. + for i := 0; i+1 < len(cfg.Environments); i++ { + vm.Edges = append(vm.Edges, Edge{ + From: cfg.Environments[i], + To: cfg.Environments[i+1], + Kind: EdgePromote, + Label: "promote", + }) + } + vm.Edges = append(vm.Edges, Edge{From: last, To: endNodeID, Kind: EdgeTransition}) + + // Divergence branches are appended after the linear ladder so the chain reads + // top to bottom before the hotfix detours, keeping the model order stable. + for i, env := range cfg.Environments { + es := state[env] + if !es.IsDiverged() { + continue + } + hotfixID := env + "_hotfix" + vm.Nodes = append(vm.Nodes, Node{ID: hotfixID, Label: hotfixLabel(es), Kind: NodeHotfix}) + vm.Edges = append(vm.Edges, Edge{From: env, To: hotfixID, Kind: EdgeDiverge, Label: "diverge"}) + + rejoinTo := endNodeID + if i+1 < len(cfg.Environments) { + rejoinTo = cfg.Environments[i+1] + } + vm.Edges = append(vm.Edges, Edge{From: hotfixID, To: rejoinTo, Kind: EdgeRejoin, Label: "rejoin"}) + } + + return vm, nil +} + +// hotfixLabel names a divergence state. It prefers the integration branch ref so +// a reader sees which branch the environment tracks, and falls back to a generic +// label when the divergence is patch-only with no recorded ref. +func hotfixLabel(es *config.EnvState) string { + if es != nil && es.Ref != "" { + return es.Ref + } + return "hotfix" +} diff --git a/internal/visualize/env_test.go b/internal/visualize/env_test.go new file mode 100644 index 00000000..339ba9f1 --- /dev/null +++ b/internal/visualize/env_test.go @@ -0,0 +1,152 @@ +package visualize + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stablekernel/cascade/internal/config" +) + +// envConfig returns a three-environment promotion ladder used by the env-state +// machine tests. +func envConfig() *config.TrunkConfig { + return &config.TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"dev", "staging", "prod"}, + } +} + +// divergedState marks staging as tracking a hotfix integration branch, so the +// env projection must draw a divergence branch off staging that rejoins at prod. +func divergedState() map[string]*config.EnvState { + return map[string]*config.EnvState{ + "dev": {}, + "staging": {Ref: "hotfix/login"}, + "prod": {}, + } +} + +func TestBuildEnvViewModel_IsStateMachine(t *testing.T) { + vm, err := BuildEnvViewModel(envConfig(), divergedState()) + if err != nil { + t.Fatalf("BuildEnvViewModel: %v", err) + } + if vm.Kind != DiagramState { + t.Errorf("expected DiagramState, got %q", vm.Kind) + } + + // The ladder must carry an env node per configured environment, in order. + var envIDs []string + for _, n := range vm.Nodes { + if n.Kind == NodeEnv { + envIDs = append(envIDs, n.ID) + } + } + if strings.Join(envIDs, ",") != "dev,staging,prod" { + t.Errorf("expected env ladder dev,staging,prod, got %v", envIDs) + } + + // A start marker enters the first env and an end marker leaves the last env. + var sawStart, sawEnd bool + for _, n := range vm.Nodes { + switch n.Kind { + case NodeStart: + sawStart = true + case NodeEnd: + sawEnd = true + } + } + if !sawStart || !sawEnd { + t.Errorf("expected start and end markers, start=%v end=%v", sawStart, sawEnd) + } + + // The diverged staging env must have a hotfix node and a diverge plus rejoin + // edge, with the rejoin landing on the downstream prod env. + var sawHotfix, sawDiverge, sawRejoin bool + for _, n := range vm.Nodes { + if n.Kind == NodeHotfix { + sawHotfix = true + } + } + for _, e := range vm.Edges { + if e.Kind == EdgeDiverge && e.From == "staging" { + sawDiverge = true + } + if e.Kind == EdgeRejoin && e.To == "prod" { + sawRejoin = true + } + } + if !sawHotfix || !sawDiverge || !sawRejoin { + t.Errorf("expected hotfix divergence and rejoin, hotfix=%v diverge=%v rejoin=%v", sawHotfix, sawDiverge, sawRejoin) + } +} + +func TestBuildEnvViewModel_NoEnvironmentsErrors(t *testing.T) { + if _, err := BuildEnvViewModel(&config.TrunkConfig{}, nil); err == nil { + t.Fatal("expected error for a config with no environments, got nil") + } +} + +func TestBuildEnvViewModel_NilConfigErrors(t *testing.T) { + if _, err := BuildEnvViewModel(nil, nil); err == nil { + t.Fatal("expected error for nil config, got nil") + } +} + +func TestMermaidEmitter_EnvGolden(t *testing.T) { + vm, err := BuildEnvViewModel(envConfig(), divergedState()) + if err != nil { + t.Fatalf("BuildEnvViewModel: %v", err) + } + + got, err := NewMermaidEmitter().Emit(vm, DefaultTheme, WithTitle("environments")) + if err != nil { + t.Fatalf("Emit: %v", err) + } + + if !strings.Contains(got, "stateDiagram-v2") { + t.Errorf("expected a stateDiagram-v2 header, got:\n%s", got) + } + + golden := filepath.Join("testdata", "env.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 env Mermaid does not match golden.\n--- got ---\n%s\n--- want ---\n%s", got, want) + } +} + +func TestMermaidEmitter_EnvDeterministic(t *testing.T) { + first, err := func() (string, error) { + vm, err := BuildEnvViewModel(envConfig(), divergedState()) + if err != nil { + return "", err + } + return NewMermaidEmitter().Emit(vm, DefaultTheme) + }() + if err != nil { + t.Fatalf("first emit: %v", err) + } + for i := 0; i < 10; i++ { + vm, err := BuildEnvViewModel(envConfig(), divergedState()) + if err != nil { + t.Fatalf("build %d: %v", i, err) + } + next, err := NewMermaidEmitter().Emit(vm, 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) + } + } +} diff --git a/internal/visualize/mermaid.go b/internal/visualize/mermaid.go index 4b721602..f9e868e8 100644 --- a/internal/visualize/mermaid.go +++ b/internal/visualize/mermaid.go @@ -18,12 +18,11 @@ 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. +// Emit renders vm as Mermaid source. The diagram family follows the model's +// Kind: a state machine (env projection) becomes a stateDiagram-v2, every other +// model becomes a top-down flowchart. 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) @@ -37,25 +36,85 @@ func (MermaidEmitter) Emit(vm ViewModel, _ Theme, opts ...Option) (string, error b.WriteString("---\n") } + if vm.Kind == DiagramState { + return emitState(&b, vm) + } + return emitFlowchart(&b, vm) +} + +// emitFlowchart renders a directed-graph model (jobs and stages) as a top-down +// flowchart. Nodes are declared first in model order with a shape per kind, then +// edges in model order, so dependency or stage flow reads in a stable order. +func emitFlowchart(b *strings.Builder, vm ViewModel) (string, error) { 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. + // subroutine for deploy, rounded for a stage. 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) + 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)) + fmt.Fprintf(b, " %s -.-> %s\n", mermaidID(e.From), mermaidID(e.To)) + case EdgeHard, EdgeStage: + 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 "", fmt.Errorf("visualize: mermaid: unknown flowchart edge kind %q", e.Kind) + } + } + + return b.String(), nil +} + +// emitState renders a state-machine model (the env projection) as a +// stateDiagram-v2. Start and end markers render as Mermaid's [*] pseudo-state +// rather than declared states; every other node is declared with a renamed +// label when its label differs from its id. Transitions follow model order and +// carry their optional caption, so promote, diverge, and rejoin read distinctly. +func emitState(b *strings.Builder, vm ViewModel) (string, error) { + b.WriteString("stateDiagram-v2\n") + + // Map each node id to the token it renders as, so edges can resolve a start + // or end marker to [*] without the builder ever encoding diagram syntax. + token := make(map[string]string, len(vm.Nodes)) + for _, n := range vm.Nodes { + switch n.Kind { + case NodeStart, NodeEnd: + token[n.ID] = "[*]" + default: + token[n.ID] = mermaidID(n.ID) + } + } + + for _, n := range vm.Nodes { + if n.Kind == NodeStart || n.Kind == NodeEnd { + continue // pseudo-states are referenced as [*], never declared + } + // A state whose label matches its id needs no rename; otherwise declare + // the display name with a quoted alias so labels with punctuation are safe. + if n.Label != "" && n.Label != n.ID { + fmt.Fprintf(b, " state %s as %s\n", mermaidStateLabel(n.Label), mermaidID(n.ID)) + } + } + + for _, e := range vm.Edges { + from, ok := token[e.From] + if !ok { + return "", fmt.Errorf("visualize: mermaid: transition from undeclared state %q", e.From) + } + to, ok := token[e.To] + if !ok { + return "", fmt.Errorf("visualize: mermaid: transition to undeclared state %q", e.To) + } + if e.Label != "" { + fmt.Fprintf(b, " %s --> %s : %s\n", from, to, mermaidTransitionLabel(e.Label)) + } else { + fmt.Fprintf(b, " %s --> %s\n", from, to) } } @@ -71,6 +130,8 @@ func nodeBrackets(kind NodeKind) (string, string) { return "([", "])" // stadium case NodeDeploy: return "[[", "]]" // subroutine + case NodeStage: + return "(", ")" // rounded case NodeBuild: return "[", "]" // rectangle default: @@ -85,6 +146,25 @@ func mermaidID(id string) string { return strings.ReplaceAll(id, "-", "_") } +// mermaidStateLabel renders a state's display name as a quoted Mermaid string +// for the `state "name" as id` rename form, escaping any embedded quote so the +// alias declaration cannot be broken by punctuation in the name. +func mermaidStateLabel(label string) string { + return `"` + strings.ReplaceAll(label, `"`, """) + `"` +} + +// mermaidTransitionLabel sanitizes a transition caption. A colon would otherwise +// be read as a second label separator and a newline would split the transition, +// so both are folded to keep the caption on one line and inside one segment. +func mermaidTransitionLabel(label string) string { + r := strings.NewReplacer( + ":", ":", + "\n", " ", + "\r", " ", + ) + return r.Replace(label) +} + // 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. diff --git a/internal/visualize/stages.go b/internal/visualize/stages.go new file mode 100644 index 00000000..edd109eb --- /dev/null +++ b/internal/visualize/stages.go @@ -0,0 +1,51 @@ +package visualize + +import ( + "fmt" + + "github.com/stablekernel/cascade/internal/config" +) + +// BuildStagesViewModel projects the manifest into the coarse lifecycle flow: +// trunk, build, deploy, promote, release. It collapses every callback into its +// stage, so the diagram shows the shape of the pipeline without per-callback +// detail. A stage appears only when the manifest exercises it: build when the +// manifest declares builds, deploy when it declares deploys, and promote when it +// declares more than one environment. Trunk and release always frame the flow. +// The present stages are chained in lifecycle order with a single edge between +// each consecutive pair. The model carries no diagram syntax. +func BuildStagesViewModel(cfg *config.TrunkConfig) (ViewModel, error) { + if cfg == nil { + return ViewModel{}, fmt.Errorf("visualize: nil config") + } + + // Candidate stages in lifecycle order, each gated on whether the manifest + // actually exercises it. Trunk and release are unconditional bookends. + candidates := []struct { + id string + label string + present bool + }{ + {id: "trunk", label: "Trunk", present: true}, + {id: "build", label: "Build", present: len(cfg.Builds) > 0}, + {id: "deploy", label: "Deploy", present: len(cfg.Deploys) > 0}, + {id: "promote", label: "Promote", present: len(cfg.Environments) > 1}, + {id: "release", label: "Release", present: true}, + } + + vm := ViewModel{Kind: DiagramFlowchart} + + var prev string + for _, c := range candidates { + if !c.present { + continue + } + vm.Nodes = append(vm.Nodes, Node{ID: c.id, Label: c.label, Kind: NodeStage}) + if prev != "" { + vm.Edges = append(vm.Edges, Edge{From: prev, To: c.id, Kind: EdgeStage}) + } + prev = c.id + } + + return vm, nil +} diff --git a/internal/visualize/stages_test.go b/internal/visualize/stages_test.go new file mode 100644 index 00000000..e5a29f87 --- /dev/null +++ b/internal/visualize/stages_test.go @@ -0,0 +1,124 @@ +package visualize + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stablekernel/cascade/internal/config" +) + +// stagesConfig exercises every stage: a multi-env manifest with builds and +// deploys, so the projection includes trunk, build, deploy, promote, and +// release. +func stagesConfig() *config.TrunkConfig { + return &config.TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"dev", "prod"}, + Builds: []config.BuildConfig{ + {Name: "api", Workflow: ".github/workflows/build-api.yaml"}, + {Name: "web", Workflow: ".github/workflows/build-web.yaml"}, + }, + Deploys: []config.DeployConfig{ + {Name: "app", Workflow: ".github/workflows/deploy.yaml"}, + }, + } +} + +func TestBuildStagesViewModel_CollapsesCallbacks(t *testing.T) { + vm, err := BuildStagesViewModel(stagesConfig()) + if err != nil { + t.Fatalf("BuildStagesViewModel: %v", err) + } + if vm.Kind != DiagramFlowchart { + t.Errorf("expected DiagramFlowchart, got %q", vm.Kind) + } + + var ids []string + for _, n := range vm.Nodes { + if n.Kind != NodeStage { + t.Errorf("stages projection must hold only stage nodes, got %+v", n) + } + ids = append(ids, n.ID) + } + // The five coarse stages, in lifecycle order, with no per-callback nodes. + if strings.Join(ids, ",") != "trunk,build,deploy,promote,release" { + t.Errorf("expected the five stages in order, got %v", ids) + } + + // No per-callback node names may leak into the stage rollup. + for _, n := range vm.Nodes { + if strings.Contains(n.ID, "api") || strings.Contains(n.ID, "web") || strings.Contains(n.ID, "app") { + t.Errorf("per-callback node leaked into stages projection: %+v", n) + } + } +} + +func TestBuildStagesViewModel_OmitsAbsentStages(t *testing.T) { + // A single-env library project with no builds or deploys collapses to just + // the trunk and release bookends. + vm, err := BuildStagesViewModel(&config.TrunkConfig{TrunkBranch: "main"}) + if err != nil { + t.Fatalf("BuildStagesViewModel: %v", err) + } + var ids []string + for _, n := range vm.Nodes { + ids = append(ids, n.ID) + } + if strings.Join(ids, ",") != "trunk,release" { + t.Errorf("expected trunk,release for a no-callback project, got %v", ids) + } +} + +func TestMermaidEmitter_StagesGolden(t *testing.T) { + vm, err := BuildStagesViewModel(stagesConfig()) + if err != nil { + t.Fatalf("BuildStagesViewModel: %v", err) + } + + got, err := NewMermaidEmitter().Emit(vm, DefaultTheme, WithTitle("stages")) + if err != nil { + t.Fatalf("Emit: %v", err) + } + if !strings.Contains(got, "flowchart TD") { + t.Errorf("expected a flowchart header, got:\n%s", got) + } + + golden := filepath.Join("testdata", "stages.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 stages Mermaid does not match golden.\n--- got ---\n%s\n--- want ---\n%s", got, want) + } +} + +func TestMermaidEmitter_StagesDeterministic(t *testing.T) { + build := func() (string, error) { + vm, err := BuildStagesViewModel(stagesConfig()) + if err != nil { + return "", err + } + return NewMermaidEmitter().Emit(vm, DefaultTheme) + } + first, err := build() + if err != nil { + t.Fatalf("first emit: %v", err) + } + for i := 0; i < 10; i++ { + next, err := build() + if err != nil { + t.Fatalf("emit %d: %v", i, err) + } + if next != first { + t.Fatalf("emit %d differs from first run", i) + } + } +} diff --git a/internal/visualize/testdata/env.mmd b/internal/visualize/testdata/env.mmd new file mode 100644 index 00000000..c9b834fb --- /dev/null +++ b/internal/visualize/testdata/env.mmd @@ -0,0 +1,11 @@ +--- +title: "environments" +--- +stateDiagram-v2 + state "hotfix/login" as staging_hotfix + [*] --> dev + dev --> staging : promote + staging --> prod : promote + prod --> [*] + staging --> staging_hotfix : diverge + staging_hotfix --> prod : rejoin diff --git a/internal/visualize/testdata/stages.mmd b/internal/visualize/testdata/stages.mmd new file mode 100644 index 00000000..5d125bc3 --- /dev/null +++ b/internal/visualize/testdata/stages.mmd @@ -0,0 +1,13 @@ +--- +title: "stages" +--- +flowchart TD + trunk(Trunk) + build(Build) + deploy(Deploy) + promote(Promote) + release(Release) + trunk --> build + build --> deploy + deploy --> promote + promote --> release diff --git a/internal/visualize/viewmodel.go b/internal/visualize/viewmodel.go index 90196964..f8425af0 100644 --- a/internal/visualize/viewmodel.go +++ b/internal/visualize/viewmodel.go @@ -11,28 +11,68 @@ import ( "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. +// DiagramKind tells an emitter which family of diagram a ViewModel describes. +// The job and stage projections are directed graphs (a flowchart); the env +// projection is a promotion state machine (a state diagram). The kind lives on +// the model, not in the emitter, so the emitter stays a thin renderer that +// switches on what the model already declares rather than on the granularity. +type DiagramKind string + +// Diagram kinds. The zero value renders as a flowchart so a model built without +// setting Kind (the original job projection) keeps its behavior. +const ( + DiagramFlowchart DiagramKind = "flowchart" + DiagramState DiagramKind = "state" +) + +// NodeKind classifies a node for rendering. The first three mirror the callback +// types cascade models (validate, build, deploy) for the job DAG. NodeStage is +// a coarse lifecycle stage; NodeEnv, NodeHotfix, NodeStart, and NodeEnd describe +// the env state machine. 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. +// Node kinds. const ( NodeValidate NodeKind = "validate" NodeBuild NodeKind = "build" NodeDeploy NodeKind = "deploy" + // NodeStage is one coarse lifecycle stage in the stages projection. + NodeStage NodeKind = "stage" + // NodeEnv is one environment state in the env promotion ladder. + NodeEnv NodeKind = "env" + // NodeHotfix is a diverged integration-branch state hanging off an env. + NodeHotfix NodeKind = "hotfix" + // NodeStart marks the state-machine entry point. An emitter renders it as the + // renderer's initial pseudo-state rather than a declared node. + NodeStart NodeKind = "start" + // NodeEnd marks the state-machine terminal. An emitter renders it as the + // renderer's final pseudo-state rather than a declared node. + NodeEnd NodeKind = "end" ) -// 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. +// EdgeKind classifies an edge. Hard edges come from Edges (they both order a job +// and skip-gate it); optional edges come from OptionalEdges (they order only). +// Stage edges connect lifecycle stages. Promote, diverge, and rejoin edges are +// transitions in the env state machine. Emitters render the kinds with visually +// distinct styling so a reader can tell them apart. type EdgeKind string // Edge kinds. const ( EdgeHard EdgeKind = "hard" EdgeOptional EdgeKind = "optional" + // EdgeStage connects two lifecycle stages in the stages projection. + EdgeStage EdgeKind = "stage" + // EdgePromote is a promotion transition between two consecutive envs. + EdgePromote EdgeKind = "promote" + // EdgeDiverge is the transition from an env onto its hotfix branch. + EdgeDiverge EdgeKind = "diverge" + // EdgeRejoin is the transition from a hotfix branch back onto the ladder. + EdgeRejoin EdgeKind = "rejoin" + // EdgeTransition is an unlabeled state-machine transition, used for the + // start and end bookend edges. + EdgeTransition EdgeKind = "transition" ) // Node is one pipeline job in the view. ID is the stable, prefixed job ID @@ -44,21 +84,24 @@ type Node struct { 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. +// Edge is one connection from a source node to a target node. From and To are +// node IDs, Kind selects the visual style, and Label is an optional transition +// caption an emitter renders when the renderer supports edge labels (the env +// state machine uses it for promote, diverge, and rejoin). An empty Label emits +// no caption, matching the unlabeled job and stage edges. type Edge struct { - From string - To string - Kind EdgeKind + From string + To string + Kind EdgeKind + Label string } -// 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. +// ViewModel is the deterministic, render-agnostic description of one pipeline +// projection. Kind tells the emitter which diagram family to render; Nodes and +// edges follow a stable construction order so two builds of the same manifest +// produce byte-identical emitter output. The model holds no diagram syntax. type ViewModel struct { + Kind DiagramKind Nodes []Node Edges []Edge } @@ -82,6 +125,7 @@ func BuildViewModel(g *generate.DependencyGraph) (ViewModel, error) { } vm := ViewModel{ + Kind: DiagramFlowchart, Nodes: make([]Node, 0, len(g.Order)), }