Reorganize GSP module layout

This commit is contained in:
2026-05-06 18:54:21 +08:00
parent df3d1f6f13
commit 69003c8152
12 changed files with 235 additions and 186 deletions

View File

@@ -0,0 +1,103 @@
package gsp
import (
"fmt"
"regexp"
"sort"
"strings"
)
func (p *Project) Graph(id string, depth int) Graph {
if id != "" {
flattened := p.Flatten(id, depth, Filter{})
return p.graphForUnits(flattened.Units)
}
return p.graphForUnits(p.Units)
}
func (p *Project) graphForUnits(units []*Unit) Graph {
nodeMap := map[string]GraphNode{}
edgeMap := map[string]GraphEdge{}
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}
}
for _, unit := range units {
if unit.Refines != "" {
addEdge(edgeMap, unit.ID, unit.Refines, "refines")
if !include[unit.Refines] {
nodeMap[unit.Refines] = GraphNode{ID: unit.Refines, Missing: p.ByID[unit.Refines] == nil}
}
}
for _, rel := range unit.With {
addEdge(edgeMap, unit.ID, rel.ID, "with")
if !include[rel.ID] {
nodeMap[rel.ID] = GraphNode{ID: rel.ID, Missing: p.ByID[rel.ID] == nil}
}
}
}
nodes := make([]GraphNode, 0, len(nodeMap))
for _, node := range nodeMap {
nodes = append(nodes, node)
}
sort.Slice(nodes, func(i, j int) bool {
return nodes[i].ID < nodes[j].ID
})
edges := make([]GraphEdge, 0, len(edgeMap))
for _, edge := range edgeMap {
edges = append(edges, edge)
}
sort.Slice(edges, func(i, j int) bool {
if edges[i].From == edges[j].From {
if edges[i].To == edges[j].To {
return edges[i].Kind < edges[j].Kind
}
return edges[i].To < edges[j].To
}
return edges[i].From < edges[j].From
})
return Graph{Nodes: nodes, Edges: edges}
}
func addEdge(edges map[string]GraphEdge, from, to, kind string) {
key := from + "\x00" + to + "\x00" + kind
edges[key] = GraphEdge{From: from, To: to, Kind: kind}
}
var mermaidIDPattern = regexp.MustCompile(`[^A-Za-z0-9_]`)
func (g Graph) Mermaid() string {
var builder strings.Builder
builder.WriteString("graph TD\n")
if len(g.Nodes) == 0 {
return builder.String()
}
for _, node := range g.Nodes {
label := node.ID
if node.Missing {
label += " (missing)"
}
builder.WriteString(fmt.Sprintf(" %s[\"%s\"]\n", mermaidID(node.ID), escapeMermaid(label)))
}
for _, edge := range g.Edges {
builder.WriteString(fmt.Sprintf(" %s -- %s --> %s\n", mermaidID(edge.From), edge.Kind, mermaidID(edge.To)))
}
return builder.String()
}
func mermaidID(id string) string {
value := mermaidIDPattern.ReplaceAllString(id, "_")
if value == "" {
return "node"
}
if value[0] >= '0' && value[0] <= '9' {
value = "n_" + value
}
return value
}
func escapeMermaid(value string) string {
value = strings.ReplaceAll(value, `"`, `\"`)
return value
}

View File

@@ -0,0 +1,92 @@
package gsp
import (
"os"
"path/filepath"
"sort"
"strings"
"gopkg.in/yaml.v3"
)
var ignoredDirs = map[string]bool{
".git": true,
".gsp": true,
"node_modules": true,
"dist": true,
"build": true,
"bin": true,
}
func LoadProject(root string) (*Project, error) {
absRoot, err := filepath.Abs(root)
if err != nil {
return nil, err
}
project := &Project{
Root: absRoot,
ByID: map[string]*Unit{},
Duplicates: map[string][]*Unit{},
}
var files []string
err = filepath.WalkDir(absRoot, func(path string, entry os.DirEntry, walkErr error) error {
if walkErr != nil {
project.LoadIssues = append(project.LoadIssues, Issue{Level: "error", Code: "walk_error", File: path, Message: walkErr.Error()})
return nil
}
if entry.IsDir() {
if ignoredDirs[entry.Name()] {
return filepath.SkipDir
}
return nil
}
if strings.EqualFold(filepath.Ext(entry.Name()), ".gsp") {
files = append(files, path)
}
return nil
})
if err != nil {
return nil, err
}
sort.Strings(files)
for _, file := range files {
unit, err := readUnit(absRoot, file)
if err != nil {
project.LoadIssues = append(project.LoadIssues, Issue{Level: "error", Code: "parse_error", File: relPath(absRoot, file), Message: err.Error()})
continue
}
project.Units = append(project.Units, unit)
if unit.ID == "" {
continue
}
if existing, ok := project.ByID[unit.ID]; ok {
project.Duplicates[unit.ID] = append(project.Duplicates[unit.ID], existing, unit)
continue
}
project.ByID[unit.ID] = unit
}
return project, nil
}
func readUnit(root, file string) (*Unit, error) {
data, err := os.ReadFile(file)
if err != nil {
return nil, err
}
var unit Unit
if err := yaml.Unmarshal(data, &unit); err != nil {
return nil, err
}
unit.File = relPath(root, file)
return &unit, nil
}
func relPath(root, path string) string {
rel, err := filepath.Rel(root, path)
if err != nil {
return path
}
return filepath.ToSlash(rel)
}

