@redpanda-data/docs-extensions-and-macros 5.14.0 → 5.17.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 +252 -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/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/parser.py +26 -0
- package/tools/property-extractor/property_extractor.py +41 -0
- package/tools/property-extractor/transformers.py +15 -2
- package/tools/rpk-docs/detect-platform-commands.js +8 -1
- package/tools/rpk-docs/scan-deprecated-commands.js +15 -18
|
@@ -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
|
+
}
|