@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,368 @@
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+
6
+ const { SourceCache } = require('../source-text')
7
+
8
+ /**
9
+ * Metrics surface: `sm::description(...)` / `ss::metrics::description(...)`
10
+ * help strings in the C++ sources.
11
+ *
12
+ * This is a purpose-built regex/paren scanner, NOT the Docker metrics scrape
13
+ * used for doc generation: the scrape reads `# HELP` lines from a live
14
+ * cluster and cannot give file:line spans, which diff-anchored linting and
15
+ * PR suggestion blocks need. The scanner:
16
+ * - finds each description(...) call,
17
+ * - captures the balanced-paren argument (string/comment aware),
18
+ * - concatenates adjacent string literals (clang-format wraps help strings),
19
+ * - records the exact 1-indexed line span of the call, and
20
+ * - resolves the metric name from the first string argument of the enclosing
21
+ * make_gauge/make_counter/make_histogram/make_total_bytes/... call.
22
+ *
23
+ * Non-literal arguments (constants, fmt::format(...) calls) become an
24
+ * info-level "unverifiable" finding - never an error.
25
+ */
26
+
27
+ const CONVENTION = {
28
+ case: 'capitalized',
29
+ terminal_period: false,
30
+ verbatim_asciidoc: true
31
+ }
32
+
33
+ const DESCRIPTION_CALL = /\b(?:sm|ss::metrics)::description\s*\(/g
34
+ const MAKE_CALL = /\b(?:sm::|ss::metrics::)?make_[a-z0-9_]+\s*\(/g
35
+
36
+ /**
37
+ * Replace // and C-style comments with spaces, preserving every offset and
38
+ * newline, so the scanner never matches description() calls mentioned in
39
+ * comments. String literal contents are left untouched.
40
+ */
41
+ function maskComments (content) {
42
+ const out = content.split('')
43
+ let i = 0
44
+ while (i < content.length) {
45
+ const ch = content[i]
46
+ if (ch === '"' || ch === "'") {
47
+ i = skipLiteral(content, i)
48
+ continue
49
+ }
50
+ if (ch === '/' && content[i + 1] === '/') {
51
+ while (i < content.length && content[i] !== '\n') out[i++] = ' '
52
+ continue
53
+ }
54
+ if (ch === '/' && content[i + 1] === '*') {
55
+ const end = content.indexOf('*/', i + 2)
56
+ const stop = end === -1 ? content.length : end + 2
57
+ while (i < stop) {
58
+ if (content[i] !== '\n') out[i] = ' '
59
+ i++
60
+ }
61
+ continue
62
+ }
63
+ i++
64
+ }
65
+ return out.join('')
66
+ }
67
+
68
+ /**
69
+ * Walk forward from an opening paren and return the index of its balanced
70
+ * closing paren, skipping string literals, char literals, and comments.
71
+ * Returns -1 when unbalanced (malformed or truncated source).
72
+ */
73
+ function findBalancedClose (content, openIndex) {
74
+ let depth = 0
75
+ let i = openIndex
76
+ while (i < content.length) {
77
+ const ch = content[i]
78
+ if (ch === '"' || ch === "'") {
79
+ i = skipLiteral(content, i)
80
+ continue
81
+ }
82
+ if (ch === '/' && content[i + 1] === '/') {
83
+ const eol = content.indexOf('\n', i)
84
+ i = eol === -1 ? content.length : eol + 1
85
+ continue
86
+ }
87
+ if (ch === '/' && content[i + 1] === '*') {
88
+ const end = content.indexOf('*/', i + 2)
89
+ i = end === -1 ? content.length : end + 2
90
+ continue
91
+ }
92
+ if (ch === '(') depth++
93
+ if (ch === ')') {
94
+ depth--
95
+ if (depth === 0) return i
96
+ }
97
+ i++
98
+ }
99
+ return -1
100
+ }
101
+
102
+ /** Skip a "..." or '...' literal starting at index; returns index after it. */
103
+ function skipLiteral (content, start) {
104
+ const quote = content[start]
105
+ let i = start + 1
106
+ while (i < content.length) {
107
+ if (content[i] === '\\') {
108
+ i += 2
109
+ continue
110
+ }
111
+ if (content[i] === quote) return i + 1
112
+ i++
113
+ }
114
+ return content.length
115
+ }
116
+
117
+ /**
118
+ * True when the paren opened at openIndex is still open at targetIndex
119
+ * (that is, the call encloses the target).
120
+ */
121
+ function callEncloses (content, openIndex, targetIndex) {
122
+ let depth = 0
123
+ let i = openIndex
124
+ while (i < content.length && i < targetIndex) {
125
+ const ch = content[i]
126
+ if (ch === '"' || ch === "'") {
127
+ i = skipLiteral(content, i)
128
+ continue
129
+ }
130
+ if (ch === '/' && content[i + 1] === '/') {
131
+ const eol = content.indexOf('\n', i)
132
+ i = eol === -1 ? content.length : eol + 1
133
+ continue
134
+ }
135
+ if (ch === '/' && content[i + 1] === '*') {
136
+ const end = content.indexOf('*/', i + 2)
137
+ i = end === -1 ? content.length : end + 2
138
+ continue
139
+ }
140
+ if (ch === '(') depth++
141
+ if (ch === ')') {
142
+ depth--
143
+ if (depth === 0) return false
144
+ }
145
+ i++
146
+ }
147
+ return depth > 0
148
+ }
149
+
150
+ /**
151
+ * Parse the argument text of a description(...) call.
152
+ * Returns { verifiable, value }: verifiable is true only when the argument
153
+ * consists purely of (adjacent) string literals.
154
+ */
155
+ function parseArgument (argText) {
156
+ const literals = []
157
+ let leftover = ''
158
+ let i = 0
159
+ while (i < argText.length) {
160
+ const ch = argText[i]
161
+ if (ch === '"') {
162
+ const end = skipLiteral(argText, i)
163
+ literals.push(argText.slice(i + 1, end - 1))
164
+ i = end
165
+ continue
166
+ }
167
+ if (ch === '/' && argText[i + 1] === '/') {
168
+ const eol = argText.indexOf('\n', i)
169
+ i = eol === -1 ? argText.length : eol + 1
170
+ continue
171
+ }
172
+ if (ch === '/' && argText[i + 1] === '*') {
173
+ const end = argText.indexOf('*/', i + 2)
174
+ i = end === -1 ? argText.length : end + 2
175
+ continue
176
+ }
177
+ leftover += ch
178
+ i++
179
+ }
180
+
181
+ if (literals.length > 0 && leftover.trim() === '') {
182
+ return { verifiable: true, value: literals.map(unescapeCpp).join('') }
183
+ }
184
+ return { verifiable: false, value: null }
185
+ }
186
+
187
+ function unescapeCpp (text) {
188
+ return text.replace(/\\(.)/g, (whole, ch) => {
189
+ if (ch === 'n') return '\n'
190
+ if (ch === 't') return '\t'
191
+ return ch
192
+ })
193
+ }
194
+
195
+ /**
196
+ * Resolve the metric name for a description at descIndex: find the nearest
197
+ * preceding make_* call that still encloses it, then read its first argument
198
+ * if that argument is a string literal (adjacent literals concatenated).
199
+ */
200
+ function resolveMetricName (content, descIndex) {
201
+ const candidates = []
202
+ MAKE_CALL.lastIndex = 0
203
+ let match
204
+ while ((match = MAKE_CALL.exec(content)) !== null && match.index < descIndex) {
205
+ candidates.push(match)
206
+ }
207
+
208
+ for (let c = candidates.length - 1; c >= 0; c--) {
209
+ const openIndex = candidates[c].index + candidates[c][0].length - 1
210
+ if (!callEncloses(content, openIndex, descIndex)) continue
211
+
212
+ // First argument: skip whitespace/comments, then read adjacent literals.
213
+ let i = openIndex + 1
214
+ let name = ''
215
+ let sawLiteral = false
216
+ while (i < content.length) {
217
+ const ch = content[i]
218
+ if (/\s/.test(ch)) {
219
+ i++
220
+ continue
221
+ }
222
+ if (ch === '/' && content[i + 1] === '/') {
223
+ const eol = content.indexOf('\n', i)
224
+ i = eol === -1 ? content.length : eol + 1
225
+ continue
226
+ }
227
+ if (ch === '"') {
228
+ const end = skipLiteral(content, i)
229
+ name += unescapeCpp(content.slice(i + 1, end - 1))
230
+ sawLiteral = true
231
+ i = end
232
+ continue
233
+ }
234
+ break
235
+ }
236
+ return sawLiteral ? name : null
237
+ }
238
+ return null
239
+ }
240
+
241
+ /** 1-indexed line number of a character offset. */
242
+ function lineOf (content, index) {
243
+ let line = 1
244
+ for (let i = 0; i < index; i++) {
245
+ if (content[i] === '\n') line++
246
+ }
247
+ return line
248
+ }
249
+
250
+ /**
251
+ * Scan one file's content for metric description declarations.
252
+ * Exported for tests.
253
+ *
254
+ * @param {string} content - File content
255
+ * @param {string} file - Repo-relative path (recorded on declarations)
256
+ * @returns {Array} declarations (without declaration_text, added by extract)
257
+ */
258
+ function scanFile (content, file) {
259
+ const declarations = []
260
+ // Scan a comment-masked copy: offsets and line numbers are identical, but
261
+ // description() calls mentioned in comments can never match.
262
+ const masked = maskComments(content)
263
+ DESCRIPTION_CALL.lastIndex = 0
264
+ let match
265
+ while ((match = DESCRIPTION_CALL.exec(masked)) !== null) {
266
+ const openIndex = match.index + match[0].length - 1
267
+ const closeIndex = findBalancedClose(masked, openIndex)
268
+ if (closeIndex === -1) continue
269
+
270
+ const { verifiable, value } = parseArgument(masked.slice(openIndex + 1, closeIndex))
271
+ declarations.push({
272
+ surface: 'metrics',
273
+ name: resolveMetricName(masked, match.index),
274
+ file,
275
+ line_start: lineOf(masked, match.index),
276
+ line_end: lineOf(masked, closeIndex),
277
+ string: value,
278
+ declaration_text: null,
279
+ convention: CONVENTION,
280
+ meta: { unverifiable: !verifiable }
281
+ })
282
+ DESCRIPTION_CALL.lastIndex = closeIndex + 1
283
+ }
284
+ return declarations
285
+ }
286
+
287
+ /** Recursively collect .cc/.h files, skipping tests and hidden dirs. */
288
+ function collectSourceFiles (root, base = root, out = []) {
289
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
290
+ if (entry.name.startsWith('.')) continue
291
+ const fullPath = path.join(root, entry.name)
292
+ if (entry.isDirectory()) {
293
+ if (entry.name === 'tests' || entry.name === 'test' || entry.name === 'node_modules') continue
294
+ collectSourceFiles(fullPath, base, out)
295
+ } else if (/\.(cc|h)$/.test(entry.name)) {
296
+ out.push(path.relative(base, fullPath))
297
+ }
298
+ }
299
+ return out
300
+ }
301
+
302
+ /**
303
+ * Extract metric description declarations.
304
+ *
305
+ * @param {Object} options - { repo, files (Set of repo-relative paths, diff
306
+ * mode; when omitted, scans all non-test .cc/.h under <repo>/src, or the
307
+ * whole repo when there is no src/) }
308
+ */
309
+ function extract ({ repo, files = null }) {
310
+ let fileList
311
+ if (files) {
312
+ fileList = [...files]
313
+ } else {
314
+ const srcRoot = fs.existsSync(path.join(repo, 'src', 'v')) ? path.join(repo, 'src', 'v') : repo
315
+ fileList = collectSourceFiles(srcRoot).map((f) => path.relative(repo, path.join(srcRoot, f)))
316
+ }
317
+
318
+ const cache = new SourceCache(repo)
319
+ const declarations = []
320
+ for (const file of fileList) {
321
+ const absPath = path.isAbsolute(file) ? file : path.join(repo, file)
322
+ if (!fs.existsSync(absPath)) continue
323
+ const content = fs.readFileSync(absPath, 'utf8')
324
+ if (!content.includes('::description')) continue
325
+ for (const decl of scanFile(content, file)) {
326
+ decl.declaration_text = cache.span(file, decl.line_start, decl.line_end)
327
+ declarations.push(decl)
328
+ }
329
+ }
330
+ return declarations
331
+ }
332
+
333
+ /** Surface-specific convention rules. */
334
+ const RULES = [
335
+ {
336
+ name: 'trailing-period',
337
+ description: 'Metric descriptions do not end with a period',
338
+ severity: 'warning',
339
+ check: (decl) => {
340
+ const text = (decl.string || '').trim()
341
+ if (!text) return []
342
+ if (/\.$/.test(text)) {
343
+ return [{ message: `Metric description ends with a period: "...${text.slice(-50)}". The metrics convention is no terminal period.` }]
344
+ }
345
+ return []
346
+ }
347
+ },
348
+ {
349
+ name: 'unverifiable-description',
350
+ description: 'Description argument is not a plain string literal',
351
+ severity: 'info',
352
+ runOnUnverifiable: true,
353
+ check: (decl) => {
354
+ if (decl.meta && decl.meta.unverifiable) {
355
+ return [{ message: 'Description is built from a constant or expression (not adjacent string literals), so it cannot be verified here. Check the referenced value manually.' }]
356
+ }
357
+ return []
358
+ }
359
+ }
360
+ ]
361
+
362
+ module.exports = {
363
+ name: 'metrics',
364
+ convention: CONVENTION,
365
+ extract,
366
+ scanFile,
367
+ rules: RULES
368
+ }
@@ -0,0 +1,216 @@
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+ const os = require('os')
5
+ const path = require('path')
6
+ const { spawnSync } = require('child_process')
7
+
8
+ const { SourceCache } = require('../source-text')
9
+
10
+ /**
11
+ * Properties surface: cluster/node configuration properties declared in
12
+ * src/v/config/{configuration,node_config}.cc and friends.
13
+ *
14
+ * Semantic truth comes from the existing Python property extractor
15
+ * (tools/property-extractor), invoked with `--path <repo>` against an
16
+ * EXISTING checkout - this module never clones redpanda. It consumes the
17
+ * extractor's JSON including the additive line_start/line_end spans recorded
18
+ * by parser.py/transformers.py, which cover the full member-initializer
19
+ * entry (so clang-format-wrapped descriptions map to whole declarations).
20
+ *
21
+ * Environment setup mirrors the Makefile's `venv` and `treesitter` targets:
22
+ * a Python venv under tools/property-extractor/tmp and a tree-sitter-cpp
23
+ * grammar checkout at v0.20.5.
24
+ */
25
+
26
+ const TOOL_ROOT = path.resolve(__dirname, '../../property-extractor')
27
+ const VENV_DIR = path.join(TOOL_ROOT, 'tmp', 'redpanda-property-extractor-venv')
28
+ const VENV_PYTHON = path.join(VENV_DIR, 'bin', 'python')
29
+ const TREESITTER_DIR = path.join(TOOL_ROOT, 'tree-sitter', 'tree-sitter-cpp')
30
+ const TREESITTER_TAG = 'v0.20.5'
31
+
32
+ const CONVENTION = {
33
+ case: 'sentence',
34
+ terminal_period: true,
35
+ verbatim_asciidoc: true,
36
+ state_default: true
37
+ }
38
+
39
+ function run (cmd, args, options = {}) {
40
+ const result = spawnSync(cmd, args, { encoding: 'utf8', ...options })
41
+ if (result.error) throw result.error
42
+ if (result.status !== 0) {
43
+ throw new Error(`${cmd} ${args.join(' ')} failed:\n${result.stderr || result.stdout}`)
44
+ }
45
+ return result
46
+ }
47
+
48
+ /**
49
+ * Mirror of the Makefile `venv` target: create the venv and install
50
+ * requirements once; later runs reuse it.
51
+ */
52
+ function ensureVenv (log) {
53
+ if (fs.existsSync(VENV_PYTHON)) return
54
+ log(`Creating property-extractor venv at ${VENV_DIR}...`)
55
+ fs.mkdirSync(path.dirname(VENV_DIR), { recursive: true })
56
+ run('python3', ['-m', 'venv', VENV_DIR])
57
+ run(path.join(VENV_DIR, 'bin', 'pip'), ['install', '--quiet', '--upgrade', 'pip'])
58
+ run(path.join(VENV_DIR, 'bin', 'pip'), ['install', '--quiet', '--no-cache-dir', '-r', path.join(TOOL_ROOT, 'requirements.txt')])
59
+ }
60
+
61
+ /**
62
+ * Mirror of the Makefile `treesitter` target: clone tree-sitter-cpp, pin to
63
+ * v0.20.5, and generate the parser only when parser.c is missing (the pinned
64
+ * tag ships a generated parser.c, so the npx step is normally skipped).
65
+ */
66
+ function ensureTreesitter (log) {
67
+ const parserC = path.join(TREESITTER_DIR, 'src', 'parser.c')
68
+ if (!fs.existsSync(path.join(TREESITTER_DIR, '.git'))) {
69
+ log(`Cloning tree-sitter-cpp grammar into ${TREESITTER_DIR}...`)
70
+ fs.rmSync(TREESITTER_DIR, { recursive: true, force: true })
71
+ fs.mkdirSync(path.dirname(TREESITTER_DIR), { recursive: true })
72
+ run('git', ['clone', '--quiet', 'https://github.com/tree-sitter/tree-sitter-cpp.git', TREESITTER_DIR])
73
+ }
74
+ const tag = spawnSync('git', ['-C', TREESITTER_DIR, 'describe', '--tags', '--exact-match'], { encoding: 'utf8' })
75
+ if ((tag.stdout || '').trim() !== TREESITTER_TAG || !fs.existsSync(parserC)) {
76
+ run('git', ['-C', TREESITTER_DIR, 'fetch', '--tags', '--quiet'])
77
+ run('git', ['-C', TREESITTER_DIR, 'checkout', '--quiet', TREESITTER_TAG])
78
+ if (!fs.existsSync(parserC)) {
79
+ log('Generating tree-sitter-cpp parser...')
80
+ run('npm', ['install', '--silent'], { cwd: TREESITTER_DIR })
81
+ run('npx', ['tree-sitter', 'generate'], { cwd: TREESITTER_DIR, env: { ...process.env, CFLAGS: '-Wno-unused-but-set-variable' } })
82
+ }
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Run the Python extractor against an existing checkout and return the
88
+ * parsed properties JSON ({ properties, definitions }).
89
+ */
90
+ function runExtractor (repo, log) {
91
+ ensureVenv(log)
92
+ ensureTreesitter(log)
93
+
94
+ const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'lint-strings-props-'))
95
+ const outputPath = path.join(scratch, 'properties.json')
96
+ try {
97
+ log(`Extracting properties from ${repo} (this parses the C++ sources and can take a few minutes)...`)
98
+ // cwd must be TOOL_ROOT: property_extractor.py resolves the tree-sitter
99
+ // grammar relative to the working directory.
100
+ run(VENV_PYTHON, ['-W', 'ignore', 'property_extractor.py', '--recursive', '--path', repo, '--output', outputPath], {
101
+ cwd: TOOL_ROOT,
102
+ maxBuffer: 256 * 1024 * 1024
103
+ })
104
+ return JSON.parse(fs.readFileSync(outputPath, 'utf8'))
105
+ } finally {
106
+ fs.rmSync(scratch, { recursive: true, force: true })
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Map extractor JSON to lint declarations. Pure - exported for tests.
112
+ *
113
+ * Skips:
114
+ * - deprecated properties (no user-facing description contract)
115
+ * - topic properties (synthesized from cluster properties, no source span)
116
+ *
117
+ * @param {Object} json - Extractor output ({ properties: {...} })
118
+ * @param {string} repo - Repo path, for reading declaration_text
119
+ * @param {Object} [options] - { files: Set<string> } restrict to these
120
+ * repo-relative files (diff mode)
121
+ */
122
+ function mapExtractorJson (json, repo, options = {}) {
123
+ const { files = null } = options
124
+ const cache = new SourceCache(repo)
125
+ const declarations = []
126
+
127
+ for (const [name, prop] of Object.entries(json.properties || {})) {
128
+ if (prop.is_deprecated) continue
129
+ if (prop.is_topic_property) continue
130
+ const file = prop.defined_in
131
+ if (!file) continue
132
+ if (files && !files.has(file)) continue
133
+
134
+ const lineStart = prop.line_start != null ? prop.line_start : null
135
+ const lineEnd = prop.line_end != null ? prop.line_end : lineStart
136
+ const hasDefault = Object.prototype.hasOwnProperty.call(prop, 'default') &&
137
+ prop.default !== null && prop.default !== ''
138
+
139
+ declarations.push({
140
+ surface: 'properties',
141
+ name,
142
+ file,
143
+ line_start: lineStart,
144
+ line_end: lineEnd,
145
+ string: typeof prop.description === 'string' && prop.description.trim() !== '' ? prop.description : null,
146
+ declaration_text: cache.span(file, lineStart, lineEnd),
147
+ convention: CONVENTION,
148
+ meta: {
149
+ has_default: hasDefault,
150
+ default: hasDefault ? prop.default : null,
151
+ is_enum: Boolean(prop.is_enum),
152
+ nullable: Boolean(prop.nullable)
153
+ }
154
+ })
155
+ }
156
+
157
+ return declarations
158
+ }
159
+
160
+ /**
161
+ * Extract property declarations from an existing redpanda checkout.
162
+ *
163
+ * @param {Object} options - { repo, files (Set of repo-relative paths, diff
164
+ * mode), log (progress logger, defaults to stderr) }
165
+ */
166
+ function extract ({ repo, files = null, log = (msg) => process.stderr.write(`${msg}\n`) }) {
167
+ const json = runExtractor(repo, log)
168
+ return mapExtractorJson(json, repo, { files })
169
+ }
170
+
171
+ /** Surface-specific convention rules. */
172
+ const RULES = [
173
+ {
174
+ name: 'missing-terminal-period',
175
+ description: 'Property descriptions are complete sentences ending in a period',
176
+ severity: 'warning',
177
+ check: (decl) => {
178
+ const text = (decl.string || '').trim()
179
+ if (!text) return []
180
+ if (!/[.!?]$/.test(text)) {
181
+ return [{ message: `Description does not end with a period: "...${text.slice(-50)}"` }]
182
+ }
183
+ return []
184
+ }
185
+ },
186
+ {
187
+ name: 'default-not-stated',
188
+ description: 'Property has a default the description never mentions',
189
+ severity: 'info',
190
+ check: (decl) => {
191
+ const text = decl.string || ''
192
+ if (!text || !decl.meta || !decl.meta.has_default) return []
193
+ const defaultText = String(decl.meta.default)
194
+ if (/\bdefaults?\b/i.test(text)) return []
195
+ if (defaultText.length > 0 && text.includes(defaultText)) return []
196
+ return [{ message: `Property has a default (${JSON.stringify(decl.meta.default)}) that the description does not state.` }]
197
+ }
198
+ }
199
+ ]
200
+
201
+ module.exports = {
202
+ name: 'properties',
203
+ convention: CONVENTION,
204
+ extract,
205
+ mapExtractorJson,
206
+ runExtractor,
207
+ rules: RULES,
208
+ // Exposed for tests and future tooling. TREESITTER_TAG is also the single
209
+ // pin for the grammar: tools/property-extractor/Makefile reads it from here
210
+ // rather than repeating the version, so the Makefile's `treesitter` target
211
+ // and this module's ensureTreesitter can never check out different grammars.
212
+ TOOL_ROOT,
213
+ VENV_PYTHON,
214
+ TREESITTER_DIR,
215
+ TREESITTER_TAG
216
+ }