From c1cc9132a4d97b228cebb6ee6c228caa9a880221 Mon Sep 17 00:00:00 2001 From: "CORE-FOLDCC\\Core" <1813547935@qq.com> Date: Thu, 7 May 2026 11:04:11 +0800 Subject: [PATCH] Add strict custom field registry --- README.md | 5 + examples/lottery/README.md | 3 + examples/lottery/gsp.fields | 2 + examples/lottery/gsp.manifest | 3 + specs/versions/0.1/README.md | 41 ++++ specs/versions/0.1/ai-usage.md | 2 + specs/versions/0.1/commands.md | 11 + specs/versions/0.1/gsp.fields.schema.json | 57 +++++ specs/versions/0.1/gsp.manifest.schema.json | 9 + toolkit/README.md | 5 + toolkit/cmd/gsp/completion.go | 15 +- toolkit/cmd/gsp/main.go | 50 ++++ toolkit/internal/gsp/ai_init.go | 2 + toolkit/internal/gsp/fields.go | 249 ++++++++++++++++++++ toolkit/internal/gsp/init.go | 8 + toolkit/internal/gsp/load.go | 13 + toolkit/internal/gsp/manifest.go | 34 ++- toolkit/internal/gsp/model.go | 20 +- toolkit/internal/gsp/project.go | 2 + toolkit/internal/gsp/project_test.go | 71 ++++++ 20 files changed, 582 insertions(+), 20 deletions(-) create mode 100644 examples/lottery/gsp.fields create mode 100644 specs/versions/0.1/gsp.fields.schema.json create mode 100644 toolkit/internal/gsp/fields.go diff --git a/README.md b/README.md index 46052fe..c326a4b 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,7 @@ bin/gsp.exe | `specs/versions/0.1/README.md` | GSP 0.1 语言说明。 | | `specs/versions/0.1/gsp.schema.json` | GSP 0.1 核心字段 schema。 | | `specs/versions/0.1/gsp.manifest.schema.json` | GSP 0.1 工程 manifest schema。 | +| `specs/versions/0.1/gsp.fields.schema.json` | GSP 0.1 自定义字段注册 schema。 | | `specs/versions/0.1/gsp.message.schema.json` | GSP 0.1 agent 通信消息 schema。 | | `specs/versions/0.1/commands.md` | GSP 0.1 命令规范。 | | `specs/versions/0.1/ai-usage.md` | GSP 0.1 AI 使用规则。 | @@ -100,6 +101,7 @@ GSP 工程默认使用以下结构: ```text project/ gsp.manifest + gsp.fields design/ *.gsp ``` @@ -155,6 +157,8 @@ gsp version gsp completion install powershell gsp validate gsp index +gsp fields list +gsp fields validate gsp flatten gsp pack --for implement --format md gsp links --format md @@ -183,6 +187,7 @@ gsp init path/to/project --name project-name --entry project.entry ```text project/ gsp.manifest + gsp.fields design/ project.entry.gsp ``` diff --git a/examples/lottery/README.md b/examples/lottery/README.md index 1b5ee9c..ece50ff 100644 --- a/examples/lottery/README.md +++ b/examples/lottery/README.md @@ -7,6 +7,7 @@ ```text lottery/ gsp.manifest + gsp.fields design/ *.gsp ``` @@ -23,6 +24,8 @@ page.lottery.main ```powershell .\bin\gsp.exe validate --root examples\lottery +.\bin\gsp.exe fields list --root examples\lottery +.\bin\gsp.exe fields validate --root examples\lottery .\bin\gsp.exe flatten page.lottery.main --root examples\lottery --depth -1 .\bin\gsp.exe pack page.lottery.main --root examples\lottery --for implement --format md .\bin\gsp.exe links page.lottery.main --root examples\lottery --format md diff --git a/examples/lottery/gsp.fields b/examples/lottery/gsp.fields new file mode 100644 index 0000000..af24f73 --- /dev/null +++ b/examples/lottery/gsp.fields @@ -0,0 +1,2 @@ +gspFieldsVersion: 0.1 +fields: {} diff --git a/examples/lottery/gsp.manifest b/examples/lottery/gsp.manifest index 314a09b..5a96957 100644 --- a/examples/lottery/gsp.manifest +++ b/examples/lottery/gsp.manifest @@ -8,6 +8,9 @@ entry: scan: - design +customFieldPolicy: strict +fieldRegistry: gsp.fields + stageRules: design: minResolution: L0 diff --git a/specs/versions/0.1/README.md b/specs/versions/0.1/README.md index b1ac627..b212765 100644 --- a/specs/versions/0.1/README.md +++ b/specs/versions/0.1/README.md @@ -13,6 +13,7 @@ README 面向准备使用 GSP 语言的人类和 AI。它说明 GSP 的用途、 | `README.md` | GSP 语言使用前说明。给人类和 AI 阅读。 | | `gsp.schema.json` | GSP 第一版核心字段规范。使用 JSON Schema 表达,便于 AI、工具、编译器和实现模块识别。 | | `gsp.manifest.schema.json` | GSP 工程 manifest 字段规范。 | +| `gsp.fields.schema.json` | GSP 工程自定义字段注册规范。 | | `gsp.message.schema.json` | GSP agent 通信消息字段规范。 | | `commands.md` | GSP Toolkit 命令规范。 | | `ai-usage.md` | GSP 项目 AI 使用规则。 | @@ -38,6 +39,46 @@ GSP 是一种游戏规格协议,不是具体游戏引擎、代码框架或资 GSP 不预设抽象和实体的硬边界。所有对象都先被视为 GSP,再由 `title`、`context`、`resolution`、`with`、`refines` 和当前任务共同解释。 +## 自定义字段 + +GSP 项目默认使用严格自定义字段策略。 + +```yaml +customFieldPolicy: strict +fieldRegistry: gsp.fields +``` + +非内置字段必须在 `gsp.fields` 中声明。 + +```yaml +gspFieldsVersion: 0.1 + +fields: + rewardTier: + type: string + allowed: + - common + - rare + - epic + scope: + type: + - mechanic + - feedback +``` + +支持类型: + +```text +string +number +integer +boolean +array +object +``` + +未声明字段在 `strict` 模式下是 error。 + ## GSP 用来做什么 GSP 的核心作用是高效传递设计信息。 diff --git a/specs/versions/0.1/ai-usage.md b/specs/versions/0.1/ai-usage.md index 2758cf8..a87f55c 100644 --- a/specs/versions/0.1/ai-usage.md +++ b/specs/versions/0.1/ai-usage.md @@ -8,6 +8,8 @@ - `title` is display text; use `id` when `title` is missing. - `links` associates a GSP with paths, folders, URLs, or external addresses. - Use only fields valid for the declared GSP version. +- Do not add non-built-in fields unless they are declared in `gsp.fields`. +- Run `gsp fields list` before using project custom fields. - `with` means related design context. - `refines` means single-source refinement. - Empty `context` means placeholder. diff --git a/specs/versions/0.1/commands.md b/specs/versions/0.1/commands.md index af44bed..a064743 100644 --- a/specs/versions/0.1/commands.md +++ b/specs/versions/0.1/commands.md @@ -79,6 +79,17 @@ Build a stable GSP index. gsp index [--root .] [--out index.json] ``` +## fields + +List or validate the project custom field registry. + +```bash +gsp fields list [--root .] [--out fields.json] +gsp fields validate [--root .] [--out fields-report.json] +``` + +`gsp validate` also validates custom fields by default. + ## trace Inspect relation chains from one GSP id. diff --git a/specs/versions/0.1/gsp.fields.schema.json b/specs/versions/0.1/gsp.fields.schema.json new file mode 100644 index 0000000..7d770dd --- /dev/null +++ b/specs/versions/0.1/gsp.fields.schema.json @@ -0,0 +1,57 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://gsp.local/schema/gsp.fields.schema.json", + "title": "GSP Fields", + "description": "Game Specification Protocol custom field registry schema.", + "type": "object", + "additionalProperties": true, + "required": ["gspFieldsVersion", "fields"], + "properties": { + "gspFieldsVersion": { + "type": "string", + "enum": ["0.1"] + }, + "fields": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": true, + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": ["string", "number", "integer", "boolean", "array", "object"] + }, + "description": { + "type": "string" + }, + "allowed": { + "type": "array" + }, + "required": { + "type": "boolean" + }, + "default": {}, + "scope": { + "type": "object", + "additionalProperties": true, + "properties": { + "type": { + "type": "array", + "items": { + "type": "string" + } + }, + "idPrefix": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + } + } +} diff --git a/specs/versions/0.1/gsp.manifest.schema.json b/specs/versions/0.1/gsp.manifest.schema.json index 090c089..5be72c2 100644 --- a/specs/versions/0.1/gsp.manifest.schema.json +++ b/specs/versions/0.1/gsp.manifest.schema.json @@ -34,6 +34,15 @@ }, "description": "Directories or files scanned by the Toolkit. Defaults to design." }, + "customFieldPolicy": { + "type": "string", + "enum": ["strict", "warn", "loose"], + "description": "Policy for custom fields that are not built in. Defaults to strict." + }, + "fieldRegistry": { + "type": "string", + "description": "Path to the project custom field registry. Defaults to gsp.fields." + }, "stageRules": { "type": "object", "additionalProperties": { diff --git a/toolkit/README.md b/toolkit/README.md index 18451a4..1ca009f 100644 --- a/toolkit/README.md +++ b/toolkit/README.md @@ -31,6 +31,7 @@ GSP Toolkit 是 GSP 的配套工具集。 ```text project/ gsp.manifest + gsp.fields design/ *.gsp ``` @@ -174,6 +175,8 @@ gsp ai-init gsp version gsp completion powershell gsp index +gsp fields list +gsp fields validate gsp trace gsp flatten gsp pack @@ -192,6 +195,8 @@ gsp stage-check --stage implement go build -o ../bin/gsp ./cmd/gsp ../bin/gsp version ../bin/gsp validate --root ../examples/lottery +../bin/gsp fields list --root ../examples/lottery +../bin/gsp fields validate --root ../examples/lottery ../bin/gsp flatten page.lottery.main --root ../examples/lottery --depth -1 --out ../.gsp/flattened.json ../bin/gsp pack page.lottery.main --root ../examples/lottery --for implement --format md --out ../.gsp/context-pack.md ../bin/gsp links page.lottery.main --root ../examples/lottery --format md --out ../.gsp/links.md diff --git a/toolkit/cmd/gsp/completion.go b/toolkit/cmd/gsp/completion.go index 538de4a..aab5a0d 100644 --- a/toolkit/cmd/gsp/completion.go +++ b/toolkit/cmd/gsp/completion.go @@ -9,6 +9,7 @@ $script:GspSubcommands = @( 'completion', 'validate', 'index', + 'fields', 'trace', 'flatten', 'pack', @@ -27,6 +28,7 @@ $script:GspFlags = @{ 'completion' = @('powershell', 'bash', 'zsh', 'fish', 'install') 'validate' = @('--root', '--out') 'index' = @('--root', '--out') + 'fields' = @('list', 'validate') 'trace' = @('--root', '--depth', '--out') 'flatten' = @('--root', '--depth', '--include-type', '--exclude-type', '--out') 'pack' = @('--root', '--for', '--stage', '--depth', '--budget', '--format', '--include-type', '--exclude-type', '--out') @@ -129,6 +131,12 @@ Register-ArgumentCompleter -Native -CommandName gsp -ScriptBlock { ForEach-Object { New-GspCompletion $_ } } + if ($command -eq 'fields') { + return @($script:GspFlags[$command]) | + Where-Object { $_ -like "$wordToComplete*" } | + ForEach-Object { New-GspCompletion $_ } + } + if (@('trace', 'flatten', 'pack', 'links', 'impact', 'graph') -contains $command) { return Get-GspIds | Where-Object { $_ -like "$wordToComplete*" } | @@ -148,7 +156,7 @@ _gsp_completion() { cur="${COMP_WORDS[COMP_CWORD]}" prev="${COMP_WORDS[COMP_CWORD-1]}" cmd="${COMP_WORDS[1]}" - local commands="init ai-init version completion validate index trace flatten pack links impact message graph stage-check help" + local commands="init ai-init version completion validate index fields trace flatten pack links impact message graph stage-check help" case "$prev" in --format) if [[ "$cmd" == "graph" ]]; then @@ -174,6 +182,7 @@ _gsp_completion() { ai-init) COMPREPLY=( $(compgen -W "--root --agents --skill --all --force" -- "$cur") ) ;; version) COMPREPLY=( $(compgen -W "--json" -- "$cur") ) ;; validate|index) COMPREPLY=( $(compgen -W "--root --out" -- "$cur") ) ;; + fields) COMPREPLY=( $(compgen -W "list validate" -- "$cur") ) ;; trace) COMPREPLY=( $(compgen -W "--root --depth --out" -- "$cur") ) ;; flatten) COMPREPLY=( $(compgen -W "--root --depth --include-type --exclude-type --out" -- "$cur") ) ;; pack) COMPREPLY=( $(compgen -W "--root --for --stage --depth --budget --format --include-type --exclude-type --out" -- "$cur") ) ;; @@ -210,6 +219,7 @@ _gsp() { 'completion' 'validate' 'index' + 'fields' 'trace' 'flatten' 'pack' @@ -228,7 +238,8 @@ _gsp "$@" func fishCompletionScript() string { return `# GSP fish completion -complete -c gsp -f -n '__fish_use_subcommand' -a 'init ai-init version completion validate index trace flatten pack links impact message graph stage-check help' +complete -c gsp -f -n '__fish_use_subcommand' -a 'init ai-init version completion validate index fields trace flatten pack links impact message graph stage-check help' +complete -c gsp -n '__fish_seen_subcommand_from fields' -a 'list validate' complete -c gsp -n '__fish_seen_subcommand_from graph' -l format -a 'json mermaid md canvas' complete -c gsp -n '__fish_seen_subcommand_from pack impact' -l format -a 'json md canvas' complete -c gsp -n '__fish_seen_subcommand_from links' -l format -a 'json md' diff --git a/toolkit/cmd/gsp/main.go b/toolkit/cmd/gsp/main.go index 85fe48a..5956e32 100644 --- a/toolkit/cmd/gsp/main.go +++ b/toolkit/cmd/gsp/main.go @@ -42,6 +42,8 @@ func run(args []string) error { return runValidate(args[1:]) case "index": return runIndex(args[1:]) + case "fields": + return runFields(args[1:]) case "trace": return runTrace(args[1:]) case "flatten": @@ -77,6 +79,7 @@ Usage: gsp completion install powershell gsp validate [--root .] [--out report.json] gsp index [--root .] [--out index.json] + gsp fields list|validate [--root .] [--out fields.json] gsp trace [--root .] [--depth 3] [--out trace.json] gsp flatten [--root .] [--depth 3] [--include-type a,b] [--exclude-type a,b] [--out flattened.json] gsp pack [--root .] [--for implement] [--stage implement] [--depth 3] [--budget 12000] [--format json|md|canvas] [--out context-pack.json] @@ -88,6 +91,53 @@ Usage: `) } +func runFields(args []string) error { + if len(args) == 0 { + return fmt.Errorf("fields requires list or validate") + } + switch args[0] { + case "list": + return runFieldsList(args[1:]) + case "validate": + return runFieldsValidate(args[1:]) + default: + return fmt.Errorf("unknown fields command %q", args[0]) + } +} + +func runFieldsList(args []string) error { + fs := flag.NewFlagSet("fields list", 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 + } + if project.Fields == nil { + return writeJSON(*out, map[string]any{"gspFieldsVersion": gsp.FieldsVersion, "fields": map[string]any{}}) + } + return writeJSON(*out, project.Fields) +} + +func runFieldsValidate(args []string) error { + fs := flag.NewFlagSet("fields 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 := gsp.Report{OK: true} + project.ValidateFieldRegistryOnly(&report) + return writeReport(*out, report) +} + func runLinks(args []string) error { fs := flag.NewFlagSet("links", flag.ContinueOnError) root := commonRoot(fs) diff --git a/toolkit/internal/gsp/ai_init.go b/toolkit/internal/gsp/ai_init.go index 08b4b42..4524702 100644 --- a/toolkit/internal/gsp/ai_init.go +++ b/toolkit/internal/gsp/ai_init.go @@ -108,6 +108,8 @@ func aiUsage(gspVersion, scan string) string { - `+"`title`"+` is display text; use `+"`id`"+` when `+"`title`"+` is missing. - `+"`links`"+` associates a GSP with paths, folders, URLs, or external addresses. - Use only fields valid for the declared GSP version. +- Do not add non-built-in fields unless they are declared in `+"`gsp.fields`"+`. +- Run `+"`gsp fields list`"+` before using project custom fields. - `+"`with`"+` means related design context. - `+"`refines`"+` means single-source refinement. - Empty `+"`context`"+` means placeholder. diff --git a/toolkit/internal/gsp/fields.go b/toolkit/internal/gsp/fields.go new file mode 100644 index 0000000..a919125 --- /dev/null +++ b/toolkit/internal/gsp/fields.go @@ -0,0 +1,249 @@ +package gsp + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +const FieldsVersion = "0.1" + +var builtInFields = map[string]bool{ + "id": true, + "title": true, + "context": true, + "resolution": true, + "with": true, + "refines": true, + "type": true, + "links": true, +} + +var allowedFieldTypes = map[string]bool{ + "string": true, + "number": true, + "integer": true, + "boolean": true, + "array": true, + "object": true, +} + +type FieldRegistry struct { + Version string `json:"gspFieldsVersion,omitempty" yaml:"gspFieldsVersion"` + Fields map[string]FieldDefinition `json:"fields" yaml:"fields"` + File string `json:"file,omitempty" yaml:"-"` +} + +type FieldDefinition struct { + Type string `json:"type,omitempty" yaml:"type"` + Description string `json:"description,omitempty" yaml:"description"` + Allowed []any `json:"allowed,omitempty" yaml:"allowed"` + Required bool `json:"required,omitempty" yaml:"required"` + Default any `json:"default,omitempty" yaml:"default"` + Scope FieldScope `json:"scope,omitempty" yaml:"scope"` +} + +type FieldScope struct { + Type []string `json:"type,omitempty" yaml:"type"` + IDPrefix []string `json:"idPrefix,omitempty" yaml:"idPrefix"` +} + +func loadFieldRegistry(root string, manifest *Manifest) (*FieldRegistry, error) { + path := filepath.Join(root, filepath.FromSlash(manifest.fieldRegistryPath())) + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + var registry FieldRegistry + if err := yaml.Unmarshal(data, ®istry); err != nil { + return nil, err + } + registry.File = relPath(root, path) + if registry.Fields == nil { + registry.Fields = map[string]FieldDefinition{} + } + return ®istry, nil +} + +func (p *Project) validateFieldRegistry(report *Report) { + if p.Fields == nil { + if p.Manifest != nil && p.Manifest.customFieldPolicy() == "strict" { + report.addError("missing_field_registry", "", p.Manifest.fieldRegistryPath(), "customFieldPolicy strict requires field registry") + } + return + } + if p.Fields.Version == "" { + report.addError("missing_fields_version", "", p.Fields.File, "field registry requires gspFieldsVersion") + } else if p.Fields.Version != FieldsVersion { + report.addError("unsupported_fields_version", "", p.Fields.File, fmt.Sprintf("GSP fields version %q is not supported", p.Fields.Version)) + } + for name, def := range p.Fields.Fields { + if builtInFields[name] { + report.addError("custom_field_shadows_builtin", "", p.Fields.File, fmt.Sprintf("custom field %q shadows a built-in field", name)) + } + if def.Type == "" { + report.addError("missing_custom_field_type", "", p.Fields.File, fmt.Sprintf("custom field %q requires type", name)) + } else if !allowedFieldTypes[def.Type] { + report.addError("invalid_custom_field_type", "", p.Fields.File, fmt.Sprintf("custom field %q has invalid type %q", name, def.Type)) + } + } +} + +func (p *Project) ValidateFieldRegistryOnly(report *Report) { + for _, issue := range p.LoadIssues { + if issue.Code == "field_registry_error" { + report.Errors = append(report.Errors, issue) + report.OK = false + } + } + p.validateFieldRegistry(report) +} + +func (p *Project) validateCustomFields(report *Report, unit *Unit) { + policy := "strict" + if p.Manifest != nil { + policy = p.Manifest.customFieldPolicy() + } + if policy != "strict" && policy != "warn" && policy != "loose" { + report.addError("invalid_custom_field_policy", "", p.Manifest.File, fmt.Sprintf("customFieldPolicy %q is not allowed", policy)) + policy = "strict" + } + for name, value := range unit.RawFields { + if builtInFields[name] { + continue + } + def, declared := FieldDefinition{}, false + if p.Fields != nil { + def, declared = p.Fields.Fields[name] + } + if !declared { + switch policy { + case "strict": + report.addError("unknown_custom_field", unit.ID, unit.File, fmt.Sprintf("custom field %q is not declared", name)) + case "warn": + report.addWarning("unknown_custom_field", unit.ID, unit.File, fmt.Sprintf("custom field %q is not declared", name)) + } + continue + } + if !fieldApplies(unit, def.Scope) { + report.addError("custom_field_out_of_scope", unit.ID, unit.File, fmt.Sprintf("custom field %q is outside its declared scope", name)) + } + if !matchesFieldType(value, def.Type) { + report.addError("custom_field_type_mismatch", unit.ID, unit.File, fmt.Sprintf("custom field %q must be %s", name, def.Type)) + } + if len(def.Allowed) > 0 && !allowedFieldValue(value, def.Allowed) { + report.addError("custom_field_value_not_allowed", unit.ID, unit.File, fmt.Sprintf("custom field %q value is not allowed", name)) + } + } + if p.Fields == nil { + return + } + for name, def := range p.Fields.Fields { + if !def.Required || !fieldApplies(unit, def.Scope) { + continue + } + if _, ok := unit.RawFields[name]; !ok { + report.addError("missing_required_custom_field", unit.ID, unit.File, fmt.Sprintf("custom field %q is required", name)) + } + } +} + +func fieldApplies(unit *Unit, scope FieldScope) bool { + if len(scope.Type) == 0 && len(scope.IDPrefix) == 0 { + return true + } + if len(scope.Type) > 0 { + for _, value := range scope.Type { + if unit.Type == value { + return true + } + } + } + if len(scope.IDPrefix) > 0 { + for _, prefix := range scope.IDPrefix { + if strings.HasPrefix(unit.ID, prefix) { + return true + } + } + } + return false +} + +func matchesFieldType(value any, kind string) bool { + switch kind { + case "string": + _, ok := value.(string) + return ok + case "number": + switch value.(type) { + case int, int64, float64, float32: + return true + default: + return false + } + case "integer": + switch value.(type) { + case int, int64: + return true + default: + if f, ok := value.(float64); ok { + return f == float64(int64(f)) + } + return false + } + case "boolean": + _, ok := value.(bool) + return ok + case "array": + _, ok := value.([]any) + return ok + case "object": + _, ok := value.(map[string]any) + return ok + default: + return false + } +} + +func allowedFieldValue(value any, allowed []any) bool { + got := fmt.Sprint(value) + for _, candidate := range allowed { + if got == fmt.Sprint(candidate) { + return true + } + } + return false +} + +func normalizeRawYAML(value any) any { + switch typed := value.(type) { + case map[string]any: + out := map[string]any{} + for key, item := range typed { + out[key] = normalizeRawYAML(item) + } + return out + case map[any]any: + out := map[string]any{} + for key, item := range typed { + out[fmt.Sprint(key)] = normalizeRawYAML(item) + } + return out + case []any: + out := make([]any, 0, len(typed)) + for _, item := range typed { + out = append(out, normalizeRawYAML(item)) + } + return out + case int: + return int64(typed) + default: + return typed + } +} diff --git a/toolkit/internal/gsp/init.go b/toolkit/internal/gsp/init.go index 2c16bb3..478277d 100644 --- a/toolkit/internal/gsp/init.go +++ b/toolkit/internal/gsp/init.go @@ -66,6 +66,8 @@ entry: - %s scan: - design +customFieldPolicy: strict +fieldRegistry: gsp.fields stageRules: design: minResolution: L0 @@ -94,10 +96,16 @@ type: concept resolution: L0 context: Project entry GSP. `, yamlScalar(entry)) + fieldsContent := `gspFieldsVersion: 0.1 +fields: {} +` if err := writeInitFile(absRoot, filepath.Join(absRoot, "gsp.manifest"), []byte(manifest), options.Force, &result); err != nil { return InitResult{}, err } + if err := writeInitFile(absRoot, filepath.Join(absRoot, "gsp.fields"), []byte(fieldsContent), options.Force, &result); err != nil { + return InitResult{}, err + } if err := writeInitFile(absRoot, entryFile, []byte(entryContent), options.Force, &result); err != nil { return InitResult{}, err } diff --git a/toolkit/internal/gsp/load.go b/toolkit/internal/gsp/load.go index 9f0cc56..5e57dc6 100644 --- a/toolkit/internal/gsp/load.go +++ b/toolkit/internal/gsp/load.go @@ -33,6 +33,11 @@ func LoadProject(root string) (*Project, error) { project.LoadIssues = append(project.LoadIssues, Issue{Level: "error", Code: "manifest_error", File: "gsp.manifest", Message: err.Error()}) } project.Manifest = manifest + fields, err := loadFieldRegistry(absRoot, project.Manifest) + if err != nil { + project.LoadIssues = append(project.LoadIssues, Issue{Level: "error", Code: "field_registry_error", File: project.Manifest.fieldRegistryPath(), Message: err.Error()}) + } + project.Fields = fields var files []string for _, scanEntry := range project.Manifest.scanEntries() { @@ -98,6 +103,14 @@ func readUnit(root, file string) (*Unit, error) { if err := yaml.Unmarshal(data, &unit); err != nil { return nil, err } + var raw map[string]any + if err := yaml.Unmarshal(data, &raw); err != nil { + return nil, err + } + unit.RawFields = map[string]any{} + for key, value := range raw { + unit.RawFields[key] = normalizeRawYAML(value) + } unit.File = relPath(root, file) return &unit, nil } diff --git a/toolkit/internal/gsp/manifest.go b/toolkit/internal/gsp/manifest.go index 21e41ec..b100224 100644 --- a/toolkit/internal/gsp/manifest.go +++ b/toolkit/internal/gsp/manifest.go @@ -8,15 +8,17 @@ import ( ) 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:"-"` + 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"` + CustomFieldPolicy string `json:"customFieldPolicy,omitempty" yaml:"customFieldPolicy"` + FieldRegistry string `json:"fieldRegistry,omitempty" yaml:"fieldRegistry"` + File string `json:"file,omitempty" yaml:"-"` } type StageRule struct { @@ -56,3 +58,17 @@ func (m *Manifest) minResolution(stage string) (string, bool) { value, ok := defaultStageRules[stage] return value, ok } + +func (m *Manifest) customFieldPolicy() string { + if m == nil || m.CustomFieldPolicy == "" { + return "strict" + } + return m.CustomFieldPolicy +} + +func (m *Manifest) fieldRegistryPath() string { + if m == nil || m.FieldRegistry == "" { + return "gsp.fields" + } + return m.FieldRegistry +} diff --git a/toolkit/internal/gsp/model.go b/toolkit/internal/gsp/model.go index 1306067..ca6e14a 100644 --- a/toolkit/internal/gsp/model.go +++ b/toolkit/internal/gsp/model.go @@ -17,15 +17,16 @@ var resolutionRank = map[string]int{ } type Unit struct { - ID string `json:"id" yaml:"id"` - Title string `json:"title,omitempty" yaml:"title"` - 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"` - Links Links `json:"links,omitempty" yaml:"links"` - File string `json:"file,omitempty" yaml:"-"` + ID string `json:"id" yaml:"id"` + Title string `json:"title,omitempty" yaml:"title"` + 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"` + Links Links `json:"links,omitempty" yaml:"links"` + File string `json:"file,omitempty" yaml:"-"` + RawFields map[string]any `json:"-" yaml:"-"` } type Relation struct { @@ -148,6 +149,7 @@ func (r *Report) addNotice(code, id, file, message string) { type Project struct { Root string Manifest *Manifest + Fields *FieldRegistry 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 096033f..2c45208 100644 --- a/toolkit/internal/gsp/project.go +++ b/toolkit/internal/gsp/project.go @@ -28,6 +28,7 @@ func (p *Project) Validate(stage string) Report { report.addError("unsupported_gsp_version", "", p.Manifest.File, fmt.Sprintf("GSP version %q is not supported by this toolkit", p.Manifest.GSPVersion)) } } + p.validateFieldRegistry(&report) for _, unit := range p.Units { if unit.ID == "" { report.addError("missing_id", "", unit.File, "GSP requires id") @@ -66,6 +67,7 @@ func (p *Project) Validate(stage string) Report { if unit.Context == "" { report.addNotice("placeholder", unit.ID, unit.File, "GSP has no context and is treated as placeholder") } + p.validateCustomFields(&report, unit) } for id, units := range p.Duplicates { files := make([]string, 0, len(units)) diff --git a/toolkit/internal/gsp/project_test.go b/toolkit/internal/gsp/project_test.go index 482f929..8d95e6f 100644 --- a/toolkit/internal/gsp/project_test.go +++ b/toolkit/internal/gsp/project_test.go @@ -212,6 +212,74 @@ scan: } } +func TestValidateCustomFieldsStrict(t *testing.T) { + root := t.TempDir() + design := filepath.Join(root, "design") + if err := os.MkdirAll(design, 0755); err != nil { + t.Fatal(err) + } + writeTestFile(t, root, "gsp.manifest", `gspVersion: 0.1 +customFieldPolicy: strict +fieldRegistry: gsp.fields +scan: + - design +`) + writeTestFile(t, root, "gsp.fields", `gspFieldsVersion: 0.1 +fields: + rewardTier: + type: string + allowed: + - common + - rare + scope: + type: + - mechanic +`) + writeTestFile(t, design, "mechanic.gsp", `id: mechanic.sample +type: mechanic +rewardTier: rare +`) + project, err := LoadProject(root) + if err != nil { + t.Fatal(err) + } + report := project.Validate("") + if !report.OK { + t.Fatalf("expected custom field to validate, got %+v", report.Errors) + } +} + +func TestValidateUnknownCustomFieldFails(t *testing.T) { + root := t.TempDir() + design := filepath.Join(root, "design") + if err := os.MkdirAll(design, 0755); err != nil { + t.Fatal(err) + } + writeTestFile(t, root, "gsp.manifest", `gspVersion: 0.1 +customFieldPolicy: strict +fieldRegistry: gsp.fields +scan: + - design +`) + writeTestFile(t, root, "gsp.fields", `gspFieldsVersion: 0.1 +fields: {} +`) + writeTestFile(t, design, "page.gsp", `id: page.sample +unknownMood: exciting +`) + project, err := LoadProject(root) + if err != nil { + t.Fatal(err) + } + report := project.Validate("") + if report.OK { + t.Fatal("expected unknown custom field failure") + } + if report.Errors[0].Code != "unknown_custom_field" { + t.Fatalf("expected unknown_custom_field, got %+v", report.Errors) + } +} + func TestInitProjectCreatesManifestAndEntry(t *testing.T) { root := filepath.Join(t.TempDir(), "sample-project") result, err := InitProject(root, InitOptions{ @@ -240,6 +308,9 @@ func TestInitProjectCreatesManifestAndEntry(t *testing.T) { if _, ok := project.ByID["page.sample.main"]; !ok { t.Fatal("expected initialized entry GSP") } + if _, err := os.Stat(filepath.Join(root, "gsp.fields")); err != nil { + t.Fatal(err) + } report := project.Validate("") if !report.OK { t.Fatalf("expected valid initialized project, got %+v", report.Errors)