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