@redpanda-data/docs-extensions-and-macros 5.46.0 → 5.47.1

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.
package/bin/doc-tools.js CHANGED
@@ -558,7 +558,15 @@ programCli
558
558
  * copy is a strict superset of the destination; otherwise it reports which
559
559
  * keys only the destination has and leaves the file alone (status
560
560
  * 'diverged') unless --force is passed. --check never writes, for a CI
561
- * gate.
561
+ * gate. A schema is also only synced into a repo that is plausibly its
562
+ * home: one that already has the schema, or that has the *.json the schema
563
+ * documents. Anything else is reported as 'not for this repo' and skipped,
564
+ * and never counts as drift. kapa-source-groups.json is the reason: it is
565
+ * generated into this package and read from node_modules by an Antora
566
+ * extension, so it never lives in a content repo at all, and planting its
567
+ * schema in redpanda-data/docs would leave a file describing data that repo
568
+ * will never have, with --check reporting its absence as drift on every run
569
+ * afterwards.
562
570
  * @example
563
571
  * # Sync into ./docs-data (writes any missing or out-of-date schema)
564
572
  * npx doc-tools sync-schemas
@@ -604,8 +612,13 @@ programCli
604
612
  created: '+ created',
605
613
  updated: '↻ updated',
606
614
  diverged: options.force ? '↻ updated (forced)' : '⚠ diverged, left alone',
615
+ 'not-applicable': '- not for this repo',
607
616
  }[status]
608
617
  console.log(` ${label} ${name}`)
618
+ if (status === 'not-applicable') {
619
+ // Said out loud, because a silently missing schema looks like a bug.
620
+ console.log(` no ${name.replace(/\.schema\.json$/, '.json')} here, so this schema has no data file to document`)
621
+ }
609
622
  if (status === 'diverged' && !options.force) {
610
623
  hasUnresolvedDivergence = true
611
624
  for (const p of destOnlyPaths) console.log(` only in the destination: ${p}`)
@@ -2630,7 +2643,7 @@ validation
2630
2643
  * Validates docs-data/property-overrides.json against its JSON Schema:
2631
2644
  * unknown keys (a typo that would otherwise be silently dropped by the
2632
2645
  * extractor), and the see_also shape (a plain string, or an object naming
2633
- * exactly one of cloud_only/self_hosted_only).
2646
+ * exactly one of cloud_only/self_managed_only).
2634
2647
  *
2635
2648
  * @why
2636
2649
  * property-overrides.json has no catch-all pass-through when an override
@@ -23,6 +23,16 @@ const { isDeepStrictEqual } = require('util')
23
23
  * package's copy is a superset of it (every object key the destination has,
24
24
  * the source also has) — otherwise it reports the destination-only keys and
25
25
  * refuses, same as `check` mode, unless the caller passes `force: true`.
26
+ *
27
+ * Not every schema belongs in every content repo. kapa-source-groups.json is
28
+ * generated into THIS package and read from node_modules by an Antora
29
+ * extension, so it never lives in a content repo at all; copying its schema
30
+ * into redpanda-data/docs leaves a file describing data that repo will never
31
+ * have, and `check` mode then reports its absence as drift forever. So a
32
+ * schema is only synced into a destination that is plausibly its home: the
33
+ * destination already has the schema, or it has the *.json the schema
34
+ * documents. Anything else is reported as 'not-applicable' and does not count
35
+ * as drift.
26
36
  */
27
37
 
28
38
  const PACKAGE_SCHEMA_DIR = path.resolve(__dirname, '..', 'docs-data')
