Add GSP project manifest and design layout
This commit is contained in:
16
README.md
16
README.md
@@ -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 列表。
|
||||
|
||||
28
examples/lottery/README.md
Normal file
28
examples/lottery/README.md
Normal 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
|
||||
```
|
||||
39
examples/lottery/gsp.manifest
Normal file
39
examples/lottery/gsp.manifest
Normal 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
|
||||
@@ -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` 用于写入核心设计内容。
|
||||
|
||||
62
language/gsp.manifest.schema.json
Normal file
62
language/gsp.manifest.schema.json
Normal 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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
58
toolkit/internal/gsp/manifest.go
Normal file
58
toolkit/internal/gsp/manifest.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user