View File

@@ -0,0 +1,179 @@
package gsp
import (
"fmt"
"gopkg.in/yaml.v3"
)
var resolutionRank = map[string]int{
"": 0,
"L0": 0,
"L1": 1,
"L2": 2,
"L3": 3,
"L4": 4,
"L5": 5,
}
type Unit struct {
ID string `json:"id" yaml:"id"`
Context string `json:"context,omitempty" yaml:"context"`
Resolution string `json:"resolution,omitempty" yaml:"resolution"`
With Relations `json:"with,omitempty" yaml:"with"`
Refines string `json:"refines,omitempty" yaml:"refines"`
Type string `json:"type,omitempty" yaml:"type"`
File string `json:"file,omitempty" yaml:"-"`
}
type Relation struct {
ID string `json:"id" yaml:"id"`
Context string `json:"context,omitempty" yaml:"context"`
}
type Relations []Relation
func (r *Relations) UnmarshalYAML(value *yaml.Node) error {
if value.Kind == 0 || value.Tag == "!!null" {
*r = nil
return nil
}
if value.Kind != yaml.SequenceNode {
return fmt.Errorf("with must be a list")
}
out := make([]Relation, 0, len(value.Content))
for _, item := range value.Content {
switch item.Kind {
case yaml.ScalarNode:
if item.Value == "" {
return fmt.Errorf("with item cannot be empty")
}
out = append(out, Relation{ID: item.Value})
case yaml.MappingNode:
var rel Relation
if err := item.Decode(&rel); err != nil {
return err
}
if rel.ID == "" {
return fmt.Errorf("with object item requires id")
}
out = append(out, rel)
default:
return fmt.Errorf("with item must be a string or object")
}
}
*r = out
return nil
}
type Issue struct {
Level string `json:"level"`
Code string `json:"code"`
ID string `json:"id,omitempty"`
File string `json:"file,omitempty"`
Message string `json:"message"`
}
type Report struct {
OK bool `json:"ok"`
Errors []Issue `json:"errors,omitempty"`
Warnings []Issue `json:"warnings,omitempty"`
Notices []Issue `json:"notices,omitempty"`
}
func (r *Report) addError(code, id, file, message string) {
r.Errors = append(r.Errors, Issue{Level: "error", Code: code, ID: id, File: file, Message: message})
r.OK = false
}
func (r *Report) addWarning(code, id, file, message string) {
r.Warnings = append(r.Warnings, Issue{Level: "warning", Code: code, ID: id, File: file, Message: message})
}
func (r *Report) addNotice(code, id, file, message string) {
r.Notices = append(r.Notices, Issue{Level: "notice", Code: code, ID: id, File: file, Message: message})
}
type Project struct {
Root string
Units []*Unit
ByID map[string]*Unit
Duplicates map[string][]*Unit
LoadIssues []Issue
}
type IndexEntry struct {
ID string `json:"id"`
File string `json:"file"`
Type string `json:"type,omitempty"`
Resolution string `json:"resolution,omitempty"`
With []string `json:"with,omitempty"`
Refines string `json:"refines,omitempty"`
}
type FlattenResult struct {
Entry string `json:"entry"`
Depth int `json:"depth"`
Units []*Unit `json:"units"`
Warnings []Issue `json:"warnings,omitempty"`
}
type TraceResult struct {
Entry string `json:"entry"`
Depth int `json:"depth"`
Nodes []*Unit `json:"nodes"`
Edges []GraphEdge `json:"edges"`
Warnings []Issue `json:"warnings,omitempty"`
}
type PackResult struct {
Entry string `json:"entry"`
Depth int `json:"depth"`
Budget int `json:"budget,omitempty"`
Truncated bool `json:"truncated"`
Units []*Unit `json:"units"`
ApproxChars int `json:"approxChars"`
Warnings []Issue `json:"warnings,omitempty"`
}
type Graph struct {
Nodes []GraphNode `json:"nodes"`
Edges []GraphEdge `json:"edges"`
}
type GraphNode struct {
ID string `json:"id"`
Type string `json:"type,omitempty"`
Resolution string `json:"resolution,omitempty"`
File string `json:"file,omitempty"`
Missing bool `json:"missing,omitempty"`
}
type GraphEdge struct {
From string `json:"from"`
To string `json:"to"`
Kind string `json:"kind"`
}
type Filter struct {
IncludeTypes map[string]bool
ExcludeTypes map[string]bool
}
func (f Filter) Allows(unit *Unit) bool {
if unit == nil {
return false
}
if len(f.IncludeTypes) > 0 && !f.IncludeTypes[unit.Type] {
return false
}
if len(f.ExcludeTypes) > 0 && f.ExcludeTypes[unit.Type] {
return false
}
return true
}
func resolutionValue(value string) (int, bool) {
rank, ok := resolutionRank[value]
return rank, ok
}

