@redpanda-data/docs-extensions-and-macros 5.14.0 → 5.17.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.
- package/bin/doc-tools-mcp.js +88 -0
- package/bin/doc-tools.js +252 -102
- package/bin/mcp-tools/index.js +20 -0
- package/bin/mcp-tools/lint-doc-strings.js +75 -0
- package/bin/mcp-tools/overrides-audit.js +100 -0
- package/bin/mcp-tools/preview-doc-string.js +71 -0
- package/cli-utils/diff-utils.js +77 -0
- package/cli-utils/helm-commented-values.js +172 -23
- package/package.json +2 -1
- package/tools/generate-cli-docs.js +28 -67
- package/tools/lint-strings/diff.js +137 -0
- package/tools/lint-strings/engine.js +163 -0
- package/tools/lint-strings/fixtures/connect/lint_connect.go +67 -0
- package/tools/lint-strings/fixtures/crd/crd-ref-docs-config.yaml +15 -0
- package/tools/lint-strings/fixtures/crd/lint_types.go +57 -0
- package/tools/lint-strings/fixtures/helm/lint_values.yaml +47 -0
- package/tools/lint-strings/fixtures/metrics/lint_probe.cc +41 -0
- package/tools/lint-strings/fixtures/properties/lint_fixture.cc +51 -0
- package/tools/lint-strings/fixtures/properties/lint_fixture.h +33 -0
- package/tools/lint-strings/fixtures/rpk/lint_cmd.go +69 -0
- package/tools/lint-strings/go-source.js +327 -0
- package/tools/lint-strings/index.js +229 -0
- package/tools/lint-strings/rules/common.js +148 -0
- package/tools/lint-strings/rules/verbatim-asciidoc.js +108 -0
- package/tools/lint-strings/source-text.js +41 -0
- package/tools/lint-strings/surfaces/connect.js +233 -0
- package/tools/lint-strings/surfaces/crd.js +239 -0
- package/tools/lint-strings/surfaces/helm.js +159 -0
- package/tools/lint-strings/surfaces/metrics.js +368 -0
- package/tools/lint-strings/surfaces/properties.js +216 -0
- package/tools/lint-strings/surfaces/rpk.js +377 -0
- package/tools/overrides-audit/README.adoc +152 -0
- package/tools/overrides-audit/adapters/connect.js +144 -0
- package/tools/overrides-audit/adapters/properties.js +117 -0
- package/tools/overrides-audit/adapters/rpk.js +192 -0
- package/tools/overrides-audit/classify.js +546 -0
- package/tools/overrides-audit/index.js +185 -0
- package/tools/preview-string/index.js +245 -0
- package/tools/property-extractor/Makefile +9 -4
- package/tools/property-extractor/parser.py +26 -0
- package/tools/property-extractor/property_extractor.py +41 -0
- package/tools/property-extractor/transformers.py +15 -2
- package/tools/rpk-docs/detect-platform-commands.js +8 -1
- package/tools/rpk-docs/scan-deprecated-commands.js +15 -18
|
@@ -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
|
+
}
|