From 72ed2bb8b40ff08731451224af40cc0eeab986f3 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Sat, 11 Jul 2026 14:30:26 -0400 Subject: [PATCH 1/2] feat(visualize): add a D2 emitter behind the graph emitter seam Signed-off-by: Joshua Temple --- e2e/harness/multistep.go | 19 + e2e/harness/runner.go | 56 +++ e2e/scenarios/58-graph-d2-render.yaml | 64 ++++ internal/graph/command.go | 10 +- internal/graph/graph.go | 31 +- internal/graph/graph_test.go | 55 +++ internal/visualize/d2.go | 380 ++++++++++++++++++++ internal/visualize/d2_test.go | 156 ++++++++ internal/visualize/testdata/d2_crossrepo.d2 | 160 +++++++++ internal/visualize/testdata/d2_env.d2 | 145 ++++++++ internal/visualize/testdata/d2_jobs.d2 | 147 ++++++++ internal/visualize/testdata/d2_stages.d2 | 140 ++++++++ 12 files changed, 1352 insertions(+), 11 deletions(-) create mode 100644 e2e/scenarios/58-graph-d2-render.yaml create mode 100644 internal/visualize/d2.go create mode 100644 internal/visualize/d2_test.go create mode 100644 internal/visualize/testdata/d2_crossrepo.d2 create mode 100644 internal/visualize/testdata/d2_env.d2 create mode 100644 internal/visualize/testdata/d2_jobs.d2 create mode 100644 internal/visualize/testdata/d2_stages.d2 diff --git a/e2e/harness/multistep.go b/e2e/harness/multistep.go index cfa3f0a2..0cf09d0d 100644 --- a/e2e/harness/multistep.go +++ b/e2e/harness/multistep.go @@ -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 @@ -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 diff --git a/e2e/harness/runner.go b/e2e/harness/runner.go index 57eac505..5f18e7ac 100644 --- a/e2e/harness/runner.go +++ b/e2e/harness/runner.go @@ -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) @@ -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": @@ -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 diff --git a/e2e/scenarios/58-graph-d2-render.yaml b/e2e/scenarios/58-graph-d2-render.yaml new file mode 100644 index 00000000..ab9bf50d --- /dev/null +++ b/e2e/scenarios/58-graph-d2-render.yaml @@ -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" diff --git a/internal/graph/command.go b/internal/graph/command.go index 55e50ff7..fbc2d6d4 100644 --- a/internal/graph/command.go +++ b/internal/graph/command.go @@ -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 @@ -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 diff --git a/internal/graph/graph.go b/internal/graph/graph.go index ecc04bb9..c3278f47 100644 --- a/internal/graph/graph.go +++ b/internal/graph/graph.go @@ -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 @@ -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 @@ -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) } @@ -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 diff --git a/internal/graph/graph_test.go b/internal/graph/graph_test.go index f8bbc499..93308119 100644 --- a/internal/graph/graph_test.go +++ b/internal/graph/graph_test.go @@ -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) diff --git a/internal/visualize/d2.go b/internal/visualize/d2.go new file mode 100644 index 00000000..6834803c --- /dev/null +++ b/internal/visualize/d2.go @@ -0,0 +1,380 @@ +package visualize + +import ( + "fmt" + "strings" +) + +// D2Emitter renders a ViewModel as D2 source in cascade's branded "crucible" +// visual style: a dark teal canvas, 3d state slabs, hexagon deploy invokes, +// diamond guards, rounded dashed environment plates, and small entry / double +// terminal markers. It is a second concrete Emitter behind the same seam as the +// Mermaid emitter, selected with `cascade graph --format d2`, and it is the +// source text a sidecar renderer turns into SVG or PNG. +// +// Output is deterministic: it ranges the model's ordered slices and never +// iterates a map, so the same model always yields byte-identical source. +// +// The palette is the branded crucible style rather than the passed Theme's +// colors: the Mermaid emitter is the theme-driven, GitHub-native path, while D2 +// is the high-fidelity branded renderer whose look is fixed. The theme's Name is +// recorded in a leading comment for traceability, so a caller can still tell +// which theme was requested without the colors changing. +type D2Emitter struct{} + +// NewD2Emitter returns a ready D2Emitter. The type is stateless, so the +// constructor exists only to give callers a stable construction point that +// mirrors NewMermaidEmitter. +func NewD2Emitter() *D2Emitter { return &D2Emitter{} } + +// compile-time assertion that D2Emitter satisfies the Emitter seam. +var _ Emitter = (*D2Emitter)(nil) + +// d2Header is the fixed preamble every diagram carries: the ELK layout selector +// (the crucible layout engine), the class catalog that defines the crucible +// shape and color vocabulary, and the dark canvas fill. It is emitted verbatim +// so the class definitions stay in one place and the output is stable. +const d2Header = `vars: { + d2-config: { + layout-engine: elk + } +} + +classes: { + state: { + shape: rectangle + style: { + fill: "#222a30" + stroke: "#36d0c4" + font-color: "#f4f7f8" + stroke-width: 2 + 3d: true + } + } + invoke: { + shape: hexagon + style: { + fill: "#06343a" + stroke: "#36d0c4" + font-color: "#8af0e8" + stroke-width: 2 + } + } + region: { + shape: rectangle + style: { + fill: "#1a2228" + stroke: "#1f9b92" + font-color: "#8af0e8" + stroke-width: 2 + stroke-dash: 3 + border-radius: 8 + } + } + lane: { + style: { + fill: "#1a2228" + stroke: "#1f9b92" + font-color: "#8af0e8" + stroke-dash: 3 + border-radius: 8 + } + } + repo: { + shape: rectangle + style: { + fill: "#333b42" + stroke: "#57606a" + font-color: "#e2e8ec" + border-radius: 8 + } + } + hotfix: { + shape: rectangle + style: { + fill: "#82071e" + stroke: "#cf222e" + font-color: "#f4f7f8" + stroke-width: 2 + 3d: true + } + } + init: { + shape: circle + width: 26 + style: { + fill: "#36d0c4" + stroke: "#8af0e8" + font-color: "#151b20" + } + } + final: { + shape: circle + style: { + fill: "#151b20" + stroke: "#36d0c4" + font-color: "#8af0e8" + stroke-width: 3 + multiple: true + } + } + flow: { + style: { + stroke: "#36d0c4" + font-color: "#e2e8ec" + stroke-width: 2 + } + } + promote: { + style: { + stroke: "#36d0c4" + font-color: "#8af0e8" + stroke-width: 3 + bold: true + } + } + optional: { + style: { + stroke: "#57606a" + font-color: "#8a929c" + stroke-dash: 4 + } + } + external: { + style: { + stroke: "#36d0c4" + font-color: "#8af0e8" + stroke-width: 3 + bold: true + } + } + notify: { + style: { + stroke: "#57606a" + font-color: "#8a929c" + stroke-dash: 4 + } + } + hotfix_edge: { + style: { + stroke: "#cf222e" + font-color: "#cf222e" + stroke-dash: 4 + } + } +} + +style.fill: "#151b20" +` + +// Emit renders vm as D2 source. A start/end-bracketed state model (the env +// projection) and a directed-graph model (jobs, stages, cross-repo) share one +// emit path because D2 expresses both as nodes and connections; the node and +// edge classes carry the visual distinction. When the model declares groups (the +// cross-repo projection's per-repo lanes) each group is rendered as a D2 +// container and edge endpoints are qualified with their container path. theme is +// recorded in a leading comment but does not recolor the branded palette. A +// title option, if set, is emitted as a leading comment. +func (D2Emitter) Emit(vm ViewModel, theme Theme, opts ...Option) (string, error) { + o := applyOptions(opts) + + var b strings.Builder + + if o.Title != "" { + fmt.Fprintf(&b, "# %s\n", d2Comment(o.Title)) + } + if theme.Name != "" { + fmt.Fprintf(&b, "# theme: %s\n", d2Comment(theme.Name)) + } + + b.WriteString(d2Header) + b.WriteString("\n") + + // container maps each grouped node id to the group id that holds it, so an + // edge endpoint can be qualified with its container path. An ungrouped node + // (or any node when the model has no groups) resolves to a bare id. + container := make(map[string]string, len(vm.Nodes)) + for _, g := range vm.Groups { + for _, id := range g.NodeIDs { + container[id] = g.ID + } + } + + byID := make(map[string]Node, len(vm.Nodes)) + for _, n := range vm.Nodes { + byID[n.ID] = n + } + + if err := writeD2Nodes(&b, vm, byID, container); err != nil { + return "", err + } + if err := writeD2Edges(&b, vm, container); err != nil { + return "", err + } + + return b.String(), nil +} + +// writeD2Nodes declares the model's nodes. Grouped nodes are wrapped in a D2 +// container per lane (in group order, then member order) so the cross-repo +// projection reads a lane per repo; any ungrouped node is declared flat after +// the lanes. Without groups every node is declared flat in model order. +func writeD2Nodes(b *strings.Builder, vm ViewModel, byID map[string]Node, container map[string]string) error { + if len(vm.Groups) == 0 { + for _, n := range vm.Nodes { + writeD2Node(b, n, "") + } + return nil + } + + for _, g := range vm.Groups { + fmt.Fprintf(b, "%s: %s {\n", d2ID(g.ID), d2Label(g.Label)) + b.WriteString(" class: lane\n") + for _, id := range g.NodeIDs { + n, ok := byID[id] + if !ok { + return fmt.Errorf("visualize: d2: group %q references unknown node %q", g.ID, id) + } + writeD2Node(b, n, " ") + } + b.WriteString("}\n") + } + + for _, n := range vm.Nodes { + if _, grouped := container[n.ID]; !grouped { + writeD2Node(b, n, "") + } + } + return nil +} + +// writeD2Node declares one node with the class its kind selects, indented by +// indent (deeper inside a container). The label is quoted so a display name with +// spaces or punctuation cannot break the declaration. +func writeD2Node(b *strings.Builder, n Node, indent string) { + fmt.Fprintf(b, "%s%s: %s {class: %s}\n", indent, d2ID(n.ID), d2Label(n.Label), nodeClass(n.Kind)) +} + +// writeD2Edges declares the model's connections in model order. Each endpoint is +// resolved to its container-qualified path so a cross-repo edge crosses lanes +// correctly, and the edge's kind selects a class so promotion, optional, +// divergence, external, and notify edges read distinctly. A labeled edge carries +// its caption as a quoted string. +func writeD2Edges(b *strings.Builder, vm ViewModel, container map[string]string) error { + for _, e := range vm.Edges { + class, ok := edgeClass(e.Kind) + if !ok { + return fmt.Errorf("visualize: d2: unknown edge kind %q", e.Kind) + } + from := d2Path(e.From, container) + to := d2Path(e.To, container) + if e.Label != "" { + fmt.Fprintf(b, "%s -> %s: %s {class: %s}\n", from, to, d2Label(e.Label), class) + } else { + fmt.Fprintf(b, "%s -> %s: {class: %s}\n", from, to, class) + } + } + return nil +} + +// nodeClass maps a NodeKind to its crucible class name. validate, build, and +// stage share the state slab; deploy is a hexagon invoke; env is a rounded +// dashed plate; hotfix is the red slab; start and end are the entry and terminal +// markers; a repo is an opaque external-repo node. An unknown kind falls back to +// the state slab so an unexpected node still renders rather than breaking the +// diagram. +func nodeClass(kind NodeKind) string { + switch kind { + case NodeDeploy: + return "invoke" + case NodeEnv: + return "region" + case NodeHotfix: + return "hotfix" + case NodeStart: + return "init" + case NodeEnd: + return "final" + case NodeRepo: + return "repo" + case NodeValidate, NodeBuild, NodeStage: + return "state" + default: + return "state" + } +} + +// edgeClass maps an EdgeKind to its crucible edge class. Hard, stage, and +// bookend transitions are the plain flow; promote is the bold promotion line; +// optional and notify are thin dashed lines; external is the bold cross-repo +// line; diverge and rejoin are the dashed red hotfix line. The bool is false for +// an unknown kind so the caller can surface a clear error rather than emit an +// unclassed edge. +func edgeClass(kind EdgeKind) (string, bool) { + switch kind { + case EdgeHard, EdgeStage, EdgeTransition: + return "flow", true + case EdgePromote: + return "promote", true + case EdgeOptional: + return "optional", true + case EdgeExternal: + return "external", true + case EdgeNotify: + return "notify", true + case EdgeDiverge, EdgeRejoin: + return "hotfix_edge", true + default: + return "", false + } +} + +// d2Path resolves a node id to its container-qualified reference: a grouped node +// renders as "container.id" so an edge lands inside the right lane, while an +// ungrouped node renders as a bare id. +func d2Path(id string, container map[string]string) string { + if group, ok := container[id]; ok { + return d2ID(group) + "." + d2ID(id) + } + return d2ID(id) +} + +// d2ID sanitizes an identifier into a D2 key: every rune that is not a letter, +// digit, or underscore is folded to an underscore so a hyphenated job id +// (build-app) or a slugged group id stays a single valid key. The human-facing +// name is carried on the label, so only the identity token is folded. +func d2ID(id string) string { + var b strings.Builder + b.Grow(len(id)) + for _, r := range id { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_': + b.WriteRune(r) + default: + b.WriteByte('_') + } + } + return b.String() +} + +// d2Label renders a display name as a quoted D2 string. An embedded double quote +// is folded to a single quote and a newline to a space so the label stays on one +// line and cannot terminate the quoted string early. An empty label is emitted +// as an empty quoted string, which D2 renders as an unlabeled shape (used by the +// entry and terminal markers). +func d2Label(label string) string { + r := strings.NewReplacer( + `"`, "'", + "\n", " ", + "\r", " ", + ) + return `"` + r.Replace(label) + `"` +} + +// d2Comment sanitizes a string for a single-line D2 comment, folding any newline +// to a space so the comment cannot spill onto a second line and change the +// parse. +func d2Comment(s string) string { + r := strings.NewReplacer("\n", " ", "\r", " ") + return r.Replace(s) +} diff --git a/internal/visualize/d2_test.go b/internal/visualize/d2_test.go new file mode 100644 index 00000000..b09905c3 --- /dev/null +++ b/internal/visualize/d2_test.go @@ -0,0 +1,156 @@ +package visualize + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// d2Golden emits vm through the D2 emitter and compares the result against the +// named golden file, rewriting it when -update is set. It centralizes the +// golden-file mechanics so each granularity's case stays a one-liner, mirroring +// the Mermaid golden test. +func d2Golden(t *testing.T, name string, vm ViewModel, opts ...Option) { + t.Helper() + + got, err := NewD2Emitter().Emit(vm, DefaultTheme, opts...) + if err != nil { + t.Fatalf("Emit: %v", err) + } + + golden := filepath.Join("testdata", name) + 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 D2 does not match %s.\n--- got ---\n%s\n--- want ---\n%s", name, got, want) + } +} + +func TestD2Emitter_GoldenJobs(t *testing.T) { + d2Golden(t, "d2_jobs.d2", buildVM(t, representativeConfig()), WithTitle("pipeline")) +} + +func TestD2Emitter_GoldenStages(t *testing.T) { + vm, err := BuildStagesViewModel(representativeConfig()) + if err != nil { + t.Fatalf("BuildStagesViewModel: %v", err) + } + d2Golden(t, "d2_stages.d2", vm) +} + +func TestD2Emitter_GoldenEnv(t *testing.T) { + vm, err := BuildEnvViewModel(envConfig(), divergedState()) + if err != nil { + t.Fatalf("BuildEnvViewModel: %v", err) + } + d2Golden(t, "d2_env.d2", vm) +} + +func TestD2Emitter_GoldenCrossRepo(t *testing.T) { + vm, err := BuildCrossRepoViewModel(primaryWithDependents()) + if err != nil { + t.Fatalf("BuildCrossRepoViewModel: %v", err) + } + d2Golden(t, "d2_crossrepo.d2", vm) +} + +func TestD2Emitter_Deterministic(t *testing.T) { + cfg := representativeConfig() + + first, err := NewD2Emitter().Emit(buildVM(t, cfg), DefaultTheme) + if err != nil { + t.Fatalf("first emit: %v", err) + } + for i := 0; i < 10; i++ { + next, err := NewD2Emitter().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) + } + } +} + +// d2NodeDeclRe captures a node or lane declaration's key (the token before the +// colon), at any indent, for both the "id: label {..}" node form and the +// "id: label {" container-opening form. +var d2NodeDeclRe = regexp.MustCompile(`(?m)^\s*(\w+): .*\{`) + +// d2EdgeRe captures both endpoints of a connection, allowing a container- +// qualified "lane.node" reference on either side. +var d2EdgeRe = regexp.MustCompile(`(?m)^([\w.]+) -> ([\w.]+):`) + +func TestD2Emitter_StructurallyValid(t *testing.T) { + vm, err := BuildCrossRepoViewModel(primaryWithDependents()) + if err != nil { + t.Fatalf("BuildCrossRepoViewModel: %v", err) + } + out, err := NewD2Emitter().Emit(vm, DefaultTheme) + if err != nil { + t.Fatalf("Emit: %v", err) + } + + if !strings.Contains(out, "layout-engine: elk") { + t.Fatalf("missing elk layout selector in:\n%s", out) + } + if !strings.Contains(out, `style.fill: "#151b20"`) { + t.Fatalf("missing crucible canvas fill in:\n%s", out) + } + + // Collect every declared key. A container-qualified edge endpoint resolves + // when its trailing segment is a declared key, so the leaf token is what the + // endpoint check needs. + declared := map[string]bool{} + for _, m := range d2NodeDeclRe.FindAllStringSubmatch(out, -1) { + declared[m[1]] = true + } + if len(declared) == 0 { + t.Fatalf("no node declarations found in:\n%s", out) + } + + edges := d2EdgeRe.FindAllStringSubmatch(out, -1) + if len(edges) == 0 { + t.Fatalf("no edges found in:\n%s", out) + } + for _, e := range edges { + from := leafSegment(e[1]) + to := leafSegment(e[2]) + if !declared[from] { + t.Errorf("edge references undeclared source %q (in %q)", from, e[1]) + } + if !declared[to] { + t.Errorf("edge references undeclared target %q (in %q)", to, e[2]) + } + } +} + +// leafSegment returns the final dotted segment of a D2 reference, so a +// container-qualified "lane.node" endpoint is checked against its declared leaf +// key. +func leafSegment(ref string) string { + if i := strings.LastIndex(ref, "."); i >= 0 { + return ref[i+1:] + } + return ref +} + +func TestD2Emitter_UnknownEdgeKindErrors(t *testing.T) { + vm := ViewModel{ + Kind: DiagramFlowchart, + Nodes: []Node{{ID: "a", Label: "A", Kind: NodeBuild}, {ID: "b", Label: "B", Kind: NodeBuild}}, + Edges: []Edge{{From: "a", To: "b", Kind: EdgeKind("bogus")}}, + } + if _, err := NewD2Emitter().Emit(vm, DefaultTheme); err == nil { + t.Fatal("expected an error for an unknown edge kind, got nil") + } +} diff --git a/internal/visualize/testdata/d2_crossrepo.d2 b/internal/visualize/testdata/d2_crossrepo.d2 new file mode 100644 index 00000000..77c7e2dc --- /dev/null +++ b/internal/visualize/testdata/d2_crossrepo.d2 @@ -0,0 +1,160 @@ +# theme: cascade +vars: { + d2-config: { + layout-engine: elk + } +} + +classes: { + state: { + shape: rectangle + style: { + fill: "#222a30" + stroke: "#36d0c4" + font-color: "#f4f7f8" + stroke-width: 2 + 3d: true + } + } + invoke: { + shape: hexagon + style: { + fill: "#06343a" + stroke: "#36d0c4" + font-color: "#8af0e8" + stroke-width: 2 + } + } + region: { + shape: rectangle + style: { + fill: "#1a2228" + stroke: "#1f9b92" + font-color: "#8af0e8" + stroke-width: 2 + stroke-dash: 3 + border-radius: 8 + } + } + lane: { + style: { + fill: "#1a2228" + stroke: "#1f9b92" + font-color: "#8af0e8" + stroke-dash: 3 + border-radius: 8 + } + } + repo: { + shape: rectangle + style: { + fill: "#333b42" + stroke: "#57606a" + font-color: "#e2e8ec" + border-radius: 8 + } + } + hotfix: { + shape: rectangle + style: { + fill: "#82071e" + stroke: "#cf222e" + font-color: "#f4f7f8" + stroke-width: 2 + 3d: true + } + } + init: { + shape: circle + width: 26 + style: { + fill: "#36d0c4" + stroke: "#8af0e8" + font-color: "#151b20" + } + } + final: { + shape: circle + style: { + fill: "#151b20" + stroke: "#36d0c4" + font-color: "#8af0e8" + stroke-width: 3 + multiple: true + } + } + flow: { + style: { + stroke: "#36d0c4" + font-color: "#e2e8ec" + stroke-width: 2 + } + } + promote: { + style: { + stroke: "#36d0c4" + font-color: "#8af0e8" + stroke-width: 3 + bold: true + } + } + optional: { + style: { + stroke: "#57606a" + font-color: "#8a929c" + stroke-dash: 4 + } + } + external: { + style: { + stroke: "#36d0c4" + font-color: "#8af0e8" + stroke-width: 3 + bold: true + } + } + notify: { + style: { + stroke: "#57606a" + font-color: "#8a929c" + stroke-dash: 4 + } + } + hotfix_edge: { + style: { + stroke: "#cf222e" + font-color: "#cf222e" + stroke-dash: 4 + } + } +} + +style.fill: "#151b20" + +primary: "primary" { + class: lane + trunk: "Trunk" {class: state} + build: "Build" {class: state} + deploy: "Deploy" {class: state} + promote: "Promote" {class: state} + release: "Release" {class: state} +} +repo_org_cdk_infra: "org/cdk-infra" { + class: lane + ext_org_cdk_infra_cdk: "cdk" {class: invoke} +} +repo_org_web_edge: "org/web-edge" { + class: lane + ext_org_web_edge_edge: "edge" {class: invoke} +} +repo_org_platform: "org/platform" { + class: lane + notify_org_platform: "org/platform" {class: repo} +} +primary.trunk -> primary.build: {class: flow} +primary.build -> primary.deploy: {class: flow} +primary.deploy -> primary.promote: {class: flow} +primary.promote -> primary.release: {class: flow} +primary.release -> repo_org_cdk_infra.ext_org_cdk_infra_cdk: "cdk" {class: external} +primary.release -> repo_org_web_edge.ext_org_web_edge_edge: "edge" {class: external} +primary.release -> repo_org_platform.notify_org_platform: "notify" {class: notify} diff --git a/internal/visualize/testdata/d2_env.d2 b/internal/visualize/testdata/d2_env.d2 new file mode 100644 index 00000000..ea3ae61a --- /dev/null +++ b/internal/visualize/testdata/d2_env.d2 @@ -0,0 +1,145 @@ +# theme: cascade +vars: { + d2-config: { + layout-engine: elk + } +} + +classes: { + state: { + shape: rectangle + style: { + fill: "#222a30" + stroke: "#36d0c4" + font-color: "#f4f7f8" + stroke-width: 2 + 3d: true + } + } + invoke: { + shape: hexagon + style: { + fill: "#06343a" + stroke: "#36d0c4" + font-color: "#8af0e8" + stroke-width: 2 + } + } + region: { + shape: rectangle + style: { + fill: "#1a2228" + stroke: "#1f9b92" + font-color: "#8af0e8" + stroke-width: 2 + stroke-dash: 3 + border-radius: 8 + } + } + lane: { + style: { + fill: "#1a2228" + stroke: "#1f9b92" + font-color: "#8af0e8" + stroke-dash: 3 + border-radius: 8 + } + } + repo: { + shape: rectangle + style: { + fill: "#333b42" + stroke: "#57606a" + font-color: "#e2e8ec" + border-radius: 8 + } + } + hotfix: { + shape: rectangle + style: { + fill: "#82071e" + stroke: "#cf222e" + font-color: "#f4f7f8" + stroke-width: 2 + 3d: true + } + } + init: { + shape: circle + width: 26 + style: { + fill: "#36d0c4" + stroke: "#8af0e8" + font-color: "#151b20" + } + } + final: { + shape: circle + style: { + fill: "#151b20" + stroke: "#36d0c4" + font-color: "#8af0e8" + stroke-width: 3 + multiple: true + } + } + flow: { + style: { + stroke: "#36d0c4" + font-color: "#e2e8ec" + stroke-width: 2 + } + } + promote: { + style: { + stroke: "#36d0c4" + font-color: "#8af0e8" + stroke-width: 3 + bold: true + } + } + optional: { + style: { + stroke: "#57606a" + font-color: "#8a929c" + stroke-dash: 4 + } + } + external: { + style: { + stroke: "#36d0c4" + font-color: "#8af0e8" + stroke-width: 3 + bold: true + } + } + notify: { + style: { + stroke: "#57606a" + font-color: "#8a929c" + stroke-dash: 4 + } + } + hotfix_edge: { + style: { + stroke: "#cf222e" + font-color: "#cf222e" + stroke-dash: 4 + } + } +} + +style.fill: "#151b20" + +__start__: "" {class: init} +__end__: "" {class: final} +dev: "dev" {class: region} +staging: "staging" {class: region} +prod: "prod" {class: region} +staging_hotfix: "hotfix/login" {class: hotfix} +__start__ -> dev: {class: flow} +dev -> staging: "promote" {class: promote} +staging -> prod: "promote" {class: promote} +prod -> __end__: {class: flow} +staging -> staging_hotfix: "diverge" {class: hotfix_edge} +staging_hotfix -> prod: "rejoin" {class: hotfix_edge} diff --git a/internal/visualize/testdata/d2_jobs.d2 b/internal/visualize/testdata/d2_jobs.d2 new file mode 100644 index 00000000..6de2dc7c --- /dev/null +++ b/internal/visualize/testdata/d2_jobs.d2 @@ -0,0 +1,147 @@ +# pipeline +# theme: cascade +vars: { + d2-config: { + layout-engine: elk + } +} + +classes: { + state: { + shape: rectangle + style: { + fill: "#222a30" + stroke: "#36d0c4" + font-color: "#f4f7f8" + stroke-width: 2 + 3d: true + } + } + invoke: { + shape: hexagon + style: { + fill: "#06343a" + stroke: "#36d0c4" + font-color: "#8af0e8" + stroke-width: 2 + } + } + region: { + shape: rectangle + style: { + fill: "#1a2228" + stroke: "#1f9b92" + font-color: "#8af0e8" + stroke-width: 2 + stroke-dash: 3 + border-radius: 8 + } + } + lane: { + style: { + fill: "#1a2228" + stroke: "#1f9b92" + font-color: "#8af0e8" + stroke-dash: 3 + border-radius: 8 + } + } + repo: { + shape: rectangle + style: { + fill: "#333b42" + stroke: "#57606a" + font-color: "#e2e8ec" + border-radius: 8 + } + } + hotfix: { + shape: rectangle + style: { + fill: "#82071e" + stroke: "#cf222e" + font-color: "#f4f7f8" + stroke-width: 2 + 3d: true + } + } + init: { + shape: circle + width: 26 + style: { + fill: "#36d0c4" + stroke: "#8af0e8" + font-color: "#151b20" + } + } + final: { + shape: circle + style: { + fill: "#151b20" + stroke: "#36d0c4" + font-color: "#8af0e8" + stroke-width: 3 + multiple: true + } + } + flow: { + style: { + stroke: "#36d0c4" + font-color: "#e2e8ec" + stroke-width: 2 + } + } + promote: { + style: { + stroke: "#36d0c4" + font-color: "#8af0e8" + stroke-width: 3 + bold: true + } + } + optional: { + style: { + stroke: "#57606a" + font-color: "#8a929c" + stroke-dash: 4 + } + } + external: { + style: { + stroke: "#36d0c4" + font-color: "#8af0e8" + stroke-width: 3 + bold: true + } + } + notify: { + style: { + stroke: "#57606a" + font-color: "#8a929c" + stroke-dash: 4 + } + } + hotfix_edge: { + style: { + stroke: "#cf222e" + font-color: "#cf222e" + stroke-dash: 4 + } + } +} + +style.fill: "#151b20" + +validate: "Validate (validate)" {class: state} +build_api: "Build (api)" {class: state} +build_web: "Build (web)" {class: state} +deploy_staging: "Deploy (staging)" {class: invoke} +deploy_prod: "Deploy (prod)" {class: invoke} +build_api -> validate: {class: flow} +build_web -> validate: {class: flow} +build_web -> build_api: {class: optional} +deploy_staging -> validate: {class: flow} +deploy_staging -> build_api: {class: flow} +deploy_staging -> build_web: {class: flow} +deploy_prod -> validate: {class: flow} +deploy_prod -> deploy_staging: {class: flow} diff --git a/internal/visualize/testdata/d2_stages.d2 b/internal/visualize/testdata/d2_stages.d2 new file mode 100644 index 00000000..a1919511 --- /dev/null +++ b/internal/visualize/testdata/d2_stages.d2 @@ -0,0 +1,140 @@ +# theme: cascade +vars: { + d2-config: { + layout-engine: elk + } +} + +classes: { + state: { + shape: rectangle + style: { + fill: "#222a30" + stroke: "#36d0c4" + font-color: "#f4f7f8" + stroke-width: 2 + 3d: true + } + } + invoke: { + shape: hexagon + style: { + fill: "#06343a" + stroke: "#36d0c4" + font-color: "#8af0e8" + stroke-width: 2 + } + } + region: { + shape: rectangle + style: { + fill: "#1a2228" + stroke: "#1f9b92" + font-color: "#8af0e8" + stroke-width: 2 + stroke-dash: 3 + border-radius: 8 + } + } + lane: { + style: { + fill: "#1a2228" + stroke: "#1f9b92" + font-color: "#8af0e8" + stroke-dash: 3 + border-radius: 8 + } + } + repo: { + shape: rectangle + style: { + fill: "#333b42" + stroke: "#57606a" + font-color: "#e2e8ec" + border-radius: 8 + } + } + hotfix: { + shape: rectangle + style: { + fill: "#82071e" + stroke: "#cf222e" + font-color: "#f4f7f8" + stroke-width: 2 + 3d: true + } + } + init: { + shape: circle + width: 26 + style: { + fill: "#36d0c4" + stroke: "#8af0e8" + font-color: "#151b20" + } + } + final: { + shape: circle + style: { + fill: "#151b20" + stroke: "#36d0c4" + font-color: "#8af0e8" + stroke-width: 3 + multiple: true + } + } + flow: { + style: { + stroke: "#36d0c4" + font-color: "#e2e8ec" + stroke-width: 2 + } + } + promote: { + style: { + stroke: "#36d0c4" + font-color: "#8af0e8" + stroke-width: 3 + bold: true + } + } + optional: { + style: { + stroke: "#57606a" + font-color: "#8a929c" + stroke-dash: 4 + } + } + external: { + style: { + stroke: "#36d0c4" + font-color: "#8af0e8" + stroke-width: 3 + bold: true + } + } + notify: { + style: { + stroke: "#57606a" + font-color: "#8a929c" + stroke-dash: 4 + } + } + hotfix_edge: { + style: { + stroke: "#cf222e" + font-color: "#cf222e" + stroke-dash: 4 + } + } +} + +style.fill: "#151b20" + +trunk: "Trunk" {class: state} +build: "Build" {class: state} +deploy: "Deploy" {class: state} +release: "Release" {class: state} +trunk -> build: {class: flow} +build -> deploy: {class: flow} +deploy -> release: {class: flow} From de8ff1d50e20bdbb20c501b8261ae132e3543aa7 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Sat, 11 Jul 2026 14:45:12 -0400 Subject: [PATCH 2/2] feat(visualize): default the env graph to a horizontal layout Signed-off-by: Joshua Temple --- internal/visualize/d2.go | 14 ++++++++++++++ internal/visualize/testdata/d2_env.d2 | 1 + 2 files changed, 15 insertions(+) diff --git a/internal/visualize/d2.go b/internal/visualize/d2.go index 6834803c..c1297e34 100644 --- a/internal/visualize/d2.go +++ b/internal/visualize/d2.go @@ -15,6 +15,10 @@ import ( // Output is deterministic: it ranges the model's ordered slices and never // iterates a map, so the same model always yields byte-identical source. // +// The env granularity (a DiagramState model) defaults to a horizontal, +// left-to-right layout, matching how a promotion pipeline is read. Every other +// granularity keeps D2's default top-down layout. +// // The palette is the branded crucible style rather than the passed Theme's // colors: the Mermaid emitter is the theme-driven, GitHub-native path, while D2 // is the high-fidelity branded renderer whose look is fixed. The theme's Name is @@ -167,6 +171,13 @@ classes: { style.fill: "#151b20" ` +// d2EnvDirection sets the env projection's layout to left-to-right. A promotion +// ladder (dev -> staging -> prod) reads as a horizontal pipeline; D2's own +// default (top-down) stacks a multi-environment ladder into a tall, narrow +// column that reads far worse. Only the env granularity (a DiagramState model) +// opts in; jobs, stages, and cross-repo keep D2's default direction. +const d2EnvDirection = "direction: right\n" + // Emit renders vm as D2 source. A start/end-bracketed state model (the env // projection) and a directed-graph model (jobs, stages, cross-repo) share one // emit path because D2 expresses both as nodes and connections; the node and @@ -188,6 +199,9 @@ func (D2Emitter) Emit(vm ViewModel, theme Theme, opts ...Option) (string, error) } b.WriteString(d2Header) + if vm.Kind == DiagramState { + b.WriteString(d2EnvDirection) + } b.WriteString("\n") // container maps each grouped node id to the group id that holds it, so an diff --git a/internal/visualize/testdata/d2_env.d2 b/internal/visualize/testdata/d2_env.d2 index ea3ae61a..793c6e0f 100644 --- a/internal/visualize/testdata/d2_env.d2 +++ b/internal/visualize/testdata/d2_env.d2 @@ -130,6 +130,7 @@ classes: { } style.fill: "#151b20" +direction: right __start__: "" {class: init} __end__: "" {class: final}