View File

@@ -0,0 +1,219 @@
package gsp
import (
"encoding/json"
"fmt"
"sort"
)
func (p *Project) Validate(stage string) Report {
report := Report{OK: true}
for _, issue := range p.LoadIssues {
report.Errors = append(report.Errors, issue)
report.OK = false
}
for _, unit := range p.Units {
if unit.ID == "" {
report.addError("missing_id", "", unit.File, "GSP requires id")
}
if unit.Resolution != "" {
if _, ok := resolutionValue(unit.Resolution); !ok {
report.addError("invalid_resolution", unit.ID, unit.File, fmt.Sprintf("resolution %q is not allowed", unit.Resolution))
}
}
for _, rel := range unit.With {
if _, ok := p.ByID[rel.ID]; !ok {
report.addError("missing_with", unit.ID, unit.File, fmt.Sprintf("with references missing GSP %q", rel.ID))
}
}
if unit.Refines != "" {
if _, ok := p.ByID[unit.Refines]; !ok {
report.addError("missing_refines", unit.ID, unit.File, fmt.Sprintf("refines references missing GSP %q", unit.Refines))
}
}
if unit.Context == "" {
report.addNotice("placeholder", unit.ID, unit.File, "GSP has no context and is treated as placeholder")
}
}
for id, units := range p.Duplicates {
files := make([]string, 0, len(units))
seen := map[string]bool{}
for _, unit := range units {
if !seen[unit.File] {
files = append(files, unit.File)
seen[unit.File] = true
}
}
sort.Strings(files)
report.addError("duplicate_id", id, "", fmt.Sprintf("id %q is defined more than once: %v", id, files))
}
if stage != "" {
stageReport := p.StageCheck(stage)
report.Errors = append(report.Errors, stageReport.Errors...)
report.Warnings = append(report.Warnings, stageReport.Warnings...)
if len(stageReport.Errors) > 0 {
report.OK = false
}
}
return report
}
func (p *Project) Index() []IndexEntry {
entries := make([]IndexEntry, 0, len(p.Units))
for _, unit := range p.Units {
with := make([]string, 0, len(unit.With))
for _, rel := range unit.With {
with = append(with, rel.ID)
}
sort.Strings(with)
entries = append(entries, IndexEntry{
ID: unit.ID,
File: unit.File,
Type: unit.Type,
Resolution: unit.Resolution,
With: with,
Refines: unit.Refines,
})
}
sort.Slice(entries, func(i, j int) bool {
return entries[i].ID < entries[j].ID
})
return entries
}
func (p *Project) StageCheck(stage string) Report {
report := Report{OK: true}
required, ok := map[string]string{
"design": "L0",
"integrate": "L2",
"implement": "L3",
"bind": "L4",
"release": "L5",
}[stage]
if !ok {
report.addError("unknown_stage", "", "", fmt.Sprintf("unknown stage %q", stage))
return report
}
minRank, _ := resolutionValue(required)
for _, unit := range p.Units {
rank, ok := resolutionValue(unit.Resolution)
if !ok {
report.addError("invalid_resolution", unit.ID, unit.File, fmt.Sprintf("resolution %q is not allowed", unit.Resolution))
continue
}
if rank < minRank {
report.addError("low_resolution", unit.ID, unit.File, fmt.Sprintf("resolution %s is below %s for stage %s", displayResolution(unit.Resolution), required, stage))
}
}
if len(report.Errors) == 0 {
report.OK = true
}
return report
}
func displayResolution(value string) string {
if value == "" {
return "L0"
}
return value
}
func (p *Project) Trace(id string, depth int, filter Filter) TraceResult {
flatten := p.Flatten(id, depth, filter)
graph := p.graphForUnits(flatten.Units)
return TraceResult{
Entry: id,
Depth: depth,
Nodes: flatten.Units,
Edges: graph.Edges,
Warnings: flatten.Warnings,
}
}
func (p *Project) Flatten(id string, depth int, filter Filter) FlattenResult {
walker := &walker{
project: p,
depth: depth,
filter: filter,
seen: map[string]bool{},
stack: map[string]bool{},
}
walker.visit(id, 0)
return FlattenResult{
Entry: id,
Depth: depth,
Units: walker.units,
Warnings: walker.warnings,
}
}
func (p *Project) Pack(id string, depth, budget int, filter Filter) PackResult {
flattened := p.Flatten(id, depth, filter)
units := make([]*Unit, 0, len(flattened.Units))
approx := 0
truncated := false
for _, unit := range flattened.Units {
candidate := append(units, unit)
data, _ := json.Marshal(candidate)
if budget > 0 && len(data) > budget && len(units) > 0 {
truncated = true
break
}
units = candidate
approx = len(data)
}
return PackResult{
Entry: id,
Depth: depth,
Budget: budget,
Truncated: truncated,
Units: units,
ApproxChars: approx,
Warnings: flattened.Warnings,
}
}
type walker struct {
project *Project
depth int
filter Filter
seen map[string]bool
stack map[string]bool
units []*Unit
warnings []Issue
}
func (w *walker) visit(id string, currentDepth int) {
if w.depth >= 0 && currentDepth > w.depth {
return
}
if w.stack[id] {
w.warnings = append(w.warnings, Issue{Level: "warning", Code: "cycle", ID: id, Message: fmt.Sprintf("cycle detected at %q", id)})
return
}
unit, ok := w.project.ByID[id]
if !ok {
w.warnings = append(w.warnings, Issue{Level: "warning", Code: "missing", ID: id, Message: fmt.Sprintf("missing GSP %q", id)})
return
}
if w.seen[id] {
return
}
w.seen[id] = true
if currentDepth == 0 || w.filter.Allows(unit) {
w.units = append(w.units, unit)
}
w.stack[id] = true
if unit.Refines != "" {
w.visit(unit.Refines, currentDepth+1)
}
withIDs := make([]string, 0, len(unit.With))
for _, rel := range unit.With {
withIDs = append(withIDs, rel.ID)
}
sort.Strings(withIDs)
for _, relID := range withIDs {
w.visit(relID, currentDepth+1)
}
delete(w.stack, id)
}

