Skip to content

Commit c6dc386

Browse files
committed
fix(core): ObsoleteAttribute's three components are nullable (#2295, SR-AUD-116)
Message, DiagnosticId and UrlFormat were non-nullable std::string, so an ABSENT value and an EMPTY one were the same state -- measured, a default attribute and one built from std::string{} compared EQUAL. .NET's are all string?. Route A, the faithful one, under SA-8 with SA-3 (sizeof 112 -> 136) and SA-10. THE TICKET'S OWN OBJECTION TO ROUTE A IS DISCHARGED BY MEASUREMENT, NOT OVERRULED. It said 'zero first-party production consumers does NOT license option A: the header is public in Core.Base and downstream consumers exist and were not inspected'. They have been now: mobile-eggbert mentions ObsoleteAttribute zero times, and cna mentions it once -- inside a COMMENT, not in code. Neither repository was modified. That is SA-2 condition 5 doing exactly the job it exists for. A GETTER CHANGE ALONE COULD NOT HAVE CLOSED IT, which is why route B was not enough either: the boundary was on the way IN as well as out, since the constructor and both setters took const std::string&, so a caller could neither supply an absent value nor return a component to that state. A test asserts all three directions. The layout pin stays a RELATIONSHIP rather than a re-recorded number, and gained a second assertion so that silently dropping the optionals back to plain strings fails rather than passing on a stale relationship. Message and IsError stay getter-only while DiagnosticId and UrlFormat keep their setters, matching .NET's own { get; } / { get; set; } split. The review's price was exact: three of four call shapes break, and only equality against a literal survives because std::optional compares against a value of its own type. Fixture set: 27 fixtures / 166 sites. Gate 17,279 run, 0 failed.
1 parent c5201fe commit c6dc386

8 files changed

Lines changed: 251 additions & 28 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

audit/AUDIT_FINDINGS_INDEX.md

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<!-- SPDX-License-Identifier: MIT -->
2+
<!-- Copyright (c) Robert Vokac and contributors -->
3+
4+
# Migration — `ObsoleteAttribute`'s three components are nullable (ticket #2295)
5+
6+
*2026-08-18.* `Message`, `DiagnosticId` and `UrlFormat` were non-nullable `std::string`, so an
7+
**absent** value and an **empty** one were the same state. .NET's are all `string?`.
8+
9+
Landed under `docs/StandingApprovals.md` **SA-8** (representation) with **SA-3** (layout) and
10+
**SA-10** (signatures), under SA-2's five conditions. `sizeof(ObsoleteAttribute)` grows
11+
**112 → 136**, so **consumers must be recompiled**.
12+
13+
---
14+
15+
## 1. What changed
16+
17+
| | Was | Is |
18+
|---|---|---|
19+
| `getMessageProperty()` | `const std::string&` | **`const std::optional<std::string>&`** |
20+
| `getDiagnosticIdProperty()`, `getUrlFormatProperty()` | same | same change |
21+
| `ObsoleteAttribute(message)`, `(message, isError)` | `const std::string&` | **`std::optional<std::string>`** |
22+
| `setDiagnosticIdProperty`, `setUrlFormatProperty` | `const std::string&` | **`std::optional<std::string>`** |
23+
| `getIsErrorProperty()` | `bool` | **unchanged** — .NET's `IsError` is `bool`, not nullable |
24+
| `sizeof` | 112 | **136** |
25+
26+
`Message` and `IsError` stay getter-only and `DiagnosticId`/`UrlFormat` keep their setters,
27+
matching .NET's own `{ get; }` / `{ get; set; }` split.
28+
29+
## 2. Why a getter change alone could not have done it
30+
31+
The boundary was on the way **in** as well as on the way out: the message constructor and both
32+
setters took `const std::string&`, so a caller could neither **supply** an absent value nor
33+
**return** a component to that state. Measured before the repair, `ObsoleteAttribute def;` and
34+
`ObsoleteAttribute empty(std::string{});` compared **equal**.
35+
36+
A test now asserts all three directions: absent ≠ empty on the way out, an explicitly absent
37+
constructor argument, and a setter round-trip through value → empty → absent.
38+
39+
## 3. The objection the ticket raised is discharged by measurement
40+
41+
The ticket said: *"Zero first-party production consumers does NOT license option A: the header is
42+
public in Core.Base and downstream consumers exist and **were not inspected**."*
43+
44+
They have been. Measured on 2026-08-18: `mobile-eggbert` mentions `ObsoleteAttribute` **zero**
45+
times, and `cna` mentions it **once — inside a comment**
46+
(`cna/modules/devices/include/Microsoft/Devices/Sensors/Accelerometer.hpp:433`), not in code.
47+
Neither repository was modified. That is SA-2's condition 5 doing exactly the job it exists for.
48+
49+
## 4. To migrate
50+
51+
```cpp
52+
// before
53+
if (attr.getMessageProperty().empty()) { ... }
54+
const std::string& m = attr.getMessageProperty();
55+
takesAString(attr.getUrlFormatProperty());
56+
57+
// after
58+
if (!attr.getMessageProperty().has_value()) { ... } // absent
59+
if (attr.getMessageProperty() == "") { ... } // present and empty — now distinct
60+
const std::string m = attr.getMessageProperty().value_or("");
61+
takesAString(attr.getUrlFormatProperty().value_or(""));
62+
```
63+
64+
Equality against a string literal is the **one call shape that survives unchanged**, because
65+
`std::optional` compares against a value of its own type. The negative fixture pins the three
66+
broken shapes, a fourth that breaks *silently* (a `decltype` on the return type), and both
67+
survivors.
68+
69+
## 5. Downstream, measured
70+
71+
See §3 — **zero code sites in either consumer**. The full-rebuild requirement is recorded here for
72+
any future consumer.

