Add GSP project manifest and design layout

This commit is contained in:
2026-05-06 19:06:32 +08:00
parent 69003c8152
commit 67a1bf2600
19 changed files with 288 additions and 31 deletions

View File

@@ -10,7 +10,7 @@ GSP 是一个通用游戏规格协议与配套工具链用于在人类、AI
GSP/
language/ GSP 语言定义与 schema
toolkit/ GSP Toolkit Go CLI 源码
examples/ 示例 GSP 文件
examples/ 示例 GSP 工程
```
生成产物默认放在:
@@ -70,4 +70,18 @@ bin/gsp.exe
|---|---|
| `language/README.md` | GSP 语言说明。 |
| `language/gsp.schema.json` | GSP 核心字段 schema。 |
| `language/gsp.manifest.schema.json` | GSP 工程 manifest schema。 |
| `toolkit/README.md` | GSP Toolkit 命令与实现说明。 |
## GSP 工程结构
GSP 工程默认使用以下结构:
```text
project/
gsp.manifest
design/
*.gsp
```
Toolkit 默认从工程根目录的 `design/` 目录扫描 `.gsp` 文件。`gsp.manifest` 可声明语言版本、Toolkit 版本、入口 GSP、扫描范围、阶段规则和 type 列表。

View File

@@ -0,0 +1,28 @@
# Lottery GSP Example
这是一个 GSP 示例工程。
## 结构
```text
lottery/
gsp.manifest
design/
*.gsp
```
## 入口
```text
page.lottery.main
```
## 生成
从仓库根目录执行:
```powershell
.\bin\gsp.exe validate --root examples\lottery
.\bin\gsp.exe flatten page.lottery.main --root examples\lottery --depth -1
.\bin\gsp.exe graph page.lottery.main --root examples\lottery --format mermaid
```

View File

@@ -0,0 +1,39 @@
gspVersion: 0.1
toolkitVersion: 0.1
project: lottery-example
entry:
- page.lottery.main
scan:
- design
stageRules:
design:
minResolution: L0
integrate:
minResolution: L2
implement:
minResolution: L3
bind:
minResolution: L4
release:
minResolution: L5
types:
- note
- concept
- style
- system
- feature
- mechanic
- flow
- page
- ui
- interaction
- feedback
- motion
- visual
- audio
- haptic
- binding

View File

@@ -12,6 +12,7 @@ README 面向准备使用 GSP 语言的人类和 AI。它说明 GSP 的用途、
|---|---|
| `README.md` | GSP 语言使用前说明。给人类和 AI 阅读。 |
| `gsp.schema.json` | GSP 第一版核心字段规范。使用 JSON Schema 表达,便于 AI、工具、编译器和实现模块识别。 |
| `gsp.manifest.schema.json` | GSP 工程 manifest 字段规范。 |
## GSP 是什么
@@ -57,6 +58,8 @@ id: feedback.positive
只有 `id` 的 GSP 是占位声明。它表示该设计对象存在,但尚未被细化。
默认情况下,`.gsp` 文件放在 GSP 工程根目录的 `design/` 目录下。
## 基础 GSP
`context` 用于写入核心设计内容。

View File

@@ -0,0 +1,62 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://gsp.local/schema/gsp.manifest.schema.json",
"title": "GSP Manifest",
"description": "Game Specification Protocol project manifest schema.",
"type": "object",
"additionalProperties": true,
"properties": {
"gspVersion": {
"type": "string",
"description": "GSP language version used by this project."
},
"toolkitVersion": {
"type": "string",
"description": "Expected GSP Toolkit version."
},
"project": {
"type": "string",
"description": "Project id or name."
},
"entry": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
},
"description": "Entry GSP ids."
},
"scan": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
},
"description": "Directories or files scanned by the Toolkit. Defaults to design."
},
"stageRules": {
"type": "object",
"additionalProperties": {
"type": "object",
"additionalProperties": true,
"properties": {
"minResolution": {
"type": "string",
"enum": ["L0", "L1", "L2", "L3", "L4", "L5"]
}
}
}
},
"types": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
}
},
"output": {
"type": "string",
"description": "Default Toolkit output directory."
}
}
}

View File

@@ -26,13 +26,25 @@ GSP Toolkit 是 GSP 的配套工具集。
`.gsp` 文件内部遵循 YAML 风格格式。
默认工程结构:
```text
project/
gsp.manifest
design/
*.gsp
```
未提供 manifest 时Toolkit 默认扫描工程根目录下的 `design/`
严格字段规范参考:
```text
../language/gsp.schema.json
../language/gsp.manifest.schema.json
```
项目级入口配置待讨论,暂定方向
项目级入口配置:
```text
gsp.manifest

View File

