@redpanda-data/docs-extensions-and-macros 5.14.0 → 5.22.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.
Files changed (56) hide show
  1. package/bin/doc-tools-mcp.js +88 -0
  2. package/bin/doc-tools.js +334 -102
  3. package/bin/mcp-tools/index.js +20 -0
  4. package/bin/mcp-tools/lint-doc-strings.js +75 -0
  5. package/bin/mcp-tools/overrides-audit.js +100 -0
  6. package/bin/mcp-tools/preview-doc-string.js +71 -0
  7. package/cli-utils/diff-utils.js +77 -0
  8. package/cli-utils/helm-commented-values.js +172 -23
  9. package/cli-utils/validation-result.js +53 -0
  10. package/macros/REFERENCE.adoc +3 -2
  11. package/macros/prop.js +34 -6
  12. package/package.json +2 -1
  13. package/tools/generate-cli-docs.js +28 -67
  14. package/tools/lint-strings/diff.js +137 -0
  15. package/tools/lint-strings/engine.js +163 -0
  16. package/tools/lint-strings/fixtures/connect/lint_connect.go +67 -0
  17. package/tools/lint-strings/fixtures/crd/crd-ref-docs-config.yaml +15 -0
  18. package/tools/lint-strings/fixtures/crd/lint_types.go +57 -0
  19. package/tools/lint-strings/fixtures/helm/lint_values.yaml +47 -0
  20. package/tools/lint-strings/fixtures/metrics/lint_probe.cc +41 -0
  21. package/tools/lint-strings/fixtures/properties/lint_fixture.cc +51 -0
  22. package/tools/lint-strings/fixtures/properties/lint_fixture.h +33 -0
  23. package/tools/lint-strings/fixtures/rpk/lint_cmd.go +69 -0
  24. package/tools/lint-strings/go-source.js +327 -0
  25. package/tools/lint-strings/index.js +229 -0
  26. package/tools/lint-strings/rules/common.js +148 -0
  27. package/tools/lint-strings/rules/verbatim-asciidoc.js +108 -0
  28. package/tools/lint-strings/source-text.js +41 -0
  29. package/tools/lint-strings/surfaces/connect.js +233 -0
  30. package/tools/lint-strings/surfaces/crd.js +239 -0
  31. package/tools/lint-strings/surfaces/helm.js +159 -0
  32. package/tools/lint-strings/surfaces/metrics.js +368 -0
  33. package/tools/lint-strings/surfaces/properties.js +216 -0
  34. package/tools/lint-strings/surfaces/rpk.js +377 -0
  35. package/tools/overrides-audit/README.adoc +152 -0
  36. package/tools/overrides-audit/adapters/connect.js +144 -0
  37. package/tools/overrides-audit/adapters/properties.js +117 -0
  38. package/tools/overrides-audit/adapters/rpk.js +192 -0
  39. package/tools/overrides-audit/classify.js +546 -0
  40. package/tools/overrides-audit/index.js +185 -0
  41. package/tools/preview-string/index.js +245 -0
  42. package/tools/property-extractor/Makefile +9 -4
  43. package/tools/property-extractor/README.adoc +17 -3
  44. package/tools/property-extractor/helpers/index.js +1 -2
  45. package/tools/property-extractor/helpers/seeAlsoView.js +76 -0
  46. package/tools/property-extractor/parser.py +26 -0
  47. package/tools/property-extractor/property_extractor.py +65 -6
  48. package/tools/property-extractor/templates/property.hbs +25 -37
  49. package/tools/property-extractor/templates/topic-property.hbs +25 -37
  50. package/tools/property-extractor/transformers.py +15 -2
  51. package/tools/property-extractor/validate-overrides.js +120 -0
  52. package/tools/rpk-docs/detect-platform-commands.js +8 -1
  53. package/tools/rpk-docs/scan-deprecated-commands.js +15 -18
  54. package/tools/rpk-docs/validate-overrides.js +1 -45
  55. package/tools/property-extractor/helpers/allTopicsConditional.js +0 -19
  56. package/tools/property-extractor/helpers/parseRelatedTopic.js +0 -13
@@ -0,0 +1,546 @@
1
+ /**
2
+ * Overrides Audit - Field-Level Classification
3
+ *
4
+ * Classifies each field of a docs-side override entry against the string
5
+ * extracted from engineering source, powering the override-retirement loop:
6
+ * a description override is a stopgap that dies once the fixed string ships
7
+ * upstream, while structured docs enrichment (related_topics, config_scope,
8
+ * examples-as-files, page attributes) stays in the override layer by design.
9
+ *
10
+ * Classes:
11
+ * - REDUNDANT: the override no longer changes anything (source already
12
+ * matches after normalization). Safe to delete in the next auto-docs PR.
13
+ * - UPSTREAMABLE: the override differs from source and is free of docs-only
14
+ * markup, so the override text itself can be sent upstream verbatim
15
+ * (upstream_candidate_text carries it).
16
+ * - KEEP_UNTIL_UPSTREAMED: the override differs AND contains docs-only
17
+ * markup (the SPLIT case). The markup-stripped prose is emitted as
18
+ * upstream_candidate_text; the entry keeps masking the source string until
19
+ * the stripped prose ships upstream.
20
+ * - UPSTREAMABLE_SLOT: structured data that has a natural home in source
21
+ * metadata (the `.example` metadata slot on property declarations).
22
+ * - REDUNDANT_OR_UPSTREAMABLE: needs a human ruling (accepted_values on an
23
+ * enum property: the override may be filtering internal-only values, or it
24
+ * may just restate the enum).
25
+ * - KEEP: docs-layer enrichment that stays in the override file by design.
26
+ * - REVIEW: a mismatch that may indicate a source bug (default/type
27
+ * overrides that disagree with source) or a unit the audit cannot rule on.
28
+ * Never auto-delete.
29
+ */
30
+
31
+ 'use strict'
32
+
33
+ const crypto = require('crypto')
34
+
35
+ const CLASSES = Object.freeze({
36
+ REDUNDANT: 'REDUNDANT',
37
+ UPSTREAMABLE: 'UPSTREAMABLE',
38
+ KEEP_UNTIL_UPSTREAMED: 'KEEP_UNTIL_UPSTREAMED',
39
+ UPSTREAMABLE_SLOT: 'UPSTREAMABLE_SLOT',
40
+ REDUNDANT_OR_UPSTREAMABLE: 'REDUNDANT_OR_UPSTREAMABLE',
41
+ KEEP: 'KEEP',
42
+ REVIEW: 'REVIEW'
43
+ })
44
+
45
+ /**
46
+ * Attributes that are acceptable inside an upstreamed source string.
47
+ *
48
+ * Empty today: the live overrides file contains no unescaped attribute
49
+ * references, and source strings ship verbatim into JSON payloads (hover
50
+ * tooltips) where an unresolved {attr} would leak raw. The hook exists so a
51
+ * product-name attribute can be allowlisted later without touching the
52
+ * markup detector.
53
+ */
54
+ const PRODUCT_ATTR_ALLOWLIST = Object.freeze([])
55
+
56
+ /**
57
+ * Fields that stay in the override layer by design: docs-site structure the
58
+ * source string cannot carry (see the override-lifecycle policy).
59
+ */
60
+ const KEEP_BY_DESIGN_FIELDS = Object.freeze([
61
+ 'related_topics',
62
+ 'category',
63
+ 'config_scope',
64
+ 'version',
65
+ 'exclude_from_docs',
66
+ 'descriptionScope'
67
+ ])
68
+
69
+ /**
70
+ * The known typo'd key: the extractor honors `accepted_values`, so an
71
+ * `acceptable_values` override is silently ignored for existing properties.
72
+ */
73
+ const TYPO_KEYS = Object.freeze({ acceptable_values: 'accepted_values' })
74
+
75
+ /**
76
+ * Fields the audit never classifies as override content.
77
+ * `upstream_ref` is a policy annotation carried onto the description row;
78
+ * `_comment` is maintainer notes.
79
+ */
80
+ const META_FIELDS = Object.freeze(['upstream_ref', '_comment'])
81
+
82
+ /**
83
+ * Normalize prose for comparison: unwrap lines and collapse all whitespace
84
+ * runs (spaces, newlines from re-wrapped C++ adjacent literals) to single
85
+ * spaces, then trim.
86
+ *
87
+ * @param {string} text - Raw description text.
88
+ * @returns {string} Normalized text.
89
+ */
90
+ function normalizeText (text) {
91
+ if (typeof text !== 'string') return ''
92
+ return text.replace(/\s+/g, ' ').trim()
93
+ }
94
+
95
+ // Docs-only markup detectors. `<<anchor>>` cross-references are included
96
+ // beyond the core macro list: they target anchors that only exist on the
97
+ // generated docs page, so they can never ship in a source string.
98
+ const MARKUP_PATTERNS = Object.freeze([
99
+ { id: 'xref', re: /xref:[^\s[]+\[[^\]]*\]/ },
100
+ { id: 'glossterm', re: /glossterm:[^[]+\[[^\]]*\]/ },
101
+ { id: 'include', re: /include::[^\s[]+\[[^\]]*\]/ },
102
+ { id: 'conditional', re: /(?:^|\n)\s*(?:ifdef|ifndef|endif)::[^\n]*/ },
103
+ { id: 'pass', re: /pass:[a-z,]*\[/ },
104
+ { id: 'anchor-xref', re: /<<[^<>\n]+>>/ },
105
+ // Property-link macros: config_ref: is the deprecated docs macro (the
106
+ // extractor rewrites it to prop:), prop: is its replacement. Both resolve
107
+ // against the published property JSON, so neither can ship in source.
108
+ { id: 'property-link', re: /\b(?:config_ref|prop):[^\s[]+\[[^\]]*\]/ },
109
+ // Audience prefix on a link or xref (tools/property-extractor/helpers/
110
+ // parseRelatedTopic.js). It is a docs-layer instruction about which product
111
+ // a link applies to, so it can never ship in a source string - and the bare
112
+ // prefix+URL form carries no other macro to trip the detectors above, so
113
+ // without this entry the audit proposed shipping the macro verbatim into
114
+ // C++. Anchored at a word boundary so it cannot match inside prose.
115
+ { id: 'audience-prefix', re: /\b(?:self-managed-only|cloud-only):/ }
116
+ ])
117
+
118
+ /**
119
+ * Find unescaped AsciiDoc attribute references (`{attr}`) that are not on
120
+ * the product-attr allowlist. Escaped literals (`\{attr}`) do not count.
121
+ *
122
+ * @param {string} text - Description text.
123
+ * @param {string[]} allowlist - Attribute names allowed in source strings.
124
+ * @returns {string[]} Disallowed attribute names found.
125
+ */
126
+ function findDisallowedAttrs (text, allowlist = PRODUCT_ATTR_ALLOWLIST) {
127
+ const found = []
128
+ const re = /(\\)?\{([a-zA-Z][a-zA-Z0-9_-]*)\}/g
129
+ let match
130
+ while ((match = re.exec(text)) !== null) {
131
+ if (match[1]) continue // escaped literal, renders as {attr}
132
+ if (!allowlist.includes(match[2]) && !found.includes(match[2])) {
133
+ found.push(match[2])
134
+ }
135
+ }
136
+ return found
137
+ }
138
+
139
+ /**
140
+ * List the docs-only markup kinds present in a description.
141
+ *
142
+ * @param {string} text - Description text.
143
+ * @param {string[]} attrAllowlist - Product-attr allowlist.
144
+ * @returns {string[]} Markup kind ids (empty when the text is markup-free).
145
+ */
146
+ function detectDocsMarkup (text, attrAllowlist = PRODUCT_ATTR_ALLOWLIST) {
147
+ const kinds = []
148
+ for (const { id, re } of MARKUP_PATTERNS) {
149
+ if (re.test(text)) kinds.push(id)
150
+ }
151
+ if (findDisallowedAttrs(text, attrAllowlist).length > 0) kinds.push('attribute')
152
+ return kinds
153
+ }
154
+
155
+ /**
156
+ * Strip docs-only markup from a description, leaving prose that could ship
157
+ * as a source string (the SPLIT case's upstream candidate).
158
+ *
159
+ * - `xref:target[text]` keeps the link text (or the last path segment when
160
+ * the text is empty).
161
+ * - `glossterm:Term[definition]` keeps the term.
162
+ * - `<<anchor,text>>` keeps the text; `<<anchor>>` keeps the anchor.
163
+ * - `pass:q[content]` keeps the content.
164
+ * - `self-managed-only:` / `cloud-only:` audience prefixes are removed
165
+ * before the macro rules run, so no prefix survives into the candidate.
166
+ * - `include::` and `ifdef::`/`ifndef::`/`endif::` directive lines are
167
+ * dropped (conditional content between the directives is kept).
168
+ * - Unresolvable `{attr}` references are left in place; the caller flags
169
+ * them for human review via the markup kinds.
170
+ *
171
+ * @param {string} text - Description text with markup.
172
+ * @returns {string} Markup-stripped prose.
173
+ */
174
+ function stripDocsMarkup (text) {
175
+ let out = text
176
+ // Audience prefixes come off first: they wrap another macro
177
+ // (`self-managed-only:xref:...[...]`), so stripping them after the xref
178
+ // rule left a dangling `self-managed-only:` in the upstream candidate.
179
+ out = out.replace(/\b(?:self-managed-only|cloud-only):/g, '')
180
+ // xref:module:page.adoc[link text] -> link text
181
+ out = out.replace(/xref:([^\s[]+)\[([^\]]*)\]/g, (m, target, label) => {
182
+ if (label.trim()) return label.trim()
183
+ const segment = target.split(/[/:]/).pop().replace(/\.adoc$/, '').replace(/#.*$/, '')
184
+ return segment.replace(/-/g, ' ')
185
+ })
186
+ // glossterm:Term[definition] -> Term
187
+ out = out.replace(/glossterm:([^[]+)\[[^\]]*\]/g, (m, term) => term.trim())
188
+ // config_ref:name,link,path[display] -> display (or `name`)
189
+ out = out.replace(/\bconfig_ref:([^\s[,]+)[^[]*\[([^\]]*)\]/g, (m, name, label) =>
190
+ label.trim() ? label.trim() : `\`${name.trim()}\``)
191
+ // prop:name[attrs] -> text= attr value (or `name`)
192
+ out = out.replace(/\bprop:([^\s[]+)\[([^\]]*)\]/g, (m, name, attrs) => {
193
+ const textAttr = /(?:^|,)\s*text=([^,\]]+)/.exec(attrs)
194
+ return textAttr ? textAttr[1].trim() : `\`${name.trim()}\``
195
+ })
196
+ // pass:q[content] -> content
197
+ out = out.replace(/pass:[a-z,]*\[([^\]]*)\]/g, '$1')
198
+ // <<anchor,text>> -> text ; <<anchor>> -> anchor
199
+ out = out.replace(/<<([^<>,\n]+),([^<>\n]+)>>/g, (m, anchor, label) => label.trim())
200
+ out = out.replace(/<<([^<>\n]+)>>/g, (m, anchor) => anchor.trim())
201
+ // Drop include and conditional directive lines, keeping surrounding prose
202
+ out = out
203
+ .split('\n')
204
+ .filter((line) => !/^\s*(?:include::|ifdef::|ifndef::|endif::)/.test(line))
205
+ .join('\n')
206
+ // Tidy whitespace artifacts without flattening paragraphs
207
+ out = out.replace(/[ \t]+/g, ' ').replace(/\n{3,}/g, '\n\n').trim()
208
+ return out
209
+ }
210
+
211
+ /**
212
+ * Stable content hash for the dedup manifest kept by the docs-repo
213
+ * workflow: the same (name, override description) pair always hashes to the
214
+ * same value, so an upstream PR already opened for this text is skipped.
215
+ *
216
+ * The separator is a literal \n--\n rather than a control character: a raw
217
+ * NUL made this whole file binary to POSIX text tools, so grep and ripgrep
218
+ * silently skipped it during code search, and any editor round-trip or
219
+ * formatter that strips control characters would have rewritten every hash
220
+ * in the dedup manifest. A property name cannot contain a newline, so the
221
+ * separator is still unambiguous.
222
+ *
223
+ * @param {string} name - Property (or command) name.
224
+ * @param {string} overrideText - The override description text.
225
+ * @returns {string} 16-hex-char sha256 prefix.
226
+ */
227
+ function contentHash (name, overrideText) {
228
+ return crypto
229
+ .createHash('sha256')
230
+ .update(`${name}\n--\n${normalizeText(overrideText)}`)
231
+ .digest('hex')
232
+ .slice(0, 16)
233
+ }
234
+
235
+ /**
236
+ * Structural deep equality (same semantics as compare-properties.js).
237
+ *
238
+ * @param {*} a - First value.
239
+ * @param {*} b - Second value.
240
+ * @returns {boolean} True when deeply equal.
241
+ */
242
+ function deepEqual (a, b) {
243
+ if (a === b) return true
244
+ if (a == null || b == null) return false
245
+ if (typeof a !== typeof b) return false
246
+ if (Array.isArray(a) && Array.isArray(b)) {
247
+ if (a.length !== b.length) return false
248
+ return a.every((val, i) => deepEqual(val, b[i]))
249
+ }
250
+ if (typeof a === 'object' && typeof b === 'object') {
251
+ const keysA = Object.keys(a)
252
+ const keysB = Object.keys(b)
253
+ if (keysA.length !== keysB.length) return false
254
+ return keysA.every((key) => keysB.includes(key) && deepEqual(a[key], b[key]))
255
+ }
256
+ return false
257
+ }
258
+
259
+ /**
260
+ * Build one manifest row.
261
+ *
262
+ * @param {Object} fields - Row fields (name, field, class, note, ...).
263
+ * @returns {Object} Manifest row with stable key order.
264
+ */
265
+ function row (fields) {
266
+ 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']) {
268
+ if (fields[key] !== undefined) base[key] = fields[key]
269
+ }
270
+ return base
271
+ }
272
+
273
+ /**
274
+ * Classify the description field of one override entry.
275
+ *
276
+ * @param {string} name - Property name.
277
+ * @param {Object} override - Override entry (may carry upstream_ref).
278
+ * @param {Object|null} sourceProp - Extracted source property, or null.
279
+ * @param {Object} [opts] - Options ({ attrAllowlist }).
280
+ * @returns {Object} Manifest row.
281
+ */
282
+ function classifyDescription (name, override, sourceProp, opts = {}) {
283
+ const attrAllowlist = opts.attrAllowlist || PRODUCT_ATTR_ALLOWLIST
284
+ const overrideText = override.description
285
+ const common = {
286
+ name,
287
+ field: 'description',
288
+ content_hash: contentHash(name, overrideText),
289
+ source_file: sourceProp ? sourceProp.defined_in : undefined,
290
+ source_line: sourceProp && sourceProp.line_start !== undefined ? sourceProp.line_start : undefined
291
+ }
292
+ if (override.upstream_ref !== undefined) common.upstream_ref = override.upstream_ref
293
+
294
+ if (!sourceProp) {
295
+ return row({
296
+ ...common,
297
+ class: CLASSES.REVIEW,
298
+ note: 'Property not present in extracted source JSON; override may create a doc-only property or the property was removed upstream.'
299
+ })
300
+ }
301
+
302
+ const sourceText = normalizeText(sourceProp.description)
303
+ const overrideNormalized = normalizeText(overrideText)
304
+
305
+ if (overrideNormalized === sourceText) {
306
+ return row({
307
+ ...common,
308
+ class: CLASSES.REDUNDANT,
309
+ note: 'Source description already matches the override after normalization.'
310
+ })
311
+ }
312
+
313
+ const markupKinds = detectDocsMarkup(overrideText, attrAllowlist)
314
+ if (markupKinds.length === 0) {
315
+ return row({
316
+ ...common,
317
+ class: CLASSES.UPSTREAMABLE,
318
+ upstream_candidate_text: overrideText,
319
+ note: 'Override prose differs from source and is markup-free; send upstream verbatim.'
320
+ })
321
+ }
322
+
323
+ const stripped = stripDocsMarkup(overrideText)
324
+ if (normalizeText(stripped) === sourceText) {
325
+ return row({
326
+ ...common,
327
+ class: CLASSES.KEEP,
328
+ note: `Markup-only enrichment (${markupKinds.join(', ')}): the prose already matches source, only docs-site markup is added. Nothing to upstream.`
329
+ })
330
+ }
331
+
332
+ return row({
333
+ ...common,
334
+ class: CLASSES.KEEP_UNTIL_UPSTREAMED,
335
+ upstream_candidate_text: stripped,
336
+ note: `SPLIT: contains docs-only markup (${markupKinds.join(', ')}). Stripped prose is the upstream candidate; keep the override until it ships.`
337
+ })
338
+ }
339
+
340
+ /**
341
+ * Classify one field of one override entry against the extracted source
342
+ * property. Dispatches on field name; unknown fields are kept (docs-layer
343
+ * data the audit does not understand yet), with typo'd keys flagged.
344
+ *
345
+ * @param {string} name - Property name.
346
+ * @param {string} field - Override field name.
347
+ * @param {Object} override - Full override entry.
348
+ * @param {Object|null} sourceProp - Extracted property, or null when absent.
349
+ * @param {Object} [opts] - Options ({ attrAllowlist }).
350
+ * @returns {Object|null} Manifest row, or null for meta fields.
351
+ */
352
+ function classifyField (name, field, override, sourceProp, opts = {}) {
353
+ if (META_FIELDS.includes(field)) return null
354
+
355
+ const sourceRef = sourceProp
356
+ ? { source_file: sourceProp.defined_in, source_line: sourceProp.line_start }
357
+ : {}
358
+
359
+ if (field === 'description') {
360
+ return classifyDescription(name, override, sourceProp, opts)
361
+ }
362
+
363
+ if (field === 'example') {
364
+ const value = override.example
365
+ const text = Array.isArray(value) ? value.join('\n') : String(value)
366
+ const hasBlockMarkup = /(^|\n)(\[,?\s*[a-z]*\]|----)/.test(text)
367
+ return row({
368
+ name,
369
+ field,
370
+ class: CLASSES.UPSTREAMABLE_SLOT,
371
+ ...sourceRef,
372
+ note: hasBlockMarkup
373
+ ? 'Migrate to the source `.example` metadata slot; contains AsciiDoc block markup, review formatting before migrating.'
374
+ : 'Migrate to the source `.example` metadata slot.'
375
+ })
376
+ }
377
+
378
+ if (field === 'example_file' || field === 'example_yaml') {
379
+ return row({
380
+ name,
381
+ field,
382
+ class: CLASSES.KEEP,
383
+ ...sourceRef,
384
+ note: 'File/YAML example variants are docs-only rendering; they have no source slot.'
385
+ })
386
+ }
387
+
388
+ if (field === 'accepted_values') {
389
+ const sourceEnum = sourceProp && (sourceProp.enum || (sourceProp.items && sourceProp.items.enum))
390
+ if (Array.isArray(sourceEnum)) {
391
+ const same = deepEqual([...override.accepted_values].sort(), [...sourceEnum].sort())
392
+ return row({
393
+ name,
394
+ field,
395
+ class: CLASSES.REDUNDANT_OR_UPSTREAMABLE,
396
+ ...sourceRef,
397
+ note: same
398
+ ? 'Source enum matches the override values exactly; likely redundant. Needs a human ruling.'
399
+ : 'Source is an enum but the values differ; the override may deliberately hide internal-only values. Needs a human ruling.'
400
+ })
401
+ }
402
+ return row({
403
+ name,
404
+ field,
405
+ class: CLASSES.KEEP,
406
+ ...sourceRef,
407
+ note: 'Extracted property is not an enum; accepted_values is docs-side enrichment.'
408
+ })
409
+ }
410
+
411
+ if (field === 'default' || field === 'type') {
412
+ if (!sourceProp) {
413
+ return row({
414
+ name,
415
+ field,
416
+ class: CLASSES.REVIEW,
417
+ note: 'Property not present in extracted source JSON; cannot compare.'
418
+ })
419
+ }
420
+ if (deepEqual(override[field], sourceProp[field])) {
421
+ return row({
422
+ name,
423
+ field,
424
+ class: CLASSES.REDUNDANT,
425
+ ...sourceRef,
426
+ note: `Source ${field} already matches the override.`
427
+ })
428
+ }
429
+ return row({
430
+ name,
431
+ field,
432
+ class: CLASSES.REVIEW,
433
+ ...sourceRef,
434
+ note: `Override ${field} (${JSON.stringify(override[field])}) differs from source (${JSON.stringify(sourceProp[field])}); possible source bug. Never auto-delete.`
435
+ })
436
+ }
437
+
438
+ if (KEEP_BY_DESIGN_FIELDS.includes(field)) {
439
+ return row({
440
+ name,
441
+ field,
442
+ class: CLASSES.KEEP,
443
+ ...sourceRef,
444
+ note: 'Docs-layer enrichment; stays in the override file by design.'
445
+ })
446
+ }
447
+
448
+ if (TYPO_KEYS[field]) {
449
+ return row({
450
+ name,
451
+ field,
452
+ class: CLASSES.REVIEW,
453
+ ...sourceRef,
454
+ note: `Typo'd key: '${field}' is silently ignored by the extractor; rename to '${TYPO_KEYS[field]}'.`
455
+ })
456
+ }
457
+
458
+ return row({
459
+ name,
460
+ field,
461
+ class: CLASSES.KEEP,
462
+ ...sourceRef,
463
+ note: `Unrecognized override field '${field}'; kept, verify it is honored by the extractor.`
464
+ })
465
+ }
466
+
467
+ /**
468
+ * Classify every field of one property override entry.
469
+ *
470
+ * @param {string} name - Property name.
471
+ * @param {Object} override - Override entry.
472
+ * @param {Object|null} sourceProp - Extracted property, or null.
473
+ * @param {Object} [opts] - Options.
474
+ * @returns {Object[]} Manifest rows for the entry.
475
+ */
476
+ function classifyPropertyEntry (name, override, sourceProp, opts = {}) {
477
+ const rows = []
478
+ for (const field of Object.keys(override)) {
479
+ const result = classifyField(name, field, override, sourceProp, opts)
480
+ if (result) rows.push(result)
481
+ }
482
+ return rows
483
+ }
484
+
485
+ /**
486
+ * Classify a whole property-overrides document against extracted source.
487
+ *
488
+ * @param {Object} overridesDoc - Parsed property-overrides.json ({ properties }).
489
+ * @param {Object} extractedDoc - Parsed extracted-properties JSON ({ properties }).
490
+ * @param {Object} [opts] - Options ({ attrAllowlist }).
491
+ * @returns {{manifest: Object[], summary: Object}} Manifest and class counts.
492
+ */
493
+ function classifyProperties (overridesDoc, extractedDoc, opts = {}) {
494
+ const overrides = (overridesDoc && overridesDoc.properties) || {}
495
+ const extracted = (extractedDoc && extractedDoc.properties) || {}
496
+
497
+ const manifest = []
498
+ for (const [name, override] of Object.entries(overrides)) {
499
+ if (typeof override !== 'object' || override === null || Array.isArray(override)) {
500
+ manifest.push(row({
501
+ name,
502
+ field: '(entry)',
503
+ class: CLASSES.REVIEW,
504
+ note: `Override entry is not an object (${typeof override}); malformed.`
505
+ }))
506
+ continue
507
+ }
508
+ manifest.push(...classifyPropertyEntry(name, override, extracted[name] || null, opts))
509
+ }
510
+ return { manifest, summary: summarize(manifest) }
511
+ }
512
+
513
+ /**
514
+ * Count manifest rows per class and per (class, field).
515
+ *
516
+ * @param {Object[]} manifest - Manifest rows.
517
+ * @returns {Object} { total, byClass, byField }.
518
+ */
519
+ function summarize (manifest) {
520
+ const byClass = {}
521
+ const byField = {}
522
+ for (const entry of manifest) {
523
+ byClass[entry.class] = (byClass[entry.class] || 0) + 1
524
+ byField[entry.field] = byField[entry.field] || {}
525
+ byField[entry.field][entry.class] = (byField[entry.field][entry.class] || 0) + 1
526
+ }
527
+ return { total: manifest.length, byClass, byField }
528
+ }
529
+
530
+ module.exports = {
531
+ CLASSES,
532
+ PRODUCT_ATTR_ALLOWLIST,
533
+ KEEP_BY_DESIGN_FIELDS,
534
+ TYPO_KEYS,
535
+ normalizeText,
536
+ detectDocsMarkup,
537
+ findDisallowedAttrs,
538
+ stripDocsMarkup,
539
+ contentHash,
540
+ deepEqual,
541
+ classifyDescription,
542
+ classifyField,
543
+ classifyPropertyEntry,
544
+ classifyProperties,
545
+ summarize
546
+ }