Skip to content

Commit 65b8d24

Browse files
committed
fix(text-json): JsonNode is not copyable and DetachParent is not public (#1888)
Deletes JsonNode's four copy/move members and moves DetachParent to protected with JsonArray/JsonObject as friends, closing three measured defects: a copy gave a second container sharing the SAME children, each still reporting the ORIGINAL as its parent (J08); assignment SLICED, rewriting parent_ on a node still stored in a container (J09); and public DetachParent let one node sit in TWO containers (J13). A .NET JsonNode is a REFERENCE type, so there was never an object copy to translate, and XObject already deleted all four -- this ends an asymmetry inside the port rather than inventing a restriction. Measured: zero first-party copy/assign sites, zero in both consumers. THE HEADER'S OWN NOTE WAS WRONG AND THE REFERENCE CORRECTS IT. It claimed to mirror "JsonNode.cs's internal DetachParent"; there is no DetachParent on JsonNode.cs at all. .NET puts it on the CONTAINERS, private on each (JsonObject.cs:316, JsonArray.IList.cs:231), body `item?.Parent = null`, with Parent's setter internal. Protected-plus-friends is that reachability in C++. Four pins inverted, and the fourth was not where the measurement said to look: JsonNodeTeardownTests built its second container with make_shared<JsonArray>(realOwner), a copy commented "shares children". A grep for `X = *y` missed it; the compiler found it. Its real subject is #1886's `== this` guard, so it is rewritten to reach that guard without a copy. Three mutations. M1 (restore copy) and M3 (DetachParent public) caught at compile time. M2 (restore move) is NOT CAUGHT and is reported as such: a proven equivalence, measured with a probe. JsonNode is ABSTRACT, so is_move_constructible_v is false whatever the declaration says, and JsonArray/JsonObject have user-declared destructors (#1895) that suppress their implicit moves, so move-constructibility falls back to the already-deleted copy. The deletion is kept for intent and becomes load-bearing if a container drops that destructor -- the header and the fixture's own site both say so. It also finally writes the fixture #1894 could not: test/consumer/ text_json_node_lifetime_negative.cpp, named in #1894's acceptance criteria, which recorded correctly that it "cannot be started, not merely should not be" because no CCF-019 repair had outlawed any spelling. Fixture set 45/231 -> 46/236. Downstream ticket #2396: zero sites in both consumers. Gate: 17,585 run, 17,585 passed, 0 failed, 0 skipped across 38 executables (+0, deliberately -- three pins became two and one case was added). Build directory: build/ only, --parallel 2 throughout.
1 parent 66f27f1 commit 65b8d24

8 files changed

Lines changed: 391 additions & 47 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
<!-- SPDX-License-Identifier: MIT -->
2+
# Migration — `JsonNode` is not copyable, and `DetachParent` is not public (#1888)
3+
4+
Ticket **#1888** (SR-AUD-327, CCF-019), landed 2026-08-19 on an explicit approval after being
5+
declined since July.
6+
7+
## What changed
8+
9+
```cpp
10+
JsonNode(const JsonNode&) = delete; JsonNode& operator=(const JsonNode&) = delete;
11+
JsonNode(JsonNode&&) = delete; JsonNode& operator=(JsonNode&&) = delete;
12+
protected: void DetachParent(); // was public; JsonArray and JsonObject are friends
13+
```
14+
15+
## The three measured defects it closes
16+
17+
| probe | what it did |
18+
|---|---|
19+
| **J08** | `JsonArray copy = *orig` gave a second array sharing the **same children**, each still reporting the **original** as its parent |
20+
| **J09** | `nodeRefA = nodeRefB` **sliced**, rewriting `parent_` on a node still stored in a container |
21+
| **J13** | public `DetachParent()` let a caller sever the link a container believes it owns and put the same node into a **second** container |
22+
23+
**A .NET `JsonNode` is a reference type**, so there was never an object copy to translate —
24+
assigning one C# variable to another copies a reference. All four members were a C++ artefact, and
25+
`System::Xml::Linq::XObject` already deleted all four, so this **ends an asymmetry inside the port**
26+
rather than inventing a restriction.
27+
28+
## The header's own note about `DetachParent` was wrong, and the reference corrects it
29+
30+
It said the member "mirrors `JsonNode.cs`'s internal `DetachParent`". **There is no `DetachParent`
31+
on `JsonNode.cs` at all.** .NET puts it on the *containers*, as a **private** helper on each
32+
(`JsonObject.cs:316`, `JsonArray.IList.cs:231`), whose whole body is `item?.Parent = null` — and
33+
`Parent`'s setter is `internal`. So in .NET a consumer can neither call it nor reach what it does.
34+
Protected-plus-friends is that reachability expressed in C++.
35+
36+
## Migration
37+
38+
* a copy → **`DeepClone()`**, which does what the implicit copy did not: the clone's children are
39+
its own and name the **clone** as their parent;
40+
* moving a node between containers → **`Remove`/`Add`**, which keeps the links and the containers in
41+
step.
42+
43+
**Measured impact: zero.** No first-party copy/assign site existed, and `cna` and `mobile-eggbert`
44+
have zero `JsonNode`/`JsonArray`/`JsonObject` sites. (`cna`'s single `JsonObject` match is its own
45+
`ExtractJsonObjectFieldEXT` helper, an unrelated name.)
46+
47+
## Four shipped pins were inverted, and one was not where the measurement said to look
48+
49+
Three were the probe-case pins, each carrying a `NOLINT - deliberate: pins today's implicit copy`
50+
marker. The fourth was **not found by the initial measurement**: `JsonNodeTeardownTests.cpp` built
51+
its second container with `std::make_shared<JsonArray>(realOwner)` — a copy-construction commented
52+
*"shares children"*. A grep for `X = *y` patterns missed it; the **compiler** found it. Its real
53+
subject is #1886's `== this` guard, so it is rewritten to reach that guard through two containers
54+
that genuinely hold different children, and a second case covers the moved-child form.
55+
56+
## SA-2 conditions
57+
58+
1. This note. ✔
59+
2. **`test/consumer/text_json_node_lifetime_negative.cpp` — 5 sites.** This is the fixture #1894's
60+
acceptance criteria named and could not write for seven weeks: its notes recorded, correctly,
61+
that it *"cannot be started, not merely should not be"*, because no CCF-019 repair had outlawed
62+
any spelling. #1888 is the one that finally did. Fixture set **45 / 231 → 46 / 236**. ✔
63+
3. Downstream ticket **#2396**. ✔
64+
4. Full gate. ✔
65+
5. Measured consumer impact: **zero sites** in both. ✔
66+
67+
## Mutation testing
68+
69+
Three mutations. **M1** (restore the copy members) and **M3** (make `DetachParent` public again) are
70+
caught at compile time. **M2 (restore the move members) is NOT caught, and that is reported rather
71+
than dressed up** — it is a **proven equivalence**, for two independent reasons measured with a
72+
probe:
73+
74+
* `JsonNode` is **abstract** (three pure virtuals), so `is_move_constructible_v<JsonNode>` is false
75+
whatever those declarations say;
76+
* `JsonArray` and `JsonObject` each have a **user-declared destructor** (#1895's iterative
77+
teardown), which suppresses their implicit move constructors — so their move-constructibility
78+
falls back to the copy constructor, already deleted.
79+
80+
The deletion is kept because it states the intent and becomes load-bearing the day a container drops
81+
its destructor. Both the header and the fixture's site 3 say exactly this, so a later reader is not
82+
misled into thinking the deletion is what rejects the spelling today.

modules/text-json/include/System/Text/Json/Nodes/JsonNode.hpp

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,34 @@ namespace System::Text::Json::Nodes {
3636
public:
3737
virtual ~JsonNode() = default;
3838

39+
// Ticket #1888. A .NET `JsonNode` is a REFERENCE type, so there is no object copy to
40+
// translate -- assigning one C# variable to another copies a reference. C++ generates the
41+
// copy and move members implicitly here, and all four are wrong for a parented node:
42+
//
43+
// * copy construction gave a second container sharing the SAME children, each of which
44+
// still reported the ORIGINAL as its parent (probe case J08);
45+
// * copy assignment SLICED, rewriting parent_ on a node that was still stored in a
46+
// container (J09).
47+
//
48+
// `System::Xml::Linq::XObject` already deletes all four for the same reason, so this ends an
49+
// asymmetry inside the port rather than inventing a restriction. Use DeepClone() for a copy.
50+
//
51+
// HONEST RECORD: deleting the two MOVE members is currently an EQUIVALENCE, and it is kept
52+
// deliberately rather than because a test can see it. Measured -- restoring them as
53+
// `= default` changes no observable, for two independent reasons:
54+
// * `JsonNode` is ABSTRACT (three pure virtuals), so `is_move_constructible_v<JsonNode>`
55+
// is false whatever these declarations say;
56+
// * `JsonArray` and `JsonObject` each have a USER-DECLARED destructor (#1895's iterative
57+
// teardown), which suppresses their implicit move constructors, so their
58+
// move-constructibility falls back to the copy constructor -- already deleted.
59+
// The deletion states the intent and becomes load-bearing the day a container drops its
60+
// user-declared destructor. It is not load-bearing today, and the mutation that restores
61+
// it is reported as uncaught rather than dressed up.
62+
JsonNode(const JsonNode&) = delete;
63+
JsonNode& operator=(const JsonNode&) = delete;
64+
JsonNode(JsonNode&&) = delete;
65+
JsonNode& operator=(JsonNode&&) = delete;
66+
3967
/** @return The options this node was constructed with. */
4068
[[nodiscard]] JsonNodeOptions getOptionsProperty() const { return options_; }
4169

@@ -64,6 +92,26 @@ namespace System::Text::Json::Nodes {
6492
*/
6593
void AssignParent(JsonNode* parent);
6694

95+
protected:
96+
/**
97+
* @brief Clears the parent container pointer.
98+
*
99+
* @note **Protected since ticket #1888, and the header's previous note about it was wrong.**
100+
* It said this "mirrors JsonNode.cs's internal DetachParent" -- there is **no
101+
* `DetachParent` on `JsonNode.cs` at all**. .NET puts it on the *containers*, as a
102+
* **private** helper on each (`JsonObject.cs:316`, `JsonArray.IList.cs:231`), whose whole
103+
* body is `item?.Parent = null` -- and `Parent`'s setter is `internal`. So in .NET a
104+
* consumer can neither call it nor reach what it does.
105+
*
106+
* Public here, it let a caller put one node into **two** containers (probe case J13). It is
107+
* now protected, with the two containers as friends, which is the same reachability .NET
108+
* has expressed in C++.
109+
*/
110+
void DetachParent() { parent_ = nullptr; }
111+
112+
friend class JsonArray;
113+
friend class JsonObject;
114+
67115
private:
68116
/** @return true if this node currently contains other nodes. #1896's cycle-guard
69117
* short-circuit; see AssignParent. Non-virtual by design -- a virtual would be a vtable
@@ -72,8 +120,6 @@ namespace System::Text::Json::Nodes {
72120

73121
public:
74122

75-
/** @brief Internal: clears the parent container pointer. Not part of .NET's public surface (mirrors JsonNode.cs's internal DetachParent). */
76-
void DetachParent() { parent_ = nullptr; }
77123

78124
/** @return This node cast to JsonArray. @throws System::InvalidOperationException if this isn't a JsonArray. */
79125
[[nodiscard]] JsonArray& AsArray();

modules/text-json/tests/System/Text/Json/JsonNamespaceReviewTests.cpp

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -838,6 +838,11 @@ TEST(JsonEmbeddedNulTests, THEDELIBERATEEXCEPTIONJsonNodeParseStillHasNoDepthBou
838838
// names the ticket that owns it. None of them endorses the behaviour; each of them fails the
839839
// day it changes, which is exactly when the owning ticket needs re-reading.
840840

841+
namespace detail1888Review {
842+
/// Dependent parameter, per the #2299 gcc trap.
843+
template <typename T> concept HasPublicDetachParent = requires(T& t) { t.DetachParent(); };
844+
}
845+
841846
TEST(JsonGatedBehaviourPins, Decl2118_GetRawTextReRendersRatherThanReturningSourceText) {
842847
// RENAMED AND RE-ROLED BY #2118 on 2026-08-19: this was a GATED pin ("a defect knowingly still
843848
// present"); it is now a DECLARATION. The user decided against retaining source spans, so the
@@ -979,11 +984,19 @@ TEST(JsonGatedBehaviourPins, PINCCF019JsonNodeSParentIsABorrowedPointerAndDetach
979984
object->Add("a", child);
980985
EXPECT_EQ(child->getParentProperty(), object.get()) << "a raw, borrowed parent pointer";
981986
EXPECT_EQ((*object)["a"]->GetValueKind(), JsonValueKind::Number);
982-
// DetachParent is public -- the source break #1888 is blocked on.
983-
child->DetachParent();
984-
EXPECT_EQ(child->getParentProperty(), nullptr)
985-
<< "#1888: a caller can sever the link the container believes it owns";
986-
EXPECT_TRUE(object->ContainsKey("a")) << "and the container still lists it";
987+
// INVERTED BY #1888 (2026-08-19). This used to call DetachParent() directly and assert that a
988+
// caller "can sever the link the container believes it owns" while the container still lists
989+
// the key -- the defect, pinned. DetachParent is now protected with JsonArray/JsonObject as
990+
// friends, which is the reachability .NET has: its DetachParent is a PRIVATE helper on each
991+
// container and there is none on JsonNode at all.
992+
static_assert(!detail1888Review::HasPublicDetachParent<Nodes::JsonObject>,
993+
"#1888: DetachParent must not be publicly callable");
994+
995+
// The link can still be severed -- through the container, which keeps the two in step instead
996+
// of letting them disagree. That is the property the old assertion was really about.
997+
object->Remove("a");
998+
EXPECT_EQ(child->getParentProperty(), nullptr) << "the container detaches its own child";
999+
EXPECT_FALSE(object->ContainsKey("a")) << "#1888: and no longer lists it -- they cannot disagree";
9871000
}
9881001

9891002
TEST(JsonGatedBehaviourPins, PINConvertersAndReferenceHandlingAreDECLARATIONONLYNotConsulted) {

modules/text-json/tests/System/Text/Json/Nodes/JsonNodeLifetimeTests.cpp

Lines changed: 62 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@ using System::Text::Json::Nodes::JsonNode;
3333
using System::Text::Json::Nodes::JsonObject;
3434
using System::Text::Json::Nodes::JsonValue;
3535

36+
namespace detail1888 {
37+
/// Dependent parameter, per the #2299 gcc trap: a non-dependent `requires` on a missing or
38+
/// inaccessible name hard-errors instead of yielding false.
39+
template <typename T> concept HasPublicDetachParent = requires(T& t) { t.DetachParent(); };
40+
}
41+
3642
namespace {
3743

3844
std::shared_ptr<JsonNode> str(const std::string& s) { return JsonValue::Create(s); }
@@ -171,58 +177,78 @@ TEST(JsonNodeLifetimeTests, RetainedLeafOnly_AfterWholeTreeDestroyed_HasNoParent
171177
EXPECT_EQ(leaf->getRootProperty(), leaf.get());
172178
}
173179

174-
// --- Only the destroying owner's own links are cleared (J08, J13) -------------------------
175-
176-
// Probe case J13: DetachParent() is public today, so a node can legitimately end up stored in
177-
// one container while its parent link names another. The destructor of the container that no
178-
// longer owns it must leave that link alone.
179-
TEST(JsonNodeLifetimeTests, ChildOwnedByAnotherContainer_KeepsItsParentWhenTheFormerOwnerDies) {
180+
// --- The three defects #1888 closed (J08, J09, J13) ---------------------------------------
181+
//
182+
// These three cases used to PIN the defects, each with a `NOLINT - deliberate` marker saying so.
183+
// #1888 landed on 2026-08-19 and they are INVERTED: what they assert now is that the spelling is
184+
// gone, and that the lifetime behaviour they were really protecting still holds by other means.
185+
186+
TEST(JsonNodeLifetimeTests, Fix1888_ANodeCannotBePutIntoTwoContainers) {
187+
// WAS: ChildOwnedByAnotherContainer_KeepsItsParentWhenTheFormerOwnerDies (probe case J13).
188+
// DetachParent() was PUBLIC, so a caller could clear a node's parent link and hand the same
189+
// node to a second container -- leaving one container holding a node whose parent named
190+
// another. The old case pinned the destructor's behaviour in that state.
191+
//
192+
// The state is now unreachable: DetachParent is protected, with JsonArray and JsonObject as
193+
// friends. That is the reachability .NET has -- its DetachParent is a PRIVATE helper on each
194+
// container (JsonObject.cs:316, JsonArray.IList.cs:231) whose body is `item?.Parent = null`,
195+
// and `Parent`'s setter is internal. There is no DetachParent on JsonNode.cs at all.
196+
static_assert(!detail1888::HasPublicDetachParent<JsonArray>,
197+
"#1888: DetachParent must not be publicly callable");
198+
199+
// What the old case really protected -- a container releasing only its OWN children -- still
200+
// holds, and is reached the supported way: Remove detaches, and the former owner's death
201+
// leaves the surviving container's link alone.
180202
JsonArray live;
181203
std::shared_ptr<JsonNode> child;
182204
{
183205
JsonArray stale;
184206
stale.Add(str("v"));
185207
child = stale[0];
186-
child->DetachParent();
208+
stale.RemoveAt(0); // the supported detach
209+
ASSERT_EQ(child->getParentProperty(), nullptr);
187210
live.Add(child);
188211
ASSERT_EQ(child->getParentProperty(), &live);
189212
}
190213
EXPECT_EQ(child->getParentProperty(), &live);
191214
EXPECT_EQ(child->getRootProperty(), &live);
192215
}
193216

194-
// Probe case J08: JsonNode's copy operations are still implicitly generated (ticket #1888, not
195-
// approved), so a copy-constructed array shares the original's children, which keep reporting
196-
// the original as their parent. Destroying the copy must not steal that link.
197-
TEST(JsonNodeLifetimeTests, CopyConstructedArrayDestroyed_LeavesTheOriginalsParentLinkIntact) {
217+
TEST(JsonNodeLifetimeTests, Fix1888_CopyAndAssignmentAreGoneAndDeepCloneReplacesThem) {
218+
// WAS: CopyConstructedArrayDestroyed_LeavesTheOriginalsParentLinkIntact (J08) and
219+
// OriginalDestroyedBeforeItsCopy_DetachesOnceAndTheCopyIsHarmless (J09). Both carried a
220+
// `NOLINT - deliberate: pins today's implicit copy` marker.
221+
//
222+
// A .NET JsonNode is a REFERENCE type, so there was never an object copy to translate --
223+
// these four members were a C++ artefact, and both were wrong for a parented node: the copy
224+
// shared the original's children, each still reporting the ORIGINAL as its parent, and
225+
// assignment SLICED, rewriting parent_ on a node still stored in a container.
226+
static_assert(!std::is_copy_constructible_v<JsonArray>);
227+
static_assert(!std::is_copy_assignable_v<JsonArray>);
228+
static_assert(!std::is_move_constructible_v<JsonArray>);
229+
static_assert(!std::is_move_assignable_v<JsonArray>);
230+
static_assert(!std::is_copy_constructible_v<JsonObject>);
231+
static_assert(!std::is_copy_constructible_v<JsonNode>);
232+
233+
// XObject already deleted all four, so this ended an asymmetry inside the port. That is NOT
234+
// asserted here: modules/text-json does not depend on modules/xml-linq, and adding the edge
235+
// for a test convenience is what the module-boundary rule exists to stop -- the same rule that
236+
// blocked #1997's A-2. XLinqLifetimeTests owns the XObject half.
237+
238+
// DeepClone is the replacement, and it does what the implicit copy did NOT: the clone's
239+
// children are its own, and they name the CLONE as their parent.
198240
JsonArray original;
199241
original.Add(str("v"));
200242
auto child = original[0];
201-
{
202-
JsonArray aliasing = original; // NOLINT - deliberate: pins today's implicit copy
203-
ASSERT_EQ(aliasing.getCountProperty(), 1);
204-
ASSERT_EQ(child->getParentProperty(), &original);
205-
}
206-
EXPECT_EQ(child->getParentProperty(), &original);
207-
EXPECT_EQ(child->getRootProperty(), &original);
208-
}
209-
210-
// The reverse order: the original dies first (detaching the child), then the aliasing copy
211-
// dies. The copy must find a link that no longer names it and do nothing - in particular it
212-
// must not re-clear, re-read, or otherwise touch an already detached child.
213-
TEST(JsonNodeLifetimeTests, OriginalDestroyedBeforeItsCopy_DetachesOnceAndTheCopyIsHarmless) {
214-
std::shared_ptr<JsonNode> child;
215-
{
216-
JsonArray aliasing;
217-
{
218-
JsonArray original;
219-
original.Add(str("v"));
220-
child = original[0];
221-
aliasing = original; // NOLINT - deliberate: pins today's implicit copy-assign
222-
}
223-
EXPECT_EQ(child->getParentProperty(), nullptr);
224-
}
225-
EXPECT_EQ(child->getParentProperty(), nullptr);
243+
const auto clone = original.DeepClone();
244+
auto* asArray = dynamic_cast<JsonArray*>(clone.get());
245+
ASSERT_NE(asArray, nullptr);
246+
ASSERT_EQ(asArray->getCountProperty(), 1);
247+
EXPECT_NE((*asArray)[0].get(), child.get()) << "a deep clone shares no child with the original";
248+
EXPECT_EQ((*asArray)[0]->getParentProperty(), asArray)
249+
<< "and the clone's children name the CLONE -- which is exactly what the implicit copy "
250+
"got wrong";
251+
EXPECT_EQ(child->getParentProperty(), &original) << "the original is untouched";
226252
}
227253

228254
// --- Structural mutation before destruction ----------------------------------------------

0 commit comments

Comments
 (0)