Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions compilers/openapi/conformance_unmodeled_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 34 additions & 2 deletions compilers/openapi/internal/ids/ids.go
Original file line number Diff line number Diff line change
Expand Up @@ -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/<kind>/<name> 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
Expand All @@ -162,6 +189,11 @@ func ForPointer(pointer string) ir.TypeID {
// schema (/components/schemas/<name> 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"
Expand Down
25 changes: 25 additions & 0 deletions compilers/openapi/internal/ids/ids_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
25 changes: 24 additions & 1 deletion compilers/openapi/internal/nodeview/nodeview.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
28 changes: 28 additions & 0 deletions compilers/openapi/internal/nodeview/nodeview_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
62 changes: 51 additions & 11 deletions compilers/openapi/internal/schema/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -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...)
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down
61 changes: 61 additions & 0 deletions compilers/openapi/internal/schema/schema_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading