@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,33 @@
|
|
|
1
|
+
// Fixture header for lint-strings tests. The declarations mirror the shapes
|
|
2
|
+
// in redpanda's src/v/config/configuration.h so the real property extractor
|
|
3
|
+
// can parse the matching .cc file.
|
|
4
|
+
#pragma once
|
|
5
|
+
|
|
6
|
+
#include "config/property.h"
|
|
7
|
+
|
|
8
|
+
namespace config {
|
|
9
|
+
|
|
10
|
+
struct lint_fixture_config {
|
|
11
|
+
// Known-bad: empty description (mirrors configuration.cc
|
|
12
|
+
// compaction_ctrl_update_interval_ms)
|
|
13
|
+
property<std::chrono::milliseconds> compaction_ctrl_update_interval_ms;
|
|
14
|
+
|
|
15
|
+
// Known-bad: unbalanced backticks (mirrors configuration.cc
|
|
16
|
+
// cloud_storage_credentials_source)
|
|
17
|
+
enum_property<ss::sstring> cloud_storage_credentials_source;
|
|
18
|
+
|
|
19
|
+
// Known-bad: lowercase-start fragment, no terminal period
|
|
20
|
+
property<double> cloud_storage_upload_ctrl_p_coeff;
|
|
21
|
+
|
|
22
|
+
// Known-bad: tautology (description just restates the name)
|
|
23
|
+
property<std::optional<ss::sstring>> cluster_id;
|
|
24
|
+
|
|
25
|
+
// Known-bad: "recomended" typo string. Typo detection is NOT a lint rule;
|
|
26
|
+
// this doubles as the lowercase-start + under-20-chars example.
|
|
27
|
+
property<int32_t> write_caching_default_bytes;
|
|
28
|
+
|
|
29
|
+
// Conforming counterpart: must produce zero findings.
|
|
30
|
+
property<int32_t> kafka_batch_max_bytes;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
} // namespace config
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Package lintfixture is a lint-strings test fixture: known-bad rpk
|
|
2
|
+
// declarations with conforming counterparts. It only needs to parse as Go
|
|
3
|
+
// text for the scanner; it is never compiled.
|
|
4
|
+
package lintfixture
|
|
5
|
+
|
|
6
|
+
import (
|
|
7
|
+
"github.com/spf13/afero"
|
|
8
|
+
"github.com/spf13/cobra"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
// cobra.Command mentioned in a comment must never match: cobra.Command{Use: "fake"}.
|
|
12
|
+
|
|
13
|
+
func newLowercaseShortCommand(fs afero.Fs) *cobra.Command {
|
|
14
|
+
cmd := &cobra.Command{
|
|
15
|
+
Use: "badshort",
|
|
16
|
+
Short: "queries cluster for health overview.",
|
|
17
|
+
Long: `Query the cluster.
|
|
18
|
+
|
|
19
|
+
USAGE DETAILS
|
|
20
|
+
|
|
21
|
+
This section heading above is intended.
|
|
22
|
+
|
|
23
|
+
Some detail text here that is not a heading.
|
|
24
|
+
====
|
|
25
|
+
|
|
26
|
+
The ==== line above breaks the generated page.
|
|
27
|
+
`,
|
|
28
|
+
Run: func(cmd *cobra.Command, _ []string) {
|
|
29
|
+
// A paren ) and brace } in a string: "don't break (the) scanner".
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
cmd.Flags().BoolVarP(&watch, "watch", "w", false, "Blocks and writes out all changes")
|
|
33
|
+
cmd.Flags().StringVar(&dest, "trailing-period", "", "Writes the output to the given path.")
|
|
34
|
+
cmd.Flags().String("verbose-output", "", "verbose output")
|
|
35
|
+
return cmd
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const conformingLong = `Manage widgets.
|
|
39
|
+
|
|
40
|
+
This is a conforming Long description with no heading-shaped lines,
|
|
41
|
+
resolved through a package-level constant.
|
|
42
|
+
`
|
|
43
|
+
|
|
44
|
+
func newConformingCommand() *cobra.Command {
|
|
45
|
+
cmd := &cobra.Command{
|
|
46
|
+
Use: "widget [NAME]",
|
|
47
|
+
Aliases: []string{"widgets"},
|
|
48
|
+
Short: "Manage widgets in the cluster",
|
|
49
|
+
Long: conformingLong,
|
|
50
|
+
}
|
|
51
|
+
f := cmd.Flags()
|
|
52
|
+
f.IntVar(&count, "retries", 3, "Number of retries before giving up")
|
|
53
|
+
f.DurationVar(&timeout, "timeout", 0, "How long to wait for the cluster to respond, for example 30s")
|
|
54
|
+
cmd.Flags().StringP("format", "f", "text", "Output format, one of text or json")
|
|
55
|
+
// Dynamic usage strings stay unverifiable, never errors:
|
|
56
|
+
cmd.Flags().BoolP("help", "h", false, "Help for "+cmd.Name())
|
|
57
|
+
return cmd
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// A multiline Short: the rpk convention is a one-line Short, so this is the
|
|
61
|
+
// known-bad counterpart for the rpk-short-multiline rule. Everything else
|
|
62
|
+
// about it conforms, so it isolates that one rule.
|
|
63
|
+
func newMultilineShortCommand() *cobra.Command {
|
|
64
|
+
return &cobra.Command{
|
|
65
|
+
Use: "multiline",
|
|
66
|
+
Short: `Manage the widget cache
|
|
67
|
+
and everything attached to it`,
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared Go source scanning helpers for the rpk, crd, and connect surfaces.
|
|
5
|
+
*
|
|
6
|
+
* Same philosophy as the metrics scanner (surfaces/metrics.js): a
|
|
7
|
+
* comment-masked regex/paren scanner over real source text, never a compiler.
|
|
8
|
+
* Go adds two string shapes the C++ scanner does not have: raw strings
|
|
9
|
+
* (`...` backticks, no escapes, literal newlines) and rune literals ('x').
|
|
10
|
+
* All helpers preserve offsets and newlines so line numbers stay exact.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Replace // and /* *\/ comments with spaces, preserving every offset and
|
|
15
|
+
* newline. String and rune literal contents are left untouched, so a "//"
|
|
16
|
+
* inside a string never masks anything.
|
|
17
|
+
*/
|
|
18
|
+
function maskComments (content) {
|
|
19
|
+
const out = content.split('')
|
|
20
|
+
let i = 0
|
|
21
|
+
while (i < content.length) {
|
|
22
|
+
const ch = content[i]
|
|
23
|
+
if (ch === '"' || ch === '`' || ch === "'") {
|
|
24
|
+
i = skipString(content, i)
|
|
25
|
+
continue
|
|
26
|
+
}
|
|
27
|
+
if (ch === '/' && content[i + 1] === '/') {
|
|
28
|
+
while (i < content.length && content[i] !== '\n') out[i++] = ' '
|
|
29
|
+
continue
|
|
30
|
+
}
|
|
31
|
+
if (ch === '/' && content[i + 1] === '*') {
|
|
32
|
+
const end = content.indexOf('*/', i + 2)
|
|
33
|
+
const stop = end === -1 ? content.length : end + 2
|
|
34
|
+
while (i < stop) {
|
|
35
|
+
if (content[i] !== '\n') out[i] = ' '
|
|
36
|
+
i++
|
|
37
|
+
}
|
|
38
|
+
continue
|
|
39
|
+
}
|
|
40
|
+
i++
|
|
41
|
+
}
|
|
42
|
+
return out.join('')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Skip a Go string/rune literal starting at index; returns the index after
|
|
47
|
+
* it. Interpreted strings ("...") and runes ('x') honor backslash escapes;
|
|
48
|
+
* raw strings (`...`) have no escapes and may span lines.
|
|
49
|
+
*/
|
|
50
|
+
function skipString (content, start) {
|
|
51
|
+
const quote = content[start]
|
|
52
|
+
let i = start + 1
|
|
53
|
+
if (quote === '`') {
|
|
54
|
+
while (i < content.length && content[i] !== '`') i++
|
|
55
|
+
return Math.min(i + 1, content.length)
|
|
56
|
+
}
|
|
57
|
+
while (i < content.length) {
|
|
58
|
+
if (content[i] === '\\') {
|
|
59
|
+
i += 2
|
|
60
|
+
continue
|
|
61
|
+
}
|
|
62
|
+
if (content[i] === quote) return i + 1
|
|
63
|
+
i++
|
|
64
|
+
}
|
|
65
|
+
return content.length
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Walk forward from an opening delimiter and return the index of its
|
|
70
|
+
* balanced closing delimiter, skipping string/rune literals. Assumes
|
|
71
|
+
* comment-masked content. Returns -1 when unbalanced.
|
|
72
|
+
*/
|
|
73
|
+
function findBalancedClose (content, openIndex, open = '(', close = ')') {
|
|
74
|
+
let depth = 0
|
|
75
|
+
let i = openIndex
|
|
76
|
+
while (i < content.length) {
|
|
77
|
+
const ch = content[i]
|
|
78
|
+
if (ch === '"' || ch === '`' || ch === "'") {
|
|
79
|
+
i = skipString(content, i)
|
|
80
|
+
continue
|
|
81
|
+
}
|
|
82
|
+
if (ch === open) depth++
|
|
83
|
+
if (ch === close) {
|
|
84
|
+
depth--
|
|
85
|
+
if (depth === 0) return i
|
|
86
|
+
}
|
|
87
|
+
i++
|
|
88
|
+
}
|
|
89
|
+
return -1
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Decode a Go interpreted-string body (the text between the quotes). */
|
|
93
|
+
function unescapeGo (text) {
|
|
94
|
+
return text.replace(/\\(.)/g, (whole, ch) => {
|
|
95
|
+
if (ch === 'n') return '\n'
|
|
96
|
+
if (ch === 't') return '\t'
|
|
97
|
+
if (ch === 'r') return '\r'
|
|
98
|
+
return ch
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Split argument text on top-level commas (string, paren, brace, and
|
|
104
|
+
* bracket aware). Assumes comment-masked content.
|
|
105
|
+
*/
|
|
106
|
+
function splitTopLevelArgs (argText) {
|
|
107
|
+
const args = []
|
|
108
|
+
let start = 0
|
|
109
|
+
let depth = 0
|
|
110
|
+
let i = 0
|
|
111
|
+
while (i < argText.length) {
|
|
112
|
+
const ch = argText[i]
|
|
113
|
+
if (ch === '"' || ch === '`' || ch === "'") {
|
|
114
|
+
i = skipString(argText, i)
|
|
115
|
+
continue
|
|
116
|
+
}
|
|
117
|
+
if (ch === '(' || ch === '{' || ch === '[') depth++
|
|
118
|
+
if (ch === ')' || ch === '}' || ch === ']') depth--
|
|
119
|
+
if (ch === ',' && depth === 0) {
|
|
120
|
+
args.push(argText.slice(start, i))
|
|
121
|
+
start = i + 1
|
|
122
|
+
}
|
|
123
|
+
i++
|
|
124
|
+
}
|
|
125
|
+
const last = argText.slice(start)
|
|
126
|
+
if (last.trim() !== '' || args.length > 0) args.push(last)
|
|
127
|
+
return args
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Collect same-file string constants: `const x = "..."` and const blocks.
|
|
132
|
+
* Only constants whose value is purely (concatenated) string literals are
|
|
133
|
+
* recorded - anything else stays unresolvable so it can never fake a
|
|
134
|
+
* verifiable description.
|
|
135
|
+
*
|
|
136
|
+
* @param {string} masked - Comment-masked file content
|
|
137
|
+
* @returns {Map<string, string>} identifier -> string value
|
|
138
|
+
*/
|
|
139
|
+
function collectStringConsts (masked) {
|
|
140
|
+
const consts = new Map()
|
|
141
|
+
const pattern = /\bconst\b/g
|
|
142
|
+
let match
|
|
143
|
+
while ((match = pattern.exec(masked)) !== null) {
|
|
144
|
+
let i = match.index + match[0].length
|
|
145
|
+
while (i < masked.length && /[ \t]/.test(masked[i])) i++
|
|
146
|
+
if (masked[i] === '(') {
|
|
147
|
+
const close = findBalancedClose(masked, i)
|
|
148
|
+
if (close === -1) continue
|
|
149
|
+
collectConstEntries(masked.slice(i + 1, close), consts)
|
|
150
|
+
pattern.lastIndex = close + 1
|
|
151
|
+
} else {
|
|
152
|
+
// Single-const form: the statement ends at the first newline OUTSIDE
|
|
153
|
+
// a string (a raw-string value may span lines), unless that newline
|
|
154
|
+
// follows a `+` continuation - Go allows a string concatenation to
|
|
155
|
+
// wrap across lines with no surrounding parens.
|
|
156
|
+
let j = i
|
|
157
|
+
while (j < masked.length) {
|
|
158
|
+
if (masked[j] === '"' || masked[j] === '`' || masked[j] === "'") {
|
|
159
|
+
j = skipString(masked, j)
|
|
160
|
+
continue
|
|
161
|
+
}
|
|
162
|
+
if (masked[j] === '\n') {
|
|
163
|
+
let k = j - 1
|
|
164
|
+
while (k >= i && /[ \t\r]/.test(masked[k])) k--
|
|
165
|
+
if (k >= i && masked[k] === '+') {
|
|
166
|
+
j++
|
|
167
|
+
continue
|
|
168
|
+
}
|
|
169
|
+
break
|
|
170
|
+
}
|
|
171
|
+
j++
|
|
172
|
+
}
|
|
173
|
+
collectConstEntries(masked.slice(i, j), consts)
|
|
174
|
+
pattern.lastIndex = j
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return consts
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Parse `name [type] = <string expr>` lines inside a const region. */
|
|
181
|
+
function collectConstEntries (regionText, consts) {
|
|
182
|
+
// Split on newlines OUTSIDE strings (raw strings may span lines), unless
|
|
183
|
+
// the newline follows a `+` continuation - a string concatenation may
|
|
184
|
+
// wrap across lines with no surrounding parens, and must stay one entry.
|
|
185
|
+
const lines = []
|
|
186
|
+
let start = 0
|
|
187
|
+
let i = 0
|
|
188
|
+
while (i < regionText.length) {
|
|
189
|
+
const ch = regionText[i]
|
|
190
|
+
if (ch === '"' || ch === '`' || ch === "'") {
|
|
191
|
+
i = skipString(regionText, i)
|
|
192
|
+
continue
|
|
193
|
+
}
|
|
194
|
+
if (ch === '\n') {
|
|
195
|
+
let k = i - 1
|
|
196
|
+
while (k >= start && /[ \t\r]/.test(regionText[k])) k--
|
|
197
|
+
if (k >= start && regionText[k] === '+') {
|
|
198
|
+
i++
|
|
199
|
+
continue
|
|
200
|
+
}
|
|
201
|
+
lines.push(regionText.slice(start, i))
|
|
202
|
+
start = i + 1
|
|
203
|
+
}
|
|
204
|
+
i++
|
|
205
|
+
}
|
|
206
|
+
lines.push(regionText.slice(start))
|
|
207
|
+
|
|
208
|
+
for (const line of lines) {
|
|
209
|
+
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*(?:[A-Za-z_][A-Za-z0-9_.]*\s*)?=\s*(.+)$/s)
|
|
210
|
+
if (!m) continue
|
|
211
|
+
const { verifiable, value } = evalStringExpr(m[2], consts)
|
|
212
|
+
if (verifiable) consts.set(m[1], value)
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Evaluate a Go expression expected to produce a string: a sequence of
|
|
218
|
+
* string literals and known identifiers joined by `+`. Anything else
|
|
219
|
+
* (function calls, fmt.Sprintf, selectors, unknown identifiers) makes the
|
|
220
|
+
* result unverifiable - the caller reports it, never guesses.
|
|
221
|
+
*
|
|
222
|
+
* @param {string} exprText - Comment-masked expression text
|
|
223
|
+
* @param {Map<string, string>} [consts] - Same-file string constants
|
|
224
|
+
* @returns {{ verifiable: boolean, value: string|null, sawString: boolean }}
|
|
225
|
+
*/
|
|
226
|
+
function evalStringExpr (exprText, consts = new Map()) {
|
|
227
|
+
const parts = []
|
|
228
|
+
let verifiable = true
|
|
229
|
+
let sawString = false
|
|
230
|
+
let i = 0
|
|
231
|
+
while (i < exprText.length) {
|
|
232
|
+
const ch = exprText[i]
|
|
233
|
+
if (/\s/.test(ch) || ch === '+') {
|
|
234
|
+
i++
|
|
235
|
+
continue
|
|
236
|
+
}
|
|
237
|
+
if (ch === '"') {
|
|
238
|
+
const end = skipString(exprText, i)
|
|
239
|
+
parts.push(unescapeGo(exprText.slice(i + 1, end - 1)))
|
|
240
|
+
sawString = true
|
|
241
|
+
i = end
|
|
242
|
+
continue
|
|
243
|
+
}
|
|
244
|
+
if (ch === '`') {
|
|
245
|
+
const end = skipString(exprText, i)
|
|
246
|
+
parts.push(exprText.slice(i + 1, end - 1))
|
|
247
|
+
sawString = true
|
|
248
|
+
i = end
|
|
249
|
+
continue
|
|
250
|
+
}
|
|
251
|
+
if (/[A-Za-z_]/.test(ch)) {
|
|
252
|
+
let j = i
|
|
253
|
+
while (j < exprText.length && /[A-Za-z0-9_.]/.test(exprText[j])) j++
|
|
254
|
+
const ident = exprText.slice(i, j)
|
|
255
|
+
// A following "(" means a call - unverifiable.
|
|
256
|
+
let k = j
|
|
257
|
+
while (k < exprText.length && /\s/.test(exprText[k])) k++
|
|
258
|
+
if (exprText[k] === '(') {
|
|
259
|
+
verifiable = false
|
|
260
|
+
const close = findBalancedClose(exprText, k)
|
|
261
|
+
i = close === -1 ? exprText.length : close + 1
|
|
262
|
+
continue
|
|
263
|
+
}
|
|
264
|
+
if (consts.has(ident)) {
|
|
265
|
+
parts.push(consts.get(ident))
|
|
266
|
+
sawString = true
|
|
267
|
+
} else {
|
|
268
|
+
verifiable = false
|
|
269
|
+
}
|
|
270
|
+
i = j
|
|
271
|
+
continue
|
|
272
|
+
}
|
|
273
|
+
// Any other token (&, numbers, composite literals, ...) is not a string.
|
|
274
|
+
verifiable = false
|
|
275
|
+
if (ch === '(' || ch === '{' || ch === '[') {
|
|
276
|
+
const close = findBalancedClose(exprText, i, ch, ch === '(' ? ')' : ch === '{' ? '}' : ']')
|
|
277
|
+
i = close === -1 ? exprText.length : close + 1
|
|
278
|
+
continue
|
|
279
|
+
}
|
|
280
|
+
i++
|
|
281
|
+
}
|
|
282
|
+
if (!sawString) return { verifiable: false, value: null, sawString }
|
|
283
|
+
return { verifiable, value: verifiable ? parts.join('') : null, sawString }
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** 1-indexed line number of a character offset. */
|
|
287
|
+
function lineOf (content, index) {
|
|
288
|
+
let line = 1
|
|
289
|
+
for (let i = 0; i < index; i++) {
|
|
290
|
+
if (content[i] === '\n') line++
|
|
291
|
+
}
|
|
292
|
+
return line
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const fs = require('fs')
|
|
296
|
+
const path = require('path')
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Recursively collect .go files under root (repo-relative paths), skipping
|
|
300
|
+
* _test.go files, testdata/fuzzing dirs, and hidden dirs.
|
|
301
|
+
*/
|
|
302
|
+
function collectGoFiles (root, base = root, out = []) {
|
|
303
|
+
if (!fs.existsSync(root)) return out
|
|
304
|
+
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
|
305
|
+
if (entry.name.startsWith('.')) continue
|
|
306
|
+
const fullPath = path.join(root, entry.name)
|
|
307
|
+
if (entry.isDirectory()) {
|
|
308
|
+
if (entry.name === 'testdata' || entry.name === 'fuzzing' || entry.name === 'node_modules') continue
|
|
309
|
+
collectGoFiles(fullPath, base, out)
|
|
310
|
+
} else if (entry.name.endsWith('.go') && !entry.name.endsWith('_test.go')) {
|
|
311
|
+
out.push(path.relative(base, fullPath))
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return out
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
module.exports = {
|
|
318
|
+
maskComments,
|
|
319
|
+
skipString,
|
|
320
|
+
findBalancedClose,
|
|
321
|
+
unescapeGo,
|
|
322
|
+
splitTopLevelArgs,
|
|
323
|
+
collectStringConsts,
|
|
324
|
+
evalStringExpr,
|
|
325
|
+
lineOf,
|
|
326
|
+
collectGoFiles
|
|
327
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const fs = require('fs')
|
|
4
|
+
const path = require('path')
|
|
5
|
+
|
|
6
|
+
const { runRules, mergeResults } = require('./engine')
|
|
7
|
+
const { getChangedLines, classifyDiff, spanIntersects, SURFACE_ROUTES } = require('./diff')
|
|
8
|
+
const { COMMON_RULES } = require('./rules/common')
|
|
9
|
+
const { VERBATIM_ASCIIDOC_RULES } = require('./rules/verbatim-asciidoc')
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* doc-tools lint-strings: deterministic linting of user-facing doc strings
|
|
13
|
+
* embedded in engineering source code (the strings doc-tools publishes
|
|
14
|
+
* verbatim to docs.redpanda.com).
|
|
15
|
+
*
|
|
16
|
+
* Registered surfaces. SEAM: new surface modules plug in here - implement
|
|
17
|
+
* ./surfaces/<name>.js with the same contract as the existing modules
|
|
18
|
+
* ({ name, convention, extract({repo, files}), rules, [skipRules] }) and
|
|
19
|
+
* add it to this registry plus ./diff.js SURFACE_ROUTES for diff-mode path
|
|
20
|
+
* routing.
|
|
21
|
+
*/
|
|
22
|
+
const SURFACES = {
|
|
23
|
+
properties: require('./surfaces/properties'),
|
|
24
|
+
metrics: require('./surfaces/metrics'),
|
|
25
|
+
rpk: require('./surfaces/rpk'),
|
|
26
|
+
helm: require('./surfaces/helm'),
|
|
27
|
+
crd: require('./surfaces/crd'),
|
|
28
|
+
connect: require('./surfaces/connect')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Build the rule set for a surface: common rules + verbatim-AsciiDoc rules
|
|
33
|
+
* (for surfaces whose strings ship unescaped) + surface-specific rules.
|
|
34
|
+
* A surface may opt out of specific auto-applied rules via an optional
|
|
35
|
+
* skipRules array (for example, rpk skips too-short: one-line Shorts and
|
|
36
|
+
* flag usages are the convention, not a defect).
|
|
37
|
+
*/
|
|
38
|
+
function rulesFor (surface) {
|
|
39
|
+
const rules = [...COMMON_RULES]
|
|
40
|
+
if (surface.convention.verbatim_asciidoc) rules.push(...VERBATIM_ASCIIDOC_RULES)
|
|
41
|
+
rules.push(...surface.rules)
|
|
42
|
+
const skip = new Set(surface.skipRules || [])
|
|
43
|
+
return skip.size > 0 ? rules.filter((rule) => !skip.has(rule.name)) : rules
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Run the linter.
|
|
48
|
+
*
|
|
49
|
+
* @param {Object} options
|
|
50
|
+
* @param {string} options.repo - Path to the engineering checkout (required)
|
|
51
|
+
* @param {string[]} [options.surfaces] - Surface names (default: all registered)
|
|
52
|
+
* @param {string} [options.diffBase] - Declaration-anchored diff mode: lint
|
|
53
|
+
* only declarations whose span intersects lines changed since this ref
|
|
54
|
+
* @param {string[]} [options.skipRules]
|
|
55
|
+
* @param {string[]} [options.onlyRules]
|
|
56
|
+
* @param {Function} [options.log] - Progress logger (stderr by default)
|
|
57
|
+
* @returns {Object} { findings, summary, unsupported_surfaces }
|
|
58
|
+
*/
|
|
59
|
+
function lintStrings (options) {
|
|
60
|
+
const {
|
|
61
|
+
repo,
|
|
62
|
+
surfaces = null,
|
|
63
|
+
diffBase = null,
|
|
64
|
+
skipRules = [],
|
|
65
|
+
onlyRules = null,
|
|
66
|
+
log = (msg) => process.stderr.write(`${msg}\n`)
|
|
67
|
+
} = options
|
|
68
|
+
|
|
69
|
+
if (!repo) throw new Error('lint-strings requires --repo <path>')
|
|
70
|
+
const repoPath = path.resolve(repo)
|
|
71
|
+
if (!fs.existsSync(repoPath)) throw new Error(`Repo path does not exist: ${repoPath}`)
|
|
72
|
+
|
|
73
|
+
const requested = surfaces && surfaces.length > 0 ? surfaces : Object.keys(SURFACES)
|
|
74
|
+
for (const name of requested) {
|
|
75
|
+
if (!SURFACES[name]) {
|
|
76
|
+
const known = SURFACE_ROUTES.some((route) => route.surface === name)
|
|
77
|
+
throw new Error(known
|
|
78
|
+
? `Surface "${name}" is routed but has no extractor registered yet. Registered: ${Object.keys(SURFACES).join(', ')}`
|
|
79
|
+
: `Unknown surface "${name}". Registered: ${Object.keys(SURFACES).join(', ')}`)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const results = []
|
|
84
|
+
const unsupportedSurfaces = []
|
|
85
|
+
|
|
86
|
+
if (diffBase) {
|
|
87
|
+
const changed = getChangedLines(repoPath, diffBase)
|
|
88
|
+
const classified = classifyDiff(changed)
|
|
89
|
+
|
|
90
|
+
for (const [surfaceName, files] of Object.entries(classified)) {
|
|
91
|
+
if (!SURFACES[surfaceName]) {
|
|
92
|
+
// Routed by path but no extractor registered yet (rpk/helm/crd/connect
|
|
93
|
+
// until their surface modules land). Report instead of dropping.
|
|
94
|
+
unsupportedSurfaces.push({ surface: surfaceName, files: [...files.keys()] })
|
|
95
|
+
continue
|
|
96
|
+
}
|
|
97
|
+
if (!requested.includes(surfaceName)) continue
|
|
98
|
+
|
|
99
|
+
const surface = SURFACES[surfaceName]
|
|
100
|
+
log(`[${surfaceName}] ${files.size} changed file(s) in diff; extracting declarations at HEAD...`)
|
|
101
|
+
const fileSet = new Set(files.keys())
|
|
102
|
+
const declarations = surface
|
|
103
|
+
.extract({ repo: repoPath, files: fileSet, log })
|
|
104
|
+
.filter((decl) => spanIntersects(decl.line_start, decl.line_end, files.get(decl.file)))
|
|
105
|
+
for (const decl of declarations) decl.in_pr_diff = true
|
|
106
|
+
results.push(runRules(declarations, rulesFor(surface), { skipRules, onlyRules }))
|
|
107
|
+
}
|
|
108
|
+
} else {
|
|
109
|
+
for (const surfaceName of requested) {
|
|
110
|
+
const surface = SURFACES[surfaceName]
|
|
111
|
+
log(`[${surfaceName}] extracting declarations from ${repoPath}...`)
|
|
112
|
+
const declarations = surface.extract({ repo: repoPath, log })
|
|
113
|
+
for (const decl of declarations) decl.in_pr_diff = false
|
|
114
|
+
results.push(runRules(declarations, rulesFor(surface), { skipRules, onlyRules }))
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const merged = mergeResults(results)
|
|
119
|
+
merged.findings.sort((a, b) =>
|
|
120
|
+
a.surface.localeCompare(b.surface) || a.file.localeCompare(b.file) || (a.line_start || 0) - (b.line_start || 0))
|
|
121
|
+
merged.unsupported_surfaces = unsupportedSurfaces
|
|
122
|
+
return merged
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Human-readable report (the default --format).
|
|
127
|
+
*/
|
|
128
|
+
function formatHuman (result) {
|
|
129
|
+
const lines = []
|
|
130
|
+
const { findings, summary } = result
|
|
131
|
+
|
|
132
|
+
for (const finding of findings) {
|
|
133
|
+
const span = finding.line_start != null ? `:${finding.line_start}-${finding.line_end}` : ''
|
|
134
|
+
const diffMark = finding.in_pr_diff ? ' [in PR diff]' : ''
|
|
135
|
+
lines.push(`${finding.file}${span} ${finding.name || '(unresolved name)'} (${finding.surface})${diffMark}`)
|
|
136
|
+
if (finding.string != null) {
|
|
137
|
+
const preview = finding.string.length > 100 ? `${finding.string.slice(0, 100)}...` : finding.string
|
|
138
|
+
lines.push(` string: ${JSON.stringify(preview)}`)
|
|
139
|
+
}
|
|
140
|
+
for (const issue of finding.rules) {
|
|
141
|
+
lines.push(` ${issue.severity.toUpperCase().padEnd(7)} [${issue.id}] ${issue.message}`)
|
|
142
|
+
}
|
|
143
|
+
lines.push('')
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
lines.push('='.repeat(60))
|
|
147
|
+
lines.push('LINT-STRINGS SUMMARY')
|
|
148
|
+
lines.push('='.repeat(60))
|
|
149
|
+
lines.push(`Declarations checked: ${summary.totalDeclarations}`)
|
|
150
|
+
lines.push(`Declarations flagged: ${summary.flaggedDeclarations}`)
|
|
151
|
+
lines.push(`Errors: ${summary.errors} Warnings: ${summary.warnings} Info: ${summary.info}`)
|
|
152
|
+
if (Object.keys(summary.byRule).length > 0) {
|
|
153
|
+
lines.push('\nBy rule:')
|
|
154
|
+
for (const [rule, counts] of Object.entries(summary.byRule).sort()) {
|
|
155
|
+
const parts = []
|
|
156
|
+
if (counts.errors) parts.push(`${counts.errors} errors`)
|
|
157
|
+
if (counts.warnings) parts.push(`${counts.warnings} warnings`)
|
|
158
|
+
if (counts.info) parts.push(`${counts.info} info`)
|
|
159
|
+
lines.push(` ${rule}: ${parts.join(', ')}`)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (Object.keys(summary.bySurface).length > 0) {
|
|
163
|
+
lines.push('\nBy surface:')
|
|
164
|
+
for (const [surface, counts] of Object.entries(summary.bySurface).sort()) {
|
|
165
|
+
lines.push(` ${surface}: ${counts.flagged}/${counts.declarations} declarations flagged (${counts.errors} errors, ${counts.warnings} warnings, ${counts.info} info)`)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
for (const entry of result.unsupported_surfaces || []) {
|
|
169
|
+
lines.push(`\nNote: diff touches ${entry.surface} files but that surface has no extractor registered yet: ${entry.files.join(', ')}`)
|
|
170
|
+
}
|
|
171
|
+
return lines.join('\n')
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* CLI entry point shared by bin/doc-tools.js and direct invocation
|
|
176
|
+
* (node tools/lint-strings --repo <path> ...).
|
|
177
|
+
*
|
|
178
|
+
* Exit code contract: always 0 (suggest, never block), unless --strict is
|
|
179
|
+
* passed AND there is at least one error-severity finding.
|
|
180
|
+
*/
|
|
181
|
+
function runCli (options) {
|
|
182
|
+
let result
|
|
183
|
+
try {
|
|
184
|
+
result = lintStrings({
|
|
185
|
+
repo: options.repo,
|
|
186
|
+
surfaces: options.surface ? String(options.surface).split(',').map((s) => s.trim()).filter(Boolean) : null,
|
|
187
|
+
diffBase: options.diff || null,
|
|
188
|
+
skipRules: options.skipRules ? String(options.skipRules).split(',').map((s) => s.trim()).filter(Boolean) : [],
|
|
189
|
+
onlyRules: options.onlyRules ? String(options.onlyRules).split(',').map((s) => s.trim()).filter(Boolean) : null
|
|
190
|
+
})
|
|
191
|
+
} catch (err) {
|
|
192
|
+
console.error(`Error: ${err.message}`)
|
|
193
|
+
process.exit(2)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (options.format === 'json') {
|
|
197
|
+
console.log(JSON.stringify(result, null, 2))
|
|
198
|
+
} else {
|
|
199
|
+
console.log(formatHuman(result))
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (options.strict && result.summary.errors > 0) process.exit(1)
|
|
203
|
+
process.exit(0)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
module.exports = { lintStrings, formatHuman, runCli, SURFACES, rulesFor }
|
|
207
|
+
|
|
208
|
+
// Direct usage: node tools/lint-strings --repo <path> [--surface a,b]
|
|
209
|
+
// [--diff <base>] [--format json|human] [--strict]
|
|
210
|
+
if (require.main === module) {
|
|
211
|
+
const args = process.argv.slice(2)
|
|
212
|
+
const options = {}
|
|
213
|
+
for (let i = 0; i < args.length; i++) {
|
|
214
|
+
const arg = args[i]
|
|
215
|
+
if (arg === '--repo') options.repo = args[++i]
|
|
216
|
+
else if (arg === '--surface') options.surface = args[++i]
|
|
217
|
+
else if (arg === '--diff') options.diff = args[++i]
|
|
218
|
+
else if (arg === '--format') options.format = args[++i]
|
|
219
|
+
else if (arg === '--skip-rules') options.skipRules = args[++i]
|
|
220
|
+
else if (arg === '--only-rules') options.onlyRules = args[++i]
|
|
221
|
+
else if (arg === '--strict') options.strict = true
|
|
222
|
+
else {
|
|
223
|
+
console.error(`Unknown argument: ${arg}`)
|
|
224
|
+
console.error('Usage: node tools/lint-strings --repo <path> [--surface a,b] [--diff <base>] [--format json|human] [--skip-rules a,b] [--only-rules a,b] [--strict]')
|
|
225
|
+
process.exit(2)
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
runCli(options)
|
|
229
|
+
}
|