From 44486aaf38f945c403651fae7932ff879ccd590a Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Thu, 1 Oct 2026 10:31:02 +0100 Subject: [PATCH] Fix metadata projection and validation across context fields --- .github/workflows/ci.yml | 3 + README.md | 12 +- context.go | 24 +- examples/read/main.go | 17 +- integration_test.go | 12 + internal/generate/main.go | 15 +- metadata_regression_test.go | 128 ++++++++++ model.go | 88 +++++-- projection_test.go | 451 ++++++++++++++++++++++++++++++++++++ strings.go | 23 +- terms_generated.go | 12 +- testdata/projection.json | 19 ++ validate.go | 22 +- validate_test.go | 2 +- validate_values.go | 3 + 15 files changed, 774 insertions(+), 57 deletions(-) create mode 100644 metadata_regression_test.go create mode 100644 projection_test.go create mode 100644 testdata/projection.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1425cf9..3f7b041 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,6 +68,9 @@ jobs: "$RUNNER_TEMP/read-tinygo" testdata/upstream/codemeta-software.json > "$RUNNER_TEMP/tinygo-output" "$RUNNER_TEMP/read-go" testdata/upstream/codemeta-software.json > "$RUNNER_TEMP/go-output" diff -u "$RUNNER_TEMP/go-output" "$RUNNER_TEMP/tinygo-output" + "$RUNNER_TEMP/read-tinygo" testdata/projection.json > "$RUNNER_TEMP/tinygo-output" + "$RUNNER_TEMP/read-go" testdata/projection.json > "$RUNNER_TEMP/go-output" + diff -u "$RUNNER_TEMP/go-output" "$RUNNER_TEMP/tinygo-output" lint: runs-on: ubuntu-latest diff --git a/README.md b/README.md index 8c2033d..fd6cba8 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ package main import ( "fmt" "log" + "strings" "github.com/git-pkgs/codemeta" ) @@ -28,7 +29,7 @@ func main() { fmt.Printf("Name: %s\n", doc.Name()) fmt.Printf("CodeMeta context: %s\n", doc.Version()) - fmt.Printf("Software version: %s\n", doc.SoftwareVersion().Text()) + fmt.Printf("Software version: %s\n", strings.Join(doc.Strings("version"), ", ")) for _, author := range doc.Author() { fmt.Println(author.Name(), author.GivenName(), author.FamilyName()) } @@ -44,7 +45,7 @@ Documents are immutable and can be read concurrently. Parsing copies retained te ### Metadata -`Name` and `Description` return text. `CodeRepository`, `SoftwareVersion`, `License`, `Keywords`, `ProgrammingLanguages`, `DatePublished`, `DateModified`, `DevelopmentStatus`, and `Identifier` return values that retain the written form. `Version` identifies the pinned CodeMeta context. It does not return the software version. +`Name` and `Description` return the first nonempty projected string. `CodeRepository`, `SoftwareVersion`, `License`, `Keywords`, `ProgrammingLanguages`, `DatePublished`, `DateModified`, `DevelopmentStatus`, and `Identifier` return values that retain the written form. `Version` identifies the pinned CodeMeta context. It does not return the software version. Use `Get` to access any field, including unknown fields and invalid metadata: @@ -55,16 +56,19 @@ for _, keyword := range doc.Keywords().Values() { } ``` -`Strings` projects a term into display text, unwrapping `@list`, `@set`, and `@value` and reading `@id` or `name` from objects. It returns scalar spellings in source order and skips empty strings and nulls: +`Document.Strings` and `Agent.Strings` project a term into display text, unwrapping `@list`, `@set`, and `@value` and reading `@id` or `name` from objects. They resolve fields in their local context, return scalar spellings in source order, and skip empty strings and nulls: ```go fmt.Println(doc.Strings("keywords")) fmt.Println(doc.Strings("license")) +for _, author := range doc.Author() { + fmt.Println(author.Strings("givenName"), author.Strings("roleName")) +} ``` Values expose `Kind`, `Text`, `Items`, `Fields`, and `Position`. `Items` returns only array elements; `Values` returns array elements or a single scalar or object. A missing field has kind `Missing` and no values, while explicit JSON `Null` remains a value. Numbers retain their spelling and precision. Dates retain their text. -`Author`, `Contributor`, `Maintainer`, `CopyrightHolder`, and `Funder` return ordered agent views, accepting either a single value or an array. `Agent.Kind` distinguishes people, organisations, strings, ID references, roles, and conflicting fields. `Value` preserves each original value; `Get` accesses its fields. `RoleName` and `Agents` expose a role and its nested agents without discarding the wrapper. +`Author`, `Contributor`, `Maintainer`, `CopyrightHolder`, and `Funder` return ordered agent views, accepting single values, arrays, and list or set containers within arrays. Null entries are skipped. `Agent.Kind` distinguishes people, organisations, strings, ID references, roles, and conflicting fields. `Value` preserves each original value; `Get` accesses its fields. `Name`, `GivenName`, and `FamilyName` return the first nonempty projected string; `Name` also returns a text agent's value. `RoleName` and `Agents` expose a role and its nested agents without discarding the wrapper. `Document.Get` and `Agent.Get` resolve known aliases and prefixed or full term IRIs when the declared context is usable. An exact written key takes precedence. `Value.Get` always uses the exact key. `Fields` retains written keys and their positions, including both keys when aliases conflict. Both `http://schema.org/` and `https://schema.org/` resolve to the same schema term. CodeMeta namespace IRIs remain specific to each pinned context. diff --git a/context.go b/context.go index 7b0de1f..8c15dc8 100644 --- a/context.go +++ b/context.go @@ -33,32 +33,42 @@ func (d *Document) Version() Version { return d.context.version } func inspectContext(v Value) contextState { + return inspectScopedContext(v, nil) +} + +func inspectScopedContext(v Value, inherited map[string]termDefinition) contextState { c := contextState{missing: v.kind == Missing} - c.include(v) + c.include(v, inherited) if c.conflicting { c.version = UnknownVersion } return c } -func (c *contextState) include(v Value) { +func (c *contextState) include(v Value, inherited map[string]termDefinition) { switch v.kind { case Missing: return case Array: - if len(v.items) == 0 { + if len(v.items) == 0 && inherited == nil { c.unknown = true } for _, item := range v.items { if item.kind == Array { c.unknown = true } else { - c.include(item) + c.include(item, inherited) } } case String: c.selectVersion(contextURL(v.text)) case Object: - if len(v.fields) == 0 || (c.version != UnknownVersion && unchangedDefinitions(v, contextTerms[c.version])) { + terms := inherited + if c.version != UnknownVersion { + terms = contextTerms[c.version] + } else if c.unknown || c.modified || c.conflicting { + terms = nil + } + if unchangedDefinitions(v, terms) { return } for _, version := range []Version{Version2, Version3, VersionMaster} { @@ -140,10 +150,10 @@ func unchangedDefinitions(v Value, terms map[string]termDefinition) bool { func scopedTerms(v Value, inherited map[string]termDefinition) map[string]termDefinition { if local := v.Get(keywordContext); local.kind != Missing { - if local.kind == Object && unchangedDefinitions(local, inherited) { + state := inspectScopedContext(local, inherited) + if state.version == "" && !state.unknown && !state.modified && !state.conflicting { return inherited } - state := inspectContext(local) if !state.usable() { return nil } diff --git a/examples/read/main.go b/examples/read/main.go index a13c1a1..969e831 100644 --- a/examples/read/main.go +++ b/examples/read/main.go @@ -4,6 +4,7 @@ package main import ( "fmt" "os" + "strings" "github.com/git-pkgs/codemeta" ) @@ -26,11 +27,19 @@ func main() { fmt.Fprintln(os.Stderr, err) os.Exit(1) } - fmt.Printf("Name: %s\nContext: %s\nSoftware version: %s\n", doc.Name(), doc.Version(), doc.SoftwareVersion().Text()) - for _, a := range doc.Author() { - fmt.Printf("Author: %s %s %s\n", a.Name(), a.GivenName(), a.FamilyName()) - } + fmt.Printf("Name: %s\nContext: %s\nSoftware version: %s\n", doc.Name(), doc.Version(), strings.Join(doc.Strings("version"), ", ")) + printAuthors(doc.Author()) for _, d := range doc.Validate() { fmt.Printf("%d:%d %s %s: %s\n", d.Line, d.Column, d.Path, d.Code, d.Message) } } + +func printAuthors(authors []codemeta.Agent) { + for _, author := range authors { + fmt.Printf("Author: %s %s %s\n", author.Name(), strings.Join(author.Strings("givenName"), " "), strings.Join(author.Strings("familyName"), " ")) + if author.Kind() == codemeta.AgentRole { + fmt.Printf("Role: %s\n", strings.Join(author.Strings("roleName"), ", ")) + printAuthors(author.Agents()) + } + } +} diff --git a/integration_test.go b/integration_test.go index 95d519f..a6ca496 100644 --- a/integration_test.go +++ b/integration_test.go @@ -96,3 +96,15 @@ func commandOutput(t *testing.T, dir, name string, args ...string) string { } return string(output) } + +func TestConsumerProjection(t *testing.T) { + binary := filepath.Join(t.TempDir(), "read") + commandOutput(t, "", "go", "build", "-o", binary, "./examples/read") + got := commandOutput(t, "", binary, "testdata/projection.json") + const want = "Name: Example\nContext: 3.0\nSoftware version: 1.2, 1.3\n" + + "Author: Ada Augusta Lovelace\nAuthor: Grace Hopper \nAuthor: \n" + + "Role: creator, developer\nAuthor: Team \n" + if got != want { + t.Fatalf("output:\n%s\nwant:\n%s", got, want) + } +} diff --git a/internal/generate/main.go b/internal/generate/main.go index 3a5b87f..5b283a6 100644 --- a/internal/generate/main.go +++ b/internal/generate/main.go @@ -172,6 +172,19 @@ func readRanges(r io.Reader) (map[string]string, error) { } } } + // These properties occur in the pinned contexts but not the property CSV. + for key, expected := range map[string]string{ + "codemeta:contIntegration": "URL", + "codemeta:embargoDate": "Date", + "schema:creator": "Organization Person", + "schema:archivedAt": "URL WebPage", + "schema:encodingFormat": "Text URL", + "schema:featureList": "Text URL", + } { + if ranges[key] == "" { + ranges[key] = expected + } + } return ranges, nil } func contains(types, typ string) bool { @@ -186,7 +199,7 @@ func translate(expression string) (string, error) { var types []string for _, name := range strings.Split(expression, " or ") { switch name { - case "Text", "URL", "Number", "Integer", "Boolean", "Date", "Datetime", "ComputerLanguage", "Review", "SoftwareApplication", "CreativeWork", "SoftwareSourceCode", "DataFeed", "Organization", "Person", "MediaObject", "Grant", "PropertyValue", "PostalAddress", "ContactPoint", "ScholarlyArticle": + case "Text", "URL", "Number", "Integer", "Boolean", "Date", "Datetime", "ComputerLanguage", "Review", "SoftwareApplication", "CreativeWork", "SoftwareSourceCode", "DataFeed", "Organization", "Person", "MediaObject", "Grant", "PropertyValue", "PostalAddress", "ContactPoint", "ScholarlyArticle", "WebPage": types = append(types, name) default: return "", fmt.Errorf("unsupported property type %q", expression) diff --git a/metadata_regression_test.go b/metadata_regression_test.go new file mode 100644 index 0000000..3fa3d43 --- /dev/null +++ b/metadata_regression_test.go @@ -0,0 +1,128 @@ +package codemeta_test + +import ( + "reflect" + "testing" + + "github.com/git-pkgs/codemeta" +) + +func TestAdditionalPropertyRanges(t *testing.T) { + for _, tc := range []struct { + version, term string + values []any + }{ + {"2.0", "contIntegration", []any{"https://example.org/ci"}}, + {"2.0", "creator", []any{map[string]any{"@type": "Person", "name": "Ada"}}}, + {"2.0", "embargoDate", []any{"2024-02-29"}}, + {"master", "archivedAt", []any{"https://example.org/archive", map[string]any{"name": "Archive"}}}, + {"master", "encodingFormat", []any{"application/json"}}, + {"master", "featureList", []any{"Offline parsing"}}, + } { + t.Run(tc.term, func(t *testing.T) { + for _, value := range tc.values { + for _, shape := range []any{value, []any{value}, map[string]any{"@set": []any{value}}} { + doc := parse(t, map[string]any{"@context": contextURL(tc.version), tc.term: shape}) + if ds := doc.Validate(); len(ds) != 0 { + t.Fatalf("valid value: %+v", ds) + } + } + } + doc := parse(t, map[string]any{"@context": contextURL(tc.version), tc.term: true}) + if ds := doc.Validate(); len(ds) != 1 || ds[0].Code != "value_type" { + t.Fatalf("invalid value: %+v", ds) + } + }) + } +} + +func TestIdentifierReferenceForms(t *testing.T) { + for _, id := range []string{"", "#person", "../person", "urn:example:person", "_:person", "https://example.org/person"} { + for _, key := range []string{"@id", "id"} { + doc := parse(t, map[string]any{"@context": v3, "author": map[string]any{key: id}}) + if ds := doc.Validate(); len(ds) != 0 { + t.Fatalf("%s=%q: %+v", key, id, ds) + } + if a := doc.Author()[0]; a.Kind() != codemeta.AgentReference || a.Identifier().Text() != id { + t.Fatalf("reference changed: %+v", a) + } + } + } +} + +func TestAgentTextAndEmptyContainers(t *testing.T) { + doc := parse(t, map[string]any{"@context": v3, "author": []any{nil, map[string]any{"@set": nil}, "Ada"}}) + if authors := doc.Author(); len(authors) != 1 || authors[0].Name() != "Ada" || authors[0].Kind() != codemeta.AgentText { + t.Fatalf("authors: %+v", authors) + } +} + +func TestAgentStringsLocalContext(t *testing.T) { + for _, tc := range []struct { + context any + want []string + }{ + {map[string]any{}, []string{"Ada", "Grace"}}, + {[]any{map[string]any{}}, []string{"Ada", "Grace"}}, + {[]any{}, []string{"Ada", "Grace"}}, + {nil, nil}, + {map[string]any{"schema": "https://example.org/"}, nil}, + } { + doc := parse(t, map[string]any{"@context": v3, "author": map[string]any{ + "@context": tc.context, "schema:name": map[string]any{"@set": []any{"Ada", "Grace"}}, + }}) + if got := doc.Author()[0].Strings("name"); !reflect.DeepEqual(got, tc.want) { + t.Fatalf("context %v: %v, want %v", tc.context, got, tc.want) + } + } +} + +func TestLiteralDirection(t *testing.T) { + for _, direction := range []any{nil, "ltr", "rtl"} { + doc := parse(t, map[string]any{"@context": v3, "name": map[string]any{"@value": "Example", "@direction": direction}}) + if ds := doc.Validate(); len(ds) != 0 { + t.Fatal(ds) + } + } + doc := parse(t, map[string]any{"@context": v3, "name": map[string]any{"@value": "Example", "@direction": "sideways"}}) + if ds := doc.Validate(); len(ds) != 1 || ds[0].Code != "invalid_direction" { + t.Fatal(ds) + } +} + +func FuzzMetadataProjection(f *testing.F) { + for _, seed := range []string{ + `{"@context":"https://w3id.org/codemeta/3.0","name":{"@value":"Example"},"author":[{"@set":[{"givenName":"Ada"}]}]}`, + `{"@context":"https://w3id.org/codemeta/3.0","author":{"@context":[{}],"schema:name":{"@list":["Ada","Grace"]}}}`, + `{"author":{"@type":"Role","roleName":{"@value":"creator"},"author":{"name":"Ada"}}}`, + `{"name":[null,{},[],false,12],"author":[null,{"name":null},{"@id":"_:person"}]}`, + } { + f.Add([]byte(seed)) + } + f.Fuzz(func(t *testing.T, data []byte) { + doc, err := codemeta.Parse(data) + if err != nil { + return + } + for _, field := range doc.Fields() { + if !reflect.DeepEqual(doc.Strings(field.Name), doc.Strings(field.Name)) { + t.Fatal("nondeterministic document projection") + } + } + for _, relation := range []string{"author", "contributor", "maintainer", "copyrightHolder", "funder"} { + checkAgentProjection(t, agents(doc, relation)) + } + }) +} + +func checkAgentProjection(t *testing.T, values []codemeta.Agent) { + t.Helper() + for _, agent := range values { + for _, field := range agent.Value().Fields() { + if !reflect.DeepEqual(agent.Strings(field.Name), agent.Strings(field.Name)) { + t.Fatal("nondeterministic agent projection") + } + } + checkAgentProjection(t, agent.Agents()) + } +} diff --git a/model.go b/model.go index 0fa028e..74719ce 100644 --- a/model.go +++ b/model.go @@ -2,8 +2,8 @@ package codemeta const nameTerm = "name" -func (d *Document) Name() string { return d.Get(nameTerm).Text() } -func (d *Document) Description() string { return d.Get("description").Text() } +func (d *Document) Name() string { return firstString(d.Strings(nameTerm)) } +func (d *Document) Description() string { return firstString(d.Strings("description")) } func (d *Document) CodeRepository() Value { return d.Get("codeRepository") } func (d *Document) SoftwareVersion() Value { return d.Get("version") } func (d *Document) License() Value { return d.Get("license") } @@ -41,11 +41,16 @@ type Agent struct { func (a Agent) Value() Value { return a.value } func (a Agent) Get(term string) Value { return resolvedGet(a.value, term, a.terms) } -func (a Agent) Name() string { return a.Get(nameTerm).Text() } -func (a Agent) GivenName() string { return a.Get("givenName").Text() } -func (a Agent) FamilyName() string { return a.Get("familyName").Text() } -func (a Agent) RoleName() Value { return a.Get("roleName") } -func (a Agent) Identifier() Value { return a.Get(keywordID) } +func (a Agent) Name() string { + if a.value.kind == String { + return a.value.Text() + } + return firstString(a.Strings(nameTerm)) +} +func (a Agent) GivenName() string { return firstString(a.Strings("givenName")) } +func (a Agent) FamilyName() string { return firstString(a.Strings("familyName")) } +func (a Agent) RoleName() Value { return a.Get("roleName") } +func (a Agent) Identifier() Value { return a.Get(keywordID) } func (a Agent) Kind() AgentKind { if a.value.kind == String { return AgentText @@ -53,13 +58,13 @@ func (a Agent) Kind() AgentKind { if a.value.kind != Object { return AgentUnknown } - person := a.Get("givenName").kind != Missing || a.Get("familyName").kind != Missing || a.Get("affiliation").kind != Missing - organization := a.Get("legalName").kind != Missing || a.Get("foundingDate").kind != Missing + person := hasAgentValue(a.Get("givenName")) || hasAgentValue(a.Get("familyName")) || hasAgentValue(a.Get("affiliation")) + organization := hasAgentValue(a.Get("legalName")) || hasAgentValue(a.Get("foundingDate")) personType, orgType, roleType := a.types() if (person && (organization || orgType)) || (organization && personType) || (personType && orgType) { return AgentConflict } - if roleType || a.Get("roleName").kind != Missing { + if roleType || hasAgentValue(a.Get("roleName")) { return AgentRole } if person { @@ -68,17 +73,51 @@ func (a Agent) Kind() AgentKind { if organization { return AgentOrganization } - if a.Get(keywordID).kind != Missing && len(a.value.fields) == 1 { + if a.referenceOnly() { return AgentReference } if personType { return AgentPerson } - if orgType || a.Get(nameTerm).kind != Missing { + if orgType || hasAgentValue(a.Get(nameTerm)) { return AgentOrganization } return AgentUnknown } + +func (a Agent) referenceOnly() bool { + if a.Identifier().kind != String { + return false + } + for _, field := range a.value.fields { + name := expandIRI(field.Name, a.terms) + if name != keywordID && name != keywordContext && hasAgentValue(field.Value) { + return false + } + } + return true +} + +func hasAgentValue(v Value) bool { + switch v.kind { + case Missing, Null: + return false + case Array: + for _, item := range v.items { + if hasAgentValue(item) { + return true + } + } + return false + case Object: + for _, key := range []string{keywordList, keywordSet, keywordValue} { + if field := v.Get(key); field.kind != Missing { + return hasAgentValue(field) + } + } + } + return true +} func (a Agent) types() (person, organization, role bool) { for _, typ := range a.Get(keywordType).Values() { switch expandIRI(typ.text, a.terms) { @@ -106,16 +145,21 @@ func (d *Document) agents(term string) []Agent { return agentValues(d.Get(term), terms, term) } func agentValues(value Value, terms map[string]termDefinition, relation string) []Agent { - if list := value.Get(keywordList); list.kind != Missing { - value = list - } - if set := value.Get(keywordSet); set.kind != Missing { - value = set - } - values := value.Values() - agents := make([]Agent, len(values)) - for i, v := range values { - agents[i] = Agent{value: v, terms: scopedTerms(v, terms), relation: relation} + var agents []Agent + for _, v := range value.Values() { + if v.kind == Null { + continue + } + localTerms := scopedTerms(v, terms) + if list := v.Get(keywordList); list.kind != Missing { + agents = append(agents, agentValues(list, localTerms, relation)...) + } else if set := v.Get(keywordSet); set.kind != Missing { + agents = append(agents, agentValues(set, localTerms, relation)...) + } else if v.kind == Array { + agents = append(agents, agentValues(v, localTerms, relation)...) + } else { + agents = append(agents, Agent{value: v, terms: localTerms, relation: relation}) + } } return agents } diff --git a/projection_test.go b/projection_test.go new file mode 100644 index 0000000..0a3580d --- /dev/null +++ b/projection_test.go @@ -0,0 +1,451 @@ +package codemeta_test + +import ( + "bytes" + "encoding/csv" + "encoding/json" + "os" + "reflect" + "sort" + "strings" + "testing" + + cm "github.com/git-pkgs/codemeta" +) + +const v3 = "https://w3id.org/codemeta/3.0" + +func parse(t *testing.T, input any) *cm.Document { + t.Helper() + b, err := json.Marshal(input) + if err != nil { + t.Fatal(err) + } + d, err := cm.Parse(b) + if err != nil { + t.Fatal(err) + } + return d +} +func contextURL(v string) string { + if v == "master" { + return "https://raw.githubusercontent.com/codemeta/codemeta/master/codemeta.jsonld" + } + return "https://w3id.org/codemeta/" + v +} +func contexts(t *testing.T, v string) map[string]any { + t.Helper() + b, e := os.ReadFile("internal/contexts/" + v + ".jsonld") + if e != nil { + t.Fatal(e) + } + var doc struct { + Context map[string]any `json:"@context"` + } + if e = json.Unmarshal(b, &doc); e != nil { + t.Fatal(e) + } + return doc.Context +} +func iri(ctx map[string]any, term string) string { + d := ctx[term] + s, ok := d.(string) + if !ok { + s, _ = d.(map[string]any)["@id"].(string) + } + p, rest, ok := strings.Cut(s, ":") + if ok && p != "http" && p != "https" { + if base, ok := ctx[p].(string); ok { + s = base + rest + } + } + return s +} +func properties(ctx map[string]any) []string { + var out []string + for term := range ctx { + if term == "id" || term == "type" || term == "schema" || term == "codemeta" || term[0] < 'a' || term[0] > 'z' { + continue + } + out = append(out, term) + } + sort.Strings(out) + return out +} +func TestEveryPropertyProjection(t *testing.T) { + cases := []struct { + name string + value any + want []string + }{ + {"text", "alpha", []string{"alpha"}}, {"number", json.Number("1.20"), []string{"1.20"}}, {"boolean", false, []string{"false"}}, + {"empty", "", nil}, {"null", nil, nil}, {"array", []any{"alpha", "beta"}, []string{"alpha", "beta"}}, + {"value", map[string]any{"@value": "alpha", "@language": "en"}, []string{"alpha"}}, + {"list", map[string]any{"@list": []any{"alpha", map[string]any{"@value": "beta"}}}, []string{"alpha", "beta"}}, + {"set", map[string]any{"@set": []any{"alpha", "beta"}}, []string{"alpha", "beta"}}, + {"array-list", []any{map[string]any{"@list": []any{"alpha", "beta"}}}, []string{"alpha", "beta"}}, + {"id", map[string]any{"@id": "alpha"}, []string{"alpha"}}, + {"id-alias", map[string]any{"id": "alpha"}, []string{"alpha"}}, + {"object-name", map[string]any{"schema:name": "alpha"}, []string{"alpha"}}, + {"id-before-name", map[string]any{"@id": "alpha", "name": "beta"}, []string{"alpha"}}, + {"empty-id", map[string]any{"@id": "", "name": "beta"}, []string{"beta"}}, + {"empty-list", map[string]any{"@list": []any{}, "name": "beta"}, nil}, + {"empty-object", map[string]any{}, nil}, + } + total := 0 + for _, v := range []string{"2.0", "3.0", "master"} { + ctx := contexts(t, v) + props := properties(ctx) + t.Logf("%s: %d properties", v, len(props)) + for _, term := range props { + full := iri(ctx, term) + keys := []string{term, full} + if strings.Contains(full, "schema.org/") { + keys = append(keys, "https://schema.org/"+strings.Split(full, "schema.org/")[1], "http://schema.org/"+strings.Split(full, "schema.org/")[1], "schema:"+strings.Split(full, "schema.org/")[1]) + } else { + base, _ := ctx["codemeta"].(string) + keys = append(keys, "codemeta:"+strings.TrimPrefix(full, base)) + } + for _, key := range keys { + for _, c := range cases { + d := parse(t, map[string]any{"@context": contextURL(v), key: c.value}) + total++ + if got := d.Strings(term); !reflect.DeepEqual(got, c.want) { + t.Errorf("%s/%s/%s/%s got %v want %v", v, term, key, c.name, got, c.want) + } + if !reflect.DeepEqual(d.Get(term), d.Get(key)) { + t.Errorf("%s/%s alias mismatch", v, key) + } + a := parse(t, map[string]any{"@context": contextURL(v), "author": map[string]any{key: c.value}}).Author()[0] + if got := a.Strings(term); !reflect.DeepEqual(got, c.want) { + t.Errorf("agent %s/%s/%s/%s got %v want %v", v, term, key, c.name, got, c.want) + } + } + } + } + } + t.Logf("%d property/alias/shape checks", total) +} +func TestAllDeclaredRanges(t *testing.T) { + data, e := os.ReadFile("internal/contexts/properties_description.csv") + if e != nil { + t.Fatal(e) + } + rows, e := csv.NewReader(bytes.NewReader(data)).ReadAll() + if e != nil { + t.Fatal(e) + } + ranges := map[string][]string{} + for _, r := range rows[1:] { + p, _, _ := strings.Cut(r[0], ":") + key := p + ":" + r[1] + ranges[key] = append(ranges[key], strings.Split(r[2], " or ")...) + } + examples := map[string]any{"Text": "example", "URL": "https://example.org/item", "Date": "2024-02-29", "Datetime": "2024-02-29T12:00:00Z", "Integer": 12, "Number": 1.25, "Boolean": true} + total := 0 + noRange := map[string][]string{} + for _, v := range []string{"2.0", "3.0", "master"} { + ctx := contexts(t, v) + for _, term := range properties(ctx) { + full := iri(ctx, term) + key := rangeKey(ctx, full) + if len(ranges[key]) == 0 { + noRange[v] = append(noRange[v], term) + } + for _, typ := range ranges[key] { + base, scalar := examples[typ] + if !scalar { + base = map[string]any{"@id": "https://example.org/item"} + } + shapes := []any{base, []any{base}, map[string]any{"@set": []any{base}}, map[string]any{"@list": []any{base}}} + if scalar { + shapes = append(shapes, map[string]any{"@value": base}) + } + for i, val := range shapes { + d := parse(t, map[string]any{"@context": contextURL(v), term: val}) + total++ + if ds := d.Validate(); len(ds) > 0 { + t.Errorf("%s/%s/%s/shape%d: %+v", v, term, typ, i, ds) + } + } + } + } + } + t.Logf("%d declared-range shape checks; properties using supplemental ranges: %v", total, noRange) +} +func agents(d *cm.Document, rel string) []cm.Agent { + switch rel { + case "author": + return d.Author() + case "contributor": + return d.Contributor() + case "maintainer": + return d.Maintainer() + case "copyrightHolder": + return d.CopyrightHolder() + case "funder": + return d.Funder() + } + return nil +} +func TestAgentContainers(t *testing.T) { + a := map[string]any{"@type": "Person", "givenName": "Ada"} + b := map[string]any{"@type": "Person", "givenName": "Grace"} + shapes := []struct { + name string + value any + }{ + {"array", []any{a, b}}, {"list", map[string]any{"@list": []any{a, b}}}, {"set", map[string]any{"@set": []any{a, b}}}, + {"array-list", []any{map[string]any{"@list": []any{a, b}}}}, {"array-set", []any{map[string]any{"@set": []any{a, b}}}}, + } + for _, v := range []string{"2.0", "3.0", "master"} { + for _, rel := range []string{"author", "contributor", "maintainer", "copyrightHolder", "funder"} { + for _, s := range shapes { + for _, role := range []bool{false, true} { + label := v + "/" + rel + "/" + s.name + if role { + label += "/role" + } + t.Run(label, func(t *testing.T) { + checkAgentContainer(t, v, rel, s.value, role) + }) + } + } + } + } +} + +func checkAgentContainer(t *testing.T, version, relation string, value any, role bool) { + t.Helper() + if role { + value = map[string]any{"@type": "schema:Role", relation: value} + } + doc := parse(t, map[string]any{"@context": contextURL(version), relation: value}) + if ds := doc.Validate(); len(ds) != 0 { + t.Fatalf("validation: %+v", ds) + } + got := agents(doc, relation) + if role { + if len(got) != 1 { + t.Fatal("missing role") + } + got = got[0].Agents() + } + var names []string + for _, agent := range got { + names = append(names, agent.GivenName()) + } + if !reflect.DeepEqual(names, []string{"Ada", "Grace"}) { + t.Errorf("names=%q", names) + } +} +func TestTextAccessorShapes(t *testing.T) { + shapes := []struct { + name string + wrap func(string) any + }{ + {"scalar", func(v string) any { return v }}, {"array", func(v string) any { return []any{v} }}, + {"value", func(v string) any { return map[string]any{"@value": v} }}, {"list", func(v string) any { return map[string]any{"@list": []any{v}} }}, {"set", func(v string) any { return map[string]any{"@set": []any{v}} }}, + } + for _, s := range shapes { + t.Run(s.name, func(t *testing.T) { + d := parse(t, map[string]any{"@context": v3, "name": s.wrap("Name"), "description": s.wrap("Description"), "version": s.wrap("1.2"), "author": map[string]any{"@type": "Person", "name": s.wrap("Author"), "givenName": s.wrap("Ada"), "familyName": s.wrap("Lovelace")}, "contributor": map[string]any{"@type": "Role", "roleName": s.wrap("developer"), "contributor": map[string]any{"name": "A"}}}) + if ds := d.Validate(); len(ds) > 0 { + t.Fatal(ds) + } + a := d.Author()[0] + values := map[string]string{"doc.Name": d.Name(), "doc.Description": d.Description(), "version.Strings": strings.Join(d.Strings("version"), ", "), "agent.Name": a.Name(), "agent.GivenName": a.GivenName(), "agent.FamilyName": a.FamilyName(), "roleName.Strings": strings.Join(d.Contributor()[0].Strings("roleName"), ", ")} + for name, v := range values { + if v == "" { + t.Errorf("%s empty for valid %s", name, s.name) + } + } + }) + } +} +func TestContextPropagation(t *testing.T) { + cases := []struct { + name string + input any + want []string + }{ + {"local-enable", map[string]any{"programmingLanguage": map[string]any{"@context": v3, "name": map[string]any{"schema:name": "Go"}}}, []string{"Go"}}, + {"local-reset", map[string]any{"@context": v3, "programmingLanguage": map[string]any{"@context": nil, "name": map[string]any{"schema:name": "Go"}}}, nil}, + {"local-modified", map[string]any{"@context": v3, "programmingLanguage": map[string]any{"@context": map[string]any{"schema": "https://example.org/"}, "name": map[string]any{"schema:name": "Go"}}}, nil}, + {"direct-local-enable", map[string]any{"programmingLanguage": map[string]any{"@context": v3, "schema:name": "Go"}}, []string{"Go"}}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + d := parse(t, c.input) + if got := d.Strings("programmingLanguage"); !reflect.DeepEqual(got, c.want) { + t.Errorf("got %v want %v; diagnostics=%+v", got, c.want, d.Validate()) + } + }) + } +} +func TestNestedContextArrays(t *testing.T) { + for _, local := range []any{map[string]any{}, []any{map[string]any{}}, map[string]any{"givenName": "schema:givenName"}, []any{map[string]any{"givenName": "schema:givenName"}}} { + d := parse(t, map[string]any{"@context": v3, "author": map[string]any{"@context": local, "schema:givenName": "Ada"}}) + if got := d.Author()[0].GivenName(); got != "Ada" { + t.Errorf("local=%v got %q diagnostics=%+v", local, got, d.Validate()) + } + } +} +func TestReferenceValidation(t *testing.T) { + for _, term := range []string{"codeRepository", "downloadUrl", "installUrl", "url", "issueTracker", "referencePublication", "license", "author"} { + for _, key := range []string{"@id", "id"} { + d := parse(t, map[string]any{"@context": v3, term: map[string]any{key: "not an IRI"}}) + if ds := d.Validate(); len(ds) == 0 { + t.Errorf("%s/%s accepts invalid IRI", term, key) + } + } + } +} + +func TestRawAccessorsRetainAllShapes(t *testing.T) { + values := []any{nil, "", "text", json.Number("1.20"), true, []any{"first", "second"}, map[string]any{"@value": "text"}, map[string]any{"@list": []any{"first", "second"}}, map[string]any{"@id": "urn:example:x"}, map[string]any{"@set": []any{"first", "second"}}} + accessors := map[string]func(*cm.Document) cm.Value{"codeRepository": (*cm.Document).CodeRepository, "version": (*cm.Document).SoftwareVersion, "license": (*cm.Document).License, "keywords": (*cm.Document).Keywords, "programmingLanguage": (*cm.Document).ProgrammingLanguages, "datePublished": (*cm.Document).DatePublished, "dateModified": (*cm.Document).DateModified, "developmentStatus": (*cm.Document).DevelopmentStatus, "identifier": (*cm.Document).Identifier} + count := 0 + for _, v := range []string{"2.0", "3.0", "master"} { + ctx := contexts(t, v) + for term, accessor := range accessors { + for _, key := range []string{term, iri(ctx, term)} { + for _, value := range values { + d := parse(t, map[string]any{"@context": contextURL(v), key: value}) + count++ + if !reflect.DeepEqual(accessor(d), d.Get(key)) { + t.Errorf("%s/%s did not retain value", v, key) + } + } + } + } + } + t.Logf("%d raw-accessor preservation checks", count) +} + +func TestGenericScopesEveryField(t *testing.T) { + for _, v := range []string{"2.0", "3.0", "master"} { + for _, term := range properties(contexts(t, v)) { + for _, s := range []struct { + name string + local any + valueKey string + want []string + }{ + {"inherit-empty", map[string]any{}, "schema:name", []string{"Name"}}, + {"inherit-partial", map[string]any{"name": "schema:name"}, "schema:name", []string{"Name"}}, + {"reset", nil, "schema:name", nil}, + {"unsupported", "https://example.org/context", "schema:name", nil}, + {"modified", map[string]any{"schema": "https://example.org/"}, "schema:name", nil}, + {"replace", v3, "schema:name", []string{"Name"}}, + } { + d := parse(t, map[string]any{"@context": contextURL(v), term: map[string]any{"@context": s.local, s.valueKey: "Name"}}) + if got := d.Strings(term); !reflect.DeepEqual(got, s.want) { + t.Errorf("%s/%s/%s got %v want %v", v, term, s.name, got, s.want) + } + } + } + } +} + +func TestAllRangeInvalidShapes(t *testing.T) { + data, e := os.ReadFile("internal/contexts/properties_description.csv") + if e != nil { + t.Fatal(e) + } + rows, e := csv.NewReader(bytes.NewReader(data)).ReadAll() + if e != nil { + t.Fatal(e) + } + ranges := map[string]string{} + for _, r := range rows[1:] { + p, _, _ := strings.Cut(r[0], ":") + ranges[p+":"+r[1]] += " " + r[2] + } + count := 0 + for _, v := range []string{"2.0", "3.0", "master"} { + ctx := contexts(t, v) + for _, term := range properties(ctx) { + full := iri(ctx, term) + key := rangeKey(ctx, full) + expected := ranges[key] + bad := any(true) + if strings.Contains(expected, "Boolean") { + bad = 12 + } + for _, value := range []any{bad, []any{bad}, map[string]any{"@value": bad}, map[string]any{"@list": []any{bad}}, map[string]any{"@set": []any{bad}}} { + d := parse(t, map[string]any{"@context": contextURL(v), term: value}) + count++ + ds := d.Validate() + if len(ds) != 1 || ds[0].Code != "value_type" { + t.Errorf("%s/%s invalid value: %+v", v, term, ds) + } + } + } + } + t.Logf("%d invalid-range shape checks", count) +} + +func rangeKey(ctx map[string]any, full string) string { + if strings.Contains(full, "schema.org/") { + return "schema:" + strings.Split(full, "schema.org/")[1] + } + base, _ := ctx["codemeta"].(string) + return "codemeta:" + strings.TrimPrefix(full, base) +} + +func TestAgentClassificationEmptyFields(t *testing.T) { + for _, rel := range []string{"author", "contributor", "maintainer", "copyrightHolder", "funder"} { + for _, field := range []string{"givenName", "familyName", "affiliation"} { + for _, empty := range []any{nil, []any{}} { + d := parse(t, map[string]any{"@context": v3, rel: map[string]any{"@type": "Organization", "name": "Team", field: empty}}) + if ds := d.Validate(); len(ds) != 0 { + t.Errorf("%s/%s/%v: %+v", rel, field, empty, ds) + } + if got := agents(d, rel)[0].Kind(); got != cm.AgentOrganization { + t.Errorf("%s/%s/%v: kind=%d", rel, field, empty, got) + } + } + } + for _, empty := range []any{nil, []any{}} { + d := parse(t, map[string]any{"@context": v3, rel: map[string]any{"@type": "Person", "name": "Ada", "roleName": empty}}) + if got := agents(d, rel)[0].Kind(); got != cm.AgentPerson { + t.Errorf("%s/null-role/%v kind=%d", rel, empty, got) + } + } + d := parse(t, map[string]any{"@context": v3, rel: map[string]any{"@context": map[string]any{}, "@id": "https://example.org/person"}}) + if got := agents(d, rel)[0].Kind(); got != cm.AgentReference { + t.Errorf("%s/context-reference kind=%d diagnostics=%+v", rel, got, d.Validate()) + } + } +} + +func TestNullLiteralLanguage(t *testing.T) { + for _, term := range []string{"name", "description", "keywords", "givenName", "familyName", "roleName"} { + for _, keyword := range []string{"@language", "@direction"} { + d := parse(t, map[string]any{"@context": v3, term: map[string]any{"@value": "Value", keyword: nil}}) + if ds := d.Validate(); len(ds) > 0 { + t.Errorf("%s/%s: %+v", term, keyword, ds) + } + if got := d.Strings(term); !reflect.DeepEqual(got, []string{"Value"}) { + t.Errorf("projection lost for %s", term) + } + } + } +} + +func TestNestedAgentContextShapes(t *testing.T) { + for _, rel := range []string{"author", "contributor", "maintainer", "copyrightHolder", "funder"} { + for _, key := range []string{"name", "givenName", "familyName", "roleName", "affiliation", "identifier", "email", "url"} { + child := map[string]any{"@context": map[string]any{}, "schema:" + key: "Value"} + if rel == "maintainer" { + child["@type"] = "Person" + } + d := parse(t, map[string]any{"@context": v3, rel: child}) + a := agents(d, rel)[0] + if a.Get(key).Text() != "Value" { + t.Errorf("%s/%s missing", rel, key) + } + } + } +} diff --git a/strings.go b/strings.go index 97d4137..4e8f4ed 100644 --- a/strings.go +++ b/strings.go @@ -15,8 +15,23 @@ func (d *Document) Strings(term string) []string { return stringValues(d.Get(term), terms) } +// Strings projects an agent field using the agent's local context. +func (a Agent) Strings(term string) []string { + return stringValues(a.Get(term), a.terms) +} + +func firstString(values []string) string { + if len(values) == 0 { + return "" + } + return values[0] +} + func stringValues(value Value, terms map[string]termDefinition) []string { - var values []string + return appendStringValues(nil, value, terms) +} + +func appendStringValues(values []string, value Value, terms map[string]termDefinition) []string { for _, item := range value.Values() { if item.kind == String || item.kind == Number || item.kind == Boolean { if item.text != "" { @@ -35,9 +50,9 @@ func stringValues(value Value, terms map[string]termDefinition) []string { if field.kind == Missing { continue } - projected := stringValues(field, localTerms) - values = append(values, projected...) - if key != keywordID || len(projected) != 0 { + count := len(values) + values = appendStringValues(values, field, localTerms) + if key != keywordID || len(values) != count { break } } diff --git a/terms_generated.go b/terms_generated.go index 5796028..bb08707 100644 --- a/terms_generated.go +++ b/terms_generated.go @@ -19,11 +19,11 @@ var contextTerms = map[Version]map[string]termDefinition{ "citation": {iri: "http://schema.org/citation", coercion: "", container: "", prefix: false, expected: "CreativeWork Text"}, "codeRepository": {iri: "http://schema.org/codeRepository", coercion: "@id", container: "", prefix: false, expected: "URL"}, "codemeta": {iri: "https://codemeta.github.io/terms/", coercion: "", container: "", prefix: true, expected: ""}, - "contIntegration": {iri: "https://codemeta.github.io/terms/contIntegration", coercion: "@id", container: "", prefix: false, expected: ""}, + "contIntegration": {iri: "https://codemeta.github.io/terms/contIntegration", coercion: "@id", container: "", prefix: false, expected: "URL"}, "contributor": {iri: "http://schema.org/contributor", coercion: "", container: "", prefix: false, expected: "Organization Person"}, "copyrightHolder": {iri: "http://schema.org/copyrightHolder", coercion: "", container: "", prefix: false, expected: "Organization Person"}, "copyrightYear": {iri: "http://schema.org/copyrightYear", coercion: "", container: "", prefix: false, expected: "Number"}, - "creator": {iri: "http://schema.org/creator", coercion: "", container: "", prefix: false, expected: ""}, + "creator": {iri: "http://schema.org/creator", coercion: "", container: "", prefix: false, expected: "Organization Person"}, "dateCreated": {iri: "http://schema.org/dateCreated", coercion: "http://schema.org/Date", container: "", prefix: false, expected: "Date"}, "dateModified": {iri: "http://schema.org/dateModified", coercion: "http://schema.org/Date", container: "", prefix: false, expected: "Date"}, "datePublished": {iri: "http://schema.org/datePublished", coercion: "http://schema.org/Date", container: "", prefix: false, expected: "Date"}, @@ -32,7 +32,7 @@ var contextTerms = map[Version]map[string]termDefinition{ "downloadUrl": {iri: "http://schema.org/downloadUrl", coercion: "@id", container: "", prefix: false, expected: "URL"}, "editor": {iri: "http://schema.org/editor", coercion: "", container: "", prefix: false, expected: "Person"}, "email": {iri: "http://schema.org/email", coercion: "", container: "", prefix: false, expected: "Text"}, - "embargoDate": {iri: "https://codemeta.github.io/terms/embargoDate", coercion: "http://schema.org/Date", container: "", prefix: false, expected: ""}, + "embargoDate": {iri: "https://codemeta.github.io/terms/embargoDate", coercion: "http://schema.org/Date", container: "", prefix: false, expected: "Date"}, "encoding": {iri: "http://schema.org/encoding", coercion: "", container: "", prefix: false, expected: "MediaObject"}, "familyName": {iri: "http://schema.org/familyName", coercion: "", container: "", prefix: false, expected: "Text"}, "fileFormat": {iri: "http://schema.org/fileFormat", coercion: "@id", container: "", prefix: false, expected: "Text URL"}, @@ -182,7 +182,7 @@ var contextTerms = map[Version]map[string]termDefinition{ "alternateName": {iri: "https://schema.org/alternateName", coercion: "", container: "", prefix: false, expected: "Text"}, "applicationCategory": {iri: "https://schema.org/applicationCategory", coercion: "", container: "", prefix: false, expected: "Text URL"}, "applicationSubCategory": {iri: "https://schema.org/applicationSubCategory", coercion: "", container: "", prefix: false, expected: "Text URL"}, - "archivedAt": {iri: "https://schema.org/archivedAt", coercion: "@id", container: "", prefix: false, expected: ""}, + "archivedAt": {iri: "https://schema.org/archivedAt", coercion: "@id", container: "", prefix: false, expected: "URL WebPage"}, "author": {iri: "https://schema.org/author", coercion: "", container: "@list", prefix: false, expected: "Organization Person"}, "buildInstructions": {iri: "https://w3id.org/codemeta/terms/buildInstructions", coercion: "@id", container: "", prefix: false, expected: "URL"}, "citation": {iri: "https://schema.org/citation", coercion: "", container: "", prefix: false, expected: "CreativeWork Text"}, @@ -207,10 +207,10 @@ var contextTerms = map[Version]map[string]termDefinition{ "editor": {iri: "https://schema.org/editor", coercion: "", container: "", prefix: false, expected: "Person"}, "email": {iri: "https://schema.org/email", coercion: "", container: "", prefix: false, expected: "Text"}, "embargoEndDate": {iri: "https://w3id.org/codemeta/terms/embargoEndDate", coercion: "https://schema.org/Date", container: "", prefix: false, expected: "Date"}, - "encodingFormat": {iri: "https://schema.org/encodingFormat", coercion: "", container: "", prefix: false, expected: ""}, + "encodingFormat": {iri: "https://schema.org/encodingFormat", coercion: "", container: "", prefix: false, expected: "Text URL"}, "endDate": {iri: "https://schema.org/endDate", coercion: "https://schema.org/Date", container: "", prefix: false, expected: "Date Datetime"}, "familyName": {iri: "https://schema.org/familyName", coercion: "", container: "", prefix: false, expected: "Text"}, - "featureList": {iri: "https://schema.org/featureList", coercion: "", container: "", prefix: false, expected: ""}, + "featureList": {iri: "https://schema.org/featureList", coercion: "", container: "", prefix: false, expected: "Text URL"}, "fileSize": {iri: "https://schema.org/fileSize", coercion: "", container: "", prefix: false, expected: "Text"}, "funder": {iri: "https://schema.org/funder", coercion: "", container: "", prefix: false, expected: "Organization Person"}, "funding": {iri: "https://schema.org/funding", coercion: "", container: "", prefix: false, expected: "Grant"}, diff --git a/testdata/projection.json b/testdata/projection.json new file mode 100644 index 0000000..43bc2d2 --- /dev/null +++ b/testdata/projection.json @@ -0,0 +1,19 @@ +{ + "@context": "https://w3id.org/codemeta/3.0", + "name": {"@value": "Example"}, + "description": {"@value": "Description", "@language": null}, + "version": {"@set": ["1.2", "1.3"]}, + "author": [{"@set": [ + { + "@type": "Person", + "givenName": {"@list": ["Ada", "Augusta"]}, + "familyName": {"@value": "Lovelace"} + }, + {"@context": [{}], "schema:name": {"@value": "Grace Hopper"}}, + { + "@type": "Role", + "roleName": {"@set": ["creator", "developer"]}, + "author": [{"@set": [{"name": "Team"}]}] + } + ]}] +} diff --git a/validate.go b/validate.go index 83889fc..cedaf53 100644 --- a/validate.go +++ b/validate.go @@ -22,7 +22,7 @@ func (d *Document) Validate() []Diagnostic { limit = 100 } c := validator{limit: limit} - c.context(d.root.Get(keywordContext), keywordContext, d.root.pos) + c.context(d.root.Get(keywordContext), keywordContext, d.root.pos, nil) var terms map[string]termDefinition if d.context.usable() { terms = contextTerms[d.context.version] @@ -60,8 +60,8 @@ func (c *validator) add(pos Position, path, code, message string) { copy(c.diagnostics[i+1:], c.diagnostics[i:len(c.diagnostics)-1]) c.diagnostics[i] = d } -func (c *validator) context(v Value, path string, fallback Position) { - state := inspectContext(v) +func (c *validator) context(v Value, path string, fallback Position, inherited map[string]termDefinition) { + state := inspectScopedContext(v, inherited) pos := v.pos if pos.Line == 0 { pos = fallback @@ -69,7 +69,7 @@ func (c *validator) context(v Value, path string, fallback Position) { if state.missing { c.add(pos, path, "missing_context", "@context is missing") } - if state.unknown || (state.version == "" && !state.missing && !state.modified && !state.conflicting) { + if state.unknown || (state.version == "" && inherited == nil && !state.missing && !state.modified && !state.conflicting) { c.add(pos, path, "unsupported_version", "context is not one of the pinned CodeMeta contexts") } if state.modified { @@ -89,9 +89,7 @@ func itemPath(parent string, i int) string { return fmt.Sprintf("%s[%d]", parent func (c *validator) object(v Value, path string, terms map[string]termDefinition) { if path != "" { if local := v.Get(keywordContext); local.kind != Missing { - if local.kind != Object || !unchangedDefinitions(local, terms) { - c.context(local, fieldPath(path, keywordContext), v.pos) - } + c.context(local, fieldPath(path, keywordContext), v.pos, terms) terms = scopedTerms(v, terms) } } @@ -198,6 +196,8 @@ func (c *validator) keyword(v Value, name, path string, terms map[string]termDef case keywordID: if v.kind != String { c.add(v.pos, path, "value_type", "@id must be a string") + } else if !validIRI(v.text) { + c.add(v.pos, path, "invalid_iri", "expected an IRI reference") } case keywordType: c.typeValue(v, path, terms) @@ -209,7 +209,13 @@ func (c *validator) keyword(v Value, name, path string, terms map[string]termDef if v.kind == Object || v.kind == Array { c.add(v.pos, path, "value_type", "@value must be a scalar") } - case "@language", "@direction", keywordIndex: + case "@language", "@direction": + if v.kind != String && v.kind != Null { + c.add(v.pos, path, "value_type", name+" must be a string or null") + } else if name == "@direction" && v.kind == String && v.text != "ltr" && v.text != "rtl" { + c.add(v.pos, path, "invalid_direction", "@direction must be ltr, rtl, or null") + } + case keywordIndex: if v.kind != String { c.add(v.pos, path, "value_type", name+" must be a string") } diff --git a/validate_test.go b/validate_test.go index 62bf87e..1661a03 100644 --- a/validate_test.go +++ b/validate_test.go @@ -115,7 +115,7 @@ func TestLiteralValidation(t *testing.T) { want = append(want, codemeta.Diagnostic{Code: tc.code, Path: "name.@value", Message: tc.message, Position: codemeta.Position{Line: 1, Column: strings.Index(input, `"@value":`) + len(`"@value":`) + 1}}) } - want = append(want, codemeta.Diagnostic{Code: "value_type", Path: "name.@language", Message: "@language must be a string", + want = append(want, codemeta.Diagnostic{Code: "value_type", Path: "name.@language", Message: "@language must be a string or null", Position: codemeta.Position{Line: 1, Column: strings.Index(input, `"@language":`) + len(`"@language":`) + 1}}) if got := doc.Validate(); !reflect.DeepEqual(got, want) { t.Fatalf("%s: got %+v, want %+v", input, got, want) diff --git a/validate_values.go b/validate_values.go index 37de315..f888e71 100644 --- a/validate_values.go +++ b/validate_values.go @@ -132,6 +132,9 @@ func validIRI(text string) bool { if strings.ContainsFunc(text, func(r rune) bool { return unicode.IsSpace(r) || unicode.IsControl(r) }) { return false } + if strings.HasPrefix(text, "_:") { + return len(text) > len("_:") + } _, err := url.Parse(text) return err == nil }