From 2350d1d958ad3e92eaa899c4ce0d6e0c29675c78 Mon Sep 17 00:00:00 2001 From: Fuad Daoud Date: Sat, 12 Sep 2026 14:03:19 +0300 Subject: [PATCH] fix(compilers/openapi): report a bare deprecation extension key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JSON null decodes into a plain string without error and leaves it empty, so a bare `x-sunset:` read as text that says nothing: an empty field written, the node marked inferred, and no diagnostic — while the doc beside it says a value of another shape is a document meaning something else by the key. Decode into *string so a key with no value is told apart from an empty string a document wrote, and report it as the other shapes are. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011T5no6iADeMGgYjsYcV5in --- .../openapi/internal/lowering/promotion.go | 12 ++++-- .../internal/lowering/promotion_test.go | 37 +++++++++++++------ 2 files changed, 34 insertions(+), 15 deletions(-) diff --git a/compilers/openapi/internal/lowering/promotion.go b/compilers/openapi/internal/lowering/promotion.go index 75c1a679..261f18d9 100644 --- a/compilers/openapi/internal/lowering/promotion.go +++ b/compilers/openapi/internal/lowering/promotion.go @@ -204,12 +204,18 @@ func deprecationField(dep *ir.Deprecation, target ExtensionTarget) *string { // shape is taken as written — a date is not parsed here, because the mapping is // the caller's policy and a key it points at the date field is its statement // that the key holds one. +// +// The target is *string rather than string because JSON null decodes into a +// string without error and leaves it empty, so a bare `x-sunset:` would +// otherwise read as text that says nothing — an empty field written and the +// node marked inferred, with no diagnostic. A key with no value is a value of +// another shape, and is reported as one. func extensionText(raw ir.RawValue) (string, bool) { - var text string - if err := json.Unmarshal(raw, &text); err != nil { + var text *string + if err := json.Unmarshal(raw, &text); err != nil || text == nil { return "", false } - return text, true + return *text, true } // extensionOpenness reads a preserved extension value as a statement that an diff --git a/compilers/openapi/internal/lowering/promotion_test.go b/compilers/openapi/internal/lowering/promotion_test.go index b1444518..805f6a4c 100644 --- a/compilers/openapi/internal/lowering/promotion_test.go +++ b/compilers/openapi/internal/lowering/promotion_test.go @@ -142,18 +142,31 @@ func TestPromoteDeprecation_UndeprecatedNodeIsTheWholeAnswer(t *testing.T) { // is a reason to leave the field empty and say so, not to coerce. func TestPromoteDeprecation_ValueThatIsNotTextIsReported(t *testing.T) { t.Parallel() - unmodeled := ir.Unmodeled{"openapi:x-deprecated-reason": vendorExtension(`7`)} - var dep ir.Deprecation - var prov ir.Provenance - diags := promotionCtx(lowering.ExtensionPromotions{}).PromoteDeprecation(unmodeled, &dep, &prov) - - require.Len(t, diags, 1) - assert.Equal(t, ir.SeverityInfo, diags[0].Severity) - assert.Equal(t, "openapi/degraded-construct", diags[0].Code) - assert.Equal(t, "/components/schemas/S", diags[0].Provenance.Pointer, - "the report names the extension rather than the node holding it") - assert.Equal(t, ir.Deprecation{}, dep) - assert.Empty(t, prov.Inferred) + for _, tc := range []struct { + name, value string + }{ + {"a number", `7`}, + // JSON null decodes into a plain string as "", so this row is what + // separates a bare key from an empty string a document wrote on purpose: + // the first fills nothing and is reported, the second is text. + {"a bare key", `null`}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + unmodeled := ir.Unmodeled{"openapi:x-deprecated-reason": vendorExtension(tc.value)} + var dep ir.Deprecation + var prov ir.Provenance + diags := promotionCtx(lowering.ExtensionPromotions{}).PromoteDeprecation(unmodeled, &dep, &prov) + + require.Len(t, diags, 1) + assert.Equal(t, ir.SeverityInfo, diags[0].Severity) + assert.Equal(t, "openapi/degraded-construct", diags[0].Code) + assert.Equal(t, "/components/schemas/S", diags[0].Provenance.Pointer, + "the report names the extension rather than the node holding it") + assert.Equal(t, ir.Deprecation{}, dep) + assert.Empty(t, prov.Inferred) + }) + } } // TestPromoteDeprecation_MarksOnceBesideWhateverWasAlreadyThere pins the two