@redpanda-data/docs-extensions-and-macros 5.31.0 → 5.35.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.
@@ -17,6 +17,14 @@ const { collectGoFiles } = require('../go-source')
17
17
  * the processor ignoreTypes/ignoreFields regexes, so fields the generator
18
18
  * never documents are never linted.
19
19
  *
20
+ * `+hidefromdoc` suppresses the TYPE's own section, not its fields. A hidden
21
+ * type embedded with `json:\",inline\"` still has every one of its fields
22
+ * published, flattened into the parent's table. Treating the marker as hiding
23
+ * the fields too made this surface blind to exactly the fields the Console CRD
24
+ * reference is missing: `ConsoleValues` is `+hidefromdoc` and inlined into
25
+ * `ConsoleSpec`, so 27 of the consoles CRD's 38 top-level `spec` properties
26
+ * ship blank while the linter reported none of them (DOC-2455).
27
+ *
20
28
  * Marker lines (+kubebuilder:..., +optional, +required, +genclient, and any
21
29
  * other +directive) are stripped before the prose is judged.
22
30
  *
@@ -24,6 +32,30 @@ const { collectGoFiles } = require('../go-source')
24
32
  * field identifier ("ClusterSource is a reference to...") when users type
25
33
  * the json key ("cluster") - the docs and kubectl explain both show the
26
34
  * json name, so the Go name means nothing to readers.
35
+ *
36
+ * A field with NO doc comment does not necessarily ship blank.
37
+ * controller-gen falls back to the doc comment of the field's TYPE, so
38
+ *
39
+ * // +optional
40
+ * OAUth *KafkaSASLOAuthBearer `json:"oauth,omitempty"`
41
+ *
42
+ * publishes "KafkaSASLOAuthBearer is the config struct for the SASL
43
+ * OAuthBearer mechanism" - the Go type's comment, under the key `oauth`.
44
+ * Verified by running controller-gen v0.20.1 over a fixture covering every
45
+ * shape:
46
+ *
47
+ * field comment present -> the field's comment (beats the type's)
48
+ * no comment, ref to typed X -> X's doc comment, INHERITED
49
+ * no comment, ref to undocumented X -> blank
50
+ * no comment, primitive -> blank
51
+ * no comment, []X or map[K]X -> BLANK on the field itself; X's comment
52
+ * lands on items/additionalProperties
53
+ *
54
+ * So `undocumented-field` must only fire where the description genuinely
55
+ * ships blank. Of 208 findings on operator main it was wrong on 37: 19
56
+ * inheriting a local type's comment and 18 an external one's. The inherited
57
+ * case is still a defect - 18 of those 19 lead with the Go type name - but
58
+ * it is a different one, so it gets its own rule and its own message.
27
59
  */
28
60
 