modules/core/include/System/ObsoleteAttribute.hpp

Lines changed: 27 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
// Copyright (c) Robert Vokac and contributors
33
// Portions based on .NET runtime API (MIT License, Copyright .NET Foundation and Contributors)
44
#pragma once
5+
#include <optional>
56
#include <string>
7+
#include <utility>
68
#include "System/Attribute.hpp"
79

810
namespace System {
@@ -58,12 +60,20 @@ namespace System {
5860
* left derivable rather than `final` because sealing it would reject code
5961
* that compiles today.
6062
*
61-
* `Message`, `DiagnosticId` and `UrlFormat` are nullable (`string?`) in
62-
* .NET, so a default-constructed attribute exposes `null` there and a
63-
* caller can tell that apart from an explicitly supplied `""`. This port
64-
* stores three non-nullable `std::string`s, so **an absent value and an
65-
* empty one are the same state here** — SR-AUD-116, whose representation is
66-
* under decision at ticket #2295 and must not be assumed either way.
63+
* `Message`, `DiagnosticId` and `UrlFormat` are nullable (`string?`) in .NET, so a
64+
* default-constructed attribute exposes `null` and a caller can tell that apart from an
65+
* explicitly supplied `""`. **Since ticket #2295 this port can too**: all three are
66+
* `std::optional<std::string>`, and the constructors and setters take one, so a caller can
67+
* both supply an absent value and return a component to that state.
68+
*
69+
* They used to be three non-nullable `std::string`s, which made an absent value and an empty
70+
* one **the same state** — measured, `ObsoleteAttribute def;` and
71+
* `ObsoleteAttribute empty(std::string{});` compared equal. The boundary was on the way in as
72+
* well as on the way out, so the gap was not closable by a getter alone.
73+
*
74+
* `sizeof(ObsoleteAttribute)` grows **112 → 136** (SA-3). `Message` and `IsError` stay
75+
* getter-only and `DiagnosticId`/`UrlFormat` keep their setters, matching .NET's own
76+
* `{ get; }` / `{ get; set; }` split.
6777
*
6878
* The class exists so that ported code naming `System::ObsoleteAttribute`
6979
* still compiles and so that the .NET intent stays readable beside the
@@ -73,10 +83,10 @@ namespace System {
7383
* SR-AUD-115, tickets #2293 (review) and #2294.
7484
*/
7585
class ObsoleteAttribute : public Attribute {
76-
std::string message_;
86+
std::optional<std::string> message_;
7787
bool isError_ = false;
78-
std::string diagnosticId_;
79-
std::string urlFormat_;
88+
std::optional<std::string> diagnosticId_;
89+
std::optional<std::string> urlFormat_;
8090

8191
public:
8292
/**
@@ -89,7 +99,7 @@ namespace System {
8999
* @brief Constructs an ObsoleteAttribute with the given informational message.
90100
* @param message Human-readable explanation of the obsolescence.
91101
*/
92-
explicit ObsoleteAttribute(const std::string& message) : message_(message) {}
102+
explicit ObsoleteAttribute(std::optional<std::string> message) : message_(std::move(message)) {}
93103
/**
94104
* @brief Constructs an ObsoleteAttribute with a message and an error flag.
95105
* @param message Human-readable explanation of the obsolescence.
@@ -99,10 +109,11 @@ namespace System {
99109
* stored and returned unchanged, and no value of it makes any
100110
* declaration harder or easier to use.
101111
*/
102-
ObsoleteAttribute(const std::string& message, bool isError) : message_(message), isError_(isError) {}
112+
ObsoleteAttribute(std::optional<std::string> message, bool isError)
113+
: message_(std::move(message)), isError_(isError) {}
103114

104115
/** Returns the informational message describing the obsolescence. */
105-
[[nodiscard]] const std::string& getMessageProperty() const { return message_; }
116+
[[nodiscard]] const std::optional<std::string>& getMessageProperty() const { return message_; }
106117
/**
107118
* Returns the recorded .NET error flag. It reports what .NET's compiler
108119
* would do with the declaration this attribute described; it does not
@@ -113,18 +124,18 @@ namespace System {
113124
* Returns the diagnostic ID associated with this obsolescence, or an
114125
* empty string where .NET would return null (SR-AUD-116).
115126
*/
116-
[[nodiscard]] const std::string& getDiagnosticIdProperty() const { return diagnosticId_; }
127+
[[nodiscard]] const std::optional<std::string>& getDiagnosticIdProperty() const { return diagnosticId_; }
117128
/**
118129
* Returns the URL format string for further documentation, or an empty
119130
* string where .NET would return null (SR-AUD-116). Nothing in this
120131
* port formats it; it is metadata for a human reader.
121132
*/
122-
[[nodiscard]] const std::string& getUrlFormatProperty() const { return urlFormat_; }
133+
[[nodiscard]] const std::optional<std::string>& getUrlFormatProperty() const { return urlFormat_; }
123134

124135
/** Sets the diagnostic ID associated with this obsolescence. */
125-
void setDiagnosticIdProperty(const std::string& v) { diagnosticId_ = v; }
136+
void setDiagnosticIdProperty(std::optional<std::string> v) { diagnosticId_ = std::move(v); }
126137
/** Sets the URL format string pointing to further documentation. */
127-
void setUrlFormatProperty(const std::string& v) { urlFormat_ = v; }
138+
void setUrlFormatProperty(std::optional<std::string> v) { urlFormat_ = std::move(v); }
128139
};
129140

130141
} // namespace System

modules/core/tests/System/ObsoleteAttributeTests.cpp

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Copyright (c) Robert Vokac and contributors
33
// Portions based on .NET runtime API (MIT License, Copyright .NET Foundation and Contributors)
44
#include <gtest/gtest.h>
5+
#include <optional>
56
#include <cstddef>
67
#include <string>
78
#include <type_traits>
@@ -22,8 +23,10 @@ int legacyMarkedWithDeprecated() { return 7; }
2223
} // namespace
2324

2425
TEST(ObsoleteAttributeTest, DefaultCtor) {
26+
// #2295: absent, not empty. The old assertion could not tell the two apart, which is the
27+
// finding.
2528
ObsoleteAttribute a;
26-
EXPECT_TRUE(a.getMessageProperty().empty());
29+
EXPECT_EQ(std::nullopt, a.getMessageProperty());
2730
EXPECT_FALSE(a.getIsErrorProperty());
2831
}
2932

@@ -41,14 +44,14 @@ TEST(ObsoleteAttributeTest, MessageAndErrorCtor) {
4144

4245
TEST(ObsoleteAttributeTest, DiagnosticId) {
4346
ObsoleteAttribute a("msg");
44-
EXPECT_TRUE(a.getDiagnosticIdProperty().empty());
47+
EXPECT_EQ(std::nullopt, a.getDiagnosticIdProperty());
4548
a.setDiagnosticIdProperty("SYSLIB0001");
4649
EXPECT_EQ(a.getDiagnosticIdProperty(), "SYSLIB0001");
4750
}
4851

4952
TEST(ObsoleteAttributeTest, UrlFormat) {
5053
ObsoleteAttribute a("msg");
51-
EXPECT_TRUE(a.getUrlFormatProperty().empty());
54+
EXPECT_EQ(std::nullopt, a.getUrlFormatProperty());
5255
a.setUrlFormatProperty("https://example.com/{0}");
5356
EXPECT_EQ(a.getUrlFormatProperty(), "https://example.com/{0}");
5457
}
@@ -63,13 +66,49 @@ TEST(ObsoleteAttributeTest, IsAttribute) {
6366
// constructed attribute could reach a declaration. A pointer-sized member would
6467
// not fit in the padding the declared members already leave, so this trips on
6568
// exactly the shape an attempt to "implement" the attachment would take.
66-
TEST(ObsoleteAttributeTest, CarriesItsFourDeclaredMembersAndNoSideChannel) {
69+
TEST(ObsoleteAttributeTest, Fix2295_CarriesItsFourDeclaredMembersAndNoSideChannel) {
70+
// #2295 grew the three string members into std::optional<std::string>, so
71+
// sizeof(ObsoleteAttribute) moves 112 -> 136 under SA-3. The pin stays a RELATIONSHIP rather
72+
// than a number: it is the base plus three optionals plus the bool, rounded to alignment, so
73+
// it keeps proving there is no side channel rather than re-recording a compiler's answer.
6774
constexpr std::size_t declared =
68-
sizeof(System::Attribute) + 3 * sizeof(std::string) + sizeof(bool);
75+
sizeof(System::Attribute) + 3 * sizeof(std::optional<std::string>) + sizeof(bool);
6976
constexpr std::size_t align = alignof(ObsoleteAttribute);
7077
constexpr std::size_t rounded = ((declared + align - 1) / align) * align;
7178
EXPECT_EQ(sizeof(ObsoleteAttribute), rounded);
7279
EXPECT_TRUE((std::is_base_of_v<System::Attribute, ObsoleteAttribute>));
80+
81+
// ...and the growth is real and was measured, so a future edit that silently drops the
82+
// optionals back to plain strings fails here rather than passing on a stale relationship.
83+
EXPECT_GT(sizeof(ObsoleteAttribute),
84+
sizeof(System::Attribute) + 3 * sizeof(std::string) + sizeof(bool));
85+
}
86+
87+
TEST(ObsoleteAttributeTest, Fix2295_AbsentAndEmptyAreDifferentStates) {
88+
// THE FINDING ITSELF. Measured before #2295, these two compared EQUAL: three non-nullable
89+
// std::strings cannot express .NET's `string?`, and the boundary was on the way IN as well as
90+
// on the way out -- the constructor and both setters took const std::string&, so a caller
91+
// could neither supply an absent value nor return a component to that state.
92+
const ObsoleteAttribute absent;
93+
const ObsoleteAttribute empty(std::string{});
94+
EXPECT_EQ(std::nullopt, absent.getMessageProperty());
95+
EXPECT_EQ(std::optional<std::string>(""), empty.getMessageProperty());
96+
EXPECT_NE(absent.getMessageProperty(), empty.getMessageProperty());
97+
98+
// The way IN, for the two settable components: a caller can now supply absence and take it
99+
// back, which .NET allows and this port could not express at all.
100+
ObsoleteAttribute a("msg");
101+
a.setDiagnosticIdProperty("SYSLIB0001");
102+
EXPECT_EQ(std::optional<std::string>("SYSLIB0001"), a.getDiagnosticIdProperty());
103+
a.setDiagnosticIdProperty(std::string{});
104+
EXPECT_EQ(std::optional<std::string>(""), a.getDiagnosticIdProperty());
105+
a.setDiagnosticIdProperty(std::nullopt);
106+
EXPECT_EQ(std::nullopt, a.getDiagnosticIdProperty());
107+
108+
// The constructor too: an explicitly absent message differs from an explicitly empty one.
109+
EXPECT_EQ(std::nullopt, ObsoleteAttribute(std::nullopt).getMessageProperty());
110+
EXPECT_EQ(std::nullopt, ObsoleteAttribute(std::nullopt, true).getMessageProperty());
111+
EXPECT_TRUE(ObsoleteAttribute(std::nullopt, true).getIsErrorProperty());
73112
}
74113

75114
// SR-AUD-115 (#2294). This is a LANGUAGE-BOUNDARY DEMONSTRATION, not a
@@ -125,7 +164,7 @@ TEST(ObsoleteAttributeTest, TextComponentsAreByteTransparent) {
125164
a.setUrlFormatProperty("https://example.com/dokumentace/{0}?jazyk=čeština");
126165

127166
EXPECT_EQ(a.getMessageProperty(), message);
128-
EXPECT_EQ(a.getMessageProperty().size(), message.size());
167+
EXPECT_EQ(a.getMessageProperty().value().size(), message.size());
129168
EXPECT_EQ(a.getUrlFormatProperty(),
130169
"https://example.com/dokumentace/{0}?jazyk=čeština");
131170
}

modules/core/tests/System/SystemAttributeTests.cpp

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
// Attribute, AttributeTargets, AttributeUsageAttribute, CLSCompliantAttribute,
77
// ObsoleteAttribute, FlagsAttribute, and other marker attributes.
88
#include <gtest/gtest.h>
9+
#include <optional>
910
#include <type_traits>
1011
#include <string>
1112
#include "System/Attribute.hpp"
@@ -242,9 +243,10 @@ TEST(CLSCompliantAttributeTests, IsCompliant_False) {
242243
// ObsoleteAttribute
243244
// ===========================================================================
244245

245-
TEST(ObsoleteAttributeTests, DefaultCtor_MessageEmpty) {
246+
TEST(ObsoleteAttributeTests, DefaultCtor_MessageIsAbsent) {
247+
// #2295: nullopt, not "". A default attribute has NO message in .NET, and now here too.
246248
ObsoleteAttribute attr;
247-
EXPECT_TRUE(attr.getMessageProperty().empty());
249+
EXPECT_EQ(std::nullopt, attr.getMessageProperty());
248250
}
249251

250252
TEST(ObsoleteAttributeTests, MessageCtor_StoresMessage) {
@@ -267,9 +269,9 @@ TEST(ObsoleteAttributeTests, DefaultCtor_IsError_False) {
267269
EXPECT_FALSE(attr.getIsErrorProperty());
268270
}
269271

270-
TEST(ObsoleteAttributeTests, DiagnosticId_DefaultEmpty) {
272+
TEST(ObsoleteAttributeTests, DiagnosticId_DefaultIsAbsent) {
271273
ObsoleteAttribute attr("msg");
272-
EXPECT_TRUE(attr.getDiagnosticIdProperty().empty());
274+
EXPECT_EQ(std::nullopt, attr.getDiagnosticIdProperty());
273275
}
274276

275277
TEST(ObsoleteAttributeTests, SetDiagnosticId_Stored) {

plan.sqlite3

4 KB
Binary file not shown.

0 commit comments

Comments
 (0)