@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,377 @@
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+
6
+ const { SourceCache } = require('../source-text')
7
+ const {
8
+ maskComments,
9
+ findBalancedClose,
10
+ splitTopLevelArgs,
11
+ collectStringConsts,
12
+ evalStringExpr,
13
+ lineOf,
14
+ collectGoFiles
15
+ } = require('../go-source')
16
+
17
+ /**
18
+ * rpk surface: cobra.Command Use/Short/Long strings and pflag usage strings
19
+ * in src/go/rpk/pkg/cli.
20
+ *
21
+ * Direct Go source scan (comment-masked regex/paren scanner in the style of
22
+ * surfaces/metrics.js) so findings carry exact file:line spans:
23
+ * - cobra.Command composite literals -> Short and Long declarations (Use is
24
+ * captured to name the command, not linted as prose),
25
+ * - .Flags()/.PersistentFlags() registration calls (StringVar, BoolVarP,
26
+ * Int32Var, Duration, ...) -> flag name + usage string.
27
+ *
28
+ * rpk is NOT a verbatim surface: generate-rpk-docs.js formatDescription()
29
+ * rewrites these strings (~40 passes: auto-backticking, e.g. -> "for
30
+ * example,", ALLCAPS -> === headings, ensurePeriod) before publication, so
31
+ * escaping rules do not apply. The convention rules here catch what the
32
+ * transformer cannot fix: lowercase starts, terminal periods, name echoes,
33
+ * and Long-text patterns that change page structure when transformed.
34
+ */
35
+
36
+ const CONVENTION = {
37
+ case: 'sentence',
38
+ terminal_period: false,
39
+ verbatim_asciidoc: false,
40
+ transformer: 'formatDescription'
41
+ }
42
+
43
+ // Composite literal only: gofmt writes `cobra.Command{` with no space, while
44
+ // a function signature returning *cobra.Command always has a space (or a
45
+ // star) before its body brace - so this never swallows a function body.
46
+ const COMMAND_LITERAL = /\bcobra\.Command\{/g
47
+ // Registration methods are pflag value-type names (String, BoolVarP,
48
+ // Int32Var, DurationP, StringSlice, ...). Accessors (Lookup, Set,
49
+ // GetString, MarkHidden, ...) do not start with a value-type prefix.
50
+ const FLAG_CALL = /\.\s*(?:Flags|PersistentFlags)\s*\(\s*\)\s*\.\s*([A-Za-z0-9]+)\s*\(/g
51
+ const FLAG_METHOD = /^(String|Bool|Int|Uint|Float|Duration|Count|Bytes|IP)[A-Za-z0-9]*$/
52
+ // Flag sets bound to a variable first: `f := cmd.Flags()` ... `f.StringVar(...)`.
53
+ const FLAGSET_ASSIGN = /\b([A-Za-z_][A-Za-z0-9_]*)\s*:?=\s*[\w.]+\.(?:Flags|PersistentFlags)\s*\(\s*\)/g
54
+ // Hidden and deprecated flags never reach the generated docs (cobra help and
55
+ // printtree.go both skip them), so their usage strings are not linted.
56
+ const MARK_UNPUBLISHED = /\.\s*Mark(?:Hidden|Deprecated)\s*\(/g
57
+
58
+ /**
59
+ * Parse the top-level fields of a composite literal body.
60
+ * Returns Map(fieldName -> { start, end }) with offsets relative to body.
61
+ */
62
+ function parseLiteralFields (body) {
63
+ const fields = new Map()
64
+ let depth = 0
65
+ let i = 0
66
+ while (i < body.length) {
67
+ const ch = body[i]
68
+ if (ch === '"' || ch === '`' || ch === "'") {
69
+ let j = i + 1
70
+ if (ch === '`') {
71
+ while (j < body.length && body[j] !== '`') j++
72
+ } else {
73
+ while (j < body.length && body[j] !== ch) j += body[j] === '\\' ? 2 : 1
74
+ }
75
+ i = j + 1
76
+ continue
77
+ }
78
+ if (ch === '(' || ch === '{' || ch === '[') { depth++; i++; continue }
79
+ if (ch === ')' || ch === '}' || ch === ']') { depth--; i++; continue }
80
+ if (depth === 0 && /[A-Za-z_]/.test(ch) && (i === 0 || /[\s,{]/.test(body[i - 1]))) {
81
+ let j = i
82
+ while (j < body.length && /[A-Za-z0-9_]/.test(body[j])) j++
83
+ // A field key is `Ident:` not followed by another ':' (rules out `::`).
84
+ if (body[j] === ':' && body[j + 1] !== ':' && body[j + 1] !== '=') {
85
+ const name = body.slice(i, j)
86
+ const valueStart = j + 1
87
+ const valueEnd = findTopLevelComma(body, valueStart)
88
+ fields.set(name, { start: i, valueStart, end: valueEnd })
89
+ i = valueEnd + 1
90
+ continue
91
+ }
92
+ i = j
93
+ continue
94
+ }
95
+ i++
96
+ }
97
+ return fields
98
+ }
99
+
100
+ /** Offset of the next top-level comma at/after start (or body end). */
101
+ function findTopLevelComma (body, start) {
102
+ let depth = 0
103
+ let i = start
104
+ while (i < body.length) {
105
+ const ch = body[i]
106
+ if (ch === '"' || ch === '`' || ch === "'") {
107
+ let j = i + 1
108
+ if (ch === '`') {
109
+ while (j < body.length && body[j] !== '`') j++
110
+ } else {
111
+ while (j < body.length && body[j] !== ch) j += body[j] === '\\' ? 2 : 1
112
+ }
113
+ i = j + 1
114
+ continue
115
+ }
116
+ if (ch === '(' || ch === '{' || ch === '[') depth++
117
+ if (ch === ')' || ch === '}' || ch === ']') depth--
118
+ if (ch === ',' && depth === 0) return i
119
+ i++
120
+ }
121
+ return body.length
122
+ }
123
+
124
+ /**
125
+ * Scan one file's content for rpk doc-string declarations.
126
+ * Exported for tests.
127
+ */
128
+ function scanFile (content, file) {
129
+ const declarations = []
130
+ const masked = maskComments(content)
131
+ const consts = collectStringConsts(masked)
132
+
133
+ // cobra.Command composite literals -> Short / Long
134
+ COMMAND_LITERAL.lastIndex = 0
135
+ let match
136
+ while ((match = COMMAND_LITERAL.exec(masked)) !== null) {
137
+ const openIndex = match.index + match[0].length - 1
138
+ const closeIndex = findBalancedClose(masked, openIndex, '{', '}')
139
+ if (closeIndex === -1) continue
140
+ const bodyStart = openIndex + 1
141
+ const body = masked.slice(bodyStart, closeIndex)
142
+ const fields = parseLiteralFields(body)
143
+
144
+ let commandName = null
145
+ let use = null
146
+ if (fields.has('Use')) {
147
+ const f = fields.get('Use')
148
+ const result = evalStringExpr(body.slice(f.valueStart, f.end), consts)
149
+ if (result.verifiable) {
150
+ use = result.value
151
+ commandName = use.trim().split(/\s+/)[0] || null
152
+ }
153
+ }
154
+
155
+ for (const kind of ['Short', 'Long']) {
156
+ if (!fields.has(kind)) continue
157
+ const f = fields.get(kind)
158
+ const result = evalStringExpr(body.slice(f.valueStart, f.end), consts)
159
+ declarations.push({
160
+ surface: 'rpk',
161
+ name: commandName,
162
+ file,
163
+ line_start: lineOf(masked, bodyStart + f.start),
164
+ line_end: lineOf(masked, bodyStart + f.end),
165
+ string: result.verifiable ? result.value : null,
166
+ declaration_text: null,
167
+ convention: CONVENTION,
168
+ meta: {
169
+ kind: kind.toLowerCase(),
170
+ use,
171
+ unverifiable: !result.verifiable
172
+ }
173
+ })
174
+ }
175
+ COMMAND_LITERAL.lastIndex = closeIndex + 1
176
+ }
177
+
178
+ // Flag registrations -> flag usage strings. Two shapes: direct chains
179
+ // (cmd.Flags().BoolVarP(...)) and flag sets bound to a variable first
180
+ // (f := cmd.Flags(); f.StringVar(...)).
181
+ const flagCalls = []
182
+ FLAG_CALL.lastIndex = 0
183
+ while ((match = FLAG_CALL.exec(masked)) !== null) {
184
+ flagCalls.push({ index: match.index, method: match[1], openIndex: match.index + match[0].length - 1 })
185
+ }
186
+ const flagsetVars = new Set()
187
+ FLAGSET_ASSIGN.lastIndex = 0
188
+ while ((match = FLAGSET_ASSIGN.exec(masked)) !== null) flagsetVars.add(match[1])
189
+ if (flagsetVars.size > 0) {
190
+ const varCall = new RegExp(`\\b(${[...flagsetVars].join('|')})\\s*\\.\\s*([A-Za-z0-9]+)\\s*\\(`, 'g')
191
+ while ((match = varCall.exec(masked)) !== null) {
192
+ flagCalls.push({ index: match.index, method: match[2], openIndex: match.index + match[0].length - 1 })
193
+ }
194
+ flagCalls.sort((a, b) => a.index - b.index)
195
+ }
196
+
197
+ // Flags marked hidden or deprecated anywhere in the file are excluded:
198
+ // they never ship to the generated docs, so their usage strings (often
199
+ // intentionally empty) are not part of the docs contract.
200
+ const unpublished = new Set()
201
+ MARK_UNPUBLISHED.lastIndex = 0
202
+ while ((match = MARK_UNPUBLISHED.exec(masked)) !== null) {
203
+ const openIndex = match.index + match[0].length - 1
204
+ const closeIndex = findBalancedClose(masked, openIndex)
205
+ if (closeIndex === -1) continue
206
+ const args = splitTopLevelArgs(masked.slice(openIndex + 1, closeIndex))
207
+ if (args.length === 0) continue
208
+ const result = evalStringExpr(args[0], consts)
209
+ if (result.verifiable) unpublished.add(result.value)
210
+ }
211
+
212
+ for (const call of flagCalls) {
213
+ if (!FLAG_METHOD.test(call.method)) continue
214
+ const openIndex = call.openIndex
215
+ const closeIndex = findBalancedClose(masked, openIndex)
216
+ if (closeIndex === -1) continue
217
+ const args = splitTopLevelArgs(masked.slice(openIndex + 1, closeIndex))
218
+ if (args.length < 2) continue
219
+
220
+ // Flag name: the first argument that is a resolvable string (Var forms
221
+ // put &target first). Shorthand ("w") is the following one-char string.
222
+ let flagName = null
223
+ for (const arg of args.slice(0, -1)) {
224
+ const result = evalStringExpr(arg, consts)
225
+ if (result.verifiable) {
226
+ flagName = result.value
227
+ break
228
+ }
229
+ }
230
+ if (flagName != null && unpublished.has(flagName)) continue
231
+ const usage = evalStringExpr(args[args.length - 1], consts)
232
+
233
+ declarations.push({
234
+ surface: 'rpk',
235
+ name: flagName,
236
+ file,
237
+ line_start: lineOf(masked, call.index),
238
+ line_end: lineOf(masked, closeIndex),
239
+ string: usage.verifiable ? usage.value : null,
240
+ declaration_text: null,
241
+ convention: CONVENTION,
242
+ meta: {
243
+ kind: 'flag',
244
+ method: call.method,
245
+ unverifiable: !usage.verifiable
246
+ }
247
+ })
248
+ }
249
+
250
+ return declarations
251
+ }
252
+
253
+ /**
254
+ * Extract rpk declarations.
255
+ *
256
+ * @param {Object} options - { repo, files (Set of repo-relative paths, diff
257
+ * mode; when omitted, scans src/go/rpk/pkg/cli), log }
258
+ */
259
+ function extract ({ repo, files = null }) {
260
+ const CLI_ROOT = path.join('src', 'go', 'rpk', 'pkg', 'cli')
261
+ let fileList
262
+ if (files) {
263
+ fileList = [...files].filter((f) => f.endsWith('.go') && !f.endsWith('_test.go'))
264
+ } else {
265
+ fileList = collectGoFiles(path.join(repo, CLI_ROOT)).map((f) => path.join(CLI_ROOT, f))
266
+ }
267
+
268
+ const cache = new SourceCache(repo)
269
+ const declarations = []
270
+ for (const file of fileList) {
271
+ const absPath = path.isAbsolute(file) ? file : path.join(repo, file)
272
+ if (!fs.existsSync(absPath)) continue
273
+ const content = fs.readFileSync(absPath, 'utf8')
274
+ if (!content.includes('cobra.Command') && !content.includes('Flags()')) continue
275
+ for (const decl of scanFile(content, file)) {
276
+ decl.declaration_text = cache.span(file, decl.line_start, decl.line_end)
277
+ declarations.push(decl)
278
+ }
279
+ }
280
+ return declarations
281
+ }
282
+
283
+ /**
284
+ * Mirror of parseDescriptionSections' section-header heuristic in
285
+ * tools/rpk-docs/generate-rpk-docs.js: an all-caps line (no double spaces,
286
+ * not directly under a "$ command" invocation) is promoted to a `===`
287
+ * heading in the generated docs.
288
+ */
289
+ const ALLCAPS_LINE = /^([A-Z][A-Z\s\-/&]*[A-Z])$/
290
+
291
+ function isHeadingLine (lines, i) {
292
+ const line = lines[i]
293
+ if (/ {2}/.test(line) || !ALLCAPS_LINE.test(line)) return false
294
+ for (let p = i - 1; p >= 0; p--) {
295
+ if (lines[p].trim() === '') break
296
+ if (/^\$\s/.test(lines[p])) return false
297
+ }
298
+ return true
299
+ }
300
+
301
+ /** Surface-specific convention rules. */
302
+ const RULES = [
303
+ {
304
+ name: 'rpk-short-multiline',
305
+ description: 'Short must be a single line',
306
+ severity: 'warning',
307
+ check: (decl) => {
308
+ if (decl.meta.kind !== 'short') return []
309
+ const text = (decl.string || '').trim()
310
+ if (text.includes('\n')) {
311
+ return [{ message: 'Short spans multiple lines. The rpk convention is a one-line Short; move detail into Long.' }]
312
+ }
313
+ return []
314
+ }
315
+ },
316
+ {
317
+ name: 'rpk-terminal-period',
318
+ description: 'Short and flag usage strings do not end with a period',
319
+ severity: 'warning',
320
+ check: (decl) => {
321
+ if (decl.meta.kind !== 'short' && decl.meta.kind !== 'flag') return []
322
+ const text = (decl.string || '').trim()
323
+ if (!text) return []
324
+ if (/\.$/.test(text)) {
325
+ const what = decl.meta.kind === 'flag' ? 'Flag usage' : 'Short'
326
+ return [{ message: `${what} ends with a period: "...${text.slice(-50)}". The rpk convention is no terminal period; formatDescription adds one where the output needs it.` }]
327
+ }
328
+ return []
329
+ }
330
+ },
331
+ {
332
+ name: 'rpk-long-allcaps-heading',
333
+ description: 'ALLCAPS line in Long becomes a === heading in the docs',
334
+ severity: 'info',
335
+ check: (decl) => {
336
+ if (decl.meta.kind !== 'long' || !decl.string) return []
337
+ const issues = []
338
+ const lines = decl.string.split('\n')
339
+ for (let i = 0; i < lines.length; i++) {
340
+ if (isHeadingLine(lines, i)) {
341
+ issues.push({ message: `"${lines[i].trim()}" is an ALLCAPS line, which formatDescription promotes to a "===" heading on the docs page. Keep it if a section heading is intended; reword it if not.` })
342
+ }
343
+ }
344
+ return issues
345
+ }
346
+ },
347
+ {
348
+ name: 'rpk-long-block-delimiter',
349
+ description: 'A standalone ==== or ---- line in Long breaks AsciiDoc blocks',
350
+ severity: 'warning',
351
+ check: (decl) => {
352
+ if (decl.meta.kind !== 'long' || !decl.string) return []
353
+ const issues = []
354
+ const lines = decl.string.split('\n')
355
+ for (let i = 0; i < lines.length; i++) {
356
+ if (!/^\s*(={4,}|-{4,})\s*$/.test(lines[i])) continue
357
+ // An underline directly below an ALLCAPS heading is consumed by the
358
+ // section parser; anywhere else it ships as an unterminated AsciiDoc
359
+ // block delimiter and breaks the page.
360
+ if (i > 0 && isHeadingLine(lines, i - 1)) continue
361
+ issues.push({ message: `Line ${i + 1} of Long is a run of "${lines[i].trim()[0]}" characters. Outside a heading underline, this renders as an AsciiDoc block delimiter and breaks the generated page.` })
362
+ }
363
+ return issues
364
+ }
365
+ }
366
+ ]
367
+
368
+ module.exports = {
369
+ name: 'rpk',
370
+ convention: CONVENTION,
371
+ extract,
372
+ scanFile,
373
+ rules: RULES,
374
+ // Shorts and flag usages are one-liners by convention; the generic
375
+ // too-short prose rule would flag nearly every conforming declaration.
376
+ skipRules: ['too-short']
377
+ }
@@ -0,0 +1,152 @@
1
+ = Overrides Audit
2
+ :toc:
3
+
4
+ Field-level classification of docs-side override entries against extracted source strings. This tool powers the override-retirement loop: overrides that patch bad source prose are stopgaps that die once the fixed string ships upstream, while structured docs enrichment stays in the override layer by design.
5
+
6
+ == Why
7
+
8
+ `docs-data/property-overrides.json` in the docs repo carries hundreds of hand-patched property entries. Each `description` override masks the string engineers wrote in C++ source. When that source string gets fixed (upstreamed), the override becomes dead weight that silently masks all future source improvements. This audit classifies every override *field* so that:
9
+
10
+ * redundant fields can be deleted automatically on each release regeneration (retirement workflow),
11
+ * upstreamable prose can be drafted into source PRs against the engineering repo (upstreaming workflow),
12
+ * by-design docs enrichment is left alone.
13
+
14
+ == Usage
15
+
16
+ [,bash]
17
+ ----
18
+ # Through doc-tools
19
+ npx doc-tools overrides audit \
20
+ --overrides docs-data/property-overrides.json \
21
+ --extracted tools/property-extractor/gen/properties-output.json \
22
+ --format human
23
+
24
+ # Standalone
25
+ node tools/overrides-audit/index.js --overrides <path> --extracted <path> [--surface properties|rpk|connect] [--format json|human] [--output <file>]
26
+ ----
27
+
28
+ IMPORTANT: `--extracted` must be the property extractor's *raw* output (the `--output` file), never the enhanced output (`--enhanced-output`) or the versioned `redpanda-properties-<tag>.json` attachments: those already have overrides applied, so every override would classify as REDUNDANT. The same trap exists for rpk: the versioned `rpk-v<version>.json` files in docs repos are post-override snapshots.
29
+
30
+ To produce a raw extraction from a local Redpanda checkout:
31
+
32
+ [,bash]
33
+ ----
34
+ cd tools/property-extractor
35
+ make venv treesitter # one-time setup
36
+ tmp/redpanda-property-extractor-venv/bin/python -W ignore property_extractor.py \
37
+ --recursive --path <path-to-redpanda-checkout> \
38
+ --output gen/source-properties.json
39
+ ----
40
+
41
+ == Classification rules (properties surface)
42
+
43
+ Each field of each override entry gets one manifest row.
44
+
45
+ [cols="1,1,3"]
46
+ |===
47
+ |Field |Class |Rule
48
+
49
+ |`description`
50
+ |`REDUNDANT`
51
+ |Override equals the extracted source description after normalization (whitespace collapsed, lines unwrapped). Safe to delete in the next auto-docs PR.
52
+
53
+ |`description`
54
+ |`UPSTREAMABLE`
55
+ |Differs from source and is free of docs-only markup. `upstream_candidate_text` carries the override text verbatim.
56
+
57
+ |`description`
58
+ |`KEEP_UNTIL_UPSTREAMED`
59
+ |Differs from source and contains docs-only markup (the SPLIT case). `upstream_candidate_text` carries the markup-stripped prose; the override keeps masking source until that prose ships upstream.
60
+
61
+ |`description`
62
+ |`KEEP`
63
+ |Contains markup, but the stripped prose already matches source: markup-only enrichment, nothing to upstream.
64
+
65
+ |`example`
66
+ |`UPSTREAMABLE_SLOT`
67
+ |Property declarations have a `.example` metadata slot in C++ source; migrate the value there.
68
+
69
+ |`example_file` / `example_yaml`
70
+ |`KEEP`
71
+ |File- and YAML-rendered examples are docs-only rendering with no source slot.
72
+
73
+ |`accepted_values`
74
+ |`REDUNDANT_OR_UPSTREAMABLE`
75
+ |The extracted property is an enum. The override may merely restate the enum (redundant) or deliberately hide internal-only values (for example `leader_balancer_mode` hides `greedy`). Always needs a human ruling.
76
+
77
+ |`accepted_values`
78
+ |`KEEP`
79
+ |The extracted property is not an enum; the values are docs-side enrichment.
80
+
81
+ |`default` / `type`
82
+ |`REDUNDANT`
83
+ |Equal to the extracted value.
84
+
85
+ |`default` / `type`
86
+ |`REVIEW`
87
+ |Differs from the extracted value: possible source bug. Never auto-delete.
88
+
89
+ |`related_topics`, `category`, `config_scope`, `version`, `exclude_from_docs`, `descriptionScope`, page attributes
90
+ |`KEEP`
91
+ |Docs-layer structure by design; source strings cannot carry it.
92
+
93
+ |`acceptable_values` (typo)
94
+ |`REVIEW`
95
+ |Known typo'd key: the extractor only honors `accepted_values`, so this key is silently ignored. Flagged wherever found.
96
+ |===
97
+
98
+ Any field on a property that is missing from the extracted JSON classifies `REVIEW` (the override may create a doc-only property, the property may have been removed upstream, or the extractor may not see the declaration -- `advertised_kafka_api` and `advertised_rpc_api` are known extractor blind spots).
99
+
100
+ === Docs-only markup
101
+
102
+ A description counts as markup-laden when it contains any of: `xref:`, `glossterm:`, `include::`, `ifdef::`/`ifndef::`/`endif::`, `pass:`, `config_ref:`/`prop:` property-link macros, `<<anchor>>` cross-references (anchors only exist on the generated page), or unescaped `{attr}` attribute references beyond the product-attr allowlist (`PRODUCT_ATTR_ALLOWLIST` in `classify.js`, empty today; escaped `\{attr}` literals are fine).
103
+
104
+ The stripper converts markup to plain prose: `xref:page[text]` keeps the text, `glossterm:Term[def]` keeps the term, `<<anchor,text>>` keeps the text, `config_ref:`/`prop:` macros become the backticked property name (or their display text), `pass:q[content]` keeps the content, and `include::`/conditional directive lines are dropped.
105
+
106
+ == The `upstream_ref` policy field
107
+
108
+ Description overrides may carry an *optional* `upstream_ref` field: a URL or identifier of the engineering-repo PR or issue that upstreams the fix, so every description override is born with a retirement path.
109
+
110
+ [,json]
111
+ ----
112
+ "my_property": {
113
+ "description": "The corrected description.",
114
+ "upstream_ref": "https://github.com/redpanda-data/redpanda/pull/12345"
115
+ }
116
+ ----
117
+
118
+ The audit carries `upstream_ref` from the override entry into the manifest row for the description field. The extractor ignores unknown keys on existing properties, so adding `upstream_ref` is safe today; the docs-repo `validate-docs-data` workflow is the intended home for a warn-only check that new or changed description overrides carry it. This tool never modifies the overrides file.
119
+
120
+ == Output
121
+
122
+ JSON (default): `{ surface, overrides_file, extracted_file, manifest, summary, cross_check }`.
123
+
124
+ * `manifest`: one row per classified field: `{ name, field, class, upstream_candidate_text?, upstream_ref?, content_hash?, source_file?, source_line?, note }`.
125
+ ** `content_hash` is a stable 16-hex-char digest of `(name, normalized override description)`. The docs-repo upstreaming workflow keeps a manifest of already-PRed hashes, so re-runs skip candidates whose hash is unchanged and re-draft when the override text moved.
126
+ ** `source_line` is populated only when the extracted JSON carries `line_start` (pending the extractor line-span patch); today it is absent.
127
+ * `summary`: total, counts per class, and counts per (field, class).
128
+ * `cross_check` (properties only): reuses `tools/property-extractor/compare-properties.js` -- the override descriptions are overlaid on the extracted properties and diffed. Every raw-equal description must have classified `REDUNDANT`; `violations` lists classifier bugs (expected empty).
129
+
130
+ `--format human` prints the summary plus per-class listings for the actionable classes.
131
+
132
+ == Surfaces
133
+
134
+ * `properties` (complete): `property-overrides.json` vs the property extractor's raw JSON.
135
+ * `rpk` (structural): enumerates `rpk-overrides.json` (see `docs-data/rpk-overrides.schema.json`) -- command/flag `description` fields are the upstreamable prose (classified `REVIEW` with a TODO note until source comparison lands; it needs a raw pre-override `--print-tree` snapshot plus the `formatDescription()` pipeline from `tools/rpk-docs`), everything else (`seeAlso`, `pageAliases`, `content`, platform scoping, ...) is `KEEP` by design.
136
+ * `connect` (structural): enumerates the rp-connect-docs `overrides.json` (`definitions` + per-component arrays with nested `config.children`); prose fields classify `REVIEW` with a TODO note until `$ref` resolution and plugin-JSON comparison land.
137
+
138
+ == Golden run (local checkouts required)
139
+
140
+ The class distribution against live data depends on local checkouts of the docs repo and Redpanda source, so it is a documented command rather than a CI jest test:
141
+
142
+ [,bash]
143
+ ----
144
+ # 1. Extract raw source properties (see setup above)
145
+ # 2. Audit the live overrides file
146
+ node tools/overrides-audit/index.js \
147
+ --overrides <docs-repo>/docs-data/property-overrides.json \
148
+ --extracted tools/property-extractor/gen/source-properties.json \
149
+ --format human
150
+ ----
151
+
152
+ Sanity expectations for the live 445-entry file: ~343 description fields classified; tens of REDUNDANT (source already matches); a large UPSTREAMABLE plus KEEP_UNTIL_UPSTREAMED (SPLIT) population; exactly one `acceptable_values` typo flag; `cross_check.violations` empty.
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Overrides Audit - Redpanda Connect Adapter (structural)
3
+ *
4
+ * Reads the rp-connect-docs docs-data/overrides.json:
5
+ * - top-level `definitions`: shared field descriptions referenced with
6
+ * `{"$ref": "#/definitions/<name>"}`
7
+ * - component sections (`inputs`, `outputs`, `processors`, `caches`,
8
+ * `tracers`, `scanners`, `rate-limits`): ARRAYS of connector entries
9
+ * `{ name, summary?, description?, config?: { children: [...] },
10
+ * examples?, version?, ... }` where config children nest recursively
11
+ * (`{ name, description?, children? , $ref? }`).
12
+ *
13
+ * The prose fields (summary, description, config field descriptions) map to
14
+ * ConfigSpec strings in connect Go source (`.Summary()`, `.Description()`,
15
+ * `NewXField(n).Description(...)`).
16
+ *
17
+ * TODO(quality logic): source comparison for connect is deferred. The
18
+ * extracted side is the connect plugin JSON (rpk connect list --format json
19
+ * or the cached connect-v<ver>.json in rp-connect-docs docs-data), and the
20
+ * comparison must resolve $refs before classifying, then apply the same
21
+ * markup rules as the properties surface (connect descriptions are AsciiDoc
22
+ * inside Go strings, so xref:/glossterm: usage is docs-only there too).
23
+ * Until then, prose fields classify REVIEW with an explicit note and
24
+ * everything else classifies KEEP, so the manifest shape is stable.
25
+ */
26
+
27
+ 'use strict'
28
+
29
+ const classify = require('../classify')
30
+ const { loadJson } = require('./properties')
31
+
32
+ const COMPONENT_SECTIONS = [
33
+ 'inputs',
34
+ 'outputs',
35
+ 'processors',
36
+ 'caches',
37
+ 'tracers',
38
+ 'scanners',
39
+ 'rate-limits',
40
+ 'buffers',
41
+ 'metrics'
42
+ ]
43
+
44
+ const TODO_NOTE = 'TODO: connect source comparison not implemented yet (needs the connect plugin JSON and $ref resolution).'
45
+
46
+ /**
47
+ * Build a REVIEW row for a connect prose field.
48
+ *
49
+ * @param {string} name - Unit name (for example "inputs/amqp_0_9/urls").
50
+ * @param {string} field - Manifest field label.
51
+ * @param {string} text - Override text.
52
+ * @returns {Object} Manifest row.
53
+ */
54
+ function proseRow (name, field, text) {
55
+ return {
56
+ name,
57
+ field,
58
+ class: classify.CLASSES.REVIEW,
59
+ content_hash: classify.contentHash(name, text),
60
+ note: TODO_NOTE
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Walk a config children tree, emitting one row per field description.
66
+ *
67
+ * @param {string} prefix - Name prefix ("inputs/amqp_0_9").
68
+ * @param {Object[]} children - Config children array.
69
+ * @param {Object[]} manifest - Output rows (mutated).
70
+ */
71
+ function walkConfigChildren (prefix, children, manifest) {
72
+ if (!Array.isArray(children)) return
73
+ for (const child of children) {
74
+ if (!child || typeof child !== 'object') continue
75
+ const childName = `${prefix}/${child.name || '(unnamed)'}`
76
+ if (typeof child.description === 'string') {
77
+ manifest.push(proseRow(childName, 'config.description', child.description))
78
+ }
79
+ if (child.$ref) {
80
+ manifest.push({
81
+ name: childName,
82
+ field: 'config.$ref',
83
+ class: classify.CLASSES.KEEP,
84
+ note: `Shared definition reference (${child.$ref}); audit the referenced definition instead.`
85
+ })
86
+ }
87
+ if (child.children) walkConfigChildren(childName, child.children, manifest)
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Run the audit for the connect surface (structural pass).
93
+ *
94
+ * @param {Object} args - { overridesPath, extractedPath }.
95
+ * @returns {Object} { surface, manifest, summary }.
96
+ */
97
+ function audit ({ overridesPath, extractedPath }) {
98
+ const overridesDoc = loadJson(overridesPath, 'connect overrides')
99
+
100
+ const manifest = []
101
+
102
+ const definitions = overridesDoc.definitions
103
+ if (definitions && typeof definitions === 'object') {
104
+ for (const [defName, def] of Object.entries(definitions)) {
105
+ if (def && typeof def.description === 'string') {
106
+ manifest.push(proseRow(`definitions/${defName}`, 'description', def.description))
107
+ }
108
+ }
109
+ }
110
+
111
+ for (const section of COMPONENT_SECTIONS) {
112
+ const entries = overridesDoc[section]
113
+ if (!Array.isArray(entries)) continue
114
+ for (const entry of entries) {
115
+ if (!entry || typeof entry !== 'object') continue
116
+ const entryName = `${section}/${entry.name || '(unnamed)'}`
117
+ for (const [field, value] of Object.entries(entry)) {
118
+ if (field === 'name') continue
119
+ if (field === 'summary' || field === 'description') {
120
+ manifest.push(proseRow(entryName, field, value))
121
+ } else if (field === 'config' && value && typeof value === 'object') {
122
+ walkConfigChildren(entryName, value.children, manifest)
123
+ } else {
124
+ manifest.push({
125
+ name: entryName,
126
+ field,
127
+ class: classify.CLASSES.KEEP,
128
+ note: 'Docs-side connector metadata; stays in the override file by design.'
129
+ })
130
+ }
131
+ }
132
+ }
133
+ }
134
+
135
+ return {
136
+ surface: 'connect',
137
+ overrides_file: overridesPath,
138
+ extracted_file: extractedPath || null,
139
+ manifest,
140
+ summary: classify.summarize(manifest)
141
+ }
142
+ }
143
+
144
+ module.exports = { audit, COMPONENT_SECTIONS }