tracker/mockup_catalog.go
package main
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
type MockVariant struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Preview string `json:"preview"`
}
type MockScreen struct {
ID string `json:"id"`
Title string `json:"title"`
Module string `json:"module"`
ModuleName string `json:"module_name"`
Modules []string `json:"modules"`
Channel string `json:"channel"`
Kind string `json:"kind"`
Status string `json:"status"`
Description string `json:"description"`
Route string `json:"route_proposal"`
UseCases []string `json:"use_cases"`
Components []string `json:"components"`
Provenance map[string]string `json:"provenance"`
Variants []MockVariant `json:"variants"`
Manifest string `json:"manifest"`
}
type MockCatalog struct {
ModuleNames map[string]string `json:"module_names"`
Screens []MockScreen `json:"screens"`
ByID map[string]MockScreen `json:"-"`
Assets map[string]bool `json:"-"`
}
func (s MockScreen) Preview() string {
if len(s.Variants) == 0 {
return ""
}
return "/mockup-assets/" + s.Variants[0].Preview
}
func mockFile(root, path string) ([]byte, error) {
if filepath.IsAbs(path) || strings.Contains(path, "\\") || strings.Contains(path, "\x00") {
return nil, errors.New("invalid artifact path")
}
clean := filepath.Clean(path)
if clean != path || strings.HasPrefix(clean, "..") {
return nil, errors.New("invalid artifact path")
}
base, err := filepath.EvalSymlinks(root)
if err != nil {
return nil, err
}
full, err := filepath.EvalSymlinks(filepath.Join(base, path))
if err != nil {
return nil, err
}
rel, err := filepath.Rel(base, full)
if err != nil || strings.HasPrefix(rel, "..") {
return nil, errors.New("artifact outside repository")
}
return os.ReadFile(full)
}
func loadMockCatalog(root string) (MockCatalog, error) {
c := MockCatalog{ByID: map[string]MockScreen{}, Assets: map[string]bool{}}
b, err := mockFile(root, "mockups/catalog.json")
if err != nil {
return c, err
}
var index struct {
Paths []string `json:"screen_manifests"`
Names map[string]string `json:"module_names"`
}
if err = json.Unmarshal(b, &index); err != nil {
return c, err
}
c.ModuleNames = index.Names
for _, path := range index.Paths {
if !strings.HasPrefix(path, "mockups/") {
return c, errors.New("invalid manifest")
}
b, err = mockFile(root, path)
if err != nil {
return c, err
}
var s MockScreen
if err = json.Unmarshal(b, &s); err != nil {
return c, err
}
if !mockID.MatchString(s.ID) || c.ByID[s.ID].ID != "" || s.Title == "" || len(s.Variants) == 0 {
return c, errors.New("invalid screen manifest")
}
s.Manifest = path
s.ModuleName = moduleLabel(c.ModuleNames, s.Module)
seen := map[string]bool{}
for _, v := range s.Variants {
if !mockID.MatchString(v.ID) || seen[v.ID] {
return c, errors.New("invalid screen variant")
}
seen[v.ID] = true
if !strings.HasPrefix(v.Preview, "mockups/") && !strings.HasPrefix(v.Preview, "docs/agent/assets/ui/") {
return c, errors.New("invalid preview path")
}
if ext := strings.ToLower(filepath.Ext(v.Preview)); ext != ".svg" && ext != ".png" && ext != ".jpg" && ext != ".webp" {
return c, errors.New("invalid preview format")
}
c.Assets[v.Preview] = true
}
c.Screens = append(c.Screens, s)
c.ByID[s.ID] = s
}
sort.Slice(c.Screens, func(i, j int) bool { return c.Screens[i].ID < c.Screens[j].ID })
return c, nil
}
func draftMockSitemap(root string, c MockCatalog) (MockGraph, error) {
g := MockGraph{Direction: "TB", SourceHash: digestJSON(c.Screens), Nodes: []MockNode{}, Edges: []DiagramEdge{}}
b, err := mockFile(root, "mockups/sitemap/graph.json")
if err != nil {
return g, err
}
var seed struct {
Edges []DiagramEdge `json:"edges"`
}
if err = json.Unmarshal(b, &seed); err != nil {
return g, err
}
g.Edges = seed.Edges
g.SourceHash = digestJSON([]any{c.Screens, seed.Edges})
for i, s := range c.Screens {
g.Nodes = append(g.Nodes, MockNode{ID: s.ID, Type: "screen", ScreenID: s.ID, Variant: "default", Title: s.Title, Description: s.Description, Mapping: "proposed", X: float64(80 + i%8*360), Y: float64(80 + i/8*380)})
}
return g, validateMockGraph(g, c)
}
func draftMockFlow(root string, u UseCase, c MockCatalog) (MockGraph, error) {
b, err := mockFile(root, "mockups/page-flows/"+u.ID+"/flow.json")
if err != nil {
return MockGraph{}, err
}
var seed struct {
Mapping map[string]struct {
ScreenID string `json:"screen_id"`
Variant string `json:"variant"`
Status string `json:"status"`
} `json:"mapping"`
}
if err = json.Unmarshal(b, &seed); err != nil {
return MockGraph{}, err
}
d := draftDiagram(u)
g := MockGraph{Direction: "LR", SourceHash: digestJSON([]any{sourceDigest(u), c.Screens, seed.Mapping}), Nodes: []MockNode{}, Edges: d.Edges}
for _, n := range d.Nodes {
m := MockNode{ID: n.ID, Type: n.Type, Title: n.Title, Description: n.Description, StepID: n.ID, X: n.X, Y: n.Y}
if mapping, ok := seed.Mapping[n.ID]; ok {
m.Type = "screen"
m.ScreenID = mapping.ScreenID
m.Variant = mapping.Variant
m.Mapping = mapping.Status
}
g.Nodes = append(g.Nodes, m)
}
if err = validateMockGraph(g, c); err != nil {
return g, fmt.Errorf("%s: %w", u.ID, err)
}
return g, nil
}