29
61
  const CONVENTION = {
@@ -71,6 +103,110 @@ function matchesAny (patterns, ...candidates) {
71
103
  return patterns.some((pattern) => candidates.some((c) => pattern.test(c)))
72
104
  }
73
105
 
106
+ /**
107
+ * Go predeclared types. A field of one of these has nothing to inherit a
108
+ * description from, so no comment means it genuinely ships blank.
109
+ */
110
+ /**
111
+ * External types that controller-gen maps structurally and publishes with NO
112
+ * description, so a field referencing one inherits nothing and does ship blank.
113
+ *
114
+ * Kept as an explicit short list rather than guessed, because an external
115
+ * type's doc comment is not in this checkout: for every other external type in
116
+ * the operator API the inherited description is real prose. To check whether a
117
+ * type belongs here, look the field up in
118
+ * `operator/config/crd/bases/*.yaml` - `runtime.RawExtension` becomes
119
+ * `x-kubernetes-preserve-unknown-fields` and `metav1.Time` a bare
120
+ * date-time string, both with no `description`.
121
+ */
122
+ const UNDESCRIBED_EXTERNAL_TYPES = new Set([
123
+ 'runtime.RawExtension',
124
+ 'metav1.Time'
125
+ ])
126
+
127
+ const GO_BUILTINS = new Set([
128
+ 'bool', 'string', 'byte', 'rune', 'error', 'any',
129
+ 'int', 'int8', 'int16', 'int32', 'int64',
130
+ 'uint', 'uint8', 'uint16', 'uint32', 'uint64', 'uintptr',
131
+ 'float32', 'float64', 'complex64', 'complex128'
132
+ ])
133
+
134
+ /**
135
+ * Doc comments on the `type` declarations in one file, as
136
+ * `name -> prose`. Marker lines are stripped, matching what controller-gen
137
+ * publishes.
138
+ *
139
+ * Collected per file and merged across the tree by `extract`, because a
140
+ * field's type is usually declared in a different file from the field
141
+ * (`ValueSource` lives in common.go and is referenced from four others).
142
+ */
143
+ /**
144
+ * Type names embedded with `json:\",inline\"` in this file. Their fields are
145
+ * flattened into the embedding type, so they are published even when the type
146
+ * itself is `+hidefromdoc`. Only unqualified names are collected: a qualified
147
+ * one (`metav1.TypeMeta`) is declared in another module and is not ours.
148
+ */
149
+ function collectInlinedTypes (content) {
150
+ const inlined = new Set()
151
+ const re = /^\s*([A-Z][A-Za-z0-9_]*)\s+`json:\",inline\"`/gm
152
+ let match
153
+ while ((match = re.exec(content)) !== null) inlined.add(match[1])
154
+ return inlined
155
+ }
156
+
157
+ function collectTypeDocs (content) {
158
+ const docs = new Map()
159
+ const lines = content.split('\n')
160
+ let comment = []
161
+ for (const line of lines) {
162
+ const trimmed = line.trim()
163
+ if (trimmed.startsWith('//')) {
164
+ comment.push(trimmed.replace(/^\/\/\s?/, ''))
165
+ continue
166
+ }
167
+ const match = /^type\s+([A-Za-z0-9_]+)\s+/.exec(line)
168
+ if (match) {
169
+ const prose = comment.filter((c) => !c.trim().startsWith('+')).join('\n').trim()
170
+ if (prose) docs.set(match[1], prose)
171
+ comment = []
172
+ continue
173
+ }
174
+ if (trimmed !== '') comment = []
175
+ }
176
+ return docs
177
+ }
178
+
179
+ /**
180
+ * Where an uncommented field's published description will come from.
181
+ *
182
+ * @param {string} line - The field's source line
183
+ * @param {Map} typeDocs - name -> doc comment, from collectTypeDocs
184
+ * @returns {{blank: true}|{blank: false, from: string, external: boolean}}
185
+ */
186
+ function describeFallback (line, typeDocs) {
187
+ const match = /^\s*[A-Z][A-Za-z0-9_]*\s+([^`]+?)\s*(?:`|$)/.exec(line)
188
+ if (!match) return { blank: true }
189
+ const typeExpr = match[1].trim()
190
+ // A slice or map inherits onto items/additionalProperties, never onto the
191
+ // field, so the field's own description is blank either way.
192
+ if (/^\[\]|^map\[/.test(typeExpr)) return { blank: true }
193
+ const bare = typeExpr.replace(/^[*&]+/, '')
194
+ if (!/^[A-Za-z_][A-Za-z0-9_.]*$/.test(bare)) return { blank: true }
195
+ if (GO_BUILTINS.has(bare)) return { blank: true }
196
+ // A qualified name is declared in another package (corev1.ResourceRequirements),
197
+ // whose doc comment is not in this checkout. Every such field in the operator
198
+ // API inherits real prose apart from the structural types listed in
199
+ // UNDESCRIBED_EXTERNAL_TYPES, so claiming the rest ship blank would be wrong
200
+ // far more often than right; report nothing rather than guess.
201
+ if (bare.includes('.')) {
202
+ if (UNDESCRIBED_EXTERNAL_TYPES.has(bare)) return { blank: true }
203
+ return { blank: false, from: bare, external: true }
204
+ }
205
+ const doc = typeDocs.get(bare)
206
+ if (!doc) return { blank: true }
207
+ return { blank: false, from: bare, external: false, doc }
208
+ }
209
+
74
210
  /**
75
211
  * Parse one Go file's struct fields. Exported for tests.
76
212
  *
@@ -82,6 +218,10 @@ function matchesAny (patterns, ...candidates) {
82
218
  function scanFile (content, file, config = { ignoreTypes: [], ignoreFields: [], hiddenMarker: 'hidefromdoc' }) {
83
219
  const declarations = []
84
220
  const lines = content.split('\n')
221
+ // Repo-wide when extract supplies it; this file's own types otherwise, so a
222
+ // standalone scanFile still resolves same-file inheritance.
223
+ const typeDocs = config.typeDocs || collectTypeDocs(content)
224
+ const inlinedTypes = config.inlinedTypes || collectInlinedTypes(content)
85
225
  const packageMatch = content.match(/^package\s+(\w+)/m)
86
226
  const pkg = packageMatch ? packageMatch[1] : ''
87
227
 
@@ -101,11 +241,23 @@ function scanFile (content, file, config = { ignoreTypes: [], ignoreFields: [],
101
241
  const typeMatch = line.match(/^type\s+([A-Za-z0-9_]+)\s+struct\s*\{/)
102
242
  if (typeMatch) {
103
243
  const name = typeMatch[1]
104
- const hidden = comment.some((c) => c.text.trim().startsWith(`+${config.hiddenMarker}`)) ||
244
+ // `+hidefromdoc` removes the type's own section; an inlined type's
245
+ // fields are published in the parent regardless, so the marker must
246
+ // not silence them.
247
+ const markedHidden = comment.some((c) => c.text.trim().startsWith(`+${config.hiddenMarker}`))
248
+ const hidden = (markedHidden && !inlinedTypes.has(name)) ||
105
249
  matchesAny(config.ignoreTypes, name, `${pkg}.${name}`)
106
- struct = { name, hidden, exported: /^[A-Z]/.test(name) }
107
- depth = 1
250
+ // Count the closing brace on this same line. `type X struct{}` opens
251
+ // and closes at once; assuming depth 1 left the parser inside a
252
+ // struct that had already ended, so every following `type ... struct
253
+ // {` was read as a field line and its fields sat at depth 2, where
254
+ // the depth === 1 gate drops them. One such declaration silently
255
+ // blanked the rest of the file.
256
+ const netDepth = (line.match(/\{/g) || []).length - (line.match(/\}/g) || []).length
108
257
  comment = []
258
+ if (netDepth <= 0) continue
259
+ struct = { name, hidden, exported: /^[A-Z]/.test(name) }
260
+ depth = netDepth
109
261
  continue
110
262
  }
111
263
  if (trimmed !== '') comment = []
@@ -139,6 +291,10 @@ function scanFile (content, file, config = { ignoreTypes: [], ignoreFields: [],
139
291
  .map((c) => c.text)
140
292
  .join('\n')
141
293
  .trim()
294
+ // With no comment of its own, the field publishes its TYPE's comment
295
+ // if that type has one. Resolve which, so the rules can tell a blank
296
+ // description from an inherited one.
297
+ const fallback = prose ? null : describeFallback(lines[i], typeDocs)
142
298
  declarations.push({
143
299
  surface: 'crd',
144
300
  name: jsonName,
@@ -152,7 +308,12 @@ function scanFile (content, file, config = { ignoreTypes: [], ignoreFields: [],
152
308
  kind: 'field',
153
309
  struct: struct.name,
154
310
  go_name: goName,
155
- json_name: jsonName
311
+ json_name: jsonName,
312
+ // Set only when the field has no comment: the type whose comment
313
+ // ships in its place, or null when nothing does.
314
+ inherited_from: fallback && !fallback.blank ? fallback.from : null,
315
+ inherited_external: Boolean(fallback && !fallback.blank && fallback.external),
316
+ inherited_doc: fallback && !fallback.blank ? (fallback.doc || null) : null
156
317
  }
157
318
  })
158
319
  }
@@ -182,14 +343,28 @@ function extract ({ repo, files = null }) {
182
343
  .map((f) => path.join(API_ROOT, f))
183
344
  }
184
345
 
185
- const cache = new SourceCache(repo)
186
- const declarations = []
346
+ // First pass: every type doc comment in the tree, because a field's type is
347
+ // usually declared in another file.
348
+ const typeDocs = new Map()
349
+ const inlinedTypes = new Set()
350
+ const contents = new Map()
187
351
  for (const file of fileList) {
188
352
  const absPath = path.isAbsolute(file) ? file : path.join(repo, file)
189
353
  if (!fs.existsSync(absPath)) continue
190
354
  const content = fs.readFileSync(absPath, 'utf8')
355
+ contents.set(file, content)
356
+ for (const [name, doc] of collectTypeDocs(content)) typeDocs.set(name, doc)
357
+ for (const name of collectInlinedTypes(content)) inlinedTypes.add(name)
358
+ }
359
+ const scanConfig = { ...config, typeDocs, inlinedTypes }
360
+
361
+ const cache = new SourceCache(repo)
362
+ const declarations = []
363
+ for (const file of fileList) {
364
+ const content = contents.get(file)
365
+ if (content === undefined) continue
191
366
  if (!content.includes('struct')) continue
192
- for (const decl of scanFile(content, file, config)) {
367
+ for (const decl of scanFile(content, file, scanConfig)) {
193
368
  decl.declaration_text = cache.span(file, decl.line_start, decl.line_end)
194
369
  declarations.push(decl)
195
370
  }
@@ -205,9 +380,29 @@ const RULES = [
205
380
  severity: 'warning',
206
381
  check: (decl) => {
207
382
  if (decl.string !== null) return []
383
+ // controller-gen falls back to the field type's own comment, so a
384
+ // missing comment only ships blank when there is nothing to inherit.
385
+ if (decl.meta.inherited_from) return []
208
386
  return [{ message: `Field "${decl.meta.go_name}" (json: "${decl.name}") in ${decl.meta.struct} has no doc comment. It ships blank in the CRD reference and in kubectl explain.` }]
209
387
  }
210
388
  },
389
+ {
390
+ name: 'inherited-type-description',
391
+ description: 'Field with no comment publishes its Go type\'s comment instead',
392
+ severity: 'warning',
393
+ check: (decl) => {
394
+ if (decl.string !== null || !decl.meta.inherited_from) return []
395
+ // An external type's comment is upstream prose we do not own and cannot
396
+ // see from this checkout; k8s' own field docs are generally good, so
397
+ // there is nothing here to act on.
398
+ if (decl.meta.inherited_external) return []
399
+ const doc = (decl.meta.inherited_doc || '').split('\n')[0]
400
+ const leadsWithTypeName = doc.startsWith(decl.meta.inherited_from)
401
+ return [{
402
+ message: `"${decl.name}" has no doc comment of its own, so the CRD reference and kubectl explain publish type \`${decl.meta.inherited_from}\`'s comment under it: "${doc.slice(0, 90)}"${leadsWithTypeName ? `. That opens with the Go type name, which users never type - they type "${decl.name}"` : ''}. Add a comment on the field; it takes precedence over the type's.`
403
+ }]
404
+ }
405
+ },
211
406
  {
212
407
  name: 'go-field-name-first',
213
408
  description: 'Description leads with the Go field name instead of the YAML key',
@@ -232,6 +427,8 @@ module.exports = {
232
427
  extract,
233
428
  scanFile,
234
429
  loadConfig,
430
+ collectInlinedTypes,
431
+ UNDESCRIBED_EXTERNAL_TYPES,
235
432
  rules: RULES,
236
433
  // Missing prose is surfaced by the crd-specific undocumented-field rule
237
434
  // (warning, per the docs contract) instead of the generic error.