diff --git a/compilers/openapi/internal/schema/schema.go b/compilers/openapi/internal/schema/schema.go index d06d1eb..cd594ea 100644 --- a/compilers/openapi/internal/schema/schema.go +++ b/compilers/openapi/internal/schema/schema.go @@ -1443,20 +1443,35 @@ func componentSchemaAt(c lowering.Ctx, pointer string) *oas3.Schema { func declaresResourceIDAbove(c lowering.Ctx, pointer string) bool { view := nodeview.New() cur := nodeview.DocumentRoot(nodeview.Deref(c.Doc.GetRootNode())) - for seg := range strings.SplitSeq(pointer, "/") { + for _, token := range pointerTokens(pointer) { if cur == nil { return false } if view.ChildByToken(cur, "$id") != nil { return true } - if seg != "" { // every pointer starts with the empty segment - cur = nodeview.Deref(view.ChildByToken(cur, ids.UnescapeSegment(seg))) - } + cur = nodeview.Deref(view.ChildByToken(cur, ids.UnescapeSegment(token))) } return cur != nil && view.ChildByToken(cur, "$id") != nil } +// pointerTokens returns the RFC 6901 reference tokens of pointer, still escaped. +// Splitting on '/' yields one leading empty segment that is the split's artifact +// rather than a token, and only that one is: a later empty segment names the key +// "", which is how a component schema named "" is addressed +// (/components/schemas/). Dropping every empty segment stopped the walk above +// such a position and read the $id of its parent instead of its own. +// +// A string with no leading '/' has no tokens at all: the empty pointer names the +// whole document, and a relative pointer names no position in it. +func pointerTokens(pointer string) []string { + rest, found := strings.CutPrefix(pointer, "/") + if !found { + return nil + } + return strings.Split(rest, "/") +} + // dynamicFragment returns the plain fragment name a $dynamicRef addresses. Only // the same-document `#name` spelling resolves here: a URI part names another // resource (Milestone 1 interns only same-file targets) and a `#/…` pointer diff --git a/compilers/openapi/internal/schema/schema_internal_test.go b/compilers/openapi/internal/schema/schema_internal_test.go index e1b560a..7594173 100644 --- a/compilers/openapi/internal/schema/schema_internal_test.go +++ b/compilers/openapi/internal/schema/schema_internal_test.go @@ -212,6 +212,42 @@ func TestDeclaresResourceIDAbove_WithoutARawTree(t *testing.T) { "a document with no raw tree declares no resource anywhere") } +// TestDeclaresResourceIDAbove_EmptySegmentIsATokenNotAnArtifact pins which empty +// segments the path walk may drop. Splitting a pointer on '/' produces one +// leading empty segment that no reference token stands behind; every later one +// is the token naming the key "", which is how a schema literally named "" is +// addressed. Dropping those stopped the walk above the position and read the +// $id of the components/schemas map instead of the schema's own. +func TestDeclaresResourceIDAbove_EmptySegmentIsATokenNotAnArtifact(t *testing.T) { + t.Parallel() + l, diags := loweredFor(t, `openapi: 3.1.0 +info: {title: T, version: "1"} +paths: {} +components: + schemas: + "": + $id: https://example.com/empty + properties: {x: {type: string}} + Sibling: {type: string} +`) + requireNoErrorDiags(t, diags) + + for name, tc := range map[string]struct { + pointer string + want bool + }{ + "the position is named by a trailing empty token": {"/components/schemas/", true}, + "an interior empty token still descends": {"/components/schemas//properties/x", true}, + "a sibling sits above no $id at all": {"/components/schemas/Sibling", false}, + "the empty pointer names the document root": {"", false}, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.want, declaresResourceIDAbove(l.ctx, tc.pointer)) + }) + } +} + // TestDynamicAnchors_WalksEveryNodeShape drives the raw-tree walk over the // shapes a YAML document can present, rather than only the mappings a schema // happens to be written as. The walk reads the raw tree because oas3.Schema has diff --git a/compilers/openapi/internal/schema/schema_test.go b/compilers/openapi/internal/schema/schema_test.go index f4b1c91..d05980f 100644 --- a/compilers/openapi/internal/schema/schema_test.go +++ b/compilers/openapi/internal/schema/schema_test.go @@ -3021,6 +3021,17 @@ func TestDynamicRef_IrreducibleIsKeptAndSaysWhy(t *testing.T) { schemas: anchor + " A: {$id: 'https://example.com/a', $dynamicRef: '#m'}\n", wantWhy: `an $id at or above "/components/schemas/A"`, }, + { + // The pointer of a schema named "" ends in an empty reference token, + // which the path walk once dropped as if it were the artifact of + // splitting a pointer on '/' — reading the components/schemas map for + // the $id instead of the schema itself, and expanding across a + // boundary it should have stopped at. + name: `a schema named "" is still in a resource of its own`, + schemas: anchor + " \"\": {$id: 'https://example.com/empty', $dynamicRef: '#m'}\n", + wantWhy: `an $id at or above "/components/schemas/"`, + at: "t/anon/components/schemas/", + }, { name: "an enclosing schema starts the resource", schemas: anchor +