diff --git a/compilers/openapi/conformance_unmodeled_test.go b/compilers/openapi/conformance_unmodeled_test.go index cbc6018..4527937 100644 --- a/compilers/openapi/conformance_unmodeled_test.go +++ b/compilers/openapi/conformance_unmodeled_test.go @@ -410,6 +410,36 @@ func assertDynamicRef(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { assert.JSONEq(t, `"#Missing"`, string(entry.Value)) assert.Equal(t, []ir.Severity{ir.SeverityInfo}, diagsAt(diags, "openapi/degraded-construct", "/components/schemas/Tree/properties/ghost/$dynamicRef")) + + assertDynamicRefAcrossAnEmptyName(t, doc, diags) +} + +// assertDynamicRefAcrossAnEmptyName pins the reference declared beside an $id on +// the component schema keyed "". Its pointer, /components/schemas/, ends in an +// empty reference token, and a walk that drops that token reads the +// components/schemas map instead of the schema — a map declaring no $id, so the +// resource boundary disappears and the reference expands across it (GitHub +// #302). +// +// It lives in the corpus rather than only in a unit test because the oracles run +// here: order-invariance, determinism, JSON round-trip and irverify each drive +// this construct only if some committed spec writes it, and until this one did, +// no spec combined an empty component name with $id and $dynamicRef at all. +func assertDynamicRefAcrossAnEmptyName(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { + t.Helper() + // An empty name earns no named TypeID, so the schema hoists anonymously. + empty, ok := doc.Types[ir.TypeID("t/anon/components/schemas/")].(*ir.Scalar) + require.True(t, ok, `the component schema keyed "" owns a node`) + + entry := unmodeledEntry(t, empty.Unmodeled, "openapi:$dynamicRef") + assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason) + assert.JSONEq(t, `"#T"`, string(entry.Value), + "the reference is kept verbatim rather than expanded across the $id") + require.NotNil(t, empty.Base) + assert.Equal(t, ir.TypeID("t/prim/any"), empty.Base.Target, + "an expansion would have made the anchor's own node this one's base instead") + assert.Equal(t, []ir.Severity{ir.SeverityInfo}, diagsAt(diags, "openapi/degraded-construct", + "/components/schemas//$dynamicRef")) } // assertInlineResidue pins the other half of ir-design §14's OpenAPI row: a diff --git a/compilers/openapi/internal/ids/ids.go b/compilers/openapi/internal/ids/ids.go index 8062b20..a1c1e53 100644 --- a/compilers/openapi/internal/ids/ids.go +++ b/compilers/openapi/internal/ids/ids.go @@ -130,15 +130,42 @@ func DeclarationHint(pointer, fallback string) string { // and ComponentSchemaName narrows it to the schemas kind, which is the only one // that earns a named TypeID. func ComponentEntry(pointer string) (kind, name string, ok bool) { + kind, name, ok = componentEntrySplit(pointer) + if !ok || name == "" { + return "", "", false + } + return kind, UnescapeSegment(name), true +} + +// componentEntrySplit splits the /components// shape without judging +// the name, so a caller that must tell "not that shape at all" from "that shape +// with an empty name" can. ComponentEntry folds the two together on purpose — an +// entry keyed "" earns no named TypeID either way — but the two are different +// facts about a document, and a diagnostic naming the wrong one is simply false. +func componentEntrySplit(pointer string) (kind, name string, ok bool) { const prefix = "/components/" if !strings.HasPrefix(pointer, prefix) { return "", "", false } kind, name, found := strings.Cut(pointer[len(prefix):], "/") - if !found || kind == "" || name == "" || strings.Contains(name, "/") { + if !found || kind == "" || strings.Contains(name, "/") { return "", "", false } - return kind, UnescapeSegment(name), true + return kind, name, true +} + +// ComponentSchemaNamedEmpty reports whether pointer addresses the top-level +// component schema keyed "" — the position /components/schemas/ addresses. +// +// It exists because that schema is a component schema that ComponentSchemaName +// still refuses: an empty name earns no named TypeID, so the schema hoists +// anonymously (testdata/conformance/openapi/empty-names.yaml records that +// policy). A caller that reports the refusal needs the distinction to word it +// truthfully, since a reader who follows the pointer finds a component schema +// sitting exactly where a "not a component schema" message denies one is. +func ComponentSchemaNamedEmpty(pointer string) bool { + kind, name, ok := componentEntrySplit(pointer) + return ok && kind == "schemas" && name == "" } // componentEntryName returns the unescaped name of a top-level component entry @@ -162,6 +189,11 @@ func ForPointer(pointer string) ir.TypeID { // schema (/components/schemas/ with no deeper path) and returns its name. // Only this kind of component declares a named type in OpenAPI, which is why it // alone gates NamedType. +// +// It answers false for two unlike documents: one that declares no such entry, +// and one that declares it keyed "". Reporting the refusal as "not a component +// schema" is false for the second, since the pointer addresses exactly that — +// ComponentSchemaNamedEmpty separates them for a caller that has to say why. func ComponentSchemaName(pointer string) (string, bool) { kind, name, ok := ComponentEntry(pointer) return name, ok && kind == "schemas" diff --git a/compilers/openapi/internal/ids/ids_test.go b/compilers/openapi/internal/ids/ids_test.go index 3f74041..df1c160 100644 --- a/compilers/openapi/internal/ids/ids_test.go +++ b/compilers/openapi/internal/ids/ids_test.go @@ -152,6 +152,31 @@ func TestComponentSchemaName_NarrowsToSchemas(t *testing.T) { } } +// TestComponentSchemaNamedEmpty_SeparatesAnEmptyNameFromNoEntry pins the one +// distinction ComponentEntry deliberately throws away. Both answer "no named +// type", but they are different facts: a component schema keyed "" exists at +// /components/schemas/ and earns none, while the other pointers name no +// component-schema entry at all. Only a caller that can tell them apart can +// report the first without denying the schema the document plainly declares. +func TestComponentSchemaNamedEmpty_SeparatesAnEmptyNameFromNoEntry(t *testing.T) { + t.Parallel() + assert.True(t, ids.ComponentSchemaNamedEmpty("/components/schemas/"), + `/components/schemas/ addresses the component schema keyed ""`) + + for _, pointer := range []string{ + "/components/schemas/User", // a named entry + "/components/headers/", // an empty name of another kind + "/components/schemas", // the kind alone, no trailing token + "/components/schemas//properties/id", // a position inside the empty-named schema + "/components//", // no kind + "/paths/~1x/get", // not under components + "", // the empty pointer + } { + assert.False(t, ids.ComponentSchemaNamedEmpty(pointer), + `%q does not address the component schema keyed ""`, pointer) + } +} + // TestForPointer_ChoosesTheNamespace pins the split ForPointer exists for: a // component schema keeps its named ID, everything else is anonymous. The two // namespaces are what stop a hoisted inline type colliding with a declared one. diff --git a/compilers/openapi/internal/nodeview/nodeview.go b/compilers/openapi/internal/nodeview/nodeview.go index 724a8c8..e86b886 100644 --- a/compilers/openapi/internal/nodeview/nodeview.go +++ b/compilers/openapi/internal/nodeview/nodeview.go @@ -374,12 +374,35 @@ func InternalPointer(ref string) (string, bool) { // the node it stops at, and the caller's re-entrancy check exempts exactly that // node (GitHub #238). func (v *View) PointerPath(root *yaml.Node, pointer string) (path []*yaml.Node, complete bool) { + return v.walkPointer(root, pointer, tokenless(pointer)) +} + +// DocumentPath walks a pointer that names a position in this document rather +// than a reference some source wrote, and is otherwise PointerPath. +// +// The two part company on '/'. PointerPath lands it on the root because that is +// where the resolver lands it, a departure from RFC 6901 that tokenless records. +// A position built by ids.Ptr carries no such departure: ids.Ptr("") spells the +// root member whose key is the empty string exactly '/', so reading that as the +// root walks past the member the pointer names. Only the empty pointer names the +// root here. +// +// The distinction is load-bearing for a caller reading $id down a path: taking +// '/' for the root hides an $id written on that member, which is the same +// dropped-empty-token loss the rest of this walk exists to avoid. +func (v *View) DocumentPath(root *yaml.Node, pointer string) (path []*yaml.Node, complete bool) { + return v.walkPointer(root, pointer, pointer == "") +} + +// walkPointer is the shared walk; atRoot says whether pointer carries no tokens +// at all, which is the one question the two readings answer differently. +func (v *View) walkPointer(root *yaml.Node, pointer string, atRoot bool) (path []*yaml.Node, complete bool) { cur := Deref(root) if cur == nil { return nil, false } path = append(path, cur) - if tokenless(pointer) { + if atRoot { return path, true } diff --git a/compilers/openapi/internal/nodeview/nodeview_internal_test.go b/compilers/openapi/internal/nodeview/nodeview_internal_test.go index 8fec91a..542f53a 100644 --- a/compilers/openapi/internal/nodeview/nodeview_internal_test.go +++ b/compilers/openapi/internal/nodeview/nodeview_internal_test.go @@ -491,6 +491,34 @@ func TestPointerPath_RootTokenlessAndNil(t *testing.T) { assert.Nil(t, path, "a nil root reaches nothing") } +// TestDocumentPath_SeparatesTheLoneSlashFromTheRoot pins the one question the +// two readings answer differently. +// +// PointerPath lands '/' on the root because that is where the resolver lands a +// reference spelled that way. A pointer naming a position in this document +// carries no such departure: ids.Ptr("") spells the root member keyed "" exactly +// '/', so reading it as the root walks past the member the pointer names — and a +// caller reading $id down the path would miss one written there. +func TestDocumentPath_SeparatesTheLoneSlashFromTheRoot(t *testing.T) { + t.Parallel() + member := ymap(yscalar("$id"), yscalar("https://example.com/root-member")) + root := ymap(yscalar(""), member) + + path, complete := New().DocumentPath(root, "/") + assert.True(t, complete, `"/" resolves the one token it carries`) + assert.Equal(t, []*yaml.Node{root, member}, path, + `ids.Ptr("") spells the root member keyed "" as "/", so the walk descends into it`) + + path, complete = New().PointerPath(root, "/") + assert.True(t, complete) + assert.Equal(t, []*yaml.Node{root}, path, + "the reference reading stops at the root, which is what tokenless records") + + path, complete = New().DocumentPath(root, "") + assert.True(t, complete, "only the empty pointer names the root here") + assert.Equal(t, []*yaml.Node{root}, path) +} + // TestPointerPath_EmptyTokenIsARealToken pins the one token a walk must not // normalize away. RFC 6901 makes "" a reference token naming the key "", and the // resolver's own parser agrees (jsonpointer/navigation.go getNavigationStack, diff --git a/compilers/openapi/internal/schema/schema.go b/compilers/openapi/internal/schema/schema.go index 0729d1a..97ddafb 100644 --- a/compilers/openapi/internal/schema/schema.go +++ b/compilers/openapi/internal/schema/schema.go @@ -1536,7 +1536,7 @@ func dynamicExpansion(c lowering.Ctx, anchors *AnchorIndex, s *oas3.Schema, poin } id, resolved, handled := c.RefScope().ComponentRef(at) if !handled || !resolved { - return "", fmt.Sprintf("$dynamicAnchor %q is declared at %q rather than on a component schema", name, at), false, diags + return "", unnamedAnchorSiteWhy(name, at), false, diags } chainWhy, chainOK, chainDiags := dynamicChainVerdict(c, anchors, at, pointer) diags = append(diags, chainDiags...) @@ -1546,6 +1546,25 @@ func dynamicExpansion(c lowering.Ctx, anchors *AnchorIndex, s *oas3.Schema, poin return id, "", true, diags } +// unnamedAnchorSiteWhy words why the position declaring an anchor is not a +// target the IR can name, for the two document shapes that reach it. +// +// A pointer deeper than a top-level component schema names a position with no +// TypeID stable enough to expand to, which is the ordinary case. A component +// schema keyed "" is the other, and it is a component schema — /components/ +// schemas/ addresses it — that earns no named TypeID all the same, because an +// empty name is not one. Wording that as "rather than on a component schema" +// states something the document contradicts: a reader who follows the pointer +// lands on the very thing the message says is not there. The verdict is the same +// either way; only the reason differs. +func unnamedAnchorSiteWhy(name, at string) string { + if ids.ComponentSchemaNamedEmpty(at) { + return fmt.Sprintf(`$dynamicAnchor %q is declared on the component schema keyed "" at %q, `+ + "and an empty name earns no named type to expand to", name, at) + } + return fmt.Sprintf("$dynamicAnchor %q is declared at %q rather than on a component schema", name, at) +} + // dynamicRefName returns the $dynamicAnchor name the $dynamicRef s writes // addresses, or the reason it addresses none. It stops short of the index, so // the chain walk and the lowering path ask one function what a schema requests. @@ -1692,21 +1711,42 @@ func componentSchemaAt(c lowering.Ctx, pointer string) *oas3.Schema { // there. That is the same direction the anchor index errs in: a false boundary // costs an expansion that would have been safe, where a missed one mints a // reference the IR cannot express. +// +// The path comes from nodeview.DocumentPath, which drops only the leading empty +// segment: a later empty token still takes a step of its own and names the key +// "", which is how a component schema named "" is addressed +// (/components/schemas/). Walking the tokens here instead had dropped every +// empty segment, stopping above such a position and reading the $id of the +// components/schemas map rather than the schema's own. DocumentPath rather than +// PointerPath because every pointer arriving here is a position this compiler +// built with ids.Ptr: the two differ only on "/", which ids.Ptr("") uses to +// spell the root member named "", and which PointerPath lands on the root +// instead because that is where a *reference* resolves. +// +// An incomplete walk needs no separate arm: the path holds the nodes it did +// reach, and a boundary above a pointer that falls off the tree still binds. +// +// The view is built per call and deliberately not shared. nodeview memoizes a +// mapping's merge expansion, and a node first expanded shallowly is served from +// that memo to a later walk that reaches it deeper than MergeDepthLimit would +// allow — so a view outliving one walk makes this answer depend on which schema +// lowered first, which invariant #7 forbids. A per-call view costs one expansion +// per path node and is order-invariant. +// +// Known gap: a path node whose own merge chain exceeds MergeDepthLimit expands +// to nothing, so an $id written there is invisible and this reports no boundary +// — the direction it must not err in. That predates this walk and needs a +// reporting channel of its own; GitHub #401 carries it. 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, "/") { - if cur == nil { - return false - } - if view.ChildByToken(cur, "$id") != nil { + root := nodeview.DocumentRoot(nodeview.Deref(c.Doc.GetRootNode())) + path, _ := view.DocumentPath(root, pointer) + for _, n := range path { + if view.ChildByToken(n, "$id") != nil { return true } - if seg != "" { // every pointer starts with the empty segment - cur = nodeview.Deref(view.ChildByToken(cur, ids.UnescapeSegment(seg))) - } } - return cur != nil && view.ChildByToken(cur, "$id") != nil + return false } // dynamicFragment returns the anchor name a $dynamicRef addresses, or the reason diff --git a/compilers/openapi/internal/schema/schema_internal_test.go b/compilers/openapi/internal/schema/schema_internal_test.go index 950366e..f312ef8 100644 --- a/compilers/openapi/internal/schema/schema_internal_test.go +++ b/compilers/openapi/internal/schema/schema_internal_test.go @@ -217,6 +217,67 @@ 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. +// +// The document root carries a member named "" of its own, carrying an $id. It is +// what makes the two rootward cases discriminating rather than decorative: the +// empty pointer names the root and must not reach it, while "/" is precisely how +// ids.Ptr spells that member and must. Without the $id there, "stopped at the +// root" and "descended and fell off the tree" are the same false. +func TestDeclaresResourceIDAbove_EmptySegmentIsATokenNotAnArtifact(t *testing.T) { + t.Parallel() + l, diags := loweredFor(t, `openapi: 3.1.0 +info: {title: T, version: "1"} +paths: {} +"": {$id: https://example.com/root-member} +components: + schemas: + "": + $id: https://example.com/empty + properties: {x: {type: string}} + Sibling: {type: string} +`) + openapitest.RequireNoErrorDiags(t, diags) + + for name, tc := range map[string]struct { + pointer string + want bool + why string + }{ + "the position is named by a trailing empty token": { + "/components/schemas/", true, + "the trailing token names the schema itself, whose own $id is the boundary", + }, + "an interior empty token still descends": { + "/components/schemas//properties/x", true, + "the $id above a property is still a boundary over it", + }, + "a sibling sits above no $id at all": { + "/components/schemas/Sibling", false, + "a neighbour of the named schema inherits no boundary from it", + }, + "the empty pointer names the document root": { + "", false, + `the empty pointer stops at the root, so the $id under the root's "" member is out of reach`, + }, + "a lone slash names the root member keyed \"\"": { + "/", true, + `ids.Ptr("") spells that member "/", so its own $id is the boundary — reading "/" as the root, ` + + "the way a reference resolves, would walk straight past it", + }, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.want, declaresResourceIDAbove(l.ctx, tc.pointer), tc.why) + }) + } +} + // 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 4b32145..83ba96f 100644 --- a/compilers/openapi/internal/schema/schema_test.go +++ b/compilers/openapi/internal/schema/schema_test.go @@ -2,6 +2,7 @@ package schema_test import ( "encoding/json" + "fmt" "sort" "strings" "testing" @@ -3173,6 +3174,28 @@ 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/", + }, + { + // The mirror of the case above, on the anchor's side. /components/ + // schemas/ addresses a component schema, so the refusal may not say it + // does not — the reason is that an empty name earns no named TypeID, + // which is the policy testdata/conformance/openapi/empty-names.yaml + // records rather than a shape the compiler failed to recognise. + name: `an anchor on a schema named "" says why, not that it is no component`, + schemas: " \"\": {$dynamicAnchor: m, type: string}\n A: {$dynamicRef: '#m'}\n", + wantWhy: `is declared on the component schema keyed "" at "/components/schemas/", ` + + `and an empty name earns no named type to expand to`, + }, { name: "an enclosing schema starts the resource", schemas: anchor + @@ -3212,6 +3235,67 @@ func TestDynamicRef_IrreducibleIsKeptAndSaysWhy(t *testing.T) { } } +// mergeBoundOrderSpec writes one document whose resource boundary is reachable +// only through a YAML merge chain, with the two schemas that walk it declared in +// the given order. P's chain is 51 links and expands whole; Q's rides on P's tail +// for 71, past nodeview's MergeDepthLimit of 64. +func mergeBoundOrderSpec(pFirst bool) string { + var b strings.Builder + b.WriteString("openapi: 3.1.0\ninfo: {title: MergeBound, version: \"1\"}\npaths: {}\n") + b.WriteString("x-c0: &c0 {$id: 'https://example.com/boundary'}\n") + for i := 1; i <= 50; i++ { + fmt.Fprintf(&b, "x-c%d: &c%d {<<: *c%d}\n", i, i, i-1) + } + b.WriteString("x-d0: &d0 {<<: *c50}\n") + for i := 1; i <= 20; i++ { + fmt.Fprintf(&b, "x-d%d: &d%d {<<: *d%d}\n", i, i, i-1) + } + b.WriteString("components:\n schemas:\n Node: {$dynamicAnchor: T, type: string}\n") + const p = " P:\n <<: *c50\n $dynamicRef: '#T'\n" + const q = " Q:\n <<: *d20\n $dynamicRef: '#T'\n" + if pFirst { + b.WriteString(p + q) + return b.String() + } + b.WriteString(q + p) + return b.String() +} + +// TestDynamicRef_ResourceBoundaryVerdictIsOrderInvariant pins that which schema +// lowered first cannot decide whether the other sees the $id above it. +// +// declaresResourceIDAbove builds its nodeview.View per call for this reason. A +// view outliving one walk memoizes a mapping's merge expansion, and a node first +// expanded shallowly is then served from that memo to a walk reaching it deeper +// than MergeDepthLimit permits. Sharing one made P-then-Q keep Q's reference +// verbatim where Q-then-P expanded it, in the same document. +// +// The order-invariance oracle cannot ask this. The construct needs YAML anchors, +// and reverseMappings declines to permute a document whose aliases the reversal +// would lift above their anchors, so the sweep returns ok whichever way the +// walk answers — which is what earns this a two-order diff of its own. +// +// It asserts the two orders agree rather than which verdict they agree on: a +// chain past the bound expands to nothing, so the $id is invisible and both +// currently miss the boundary (GitHub #401). Fixing that changes the shared +// answer, not this test. +func TestDynamicRef_ResourceBoundaryVerdictIsOrderInvariant(t *testing.T) { + t.Parallel() + first, diags := parseFull(t, mergeBoundOrderSpec(true)) + openapitest.RequireNoErrorDiags(t, diags) + last, diags := parseFull(t, mergeBoundOrderSpec(false)) + openapitest.RequireNoErrorDiags(t, diags) + + // The registry rather than the whole document: the cycle pre-scan shares one + // view across its own walk, so whether it reports stopping at the same + // 64-level bound depends on which schema it reached first, and that warning + // appears in one order only (GitHub #402). It is the same mechanism in a + // different walk, and it is not what this test governs — the boundary verdict + // lands in the registry. + assert.Empty(t, cmp.Diff(first.Types, last.Types, orderInvariantIR()...), + "the declaration order must not decide a resource-boundary verdict") +} + // TestDynamicRef_CycleIsRefusedAtEveryEdge pins that a cycle of $dynamicRef // expansions is preserved whole rather than broken at whichever edge lowered // first. Each member reaches the verdict from its own position, so no Scalar diff --git a/testdata/conformance/openapi/dynamic-ref.golden.json b/testdata/conformance/openapi/dynamic-ref.golden.json index 7bd98ba..b3a4879 100644 --- a/testdata/conformance/openapi/dynamic-ref.golden.json +++ b/testdata/conformance/openapi/dynamic-ref.golden.json @@ -18,6 +18,42 @@ } ], "types": { + "t/anon/components/schemas/": { + "kind": "scalar", + "id": "t/anon/components/schemas/", + "name": { + "hint": "empty" + }, + "anonymous": true, + "docs": {}, + "sensitive": false, + "unmodeled": { + "openapi:$dynamicRef": { + "reason": "degraded_lowering", + "value": "#T", + "provenance": { + "source": 0, + "pointer": "/components/schemas//$dynamicRef" + } + }, + "openapi:$id": { + "reason": "out_of_scope", + "value": "https://example.com/empty", + "provenance": { + "source": 0, + "pointer": "/components/schemas//$id" + } + } + }, + "provenance": { + "source": 0, + "pointer": "/components/schemas/" + }, + "base": { + "target": "t/prim/any", + "nullable": false + } + }, "t/openapi/components/schemas/Leaf": { "kind": "scalar", "id": "t/openapi/components/schemas/Leaf", @@ -257,13 +293,31 @@ "source": 0, "pointer": "/components/schemas/Tree/properties/ghost/$dynamicRef" } + }, + { + "severity": "info", + "code": "openapi/degraded-construct", + "message": "$id identifies or configures a JSON Schema resource rather than describing data; the IR models no such axis, so it is kept verbatim under Unmodeled and is not honoured for reference resolution", + "provenance": { + "source": 0, + "pointer": "/components/schemas//$id" + } + }, + { + "severity": "info", + "code": "openapi/degraded-construct", + "message": "$dynamicRef was not expanded because an $id at or above \"/components/schemas/\" starts a schema resource of its own, and the IR resolves no resource base URIs; it is kept verbatim under Unmodeled", + "provenance": { + "source": 0, + "pointer": "/components/schemas//$dynamicRef" + } } ], "sources": [ { "format": "openapi@3.1", "path": "dynamic-ref.yaml", - "hash": "bcd41f5be51089faa6e29efcfa4b0166b66e1850ac7c2d3a689bbcac808532ff" + "hash": "4ae3f35d31355eedd294ecae9d7a312ec39cb830a1ed5826b90404d29eaf529b" } ] } diff --git a/testdata/conformance/openapi/dynamic-ref.yaml b/testdata/conformance/openapi/dynamic-ref.yaml index 338b0f0..abe2f71 100644 --- a/testdata/conformance/openapi/dynamic-ref.yaml +++ b/testdata/conformance/openapi/dynamic-ref.yaml @@ -23,3 +23,12 @@ components: # No anchor declares this name, so the reference is irreducible and is # kept verbatim instead of being dropped. ghost: {$dynamicRef: '#Missing'} + # A component keyed "" is addressed by /components/schemas/, a pointer whose + # last reference token is empty. The $id here starts a schema resource of its + # own, so this reference may not expand across it — and deciding that means + # reading the trailing empty token as this schema's own position instead of + # dropping it and reading the components/schemas map above it, which declares + # no $id and would have let the expansion through. + "": + $id: https://example.com/empty + $dynamicRef: '#T'