Add strict custom field registry
This commit is contained in:
@@ -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 <id>
|
||||
gsp pack <id> --for implement --format md
|
||||
gsp links <id> --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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
2
examples/lottery/gsp.fields
Normal file
2
examples/lottery/gsp.fields
Normal file
@@ -0,0 +1,2 @@
|
||||
gspFieldsVersion: 0.1
|
||||
fields: {}
|
||||
@@ -8,6 +8,9 @@ entry:
|
||||
scan:
|
||||
- design
|
||||
|
||||
customFieldPolicy: strict
|
||||
fieldRegistry: gsp.fields
|
||||
|
||||
stageRules:
|
||||
design:
|
||||
minResolution: L0
|
||||
|
||||
@@ -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 的核心作用是高效传递设计信息。
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
57
specs/versions/0.1/gsp.fields.schema.json
Normal file
57
specs/versions/0.1/gsp.fields.schema.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 <id>
|
||||
gsp flatten <id>
|
||||
gsp pack <id>
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 <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 .] [--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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
249
toolkit/internal/gsp/fields.go
Normal file
249
toolkit/internal/gsp/fields.go
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user