Add graph markdown and canvas outputs

This commit is contained in:
2026-05-06 20:00:50 +08:00
parent 1478972e53
commit f7f5e2c67c
22 changed files with 313 additions and 15 deletions

View File

@@ -84,6 +84,7 @@ manifest 可用于声明:
索引内容:
- `id`
- `title`
- 文件路径
- `type`
- `resolution`
@@ -138,6 +139,8 @@ manifest 可用于声明:
- JSON 图数据
- Mermaid 图
- Markdown Mermaid 图
- Obsidian Canvas 图
图类型:
@@ -187,6 +190,8 @@ go build -o ../bin/gsp ./cmd/gsp
../bin/gsp validate --root ../examples/lottery
../bin/gsp flatten page.lottery.main --root ../examples/lottery --depth -1 --out ../.gsp/flattened.json
../bin/gsp graph page.lottery.main --root ../examples/lottery --format mermaid --out ../.gsp/graph.mmd
../bin/gsp graph page.lottery.main --root ../examples/lottery --format md --out ../.gsp/graph.md
../bin/gsp graph page.lottery.main --root ../examples/lottery --format canvas --out ../.gsp/graph.canvas
```
## 5. 输出
@@ -206,6 +211,8 @@ go build -o ../bin/gsp ./cmd/gsp
.gsp/report.json
.gsp/graph.json
.gsp/graph.mmd
.gsp/graph.md
.gsp/graph.canvas
.gsp/context-pack.json
.gsp/flattened.json
```

View File

