@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.
- package/bin/doc-tools-mcp.js +88 -0
- package/bin/doc-tools.js +334 -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/cli-utils/validation-result.js +53 -0
- package/macros/REFERENCE.adoc +3 -2
- package/macros/prop.js +34 -6
- 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/README.adoc +17 -3
- package/tools/property-extractor/helpers/index.js +1 -2
- package/tools/property-extractor/helpers/seeAlsoView.js +76 -0
- package/tools/property-extractor/parser.py +26 -0
- package/tools/property-extractor/property_extractor.py +65 -6
- package/tools/property-extractor/templates/property.hbs +25 -37
- package/tools/property-extractor/templates/topic-property.hbs +25 -37
- package/tools/property-extractor/transformers.py +15 -2
- package/tools/property-extractor/validate-overrides.js +120 -0
- package/tools/rpk-docs/detect-platform-commands.js +8 -1
- package/tools/rpk-docs/scan-deprecated-commands.js +15 -18
- package/tools/rpk-docs/validate-overrides.js +1 -45
- package/tools/property-extractor/helpers/allTopicsConditional.js +0 -19
- package/tools/property-extractor/helpers/parseRelatedTopic.js +0 -13
|
@@ -0,0 +1,233 @@
|
|
|
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
|
+
* Connect surface: service.NewConfigSpec() Summary/Description strings and
|
|
19
|
+
* service.NewXField(name).Description(...) chains in internal/impl.
|
|
20
|
+
*
|
|
21
|
+
* These strings are AsciiDoc page bodies (connect docs are generated from
|
|
22
|
+
* them directly), so AsciiDoc constructs are LEGITIMATE here: `==` headings,
|
|
23
|
+
* links, code fences, and the `raw + "`literal`" + raw` backtick
|
|
24
|
+
* concatenation idiom all appear in healthy descriptions. The scanner
|
|
25
|
+
* evaluates Go string concatenation (raw strings, interpreted strings, and
|
|
26
|
+
* same-file constants) so those idioms resolve to the exact shipped text.
|
|
27
|
+
* Descriptions built from calls or cross-file values are skipped silently -
|
|
28
|
+
* suggest-only posture means silence beats guessing.
|
|
29
|
+
*
|
|
30
|
+
* Because a connect description IS the page body (not a table cell), the
|
|
31
|
+
* verbatim raw-pipe rule is skipped: a bare `|` is valid AsciiDoc table
|
|
32
|
+
* syntax in this position.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const CONVENTION = {
|
|
36
|
+
case: 'sentence',
|
|
37
|
+
terminal_period: true,
|
|
38
|
+
verbatim_asciidoc: true
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Field constructors that ship with NO built-in documentation: a chain on
|
|
42
|
+
// one of these without .Description() publishes an empty field description.
|
|
43
|
+
// Composite helpers (NewTLSToggledField, NewAutoRetryNacksToggleField,
|
|
44
|
+
// NewBatchPolicyField, ...) carry their own docs and are exempt.
|
|
45
|
+
const BARE_FIELD_CTORS = new Set([
|
|
46
|
+
'NewStringField', 'NewStringListField', 'NewStringMapField',
|
|
47
|
+
'NewStringEnumField', 'NewStringAnnotatedEnumField',
|
|
48
|
+
'NewIntField', 'NewIntListField', 'NewIntMapField',
|
|
49
|
+
'NewFloatField', 'NewBoolField', 'NewDurationField',
|
|
50
|
+
'NewInterpolatedStringField', 'NewInterpolatedStringListField',
|
|
51
|
+
'NewInterpolatedStringMapField', 'NewInterpolatedStringEnumField',
|
|
52
|
+
'NewBloblangField', 'NewAnyField', 'NewAnyListField', 'NewAnyMapField',
|
|
53
|
+
'NewObjectField', 'NewObjectListField', 'NewURLField'
|
|
54
|
+
])
|
|
55
|
+
|
|
56
|
+
const FIELD_CTOR = /\bNew([A-Z][A-Za-z0-9]*)Field\s*\(/g
|
|
57
|
+
const CONFIG_SPEC = /\bNewConfigSpec\s*\(\s*\)/g
|
|
58
|
+
const REGISTER_NAME = /\b(?:Must)?Register\w*\(\s*(?:"([^"]+)"|`([^`]+)`)\s*,/
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Follow a builder chain starting right after `startIndex` (the index just
|
|
62
|
+
* past a closing paren): repeated `.Method(args)`. Returns
|
|
63
|
+
* [{ method, args, openIndex, closeIndex }] and the chain end offset.
|
|
64
|
+
*/
|
|
65
|
+
function readChain (masked, startIndex) {
|
|
66
|
+
const calls = []
|
|
67
|
+
let i = startIndex
|
|
68
|
+
for (;;) {
|
|
69
|
+
const rest = masked.slice(i)
|
|
70
|
+
const m = rest.match(/^\s*\.\s*([A-Za-z0-9_]+)\s*\(/)
|
|
71
|
+
if (!m) break
|
|
72
|
+
const openIndex = i + m[0].length - 1
|
|
73
|
+
const closeIndex = findBalancedClose(masked, openIndex)
|
|
74
|
+
if (closeIndex === -1) break
|
|
75
|
+
calls.push({
|
|
76
|
+
method: m[1],
|
|
77
|
+
args: masked.slice(openIndex + 1, closeIndex),
|
|
78
|
+
openIndex,
|
|
79
|
+
closeIndex
|
|
80
|
+
})
|
|
81
|
+
i = closeIndex + 1
|
|
82
|
+
}
|
|
83
|
+
return { calls, end: i }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Scan one file's content for connect declarations. Exported for tests.
|
|
88
|
+
*/
|
|
89
|
+
function scanFile (content, file) {
|
|
90
|
+
const declarations = []
|
|
91
|
+
const masked = maskComments(content)
|
|
92
|
+
const consts = collectStringConsts(masked)
|
|
93
|
+
|
|
94
|
+
// Component name: the registration literal, when the file has exactly one.
|
|
95
|
+
const registerMatches = [...masked.matchAll(new RegExp(REGISTER_NAME.source, 'g'))]
|
|
96
|
+
const componentName = registerMatches.length === 1
|
|
97
|
+
? (registerMatches[0][1] || registerMatches[0][2])
|
|
98
|
+
: null
|
|
99
|
+
|
|
100
|
+
// ConfigSpec chains -> Summary / Description declarations.
|
|
101
|
+
CONFIG_SPEC.lastIndex = 0
|
|
102
|
+
let match
|
|
103
|
+
while ((match = CONFIG_SPEC.exec(masked)) !== null) {
|
|
104
|
+
const { calls } = readChain(masked, match.index + match[0].length)
|
|
105
|
+
for (const call of calls) {
|
|
106
|
+
if (call.method !== 'Summary' && call.method !== 'Description') continue
|
|
107
|
+
const result = evalStringExpr(call.args, consts)
|
|
108
|
+
if (!result.verifiable) continue // built dynamically; skip silently
|
|
109
|
+
declarations.push({
|
|
110
|
+
surface: 'connect',
|
|
111
|
+
name: componentName,
|
|
112
|
+
file,
|
|
113
|
+
line_start: lineOf(masked, call.openIndex),
|
|
114
|
+
line_end: lineOf(masked, call.closeIndex),
|
|
115
|
+
string: result.value.trim() === '' ? null : result.value.trim(),
|
|
116
|
+
declaration_text: null,
|
|
117
|
+
convention: CONVENTION,
|
|
118
|
+
meta: { kind: call.method.toLowerCase() }
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Field constructor chains -> field description declarations.
|
|
124
|
+
FIELD_CTOR.lastIndex = 0
|
|
125
|
+
while ((match = FIELD_CTOR.exec(masked)) !== null) {
|
|
126
|
+
const ctor = `New${match[1]}Field`
|
|
127
|
+
const openIndex = match.index + match[0].length - 1
|
|
128
|
+
const closeIndex = findBalancedClose(masked, openIndex)
|
|
129
|
+
if (closeIndex === -1) continue
|
|
130
|
+
|
|
131
|
+
const ctorArgs = splitTopLevelArgs(masked.slice(openIndex + 1, closeIndex))
|
|
132
|
+
const nameResult = ctorArgs.length > 0 ? evalStringExpr(ctorArgs[0], consts) : { verifiable: false, value: null }
|
|
133
|
+
const fieldName = nameResult.verifiable ? nameResult.value : null
|
|
134
|
+
|
|
135
|
+
const { calls, end } = readChain(masked, closeIndex + 1)
|
|
136
|
+
const descriptionCall = calls.find((c) => c.method === 'Description')
|
|
137
|
+
|
|
138
|
+
if (descriptionCall) {
|
|
139
|
+
const result = evalStringExpr(descriptionCall.args, consts)
|
|
140
|
+
if (result.verifiable) {
|
|
141
|
+
declarations.push({
|
|
142
|
+
surface: 'connect',
|
|
143
|
+
name: fieldName,
|
|
144
|
+
file,
|
|
145
|
+
line_start: lineOf(masked, match.index),
|
|
146
|
+
line_end: lineOf(masked, descriptionCall.closeIndex),
|
|
147
|
+
string: result.value.trim() === '' ? null : result.value.trim(),
|
|
148
|
+
declaration_text: null,
|
|
149
|
+
convention: CONVENTION,
|
|
150
|
+
meta: { kind: 'field', ctor }
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
// Unverifiable description: skip silently (cross-file constant or call).
|
|
154
|
+
} else if (BARE_FIELD_CTORS.has(ctor) && !calls.some((c) => c.method === 'Deprecated')) {
|
|
155
|
+
// Deprecated fields are not part of the user-facing docs contract.
|
|
156
|
+
// A bare constructor with no Description() anywhere in its chain
|
|
157
|
+
// ships an empty field description.
|
|
158
|
+
declarations.push({
|
|
159
|
+
surface: 'connect',
|
|
160
|
+
name: fieldName,
|
|
161
|
+
file,
|
|
162
|
+
line_start: lineOf(masked, match.index),
|
|
163
|
+
line_end: lineOf(masked, Math.max(closeIndex, end - 1)),
|
|
164
|
+
string: null,
|
|
165
|
+
declaration_text: null,
|
|
166
|
+
convention: CONVENTION,
|
|
167
|
+
meta: { kind: 'field', ctor, missing_description: true }
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
// Resume INSIDE the constructor args: object fields nest child field
|
|
171
|
+
// constructors (NewObjectField("x", NewStringField("y")...)).
|
|
172
|
+
FIELD_CTOR.lastIndex = openIndex + 1
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return declarations
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Extract connect declarations.
|
|
180
|
+
*
|
|
181
|
+
* @param {Object} options - { repo, files (Set of repo-relative paths, diff
|
|
182
|
+
* mode; when omitted, scans internal/impl), log }
|
|
183
|
+
*/
|
|
184
|
+
function extract ({ repo, files = null }) {
|
|
185
|
+
const IMPL_ROOT = path.join('internal', 'impl')
|
|
186
|
+
let fileList
|
|
187
|
+
if (files) {
|
|
188
|
+
fileList = [...files].filter((f) => f.endsWith('.go') && !f.endsWith('_test.go'))
|
|
189
|
+
} else {
|
|
190
|
+
fileList = collectGoFiles(path.join(repo, IMPL_ROOT)).map((f) => path.join(IMPL_ROOT, f))
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const cache = new SourceCache(repo)
|
|
194
|
+
const declarations = []
|
|
195
|
+
for (const file of fileList) {
|
|
196
|
+
const absPath = path.isAbsolute(file) ? file : path.join(repo, file)
|
|
197
|
+
if (!fs.existsSync(absPath)) continue
|
|
198
|
+
const content = fs.readFileSync(absPath, 'utf8')
|
|
199
|
+
if (!content.includes('Field(') && !content.includes('NewConfigSpec')) continue
|
|
200
|
+
for (const decl of scanFile(content, file)) {
|
|
201
|
+
decl.declaration_text = cache.span(file, decl.line_start, decl.line_end)
|
|
202
|
+
declarations.push(decl)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return declarations
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Surface-specific convention rules. */
|
|
209
|
+
const RULES = [
|
|
210
|
+
{
|
|
211
|
+
name: 'missing-field-description',
|
|
212
|
+
description: 'Config field constructed with no .Description()',
|
|
213
|
+
severity: 'warning',
|
|
214
|
+
check: (decl) => {
|
|
215
|
+
if (decl.meta.kind !== 'field' || !decl.meta.missing_description) return []
|
|
216
|
+
return [{ message: `Field ${decl.name ? `"${decl.name}" ` : ''}(${decl.meta.ctor}) has no .Description(). It ships as an undocumented field on the connector page.` }]
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
]
|
|
220
|
+
|
|
221
|
+
module.exports = {
|
|
222
|
+
name: 'connect',
|
|
223
|
+
convention: CONVENTION,
|
|
224
|
+
extract,
|
|
225
|
+
scanFile,
|
|
226
|
+
rules: RULES,
|
|
227
|
+
// A connect description is the AsciiDoc page body, not a table cell:
|
|
228
|
+
// bare | is legitimate table syntax there, and missing prose is handled
|
|
229
|
+
// by the connect-specific missing-field-description warning. Brace
|
|
230
|
+
// placeholders ({endpoint}, {my-domain}) are the established URL-template
|
|
231
|
+
// idiom in connect descriptions, so the unknown-attribute check is noise.
|
|
232
|
+
skipRules: ['raw-pipe', 'empty-description', 'unknown-attribute']
|
|
233
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const fs = require('fs')
|
|
4
|
+
const path = require('path')
|
|
5
|
+
const yaml = require('js-yaml')
|
|
6
|
+
|
|
7
|
+
const { SourceCache } = require('../source-text')
|
|
8
|
+
const { collectGoFiles } = require('../go-source')
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* CRD surface: Go doc comments above struct fields in the operator API
|
|
12
|
+
* types (operator/api/redpanda/...). These comments ship verbatim to the
|
|
13
|
+
* generated CRD reference on docs.redpanda.com AND to `kubectl explain`.
|
|
14
|
+
*
|
|
15
|
+
* User-facing filtering mirrors crd-ref-docs: the surface reads
|
|
16
|
+
* operator/crd-ref-docs-config.yaml for the `hidefromdoc` custom marker and
|
|
17
|
+
* the processor ignoreTypes/ignoreFields regexes, so fields the generator
|
|
18
|
+
* never documents are never linted.
|
|
19
|
+
*
|
|
20
|
+
* Marker lines (+kubebuilder:..., +optional, +required, +genclient, and any
|
|
21
|
+
* other +directive) are stripped before the prose is judged.
|
|
22
|
+
*
|
|
23
|
+
* The signature bad pattern here is a description that leads with the Go
|
|
24
|
+
* field identifier ("ClusterSource is a reference to...") when users type
|
|
25
|
+
* the json key ("cluster") - the docs and kubectl explain both show the
|
|
26
|
+
* json name, so the Go name means nothing to readers.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const CONVENTION = {
|
|
30
|
+
case: 'sentence',
|
|
31
|
+
terminal_period: true,
|
|
32
|
+
verbatim_asciidoc: true
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const API_ROOT = path.join('operator', 'api', 'redpanda')
|
|
36
|
+
const CONFIG_PATH = path.join('operator', 'crd-ref-docs-config.yaml')
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Compile a list of regex source strings, skipping (with a warning) any
|
|
40
|
+
* pattern that isn't valid JS regex syntax instead of throwing. The config
|
|
41
|
+
* file is written for a Go tool (crd-ref-docs), so it can legally contain
|
|
42
|
+
* Go/RE2-only constructs like `(?i)` or `(?P<name>...)` that JS's RegExp
|
|
43
|
+
* rejects - one such pattern must not abort the entire lint run.
|
|
44
|
+
*/
|
|
45
|
+
function compilePatterns (patterns, configKey) {
|
|
46
|
+
const compiled = []
|
|
47
|
+
for (const p of patterns) {
|
|
48
|
+
try {
|
|
49
|
+
compiled.push(new RegExp(p))
|
|
50
|
+
} catch (err) {
|
|
51
|
+
// eslint-disable-next-line no-console
|
|
52
|
+
console.warn(`lint-strings: skipping invalid ${configKey} pattern in ${CONFIG_PATH} (${JSON.stringify(p)}): ${err.message}`)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return compiled
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Load ignore rules from crd-ref-docs-config.yaml (absent file = no rules). */
|
|
59
|
+
function loadConfig (repo) {
|
|
60
|
+
const configPath = path.join(repo, CONFIG_PATH)
|
|
61
|
+
const config = { ignoreTypes: [], ignoreFields: [], hiddenMarker: 'hidefromdoc' }
|
|
62
|
+
if (!fs.existsSync(configPath)) return config
|
|
63
|
+
const parsed = yaml.load(fs.readFileSync(configPath, 'utf8')) || {}
|
|
64
|
+
const processor = parsed.processor || {}
|
|
65
|
+
config.ignoreTypes = compilePatterns(processor.ignoreTypes || [], 'processor.ignoreTypes')
|
|
66
|
+
config.ignoreFields = compilePatterns(processor.ignoreFields || [], 'processor.ignoreFields')
|
|
67
|
+
return config
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function matchesAny (patterns, ...candidates) {
|
|
71
|
+
return patterns.some((pattern) => candidates.some((c) => pattern.test(c)))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Parse one Go file's struct fields. Exported for tests.
|
|
76
|
+
*
|
|
77
|
+
* @param {string} content - File content
|
|
78
|
+
* @param {string} file - Repo-relative path
|
|
79
|
+
* @param {Object} config - From loadConfig
|
|
80
|
+
* @returns {Array} declarations (without declaration_text)
|
|
81
|
+
*/
|
|
82
|
+
function scanFile (content, file, config = { ignoreTypes: [], ignoreFields: [], hiddenMarker: 'hidefromdoc' }) {
|
|
83
|
+
const declarations = []
|
|
84
|
+
const lines = content.split('\n')
|
|
85
|
+
const packageMatch = content.match(/^package\s+(\w+)/m)
|
|
86
|
+
const pkg = packageMatch ? packageMatch[1] : ''
|
|
87
|
+
|
|
88
|
+
let comment = [] // pending comment lines: { line (0-indexed), text }
|
|
89
|
+
let struct = null // { name, hidden } while inside a struct body
|
|
90
|
+
let depth = 0
|
|
91
|
+
|
|
92
|
+
for (let i = 0; i < lines.length; i++) {
|
|
93
|
+
const line = lines[i]
|
|
94
|
+
const trimmed = line.trim()
|
|
95
|
+
|
|
96
|
+
if (struct === null) {
|
|
97
|
+
if (/^\s*\/\//.test(line)) {
|
|
98
|
+
comment.push({ line: i, text: trimmed.replace(/^\/\/\s?/, '') })
|
|
99
|
+
continue
|
|
100
|
+
}
|
|
101
|
+
const typeMatch = line.match(/^type\s+([A-Za-z0-9_]+)\s+struct\s*\{/)
|
|
102
|
+
if (typeMatch) {
|
|
103
|
+
const name = typeMatch[1]
|
|
104
|
+
const hidden = comment.some((c) => c.text.trim().startsWith(`+${config.hiddenMarker}`)) ||
|
|
105
|
+
matchesAny(config.ignoreTypes, name, `${pkg}.${name}`)
|
|
106
|
+
struct = { name, hidden, exported: /^[A-Z]/.test(name) }
|
|
107
|
+
depth = 1
|
|
108
|
+
comment = []
|
|
109
|
+
continue
|
|
110
|
+
}
|
|
111
|
+
if (trimmed !== '') comment = []
|
|
112
|
+
continue
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Inside a struct body.
|
|
116
|
+
if (/^\s*\/\//.test(line)) {
|
|
117
|
+
comment.push({ line: i, text: trimmed.replace(/^\/\/\s?/, '') })
|
|
118
|
+
continue
|
|
119
|
+
}
|
|
120
|
+
if (trimmed === '') {
|
|
121
|
+
comment = []
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Track nested braces (anonymous struct fields) without leaving the type.
|
|
126
|
+
const opens = (trimmed.match(/\{/g) || []).length
|
|
127
|
+
const closes = (trimmed.match(/\}/g) || []).length
|
|
128
|
+
const fieldMatch = line.match(/^\s*([A-Z][A-Za-z0-9_]*)\s+\S/)
|
|
129
|
+
if (fieldMatch && depth === 1 && !struct.hidden && struct.exported) {
|
|
130
|
+
const goName = fieldMatch[1]
|
|
131
|
+
const tagMatch = line.match(/`[^`]*json:"([^",]*)[^`]*`/)
|
|
132
|
+
const jsonName = tagMatch ? tagMatch[1] : null
|
|
133
|
+
const hiddenField = comment.some((c) => c.text.trim().startsWith(`+${config.hiddenMarker}`))
|
|
134
|
+
// Only json-serialized, non-inline, non-ignored fields ship to users.
|
|
135
|
+
if (jsonName && jsonName !== '-' && !hiddenField &&
|
|
136
|
+
!matchesAny(config.ignoreFields, goName, goName.toLowerCase(), jsonName)) {
|
|
137
|
+
const prose = comment
|
|
138
|
+
.filter((c) => !c.text.trim().startsWith('+'))
|
|
139
|
+
.map((c) => c.text)
|
|
140
|
+
.join('\n')
|
|
141
|
+
.trim()
|
|
142
|
+
declarations.push({
|
|
143
|
+
surface: 'crd',
|
|
144
|
+
name: jsonName,
|
|
145
|
+
file,
|
|
146
|
+
line_start: (comment.length > 0 ? comment[0].line : i) + 1,
|
|
147
|
+
line_end: i + 1,
|
|
148
|
+
string: prose || null,
|
|
149
|
+
declaration_text: null,
|
|
150
|
+
convention: CONVENTION,
|
|
151
|
+
meta: {
|
|
152
|
+
kind: 'field',
|
|
153
|
+
struct: struct.name,
|
|
154
|
+
go_name: goName,
|
|
155
|
+
json_name: jsonName
|
|
156
|
+
}
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
comment = []
|
|
161
|
+
depth += opens - closes
|
|
162
|
+
if (depth <= 0) struct = null
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return declarations
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Extract CRD field declarations.
|
|
170
|
+
*
|
|
171
|
+
* @param {Object} options - { repo, files (Set of repo-relative paths, diff
|
|
172
|
+
* mode; when omitted, scans operator/api/redpanda), log }
|
|
173
|
+
*/
|
|
174
|
+
function extract ({ repo, files = null }) {
|
|
175
|
+
const config = loadConfig(repo)
|
|
176
|
+
let fileList
|
|
177
|
+
if (files) {
|
|
178
|
+
fileList = [...files].filter((f) => f.endsWith('.go') && !f.endsWith('_test.go') && !path.basename(f).startsWith('zz_generated'))
|
|
179
|
+
} else {
|
|
180
|
+
fileList = collectGoFiles(path.join(repo, API_ROOT))
|
|
181
|
+
.filter((f) => !path.basename(f).startsWith('zz_generated'))
|
|
182
|
+
.map((f) => path.join(API_ROOT, f))
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const cache = new SourceCache(repo)
|
|
186
|
+
const declarations = []
|
|
187
|
+
for (const file of fileList) {
|
|
188
|
+
const absPath = path.isAbsolute(file) ? file : path.join(repo, file)
|
|
189
|
+
if (!fs.existsSync(absPath)) continue
|
|
190
|
+
const content = fs.readFileSync(absPath, 'utf8')
|
|
191
|
+
if (!content.includes('struct')) continue
|
|
192
|
+
for (const decl of scanFile(content, file, config)) {
|
|
193
|
+
decl.declaration_text = cache.span(file, decl.line_start, decl.line_end)
|
|
194
|
+
declarations.push(decl)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return declarations
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Surface-specific convention rules. */
|
|
201
|
+
const RULES = [
|
|
202
|
+
{
|
|
203
|
+
name: 'undocumented-field',
|
|
204
|
+
description: 'Exported user-facing field with no doc comment',
|
|
205
|
+
severity: 'warning',
|
|
206
|
+
check: (decl) => {
|
|
207
|
+
if (decl.string !== null) return []
|
|
208
|
+
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
|
+
}
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
name: 'go-field-name-first',
|
|
213
|
+
description: 'Description leads with the Go field name instead of the YAML key',
|
|
214
|
+
severity: 'warning',
|
|
215
|
+
check: (decl) => {
|
|
216
|
+
const text = (decl.string || '').trim()
|
|
217
|
+
if (!text) return []
|
|
218
|
+
const firstWord = (text.match(/^[A-Za-z0-9_]+/) || [null])[0]
|
|
219
|
+
if (!firstWord) return []
|
|
220
|
+
if (firstWord === decl.meta.go_name &&
|
|
221
|
+
decl.meta.go_name.toLowerCase() !== decl.meta.json_name.toLowerCase()) {
|
|
222
|
+
return [{ message: `Description starts with the Go field name "${decl.meta.go_name}", but users type "${decl.meta.json_name}" in YAML. Describe the json key: "${decl.meta.json_name}" (or start with what the field does).` }]
|
|
223
|
+
}
|
|
224
|
+
return []
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
]
|
|
228
|
+
|
|
229
|
+
module.exports = {
|
|
230
|
+
name: 'crd',
|
|
231
|
+
convention: CONVENTION,
|
|
232
|
+
extract,
|
|
233
|
+
scanFile,
|
|
234
|
+
loadConfig,
|
|
235
|
+
rules: RULES,
|
|
236
|
+
// Missing prose is surfaced by the crd-specific undocumented-field rule
|
|
237
|
+
// (warning, per the docs contract) instead of the generic error.
|
|
238
|
+
skipRules: ['empty-description']
|
|
239
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const fs = require('fs')
|
|
4
|
+
const path = require('path')
|
|
5
|
+
|
|
6
|
+
const { SourceCache } = require('../source-text')
|
|
7
|
+
const { parseValuesFile: parseSharedValuesFile } = require('../../../cli-utils/helm-commented-values')
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Helm surface: helm-docs description comments in chart values.yaml files
|
|
11
|
+
* (charts/<name>/chart/values.yaml, plus charts/<name>/values.yaml for
|
|
12
|
+
* charts without the chart/ subdir).
|
|
13
|
+
*
|
|
14
|
+
* Documentation conventions - the union of what actually ships:
|
|
15
|
+
* - helm-docs: `# -- description` (with plain `# ...` continuation lines)
|
|
16
|
+
* attaches to the real key DIRECTLY below the comment block; `# @default
|
|
17
|
+
* -- text`, `# @raw`, and `# @ignored` annotate it.
|
|
18
|
+
* - doc-tools helm-spec (cli-utils/helm-commented-values.js): a `# --`
|
|
19
|
+
* block directly above a COMMENTED-OUT key documents that key, and
|
|
20
|
+
* `# @doc full.path -- description` documents any path explicitly.
|
|
21
|
+
*
|
|
22
|
+
* Both conventions are decided by ONE walk, cli-utils/helm-commented-values
|
|
23
|
+
* `parseValuesFile`, which this surface calls with attachRealKeys so it also
|
|
24
|
+
* models helm-docs' own real-key attachment. This file used to hold a
|
|
25
|
+
* byte-identical copy of that module's nine regexes and a near-copy of its
|
|
26
|
+
* state machine, on the stated grounds that "the linter never calls a marker
|
|
27
|
+
* dead that the generator in this repo renders" - which nothing enforced.
|
|
28
|
+
* Sharing the walk is what actually enforces it.
|
|
29
|
+
*
|
|
30
|
+
* A DEAD marker is a `# --` block neither pipeline can attach: one buried
|
|
31
|
+
* inside another commented-out key's subtree (the classic commented-out
|
|
32
|
+
* example block), or one separated from any key by a blank line. Its
|
|
33
|
+
* description silently never ships - that is the error this surface exists
|
|
34
|
+
* to catch.
|
|
35
|
+
*
|
|
36
|
+
* helm-docs output is markdown converted via pandoc, not verbatim AsciiDoc,
|
|
37
|
+
* so the verbatim escaping rules do not apply. Terminal periods are
|
|
38
|
+
* optional prose style here; only capitalization is enforced (via the
|
|
39
|
+
* common starts-lowercase rule).
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
const CONVENTION = {
|
|
43
|
+
case: 'sentence',
|
|
44
|
+
terminal_period: 'optional',
|
|
45
|
+
verbatim_asciidoc: false
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Parse one values.yaml into lint declarations. Exported for tests.
|
|
50
|
+
*
|
|
51
|
+
* The attachment decisions and the line spans both come from the shared walk;
|
|
52
|
+
* this function only reshapes its records into the linter's declaration form.
|
|
53
|
+
*
|
|
54
|
+
* @param {string} content - File content
|
|
55
|
+
* @param {string} file - Repo-relative path
|
|
56
|
+
* @returns {Array} declarations (without declaration_text)
|
|
57
|
+
*/
|
|
58
|
+
function parseValuesFile (content, file) {
|
|
59
|
+
return parseSharedValuesFile(content, { attachRealKeys: true }).map((r) => {
|
|
60
|
+
// The generator keeps newlines in a description; helm-docs renders one
|
|
61
|
+
// paragraph, and the rules read one string, so flatten here.
|
|
62
|
+
const string = r.descLines.map((l) => l.trim()).filter(Boolean).join(' ') || null
|
|
63
|
+
let meta
|
|
64
|
+
if (r.kind === 'dead-marker') {
|
|
65
|
+
meta = { kind: 'dead-marker', unverifiable: true }
|
|
66
|
+
} else if (r.undocumented) {
|
|
67
|
+
// Nothing to lint for content: the rule fires on the absence itself.
|
|
68
|
+
meta = { kind: 'key', top_level: true, undocumented: true, unverifiable: true }
|
|
69
|
+
} else if (r.commentedOut) {
|
|
70
|
+
meta = { kind: 'key', commented_out: true, top_level: r.topLevel, default_annotation: r.default || null }
|
|
71
|
+
} else {
|
|
72
|
+
meta = { kind: 'key', top_level: r.topLevel, raw: Boolean(r.annotations.raw), default_annotation: r.default || null }
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
surface: 'helm',
|
|
76
|
+
name: r.kind === 'dead-marker' ? null : r.path,
|
|
77
|
+
file,
|
|
78
|
+
line_start: r.lineStart + 1, // 0-indexed -> 1-indexed
|
|
79
|
+
line_end: r.lineEnd + 1,
|
|
80
|
+
string: r.undocumented ? null : string,
|
|
81
|
+
declaration_text: null,
|
|
82
|
+
convention: CONVENTION,
|
|
83
|
+
meta
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Extract helm declarations.
|
|
90
|
+
*
|
|
91
|
+
* @param {Object} options - { repo, files (Set of repo-relative paths, diff
|
|
92
|
+
* mode; when omitted, scans charts/<*>/chart/values.yaml and
|
|
93
|
+
* charts/<*>/values.yaml), log }
|
|
94
|
+
*/
|
|
95
|
+
function extract ({ repo, files = null }) {
|
|
96
|
+
let fileList
|
|
97
|
+
if (files) {
|
|
98
|
+
fileList = [...files]
|
|
99
|
+
} else {
|
|
100
|
+
fileList = []
|
|
101
|
+
const chartsRoot = path.join(repo, 'charts')
|
|
102
|
+
if (fs.existsSync(chartsRoot)) {
|
|
103
|
+
for (const entry of fs.readdirSync(chartsRoot, { withFileTypes: true })) {
|
|
104
|
+
if (!entry.isDirectory()) continue
|
|
105
|
+
for (const candidate of [
|
|
106
|
+
path.join('charts', entry.name, 'chart', 'values.yaml'),
|
|
107
|
+
path.join('charts', entry.name, 'values.yaml')
|
|
108
|
+
]) {
|
|
109
|
+
if (fs.existsSync(path.join(repo, candidate))) fileList.push(candidate)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const cache = new SourceCache(repo)
|
|
116
|
+
const declarations = []
|
|
117
|
+
for (const file of fileList) {
|
|
118
|
+
const absPath = path.isAbsolute(file) ? file : path.join(repo, file)
|
|
119
|
+
if (!fs.existsSync(absPath)) continue
|
|
120
|
+
const content = fs.readFileSync(absPath, 'utf8')
|
|
121
|
+
for (const decl of parseValuesFile(content, file)) {
|
|
122
|
+
decl.declaration_text = cache.span(file, decl.line_start, decl.line_end)
|
|
123
|
+
declarations.push(decl)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return declarations
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Surface-specific convention rules. */
|
|
130
|
+
const RULES = [
|
|
131
|
+
{
|
|
132
|
+
name: 'dead-marker',
|
|
133
|
+
description: 'A # -- marker no docs pipeline can attach to any key',
|
|
134
|
+
severity: 'error',
|
|
135
|
+
runOnUnverifiable: true,
|
|
136
|
+
check: (decl) => {
|
|
137
|
+
if (decl.meta.kind !== 'dead-marker') return []
|
|
138
|
+
return [{ message: 'This "# --" description is buried where neither helm-docs nor the helm-spec commented-values pass can attach it (inside another commented-out key\'s subtree, or separated from any key), so it silently never ships. Move it directly above the key it documents, use "# @doc full.path -- ...", or delete it.' }]
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
name: 'undocumented-top-level-key',
|
|
143
|
+
description: 'Top-level user-visible key with no # -- description',
|
|
144
|
+
severity: 'info',
|
|
145
|
+
runOnUnverifiable: true,
|
|
146
|
+
check: (decl) => {
|
|
147
|
+
if (decl.meta.kind !== 'key' || !decl.meta.undocumented) return []
|
|
148
|
+
return [{ message: `Top-level key "${decl.name}" has no "# --" description, so helm-docs ships it undocumented. Add a marker comment directly above it (or "# @ignored" if it is not user-facing).` }]
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
]
|
|
152
|
+
|
|
153
|
+
module.exports = {
|
|
154
|
+
name: 'helm',
|
|
155
|
+
convention: CONVENTION,
|
|
156
|
+
extract,
|
|
157
|
+
parseValuesFile,
|
|
158
|
+
rules: RULES
|
|
159
|
+
}
|