@@ -42,16 +52,27 @@ function listPackageSchemas () {
42
52
  /**
43
53
  * Find object keys present in `dest` but absent from the same path in
44
54
  * `source` — the shape of "the destination knows something the source
45
- * doesn't". Arrays are compared as opaque leaves (JSON Schema's own
46
- * structure keeps named, distinguishable content in objects — `properties`,
47
- * `$defs` — not in arrays like `required`/`enum`, so this is where a
48
- * destination-only capability would actually show up).
55
+ * doesn't". Most arrays are compared as opaque leaves: `required`/`enum`
56
+ * hold literal values, not named content, so there is nothing inside one to
57
+ * be "destination-only" in the sense this function cares about.
58
+ *
59
+ * `oneOf`/`anyOf`/`allOf` are the exception, and are recursed into by
60
+ * matching index. Those are JSON Schema's own combinators, and their array
61
+ * ELEMENTS are full schema objects that can carry real, named content --
62
+ * `see_also.items.oneOf[1].properties.cloud_only` is exactly the
63
+ * distinguishable content this function otherwise only looks for inside a
64
+ * plain object. Treating them as opaque like `required`/`enum` missed
65
+ * every audience flag living inside see_also's object variant, and would
66
+ * let a write-mode sync silently delete a destination-only flag added
67
+ * there, with no warning and no --force.
49
68
  *
50
69
  * @param {*} source
51
70
  * @param {*} dest
52
71
  * @param {string} [pathPrefix]
53
72
  * @returns {string[]} Dotted paths that exist in dest but not source.
54
73
  */
74
+ const SCHEMA_COMBINATOR_KEYS = new Set(['oneOf', 'anyOf', 'allOf'])
75
+
55
76
  function findDestOnlyPaths (source, dest, pathPrefix = '') {
56
77
  if (
57
78
  dest === null || typeof dest !== 'object' || Array.isArray(dest) ||
@@ -67,11 +88,35 @@ function findDestOnlyPaths (source, dest, pathPrefix = '') {
67
88
  onlyInDest.push(keyPath)
68
89
  continue
69
90
  }
70
- onlyInDest.push(...findDestOnlyPaths(source[key], destValue, keyPath))
91
+ const sourceValue = source[key]
92
+ if (SCHEMA_COMBINATOR_KEYS.has(key) && Array.isArray(destValue) && Array.isArray(sourceValue)) {
93
+ const length = Math.max(destValue.length, sourceValue.length)
94
+ for (let i = 0; i < length; i++) {
95
+ const itemPath = `${keyPath}[${i}]`
96
+ if (i >= sourceValue.length) {
97
+ onlyInDest.push(itemPath)
98
+ continue
99
+ }
100
+ onlyInDest.push(...findDestOnlyPaths(sourceValue[i], destValue[i], itemPath))
101
+ }
102
+ continue
103
+ }
104
+ onlyInDest.push(...findDestOnlyPaths(sourceValue, destValue, keyPath))
71
105
  }
72
106
  return onlyInDest
73
107
  }
74
108
 
109
+ /**
110
+ * The docs-data file a schema documents: property-overrides.schema.json
111
+ * documents property-overrides.json.
112
+ *
113
+ * @param {string} schemaName - Bare schema filename.
114
+ * @returns {string} The data filename it documents.
115
+ */
116
+ function dataFileFor (schemaName) {
117
+ return schemaName.replace(/\.schema\.json$/, '.json')
118
+ }
119
+
75
120
  /**
76
121
  * Compare this package's schemas against the copies in a destination
77
122
  * directory (a content repo's docs-data/), and optionally write updates.
@@ -88,15 +133,18 @@ function findDestOnlyPaths (source, dest, pathPrefix = '') {
88
133
  * destDir: string,
89
134
  * results: Array<{
90
135
  * name: string,
91
- * status: ('created'|'updated'|'unchanged'|'diverged'),
136
+ * status: ('created'|'updated'|'unchanged'|'diverged'|'not-applicable'),
92
137
  * sourcePath: string,
93
138
  * destPath: string,
94
139
  * destOnlyPaths?: string[]
95
140
  * }>,
96
141
  * drift: boolean
97
- * }} drift is true when any schema is missing, differs, or has diverged.
98
- * status 'diverged' means the destination has content this package's copy
99
- * doesn't — nothing was written for that file unless `force` was set.
142
+ * }} drift is true when any applicable schema is missing, differs, or has
143
+ * diverged. status 'diverged' means the destination has content this
144
+ * package's copy doesn't — nothing was written for that file unless `force`
145
+ * was set. status 'not-applicable' means neither the schema nor the *.json it
146
+ * documents is in the destination, so that schema does not belong there;
147
+ * nothing is written and it never counts as drift.
100
148
  */
101
149
  function syncSchemas ({ destDir, check = false, force = false } = {}) {
102
150
  const resolvedDest = path.resolve(destDir || 'docs-data')
@@ -110,7 +158,13 @@ function syncSchemas ({ destDir, check = false, force = false } = {}) {
110
158
 
111
159
  let status
112
160
  let destOnlyPaths
113
- if (!destExists) {
161
+ if (!destExists && !fs.existsSync(path.join(resolvedDest, dataFileFor(name)))) {
162
+ // Neither the schema nor the data file it documents is here, so this
163
+ // repo is not where that schema lives. Writing it would plant a file
164
+ // describing data this repo never has, and `check` would then call its
165
+ // absence drift on every run.
166
+ status = 'not-applicable'
167
+ } else if (!destExists) {
114
168
  status = 'created'
115
169
  } else {
116
170
  // Compare parsed content, not raw text — a destination reformatted by a
@@ -136,9 +190,9 @@ function syncSchemas ({ destDir, check = false, force = false } = {}) {
136
190
  return { name, status, sourcePath, destPath, ...(destOnlyPaths ? { destOnlyPaths } : {}) }
137
191
  })
138
192
 
139
- const drift = results.some((r) => r.status !== 'unchanged')
193
+ const drift = results.some((r) => r.status !== 'unchanged' && r.status !== 'not-applicable')
140
194
 
141
195
  return { destDir: resolvedDest, results, drift }
142
196
  }
143
197
 
144
- module.exports = { listPackageSchemas, syncSchemas, findDestOnlyPaths, PACKAGE_SCHEMA_DIR }
198
+ module.exports = { listPackageSchemas, syncSchemas, findDestOnlyPaths, dataFileFor, PACKAGE_SCHEMA_DIR }
@@ -7,8 +7,10 @@
7
7
  "properties": {
8
8
  "properties": {
9
9
  "type": "object",
10
- "description": "Keyed by property key or name (see property_extractor.py's name-fallback lookup). An override key that matches neither creates a phantom stub property — see property_extractor.py's report_phantom_stubs().",
11
- "additionalProperties": { "$ref": "#/$defs/propertyOverride" }
10
+ "description": "Keyed by property key or name (see property_extractor.py's name-fallback lookup). An override key that matches neither creates a phantom stub property \u2014 see property_extractor.py's report_phantom_stubs().",
11
+ "additionalProperties": {
12
+ "$ref": "#/$defs/propertyOverride"
13
+ }
12
14
  }
13
15
  },
14
16
  "additionalProperties": false,
@@ -21,10 +23,40 @@
21
23
  "type": "string",
22
24
  "description": "Free-text note to whoever edits this entry next. Never read by the generator."
23
25
  },
24
- "description": { "type": "string" },
26
+ "description": {
27
+ "description": "Property description. A plain 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 ifdef::env-cloud[] conditional. Prefer the array form over writing the conditional into the string, because the overrides audit can only compare prose it can separate from markup, so a hand-written conditional pins the override forever.",
28
+ "oneOf": [
29
+ {
30
+ "type": "string"
31
+ },
32
+ {
33
+ "type": "array",
34
+ "minItems": 1,
35
+ "items": {
36
+ "type": "string"
37
+ }
38
+ }
39
+ ]
40
+ },
41
+ "links": {
42
+ "type": "object",
43
+ "description": "Inline links, keyed by the literal text to find in the prose (backticks included, as written) and valued by the target. Only the first occurrence of a key is linked, and the longest key wins. Two target forms: '#<property_name>' emits a prop macro, which resolves the reference page itself and renders plain code in a build that does not publish the target, so it needs no conditional and cannot dangle; 'xref:<target>' emits a literal xref with the key as its link text; 'glossterm' wraps the key in a glossary tooltip (use 'glossterm:<Term>' when the prose says something other than the term's own name). 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. Keeping links here rather than in the sentence is what lets the description stay prose the audit can compare against the source string.",
44
+ "additionalProperties": {
45
+ "type": "string",
46
+ "pattern": "^(?:(?:cloud-only|self-managed-only):\\s*)?(?:#[^\\s#]+|xref:[^\\s\\[\\]]+|glossterm(?::[^\\[\\]]+)?)$"
47
+ }
48
+ },
49
+ "includes": {
50
+ "description": "Shared partials pulled into this property's entry, as Antora resource IDs (for example reference:partial$internal-use-property.adoc). Rendered as include:: directives immediately after the description, which is where they used to be written by hand. Keeping them here rather than inside the description string is what lets the prose stay comparable to the source doc string. An entry may carry a 'cloud-only:' / 'self-managed-only:' prefix, and its own [attrs] if it needs them.",
51
+ "type": "array",
52
+ "minItems": 1,
53
+ "items": {
54
+ "type": "string"
55
+ }
56
+ },
25
57
  "upstream_ref": {
26
58
  "type": "string",
27
- "description": "The upstream issue or PR (against the engineering repo whose defective string this description patches, either the short form 'redpanda-data/streaming-enterprise#12345' or the full URL) that will make this description override redundant once merged and released. A description override is a stopgap, not a fix: set this so the override is born with a retirement path, and remove both the override and this field once the fix ships. Only meaningful alongside 'description' — has no effect on generation or rendering."
59
+ "description": "The upstream issue or PR (against the engineering repo whose defective string this description patches, either the short form 'redpanda-data/streaming-enterprise#12345' or the full URL) that will make this description override redundant once merged and released. A description override is a stopgap, not a fix: set this so the override is born with a retirement path, and remove both the override and this field once the fix ships. Only meaningful alongside 'description' \u2014 has no effect on generation or rendering."
28
60
  },
29
61
  "version": {
30
62
  "type": "string",
@@ -33,17 +65,30 @@
33
65
  "example": {
34
66
  "description": "Literal example content, or an example_file/example_yaml reference resolved relative to this overrides file."
35
67
  },
36
- "example_file": { "type": "string" },
37
- "example_yaml": { "type": "string" },
68
+ "example_file": {
69
+ "type": "string"
70
+ },
71
+ "example_yaml": {
72
+ "type": "string"
73
+ },
38
74
  "default": {
39
- "description": "Replaces the extracted default. Prefer leaving this unset — it silently pins a value the extractor would otherwise keep in sync with the C++ source on every release."
75
+ "description": "Replaces the extracted default. Prefer leaving this unset \u2014 it silently pins a value the extractor would otherwise keep in sync with the C++ source on every release."
76
+ },
77
+ "type": {
78
+ "type": "string"
40
79
  },
41
- "type": { "type": "string" },
42
80
  "config_scope": {
43
- "enum": ["cluster", "broker", "topic", null],
44
- "description": "Almost always inferred automatically from defined_in — see property_extractor.py's add_config_scope(). Only set this for an override-created property (defined_in: 'override'), which has no source file to infer from."
81
+ "enum": [
82
+ "cluster",
83
+ "broker",
84
+ "topic",
85
+ null
86
+ ],
87
+ "description": "Almost always inferred automatically from defined_in \u2014 see property_extractor.py's add_config_scope(). Only set this for an override-created property (defined_in: 'override'), which has no source file to infer from."
88
+ },
89
+ "exclude_from_docs": {
90
+ "type": "boolean"
45
91
  },
46
- "exclude_from_docs": { "type": "boolean" },
47
92
  "category": {
48
93
  "type": "string",
49
94
  "description": "Documentation grouping. Not inferable for cluster/broker properties; topic properties get a mechanical default from infer_category() that this can still override."
@@ -53,17 +98,23 @@
53
98
  "description": "Replaces the enum field to filter displayed values (e.g. hiding an internal-only mode).",
54
99
  "items": {}
55
100
  },
56
- "is_deprecated": { "type": "boolean" },
57
- "visibility": { "type": "string" },
101
+ "is_deprecated": {
102
+ "type": "boolean"
103
+ },
104
+ "visibility": {
105
+ "type": "string"
106
+ },
58
107
  "related_topics": {
59
108
  "type": "array",
60
109
  "deprecated": true,
61
- "description": "Deprecated — use see_also. A plain string, or one prefixed 'cloud-only:'/'self-managed-only:' for conditional display. The prefix is free text with no validation; still read for existing entries, normalized alongside see_also by seeAlsoView.js.",
62
- "items": { "type": "string" }
110
+ "description": "Deprecated \u2014 use see_also. A plain string, or one prefixed 'cloud-only:'/'self-managed-only:' for conditional display. The prefix is free text with no validation; still read for existing entries, normalized alongside see_also by seeAlsoView.js.",
111
+ "items": {
112
+ "type": "string"
113
+ }
63
114
  },
64
115
  "see_also": {
65
116
  "type": "array",
66
- "description": "Related commands or documentation pages, in the docs-data/rpk-overrides.json 'seeAlso' shape. Each item is a plain string (shown everywhere) or an object naming exactly one of cloud_only/self_hosted_only (shown only in that build).",
117
+ "description": "Related commands or documentation pages. Each item is a plain string (shown everywhere) or an object naming exactly one of cloud_only/self_managed_only (shown only in that build). Same shape as rpk-overrides.json's 'seeAlso', except that file spells the flags in camelCase to match its own key style.",
67
118
  "items": {
68
119
  "oneOf": [
69
120
  {
@@ -79,24 +130,80 @@
79
130
  "description": "The xref link or related-topic text."
80
131
  },
81
132
  "cloud_only": {
82
- "type": "boolean",
83
- "description": "If true, only shown in Cloud docs (wrapped with ifdef::env-cloud[])."
133
+ "const": true,
134
+ "description": "If true, only shown in Cloud docs (wrapped with ifdef::env-cloud[]). Only `true` is accepted: the emitters test for exactly true, so `false` behaves as if the key were absent while reading as though it meant the opposite audience. Omit the key instead."
84
135
  },
85
136
  "self_hosted_only": {
86
- "type": "boolean",
87
- "description": "If true, only shown in self-hosted docs (wrapped with ifndef::env-cloud[])."
137
+ "const": true,
138
+ "deprecated": true,
139
+ "description": "Deprecated spelling of self_managed_only. Still read, so an override written against the older schema keeps working, but nothing emits it. Use self_managed_only. Only `true` is accepted: the emitters test for exactly true, so `false` behaves as if the key were absent while reading as though it meant the opposite audience. Omit the key instead."
140
+ },
141
+ "self_managed_only": {
142
+ "const": true,
143
+ "description": "If true, only shown in self-managed docs (wrapped with ifndef::env-cloud[]). Only `true` is accepted: the emitters test for exactly true, so `false` behaves as if the key were absent while reading as though it meant the opposite audience. Omit the key instead."
88
144
  }
89
145
  },
90
- "required": ["content"],
146
+ "required": [
147
+ "content"
148
+ ],
91
149
  "additionalProperties": false,
92
150
  "oneOf": [
93
151
  {
94
- "required": ["cloud_only"],
95
- "not": { "required": ["self_hosted_only"] }
152
+ "required": [
153
+ "cloud_only"
154
+ ],
155
+ "not": {
156
+ "anyOf": [
157
+ {
158
+ "required": [
159
+ "self_managed_only"
160
+ ]
161
+ },
162
+ {
163
+ "required": [
164
+ "self_hosted_only"
165
+ ]
166
+ }
167
+ ]
168
+ }
169
+ },
170
+ {
171
+ "required": [
172
+ "self_managed_only"
173
+ ],
174
+ "not": {
175
+ "anyOf": [
176
+ {
177
+ "required": [
178
+ "cloud_only"
179
+ ]
180
+ },
181
+ {
182
+ "required": [
183
+ "self_hosted_only"
184
+ ]
185
+ }
186
+ ]
187
+ }
96
188
  },
97
189
  {
98
- "required": ["self_hosted_only"],
99
- "not": { "required": ["cloud_only"] }
190
+ "required": [
191
+ "self_hosted_only"
192
+ ],
193
+ "not": {
194
+ "anyOf": [
195
+ {
196
+ "required": [
197
+ "cloud_only"
198
+ ]
199
+ },
200
+ {
201
+ "required": [
202
+ "self_managed_only"
203
+ ]
204
+ }
205
+ ]
206
+ }
100
207
  }
101
208
  ]
102
209
  }
@@ -108,14 +215,31 @@
108
215
  "description": "Structured NOTE/TIP/IMPORTANT/WARNING/CAUTION callouts, rendered as Asciidoctor admonition blocks instead of hand-written markup in description. Each entry needs type and text; title is optional. See property_extractor.py's _normalize_admonitions() -- an entry that doesn't match this shape is dropped with a warning, not rejected here, so the schema stays permissive on casing/whitespace that the generator itself normalizes.",
109
216
  "items": {
110
217
  "type": "object",
111
- "required": ["type", "text"],
218
+ "required": [
219
+ "type",
220
+ "text"
221
+ ],
112
222
  "properties": {
113
223
  "type": {
114
224
  "type": "string",
115
225
  "description": "Case-insensitive; normalized to uppercase. One of note, tip, important, warning, caution.",
116
- "enum": ["note", "tip", "important", "warning", "caution",
117
- "NOTE", "TIP", "IMPORTANT", "WARNING", "CAUTION",
118
- "Note", "Tip", "Important", "Warning", "Caution"]
226
+ "enum": [
227
+ "note",
228
+ "tip",
229
+ "important",
230
+ "warning",
231
+ "caution",
232
+ "NOTE",
233
+ "TIP",
234
+ "IMPORTANT",
235
+ "WARNING",
236
+ "CAUTION",
237
+ "Note",
238
+ "Tip",
239
+ "Important",
240
+ "Warning",
241
+ "Caution"
242
+ ]
119
243
  },
120
244
  "text": {
121
245
  "type": "string",
@@ -124,8 +248,57 @@
124
248
  "title": {
125
249
  "type": "string",
126
250
  "description": "Optional admonition title, shown in place of the default type label."
251
+ },
252
+ "cloud_only": {
253
+ "const": true,
254
+ "description": "Show this admonition only in the Cloud build (wrapped with ifdef::env-cloud[]). Only `true` is accepted: the emitters test for exactly true, so `false` behaves as if the key were absent while reading as though it meant the opposite audience. Omit the key instead."
255
+ },
256
+ "self_managed_only": {
257
+ "const": true,
258
+ "description": "Show this admonition only in the self-managed build (wrapped with ifndef::env-cloud[]). Only `true` is accepted: the emitters test for exactly true, so `false` behaves as if the key were absent while reading as though it meant the opposite audience. Omit the key instead."
259
+ },
260
+ "self_hosted_only": {
261
+ "const": true,
262
+ "deprecated": true,
263
+ "description": "Deprecated spelling of self_managed_only. Still read, so an override written against the older schema keeps working, but nothing emits it: the product is Self-Managed and the string prefix has always been 'self-managed-only:', so one concept was going by two words depending on whether you wrote it as a prefix or a boolean. Use self_managed_only. Only `true` is accepted: the emitters test for exactly true, so `false` behaves as if the key were absent while reading as though it meant the opposite audience. Omit the key instead."
127
264
  }
128
265
  },
266
+ "allOf": [
267
+ {
268
+ "not": {
269
+ "$comment": "Setting both audiences would wrap the block in ifdef and ifndef at once, so it would render in neither build. Only an exact true counts, so cloud_only: false beside self_managed_only: true stays valid.",
270
+ "properties": {
271
+ "cloud_only": {
272
+ "const": true
273
+ },
274
+ "self_managed_only": {
275
+ "const": true
276
+ }
277
+ },
278
+ "required": [
279
+ "cloud_only",
280
+ "self_managed_only"
281
+ ]
282
+ }
283
+ },
284
+ {
285
+ "not": {
286
+ "$comment": "Same check against the deprecated spelling.",
287
+ "properties": {
288
+ "cloud_only": {
289
+ "const": true
290
+ },
291
+ "self_hosted_only": {
292
+ "const": true
293
+ }
294
+ },
295
+ "required": [
296
+ "cloud_only",
297
+ "self_hosted_only"
298
+ ]
299
+ }
300
+ }
301
+ ],
129
302
  "additionalProperties": false
130
303
  }
131
304
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redpanda-data/docs-extensions-and-macros",
3
- "version": "5.46.0",
3
+ "version": "5.47.1",
4
4
  "description": "Antora extensions and macros developed for Redpanda documentation.",
5
5
  "keywords": [
6
6
  "antora",
@@ -41,6 +41,13 @@ function loadJson (filePath, label) {
41
41
  * REDUNDANT (the audit's normalization only widens equality, never narrows
42
42
  * it), so a violation indicates a classifier bug.
43
43
  *
44
+ * One exemption, not an exception to the rule: a description carrying
45
+ * audience-scoped paragraphs classifies KEEP even when its unconditional prose
46
+ * is raw-equal to source, because retiring it would delete the scoped
47
+ * paragraphs too. Those entries are compared on their unconditional prose --
48
+ * skipping them outright would quietly drop the array form out of the
49
+ * invariant's coverage.
50
+ *
44
51
  * @param {Object} extractedDoc - Extracted properties document.
45
52
  * @param {Object} overrides - Override entries map.
46
53
  * @param {Object[]} manifest - Classified manifest rows.
@@ -51,8 +58,11 @@ function crossCheckWithCompare (extractedDoc, overrides, manifest) {
51
58
  const overlaidProps = {}
52
59
  for (const [name, prop] of Object.entries(extractedProps)) {
53
60
  const override = overrides[name]
54
- overlaidProps[name] = override && typeof override.description === 'string'
55
- ? { ...prop, description: override.description }
61
+ const description = override && override.description !== undefined
62
+ ? classify.unconditionalProse(override.description).prose
63
+ : undefined
64
+ overlaidProps[name] = description !== undefined
65
+ ? { ...prop, description }
56
66
  : prop
57
67
  }
58
68
 
@@ -72,7 +82,8 @@ function crossCheckWithCompare (extractedDoc, overrides, manifest) {
72
82
  row.field === 'description' &&
73
83
  row.class !== classify.CLASSES.REDUNDANT &&
74
84
  overrides[row.name] &&
75
- typeof overrides[row.name].description === 'string' &&
85
+ overrides[row.name].description !== undefined &&
86
+ classify.unconditionalProse(overrides[row.name].description).scoped === 0 &&
76
87
  extractedProps[row.name] &&
77
88
  !changedNames.has(row.name)
78
89
  )