250 lines
6.7 KiB
Go
250 lines
6.7 KiB
Go
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
|
|
}
|
|
}
|