From b7296df85d91f2f3a87223c5fbc32a3c7480b1e5 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Wed, 17 Jun 2026 19:36:14 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat(cmd/crucible):=20machine=20visualizer?= =?UTF-8?q?=20=E2=80=94=20scoped,=20granular,=20forge-themed=20render?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Project the IR through a view-model at a chosen scope and detail granularity and render it as a themed SVG via the embedded D2 engine (pure Go, no Chromium, no external Graphviz). - Scope: -from/-to (path A→X), -from (reachable-from-A), or whole machine; -mode shortest|all|trace selects the path view. - Detail: -detail outline|guards|actions|lifecycle|full (default actions), a cumulative ladder refined by repeatable -show/-hide . Lifecycle detail (entry/exit/invoke) renders as in-node compartments. - Distinct shapes per kind (initial dot, atomic 3D steel box, invoke hexagon, history circle, final double-ring, composite/parallel containers); the active path is highlighted in ember over cold-steel context. - Theme: -theme file.json overlays the embedded "forge" default palette. - New internal packages: viewmodel (projection), query (path/scope BFS over the IR), render (D2 emit + in-process SVG + post-process). Replaces the previous WebAssembly Graphviz svg/png backend with D2 and removes that dependency. `render -format png` now errors with guidance to render -format svg and convert with resvg/rsvg-convert; dot/mermaid output is unchanged. --- README.md | 9 +- THIRD_PARTY_NOTICES.md | 78 +- cmd/crucible/CHANGELOG.md | 30 +- cmd/crucible/README.md | 87 ++- cmd/crucible/cmd.go | 264 ++++++- cmd/crucible/go.mod | 23 +- cmd/crucible/go.sum | 106 ++- cmd/crucible/internal/query/query.go | 295 ++++++++ cmd/crucible/internal/query/query_test.go | 264 +++++++ cmd/crucible/internal/render/emit.go | 475 ++++++++++++ cmd/crucible/internal/render/emit_test.go | 194 +++++ cmd/crucible/internal/render/render.go | 464 ++++++++++++ cmd/crucible/internal/render/render_test.go | 203 ++++++ .../render/testdata/atomic_onpath.d2.golden | 126 ++++ .../internal/render/testdata/final.d2.golden | 119 +++ .../render/testdata/lifecycle.d2.golden | 125 ++++ .../render/testdata/offpath.d2.golden | 122 ++++ .../testdata/parallel_regions.d2.golden | 133 ++++ .../render/testdata/special_chars.d2.golden | 122 ++++ cmd/crucible/internal/render/theme.go | 81 +++ cmd/crucible/internal/render/theme_test.go | 53 ++ cmd/crucible/internal/viewmodel/scope_test.go | 221 ++++++ cmd/crucible/internal/viewmodel/viewmodel.go | 677 ++++++++++++++++++ .../internal/viewmodel/viewmodel_test.go | 542 ++++++++++++++ cmd/crucible/render_image.go | 110 --- cmd/crucible/render_image_test.go | 130 ---- cmd/crucible/render_test.go | 204 ++++++ 27 files changed, 4881 insertions(+), 376 deletions(-) create mode 100644 cmd/crucible/internal/query/query.go create mode 100644 cmd/crucible/internal/query/query_test.go create mode 100644 cmd/crucible/internal/render/emit.go create mode 100644 cmd/crucible/internal/render/emit_test.go create mode 100644 cmd/crucible/internal/render/render.go create mode 100644 cmd/crucible/internal/render/render_test.go create mode 100644 cmd/crucible/internal/render/testdata/atomic_onpath.d2.golden create mode 100644 cmd/crucible/internal/render/testdata/final.d2.golden create mode 100644 cmd/crucible/internal/render/testdata/lifecycle.d2.golden create mode 100644 cmd/crucible/internal/render/testdata/offpath.d2.golden create mode 100644 cmd/crucible/internal/render/testdata/parallel_regions.d2.golden create mode 100644 cmd/crucible/internal/render/testdata/special_chars.d2.golden create mode 100644 cmd/crucible/internal/render/theme.go create mode 100644 cmd/crucible/internal/render/theme_test.go create mode 100644 cmd/crucible/internal/viewmodel/scope_test.go create mode 100644 cmd/crucible/internal/viewmodel/viewmodel.go create mode 100644 cmd/crucible/internal/viewmodel/viewmodel_test.go delete mode 100644 cmd/crucible/render_image.go delete mode 100644 cmd/crucible/render_image_test.go create mode 100644 cmd/crucible/render_test.go diff --git a/README.md b/README.md index 9f9292c..3479c36 100644 --- a/README.md +++ b/README.md @@ -35,10 +35,11 @@ same rule. Defaults are no-ops, nothing third-party is forced on the consumer. This "stdlib-only" guarantee is about the library you import: the `state` engine and its seams pull in nothing third-party. The standalone `crucible` CLI is a -leaf tool, not a library, and is the one exception — it embeds a pure-Go -(WebAssembly) Graphviz **only** for `render -format svg|png`, so you can render -images without installing Graphviz. That convenience lives entirely in the CLI -binary; it never enters the `state` engine or any module you import. See +leaf tool, not a library, and is the one exception — it embeds +[D2](https://d2lang.com) (MPL-2.0, pure Go, no Chromium) **only** for +`render -format svg`, so you can render diagrams without installing Graphviz. +That dependency lives entirely in the CLI binary; it never enters the `state` +engine or any module you import. See [`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md) for attribution. ## Documentation diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index fba27d9..60c5aa6 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -1,7 +1,7 @@ # Third-Party Notices The `crucible` command-line tool (`cmd/crucible`) embeds third-party software so -that `crucible render -format svg|png` can produce images without requiring an +that `crucible render -format svg` can produce diagrams without requiring an external Graphviz installation. The crucible `state` engine and its seams do **not** depend on any of the software listed here; these notices apply only to the CLI binary. @@ -11,68 +11,32 @@ with each dependency, which remain authoritative. --- -## github.com/goccy/go-graphviz +## oss.terrastruct.com/d2 -Pure-Go bindings that run Graphviz compiled to WebAssembly via -[wazero](https://github.com/tetratelabs/wazero). Used by the CLI to render DOT -to SVG/PNG in-process. +The D2 diagramming engine. Used by the CLI to lay out and render the machine +diagram to SVG in-process — pure Go, with no Chromium and no external Graphviz +install. The distributed `crucible` binary therefore contains D2. -- License: MIT -- Project: https://github.com/goccy/go-graphviz +- License: Mozilla Public License, Version 2.0 (MPL-2.0) +- Project: https://github.com/terrastruct/d2 +- License text: https://www.mozilla.org/en-US/MPL/2.0/ (and the `LICENSE` + shipped within the `oss.terrastruct.com/d2` module) -``` -MIT License - -Copyright (c) 2020 Masaaki Goshima - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -``` - ---- - -## Graphviz (bundled as WebAssembly by go-graphviz) - -`go-graphviz` embeds the [Graphviz](https://graphviz.org/) graph-layout and -rendering engine compiled to WebAssembly. The distributed `crucible` binary -therefore contains Graphviz. - -- License: Eclipse Public License, Version 1.0 (EPL-1.0) -- Project: https://graphviz.org/ -- License text: https://graphviz.org/license/ (and the `LICENSE` shipped within - the go-graphviz module under `vendor`/embedded WebAssembly assets) - -Graphviz is distributed under the Eclipse Public License, Version 1.0. The full -license text is available at https://www.eclipse.org/legal/epl-v10.html. The -program source for the embedded Graphviz is available from the Graphviz project -at https://gitlab.com/graphviz/graphviz. +MPL-2.0 is a file-level copyleft license. The full license text is authoritative +and is shipped with the module in the Go module cache. --- -## Transitive image-encoding dependencies +## Transitive dependencies -`go-graphviz` pulls in pure-Go image-encoding libraries used during PNG/JPEG -rendering. Their licenses are reproduced in their respective module directories -in the Go module cache and in `cmd/crucible/go.sum`: +D2 pulls in supporting libraries during layout and SVG rendering. Their licenses +are reproduced in their respective module directories in the Go module cache and +recorded in `cmd/crucible/go.sum`. Notable entries: -- `github.com/tetratelabs/wazero` — Apache License 2.0 (WebAssembly runtime) -- `github.com/disintegration/imaging` — MIT -- `github.com/fogleman/gg` — MIT +- `github.com/dop251/goja` — MIT (JavaScript engine used by D2 layout) +- `oss.terrastruct.com/util-go` — MPL-2.0 +- `github.com/alecthomas/chroma/v2` — MIT +- `github.com/PuerkitoBio/goquery` — BSD-3-Clause +- `github.com/lucasb-eyer/go-colorful` — MIT - `github.com/golang/freetype` — FreeType License / GNU GPL (dual) -- `github.com/flopp/go-findfont` — MIT -- `golang.org/x/image`, `golang.org/x/text` — BSD-3-Clause +- `golang.org/x/image`, `golang.org/x/net`, `golang.org/x/text` — BSD-3-Clause diff --git a/cmd/crucible/CHANGELOG.md b/cmd/crucible/CHANGELOG.md index dac0590..b6b58f8 100644 --- a/cmd/crucible/CHANGELOG.md +++ b/cmd/crucible/CHANGELOG.md @@ -9,11 +9,16 @@ versioned independently of the `state` module. ### Added -- `render -format svg|png` renders the machine directly to a themed image via an - embedded, pure-Go (WebAssembly) Graphviz — no external Graphviz install is - required. The image carries the Crucible brand palette. `-o file` writes the - output to a file instead of stdout (the norm for binary `png`); `mermaid` and - `dot` output is unchanged. +- Machine visualizer: `render -format svg` renders the machine directly to a + themed, scalable SVG via the embedded D2 engine (pure Go, no Chromium, no + external Graphviz install). The pipeline supports scope and detail projection: + `-from`/`-to` with `-mode shortest|all|trace` select a whole / reachable-from + / path scope, and `-detail outline|guards|actions|lifecycle|full` (default + `actions`) sets a cumulative detail ladder, refined by repeatable + `-show`/`-hide `. `-o file` writes the SVG to a file instead of + stdout. +- `render -theme file.json` overlays a JSON theme onto the embedded default + forge palette; omitted fields keep their defaults. - `lint -format` selects the output format: `text` (default), `json`, or `sarif` (SARIF 2.1.0) for machine-readable CI ingestion. - `diff -format` selects `text` (default) or `json` output. @@ -25,6 +30,21 @@ versioned independently of the `state` module. guards default to false). `-initial` overrides the IR's declared start state. `-format` selects `text` (default) or `json` output. +### Changed + +- The `render` SVG backend now uses the embedded D2 engine + (`oss.terrastruct.com/d2`) instead of the previous WebAssembly Graphviz + backend. SVG output is themed with the Crucible forge palette and rendered + in-process. + +### Removed + +- The previous WebAssembly Graphviz rendering dependency (and its bundled + Graphviz engine) is removed in favor of D2. +- `render -format png` no longer renders directly; it now exits with a usage + error hinting to render `-format svg` and convert with `resvg` or + `rsvg-convert`. + ## [0.1.0] - 2026-06-13 Initial release. diff --git a/cmd/crucible/README.md b/cmd/crucible/README.md index 729c5ed..27bff15 100644 --- a/cmd/crucible/README.md +++ b/cmd/crucible/README.md @@ -32,18 +32,87 @@ physical location unless the IR was read from stdin (`-`). ### render ``` -crucible render [-format mermaid|dot|svg|png] [-o outfile] +crucible render [-format mermaid|dot|svg] [-o outfile] \ + [-from state] [-to state] [-mode shortest|all|trace] \ + [-detail outline|guards|actions|lifecycle|full] \ + [-show dim]... [-hide dim]... [-theme file.json] ``` -Renders the machine as a Mermaid `stateDiagram-v2` (the default), Graphviz DOT, -or a themed `svg`/`png` image. The `svg` and `png` formats are rendered directly -by an embedded, pure-Go (WebAssembly) Graphviz, so **no external Graphviz -install is required** and the image carries the Crucible brand palette. +Renders the machine as a diagram. `-format` selects the output: -`-o` writes the output to a file instead of stdout; it is the norm for `png`, -whose bytes are binary. `mermaid`/`dot` remain text and stream to stdout -unchanged (the historical `crucible render m.json -format dot | dot -Tsvg` -pipeline still works for callers who prefer their own Graphviz). +- `mermaid` (the default) — a Mermaid `stateDiagram-v2`, streamed to stdout. +- `dot` — Graphviz DOT, streamed to stdout (the historical + `crucible render m.json -format dot | dot -Tsvg` pipeline still works for + callers who prefer their own Graphviz). +- `svg` — a themed, scalable SVG rendered in-process by the embedded D2 engine + (pure Go, no Chromium and no external Graphviz install). The SVG carries the + Crucible forge palette. + +There is no `png` format: `-format png` exits with a usage error pointing you +at the conversion path. SVG is the scalable raster-free output; for a PNG, +render `-format svg` and convert it, e.g.: + +``` +crucible render m.json -format svg -o m.svg +resvg m.svg m.png # recommended +# or: rsvg-convert m.svg -o m.png +``` + +`-o` writes the output to a file instead of stdout; it is the norm for `svg`. + +#### Scope and detail + +The SVG pipeline projects the machine along two independent axes: **scope** +(how much of the graph to keep) and **detail** (how much of each +state/transition to show). + +**Scope** is chosen from `-from`/`-to`/`-mode`: + +- No `-from`: the **whole** machine. +- `-from A` only: the subgraph **reachable from A**. +- `-from A -to X`: a **path** from A to X. `-mode` shapes it: + - `shortest` (default) keeps the whole reachable subgraph but highlights the + single shortest A→X path (off-path elements stay, dimmed). + - `all` keeps the union of all simple A→X paths, all highlighted. + - `trace` keeps **only** the shortest A→X path, dropping everything else. + +`-to` requires `-from`; a non-default `-mode` requires `-from`. Endpoints are +bare state names (composite names resolve to their region). + +**Detail** is a cumulative ladder set by `-detail` (default `actions`); each +level implies all the levels below it: + +| Level | Adds | +|-------------|---------------------------------------------------| +| `outline` | states and transitions only | +| `guards` | + transition guards | +| `actions` | + effects and assigns (the default) | +| `lifecycle` | + entry/exit actions and invocations | +| `full` | + delays, descriptions, data-flow, context schema, source | + +`-show ` and `-hide ` (both repeatable) override the +ladder per dimension; `-show` wins when both name the same one. Dimensions: +`guards`, `effects`, `assigns`, `entry-exit`, `invoke`, `delays`, +`descriptions`, `data-flow`, `context-schema`, `source`. + +#### Theme + +`-theme file.json` overlays a JSON theme onto the embedded default forge +palette; fields you omit keep their defaults. Without `-theme`, the embedded +default theme is used. + +#### Examples + +``` +# Whole machine, default detail, as Mermaid (to stdout): +crucible render m.json + +# Just the shortest path from cart to done, nothing else, as SVG: +crucible render m.json -format svg -mode trace -from cart -to done -o path.svg + +# Reachable-from-A view with full detail but guards suppressed: +crucible render m.json -format svg -from active -detail full -hide guards -o active.svg +``` ### diff diff --git a/cmd/crucible/cmd.go b/cmd/crucible/cmd.go index a561753..543fa16 100644 --- a/cmd/crucible/cmd.go +++ b/cmd/crucible/cmd.go @@ -1,14 +1,21 @@ package main import ( + "errors" "flag" + "fmt" "io" "os" + "strings" "github.com/stablekernel/crucible/gen" "github.com/stablekernel/crucible/state" "github.com/stablekernel/crucible/state/analysis" "github.com/stablekernel/crucible/state/evolution" + + "github.com/stablekernel/crucible/cmd/crucible/internal/query" + "github.com/stablekernel/crucible/cmd/crucible/internal/render" + "github.com/stablekernel/crucible/cmd/crucible/internal/viewmodel" ) // runLint loads an IR, assembles it with stub behaviors, runs every static @@ -60,24 +67,69 @@ func runLint(args []string, stdout, stderr io.Writer) int { return exitOK } +// stringSliceFlag is a repeatable flag that accumulates string values. +// It implements flag.Value so it can be registered with fs.Var. +type stringSliceFlag []string + +// String returns the slice formatted as a comma-joined string. +func (s *stringSliceFlag) String() string { return strings.Join(*s, ",") } + +// Set appends v to the accumulated slice. +func (s *stringSliceFlag) Set(v string) error { + *s = append(*s, v) + return nil +} + +// dimensionByToken maps user-visible flag tokens to their Dimension constant. +var dimensionByToken = map[string]viewmodel.Dimension{ + "guards": viewmodel.DimGuards, + "effects": viewmodel.DimEffects, + "assigns": viewmodel.DimAssigns, + "entry-exit": viewmodel.DimEntryExit, + "invoke": viewmodel.DimInvoke, + "delays": viewmodel.DimDelays, + "descriptions": viewmodel.DimDescriptions, + "data-flow": viewmodel.DimDataFlow, + "context-schema": viewmodel.DimContextSchema, + "source": viewmodel.DimSource, +} + +// validDimensionTokens is the sorted list of recognized dimension tokens, +// used in error messages. +var validDimensionTokens = []string{ + "assigns", "context-schema", "data-flow", "delays", "descriptions", + "effects", "entry-exit", "guards", "invoke", "source", +} + // runRender loads an IR, assembles it with stub behaviors, and emits the -// machine diagram. -format selects mermaid (the default), dot, svg, or png. The -// svg and png formats are rendered directly via an embedded (pure-Go, WASM) -// Graphviz — no external `dot` install is required — and carry the Crucible -// brand theme. Image bytes go to -o when set, otherwise to stdout; png in -// particular is binary, so -o is the norm. +// machine diagram. -format selects mermaid (the default), dot, or svg. The svg +// format is rendered via the in-house D2 renderer and carries the Crucible +// brand theme; SVG bytes are written to -o when set, otherwise to stdout. +// -format png is rejected with a conversion hint. Scope and detail projection +// are controlled by -from, -to, -mode, -detail, -show, and -hide. func runRender(args []string, stdout, stderr io.Writer) int { fs := flag.NewFlagSet("render", flag.ContinueOnError) fs.SetOutput(stderr) - format := fs.String("format", "mermaid", "diagram format: mermaid, dot, svg, or png") + format := fs.String("format", "mermaid", "diagram format: mermaid, dot, or svg") out := fs.String("o", "", "output file (default: stdout)") + from := fs.String("from", "", "source state for scope/path filtering") + to := fs.String("to", "", "target state for path filtering (requires -from)") + mode := fs.String("mode", "shortest", "path mode: shortest, all, or trace (requires -from)") + detail := fs.String("detail", "actions", "detail level: outline, guards, actions, lifecycle, or full") + theme := fs.String("theme", "", "path to a theme JSON file") + var show, hide stringSliceFlag + fs.Var(&show, "show", "add a dimension (repeatable): guards, effects, assigns, …") + fs.Var(&hide, "hide", "suppress a dimension (repeatable)") + if err := fs.Parse(reorderArgs(args)); err != nil { return exitUsage } if fs.NArg() != 1 { - emitln(stderr, "usage: crucible render [-format mermaid|dot|svg|png] [-o outfile]") + emitln(stderr, "usage: crucible render [-format mermaid|dot|svg] [-o outfile] [-from state] [-to state] [-mode shortest|all|trace] [-detail outline|guards|actions|lifecycle|full] [-show dim] [-hide dim] [-theme file]") return exitUsage } + + // Validate -format first so the existing TestRender_UnknownFormat passes. switch *format { case "mermaid", "dot", "svg", "png": default: @@ -85,52 +137,185 @@ func runRender(args []string, stdout, stderr io.Writer) int { return exitUsage } - ir, err := loadIR(fs.Arg(0), os.Stdin) + // PNG is rejected with a conversion hint. + if *format == "png" { + emitf(stderr, "crucible render: png is not supported directly; render -format svg and convert with resvg or rsvg-convert\n") + return exitUsage + } + + // Validate -mode token. + var svgMode viewmodel.Mode + switch *mode { + case "shortest": + svgMode = viewmodel.ModeShortest + case "all": + svgMode = viewmodel.ModeAll + case "trace": + svgMode = viewmodel.ModeTrace + default: + emitf(stderr, "crucible render: unknown -mode %q (want shortest, all, or trace)\n", *mode) + return exitUsage + } + + // Validate -detail token. + var level viewmodel.DetailLevel + switch *detail { + case "outline": + level = viewmodel.Outline + case "guards": + level = viewmodel.Guards + case "actions": + level = viewmodel.Actions + case "lifecycle": + level = viewmodel.Lifecycle + case "full": + level = viewmodel.Full + default: + emitf(stderr, "crucible render: unknown -detail %q (want outline, guards, actions, lifecycle, or full)\n", *detail) + return exitUsage + } + + // Validate -show tokens. + showDims, err := parseDimensions(show) if err != nil { - emitf(stderr, "crucible render: %v\n", err) - return exitError + emitf(stderr, "crucible render: -show %v (valid: %s)\n", err, strings.Join(validDimensionTokens, ", ")) + return exitUsage } - m, err := quench(ir) + + // Validate -hide tokens. + hideDims, err := parseDimensions(hide) if err != nil { - emitf(stderr, "crucible render: %v\n", err) + emitf(stderr, "crucible render: -hide %v (valid: %s)\n", err, strings.Join(validDimensionTokens, ", ")) + return exitUsage + } + + // -to requires -from. + if *to != "" && *from == "" { + emitf(stderr, "crucible render: -to requires -from\n") + return exitUsage + } + + // Non-shortest mode requires -from. + if *mode != "shortest" && *from == "" { + emitf(stderr, "crucible render: mode requires -from/-to\n") + return exitUsage + } + + // For mermaid/dot, skip the full pipeline and emit text. + if *format == "dot" || *format == "mermaid" { + ir, loadErr := loadIR(fs.Arg(0), os.Stdin) + if loadErr != nil { + emitf(stderr, "crucible render: %v\n", loadErr) + return exitError + } + m, quenchErr := quench(ir) + if quenchErr != nil { + emitf(stderr, "crucible render: %v\n", quenchErr) + return exitError + } + switch *format { + case "dot": + emit(stdout, m.ToDOT()) + default: + emit(stdout, m.ToMermaid()) + } + return exitOK + } + + // SVG: full viewmodel/render pipeline. + var renderTheme render.Theme + if *theme != "" { + renderTheme, err = render.LoadTheme(*theme) + if err != nil { + emitf(stderr, "crucible render: %v\n", err) + return exitUsage + } + } else { + renderTheme = render.DefaultTheme + } + + ir, loadErr := loadIR(fs.Arg(0), os.Stdin) + if loadErr != nil { + emitf(stderr, "crucible render: %v\n", loadErr) + return exitError + } + m, quenchErr := quench(ir) + if quenchErr != nil { + emitf(stderr, "crucible render: %v\n", quenchErr) return exitError } - switch *format { - case "svg": - return renderImageToOutput(m, formatSVG, *out, stdout, stderr) - case "png": - return renderImageToOutput(m, formatPNG, *out, stdout, stderr) - case "dot": - emit(stdout, m.ToDOT()) + resolver := viewmodel.NewRefResolver(state.BuiltinPalette(), m.Palette()) + + var scope viewmodel.Scope + switch { + case *from != "" && *to != "": + scope = viewmodel.ScopePath + case *from != "": + scope = viewmodel.ScopeReachableFrom default: - emit(stdout, m.ToMermaid()) + scope = viewmodel.ScopeWhole } - return exitOK -} -// renderImageToOutput renders the machine to image bytes and writes them either -// to the named file or to stdout. The bytes are binary, so they bypass the -// emit* helpers (which append newlines and would corrupt a PNG). It returns the -// process exit code. -func renderImageToOutput[S comparable, E comparable, C any](m *state.Machine[S, E, C], format imageFormat, out string, stdout, stderr io.Writer) int { - img, err := renderImage(m, format) - if err != nil { - emitf(stderr, "crucible render: %v\n", err) + opts := viewmodel.ProjectionOptions{ + Level: level, + Show: showDims, + Hide: hideDims, + Scope: scope, + Mode: svgMode, + From: *from, + To: *to, + } + + vm, vmErr := viewmodel.BuildScoped(ir, resolver, opts) + if vmErr != nil { + switch { + case errors.Is(vmErr, query.ErrUnknownState) || errors.Is(vmErr, query.ErrAmbiguousState): + emitf(stderr, "crucible render: %v\n", vmErr) + case strings.Contains(vmErr.Error(), "no path"): + emitf(stderr, "crucible render: %v\n", vmErr) + default: + emitf(stderr, "crucible render: %v\n", vmErr) + } + return exitUsage + } + + svg, renderErr := render.RenderSVG(vm, renderTheme) + if renderErr != nil { + emitf(stderr, "crucible render: %v\n", renderErr) return exitError } - if out == "" { - _, err = stdout.Write(img) + + var writeErr error + if *out == "" { + _, writeErr = stdout.Write(svg) } else { - err = os.WriteFile(out, img, 0o644) + writeErr = os.WriteFile(*out, svg, 0o644) } - if err != nil { - emitf(stderr, "crucible render: write output: %v\n", err) + if writeErr != nil { + emitf(stderr, "crucible render: write output: %v\n", writeErr) return exitError } return exitOK } +// parseDimensions converts a slice of user-visible token strings to their +// Dimension constants. It returns an error naming the first unrecognized token. +func parseDimensions(tokens []string) ([]viewmodel.Dimension, error) { + if len(tokens) == 0 { + return nil, nil + } + dims := make([]viewmodel.Dimension, 0, len(tokens)) + for _, tok := range tokens { + d, ok := dimensionByToken[tok] + if !ok { + return nil, fmt.Errorf("unknown dimension token %q", tok) + } + dims = append(dims, d) + } + return dims, nil +} + // runDiff loads two serialized IRs, classifies the changes between them, and // prints the recommended semver bump along with the breaking and additive // changes split apart. @@ -289,13 +474,16 @@ func parseSingleArg(fs *flag.FlagSet, args []string, name, argHint string, stder // appear after the IR path (e.g. "render ir.json -format dot"). Go's flag // package stops at the first non-flag token, so without this a trailing flag is // read as a stray positional. Every value-taking flag in this CLI (-format, -// -package, -o) is moved together with its following value token; a -k=v token -// carries its own value. A bare "--" terminates flag processing, and everything -// after it is treated as positional. +// -package, -o, -from, -to, -mode, -detail, -show, -hide, -theme) is moved +// together with its following value token; a -k=v token carries its own value. +// A bare "--" terminates flag processing, and everything after it is treated as +// positional. func reorderArgs(args []string) []string { valueFlags := map[string]bool{ "-format": true, "-package": true, "-o": true, "-events": true, "-events-file": true, "-initial": true, "-guard": true, + "-from": true, "-to": true, "-mode": true, "-detail": true, + "-show": true, "-hide": true, "-theme": true, } var flags, positional []string for i := 0; i < len(args); i++ { diff --git a/cmd/crucible/go.mod b/cmd/crucible/go.mod index edd8797..d8a83eb 100644 --- a/cmd/crucible/go.mod +++ b/cmd/crucible/go.mod @@ -3,18 +3,29 @@ module github.com/stablekernel/crucible/cmd/crucible go 1.25.0 require ( - github.com/goccy/go-graphviz v0.2.10 github.com/stablekernel/crucible/gen v0.1.0 github.com/stablekernel/crucible/state v1.0.0 + oss.terrastruct.com/d2 v0.7.1 + oss.terrastruct.com/util-go v0.0.0-20250213174338-243d8661088a ) require ( - github.com/disintegration/imaging v1.6.2 // indirect - github.com/flopp/go-findfont v0.1.0 // indirect - github.com/fogleman/gg v1.3.0 // indirect + github.com/PuerkitoBio/goquery v1.10.0 // indirect + github.com/alecthomas/chroma/v2 v2.14.0 // indirect + github.com/andybalholm/brotli v1.2.0 // indirect + github.com/andybalholm/cascadia v1.3.2 // indirect + github.com/dlclark/regexp2 v1.11.4 // indirect + github.com/dop251/goja v0.0.0-20240927123429-241b342198c2 // indirect + github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect - github.com/tetratelabs/wazero v1.12.0 // indirect + github.com/google/pprof v0.0.0-20240927180334-d43a67379298 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mazznoer/csscolorparser v0.1.5 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/yuin/goldmark v1.7.4 // indirect + golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect golang.org/x/image v0.21.0 // indirect - golang.org/x/sys v0.45.0 // indirect + golang.org/x/net v0.35.0 // indirect golang.org/x/text v0.35.0 // indirect + golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect ) diff --git a/cmd/crucible/go.sum b/cmd/crucible/go.sum index cb827d9..4d083fe 100644 --- a/cmd/crucible/go.sum +++ b/cmd/crucible/go.sum @@ -1,28 +1,100 @@ -github.com/corona10/goimagehash v1.1.0 h1:teNMX/1e+Wn/AYSbLHX8mj+mF9r60R1kBeqE9MkoYwI= -github.com/corona10/goimagehash v1.1.0/go.mod h1:VkvE0mLn84L4aF8vCb6mafVajEb6QYMHl2ZJLn0mOGI= -github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= -github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= -github.com/flopp/go-findfont v0.1.0 h1:lPn0BymDUtJo+ZkV01VS3661HL6F4qFlkhcJN55u6mU= -github.com/flopp/go-findfont v0.1.0/go.mod h1:wKKxRDjD024Rh7VMwoU90i6ikQRCr+JTHB5n4Ejkqvw= -github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8= -github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= -github.com/goccy/go-graphviz v0.2.10 h1:jHu/1I0Iw0xIzzYk96Ous/ZeuD11Rt2oW8juHdIE30g= -github.com/goccy/go-graphviz v0.2.10/go.mod h1:LRlMnNmY17QbN6fLnvOzY7g0rXQjLKAhzxeTHbEUM6w= +github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= +github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= +github.com/PuerkitoBio/goquery v1.10.0 h1:6fiXdLuUvYs2OJSvNRqlNPoBm6YABE226xrbavY5Wv4= +github.com/PuerkitoBio/goquery v1.10.0/go.mod h1:TjZZl68Q3eGHNBA8CWaxAN7rOU1EbDz3CWuolcO5Yu4= +github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE= +github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= +github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= +github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= +github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss= +github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo= +github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dop251/goja v0.0.0-20240927123429-241b342198c2 h1:Ux9RXuPQmTB4C1MKagNLme0krvq8ulewfor+ORO/QL4= +github.com/dop251/goja v0.0.0-20240927123429-241b342198c2/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4= +github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q= +github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= -github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= -github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= +github.com/google/pprof v0.0.0-20240927180334-d43a67379298 h1:dMHbguTqGtorivvHTaOnbYp+tFzrw5M9gjkU4lCplgg= +github.com/google/pprof v0.0.0-20240927180334-d43a67379298/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mazznoer/csscolorparser v0.1.5 h1:Wr4uNIE+pHWN3TqZn2SGpA2nLRG064gB7WdSfSS5cz4= +github.com/mazznoer/csscolorparser v0.1.5/go.mod h1:OQRVvgCyHDCAquR1YWfSwwaDcM0LhnSffGnlbOew/3I= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/stablekernel/crucible/gen v0.1.0 h1:IdQlLIsoIYMlUgWeZiY3pzTBvxe5wBZhpPaZ02xW/NY= github.com/stablekernel/crucible/gen v0.1.0/go.mod h1:rl6qLJ21rp9B/nMYiAM0sbSgi+2wE0NXP+7+p/rc1RE= github.com/stablekernel/crucible/state v1.0.0 h1:YjhEM3vqqHptq33j+zZlYgSa4FupAFrDgAUUPfLwx68= github.com/stablekernel/crucible/state v1.0.0/go.mod h1:GU2LNVI+FJrdii+UNZYqfwcH219dCKO+TkX6KRE/Fys= -github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU= -github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0= -golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/goldmark v1.7.4 h1:BDXOHExt+A7gwPCJgPIIq7ENvceR7we7rOS9TNoLZeg= +github.com/yuin/goldmark v1.7.4/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk= +golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY= golang.org/x/image v0.21.0 h1:c5qV36ajHpdj4Qi0GnE0jUc/yuo33OLFaa0d+crTD5s= golang.org/x/image v0.21.0/go.mod h1:vUbsLavqK/W303ZroQQVKQ+Af3Yl6Uz1Ppu5J/cLz78= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= +golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= +golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +oss.terrastruct.com/d2 v0.7.1 h1:LafTW1UoXJGODvKDZ8obyBfGcc2k2vHZ3EzrabMqEVE= +oss.terrastruct.com/d2 v0.7.1/go.mod h1:aT0PwLaxBZGgsWrIT8oSFYm5xoYX08BaOHewi5qLE2E= +oss.terrastruct.com/util-go v0.0.0-20250213174338-243d8661088a h1:UXF/Z9i9tOx/wqGUOn/T12wZeez1Gg0sAVKKl7YUDwM= +oss.terrastruct.com/util-go v0.0.0-20250213174338-243d8661088a/go.mod h1:eMWv0sOtD9T2RUl90DLWfuShZCYp4NrsqNpI8eqO6U4= diff --git a/cmd/crucible/internal/query/query.go b/cmd/crucible/internal/query/query.go new file mode 100644 index 0000000..6936ff8 --- /dev/null +++ b/cmd/crucible/internal/query/query.go @@ -0,0 +1,295 @@ +// Package query is a pure graph layer over the crucible state IR. It answers +// reachability and path questions used to scope a machine visualization. +// +// It performs no rendering and depends only on the public state package. The +// node-identity scheme matches viewmodel: a node ID is a state's bare Name, so +// selections produced here line up with viewmodel.ViewNode.ID. Traversal is a +// small local BFS/DFS over the public IR — it deliberately does not reuse the +// unexported graph builders in state/analysis or state/verify, which are +// initial-state-only and not exported. +package query + +import ( + "errors" + "fmt" + + "github.com/stablekernel/crucible/state" +) + +// Sentinel errors for endpoint resolution, wrapped with the offending name. +var ( + // ErrUnknownState is returned when a name matches no state in the IR. + ErrUnknownState = errors.New("unknown state") + // ErrAmbiguousState is returned when a bare name matches more than one + // state (the same leaf name under different parents). + ErrAmbiguousState = errors.New("ambiguous state name") +) + +// Step is one edge of a path: a transition (or implicit descent) from one node +// to another. Event is the transition's On value, or "" for an implicit +// descent edge into a composite/parallel's initial child. +type Step struct { + From string + To string + Event string +} + +// Path is an ordered sequence of steps from a source node to a target node. +type Path []Step + +// edge is an internal adjacency entry: a destination node ID and the event +// that labels the edge ("" for an implicit descent edge). +type edge struct { + to string + event string +} + +// graph is a flattened, name-keyed view of the IR for traversal. nodes is the +// set of all state IDs; adj maps each node to its outgoing edges in document +// order (transitions first, then descent edges). +type graph struct { + nodes map[string]struct{} + adj map[string][]edge +} + +// buildGraph flattens the IR into a name-keyed adjacency graph. Every state +// (at any depth, including children and region states) becomes a node. Edges +// are the state's own transitions (From->To, labeled by On) plus implicit +// "descent" edges: a composite emits an edge to its InitialChild, and a +// parallel emits one descent edge per region to that region's initial state. +// Descent edges model "entering a composite/parallel reaches its initials", +// consistent with how the IR nests, and carry an empty event label. +func buildGraph(ir *state.IR[string, string, any]) *graph { + g := &graph{ + nodes: make(map[string]struct{}), + adj: make(map[string][]edge), + } + if ir == nil { + return g + } + for i := range ir.States { + addState(g, &ir.States[i]) + } + return g +} + +// addState records one state and its outgoing edges, then recurses into +// children and region states. +func addState(g *graph, s *state.State[string, string, any]) { + g.nodes[s.Name] = struct{}{} + + for i := range s.Transitions { + t := &s.Transitions[i] + g.adj[s.Name] = append(g.adj[s.Name], edge{to: t.To, event: t.On}) + } + + // Descent into a composite's initial child. + if len(s.Children) > 0 && s.InitialChild != nil { + g.adj[s.Name] = append(g.adj[s.Name], edge{to: *s.InitialChild}) + } + // Descent into each region's initial state. + for i := range s.Regions { + r := &s.Regions[i] + if r.InitialChild != nil { + g.adj[s.Name] = append(g.adj[s.Name], edge{to: *r.InitialChild}) + } + } + + for i := range s.Children { + addState(g, &s.Children[i]) + } + for i := range s.Regions { + r := &s.Regions[i] + for j := range r.States { + addState(g, &r.States[j]) + } + } +} + +// countByName tallies how many states across the whole IR carry each bare +// Name. Used to detect ambiguity in endpoint resolution. +func countByName(ir *state.IR[string, string, any]) map[string]int { + counts := make(map[string]int) + if ir == nil { + return counts + } + var walk func(s *state.State[string, string, any]) + walk = func(s *state.State[string, string, any]) { + counts[s.Name]++ + for i := range s.Children { + walk(&s.Children[i]) + } + for i := range s.Regions { + for j := range s.Regions[i].States { + walk(&s.Regions[i].States[j]) + } + } + } + for i := range ir.States { + walk(&ir.States[i]) + } + return counts +} + +// ResolveEndpoint maps a user-given endpoint name to a node ID using the same +// identity scheme as viewmodel (a node ID is a state's bare Name). +// +// Rule: the name is matched against every state's bare Name across the whole +// tree (leaves, composites, parallels, region states alike). It resolves to +// that same name when exactly one state carries it. Composite and parallel +// names are valid endpoints and resolve to themselves (their container node +// ID); callers that want the "inside" of a composite can rely on the descent +// edges added by buildGraph. The name is rejected with ErrUnknownState when no +// state carries it, and with ErrAmbiguousState when more than one does. +func ResolveEndpoint(ir *state.IR[string, string, any], name string) (string, error) { + counts := countByName(ir) + switch counts[name] { + case 0: + return "", fmt.Errorf("%w: %q", ErrUnknownState, name) + case 1: + return name, nil + default: + return "", fmt.Errorf("%w: %q matches %d states", ErrAmbiguousState, name, counts[name]) + } +} + +// ReachableFrom returns the set of node IDs reachable from rootID via a local +// BFS over transition and descent edges (compound/parallel-aware). The root +// itself is included. The root must resolve to exactly one state, else an +// error is returned (ErrUnknownState / ErrAmbiguousState). +func ReachableFrom(ir *state.IR[string, string, any], rootID string) (map[string]bool, error) { + root, err := ResolveEndpoint(ir, rootID) + if err != nil { + return nil, err + } + g := buildGraph(ir) + + seen := map[string]bool{root: true} + queue := []string{root} + for len(queue) > 0 { + cur := queue[0] + queue = queue[1:] + for _, e := range g.adj[cur] { + if !seen[e.to] { + seen[e.to] = true + queue = append(queue, e.to) + } + } + } + return seen, nil +} + +// ShortestPath returns the shortest path (fewest edges) from fromID to toID via +// BFS over the IR graph. found is false (not an error) when no path exists. +// Unknown or ambiguous endpoints return an error. A from==to request yields an +// empty path with found=true. +func ShortestPath(ir *state.IR[string, string, any], fromID, toID string) (Path, bool, error) { + from, err := ResolveEndpoint(ir, fromID) + if err != nil { + return nil, false, err + } + to, err := ResolveEndpoint(ir, toID) + if err != nil { + return nil, false, err + } + if from == to { + return Path{}, true, nil + } + g := buildGraph(ir) + + // BFS recording the predecessor step that first reached each node. + prev := map[string]Step{} + seen := map[string]bool{from: true} + queue := []string{from} + for len(queue) > 0 { + cur := queue[0] + queue = queue[1:] + for _, e := range g.adj[cur] { + if seen[e.to] { + continue + } + seen[e.to] = true + prev[e.to] = Step{From: cur, To: e.to, Event: e.event} + if e.to == to { + return reconstruct(prev, from, to), true, nil + } + queue = append(queue, e.to) + } + } + return nil, false, nil +} + +// reconstruct walks predecessor steps from to back to from, returning the path +// in forward order. +func reconstruct(prev map[string]Step, from, to string) Path { + var rev Path + for cur := to; cur != from; { + st := prev[cur] + rev = append(rev, st) + cur = st.From + } + // Reverse into forward order. + out := make(Path, len(rev)) + for i := range rev { + out[len(rev)-1-i] = rev[i] + } + return out +} + +// AllSimplePaths enumerates all acyclic (simple) paths from fromID to toID via +// a local DFS, capped at limit paths. truncated is true when the cap was hit +// and further paths existed but were not enumerated (paths are never silently +// dropped — the caller learns the result is partial). limit must be positive. +// Unknown or ambiguous endpoints return an error. +func AllSimplePaths(ir *state.IR[string, string, any], fromID, toID string, limit int) ([]Path, bool, error) { + if limit <= 0 { + return nil, false, fmt.Errorf("cap must be positive, got %d", limit) + } + from, err := ResolveEndpoint(ir, fromID) + if err != nil { + return nil, false, err + } + to, err := ResolveEndpoint(ir, toID) + if err != nil { + return nil, false, err + } + g := buildGraph(ir) + + // Enumerate up to limit+1 paths: finding the (limit+1)-th proves the result + // is truncated, and we then trim back to limit so paths are never silently + // dropped — the caller learns via truncated=true. + var paths []Path + onPath := map[string]bool{from: true} + var cur Path + + var dfs func(node string) + dfs = func(node string) { + if len(paths) > limit { + return // already proved truncation; stop early + } + if node == to && len(cur) > 0 { + paths = append(paths, append(Path(nil), cur...)) + return + } + for _, e := range g.adj[node] { + if onPath[e.to] { + continue // skip to keep the path simple (acyclic) + } + onPath[e.to] = true + cur = append(cur, Step{From: node, To: e.to, Event: e.event}) + dfs(e.to) + cur = cur[:len(cur)-1] + onPath[e.to] = false + if len(paths) > limit { + return + } + } + } + dfs(from) + + truncated := len(paths) > limit + if truncated { + paths = paths[:limit] + } + return paths, truncated, nil +} diff --git a/cmd/crucible/internal/query/query_test.go b/cmd/crucible/internal/query/query_test.go new file mode 100644 index 0000000..dc6fe39 --- /dev/null +++ b/cmd/crucible/internal/query/query_test.go @@ -0,0 +1,264 @@ +package query_test + +import ( + "errors" + "os" + "sort" + "testing" + + "github.com/stablekernel/crucible/cmd/crucible/internal/query" + "github.com/stablekernel/crucible/state" +) + +// loadFixture reads and parses a testdata fixture, failing on error. +func loadFixture(t *testing.T, name string) *state.IR[string, string, any] { + t.Helper() + b, err := os.ReadFile("../../testdata/" + name) + if err != nil { + t.Fatalf("read fixture %s: %v", name, err) + } + ir, err := state.LoadFromJSON[string, string, any](b) + if err != nil { + t.Fatalf("load fixture %s: %v", name, err) + } + return ir +} + +// branchyIR builds a small fixture with multiple distinct simple paths from +// "a" to "d" so AllSimplePaths can be exercised and capped: +// +// a -e-> b -e-> d +// a -e-> c -e-> d +// a -e-> d (direct) +// +// Three simple acyclic paths a->d. Also a self-ish cycle d->a to prove the +// DFS does not loop forever. +func branchyIR() *state.IR[string, string, any] { + tr := func(from, to, on string) state.Transition[string, string, any] { + return state.Transition[string, string, any]{From: from, To: to, On: on} + } + return &state.IR[string, string, any]{ + Name: "branchy", + Initial: "a", + States: []state.State[string, string, any]{ + {Name: "a", Transitions: []state.Transition[string, string, any]{ + tr("a", "b", "x"), tr("a", "c", "y"), tr("a", "d", "z"), + }}, + {Name: "b", Transitions: []state.Transition[string, string, any]{tr("b", "d", "x")}}, + {Name: "c", Transitions: []state.Transition[string, string, any]{tr("c", "d", "x")}}, + {Name: "d", IsFinal: true, Transitions: []state.Transition[string, string, any]{tr("d", "a", "loop")}}, + }, + } +} + +func TestReachableFrom_Linear(t *testing.T) { + ir := loadFixture(t, "clean.json") + got, err := query.ReachableFrom(ir, "paying") + if err != nil { + t.Fatalf("ReachableFrom: %v", err) + } + // From paying we reach done (and paying itself). cart is NOT reachable. + if !got["paying"] || !got["done"] { + t.Fatalf("expected paying and done reachable, got %v", sortedKeys(got)) + } + if got["cart"] { + t.Fatalf("cart must not be reachable from paying, got %v", sortedKeys(got)) + } +} + +func TestReachableFrom_RootNotFound(t *testing.T) { + ir := loadFixture(t, "clean.json") + if _, err := query.ReachableFrom(ir, "nope"); err == nil { + t.Fatal("expected error for unknown root") + } +} + +func TestReachableFrom_CompoundDescends(t *testing.T) { + ir := loadFixture(t, "composite.json") + // "active" is a composite whose InitialChild is "working". + // Reaching active must descend to working, then working -submit-> review. + got, err := query.ReachableFrom(ir, "active") + if err != nil { + t.Fatalf("ReachableFrom: %v", err) + } + for _, want := range []string{"active", "working", "review"} { + if !got[want] { + t.Fatalf("expected %s reachable from active, got %v", want, sortedKeys(got)) + } + } +} + +func TestReachableFrom_ParallelDescendsAllRegions(t *testing.T) { + ir := loadFixture(t, "composite.json") + // "parallel" has region regionA whose initial is ra1; ra1 -tick-> ra2. + got, err := query.ReachableFrom(ir, "parallel") + if err != nil { + t.Fatalf("ReachableFrom: %v", err) + } + for _, want := range []string{"parallel", "ra1", "ra2"} { + if !got[want] { + t.Fatalf("expected %s reachable from parallel, got %v", want, sortedKeys(got)) + } + } +} + +func TestShortestPath_Linear(t *testing.T) { + ir := loadFixture(t, "clean.json") + p, found, err := query.ShortestPath(ir, "cart", "done") + if err != nil { + t.Fatalf("ShortestPath: %v", err) + } + if !found { + t.Fatal("expected a path cart->done") + } + want := []query.Step{ + {From: "cart", To: "paying", Event: "checkout"}, + {From: "paying", To: "done", Event: "paid"}, + } + if len(p) != len(want) { + t.Fatalf("path length = %d, want %d (%v)", len(p), len(want), p) + } + for i := range want { + if p[i] != want[i] { + t.Fatalf("step %d = %+v, want %+v", i, p[i], want[i]) + } + } +} + +func TestShortestPath_NoPath_FoundFalse(t *testing.T) { + ir := loadFixture(t, "clean.json") + // done -> cart: no transition leaves done. + p, found, err := query.ShortestPath(ir, "done", "cart") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if found { + t.Fatalf("expected no path done->cart, got %v", p) + } +} + +func TestShortestPath_UnknownEndpoint_Error(t *testing.T) { + ir := loadFixture(t, "clean.json") + if _, _, err := query.ShortestPath(ir, "cart", "ghost"); err == nil { + t.Fatal("expected error for unknown 'to' endpoint") + } + if _, _, err := query.ShortestPath(ir, "ghost", "done"); err == nil { + t.Fatal("expected error for unknown 'from' endpoint") + } +} + +func TestShortestPath_PicksShortest(t *testing.T) { + ir := branchyIR() + p, found, err := query.ShortestPath(ir, "a", "d") + if err != nil { + t.Fatalf("ShortestPath: %v", err) + } + if !found { + t.Fatal("expected path a->d") + } + // The direct edge a->d (length 1) is shortest. + if len(p) != 1 || p[0].From != "a" || p[0].To != "d" { + t.Fatalf("expected single-step a->d, got %v", p) + } +} + +func TestAllSimplePaths_EnumeratesAll(t *testing.T) { + ir := branchyIR() + paths, truncated, err := query.AllSimplePaths(ir, "a", "d", 100) + if err != nil { + t.Fatalf("AllSimplePaths: %v", err) + } + if truncated { + t.Fatal("did not expect truncation at cap 100") + } + if len(paths) != 3 { + t.Fatalf("expected 3 simple paths a->d, got %d: %v", len(paths), paths) + } +} + +func TestAllSimplePaths_CapTruncates(t *testing.T) { + ir := branchyIR() + paths, truncated, err := query.AllSimplePaths(ir, "a", "d", 2) + if err != nil { + t.Fatalf("AllSimplePaths: %v", err) + } + if !truncated { + t.Fatal("expected truncated=true at cap 2") + } + if len(paths) != 2 { + t.Fatalf("expected exactly 2 paths at cap, got %d", len(paths)) + } +} + +func TestAllSimplePaths_UnknownEndpoint_Error(t *testing.T) { + ir := branchyIR() + if _, _, err := query.AllSimplePaths(ir, "a", "zzz", 10); err == nil { + t.Fatal("expected error for unknown endpoint") + } +} + +func TestAllSimplePaths_NonPositiveCap_Error(t *testing.T) { + ir := branchyIR() + if _, _, err := query.AllSimplePaths(ir, "a", "d", 0); err == nil { + t.Fatal("expected error for non-positive cap") + } +} + +func TestResolveEndpoint_LeafAndCompound(t *testing.T) { + ir := loadFixture(t, "composite.json") + // Leaf name resolves to itself. + id, err := query.ResolveEndpoint(ir, "working") + if err != nil { + t.Fatalf("resolve working: %v", err) + } + if id != "working" { + t.Fatalf("resolve working = %q, want working", id) + } + // Compound name resolves to itself (the container node ID). + id, err = query.ResolveEndpoint(ir, "active") + if err != nil { + t.Fatalf("resolve active: %v", err) + } + if id != "active" { + t.Fatalf("resolve active = %q, want active", id) + } +} + +func TestResolveEndpoint_Unknown_Error(t *testing.T) { + ir := loadFixture(t, "composite.json") + _, err := query.ResolveEndpoint(ir, "missing") + if err == nil { + t.Fatal("expected error for unknown endpoint") + } + if !errors.Is(err, query.ErrUnknownState) { + t.Fatalf("expected ErrUnknownState, got %v", err) + } +} + +func TestResolveEndpoint_Ambiguous_Error(t *testing.T) { + // Two distinct states share the leaf name "dup" under different parents. + ir := &state.IR[string, string, any]{ + Name: "amb", + Initial: "p1", + States: []state.State[string, string, any]{ + {Name: "p1", Children: []state.State[string, string, any]{{Name: "dup"}}}, + {Name: "p2", Children: []state.State[string, string, any]{{Name: "dup"}}}, + }, + } + _, err := query.ResolveEndpoint(ir, "dup") + if err == nil { + t.Fatal("expected error for ambiguous endpoint") + } + if !errors.Is(err, query.ErrAmbiguousState) { + t.Fatalf("expected ErrAmbiguousState, got %v", err) + } +} + +func sortedKeys(m map[string]bool) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} diff --git a/cmd/crucible/internal/render/emit.go b/cmd/crucible/internal/render/emit.go new file mode 100644 index 0000000..5f735fe --- /dev/null +++ b/cmd/crucible/internal/render/emit.go @@ -0,0 +1,475 @@ +package render + +import ( + "fmt" + "regexp" + "strings" + + "github.com/stablekernel/crucible/cmd/crucible/internal/viewmodel" +) + +// EmitD2 generates deterministic D2 source from a viewmodel under the given +// theme. The output is stable: it ranges slices in document order and never +// iterates a map for emitted content, so identical input yields identical D2. +// +// Structure mirrors the forge v5 reference: a vars/d2-config header pinning the +// ELK engine, a classes block parameterised by the theme, then nodes and edges +// emitted with container nesting. Node kinds map to classes (state/invoke/ +// history/final/init), on-path nodes get a hot ember rim, off-path atomic nodes +// dim to dim_node, and edges pick hot_edge (on-path) or a cold class by kind. +func EmitD2(vm viewmodel.ViewModel, theme Theme) (string, error) { + idx := buildIndex(vm) + var b strings.Builder + + writeHeader(&b, theme) + writeClasses(&b, theme) + fmt.Fprintf(&b, "\nstyle.fill: %s\n\n", quote(theme.Bg)) + + // Emit every top-level entity (a node or a container with no surviving + // parent), recursing into container children. We walk containers first then + // nodes, both in document order, tracking emitted IDs so a container that is + // also backed by a node (parallel/composite) is emitted exactly once. This + // also covers the case where path/scope filtering pruned a container's node + // but kept the container record because its children survived. + emitted := make(map[string]bool) + for i := range vm.Containers { + c := vm.Containers[i] + if idx.parentOf[c.ID] != "" || emitted[c.ID] { + continue + } + emitEntity(&b, vm, idx, theme, c.ID, 0, emitted) + } + for i := range vm.Nodes { + n := vm.Nodes[i] + if idx.parentOf[n.ID] != "" || emitted[n.ID] { + continue + } + emitEntity(&b, vm, idx, theme, n.ID, 0, emitted) + } + + // Edges last, at top level, using fully-qualified dotted D2 keys so an edge + // between nested nodes resolves correctly. + for i := range vm.Edges { + emitEdge(&b, idx, vm.Edges[i]) + } + + return b.String(), nil +} + +// emitEntity emits the node and/or container identified by id at the given +// depth, recursing into container children. It marks id (and emitted children) +// in emitted to prevent duplicates. +func emitEntity(b *strings.Builder, vm viewmodel.ViewModel, idx *d2Index, theme Theme, id string, depth int, emitted map[string]bool) { + if emitted[id] { + return + } + emitted[id] = true + if idx.isContainer[id] { + c := idx.containerByID[id] + n, hasNode := idx.nodeByID[id] + if c.Kind == "region" { + emitRegion(b, vm, idx, theme, c, depth, emitted) + return + } + emitContainerNode(b, vm, idx, theme, n, c, depth, hasNode, emitted) + return + } + if n, ok := idx.nodeByID[id]; ok { + emitNode(b, vm, idx, theme, n, depth) + } +} + +// d2Index holds the derived relationships and stable key mapping for a vm. +type d2Index struct { + // keyOf maps a ViewNode/ViewContainer ID to its sanitized local D2 key. + keyOf map[string]string + // parentOf maps a node/container ID to its parent container ID ("" if top). + parentOf map[string]string + // containerByID indexes containers for kind/children lookups. + containerByID map[string]viewmodel.ViewContainer + // nodeByID indexes nodes. + nodeByID map[string]viewmodel.ViewNode + // childrenOf lists the ordered child IDs a container owns (nodes or regions). + childrenOf map[string][]string + // isContainer marks IDs that are containers (parallel/composite/region). + isContainer map[string]bool +} + +// buildIndex derives parent relationships and stable D2 keys from the viewmodel. +// +// Parent rule: container X is the parent of Y when Y's ID appears in X.Children. +// Containers may themselves be children (a region is a child of a parallel +// container, an inner composite is a child of an outer composite), so the same +// relation builds the full nesting tree. Keys are sanitized IDs disambiguated to +// stay unique and stable. +func buildIndex(vm viewmodel.ViewModel) *d2Index { + idx := &d2Index{ + keyOf: make(map[string]string), + parentOf: make(map[string]string), + containerByID: make(map[string]viewmodel.ViewContainer), + nodeByID: make(map[string]viewmodel.ViewNode), + childrenOf: make(map[string][]string), + isContainer: make(map[string]bool), + } + for i := range vm.Nodes { + idx.nodeByID[vm.Nodes[i].ID] = vm.Nodes[i] + } + for i := range vm.Containers { + c := vm.Containers[i] + idx.containerByID[c.ID] = c + idx.isContainer[c.ID] = true + idx.childrenOf[c.ID] = append(idx.childrenOf[c.ID], c.Children...) + for _, child := range c.Children { + idx.parentOf[child] = c.ID + } + } + // Assign deterministic, unique keys. Sanitize, then disambiguate collisions + // by appending an index in stable iteration order (nodes then containers, in + // document order) so the mapping never depends on map iteration. + used := make(map[string]bool) + assign := func(id string) { + if _, ok := idx.keyOf[id]; ok { + return + } + base := sanitizeKey(id) + k := base + for n := 1; used[k]; n++ { + k = fmt.Sprintf("%s_%d", base, n) + } + used[k] = true + idx.keyOf[id] = k + } + for i := range vm.Nodes { + assign(vm.Nodes[i].ID) + } + for i := range vm.Containers { + assign(vm.Containers[i].ID) + } + return idx +} + +// dottedPath returns the fully-qualified D2 path (parent.child...) for an ID. +func (idx *d2Index) dottedPath(id string) string { + var parts []string + cur := id + for cur != "" { + key, ok := idx.keyOf[cur] + if !ok { + key = sanitizeKey(cur) + } + parts = append([]string{key}, parts...) + cur = idx.parentOf[cur] + } + return strings.Join(parts, ".") +} + +// emitNode writes one leaf node (atomic/invoke/history/final/initial or a +// lifecycle compartment). Containers are handled by emitEntity. depth controls +// indentation for readable nested D2. +func emitNode(b *strings.Builder, _ viewmodel.ViewModel, idx *d2Index, theme Theme, n viewmodel.ViewNode, depth int) { + ind := indent(depth) + key := idx.keyOf[n.ID] + + // Lifecycle compartment: a node carrying Entry/Exit/Invoke detail renders as + // a D2 `shape: class` table with field rows. + if hasLifecycle(n) { + emitLifecycle(b, theme, n, key, depth) + return + } + + switch n.Kind { + case viewmodel.NodeInitial: + fmt.Fprintf(b, "%s%s: %s {\n", ind, key, quote(n.Name)) + fmt.Fprintf(b, "%s class: init\n", ind) + fmt.Fprintf(b, "%s label: \"\"\n", ind) + fmt.Fprintf(b, "%s}\n", ind) + case viewmodel.NodeFinal: + fmt.Fprintf(b, "%s%s: %s {\n", ind, key, quote(n.Name)) + fmt.Fprintf(b, "%s class: final\n", ind) + fmt.Fprintf(b, "%s label: \"\"\n", ind) + fmt.Fprintf(b, "%s}\n", ind) + case viewmodel.NodeHistory: + fmt.Fprintf(b, "%s%s: %s {\n", ind, key, quote(historyLabel(n))) + fmt.Fprintf(b, "%s class: history\n", ind) + fmt.Fprintf(b, "%s}\n", ind) + case viewmodel.NodeInvoke: + fmt.Fprintf(b, "%s%s: %s {\n", ind, key, quote(n.Name)) + fmt.Fprintf(b, "%s class: invoke\n", ind) + fmt.Fprintf(b, "%s}\n", ind) + default: // NodeAtomic + emitAtomic(b, theme, n, key, depth) + } +} + +// emitAtomic renders an atomic state. On-path nodes keep `class: state` but gain +// a hot ember rim (stroke + stroke-width). Off-path atomics dim to dim_node. +func emitAtomic(b *strings.Builder, theme Theme, n viewmodel.ViewNode, key string, depth int) { + ind := indent(depth) + fmt.Fprintf(b, "%s%s: %s {\n", ind, key, quote(n.Name)) + if n.OnPath { + fmt.Fprintf(b, "%s class: state\n", ind) + fmt.Fprintf(b, "%s style.stroke: %s\n", ind, quote(theme.Hot)) + fmt.Fprintf(b, "%s style.stroke-width: 3\n", ind) + } else { + fmt.Fprintf(b, "%s class: dim_node\n", ind) + } + fmt.Fprintf(b, "%s}\n", ind) +} + +// emitContainerNode renders a composite or parallel container and recurses into +// its children (which may themselves be containers). Every such container gets +// an EXPLICIT forge style so the DarkMauve base theme's mauve fill / mauve +// border / lavender title never bleed through: a dark steel/charcoal panel fill +// (SteelDark) and warm-white title (TextWarm) always, with an ember border that +// goes HOT ember at a heavier weight when the container is on the highlighted +// path and plain ember otherwise. hasNode reports whether a backing ViewNode +// exists; on-path status comes from that node. Children are emitted via +// emitEntity so nested containers whose node was pruned still render. +func emitContainerNode( + b *strings.Builder, + vm viewmodel.ViewModel, + idx *d2Index, + theme Theme, + n viewmodel.ViewNode, + c viewmodel.ViewContainer, + depth int, + hasNode bool, + emitted map[string]bool, +) { + ind := indent(depth) + key := idx.keyOf[c.ID] + fmt.Fprintf(b, "%s%s: %s {\n", ind, key, quote(c.Name)) + // Forge container-panel styling. The base (off-path) stroke is ember; on-path + // containers switch to hot ember at a heavier 3px weight to read as part of + // the highlighted path. Fill and title are constant so no DarkMauve leaks. + onPath := hasNode && n.OnPath + stroke := theme.Ember + strokeWidth := 2 + if onPath { + stroke = theme.Hot + strokeWidth = 3 + } + fmt.Fprintf(b, "%s style.fill: %s\n", ind, quote(theme.SteelDark)) + fmt.Fprintf(b, "%s style.stroke: %s\n", ind, quote(stroke)) + fmt.Fprintf(b, "%s style.font-color: %s\n", ind, quote(theme.TextWarm)) + fmt.Fprintf(b, "%s style.stroke-width: %d\n", ind, strokeWidth) + for _, childID := range c.Children { + emitEntity(b, vm, idx, theme, childID, depth+1, emitted) + } + fmt.Fprintf(b, "%s}\n", ind) +} + +// emitRegion renders a parallel region container (`class: region`) and its +// child states. +func emitRegion(b *strings.Builder, vm viewmodel.ViewModel, idx *d2Index, theme Theme, c viewmodel.ViewContainer, depth int, emitted map[string]bool) { + ind := indent(depth) + key := idx.keyOf[c.ID] + fmt.Fprintf(b, "%s%s: %s {\n", ind, key, quote(c.Name)) + fmt.Fprintf(b, "%s class: region\n", ind) + for _, childID := range c.Children { + emitEntity(b, vm, idx, theme, childID, depth+1, emitted) + } + fmt.Fprintf(b, "%s}\n", ind) +} + +// emitLifecycle renders a lifecycle node as a D2 class table with entry/exit/ +// invoke rows. The styling matches the forge v5 Session plate: steel header, +// steelDark body, soft-orange keys (post-processed), font-size 13. +func emitLifecycle(b *strings.Builder, theme Theme, n viewmodel.ViewNode, key string, depth int) { + ind := indent(depth) + fmt.Fprintf(b, "%s%s: %s {\n", ind, key, quote(n.Name)) + fmt.Fprintf(b, "%s shape: class\n", ind) + fmt.Fprintf(b, "%s style.fill: %s\n", ind, quote(theme.Steel)) + fmt.Fprintf(b, "%s style.stroke: %s\n", ind, quote(theme.SteelDark)) + fmt.Fprintf(b, "%s style.font-color: %s\n", ind, quote(theme.SoftOrange)) + fmt.Fprintf(b, "%s style.font-size: 13\n", ind) + emitLifecycleRows(b, ind, "entry", n.Entry) + emitLifecycleRows(b, ind, "exit", n.Exit) + emitLifecycleRows(b, ind, "invoke", n.Invoke) + fmt.Fprintf(b, "%s}\n", ind) +} + +// emitLifecycleRows writes one class field row per detail item under a label. +// A single item collapses to `label: value`; multiple items number the rows. +func emitLifecycleRows(b *strings.Builder, ind, label string, items []viewmodel.DetailItem) { + switch len(items) { + case 0: + return + case 1: + fmt.Fprintf(b, "%s %s: %s\n", ind, label, quote(items[0].Name)) + default: + for i := range items { + fmt.Fprintf(b, "%s %s%d: %s\n", ind, label, i+1, quote(items[i].Name)) + } + } +} + +// emitEdge writes one transition edge with a class chosen by on-path status and +// edge kind. The label folds event, guards, effects, assigns and after. +func emitEdge(b *strings.Builder, idx *d2Index, e viewmodel.ViewEdge) { + from := idx.dottedPath(e.From) + to := idx.dottedPath(e.To) + label := edgeLabel(e) + class := edgeClass(e) + if label == "" { + fmt.Fprintf(b, "%s -> %s: \"\" { class: %s }\n", from, to, class) + return + } + fmt.Fprintf(b, "%s -> %s: %s { class: %s }\n", from, to, quote(label), class) +} + +// edgeClass picks the D2 edge class. On-path edges are hot_edge. Off-path edges +// map by kind: eventless -> eventless, delayed -> delayed, and every other kind +// (event/internal/forbidden/wildcard) falls through to dim_edge. We collapse the +// remaining kinds to dim_edge deliberately: the forge look only distinguishes +// the cold dashed families (eventless/delayed) from the solid cold default, and +// the viewmodel's richer kind taxonomy has no separate forge styling. +func edgeClass(e viewmodel.ViewEdge) string { + if e.OnPath { + return "hot_edge" + } + switch e.Kind { + case viewmodel.EdgeEventless: + return "eventless" + case viewmodel.EdgeDelayed: + return "delayed" + default: + return "dim_edge" + } +} + +// edgeLabel builds a compact, deterministic edge label from the edge fields. +// Order: event, then "after", then guards in [..], then effects/assigns as a +// trailing list. Empty sections are skipped. +func edgeLabel(e viewmodel.ViewEdge) string { + var parts []string + if e.Event != "" { + parts = append(parts, e.Event) + } + if e.After != "" { + parts = append(parts, "after "+e.After) + } + if g := joinItems(e.Guards); g != "" { + parts = append(parts, "["+g+"]") + } + var eff []string + eff = append(eff, itemNames(e.Effects)...) + eff = append(eff, itemNames(e.Assigns)...) + if len(eff) > 0 { + parts = append(parts, "/ "+strings.Join(eff, ", ")) + } + return strings.Join(parts, " ") +} + +func itemNames(items []viewmodel.DetailItem) []string { + out := make([]string, 0, len(items)) + for i := range items { + out = append(out, items[i].Name) + } + return out +} + +func joinItems(items []viewmodel.DetailItem) string { + return strings.Join(itemNames(items), ", ") +} + +// hasLifecycle reports whether a node carries any lifecycle/invoke detail that +// should render as a compartment table. +func hasLifecycle(n viewmodel.ViewNode) bool { + return len(n.Entry) > 0 || len(n.Exit) > 0 || len(n.Invoke) > 0 +} + +// historyLabel derives the history marker: deep history -> "H*", shallow -> "H". +func historyLabel(n viewmodel.ViewNode) string { + if strings.Contains(n.Name, "*") || strings.Contains(strings.ToLower(n.Name), "deep") { + return "H*" + } + return "H" +} + +// writeHeader emits the vars/d2-config block pinning the ELK engine. +func writeHeader(b *strings.Builder, _ Theme) { + b.WriteString("vars: {\n") + b.WriteString(" d2-config: {\n") + b.WriteString(" layout-engine: elk\n") + b.WriteString(" }\n") + b.WriteString("}\n\n") +} + +// writeClasses emits the theme-parameterised classes block. +func writeClasses(b *strings.Builder, t Theme) { + fmt.Fprintf(b, "classes: {\n") + // atomic state: extruded steel slab. + fmt.Fprintf(b, " state: {\n shape: rectangle\n style: {\n fill: %s\n stroke: %s\n font-color: %s\n stroke-width: 2\n 3d: true\n }\n }\n", + quote(t.Steel), quote(t.Copper), quote(t.TextWarm)) + // invoke: hexagon. + fmt.Fprintf(b, " invoke: {\n shape: hexagon\n style: {\n fill: %s\n stroke: %s\n font-color: %s\n stroke-width: 2\n }\n }\n", + quote(t.InvokeFill), quote(t.Copper), quote(t.InvokeText)) + // init: small ember circle. + fmt.Fprintf(b, " init: {\n shape: circle\n width: 26\n style: {\n fill: %s\n stroke: %s\n font-color: %s\n }\n }\n", + quote(t.Ember), quote(t.Hot), quote(t.Bg)) + // history: copper circle. + fmt.Fprintf(b, " history: {\n shape: circle\n width: 34\n style: {\n fill: %s\n stroke: %s\n font-color: %s\n }\n }\n", + quote(t.Copper), quote(t.Copper), quote(t.HistoryText)) + // final: double-ring ember. + fmt.Fprintf(b, " final: {\n shape: circle\n width: 34\n style: {\n fill: %s\n stroke: %s\n stroke-width: 3\n font-color: %s\n multiple: true\n }\n }\n", + quote(t.Bg), quote(t.Ember), quote(t.InvokeText)) + // region: dashed copper border. + fmt.Fprintf(b, " region: {\n shape: rectangle\n style: {\n fill: %s\n stroke: %s\n font-color: %s\n stroke-dash: 3\n border-radius: 8\n }\n }\n", + quote(t.SteelDark), quote(t.Copper), quote(t.SoftOrange)) + // hot_edge: crisp ember, heavier. + fmt.Fprintf(b, " hot_edge: {\n style: {\n stroke: %s\n stroke-width: 3\n font-color: %s\n bold: true\n }\n }\n", + quote(t.Hot), quote(t.HotBright)) + // guard_edge. + fmt.Fprintf(b, " guard_edge: {\n style: {\n stroke: %s\n font-color: %s\n }\n }\n", + quote(t.ScaleGrey), quote(t.ScaleText)) + // eventless: cold dashed. + fmt.Fprintf(b, " eventless: {\n style: {\n stroke: %s\n stroke-dash: 4\n font-color: %s\n }\n }\n", + quote(t.ScaleGrey), quote(t.ScaleText)) + // delayed: copper dashed. + fmt.Fprintf(b, " delayed: {\n style: {\n stroke: %s\n stroke-dash: 4\n font-color: %s\n }\n }\n", + quote(t.Copper), quote(t.SoftOrange)) + // dim_node: cold extruded. + fmt.Fprintf(b, " dim_node: {\n shape: rectangle\n style: {\n fill: %s\n stroke: %s\n font-color: %s\n stroke-width: 1\n opacity: 0.85\n 3d: true\n }\n }\n", + quote(t.DimNodeFill), quote(t.ScaleGrey), quote(t.ScaleText)) + // dim_edge: cold faint. + fmt.Fprintf(b, " dim_edge: {\n style: {\n stroke: %s\n font-color: %s\n opacity: 0.7\n }\n }\n", + quote(t.ScaleGrey), quote(t.ScaleText)) + fmt.Fprintf(b, "}\n") +} + +// sanitizeKeyRe matches characters not allowed in a bare D2 key. +var sanitizeKeyRe = regexp.MustCompile(`[^A-Za-z0-9_]`) + +// sanitizeKey turns an arbitrary ID into a valid, stable D2 identifier. +func sanitizeKey(id string) string { + k := sanitizeKeyRe.ReplaceAllString(id, "_") + if k == "" { + k = "n" + } + // A key must not start with a digit for a clean bare identifier. + if k[0] >= '0' && k[0] <= '9' { + k = "n_" + k + } + return k +} + +// quote wraps a label/value in double quotes when it contains D2-special +// characters (spaces, the middle dot, > - : or a quote) or begins with '#' (the +// D2 comment marker — color values like "#ff7a18" MUST be quoted or D2 treats +// the rest of the line as a comment). Embedded quotes are escaped. Plain +// identifiers are returned bare. +func quote(s string) string { + if s == "" { + return `""` + } + if strings.HasPrefix(s, "#") || strings.ContainsAny(s, " ·>-:\"\n") { + return `"` + strings.ReplaceAll(s, `"`, `\"`) + `"` + } + return s +} + +// indent returns 2*depth spaces. +func indent(depth int) string { + return strings.Repeat(" ", depth) +} diff --git a/cmd/crucible/internal/render/emit_test.go b/cmd/crucible/internal/render/emit_test.go new file mode 100644 index 0000000..a3ec974 --- /dev/null +++ b/cmd/crucible/internal/render/emit_test.go @@ -0,0 +1,194 @@ +package render + +import ( + "flag" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stablekernel/crucible/cmd/crucible/internal/viewmodel" +) + +var update = flag.Bool("update", false, "update golden files") + +// goldenCheck emits D2 for vm and compares it to testdata/.d2.golden, +// writing the golden when -update is set. +func goldenCheck(t *testing.T, name string, vm viewmodel.ViewModel) string { + t.Helper() + got, err := EmitD2(vm, DefaultTheme) + if err != nil { + t.Fatalf("EmitD2: %v", err) + } + path := filepath.Join("testdata", name+".d2.golden") + if *update { + if err := os.WriteFile(path, []byte(got), 0o600); err != nil { + t.Fatalf("write golden: %v", err) + } + return got + } + want, rErr := os.ReadFile(path) + if rErr != nil { + t.Fatalf("read golden (run with -update first): %v", rErr) + } + if got != string(want) { + t.Errorf("EmitD2 output mismatch for %s.\n--- got ---\n%s\n--- want ---\n%s", name, got, want) + } + return got +} + +func TestEmit_AtomicOnPath(t *testing.T) { + vm := viewmodel.ViewModel{ + Nodes: []viewmodel.ViewNode{ + {ID: "a", Name: "a", Kind: viewmodel.NodeAtomic, OnPath: true}, + {ID: "b", Name: "b", Kind: viewmodel.NodeAtomic, OnPath: true}, + }, + Edges: []viewmodel.ViewEdge{ + {From: "a", To: "b", Event: "go", Kind: viewmodel.EdgeEvent, OnPath: true}, + }, + } + got := goldenCheck(t, "atomic_onpath", vm) + if !strings.Contains(got, "class: state") { + t.Error("want class: state for on-path atomic") + } + if !strings.Contains(got, `style.stroke: "`+DefaultTheme.Hot+`"`) { + t.Error("want hot rim stroke on on-path atomic") + } + if !strings.Contains(got, "class: hot_edge") { + t.Error("want hot_edge class for on-path edge") + } +} + +func TestEmit_OffPath(t *testing.T) { + vm := viewmodel.ViewModel{ + Nodes: []viewmodel.ViewNode{ + {ID: "a", Name: "a", Kind: viewmodel.NodeAtomic}, + {ID: "b", Name: "b", Kind: viewmodel.NodeAtomic}, + }, + Edges: []viewmodel.ViewEdge{ + {From: "a", To: "b", Event: "go", Kind: viewmodel.EdgeEvent}, + }, + } + got := goldenCheck(t, "offpath", vm) + if !strings.Contains(got, "class: dim_node") { + t.Error("want dim_node for off-path atomic") + } + if !strings.Contains(got, "class: dim_edge") { + t.Error("want dim_edge for off-path event edge") + } +} + +func TestEmit_LifecycleCompartment(t *testing.T) { + vm := viewmodel.ViewModel{ + Nodes: []viewmodel.ViewNode{ + { + ID: "session", + Name: "Session", + Kind: viewmodel.NodeAtomic, + Entry: []viewmodel.DetailItem{{Name: "openSocket"}}, + Exit: []viewmodel.DetailItem{{Name: "clearTimers"}}, + Invoke: []viewmodel.DetailItem{{Name: "paymentService"}}, + }, + }, + } + got := goldenCheck(t, "lifecycle", vm) + if !strings.Contains(got, "shape: class") { + t.Error("want shape: class for lifecycle node") + } + for _, want := range []string{"entry: openSocket", "exit: clearTimers", "invoke: paymentService"} { + if !strings.Contains(got, want) { + t.Errorf("want lifecycle row %q", want) + } + } +} + +func TestEmit_FinalNode(t *testing.T) { + vm := viewmodel.ViewModel{ + Nodes: []viewmodel.ViewNode{ + {ID: "done", Name: "done", Kind: viewmodel.NodeFinal}, + }, + } + got := goldenCheck(t, "final", vm) + if !strings.Contains(got, "class: final") { + t.Error("want class: final") + } + if !strings.Contains(got, `label: ""`) { + t.Error("want empty label for final node") + } +} + +func TestEmit_ParallelWithRegions(t *testing.T) { + vm := viewmodel.ViewModel{ + Nodes: []viewmodel.ViewNode{ + {ID: "par", Name: "par", Kind: viewmodel.NodeParallel}, + {ID: "ra1", Name: "ra1", Kind: viewmodel.NodeAtomic}, + {ID: "rb1", Name: "rb1", Kind: viewmodel.NodeAtomic}, + }, + Containers: []viewmodel.ViewContainer{ + {ID: "par", Name: "par", Kind: "parallel", Children: []string{"regionA", "regionB"}}, + {ID: "regionA", Name: "regionA", Kind: "region", Children: []string{"ra1"}}, + {ID: "regionB", Name: "regionB", Kind: "region", Children: []string{"rb1"}}, + }, + } + got := goldenCheck(t, "parallel_regions", vm) + if strings.Count(got, "class: region") != 2 { + t.Errorf("want two region classes, got %d", strings.Count(got, "class: region")) + } + // The parallel container must carry EXPLICIT forge styling so no DarkMauve + // (mauve fill/border, lavender title) bleeds through. Off-path here -> plain + // ember stroke at width 2. + for _, want := range []string{ + "style.fill: " + quote(DefaultTheme.SteelDark), + "style.stroke: " + quote(DefaultTheme.Ember), + "style.font-color: " + quote(DefaultTheme.TextWarm), + "style.stroke-width: 2", + } { + if !strings.Contains(got, want) { + t.Errorf("want parallel container styling %q", want) + } + } +} + +func TestEmit_CompositeContainerStyling(t *testing.T) { + // An on-path composite container must use the HOT ember stroke at the heavier + // width 3, with the steel panel fill and warm title. + vm := viewmodel.ViewModel{ + Nodes: []viewmodel.ViewNode{ + {ID: "active", Name: "active", Kind: viewmodel.NodeComposite, OnPath: true}, + {ID: "inner", Name: "inner", Kind: viewmodel.NodeAtomic, OnPath: true}, + }, + Containers: []viewmodel.ViewContainer{ + {ID: "active", Name: "active", Kind: "composite", Children: []string{"inner"}}, + }, + } + got, err := EmitD2(vm, DefaultTheme) + if err != nil { + t.Fatalf("EmitD2: %v", err) + } + for _, want := range []string{ + "style.fill: " + quote(DefaultTheme.SteelDark), + "style.stroke: " + quote(DefaultTheme.Hot), + "style.font-color: " + quote(DefaultTheme.TextWarm), + "style.stroke-width: 3", + } { + if !strings.Contains(got, want) { + t.Errorf("want on-path composite styling %q", want) + } + } +} + +func TestEmit_SpecialCharEscaping(t *testing.T) { + vm := viewmodel.ViewModel{ + Nodes: []viewmodel.ViewNode{ + {ID: "a", Name: "a", Kind: viewmodel.NodeAtomic}, + {ID: "b", Name: "b", Kind: viewmodel.NodeAtomic}, + }, + Edges: []viewmodel.ViewEdge{ + {From: "a", To: "b", Event: "done · ok", Kind: viewmodel.EdgeEvent}, + }, + } + got := goldenCheck(t, "special_chars", vm) + if !strings.Contains(got, `"done · ok"`) { + t.Error("want quoted label containing the middle dot") + } +} diff --git a/cmd/crucible/internal/render/render.go b/cmd/crucible/internal/render/render.go new file mode 100644 index 0000000..72d1bce --- /dev/null +++ b/cmd/crucible/internal/render/render.go @@ -0,0 +1,464 @@ +package render + +import ( + "context" + "encoding/base64" + "fmt" + "regexp" + "sort" + "strconv" + "strings" + + "oss.terrastruct.com/d2/d2graph" + "oss.terrastruct.com/d2/d2layouts/d2elklayout" + "oss.terrastruct.com/d2/d2lib" + "oss.terrastruct.com/d2/d2renderers/d2svg" + "oss.terrastruct.com/d2/d2target" + "oss.terrastruct.com/d2/d2themes/d2themescatalog" + "oss.terrastruct.com/d2/lib/log" + "oss.terrastruct.com/d2/lib/textmeasure" + "oss.terrastruct.com/util-go/go2" + + "github.com/stablekernel/crucible/cmd/crucible/internal/viewmodel" +) + +// RenderSVG emits D2 from the viewmodel, compiles it with the ELK layout and the +// forge theme overrides, renders to SVG, then runs the forge v5 post-process +// pipeline (stripGlow -> recolorLifecycleRows -> equalizeRegions -> +// centerRegions) and returns the final SVG bytes. +// +//nolint:revive // RenderSVG is the contracted public API name for this package. +func RenderSVG(vm viewmodel.ViewModel, theme Theme) ([]byte, error) { + d2src, err := EmitD2(vm, theme) + if err != nil { + return nil, fmt.Errorf("emit d2: %w", err) + } + + ruler, err := textmeasure.NewRuler() + if err != nil { + return nil, fmt.Errorf("new ruler: %w", err) + } + + layoutResolver := func(_ string) (d2graph.LayoutGraph, error) { + return func(ctx context.Context, g *d2graph.Graph) error { + return d2elklayout.Layout(ctx, g, &d2elklayout.ConfigurableOpts{ + Algorithm: "layered", + NodeSpacing: 90, + EdgeNodeSpacing: 80, + SelfLoopSpacing: 50, + Padding: "[top=50,left=50,bottom=50,right=110]", + }) + }, nil + } + + themeID := d2themescatalog.DarkMauve.ID + renderOpts := &d2svg.RenderOpts{ + Pad: go2.Pointer(int64(60)), + ThemeID: &themeID, + ThemeOverrides: buildThemeOverrides(theme), + } + compileOpts := &d2lib.CompileOptions{ + LayoutResolver: layoutResolver, + Ruler: ruler, + } + ctx := log.WithDefault(context.Background()) + diagram, _, err := d2lib.Compile(ctx, d2src, compileOpts, renderOpts) + if err != nil { + return nil, fmt.Errorf("compile d2: %w", err) + } + out, err := d2svg.Render(diagram, renderOpts) + if err != nil { + return nil, fmt.Errorf("render svg: %w", err) + } + + svg := string(out) + svg, _ = stripGlow(svg, theme) + svg, _ = recolorLifecycleRows(svg, theme) + svg, _ = scrubMauve(svg, theme) + svg, _ = equalizeRegions(svg) + svg, _ = centerRegions(svg, regionPaths(vm)) + return []byte(svg), nil +} + +// darkMauveDefaults maps each raw DarkMauve (Catppuccin Mocha) palette hex that +// D2 v0.7.1 bakes into the SVG's embedded CSS stylesheet — regardless of the +// ThemeOverrides we pass — to its forge equivalent. D2 always emits the full +// palette as inert utility classes (.fill-B1, .stroke-B5, the .appendix/.md +// GitHub-markdown vars, etc.); even though no live shape in our output uses any +// of them (every shape carries an explicit inline forge color), the raw mauve +// and lavender hexes still appear in the file. scrubMauve rewrites them so a +// grep of the output SVG finds ZERO mauve/lavender. Keys are lowercased because +// the comparison is case-insensitive. +// +// The targets mirror buildThemeOverrides: the value each DarkMauve slot is +// overridden to (B1/B2->Ember, B3->Copper, B4->Steel, B5->SteelDark, +// B6/N6->CanvasN6, N1->TextWarm, N2->TextSecondary, N3->ScaleText, N4->Steel, +// N5->SteelDark, N7->Bg, AA2->SoftOrange). DarkMauve reuses some hexes across +// slots (e.g. #45475A is B5/N5/AA4/AB4, #313244 is B6/N6/AA5/AB5); a single +// mapping per hex is therefore unambiguous and lands on the forge color. +func darkMauveDefaults(theme Theme) map[string]string { + return map[string]string{ + "#cba6f7": theme.Ember, // B1, B2 + "#6c7086": theme.Copper, // B3 + "#585b70": theme.Steel, // B4, N4 + "#45475a": theme.SteelDark, // B5, N5, AA4, AB4 + "#313244": theme.CanvasN6, // B6, N6, AA5, AB5 + "#cdd6f4": theme.TextWarm, // N1 + "#bac2de": theme.TextSecondary, // N2 + "#a6adc8": theme.ScaleText, // N3 + "#1e1e2e": theme.Bg, // N7 + "#f38ba8": theme.SoftOrange, // AA2 + } +} + +// scrubMauve rewrites every residual DarkMauve hex (see darkMauveDefaults) to +// its forge equivalent, case-insensitively, so no mauve/lavender survives in the +// output SVG. It returns the number of replacements made. The rewrite is purely +// cosmetic-safe: it only touches color literals, and no live shape depends on +// these defaults (each carries an explicit inline forge color already). +func scrubMauve(svg string, theme Theme) (string, int) { + count := 0 + for from, to := range darkMauveDefaults(theme) { + re := regexp.MustCompile(`(?i)` + regexp.QuoteMeta(from)) + svg = re.ReplaceAllStringFunc(svg, func(string) string { + count++ + return to + }) + } + return svg, count +} + +// buildThemeOverrides maps theme fields onto the D2 DarkMauve override slots, +// matching the forge v5 reference assignment. +func buildThemeOverrides(t Theme) *d2target.ThemeOverrides { + return &d2target.ThemeOverrides{ + N1: go2.Pointer(t.TextWarm), + N2: go2.Pointer(t.TextSecondary), + N3: go2.Pointer(t.ScaleText), + N4: go2.Pointer(t.CanvasN4), + N5: go2.Pointer(t.CanvasN5), + N6: go2.Pointer(t.CanvasN6), + N7: go2.Pointer(t.Bg), + B1: go2.Pointer(t.Ember), + B2: go2.Pointer(t.Ember), + B3: go2.Pointer(t.Copper), + B4: go2.Pointer(t.Steel), + B5: go2.Pointer(t.SteelDark), + B6: go2.Pointer(t.CanvasN6), + AA2: go2.Pointer(t.SoftOrange), + AA4: go2.Pointer(t.AccentAA4), + AA5: go2.Pointer(t.AccentAA5), + AB4: go2.Pointer(t.AccentAB4), + AB5: go2.Pointer(t.AccentAB5), + } +} + +// regionPaths derives the fully-qualified dotted D2 paths of every region +// container in the viewmodel, generalising the forge v5 hardcoded +// {"connected.work","connected.heartbeat"} to arbitrary machines. +// +// It reuses the emitter's index (which records parent relationships: container X +// is the parent of Y when Y's ID is in X.Children) so a region's path is built +// by walking its parent chain. Order is deterministic (container document order). +func regionPaths(vm viewmodel.ViewModel) []string { + idx := buildIndex(vm) + var paths []string + for i := range vm.Containers { + c := vm.Containers[i] + if c.Kind != "region" { + continue + } + paths = append(paths, idx.dottedPath(c.ID)) + } + return paths +} + +// --------------------------------------------------------------------------- +// SVG post-process — ported verbatim in behavior from forge v5, sourcing all +// colors from the theme rather than package constants. +// --------------------------------------------------------------------------- + +// stripGlow removes any blocks and filter="url(#...)" references, drops +// now-empty , and returns the number of hot-stroke elements seen. The SVG +// carries zero blur/glow filters afterwards. +func stripGlow(svg string, theme Theme) (string, int) { + filterBlock := regexp.MustCompile(`(?is)]*>.*?`) + svg = filterBlock.ReplaceAllString(svg, "") + filterRef := regexp.MustCompile(`(?i)\s*filter\s*=\s*"url\(#[^"]*\)"`) + svg = filterRef.ReplaceAllString(svg, "") + emptyDefs := regexp.MustCompile(`(?is)\s*`) + svg = emptyDefs.ReplaceAllString(svg, "") + + count := 0 + hotLower := strings.ToLower(theme.Hot) + tagRe := regexp.MustCompile(`(?i)<(path|polyline|line|polygon|rect|ellipse|circle)\b[^>]*?>`) + for _, tag := range tagRe.FindAllString(svg, -1) { + if strings.Contains(strings.ToLower(tag), hotLower) { + count++ + } + } + return svg, count +} + +// recolorLifecycleRows forces lifecycle compartment row text to the forge +// scheme: values (fill-AA2) -> white textWarm with the class token stripped, "+" +// markers (fill-B2) -> soft orange with the class stripped, and steel-colored +// keys -> soft orange. Returns how many tags were rewritten. +func recolorLifecycleRows(svg string, theme Theme) (string, int) { + count := 0 + steelLower := strings.ToLower(theme.Steel) + rowText := regexp.MustCompile(`(?i)]*>`) + out := rowText.ReplaceAllStringFunc(svg, func(tag string) string { + lower := strings.ToLower(tag) + switch { + case strings.Contains(lower, "fill-aa2"): + count++ + tag = regexp.MustCompile(`(?i)fill="[^"]*"`).ReplaceAllString(tag, `fill="`+theme.TextWarm+`"`) + tag = regexp.MustCompile(`(?i)\s+fill-AA2`).ReplaceAllString(tag, "") + return tag + case strings.Contains(lower, "fill-b2"): + count++ + tag = regexp.MustCompile(`(?i)fill="[^"]*"`).ReplaceAllString(tag, `fill="`+theme.SoftOrange+`"`) + tag = regexp.MustCompile(`(?i)\s+fill-B2`).ReplaceAllString(tag, "") + return tag + case strings.Contains(lower, `fill="`+steelLower+`"`): + count++ + tag = regexp.MustCompile(`(?i)fill="[^"]*"`).ReplaceAllString(tag, `fill="`+theme.SoftOrange+`"`) + return tag + } + return tag + }) + return out, count +} + +// equalizeRegions resizes the dashed region rects to a shared max width/height +// so the dashed borders read as equal-size plates. Returns how many region rects +// were resized. +func equalizeRegions(svg string) (string, int) { + rectRe := regexp.MustCompile(`]*>`) + wRe := regexp.MustCompile(`width="([0-9.]+)"`) + hRe := regexp.MustCompile(`height="([0-9.]+)"`) + var regions []string + for _, m := range rectRe.FindAllString(svg, -1) { + l := strings.ToLower(m) + if strings.Contains(l, "dasharray") || strings.Contains(l, "stroke-dash") { + regions = append(regions, m) + } + } + if len(regions) < 2 { + return svg, 0 + } + maxW, maxH := "", "" + parse := func(re *regexp.Regexp, s string) string { + if mm := re.FindStringSubmatch(s); mm != nil { + return mm[1] + } + return "" + } + bigger := func(a, b string) string { + fa, _ := strconv.ParseFloat(a, 64) + fb, _ := strconv.ParseFloat(b, 64) + if fb > fa { + return b + } + return a + } + for _, r := range regions { + w, h := parse(wRe, r), parse(hRe, r) + if maxW == "" { + maxW, maxH = w, h + } else { + maxW, maxH = bigger(maxW, w), bigger(maxH, h) + } + } + count := 0 + for _, r := range regions { + nr := wRe.ReplaceAllString(r, `width="`+maxW+`"`) + nr = hRe.ReplaceAllString(nr, `height="`+maxH+`"`) + if nr != r { + svg = strings.Replace(svg, r, nr, 1) + } + count++ + } + return svg, count +} + +// centerRegions horizontally centers each region's child contents inside its +// (equalized) region box. regionPaths are the fully-qualified dotted D2 paths of +// the region containers, derived from the viewmodel (generalising forge v5's +// hardcoded list). Returns how many child groups were shifted. +func centerRegions(svg string, regionList []string) (string, int) { + if len(regionList) == 0 { + return svg, 0 + } + b64decode := func(tok string) string { + for len(tok)%4 != 0 { + tok += "=" + } + if b, err := base64.StdEncoding.DecodeString(tok); err == nil { + return string(b) + } + return "" + } + blockEnd := func(s string, start int) int { + depth := 0 + i := start + for i < len(s) { + if strings.HasPrefix(s[i:], "= len(s) || s[i+2] == ' ' || s[i+2] == '>') { + depth++ + j := strings.IndexByte(s[i:], '>') + if j < 0 { + return len(s) + } + if s[i+j-1] == '/' { + depth-- + } + i += j + 1 + continue + } + if strings.HasPrefix(s[i:], "") { + depth-- + i += 4 + if depth == 0 { + return i + } + continue + } + i++ + } + return len(s) + } + classRe := regexp.MustCompile(`class="([^"]*)"`) + fattr := func(re *regexp.Regexp, s string) (float64, bool) { + if m := re.FindStringSubmatch(s); m != nil { + if v, err := strconv.ParseFloat(m[1], 64); err == nil { + return v, true + } + } + return 0, false + } + xRe := regexp.MustCompile(`\bx="([0-9.\-]+)"`) + wRe := regexp.MustCompile(`\bwidth="([0-9.]+)"`) + cxRe := regexp.MustCompile(`\bcx="([0-9.\-]+)"`) + rRe := regexp.MustCompile(`\br="([0-9.]+)"`) + dashRe := regexp.MustCompile(`(?i)dasharray|stroke-dash`) + + type gblock struct { + path string + start, end int + } + var blocks []gblock + for i := 0; i < len(svg); { + if strings.HasPrefix(svg[i:], "') { + end := blockEnd(svg, i) + open := svg[i : strings.IndexByte(svg[i:], '>')+i+1] + path := "" + if m := classRe.FindStringSubmatch(open); m != nil { + if fields := strings.Fields(m[1]); len(fields) > 0 { + path = b64decode(fields[0]) + } + } + blocks = append(blocks, gblock{path, i, end}) + i = end + continue + } + i++ + } + + nodeExtent := func(seg string) (minx, maxx float64, ok bool) { + minx, maxx = 1e18, -1e18 + tagRe := regexp.MustCompile(`(?i)<(rect|ellipse|circle|path)\b[^>]*>`) + for _, t := range tagRe.FindAllString(seg, -1) { + if x, has := fattr(xRe, t); has { + if w, hasw := fattr(wRe, t); hasw { + if x < minx { + minx = x + } + if x+w > maxx { + maxx = x + w + } + ok = true + } + } else if cx, hascx := fattr(cxRe, t); hascx { + if r, hasr := fattr(rRe, t); hasr { + if cx-r < minx { + minx = cx - r + } + if cx+r > maxx { + maxx = cx + r + } + ok = true + } + } + } + return + } + + count := 0 + type edit struct { + start, end int + dx float64 + } + var edits []edit + + for _, region := range regionList { + prefix := region + "." + var boxCenter float64 + var haveBox bool + nMin, nMax := 1e18, -1e18 + var haveNodes bool + var spans []gblock + for _, bl := range blocks { + if bl.path == region { + seg := svg[bl.start:bl.end] + rectRe := regexp.MustCompile(`]*>`) + for _, rt := range rectRe.FindAllString(seg, -1) { + if dashRe.MatchString(rt) { + x, hx := fattr(xRe, rt) + w, hw := fattr(wRe, rt) + if hx && hw { + boxCenter = x + w/2 + haveBox = true + } + } + } + continue + } + if !strings.HasPrefix(bl.path, prefix) { + continue + } + spans = append(spans, bl) + if !strings.Contains(bl.path, "(") { + if lo, hi, ok := nodeExtent(svg[bl.start:bl.end]); ok { + if lo < nMin { + nMin = lo + } + if hi > nMax { + nMax = hi + } + haveNodes = true + } + } + } + if !haveBox || !haveNodes || len(spans) == 0 { + continue + } + contentCenter := (nMin + nMax) / 2 + dx := boxCenter - contentCenter + if dx > -0.5 && dx < 0.5 { + continue + } + for _, s := range spans { + edits = append(edits, edit{s.start, s.end, dx}) + count++ + } + } + + sort.Slice(edits, func(a, b int) bool { return edits[a].start > edits[b].start }) + for _, e := range edits { + seg := svg[e.start:e.end] + wrapped := fmt.Sprintf(`%s`, e.dx, seg) + svg = svg[:e.start] + wrapped + svg[e.end:] + } + return svg, count +} diff --git a/cmd/crucible/internal/render/render_test.go b/cmd/crucible/internal/render/render_test.go new file mode 100644 index 0000000..491d032 --- /dev/null +++ b/cmd/crucible/internal/render/render_test.go @@ -0,0 +1,203 @@ +package render + +import ( + "os" + "regexp" + "strconv" + "testing" + + "github.com/stablekernel/crucible/cmd/crucible/internal/viewmodel" + "github.com/stablekernel/crucible/state" +) + +// loadComposite loads the shared composite.json fixture, mirroring the idiom in +// the viewmodel package's tests. +func loadComposite(t *testing.T) *state.IR[string, string, any] { + t.Helper() + b, err := os.ReadFile("../../testdata/composite.json") + if err != nil { + t.Fatalf("read fixture: %v", err) + } + ir, err := state.LoadFromJSON[string, string, any](b) + if err != nil { + t.Fatalf("load fixture: %v", err) + } + return ir +} + +func renderComposite(t *testing.T, opts viewmodel.ProjectionOptions) string { + t.Helper() + ir := loadComposite(t) + vm, err := viewmodel.BuildScoped(ir, nil, opts) + if err != nil { + t.Fatalf("BuildScoped: %v", err) + } + out, err := RenderSVG(vm, DefaultTheme) + if err != nil { + t.Fatalf("RenderSVG: %v", err) + } + return string(out) +} + +func TestRender_StructuralAndNoGlow(t *testing.T) { + // Path scope so an on-path hot_edge exists, guaranteeing the hot color is + // present alongside ember and steel from the base palette. + svg := renderComposite(t, viewmodel.ProjectionOptions{ + Level: viewmodel.Full, + Scope: viewmodel.ScopePath, + From: "working", + To: "review", + Mode: viewmodel.ModeShortest, + }) + + if !regexp.MustCompile(`]*>`) + wRe := regexp.MustCompile(`width="([0-9.]+)"`) + hRe := regexp.MustCompile(`height="([0-9.]+)"`) + var ws, hs []float64 + for _, m := range rectRe.FindAllString(svg, -1) { + if !regexp.MustCompile(`(?i)dasharray|stroke-dash`).MatchString(m) { + continue + } + if wm := wRe.FindStringSubmatch(m); wm != nil { + if v, err := strconv.ParseFloat(wm[1], 64); err == nil { + ws = append(ws, v) + } + } + if hm := hRe.FindStringSubmatch(m); hm != nil { + if v, err := strconv.ParseFloat(hm[1], 64); err == nil { + hs = append(hs, v) + } + } + } + if len(ws) < 2 { + t.Fatalf("expected at least two dashed region rects, got %d", len(ws)) + } + for i := 1; i < len(ws); i++ { + if ws[i] != ws[0] { + t.Errorf("dashed region widths not equal: %v", ws) + break + } + } + for i := 1; i < len(hs); i++ { + if hs[i] != hs[0] { + t.Errorf("dashed region heights not equal: %v", hs) + break + } + } +} + +// TestRender_NoMauveBleed asserts the rendered SVG contains ZERO raw DarkMauve +// (Catppuccin Mocha) palette hexes — neither on live shapes nor in D2's embedded +// utility stylesheet — so no mauve fill / mauve border / lavender title can ever +// leak from the base theme. It checks both an on-path (composite hot border) and +// a whole-scope projection. +func TestRender_NoMauveBleed(t *testing.T) { + mauve := []string{ + "#CBA6f7", "#6C7086", "#585B70", "#45475A", "#313244", + "#CDD6F4", "#BAC2DE", "#A6ADC8", "#1E1E2E", "#f38BA8", + } + cases := []struct { + name string + opts viewmodel.ProjectionOptions + }{ + {"path", viewmodel.ProjectionOptions{Level: viewmodel.Lifecycle, Scope: viewmodel.ScopePath, From: "working", To: "review", Mode: viewmodel.ModeShortest}}, + {"whole", viewmodel.ProjectionOptions{Level: viewmodel.Full, Scope: viewmodel.ScopeWhole}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + svg := renderComposite(t, tc.opts) + for _, hex := range mauve { + if regexp.MustCompile(`(?i)` + regexp.QuoteMeta(hex)).MatchString(svg) { + t.Errorf("mauve/lavender hex %q leaked into %s SVG", hex, tc.name) + } + } + }) + } +} + +// TestRender_ContainerForgeStyle verifies the composite/parallel container shape +// carries the explicit forge panel fill (steelDark) and an ember-family border, +// never a DarkMauve default. It inspects the live (non-`).ReplaceAllString(svg, "") + // At least one container rect: steelDark fill + ember stroke (off-path here). + rect := regexp.MustCompile(`(?i)]*fill="` + regexp.QuoteMeta(DefaultTheme.SteelDark) + `"[^>]*>`) + found := false + for _, m := range rect.FindAllString(body, -1) { + if regexp.MustCompile(`(?i)stroke="` + regexp.QuoteMeta(DefaultTheme.Ember) + `"`).MatchString(m) { + found = true + break + } + } + if !found { + t.Error("expected a container rect with steelDark fill and ember stroke") + } +} + +func TestRender_LifecycleRecolored(t *testing.T) { + // A path scope that includes the composite's lifecycle-bearing "active" + // state, projected at Lifecycle level so entry/exit/invoke rows are emitted. + svg := renderComposite(t, viewmodel.ProjectionOptions{ + Level: viewmodel.Lifecycle, + Scope: viewmodel.ScopePath, + From: "working", + To: "review", + Mode: viewmodel.ModeShortest, + }) + // The fill-AA2 CSS rule may remain in the