Add strict custom field registry

This commit is contained in:
2026-05-07 11:04:11 +08:00
parent 27e71d8c51
commit c1cc9132a4
20 changed files with 582 additions and 20 deletions

View File

@@ -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.

View 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, &registry); err != nil {
return nil, err
}
registry.File = relPath(root, path)
if registry.Fields == nil {
registry.Fields = map[string]FieldDefinition{}
}
return &registry, 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
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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))

View File

@@ -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)