@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.
- package/bin/doc-tools-mcp.js +1 -1
- package/bin/doc-tools.js +9 -7
- package/bin/mcp-tools/lint-doc-strings.js +2 -1
- package/cli-utils/enterprise-sources.js +3 -3
- package/cli-utils/github-token.js +53 -13
- package/cli-utils/octokit-client.js +2 -2
- package/cli-utils/self-managed-docs-branch.js +2 -2
- package/extensions/version-fetcher/set-latest-version.js +6 -3
- package/package.json +1 -1
- package/tools/get-redpanda-version.js +2 -2
- package/tools/lint-strings/diff.js +15 -1
- package/tools/lint-strings/fixtures/api/lint_admin_v2.proto +114 -0
- package/tools/lint-strings/fixtures/api/lint_api.proto +123 -0
- package/tools/lint-strings/fixtures/connect/lint_connect.go +1 -1
- package/tools/lint-strings/fixtures/crd/lint_types.go +70 -0
- package/tools/lint-strings/fixtures/helm/lint_values.yaml +16 -3
- package/tools/lint-strings/index.js +2 -1
- package/tools/lint-strings/rules/common.js +87 -1
- package/tools/lint-strings/surfaces/api.js +786 -0
- package/tools/lint-strings/surfaces/crd.js +204 -7
- package/tools/lint-strings/surfaces/helm.js +338 -12
- package/tools/property-extractor/rp-util-fetch.js +2 -2
- package/tools/rpk-docs/rpk-docs-handler.js +2 -2
|
@@ -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
|
-
|
|
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
|
-
|
|
107
|
-
depth
|
|
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
|
-
|
|
186
|
-
|
|
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,
|
|
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.
|