Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions e2e/harness/multistep.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,10 @@ type Step struct {
// a per-file unified diff of committed-vs-planned workflows and always exits 0
// on success, exercising plan's informational (non-gate) contract.
Plan *PlanStep `yaml:"plan,omitempty"`
// Graph configures a "graph" action: a read-only `cascade graph` run that
// renders the manifest's pipeline as diagram source (Mermaid or D2) on stdout,
// exercising the renderer's deterministic, read-only text contract.
Graph *GraphStep `yaml:"graph,omitempty"`
// Consistency configures a "consistency" action: a `cascade status
// consistency` run (optionally --fix) that flags, and with --fix deletes,
// orphan env/* branches on the Gitea remote, then asserts the JSON report and
Expand Down Expand Up @@ -362,6 +366,21 @@ type PlanStep struct {
ExpectNotContains []string `yaml:"expect_not_contains,omitempty"`
}

// GraphStep defines a "graph" action: a read-only `cascade graph` run that emits
// diagram source on stdout without touching the repo. Format selects the syntax
// (mermaid by default, or d2 for the branded crucible source); Granularity
// selects the projection (jobs, stages, env, cross-repo). ExpectExit is the exit
// code the command must return (0 on success). ExpectContains and
// ExpectNotContains are substrings the emitted diagram must and must not carry,
// so a scenario asserts the deterministic source shape end to end.
type GraphStep struct {
Format string `yaml:"format,omitempty"`
Granularity string `yaml:"granularity,omitempty"`
ExpectExit int `yaml:"expect_exit"`
ExpectContains []string `yaml:"expect_contains,omitempty"`
ExpectNotContains []string `yaml:"expect_not_contains,omitempty"`
}

// ReconcileStep defines a "reconcile" action: `cascade reconcile` run against
// the synced repo to prove a governed pin bump landing in an already-generated
// workflow file (simulating an external change such as a merged Dependabot
Expand Down
56 changes: 56 additions & 0 deletions e2e/harness/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,10 @@ func (r *Runner) ValidateScenario(scenario *MultiStepScenario) error {
if step.Plan.MutatePath != "" && step.Plan.MutateAppend == "" {
return fmt.Errorf("step %d (%s): plan mutate_path requires mutate_append", i, step.Name)
}
case "graph":
if step.Graph == nil {
return fmt.Errorf("step %d (%s): graph action requires graph config", i, step.Name)
}
case "consistency":
if step.Consistency == nil {
return fmt.Errorf("step %d (%s): consistency action requires consistency config", i, step.Name)
Expand Down Expand Up @@ -399,6 +403,8 @@ func (r *Runner) executeStep(ctx context.Context, step *Step, config Config) err
return r.executeVerify(ctx, step.Verify)
case "plan":
return r.executePlan(ctx, step.Plan)
case "graph":
return r.executeGraph(ctx, step.Graph)
case "consistency":
return r.executeConsistency(ctx, step.Consistency)
case "reconcile":
Expand Down Expand Up @@ -580,6 +586,56 @@ func (r *Runner) executePlan(ctx context.Context, step *PlanStep) error {
return nil
}

// executeGraph runs `cascade graph` in the synced repo and asserts the emitted
// diagram source. It is fully read-only: graph never writes a file, runs git, or
// mutates the repo, so the step syncs the repo, runs the command with the step's
// format and granularity, checks the exit code, and asserts the deterministic
// stdout carries every ExpectContains substring and none of ExpectNotContains.
func (r *Runner) executeGraph(ctx context.Context, step *GraphStep) error {
if r.harness == nil || r.harness.act == nil {
r.t.Logf(" Would run cascade graph (expect exit %d, no harness)", step.ExpectExit)
return nil
}

if err := r.harness.SyncRepoToActContainer(ctx); err != nil {
return fmt.Errorf("graph: failed to sync repo: %w", err)
}

cmd := "cd /tmp/repo && /usr/local/bin/cascade graph"
if step.Format != "" {
cmd += " --format " + shellQuote(step.Format)
}
if step.Granularity != "" {
cmd += " --granularity " + shellQuote(step.Granularity)
}
graphCmd := []string{"bash", "-c", cmd}
exitCode, reader, err := r.harness.act.Container().Exec(ctx, graphCmd)
if err != nil {
return fmt.Errorf("graph: exec failed: %w", err)
}
var out bytes.Buffer
if reader != nil {
_, _ = io.Copy(&out, reader)
}
output := out.String()
r.t.Logf(" Graph: exit=%d (expected %d)", exitCode, step.ExpectExit)

if exitCode != step.ExpectExit {
return fmt.Errorf("graph: expected exit %d, got %d: %s", step.ExpectExit, exitCode, output)
}
for _, want := range step.ExpectContains {
if !strings.Contains(output, want) {
return fmt.Errorf("graph: stdout missing expected substring %q: %s", want, output)
}
}
for _, unwant := range step.ExpectNotContains {
if strings.Contains(output, unwant) {
return fmt.Errorf("graph: stdout contains unexpected substring %q: %s", unwant, output)
}
}
return nil
}

// executeReconcile runs `cascade reconcile` in the synced repo to prove a
// governed pin bump landing in one already-generated workflow file (simulating
// an external change such as a merged Dependabot bump) is adopted into the
Expand Down
64 changes: 64 additions & 0 deletions e2e/scenarios/58-graph-d2-render.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
name: "Graph D2 Render"
description: |
Exercises the read-only `cascade graph --format d2` command end to end. After a
feature commit against a two-environment manifest, graph renders the branded
crucible-style D2 source on stdout: it selects the ELK layout engine, paints the
dark canvas, and emits the job nodes and their dependency edge with the crucible
node and edge classes. The default Mermaid format still renders on the same
manifest, and graph never writes a file or mutates the repo, so both runs are
purely informational.

config:
trunk_branch: main
environments: [dev, prod]
builds:
- name: app
workflow: build.yaml
triggers: ["src/**"]
deploys:
- name: app
workflow: deploy.yaml
triggers: ["src/**"]
depends_on: ["app"]

steps:
- name: "Initial feature commit"
action: commit
commit:
message: "feat: add app feature"
files:
src/app.go: |
package main

func main() {}

- name: "Render the job graph as crucible-style D2"
action: graph
graph:
format: d2
granularity: jobs
expect_exit: 0
expect_contains:
- "layout-engine: elk"
- 'style.fill: "#151b20"'
- "deploy_app -> build_app: {class: flow}"
expect_not_contains:
- "flowchart TD"

- name: "Render the coarse stages as D2"
action: graph
graph:
format: d2
granularity: stages
expect_exit: 0
expect_contains:
- "layout-engine: elk"
- "{class: state}"

- name: "Default Mermaid format still renders"
action: graph
graph:
granularity: jobs
expect_exit: 0
expect_contains:
- "flowchart TD"
10 changes: 6 additions & 4 deletions internal/graph/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,13 @@ func NewCommand() *cobra.Command {

cmd := &cobra.Command{
Use: "graph",
Short: "Render the generated pipeline as a Mermaid diagram",
Short: "Render the generated pipeline as a Mermaid or D2 diagram",
Long: `Render the manifest's generated pipeline as a diagram on stdout.

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.
graph loads the manifest and emits diagram source. The --format flag chooses the
syntax: mermaid (the default) renders natively in GitHub Markdown, so pipe it
into a file or paste it into a README or pull request; d2 emits cascade's branded
crucible-style D2 source a sidecar renderer turns into SVG or PNG.
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
Expand All @@ -43,7 +45,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 values: jobs, stages, env, cross-repo")
cmd.Flags().StringVar(&o.Format, "format", formatMermaid, "Diagram output format; supported value: mermaid")
cmd.Flags().StringVar(&o.Format, "format", formatMermaid, "Diagram output format; supported values: mermaid, d2")
cmd.Flags().StringVar(&o.Theme, "theme", defaultThemeName, "Diagram theme: cascade, bland, or a path to a JSON theme file")

return cmd
Expand Down
31 changes: 24 additions & 7 deletions internal/graph/graph.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,14 @@ const (
GranularityCrossRepo Granularity = "cross-repo"
)

// formatMermaid is the only diagram format cascade graph emits today. The flag
// validates against it so a future format is additive rather than a silent
// behavior change.
const formatMermaid = "mermaid"
// Diagram formats cascade graph emits. mermaid is the GitHub-native default that
// renders in Markdown; d2 emits cascade's branded crucible-style D2 source a
// sidecar renderer turns into SVG or PNG. The flag validates against this set so
// a future format is additive rather than a silent behavior change.
const (
formatMermaid = "mermaid"
formatD2 = "d2"
)

// defaultThemeName is the theme applied when no theme flag is set. It mirrors
// the visualize package default (the branded cascade palette) so a manifest
Expand Down Expand Up @@ -72,8 +76,11 @@ func Run(o Options, stdout io.Writer) error {
if format == "" {
format = formatMermaid
}
if format != formatMermaid {
return fmt.Errorf("unsupported format %q: only %q is supported", format, formatMermaid)
switch format {
case formatMermaid, formatD2:
// Each format maps to a supported emitter.
default:
return fmt.Errorf("unsupported format %q: supported values are %q and %q", format, formatMermaid, formatD2)
}

granularity := o.Granularity
Expand Down Expand Up @@ -107,7 +114,7 @@ func Run(o Options, stdout io.Writer) error {
return err
}

diagram, err := visualize.NewMermaidEmitter().Emit(vm, theme)
diagram, err := selectEmitter(format).Emit(vm, theme)
if err != nil {
return fmt.Errorf("rendering %s: %w", format, err)
}
Expand All @@ -124,6 +131,16 @@ func Run(o Options, stdout io.Writer) error {
return nil
}

// selectEmitter returns the Emitter for a validated format. Run guards the
// format before calling, so the mermaid emitter is a safe default for any value
// that is not the D2 format.
func selectEmitter(format string) visualize.Emitter {
if format == formatD2 {
return visualize.NewD2Emitter()
}
return visualize.NewMermaidEmitter()
}

// resolveTheme turns the --theme value into a concrete theme. An empty value or
// a built-in name (default, cascade, bland) selects a built-in palette; any
// other value is treated as a path to a user-supplied JSON theme file, loaded
Expand Down
55 changes: 55 additions & 0 deletions internal/graph/graph_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,64 @@ func TestRun_UnknownFormat_Errors(t *testing.T) {
err := Run(o, &out)
require.Error(t, err)
require.Contains(t, err.Error(), "mermaid")
require.Contains(t, err.Error(), "d2")
require.Empty(t, out.String())
}

func TestRun_D2Format_EmitsCrucibleSource(t *testing.T) {
path := writeManifest(t)
o := baseOptions(path)
o.Format = formatD2

var out bytes.Buffer
require.NoError(t, Run(o, &out))

got := out.String()
// The D2 emitter selects ELK, defines the crucible class catalog, and paints
// the dark canvas, none of which the Mermaid emitter produces.
require.Contains(t, got, "layout-engine: elk")
require.Contains(t, got, `style.fill: "#151b20"`)
require.NotContains(t, got, "flowchart TD")
// The job nodes and a hard dependency edge carry their crucible classes.
require.Contains(t, got, "deploy_app: ")
require.Contains(t, got, "deploy_app -> build_app: {class: flow}")
}

func TestRun_D2Format_CrossRepoLanes(t *testing.T) {
path := writeCrossRepoManifest(t)
o := baseOptions(path)
o.Format = formatD2
o.Granularity = string(GranularityCrossRepo)

var out bytes.Buffer
require.NoError(t, Run(o, &out))

got := out.String()
// Each repo lane renders as a D2 container and the primary coordinates the
// external satellite across lanes.
require.Contains(t, got, `primary: "primary" {`)
require.Contains(t, got, `repo_org_cdk_infra: "org/cdk-infra" {`)
require.Contains(t, got, `{class: external}`)
}

func TestRun_D2Format_JSONReportsFormat(t *testing.T) {
path := writeManifest(t)
o := baseOptions(path)
o.Format = formatD2
o.JSON = true

var out bytes.Buffer
require.NoError(t, Run(o, &out))

var payload struct {
Format string `json:"format"`
Diagram string `json:"diagram"`
}
require.NoError(t, json.Unmarshal(out.Bytes(), &payload))
require.Equal(t, formatD2, payload.Format)
require.Contains(t, payload.Diagram, "layout-engine: elk")
}

func TestRun_StagesGranularity_EmitsFlowchart(t *testing.T) {
path := writeManifest(t)
o := baseOptions(path)
Expand Down
Loading