Implement initial GSP toolkit

This commit is contained in:
2026-05-06 18:40:37 +08:00
parent 61b7a9a485
commit 6ba52e3297
19 changed files with 1098 additions and 4 deletions

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
# Generated toolkit outputs
.gsp/
.tools/
# Build outputs
bin/

View File

@@ -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 是一种游戏规格协议,不是具体游戏引擎、代码框架或资源格式。

View File

@@ -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
View 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)
}

View File

@@ -0,0 +1,4 @@
id: audio.reward.pop
type: audio
resolution: L3
context: 奖励弹出音效,用于强化奖励出现的瞬间反馈。

View File

@@ -0,0 +1,4 @@
id: feedback.positive
type: feedback
resolution: L3
context: 积极反馈。用于让玩家在操作后获得明确、正向、值得继续的感受。

View File

@@ -0,0 +1,4 @@
id: mechanic.lottery.basic
type: mechanic
resolution: L3
context: 基础抽奖机制,负责抽取请求、奖励结果和结果反馈触发。

View File

@@ -0,0 +1,4 @@
id: motion.button.reward_pop
type: motion
resolution: L3
context: 奖励按钮点击动效,表现为轻微缩放和快速回弹。

View 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

View File

@@ -0,0 +1,4 @@
id: style.reward.light
type: style
resolution: L3
context: 奖励表现要轻快、积极,但不要过度刺激。

View File

@@ -0,0 +1,4 @@
id: ui.button.primary
type: ui
resolution: L3
context: 通用主按钮,用于明确、正向、优先级最高的玩家操作。

View 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
View File

@@ -0,0 +1,5 @@
module gsp.toolkit
go 1.22
require gopkg.in/yaml.v3 v3.0.1

4
go.sum Normal file
View 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
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
}

92
internal/gsp/load.go Normal file
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)
}

179
internal/gsp/model.go Normal file
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
}

219
internal/gsp/project.go Normal file
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
}