Implement initial GSP toolkit
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
# Generated toolkit outputs
|
||||
.gsp/
|
||||
.tools/
|
||||
|
||||
# Build outputs
|
||||
bin/
|
||||
|
||||
24
README.md
24
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 是一种游戏规格协议,不是具体游戏引擎、代码框架或资源格式。
|
||||
|
||||
17
TOOLKIT.md
17
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 <id>
|
||||
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
|
||||
|
||||
这些能力后续作为独立模块讨论。
|
||||
|
||||
|
||||
293
cmd/gsp/main.go
Normal file
293
cmd/gsp/main.go
Normal file
@@ -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 <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 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)
|
||||
}
|
||||
4
examples/lottery/audio.reward.pop.gsp
Normal file
4
examples/lottery/audio.reward.pop.gsp
Normal file
@@ -0,0 +1,4 @@
|
||||
id: audio.reward.pop
|
||||
type: audio
|
||||
resolution: L3
|
||||
context: 奖励弹出音效,用于强化奖励出现的瞬间反馈。
|
||||
4
examples/lottery/feedback.positive.gsp
Normal file
4
examples/lottery/feedback.positive.gsp
Normal file
@@ -0,0 +1,4 @@
|
||||
id: feedback.positive
|
||||
type: feedback
|
||||
resolution: L3
|
||||
context: 积极反馈。用于让玩家在操作后获得明确、正向、值得继续的感受。
|
||||
4
examples/lottery/mechanic.lottery.basic.gsp
Normal file
4
examples/lottery/mechanic.lottery.basic.gsp
Normal file
@@ -0,0 +1,4 @@
|
||||
id: mechanic.lottery.basic
|
||||
type: mechanic
|
||||
resolution: L3
|
||||
context: 基础抽奖机制,负责抽取请求、奖励结果和结果反馈触发。
|
||||
4
examples/lottery/motion.button.reward_pop.gsp
Normal file
4
examples/lottery/motion.button.reward_pop.gsp
Normal file
@@ -0,0 +1,4 @@
|
||||
id: motion.button.reward_pop
|
||||
type: motion
|
||||
resolution: L3
|
||||
context: 奖励按钮点击动效,表现为轻微缩放和快速回弹。
|
||||
10
examples/lottery/page.lottery.main.gsp
Normal file
10
examples/lottery/page.lottery.main.gsp
Normal file
@@ -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
|
||||
4
examples/lottery/style.reward.light.gsp
Normal file
4
examples/lottery/style.reward.light.gsp
Normal file
@@ -0,0 +1,4 @@
|
||||
id: style.reward.light
|
||||
type: style
|
||||
resolution: L3
|
||||
context: 奖励表现要轻快、积极,但不要过度刺激。
|
||||
4
examples/lottery/ui.button.primary.gsp
Normal file
4
examples/lottery/ui.button.primary.gsp
Normal file
@@ -0,0 +1,4 @@
|
||||
id: ui.button.primary
|
||||
type: ui
|
||||
resolution: L3
|
||||
context: 通用主按钮,用于明确、正向、优先级最高的玩家操作。
|
||||
9
examples/lottery/ui.button.reward_primary.gsp
Normal file
9
examples/lottery/ui.button.reward_primary.gsp
Normal file
@@ -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
|
||||
5
go.mod
Normal file
5
go.mod
Normal file
@@ -0,0 +1,5 @@
|
||||
module gsp.toolkit
|
||||
|
||||
go 1.22
|
||||
|
||||
require gopkg.in/yaml.v3 v3.0.1
|
||||
4
go.sum
Normal file
4
go.sum
Normal file
@@ -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=
|
||||
103
internal/gsp/graph.go
Normal file
103
internal/gsp/graph.go
Normal 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
|
||||
}
|
||||
92
internal/gsp/load.go
Normal file
92
internal/gsp/load.go
Normal 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)
|
||||
}
|
||||
179
internal/gsp/model.go
Normal file
179
internal/gsp/model.go
Normal 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
|
||||
}
|
||||
219
internal/gsp/project.go
Normal file
219
internal/gsp/project.go
Normal 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)
|
||||
}
|
||||
122
internal/gsp/project_test.go
Normal file
122
internal/gsp/project_test.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user