diff --git a/.gitignore b/.gitignore index e4a8de9..0b2631d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Generated toolkit outputs .gsp/ +.tools/ # Build outputs bin/ diff --git a/README.md b/README.md index 548e7b7..93b40be 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,30 @@ README 面向准备使用 GSP 的人类和 AI。它说明 GSP 的用途、边界 | `gsp.schema.json` | GSP 第一版核心字段规范。使用 JSON Schema 表达,便于 AI、工具、编译器和实现模块识别。 | | `TOOLKIT.md` | GSP Toolkit 第一版设计草案。记录工具集目标、核心命令、输入输出和暂缓功能。 | +## Toolkit 快速使用 + +第一版 Toolkit 使用 Go 实现。 + +构建: + +```bash +go build -o bin/gsp ./cmd/gsp +``` + +常用命令: + +```bash +gsp validate --root examples/lottery +gsp index --root examples/lottery --out .gsp/index.json +gsp trace page.lottery.main --root examples/lottery --depth -1 +gsp flatten page.lottery.main --root examples/lottery --depth -1 +gsp pack page.lottery.main --root examples/lottery --budget 12000 +gsp graph page.lottery.main --root examples/lottery --format mermaid +gsp stage-check --root examples/lottery --stage implement +``` + +Toolkit 输出目录 `.gsp/` 默认不进入 Git。 + ## GSP 是什么 GSP 是一种游戏规格协议,不是具体游戏引擎、代码框架或资源格式。 diff --git a/TOOLKIT.md b/TOOLKIT.md index 5ccf660..c81cd68 100644 --- a/TOOLKIT.md +++ b/TOOLKIT.md @@ -1,4 +1,4 @@ -# GSP Toolkit 设计草案 +# GSP Toolkit GSP Toolkit 是 GSP 的配套工具集。 @@ -116,7 +116,7 @@ manifest 可用于声明: - 根据入口 GSP 生成 AI 可读上下文 - 控制上下文大小 - 裁剪无关内容 -- 可按任务目标过滤 type、resolution 或路径 +- 可按任务目标过滤 type ### 3.6 graph @@ -150,7 +150,7 @@ manifest 可用于声明: | bind | 平台相关 GSP 达到 L4 | | release | 关键 GSP 达到 L5 | -## 4. 第一版命令草案 +## 4. 第一版命令 ```bash gsp validate @@ -162,6 +162,16 @@ gsp graph gsp stage-check --stage implement ``` +命令可使用 `--root` 指定扫描目录,使用 `--out` 输出文件。 + +示例: + +```bash +gsp validate --root examples/lottery +gsp flatten page.lottery.main --root examples/lottery --depth -1 --out .gsp/flattened.json +gsp graph page.lottery.main --root examples/lottery --format mermaid --out .gsp/graph.mmd +``` + ## 5. 输出 第一版输出以机器可读和人类可读并重。 @@ -212,4 +222,3 @@ gsp stage-check --stage implement - H5 runtime binding 这些能力后续作为独立模块讨论。 - diff --git a/cmd/gsp/main.go b/cmd/gsp/main.go new file mode 100644 index 0000000..5758ae3 --- /dev/null +++ b/cmd/gsp/main.go @@ -0,0 +1,293 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + "path/filepath" + "strings" + + "gsp.toolkit/internal/gsp" +) + +func main() { + if err := run(os.Args[1:]); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + os.Exit(1) + } +} + +func run(args []string) error { + if len(args) == 0 { + printHelp() + return nil + } + + switch args[0] { + case "validate": + return runValidate(args[1:]) + case "index": + return runIndex(args[1:]) + case "trace": + return runTrace(args[1:]) + case "flatten": + return runFlatten(args[1:]) + case "pack": + return runPack(args[1:]) + case "graph": + return runGraph(args[1:]) + case "stage-check": + return runStageCheck(args[1:]) + case "help", "-h", "--help": + printHelp() + return nil + default: + return fmt.Errorf("unknown command %q", args[0]) + } +} + +func printHelp() { + fmt.Print(`GSP Toolkit + +Usage: + gsp validate [--root .] [--out report.json] + gsp index [--root .] [--out index.json] + gsp trace [--root .] [--depth 3] [--out trace.json] + gsp flatten [--root .] [--depth 3] [--include-type a,b] [--exclude-type a,b] [--out flattened.json] + gsp pack [--root .] [--depth 3] [--budget 12000] [--out context-pack.json] + gsp graph [id] [--root .] [--depth 3] [--format json|mermaid] [--out graph.json] + gsp stage-check --stage implement [--root .] [--out stage-report.json] +`) +} + +func commonRoot(fs *flag.FlagSet) *string { + return fs.String("root", ".", "project root to scan") +} + +func commonOut(fs *flag.FlagSet) *string { + return fs.String("out", "", "optional output file") +} + +func runValidate(args []string) error { + fs := flag.NewFlagSet("validate", flag.ContinueOnError) + root := commonRoot(fs) + out := commonOut(fs) + if err := fs.Parse(args); err != nil { + return err + } + project, err := gsp.LoadProject(*root) + if err != nil { + return err + } + report := project.Validate("") + return writeReport(*out, report) +} + +func runIndex(args []string) error { + fs := flag.NewFlagSet("index", flag.ContinueOnError) + root := commonRoot(fs) + out := commonOut(fs) + if err := fs.Parse(args); err != nil { + return err + } + project, err := gsp.LoadProject(*root) + if err != nil { + return err + } + return writeJSON(*out, project.Index()) +} + +func runTrace(args []string) error { + fs := flag.NewFlagSet("trace", flag.ContinueOnError) + root := commonRoot(fs) + out := commonOut(fs) + depth := fs.Int("depth", 3, "maximum relation depth; -1 means unlimited") + if err := fs.Parse(normalizeFlagArgs(args)); err != nil { + return err + } + if fs.NArg() != 1 { + return fmt.Errorf("trace requires one GSP id") + } + project, err := gsp.LoadProject(*root) + if err != nil { + return err + } + result := project.Trace(fs.Arg(0), *depth, gsp.Filter{}) + return writeJSON(*out, result) +} + +func runFlatten(args []string) error { + fs := flag.NewFlagSet("flatten", flag.ContinueOnError) + root := commonRoot(fs) + out := commonOut(fs) + depth := fs.Int("depth", 3, "maximum relation depth; -1 means unlimited") + includeType := fs.String("include-type", "", "comma-separated type allow-list") + excludeType := fs.String("exclude-type", "", "comma-separated type deny-list") + if err := fs.Parse(normalizeFlagArgs(args)); err != nil { + return err + } + if fs.NArg() != 1 { + return fmt.Errorf("flatten requires one GSP id") + } + project, err := gsp.LoadProject(*root) + if err != nil { + return err + } + result := project.Flatten(fs.Arg(0), *depth, gsp.Filter{ + IncludeTypes: splitCSV(*includeType), + ExcludeTypes: splitCSV(*excludeType), + }) + return writeJSON(*out, result) +} + +func runPack(args []string) error { + fs := flag.NewFlagSet("pack", flag.ContinueOnError) + root := commonRoot(fs) + out := commonOut(fs) + depth := fs.Int("depth", 3, "maximum relation depth; -1 means unlimited") + budget := fs.Int("budget", 0, "approximate JSON character budget; 0 means unlimited") + includeType := fs.String("include-type", "", "comma-separated type allow-list") + excludeType := fs.String("exclude-type", "", "comma-separated type deny-list") + if err := fs.Parse(normalizeFlagArgs(args)); err != nil { + return err + } + if fs.NArg() != 1 { + return fmt.Errorf("pack requires one GSP id") + } + project, err := gsp.LoadProject(*root) + if err != nil { + return err + } + result := project.Pack(fs.Arg(0), *depth, *budget, gsp.Filter{ + IncludeTypes: splitCSV(*includeType), + ExcludeTypes: splitCSV(*excludeType), + }) + return writeJSON(*out, result) +} + +func runGraph(args []string) error { + fs := flag.NewFlagSet("graph", flag.ContinueOnError) + 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") + if err := fs.Parse(normalizeFlagArgs(args)); err != nil { + return err + } + id := "" + if fs.NArg() > 1 { + return fmt.Errorf("graph accepts zero or one GSP id") + } + if fs.NArg() == 1 { + id = fs.Arg(0) + } + project, err := gsp.LoadProject(*root) + if err != nil { + return err + } + graph := project.Graph(id, *depth) + if *format == "mermaid" { + return writeText(*out, graph.Mermaid()) + } + if *format != "json" { + return fmt.Errorf("unsupported graph format %q", *format) + } + return writeJSON(*out, graph) +} + +func runStageCheck(args []string) error { + fs := flag.NewFlagSet("stage-check", flag.ContinueOnError) + root := commonRoot(fs) + out := commonOut(fs) + stage := fs.String("stage", "", "design, integrate, implement, bind, or release") + if err := fs.Parse(args); err != nil { + return err + } + if *stage == "" { + return fmt.Errorf("stage-check requires --stage") + } + project, err := gsp.LoadProject(*root) + if err != nil { + return err + } + report := project.StageCheck(*stage) + return writeReport(*out, report) +} + +func splitCSV(value string) map[string]bool { + result := map[string]bool{} + for _, part := range strings.Split(value, ",") { + part = strings.TrimSpace(part) + if part != "" { + result[part] = true + } + } + return result +} + +func normalizeFlagArgs(args []string) []string { + valueFlags := map[string]bool{ + "-root": true, "--root": true, + "-out": true, "--out": true, + "-depth": true, "--depth": true, + "-include-type": true, "--include-type": true, + "-exclude-type": true, "--exclude-type": true, + "-budget": true, "--budget": true, + "-format": true, "--format": true, + "-stage": true, "--stage": true, + } + var flags []string + var positionals []string + for i := 0; i < len(args); i++ { + arg := args[i] + if !strings.HasPrefix(arg, "-") || arg == "-" { + positionals = append(positionals, arg) + continue + } + flags = append(flags, arg) + name := arg + if index := strings.Index(arg, "="); index >= 0 { + name = arg[:index] + } + if valueFlags[name] && !strings.Contains(arg, "=") && i+1 < len(args) { + flags = append(flags, args[i+1]) + i++ + } + } + return append(flags, positionals...) +} + +func writeJSON(path string, value any) error { + data, err := json.MarshalIndent(value, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + return writeBytes(path, data) +} + +func writeReport(path string, report gsp.Report) error { + if err := writeJSON(path, report); err != nil { + return err + } + if !report.OK { + return fmt.Errorf("GSP check failed") + } + return nil +} + +func writeText(path string, value string) error { + return writeBytes(path, []byte(value)) +} + +func writeBytes(path string, data []byte) error { + if path == "" { + _, err := os.Stdout.Write(data) + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + return os.WriteFile(path, data, 0644) +} diff --git a/examples/lottery/audio.reward.pop.gsp b/examples/lottery/audio.reward.pop.gsp new file mode 100644 index 0000000..8061dc2 --- /dev/null +++ b/examples/lottery/audio.reward.pop.gsp @@ -0,0 +1,4 @@ +id: audio.reward.pop +type: audio +resolution: L3 +context: 奖励弹出音效,用于强化奖励出现的瞬间反馈。 diff --git a/examples/lottery/feedback.positive.gsp b/examples/lottery/feedback.positive.gsp new file mode 100644 index 0000000..3487c96 --- /dev/null +++ b/examples/lottery/feedback.positive.gsp @@ -0,0 +1,4 @@ +id: feedback.positive +type: feedback +resolution: L3 +context: 积极反馈。用于让玩家在操作后获得明确、正向、值得继续的感受。 diff --git a/examples/lottery/mechanic.lottery.basic.gsp b/examples/lottery/mechanic.lottery.basic.gsp new file mode 100644 index 0000000..b0e50d6 --- /dev/null +++ b/examples/lottery/mechanic.lottery.basic.gsp @@ -0,0 +1,4 @@ +id: mechanic.lottery.basic +type: mechanic +resolution: L3 +context: 基础抽奖机制,负责抽取请求、奖励结果和结果反馈触发。 diff --git a/examples/lottery/motion.button.reward_pop.gsp b/examples/lottery/motion.button.reward_pop.gsp new file mode 100644 index 0000000..aff1f52 --- /dev/null +++ b/examples/lottery/motion.button.reward_pop.gsp @@ -0,0 +1,4 @@ +id: motion.button.reward_pop +type: motion +resolution: L3 +context: 奖励按钮点击动效,表现为轻微缩放和快速回弹。 diff --git a/examples/lottery/page.lottery.main.gsp b/examples/lottery/page.lottery.main.gsp new file mode 100644 index 0000000..f213d3f --- /dev/null +++ b/examples/lottery/page.lottery.main.gsp @@ -0,0 +1,10 @@ +id: page.lottery.main +type: page +resolution: L3 +context: 抽奖页面,需要表达奖励期待、抽取行为和结果反馈。玩家应快速理解奖池价值、抽奖入口和结果反馈。 +with: + - ui.button.reward_primary + - feedback.positive + - style.reward.light + - mechanic.lottery.basic + - audio.reward.pop diff --git a/examples/lottery/style.reward.light.gsp b/examples/lottery/style.reward.light.gsp new file mode 100644 index 0000000..955b12f --- /dev/null +++ b/examples/lottery/style.reward.light.gsp @@ -0,0 +1,4 @@ +id: style.reward.light +type: style +resolution: L3 +context: 奖励表现要轻快、积极,但不要过度刺激。 diff --git a/examples/lottery/ui.button.primary.gsp b/examples/lottery/ui.button.primary.gsp new file mode 100644 index 0000000..6e25295 --- /dev/null +++ b/examples/lottery/ui.button.primary.gsp @@ -0,0 +1,4 @@ +id: ui.button.primary +type: ui +resolution: L3 +context: 通用主按钮,用于明确、正向、优先级最高的玩家操作。 diff --git a/examples/lottery/ui.button.reward_primary.gsp b/examples/lottery/ui.button.reward_primary.gsp new file mode 100644 index 0000000..3a822b8 --- /dev/null +++ b/examples/lottery/ui.button.reward_primary.gsp @@ -0,0 +1,9 @@ +id: ui.button.reward_primary +type: ui +resolution: L3 +refines: ui.button.primary +context: 奖励场景下的主按钮,比普通主按钮更强调正向点击反馈。 +with: + - feedback.positive + - motion.button.reward_pop + - audio.reward.pop diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..df36f65 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module gsp.toolkit + +go 1.22 + +require gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a62c313 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/gsp/graph.go b/internal/gsp/graph.go new file mode 100644 index 0000000..b12f210 --- /dev/null +++ b/internal/gsp/graph.go @@ -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 +} diff --git a/internal/gsp/load.go b/internal/gsp/load.go new file mode 100644 index 0000000..a669698 --- /dev/null +++ b/internal/gsp/load.go @@ -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) +} diff --git a/internal/gsp/model.go b/internal/gsp/model.go new file mode 100644 index 0000000..15e7310 --- /dev/null +++ b/internal/gsp/model.go @@ -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 +} diff --git a/internal/gsp/project.go b/internal/gsp/project.go new file mode 100644 index 0000000..93eda89 --- /dev/null +++ b/internal/gsp/project.go @@ -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) +} diff --git a/internal/gsp/project_test.go b/internal/gsp/project_test.go new file mode 100644 index 0000000..79b11d3 --- /dev/null +++ b/internal/gsp/project_test.go @@ -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 +}