@@ -28,26 +28,45 @@ func LoadProject(root string) (*Project, error) {
ByID: map[string]*Unit{},
Duplicates: map[string][]*Unit{},
}
manifest, err := loadManifest(absRoot)
if err != nil {
project.LoadIssues = append(project.LoadIssues, Issue{Level: "error", Code: "manifest_error", File: "gsp.manifest", Message: err.Error()})
}
project.Manifest = manifest
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
for _, scanEntry := range project.Manifest.scanEntries() {
scanPath := filepath.Join(absRoot, filepath.FromSlash(scanEntry))
info, statErr := os.Stat(scanPath)
if statErr != nil {
project.LoadIssues = append(project.LoadIssues, Issue{Level: "warning", Code: "scan_missing", File: scanEntry, Message: "scan path does not exist"})
continue
}
if entry.IsDir() {
if ignoredDirs[entry.Name()] {
return filepath.SkipDir
if !info.IsDir() {
if strings.EqualFold(filepath.Ext(scanPath), ".gsp") {
files = append(files, scanPath)
}
continue
}
walkErr := filepath.WalkDir(scanPath, 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 walkErr != nil {
return nil, walkErr
}
if strings.EqualFold(filepath.Ext(entry.Name()), ".gsp") {
files = append(files, path)
}
return nil
})
if err != nil {
return nil, err
}
sort.Strings(files)

View File

@@ -0,0 +1,58 @@
package gsp
import (
"os"
"path/filepath"
"gopkg.in/yaml.v3"
)
type Manifest struct {
GSPVersion string `json:"gspVersion,omitempty" yaml:"gspVersion"`
ToolkitVersion string `json:"toolkitVersion,omitempty" yaml:"toolkitVersion"`
Project string `json:"project,omitempty" yaml:"project"`
Entry []string `json:"entry,omitempty" yaml:"entry"`
Scan []string `json:"scan,omitempty" yaml:"scan"`
StageRules map[string]StageRule `json:"stageRules,omitempty" yaml:"stageRules"`
Types []string `json:"types,omitempty" yaml:"types"`
Output string `json:"output,omitempty" yaml:"output"`
File string `json:"file,omitempty" yaml:"-"`
}
type StageRule struct {
MinResolution string `json:"minResolution,omitempty" yaml:"minResolution"`
}
func loadManifest(root string) (*Manifest, error) {
path := filepath.Join(root, "gsp.manifest")
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var manifest Manifest
if err := yaml.Unmarshal(data, &manifest); err != nil {
return nil, err
}
manifest.File = relPath(root, path)
return &manifest, nil
}
func (m *Manifest) scanEntries() []string {
if m == nil || len(m.Scan) == 0 {
return []string{"design"}
}
return m.Scan
}
func (m *Manifest) minResolution(stage string) (string, bool) {
if m != nil && m.StageRules != nil {
if rule, ok := m.StageRules[stage]; ok && rule.MinResolution != "" {
return rule.MinResolution, true
}
}
value, ok := defaultStageRules[stage]
return value, ok
}

View File

@@ -96,6 +96,7 @@ func (r *Report) addNotice(code, id, file, message string) {
type Project struct {
Root string
Manifest *Manifest
Units []*Unit
ByID map[string]*Unit
Duplicates map[string][]*Unit

View File

@@ -9,8 +9,15 @@ import (
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
switch issue.Level {
case "error":
report.Errors = append(report.Errors, issue)
report.OK = false
case "warning":
report.Warnings = append(report.Warnings, issue)
default:
report.Notices = append(report.Notices, issue)
}
}
for _, unit := range p.Units {
if unit.ID == "" {
@@ -81,15 +88,17 @@ func (p *Project) Index() []IndexEntry {
return entries
}
var defaultStageRules = map[string]string{
"design": "L0",
"integrate": "L2",
"implement": "L3",
"bind": "L4",
"release": "L5",
}
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]
required, ok := p.Manifest.minResolution(stage)
if !ok {
report.addError("unknown_stage", "", "", fmt.Sprintf("unknown stage %q", stage))
return report

View File

@@ -8,7 +8,11 @@ import (
func TestLoadValidateAndFlatten(t *testing.T) {
root := t.TempDir()
writeTestFile(t, root, "page.gsp", `id: page.lottery.main
design := filepath.Join(root, "design")
if err := os.MkdirAll(design, 0755); err != nil {
t.Fatal(err)
}
writeTestFile(t, design, "page.gsp", `id: page.lottery.main
type: page
resolution: L3
context: Lottery page.
@@ -18,17 +22,17 @@ with:
- feedback.positive
refines: page.lottery.base
`)
writeTestFile(t, root, "button.gsp", `id: ui.button.primary
writeTestFile(t, design, "button.gsp", `id: ui.button.primary
type: ui
resolution: L3
context: Primary button.
`)
writeTestFile(t, root, "feedback.gsp", `id: feedback.positive
writeTestFile(t, design, "feedback.gsp", `id: feedback.positive
type: feedback
resolution: L2
context: Positive feedback.
`)
writeTestFile(t, root, "base.gsp", `id: page.lottery.base
writeTestFile(t, design, "base.gsp", `id: page.lottery.base
resolution: L2
context: Base lottery page.
`)
@@ -52,7 +56,11 @@ context: Base lottery page.
func TestValidateMissingReference(t *testing.T) {
root := t.TempDir()
writeTestFile(t, root, "page.gsp", `id: page.missing
design := filepath.Join(root, "design")
if err := os.MkdirAll(design, 0755); err != nil {
t.Fatal(err)
}
writeTestFile(t, design, "page.gsp", `id: page.missing
with:
- ui.missing
`)
@@ -77,7 +85,11 @@ with:
func TestStageCheck(t *testing.T) {
root := t.TempDir()
writeTestFile(t, root, "page.gsp", `id: page.low
design := filepath.Join(root, "design")
if err := os.MkdirAll(design, 0755); err != nil {
t.Fatal(err)
}
writeTestFile(t, design, "page.gsp", `id: page.low
resolution: L2
`)
project, err := LoadProject(root)