@redpanda-data/docs-extensions-and-macros 5.45.0 → 5.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -59,6 +59,10 @@ const PRODUCT_ATTR_ALLOWLIST = Object.freeze([])
59
59
  */
60
60
  const KEEP_BY_DESIGN_FIELDS = Object.freeze([
61
61
  'related_topics',
62
+ 'see_also',
63
+ 'admonitions',
64
+ 'links',
65
+ 'includes',
62
66
  'category',
63
67
  'config_scope',
64
68
  'version',
@@ -89,7 +93,59 @@ const META_FIELDS = Object.freeze(['upstream_ref', '_comment'])
89
93
  */
90
94
  function normalizeText (text) {
91
95
  if (typeof text !== 'string') return ''
92
- return text.replace(/\s+/g, ' ').trim()
96
+ // Horizontal whitespace collapses; newlines do not. A blank line is a
97
+ // paragraph break and a newline before a list marker is a list-item
98
+ // boundary, and both change what AsciiDoc renders -- the C++ source is one
99
+ // run-on sentence with no newlines at all, so treating \n the same as a
100
+ // plain space made an override that turns that wording into a real
101
+ // bulleted list or separate paragraphs compare byte-equal to source once
102
+ // "normalized". That classified it REDUNDANT, and the retirement step
103
+ // deletes an override wholesale, replacing a rendered list with a
104
+ // run-on paragraph carrying literal `*` characters. Verified against
105
+ // fetch_read_strategy in the live corpus: 2 rendered list items with the
106
+ // override, 0 (plus two stray asterisks) once "equal to source" deleted
107
+ // it.
108
+ return text
109
+ .split('\n')
110
+ .map((line) => line.replace(/[ \t]+/g, ' ').trim())
111
+ .join('\n')
112
+ .replace(/\n{3,}/g, '\n\n')
113
+ .trim()
114
+ }
115
+
116
+ /**
117
+ * Separate a description's unconditional prose from its audience-scoped
118
+ * paragraphs.
119
+ *
120
+ * A description is either a plain string or an array of paragraphs, each
121
+ * optionally prefixed `cloud-only:` / `self-managed-only:`. Only the
122
+ * unconditional paragraphs are comparable to the source string: a paragraph
123
+ * written for one docs build is docs-only by definition and must never reach an
124
+ * upstream candidate, because the C++ doc string has no audience.
125
+ *
126
+ * The string form is returned unchanged, so every existing classification is
127
+ * byte-for-byte what it was before the array form existed.
128
+ *
129
+ * @param {string|Array<string>} description - Override description, either form.
130
+ * @returns {{prose: string, scoped: number, scopedParagraphs: string[]}} The
131
+ * unconditional prose, how many paragraphs were scoped away, and their text.
132
+ * The text is returned so a row can show a reviewer what it is holding back
133
+ * rather than dropping it silently.
134
+ */
135
+ function unconditionalProse (description) {
136
+ if (typeof description === 'string') return { prose: description, scoped: 0, scopedParagraphs: [] }
137
+ if (!Array.isArray(description)) return { prose: '', scoped: 0, scopedParagraphs: [] }
138
+ const kept = []
139
+ const scopedParagraphs = []
140
+ for (const paragraph of description) {
141
+ if (typeof paragraph !== 'string' || !paragraph.trim()) continue
142
+ if (/^\s*(?:cloud-only|self-managed-only):/.test(paragraph)) {
143
+ scopedParagraphs.push(paragraph.trim())
144
+ continue
145
+ }
146
+ kept.push(paragraph.trim())
147
+ }
148
+ return { prose: kept.join('\n\n'), scoped: scopedParagraphs.length, scopedParagraphs }
93
149
  }
94
150
 
95
151
  // Docs-only markup detectors. `<<anchor>>` cross-references are included
@@ -115,6 +171,12 @@ const MARKUP_PATTERNS = Object.freeze([
115
171
  { id: 'audience-prefix', re: /\b(?:self-managed-only|cloud-only):/ }
116
172
  ])
117
173
 
174
+ // Pulled out of MARKUP_PATTERNS by id, rather than re-typed, so the two
175
+ // checks that care about audience-scoping specifically -- the guard below,
176
+ // and MARKUP_PATTERNS' own detection -- cannot silently drift apart.
177
+ const AUDIENCE_PREFIX_RX = MARKUP_PATTERNS.find((p) => p.id === 'audience-prefix').re
178
+ const CONDITIONAL_RX = MARKUP_PATTERNS.find((p) => p.id === 'conditional').re
179
+
118
180
  /**
119
181
  * Find unescaped AsciiDoc attribute references (`{attr}`) that are not on
120
182
  * the product-attr allowlist. Escaped literals (`\{attr}`) do not count.
@@ -264,7 +326,7 @@ function deepEqual (a, b) {
264
326
  */
265
327
  function row (fields) {
266
328
  const base = { name: fields.name, field: fields.field, class: fields.class }
267
- for (const key of ['upstream_candidate_text', 'upstream_ref', 'content_hash', 'source_file', 'source_line', 'note']) {
329
+ for (const key of ['upstream_candidate_text', 'audience_scoped_text', 'upstream_ref', 'content_hash', 'source_file', 'source_line', 'note']) {
268
330
  if (fields[key] !== undefined) base[key] = fields[key]
269
331
  }
270
332
  return base
@@ -281,7 +343,15 @@ function row (fields) {
281
343
  */
282
344
  function classifyDescription (name, override, sourceProp, opts = {}) {
283
345
  const attrAllowlist = opts.attrAllowlist || PRODUCT_ATTR_ALLOWLIST
284
- const overrideText = override.description
346
+ // Audience-scoped paragraphs are filtered out here rather than inside
347
+ // stripDocsMarkup, which must keep stripping a prefix and retaining the
348
+ // content: for a see_also item the prefixed value IS the link, and dropping
349
+ // it there would silently empty every conditional related-topics entry.
350
+ const {
351
+ prose: overrideText,
352
+ scoped: scopedCount,
353
+ scopedParagraphs: scopedText
354
+ } = unconditionalProse(override.description)
285
355
  const common = {
286
356
  name,
287
357
  field: 'description',
@@ -290,6 +360,13 @@ function classifyDescription (name, override, sourceProp, opts = {}) {
290
360
  source_line: sourceProp && sourceProp.line_start !== undefined ? sourceProp.line_start : undefined
291
361
  }
292
362
  if (override.upstream_ref !== undefined) common.upstream_ref = override.upstream_ref
363
+ // Shown, never upstreamed. A scoped paragraph exists precisely so the other
364
+ // audience does not see it, and the C++ doc string is one string for every
365
+ // audience, so upstreaming it would both leak it to the wrong readers and
366
+ // duplicate it for the right ones (the override keeps its copy). Putting the
367
+ // text on the row lets a reviewer decide to upstream it and delete the
368
+ // paragraph in one deliberate change, instead of the audit deciding for them.
369
+ if (scopedText && scopedText.length > 0) common.audience_scoped_text = scopedText
293
370
 
294
371
  if (!sourceProp) {
295
372
  return row({
@@ -299,10 +376,43 @@ function classifyDescription (name, override, sourceProp, opts = {}) {
299
376
  })
300
377
  }
301
378
 
379
+ // A source property with no line_start has no description to replace. The
380
+ // topic extractor reports a name constant (topic_properties.h,
381
+ // handlers/topics/types.h) and fills description with an empty string, so a
382
+ // naive comparison reads "override differs from source" and proposes
383
+ // upstreaming prose into a file that has nowhere to put it. Measured against
384
+ // dev: 45 of 216 candidates, and not one of them in a .cc file, while every
385
+ // row the upstreaming workflow can actually edit carries a line.
386
+ //
387
+ // This is REVIEW rather than KEEP because the right answer is a judgement
388
+ // call: give the property a real doc string upstream, or accept that docs are
389
+ // the only place it is described. Never UPSTREAMABLE, which would hand the
390
+ // upstreaming workflow work it cannot do.
391
+ if (sourceProp.line_start === undefined) {
392
+ return row({
393
+ ...common,
394
+ class: CLASSES.REVIEW,
395
+ note:
396
+ 'Source defines this property\'s name but carries no description for it (' +
397
+ (sourceProp.defined_in || 'unknown file') +
398
+ '), so there is nothing to upstream into. Decide whether the source should gain one.'
399
+ })
400
+ }
401
+
302
402
  const sourceText = normalizeText(sourceProp.description)
303
403
  const overrideNormalized = normalizeText(overrideText)
304
404
 
305
405
  if (overrideNormalized === sourceText) {
406
+ if (scopedCount > 0) {
407
+ // Retiring this would delete the audience-scoped paragraphs with it,
408
+ // because `description` replaces wholesale rather than merging. The
409
+ // redundant prose cannot be dropped on its own.
410
+ return row({
411
+ ...common,
412
+ class: CLASSES.KEEP,
413
+ note: `Unconditional prose already matches source, but ${scopedCount} audience-scoped paragraph(s) are docs-only and would be lost with it. Nothing to upstream.`
414
+ })
415
+ }
306
416
  return row({
307
417
  ...common,
308
418
  class: CLASSES.REDUNDANT,
@@ -310,8 +420,55 @@ function classifyDescription (name, override, sourceProp, opts = {}) {
310
420
  })
311
421
  }
312
422
 
423
+ // A STRING description carrying a raw audience prefix or a hand-written
424
+ // ifdef/ifndef pair was never routed through the array form's
425
+ // paragraph-level scoping: unconditionalProse only strips a
426
+ // cloud-only:/self-managed-only: paragraph out of an ARRAY, and returns a
427
+ // string verbatim. Falling through to stripDocsMarkup below silently
428
+ // discards the audience information instead of refusing to guess at it: a
429
+ // raw "cloud-only:" prefix strips to a bare sentence with no trace it was
430
+ // ever Cloud-specific, and a hand-written ifdef/ifndef pair keeps BOTH
431
+ // branches' text concatenated as if they were one universal sentence.
432
+ // Either shape reaching upstream_candidate_text would write
433
+ // audience-specific or audience-merged prose into a C++ string every
434
+ // audience reads.
435
+ if (typeof override.description === 'string' &&
436
+ (AUDIENCE_PREFIX_RX.test(override.description) || CONDITIONAL_RX.test(override.description))) {
437
+ return row({
438
+ ...common,
439
+ class: CLASSES.REVIEW,
440
+ note: 'This description is a plain string but contains an audience-scoped prefix or a hand-written conditional. Rewrite it as the array form (one entry per paragraph, prefixed cloud-only:/self-managed-only: where a paragraph is scoped) so the scoped text is never mistaken for universal prose.'
441
+ })
442
+ }
443
+
444
+ // A description made ENTIRELY of audience-scoped paragraphs has no
445
+ // unconditional prose, so overrideText is the empty string. Without this
446
+ // guard it fell through to the SPLIT branch below and published an empty
447
+ // upstream_candidate_text -- which the upstream workflow selects, because
448
+ // the SPLIT note is part of its filter, and would then hand the model an
449
+ // empty string to write into an engineering doc string, blanking it.
450
+ if (overrideText.length === 0) {
451
+ return row({
452
+ ...common,
453
+ class: CLASSES.REVIEW,
454
+ note: scopedCount > 0
455
+ ? `Every paragraph of this description is audience-scoped (${scopedCount}), so there is no unconditional prose to upstream. Decide whether the scoped text belongs in source at all, or whether the property needs unconditional prose written for it.`
456
+ : 'The override sets an empty description. Decide whether it should be removed or given prose.'
457
+ })
458
+ }
459
+
313
460
  const markupKinds = detectDocsMarkup(overrideText, attrAllowlist)
314
461
  if (markupKinds.length === 0) {
462
+ if (scopedCount > 0) {
463
+ // The prose is upstreamable but the override still has to stay, to carry
464
+ // the scoped paragraphs -- which is exactly the SPLIT case.
465
+ return row({
466
+ ...common,
467
+ class: CLASSES.KEEP_UNTIL_UPSTREAMED,
468
+ upstream_candidate_text: overrideText,
469
+ note: `SPLIT: unconditional prose differs from source and is markup-free, but ${scopedCount} audience-scoped paragraph(s) must stay in the override. Keep it until the prose ships.`
470
+ })
471
+ }
315
472
  return row({
316
473
  ...common,
317
474
  class: CLASSES.UPSTREAMABLE,
@@ -321,6 +478,20 @@ function classifyDescription (name, override, sourceProp, opts = {}) {
321
478
  }
322
479
 
323
480
  const stripped = stripDocsMarkup(overrideText)
481
+ // An include-only (or otherwise entirely-markup) description strips to
482
+ // nothing. Without this the SPLIT branch below would publish an empty
483
+ // upstream_candidate_text, which the upstream workflow selects (its
484
+ // filter matches on class and a SPLIT note, not on the text being
485
+ // non-empty) and would hand the model an empty string to write into a
486
+ // source description -- blanking it. The same failure the
487
+ // all-scoped-paragraphs guard above exists to prevent for the array form.
488
+ if (stripped.trim().length === 0) {
489
+ return row({
490
+ ...common,
491
+ class: CLASSES.REVIEW,
492
+ note: `Stripping docs-only markup (${markupKinds.join(', ')}) leaves no prose at all, so there is nothing to upstream. Decide whether the source needs a real description written for it.`
493
+ })
494
+ }
324
495
  if (normalizeText(stripped) === sourceText) {
325
496
  return row({
326
497
  ...common,
@@ -530,6 +701,7 @@ function summarize (manifest) {
530
701
  module.exports = {
531
702
  CLASSES,
532
703
  PRODUCT_ATTR_ALLOWLIST,
704
+ unconditionalProse,
533
705
  KEEP_BY_DESIGN_FIELDS,
534
706
  TYPO_KEYS,
535
707
  normalizeText,
@@ -156,11 +156,13 @@ Create a `property-overrides.json` file to customize extracted documentation:
156
156
 
157
157
  *Override fields:*
158
158
 
159
- * `description` - Replace auto-extracted description with custom text
159
+ * `description` - Replace the auto-extracted description. Either a string, or an array of paragraphs when one of them belongs to a single docs build: an array entry may carry a `cloud-only:` / `self-managed-only:` prefix and the generator wraps it in the matching conditional. Use the array form rather than writing `ifdef::env-cloud[]` into the string. See <<Keeping a description upstreamable>>.
160
+ * `links` - Inline links, keyed by the literal text to find in the prose and valued by the target. See <<Adding links without forking the source prose>>.
161
+ * `includes` - Shared partials to pull into the property's entry, as Antora resource IDs. See <<Pulling in a shared partial>>.
160
162
  * `example` - Add usage example in AsciiDoc format
161
163
  * `example_file` - Load example from external file
162
164
  * `version` - Document when property was introduced
163
- * `see_also` - Array of related links. Each item is either a plain string (shown in every build) or an object `{content, cloud_only}` / `{content, self_hosted_only}` (shown only in that build, wrapped with `ifdef::env-cloud[]`/`ifndef::env-cloud[]` at generation time). Setting both `cloud_only` and `self_hosted_only` on the same item is rejected by the schema — it would wrap the item in both directives at once, so it renders in neither build.
165
+ * `see_also` - Array of related links. Each item is either a plain string (shown in every build) or an object `{content, cloud_only}` / `{content, self_managed_only}` (shown only in that build, wrapped with `ifdef::env-cloud[]`/`ifndef::env-cloud[]` at generation time). Setting both `cloud_only` and `self_managed_only` on the same item is rejected by the schema — it would wrap the item in both directives at once, so it renders in neither build.
164
166
  * `related_topics` - Deprecated predecessor to `see_also`. An array of plain strings, optionally prefixed `cloud-only:` / `self-managed-only:` for the same conditional behavior — that prefix is free text with no validation, which is why `see_also` replaced it. Existing `related_topics` entries keep working; new entries should use `see_also`.
165
167
 
166
168
  Validate an overrides file against its schema (`docs-data/property-overrides.schema.json`) before generating docs:
@@ -170,6 +172,94 @@ Validate an overrides file against its schema (`docs-data/property-overrides.sch
170
172
  npx doc-tools validate property-overrides --overrides docs-data/property-overrides.json
171
173
  ----
172
174
 
175
+ ==== Adding links without forking the source prose
176
+
177
+ A property description is plain prose, and the links in it are declared separately:
178
+
179
+ [,json]
180
+ ----
181
+ {
182
+ "properties": {
183
+ "advertised_rpc_api": {
184
+ "description": "Address of RPC endpoint published to other cluster members. If not set, the `rpc_server` broker property is used.",
185
+ "links": {
186
+ "`rpc_server`": "#rpc_server"
187
+ }
188
+ },
189
+ "recovery_mode_enabled": {
190
+ "description": "If `true`, start Redpanda in recovery mode, where user partitions are not loaded.",
191
+ "links": {
192
+ "recovery mode": "self-managed-only: xref:manage:recovery-mode.adoc"
193
+ }
194
+ }
195
+ }
196
+ }
197
+ ----
198
+
199
+ Each key is the literal text to find in the prose, backticks included, as written. Only the first occurrence is linked, so a property named four times links once. The longest key wins, so `+`cloud_storage_enabled`+` cannot be stolen by `+`cloud_storage`+`.
200
+
201
+ Three target forms:
202
+
203
+ `#<property_name>`:: Emits a `prop` macro. The macro resolves the reference page itself and renders plain code in a build that does not publish the target, so a property link needs no conditional and cannot dangle. A name that is not a known property is refused with a warning rather than emitted, because an unverifiable `prop` call publishes as literal macro text.
204
+ `xref:<target>`:: Emits a literal xref with the key as its link text. Property-page targets are canonicalized to `xref:reference:properties/<page>.adoc`.
205
+ `glossterm`:: Wraps the key in a glossary tooltip. The macro takes the term as its target and looks the definition up in the glossary, so the key is the term. Use `glossterm:<Term>` when the prose says something other than the term's own name.
206
+
207
+ A target may carry a `cloud-only:` / `self-managed-only:` prefix, in which case the containing paragraph is emitted twice: linked in that branch, plain in the other. AsciiDoc preprocessor directives have to start a line, so they cannot bracket an inline span; the paragraph is the smallest thing that can be wrapped. A scoped link that matches inside a delimited block is refused with a warning, since duplicating that paragraph would split the block across both branches.
208
+
209
+ The generator reports every key that matched no text. A link key that matches nothing is silent link rot: the override looks maintained and the page has no link.
210
+
211
+ Links are applied at generation time, so they reach the rendered partials but not the published `redpanda-properties-<tag>.json`. That file is the machine-readable data surface, and its descriptions drive hover tooltips, where an inline link is not wanted.
212
+
213
+ ==== Pulling in a shared partial
214
+
215
+ Some entries end with a shared block: the "internal use only" warning, or the HTTP Proxy ephemeral-credentials breaking-change notice. Those are block-level docs structure, so they go in `includes` rather than into the description string:
216
+
217
+ [,json]
218
+ ----
219
+ {
220
+ "properties": {
221
+ "data_transforms_read_buffer_memory_percentage": {
222
+ "description": "The percentage of available memory in the transform subsystem to use for read buffers.",
223
+ "includes": ["reference:partial$internal-use-property.adoc"]
224
+ }
225
+ }
226
+ }
227
+ ----
228
+
229
+ Each entry is an Antora resource ID, rendered as an `include::` directive immediately after the description, which is where these were previously written by hand. An entry may carry a `cloud-only:` / `self-managed-only:` prefix, and its own `[attrs]` if it needs them; without brackets the generator adds the empty pair the directive requires. A value that does not look like a resource ID is emitted as given, with a warning, because an include with no family segment resolves to nothing at build time and does it silently.
230
+
231
+ ==== Keeping a description upstreamable
232
+
233
+ `links`, `includes`, `see_also` and `admonitions` all exist so that a description can carry docs-site structure without mixing markup into the sentence. That is not a style preference. It is what makes the override retirable. `doc-tools overrides audit` compares an override's prose against the C++ doc string to decide whether it is redundant, upstreamable, or has to stay. Markup in the sentence makes the two incomparable, so an enriched description could never be retired or sent upstream. A hand-written `ifdef::env-cloud[]` does the same thing, which is why the array form exists.
234
+
235
+ An audience-scoped paragraph is docs-only by definition, because the source string has no audience, so the audit compares only the unconditional paragraphs and never puts scoped text into an upstream candidate.
236
+
237
+ ==== Conditional admonitions
238
+
239
+ An `admonitions` entry takes the same `cloud_only` / `self_managed_only` booleans as a `see_also` object, wrapping the whole block:
240
+
241
+ [,json]
242
+ ----
243
+ {
244
+ "admonitions": [
245
+ { "type": "note", "text": "Applies to every build." },
246
+ { "type": "tip", "text": "Cloud readers only.", "cloud_only": true }
247
+ ]
248
+ }
249
+ ----
250
+
251
+ ==== One name per concept
252
+
253
+ The audience flags are `cloud-only:` / `self-managed-only:` as a string prefix, and `cloud_only` / `self_managed_only` as object booleans. The hyphens and underscores differ because one is prose and the other is a JSON key in a snake_case file, but the *word* is the same in both: self-managed, matching the product name.
254
+
255
+ `self_hosted_only` was the old spelling of that boolean, so the same concept went by two different words depending on how you wrote it. It still parses, and normalizes to `self_managed_only`, so nothing emits it. `docs-data/rpk-overrides.json` still spells its equivalent `selfHostedOnly` (camelCase, to match that file's own key style) and carries 34 live entries, so it needs its own rename.
256
+
257
+ ==== Conditionals and blank lines
258
+
259
+ Every conditional the generator emits has a blank line before it and after its `endif::[]`. This is load-bearing. A conditional is resolved by the preprocessor, so one glued to neighbouring prose leaves those lines adjacent and Asciidoctor merges them into a single paragraph. Worse, when the conditional wraps a delimited block it merges them in only one branch, so a self-managed preview can look correct while Cloud loses its paragraph breaks. Nothing errors and no text goes missing, so only a structural assertion on rendered HTML catches it.
260
+
261
+ The generator warns about any conditional it finds glued, including one an override still writes by hand. `__tests__/tools/property-extractor/conditional-rendering.test.js` pins the measured behaviour and proves both detectors fire; `property-corpus-rendering.test.js` runs the whole live overrides corpus through Asciidoctor in both builds.
262
+
173
263
  This catches malformed `see_also` items and unrecognized override keys — including a typo that would otherwise be silently dropped, since applying an override to an *existing* property has no catch-all pass-through (only overrides that create a new property from scratch do).
174
264
 
175
265
  === Understanding property changes
@@ -1130,6 +1220,55 @@ npx jest __tests__/tools/topic_property_extractor.test.js
1130
1220
  npm test -- --coverage
1131
1221
  ----
1132
1222
 
1223
+ ==== The live overrides corpus
1224
+
1225
+ `__tests__/tools/property-extractor/property-corpus-rendering.test.js` runs the real
1226
+ overrides file, not a fixture, because a fixture passes while the live 438-entry corpus
1227
+ breaks. Two files feed it, both mirrors of the `docs` repo:
1228
+
1229
+ `__tests__/docs-data/property-overrides.json`:: A byte copy of `docs-data/property-overrides.json`.
1230
+ `__tests__/docs-data/property-snapshot.json`:: Every extracted property, reduced to the
1231
+ fields the rendering and link layers read. `cloud_supported` is the point: it is what
1232
+ makes the "no Cloud link to a property Cloud does not publish" assertion real rather
1233
+ than assumed. Descriptions are deliberately absent, because the corpus test drives prose from
1234
+ the overrides file, and a full copy would be 695 KB of text that rots.
1235
+
1236
+ Keep the overrides copy a faithful mirror. It previously doubled as
1237
+ `property-docs-overrides.test.js`'s fixture, someone hand-edited an `admin` entry into
1238
+ it that never existed in the `docs` repo, and refreshing it would then have broken that
1239
+ test, so it sat three months and 114 entries behind. That test now has its own
1240
+ `property-overrides-admin-fixture.json`; put new fixture data there or in the test
1241
+ itself, never in the mirror.
1242
+
1243
+ Refresh both from a `docs` checkout:
1244
+
1245
+ [,bash]
1246
+ ----
1247
+ DOCS=../docs # path to your docs repo checkout
1248
+
1249
+ git -C "$DOCS" show origin/main:docs-data/property-overrides.json \
1250
+ > __tests__/docs-data/property-overrides.json
1251
+
1252
+ git -C "$DOCS" show origin/main:modules/reference/attachments/redpanda-properties-v26.2.2.json \
1253
+ | node -e '
1254
+ const fs = require("fs");
1255
+ const src = JSON.parse(fs.readFileSync(0, "utf8"));
1256
+ const keep = ["name","config_scope","type","cloud_supported","cloud_editable",
1257
+ "cloud_readonly","cloud_byoc_only","is_deprecated","nullable"];
1258
+ const properties = {};
1259
+ for (const [k, v] of Object.entries(src.properties || {})) {
1260
+ properties[k] = Object.fromEntries(keep.filter((f) => f in v).map((f) => [f, v[f]]));
1261
+ }
1262
+ const out = JSON.parse(fs.readFileSync("__tests__/docs-data/property-snapshot.json", "utf8"));
1263
+ out.properties = properties;
1264
+ fs.writeFileSync("__tests__/docs-data/property-snapshot.json", JSON.stringify(out, null, 1));
1265
+ '
1266
+ ----
1267
+
1268
+ The `docs` repo fails its own CI when the overrides mirror drifts, following the
1269
+ `sync-schemas --check` pattern; this repo's CI has no `docs` checkout, so the check
1270
+ cannot live here.
1271
+
1133
1272
  === Making changes
1134
1273
 
1135
1274
  1. **Before you start:**
@@ -225,8 +225,18 @@ function compareProperties(oldData, newData, oldVersion, newVersion) {
225
225
  });
226
226
  }
227
227
 
228
- // Check for description changes
229
- if (oldProp.description !== newProp.description) {
228
+ // Check for description changes. deepEqual, not !==: an array-form
229
+ // description (audience-scoped paragraphs) is a fresh array on both
230
+ // sides of every comparison, since each side comes from parsing its
231
+ // own attachment JSON, so reference inequality reported EVERY
232
+ // property carrying one as changed, forever, with identical old and
233
+ // new text -- permanent noise in the one section of the release PR a
234
+ // reviewer reads to spot real engineering wording changes. In
235
+ // practice this attachment is flattened to a string before it ships
236
+ // (see generate-handlebars-docs.js), so deepEqual and !== agree on a
237
+ // real release comparison; this guards the case where compare runs
238
+ // against a pre-flatten JSON, or the flatten step is ever skipped.
239
+ if (!deepEqual(oldProp.description, newProp.description)) {
230
240
  report.changedDescriptions.push({
231
241
  name,
232
242
  oldDescription: oldProp.description || 'No description',