View File

@@ -0,0 +1,122 @@
package gsp
import (
"os"
"path/filepath"
"testing"
)
func TestLoadValidateAndFlatten(t *testing.T) {
root := t.TempDir()
writeTestFile(t, root, "page.gsp", `id: page.lottery.main
type: page
resolution: L3
context: Lottery page.
with:
- id: ui.button.primary
context: Main action.
- feedback.positive
refines: page.lottery.base
`)
writeTestFile(t, root, "button.gsp", `id: ui.button.primary
type: ui
resolution: L3
context: Primary button.
`)
writeTestFile(t, root, "feedback.gsp", `id: feedback.positive
type: feedback
resolution: L2
context: Positive feedback.
`)
writeTestFile(t, root, "base.gsp", `id: page.lottery.base
resolution: L2
context: Base lottery page.
`)
project, err := LoadProject(root)
if err != nil {
t.Fatal(err)
}
report := project.Validate("")
if !report.OK {
t.Fatalf("expected valid project, got errors: %+v", report.Errors)
}
flat := project.Flatten("page.lottery.main", -1, Filter{})
got := ids(flat.Units)
want := []string{"page.lottery.main", "page.lottery.base", "feedback.positive", "ui.button.primary"}
if !sameStrings(got, want) {
t.Fatalf("flatten ids = %v, want %v", got, want)
}
}
func TestValidateMissingReference(t *testing.T) {
root := t.TempDir()
writeTestFile(t, root, "page.gsp", `id: page.missing
with:
- ui.missing
`)
project, err := LoadProject(root)
if err != nil {
t.Fatal(err)
}
report := project.Validate("")
if report.OK {
t.Fatal("expected validation failure")
}
found := false
for _, issue := range report.Errors {
if issue.Code == "missing_with" {
found = true
}
}
if !found {
t.Fatalf("expected missing_with error, got %+v", report.Errors)
}
}
func TestStageCheck(t *testing.T) {
root := t.TempDir()
writeTestFile(t, root, "page.gsp", `id: page.low
resolution: L2
`)
project, err := LoadProject(root)
if err != nil {
t.Fatal(err)
}
report := project.StageCheck("implement")
if report.OK {
t.Fatal("expected implement stage check to fail")
}
if report.Errors[0].Code != "low_resolution" {
t.Fatalf("expected low_resolution, got %+v", report.Errors)
}
}
func writeTestFile(t *testing.T, root, name, content string) {
t.Helper()
path := filepath.Join(root, name)
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatal(err)
}
}
func ids(units []*Unit) []string {
out := make([]string, 0, len(units))
for _, unit := range units {
out = append(out, unit.ID)
}
return out
}
func sameStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}