From 0d82de9735548f23c037fbe39157fe4256ac6c14 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:25:04 +0000 Subject: [PATCH 1/5] feat(view): list an action's unconnected parameter pins in a parameters compartment The Mermaid, D2 and PlantUML action and mixed forms draw only the pins an edge ends at; the parameters no edge reaches are now listed under a parameters heading in their node's label, as the tree form lists them, so a step-less calc shows in x, in y, out result in every form and the "pin(s) not drawn" notice no longer names them. Co-Authored-By: jason.han --- internal/ir/view/action_pins_test.go | 20 +++++++++--- internal/ir/view/d2.go | 3 +- internal/ir/view/d2_test.go | 5 ++- internal/ir/view/label.go | 26 ++++++++++++++++ internal/ir/view/mermaid.go | 4 ++- internal/ir/view/plantuml.go | 6 +++- internal/ir/view/plantuml_test.go | 2 +- internal/ir/view/ports.go | 9 ++++++ internal/ir/view/ports_test.go | 4 +-- internal/ir/view/testdata/action.d2.golden | 3 +- .../ir/view/testdata/action.mermaid.golden | 5 +-- .../ir/view/testdata/action.plantuml.golden | 5 ++- .../testdata/calc-action-view.mermaid.golden | 31 +++++++++++++++---- .../testdata/glyphs-action.plantuml.golden | 5 ++- .../layout-collapsed-action.mermaid.golden | 8 +++-- .../ir/view/testdata/links-action-d2.golden | 3 +- .../view/testdata/links-action-mermaid.golden | 5 +-- .../testdata/links-action-plantuml.golden | 5 ++- 18 files changed, 110 insertions(+), 39 deletions(-) diff --git a/internal/ir/view/action_pins_test.go b/internal/ir/view/action_pins_test.go index 878d544d45..fe6309582f 100644 --- a/internal/ir/view/action_pins_test.go +++ b/internal/ir/view/action_pins_test.go @@ -242,7 +242,8 @@ func TestTextActionFramePins(t *testing.T) { } // The Mermaid form draws a frame pin an edge ends at as a node inside the -// frame's subgraph, as it draws the nodes' pins, and says which pins it leaves out. +// frame's subgraph, as it draws the nodes' pins, and lists the rest in a +// `parameters` compartment of their node. func TestMermaidActionFramePins(t *testing.T) { rendering := renderActionPins(t, "Pins::ToastBread") source := rendering.MermaidWith(fullNotation) @@ -252,20 +253,29 @@ func TestMermaidActionFramePins(t *testing.T) { } } loose := renderActionPins(t, "Pins::Loose").MermaidWith(fullNotation) - if want := "%% not represented: 3 pin(s) not drawn (heat.t, pack.t, pack.boxed); a flowchart draws the pins an edge ends at"; !strings.Contains(loose, want) { - t.Errorf("Mermaid lacks the notice %q:\n%s", want, loose) + for _, want := range []string{"**heat : Heat**\nparameters\nout ^t`", "**pack : Pack**\nparameters\nin t\nout ^boxed`"} { + if !strings.Contains(loose, want) { + t.Errorf("Mermaid does not list the unconnected pins %q:\n%s", want, loose) + } + } + if strings.Contains(loose, "pin(s) not drawn") { + t.Errorf("Mermaid notices pins it lists:\n%s", loose) } } // PlantUML's state grammar has no pin: a binding is an undirected line between -// the frame and the node naming the pins, and the pins left undrawn are noticed. +// the frame and the node naming the pins, and every pin is listed in its node's +// `parameters` compartment. func TestPlantUMLActionFramePins(t *testing.T) { rendering := renderActionPins(t, "Pins::ToastBread") source, _ := rendering.PlantUMLWith(fullNotation) for _, want := range []string{"n0 -- n1 : bread = b\n", "n2 -- n0 : boxed = toast\n", - "' not represented: 6 pin(s) not drawn (Pins::ToastBread.bread, Pins::ToastBread.toast, heat.b, heat.t, pack.t, pack.boxed); PlantUML's state grammar has no pin, so the edges name them\n"} { + "**ToastBread**\\nparameters\\nin bread\\nout toast\" as n0", "**heat : Heat**\\nparameters\\nin b\\nout ^t\" as n1", "**pack : Pack**\\nparameters\\nin ^t\\nout ^boxed\" as n2"} { if !strings.Contains(source, want) { t.Errorf("PlantUML lacks %q:\n%s", want, source) } } + if strings.Contains(source, "pin(s) not drawn") { + t.Errorf("PlantUML notices pins it lists:\n%s", source) + } } diff --git a/internal/ir/view/d2.go b/internal/ir/view/d2.go index c6cf8cc9e8..478b9385e1 100644 --- a/internal/ir/view/d2.go +++ b/internal/ir/view/d2.go @@ -57,6 +57,7 @@ func (r *Rendering) D2With(options Options) (string, error) { } if r.Kind == KindAction { w.used = r.usedPorts(w.ports) + w.labels.listedPin = r.unconnectedPin(w.used) } for _, root := range r.Roots { w.fills.collect(root) @@ -119,7 +120,7 @@ func (r *Rendering) d2Notices(options Options, w *d2Writer) []string { notices = append(notices, linesNotice(options.Lines, FormD2)) } if r.Kind == KindAction { - if pins := r.undrawnPins(func(node *Node, port Port) bool { return w.used[node.ID][port.ID] }); len(pins) > 0 { + if pins := r.undrawnPins(func(node *Node, port Port) bool { return w.used[node.ID][port.ID] || port.Parameter }); len(pins) > 0 { notices = append(notices, fmt.Sprintf("%d pin(s) not drawn (%s); D2 draws the pins an edge ends at", len(pins), strings.Join(pins, ", "))) } diff --git a/internal/ir/view/d2_test.go b/internal/ir/view/d2_test.go index e89b58cb7d..f0180c1d9e 100644 --- a/internal/ir/view/d2_test.go +++ b/internal/ir/view/d2_test.go @@ -243,7 +243,7 @@ func TestD2StateDiagram(t *testing.T) { // An action graph takes the pseudostate glyphs: a fork and a join a bar, a // decision a diamond named by the node, the final a double-bordered dot; the // pins an edge ends at are drawn inside their action and joined by path, the -// rest noticed. +// rest listed in the action's `parameters` compartment. func TestD2ActionGlyphsAndPins(t *testing.T) { d2, err := render(t, "action.sysml", "FlowViews::driveView").D2With(fullNotation) if err != nil { @@ -257,8 +257,7 @@ func TestD2ActionGlyphsAndPins(t *testing.T) { "\n bar: { shape: rectangle; width: 80; height: 8; style: { fill: black; stroke: black } }\n", "\n choice: { shape: diamond; style: { fill: white; stroke: \"#181818\"; stroke-width: 1; font-color: black; font-size: 14 } }\n", "# not represented: 2 fork/join name(s) (split, sync); D2's fork bar draws no label\n", - "# not represented: 1 pin(s) not drawn (provide.fuel); D2 draws the pins an edge ends at\n", - "\n n2: \"«action»\\nprovide : Provide\" {\n class: usage\n \"n2.1\": \"^torque\" { class: pin }\n }\n", + "\n n2: \"«action»\\nprovide : Provide\\nparameters\\nin ^fuel\" {\n class: usage\n \"n2.1\": \"^torque\" { class: pin }\n }\n", "\n \"n3.0\": \"reading\" { class: pin }\n", "\nn0.n2.\"n2.1\" -> n0.n3.\"n3.0\": \"torque to reading\" { class: edge }\n", } { diff --git a/internal/ir/view/label.go b/internal/ir/view/label.go index 89313f5bc3..8e5dedfd2d 100644 --- a/internal/ir/view/label.go +++ b/internal/ir/view/label.go @@ -17,6 +17,9 @@ type labeller struct { simple map[*Node]string // a node's distinguishing suffix, when names are drawn simple skin dotSkin // the DOT skin labels are composed and measured for shown Shown // the optional notation elements the labels carry + // listedPin reports a parameter pin the form lists in the node's `parameters` + // compartment instead of drawing it; nil lists none. + listedPin func(node *Node, port Port) bool } // labelsOf finds the namespace the named roots share — the longest run of @@ -349,6 +352,7 @@ func (l labeller) lines(node *Node) []string { } lines = append(lines, l.headLines(node)...) lines = append(lines, l.details(node)...) + lines = append(lines, l.parameterLines(node)...) return append(lines, l.compartmentLines(node)...) } @@ -361,9 +365,31 @@ func (l labeller) titleLines(node *Node) []string { } lines = append(lines, head...) lines = append(lines, l.details(node)...) + lines = append(lines, l.parameterLines(node)...) return append(lines, l.compartmentLines(node)...) } +// parameterLines are the node's listed parameter pins as the notation's +// `parameters` compartment, `in x : Integer` a line under the heading. +func (l labeller) parameterLines(node *Node) []string { + var lines []string + for _, port := range node.Ports { + if port.Parameter && l.listedPin != nil && l.listedPin(node, port) { + lines = append(lines, parameterPinLine(port)) + } + } + if len(lines) == 0 { + return nil + } + return append([]string{"parameters"}, lines...) +} + +// parameterPinLine is a parameter pin as a compartment line: direction, name +// and, when typings are shown, type. +func parameterPinLine(port Port) string { + return strings.TrimSpace(port.Direction.String() + " " + port.label()) +} + // cameoStateDetails splits a state's detail into Cameo's compartment lines, one // per behaviour, and drops the `initial` marker the initial dot already draws. // The detail is split as notation, so a name quoting a comma stays one; the diff --git a/internal/ir/view/mermaid.go b/internal/ir/view/mermaid.go index 0aa4056312..ceffe8f100 100644 --- a/internal/ir/view/mermaid.go +++ b/internal/ir/view/mermaid.go @@ -48,6 +48,7 @@ func (r *Rendering) MermaidWith(options Options) string { labels := labelsOf(r.Roots, false, nil, r.Shown) labels.skin = skinOf(options.Style) ports := r.portView(options.Ports) + labels.listedPin = r.unconnectedPin(r.usedPorts(ports)) r.writeMermaidFrontmatter(&b, labels, options, ports) if r.Run { fmt.Fprintf(&b, "%%%% run — %s rendering", r.Kind) @@ -115,7 +116,7 @@ func (r *Rendering) mermaidNotices(options Options) []string { if r.Kind == KindAction || r.Kind == KindMixed { used := r.usedPorts(r.portView(options.Ports)) if pins := r.undrawnPins(func(node *Node, port Port) bool { - return r.Kind == KindMixed && port.Direction == PortUndirected || used[node.ID][port.ID] + return r.Kind == KindMixed && port.Direction == PortUndirected || used[node.ID][port.ID] || port.Parameter }); len(pins) > 0 { notices = append(notices, fmt.Sprintf("%d pin(s) not drawn (%s); a flowchart draws the pins an edge ends at", len(pins), strings.Join(pins, ", "))) @@ -1293,6 +1294,7 @@ func mermaidLabel(node *Node, labels labeller, lines []string, title bool) strin markdown = append(markdown, "**"+line+"**") } markdown = append(markdown, labels.details(node)...) + markdown = append(markdown, labels.parameterLines(node)...) markdown = append(markdown, labels.compartmentLines(node)...) return `"` + "`" + strings.Join(markdown, "\n") + "`" + `"` } diff --git a/internal/ir/view/plantuml.go b/internal/ir/view/plantuml.go index 6514f466f7..ba1077703d 100644 --- a/internal/ir/view/plantuml.go +++ b/internal/ir/view/plantuml.go @@ -51,6 +51,7 @@ func (r *Rendering) PlantUMLWith(options Options) (string, error) { w := &plantumlWriter{kind: r.Kind, borders: r.Kind.paletteBorders(), fills: familyFills{palette: options.Palette, tree: r.Kind == KindTree}, labels: labelsOf(r.Roots, false, nil, r.Shown), ports: r.portView(options.Ports), links: options.Links, writtenPorts: make(map[string]struct{}), nesting: r.nestingEdges()} + w.labels.listedPin = r.unconnectedPin(nil) for _, root := range r.Roots { w.fills.collect(root) } @@ -75,7 +76,7 @@ func (r *Rendering) PlantUMLWith(options Options) (string, error) { notices = append(notices, w.mixedContainmentNotices(r.Roots)...) } if r.Kind == KindAction { - if pins := r.undrawnPins(func(*Node, Port) bool { return false }); len(pins) > 0 { + if pins := r.undrawnPins(func(_ *Node, port Port) bool { return port.Parameter }); len(pins) > 0 { notices = append(notices, fmt.Sprintf("%d pin(s) not drawn (%s); PlantUML's state grammar has no pin, so the edges name them", len(pins), strings.Join(pins, ", "))) } @@ -707,6 +708,9 @@ func (w *plantumlWriter) plantumlLabel(node *Node) string { for _, line := range w.labels.details(node) { parts = append(parts, plantumlText(line)) } + for _, line := range w.labels.parameterLines(node) { + parts = append(parts, plantumlText(line)) + } for _, line := range w.labels.compartmentLines(node) { parts = append(parts, plantumlText(line)) } diff --git a/internal/ir/view/plantuml_test.go b/internal/ir/view/plantuml_test.go index ad4fc22260..9f53d5944e 100644 --- a/internal/ir/view/plantuml_test.go +++ b/internal/ir/view/plantuml_test.go @@ -257,7 +257,7 @@ func TestPlantUMLActionUsesStateGrammar(t *testing.T) { " state \"//«fork»//\\n**split**\" as n7 <>\n", " state \"**final**\" as n9 <>\n", " state \"//«decision»//\\n**check**\" as n11 <>\n", - " state \"//«action»//\\n**monitor**\\nown flow\" as n3 <> <> {\n", + " state \"//«action»//\\n**monitor**\\nown flow\\nparameters\\nin reading\" as n3 <> <> {\n", "\nn2 --> n3 : torque to reading\n", "\nn11 -[dashed]-> n9 : [speed 0]\n", "\nn1 -[dashed]-> n7\n", diff --git a/internal/ir/view/ports.go b/internal/ir/view/ports.go index 444b9880ab..5221217f82 100644 --- a/internal/ir/view/ports.go +++ b/internal/ir/view/ports.go @@ -163,3 +163,12 @@ func (r *Rendering) undrawnPins(drawn func(node *Node, port Port) bool) []string } return names } + +// unconnectedPin lists, for a form that draws only the pins an edge ends at, +// an action's parameter pins no edge reaches, in its node's compartment. +func (r *Rendering) unconnectedPin(used map[string]map[string]bool) func(node *Node, port Port) bool { + if r.Kind != KindAction && r.Kind != KindMixed { + return nil + } + return func(node *Node, port Port) bool { return port.Parameter && !used[node.ID][port.ID] } +} diff --git a/internal/ir/view/ports_test.go b/internal/ir/view/ports_test.go index 5c9e428891..09e19555f6 100644 --- a/internal/ir/view/ports_test.go +++ b/internal/ir/view/ports_test.go @@ -341,8 +341,8 @@ func TestMixedActionPinsKeepTheirFormBehavior(t *testing.T) { if strings.Contains(output, check.ID+"_p0[") || strings.Contains(output, `["request"]`) { t.Errorf("%s Mermaid draws Check's unconnected action pin, unlike an action rendering:\n%s", label, output) } - if !strings.Contains(output, "Check.request") || !strings.Contains(output, "pin(s) not drawn") { - t.Errorf("%s Mermaid does not report Check.request as an undrawn action pin:\n%s", label, output) + if !strings.Contains(output, "parameters\nin request") || strings.Contains(output, "pin(s) not drawn") { + t.Errorf("%s Mermaid does not list Check.request in a parameters compartment:\n%s", label, output) } } if strings.Contains(mermaidMinimal, "unused") { diff --git a/internal/ir/view/testdata/action.d2.golden b/internal/ir/view/testdata/action.d2.golden index 5d228d35f6..7dde481d11 100644 --- a/internal/ir/view/testdata/action.d2.golden +++ b/internal/ir/view/testdata/action.d2.golden @@ -1,5 +1,4 @@ # FlowViews::driveView — action rendering (render asInterconnectionDiagram, view def ActionFlowView) -# not represented: 1 pin(s) not drawn (provide.fuel); D2 draws the pins an edge ends at # not represented: 2 fork/join name(s) (split, sync); D2's fork bar draws no label classes: { definition: { style: { fill: white; stroke: "#181818"; stroke-width: 1; font-color: black; font-size: 14 } } @@ -15,7 +14,7 @@ classes: { n0: "«action def»\nDrive" { class: definition n1: "" { class: initial } - n2: "«action»\nprovide" { + n2: "«action»\nprovide\nparameters\nin ^fuel" { class: usage "n2.1": "^torque" { class: pin } } diff --git a/internal/ir/view/testdata/action.mermaid.golden b/internal/ir/view/testdata/action.mermaid.golden index d6f9e429e3..452164f7e4 100644 --- a/internal/ir/view/testdata/action.mermaid.golden +++ b/internal/ir/view/testdata/action.mermaid.golden @@ -26,12 +26,13 @@ config: --- %% FlowViews::driveView — action rendering (render asInterconnectionDiagram, view def ActionFlowView) %% not represented: 2 fork/join name(s) (split, sync); Mermaid's fork bar draws no label -%% not represented: 1 pin(s) not drawn (provide.fuel); a flowchart draws the pins an edge ends at flowchart TD subgraph n0 ["`*«action def»* **Drive**`"] direction TD n1@{ shape: f-circ, label: "" } - subgraph n2 ["`*«action»* **provide**`"] + subgraph n2 ["`*«action»* **provide** +parameters +in ^fuel`"] direction TD n2_anchor[" "] n2_p1["^torque"] diff --git a/internal/ir/view/testdata/action.plantuml.golden b/internal/ir/view/testdata/action.plantuml.golden index 1ae74a2bb0..5be0f7b334 100644 --- a/internal/ir/view/testdata/action.plantuml.golden +++ b/internal/ir/view/testdata/action.plantuml.golden @@ -1,6 +1,5 @@ @startuml ' FlowViews::driveView — action rendering (render asInterconnectionDiagram, view def ActionFlowView) -' not represented: 3 pin(s) not drawn (provide.fuel, provide.torque, monitor.reading); PlantUML's state grammar has no pin, so the edges name them