@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,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
+ }