@@ -70,7 +70,7 @@ Usage:
gsp trace <id> [--root .] [--depth 3] [--out trace.json]
gsp flatten <id> [--root .] [--depth 3] [--include-type a,b] [--exclude-type a,b] [--out flattened.json]
gsp pack <id> [--root .] [--depth 3] [--budget 12000] [--out context-pack.json]
gsp graph [id] [--root .] [--depth 3] [--format json|mermaid] [--out graph.json]
gsp graph [id] [--root .] [--depth 3] [--format json|mermaid|md|canvas] [--out graph.json]
gsp stage-check --stage implement [--root .] [--out stage-report.json]
`)
}
@@ -267,7 +267,7 @@ func runGraph(args []string) error {
root := commonRoot(fs)
out := commonOut(fs)
depth := fs.Int("depth", 3, "maximum relation depth when id is provided; -1 means unlimited")
format := fs.String("format", "json", "json or mermaid")
format := fs.String("format", "json", "json, mermaid, md, or canvas")
if err := fs.Parse(normalizeFlagArgs(args)); err != nil {
return err
}
@@ -283,13 +283,22 @@ func runGraph(args []string) error {
return err
}
graph := project.Graph(id, *depth)
if *format == "mermaid" {
switch *format {
case "mermaid":
return writeText(*out, graph.Mermaid())
}
if *format != "json" {
case "md":
return writeText(*out, graph.Markdown())
case "canvas":
data, err := graph.Canvas()
if err != nil {
return err
}
return writeBytes(*out, data)
case "json":
return writeJSON(*out, graph)
default:
return fmt.Errorf("unsupported graph format %q", *format)
}
return writeJSON(*out, graph)
}
func runStageCheck(args []string) error {

View File

@@ -105,6 +105,7 @@ func aiUsage(gspVersion, scan string) string {
- `+"`.gsp`"+` files use YAML.
- Preserve `+"`id`"+`; do not rename it unless explicitly requested.
- `+"`id`"+` is the unique identity of a GSP unit.
- `+"`title`"+` is display text; use `+"`id`"+` when `+"`title`"+` is missing.
- Use only fields valid for the declared GSP version.
- `+"`with`"+` means related design context.
- `+"`refines`"+` means single-source refinement.

View File

@@ -1,6 +1,7 @@
package gsp
import (
"encoding/json"
"fmt"
"regexp"
"sort"
@@ -21,7 +22,7 @@ func (p *Project) graphForUnits(units []*Unit) Graph {
include := map[string]bool{}
for _, unit := range units {
include[unit.ID] = true
nodeMap[unit.ID] = GraphNode{ID: unit.ID, Type: unit.Type, Resolution: unit.Resolution, File: unit.File}
nodeMap[unit.ID] = GraphNode{ID: unit.ID, Title: unit.Title, Type: unit.Type, Resolution: unit.Resolution, File: unit.File}
}
for _, unit := range units {
if unit.Refines != "" {
@@ -74,7 +75,7 @@ func (g Graph) Mermaid() string {
return builder.String()
}
for _, node := range g.Nodes {
label := node.ID
label := node.DisplayLabel()
if node.Missing {
label += " (missing)"
}
@@ -86,6 +87,190 @@ func (g Graph) Mermaid() string {
return builder.String()
}
func (g Graph) Markdown() string {
var builder strings.Builder
builder.WriteString("# GSP Graph\n\n")
builder.WriteString("```mermaid\n")
builder.WriteString(g.Mermaid())
builder.WriteString("```\n")
return builder.String()
}
func (g Graph) Canvas() ([]byte, error) {
canvas := canvasDocument{
Nodes: make([]canvasNode, 0, len(g.Nodes)),
Edges: make([]canvasEdge, 0, len(g.Edges)),
}
positions := g.canvasPositions()
for _, node := range g.Nodes {
pos := positions[node.ID]
canvas.Nodes = append(canvas.Nodes, canvasNode{
ID: canvasNodeID(node.ID),
Type: "text",
Text: node.CanvasText(),
X: pos.X,
Y: pos.Y,
Width: 280,
Height: 140,
Color: node.CanvasColor(),
})
}
for _, edge := range g.Edges {
canvas.Edges = append(canvas.Edges, canvasEdge{
ID: canvasEdgeID(edge),
FromNode: canvasNodeID(edge.From),
FromSide: "right",
ToNode: canvasNodeID(edge.To),
ToSide: "left",
Label: edge.Kind,
})
}
data, err := json.MarshalIndent(canvas, "", " ")
if err != nil {
return nil, err
}
return append(data, '\n'), nil
}
func (n GraphNode) DisplayLabel() string {
title := strings.TrimSpace(n.Title)
if title != "" {
return title
}
return n.ID
}
func (n GraphNode) CanvasText() string {
var lines []string
lines = append(lines, "## "+n.DisplayLabel())
if n.Title != "" && n.ID != "" {
lines = append(lines, "`"+n.ID+"`")
}
if n.Type != "" {
lines = append(lines, "type: "+n.Type)
}
if n.Resolution != "" {
lines = append(lines, "resolution: "+n.Resolution)
}
if n.Missing {
lines = append(lines, "missing: true")
}
return strings.Join(lines, "\n")
}
func (n GraphNode) CanvasColor() string {
if n.Missing {
return "1"
}
switch n.Resolution {
case "L0", "L1":
return "2"
case "L2":
return "3"
case "L3":
return "4"
case "L4", "L5":
return "5"
default:
return ""
}
}
type canvasDocument struct {
Nodes []canvasNode `json:"nodes"`
Edges []canvasEdge `json:"edges"`
}
type canvasNode struct {
ID string `json:"id"`
Type string `json:"type"`
Text string `json:"text"`
X int `json:"x"`
Y int `json:"y"`
Width int `json:"width"`
Height int `json:"height"`
Color string `json:"color,omitempty"`
}
type canvasEdge struct {
ID string `json:"id"`
FromNode string `json:"fromNode"`
FromSide string `json:"fromSide,omitempty"`
ToNode string `json:"toNode"`
ToSide string `json:"toSide,omitempty"`
Label string `json:"label,omitempty"`
}
type canvasPosition struct {
X int
Y int
}
func (g Graph) canvasPositions() map[string]canvasPosition {
incoming := map[string]int{}
outgoing := map[string][]string{}
for _, node := range g.Nodes {
incoming[node.ID] = 0
}
for _, edge := range g.Edges {
if _, ok := incoming[edge.From]; !ok {
incoming[edge.From] = 0
}
if _, ok := incoming[edge.To]; !ok {
incoming[edge.To] = 0
}
incoming[edge.To]++
outgoing[edge.From] = append(outgoing[edge.From], edge.To)
}
for id := range outgoing {
sort.Strings(outgoing[id])
}
var queue []string
for _, node := range g.Nodes {
if incoming[node.ID] == 0 {
queue = append(queue, node.ID)
}
}
if len(queue) == 0 {
for _, node := range g.Nodes {
queue = append(queue, node.ID)
}
}
sort.Strings(queue)
depth := map[string]int{}
for len(queue) > 0 {
id := queue[0]
queue = queue[1:]
for _, to := range outgoing[id] {
if depth[to] < depth[id]+1 {
depth[to] = depth[id] + 1
}
incoming[to]--
if incoming[to] == 0 {
queue = append(queue, to)
sort.Strings(queue)
}
}
}
columns := map[int][]string{}
for _, node := range g.Nodes {
columns[depth[node.ID]] = append(columns[depth[node.ID]], node.ID)
}
positions := map[string]canvasPosition{}
var keys []int
for key := range columns {
keys = append(keys, key)
}
sort.Ints(keys)
for _, key := range keys {
sort.Strings(columns[key])
for row, id := range columns[key] {
positions[id] = canvasPosition{X: key * 360, Y: row * 190}
}
}
return positions
}
func mermaidID(id string) string {
value := mermaidIDPattern.ReplaceAllString(id, "_")
if value == "" {
@@ -101,3 +286,11 @@ func escapeMermaid(value string) string {
value = strings.ReplaceAll(value, `"`, `\"`)
return value
}
func canvasNodeID(id string) string {
return "gsp_" + mermaidID(id)
}
func canvasEdgeID(edge GraphEdge) string {
return "edge_" + mermaidID(edge.From) + "_" + mermaidID(edge.Kind) + "_" + mermaidID(edge.To)
}

View File

@@ -89,6 +89,7 @@ output: %s
entryFile := filepath.Join(designDir, safeFileName(entry)+".gsp")
entryContent := fmt.Sprintf(`id: %s
title: Project Entry
type: concept
resolution: L0
context: Project entry GSP.

View File

@@ -18,6 +18,7 @@ var resolutionRank = map[string]int{
type Unit struct {
ID string `json:"id" yaml:"id"`
Title string `json:"title,omitempty" yaml:"title"`
Context string `json:"context,omitempty" yaml:"context"`
Resolution string `json:"resolution,omitempty" yaml:"resolution"`
With Relations `json:"with,omitempty" yaml:"with"`
@@ -105,6 +106,7 @@ type Project struct {
type IndexEntry struct {
ID string `json:"id"`
Title string `json:"title,omitempty"`
File string `json:"file"`
Type string `json:"type,omitempty"`
Resolution string `json:"resolution,omitempty"`
@@ -144,6 +146,7 @@ type Graph struct {
type GraphNode struct {
ID string `json:"id"`
Title string `json:"title,omitempty"`
Type string `json:"type,omitempty"`
Resolution string `json:"resolution,omitempty"`
File string `json:"file,omitempty"`

View File

@@ -82,6 +82,7 @@ func (p *Project) Index() []IndexEntry {
sort.Strings(with)
entries = append(entries, IndexEntry{
ID: unit.ID,
Title: unit.Title,
File: unit.File,
Type: unit.Type,
Resolution: unit.Resolution,

View File

@@ -1,8 +1,10 @@
package gsp
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
@@ -13,6 +15,7 @@ func TestLoadValidateAndFlatten(t *testing.T) {
t.Fatal(err)
}
writeTestFile(t, design, "page.gsp", `id: page.lottery.main
title: Lottery Page
type: page
resolution: L3
context: Lottery page.
@@ -52,6 +55,42 @@ context: Base lottery page.
if !sameStrings(got, want) {
t.Fatalf("flatten ids = %v, want %v", got, want)
}
index := project.Index()
if index[2].Title != "Lottery Page" {
t.Fatalf("index title = %q", index[2].Title)
}
graph := project.Graph("page.lottery.main", -1)
if !strings.Contains(graph.Mermaid(), `["Lottery Page"]`) {
t.Fatalf("expected Mermaid to use title, got:\n%s", graph.Mermaid())
}
if !strings.Contains(graph.Markdown(), "```mermaid") {
t.Fatalf("expected Markdown Mermaid block")
}
canvasData, err := graph.Canvas()
if err != nil {
t.Fatal(err)
}
var canvas struct {
Nodes []struct {
Text string `json:"text"`
} `json:"nodes"`
Edges []struct {
Label string `json:"label"`
} `json:"edges"`
}
if err := json.Unmarshal(canvasData, &canvas); err != nil {
t.Fatal(err)
}
foundTitle := false
for _, node := range canvas.Nodes {
if strings.Contains(node.Text, "Lottery Page") {
foundTitle = true
}
}
if !foundTitle {
t.Fatalf("expected canvas node text to include title, got %+v", canvas.Nodes)
}
}
func TestValidateMissingReference(t *testing.T) {