diff --git a/README.md b/README.md index b455a65..ada1aca 100644 --- a/README.md +++ b/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 列表。 diff --git a/examples/lottery/README.md b/examples/lottery/README.md new file mode 100644 index 0000000..e42aacf --- /dev/null +++ b/examples/lottery/README.md @@ -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 +``` diff --git a/examples/lottery/audio.reward.pop.gsp b/examples/lottery/design/audio.reward.pop.gsp similarity index 100% rename from examples/lottery/audio.reward.pop.gsp rename to examples/lottery/design/audio.reward.pop.gsp diff --git a/examples/lottery/feedback.positive.gsp b/examples/lottery/design/feedback.positive.gsp similarity index 100% rename from examples/lottery/feedback.positive.gsp rename to examples/lottery/design/feedback.positive.gsp diff --git a/examples/lottery/mechanic.lottery.basic.gsp b/examples/lottery/design/mechanic.lottery.basic.gsp similarity index 100% rename from examples/lottery/mechanic.lottery.basic.gsp rename to examples/lottery/design/mechanic.lottery.basic.gsp diff --git a/examples/lottery/motion.button.reward_pop.gsp b/examples/lottery/design/motion.button.reward_pop.gsp similarity index 100% rename from examples/lottery/motion.button.reward_pop.gsp rename to examples/lottery/design/motion.button.reward_pop.gsp diff --git a/examples/lottery/page.lottery.main.gsp b/examples/lottery/design/page.lottery.main.gsp similarity index 100% rename from examples/lottery/page.lottery.main.gsp rename to examples/lottery/design/page.lottery.main.gsp diff --git a/examples/lottery/style.reward.light.gsp b/examples/lottery/design/style.reward.light.gsp similarity index 100% rename from examples/lottery/style.reward.light.gsp rename to examples/lottery/design/style.reward.light.gsp diff --git a/examples/lottery/ui.button.primary.gsp b/examples/lottery/design/ui.button.primary.gsp similarity index 100% rename from examples/lottery/ui.button.primary.gsp rename to examples/lottery/design/ui.button.primary.gsp diff --git a/examples/lottery/ui.button.reward_primary.gsp b/examples/lottery/design/ui.button.reward_primary.gsp similarity index 100% rename from examples/lottery/ui.button.reward_primary.gsp rename to examples/lottery/design/ui.button.reward_primary.gsp diff --git a/examples/lottery/gsp.manifest b/examples/lottery/gsp.manifest new file mode 100644 index 0000000..5205a52 --- /dev/null +++ b/examples/lottery/gsp.manifest @@ -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 diff --git a/language/README.md b/language/README.md index dd201f1..96b740f 100644 --- a/language/README.md +++ b/language/README.md @@ -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` 用于写入核心设计内容。 diff --git a/language/gsp.manifest.schema.json b/language/gsp.manifest.schema.json new file mode 100644 index 0000000..090c089 --- /dev/null +++ b/language/gsp.manifest.schema.json @@ -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." + } + } +} diff --git a/toolkit/README.md b/toolkit/README.md index 8985585..d0d64a6 100644 --- a/toolkit/README.md +++ b/toolkit/README.md @@ -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 diff --git a/toolkit/internal/gsp/load.go b/toolkit/internal/gsp/load.go index a669698..9f0cc56 100644 --- a/toolkit/internal/gsp/load.go +++ b/toolkit/internal/gsp/load.go @@ -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) diff --git a/toolkit/internal/gsp/manifest.go b/toolkit/internal/gsp/manifest.go new file mode 100644 index 0000000..21e41ec --- /dev/null +++ b/toolkit/internal/gsp/manifest.go @@ -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 +} diff --git a/toolkit/internal/gsp/model.go b/toolkit/internal/gsp/model.go index 15e7310..ac88a5b 100644 --- a/toolkit/internal/gsp/model.go +++ b/toolkit/internal/gsp/model.go @@ -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 diff --git a/toolkit/internal/gsp/project.go b/toolkit/internal/gsp/project.go index 93eda89..9a72a96 100644 --- a/toolkit/internal/gsp/project.go +++ b/toolkit/internal/gsp/project.go @@ -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 diff --git a/toolkit/internal/gsp/project_test.go b/toolkit/internal/gsp/project_test.go index 79b11d3..8f03e9f 100644 --- a/toolkit/internal/gsp/project_test.go +++ b/toolkit/internal/gsp/project_test.go @@ -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)