@redpanda-data/docs-extensions-and-macros 5.13.5 → 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 +349 -102
- package/bin/mcp-tools/index.js +20 -0
- package/bin/mcp-tools/lint-doc-strings.js +75 -0
- package/bin/mcp-tools/overrides-audit.js +100 -0
- package/bin/mcp-tools/preview-doc-string.js +71 -0
- package/cli-utils/diff-utils.js +77 -0
- package/cli-utils/helm-commented-values.js +172 -23
- package/cli-utils/sync-schemas.js +144 -0
- package/docs-data/rpk-overrides.schema.json +4 -0
- 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,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
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Rules that apply to every doc-string surface.
|
|
5
|
+
*
|
|
6
|
+
* Rule shape follows tools/rpk-docs/validate-output.js VALIDATION_RULES:
|
|
7
|
+
* { name, description, severity, check(decl) -> issues[] }.
|
|
8
|
+
* `decl` is the declaration object documented in ../engine.js.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Jargon that ships verbatim to docs.redpanda.com but means nothing to users
|
|
13
|
+
* unless expanded. Matched on word boundaries (underscores count as word
|
|
14
|
+
* characters, so `archival_meta_stm` does not trip the `stm` entry).
|
|
15
|
+
*/
|
|
16
|
+
const JARGON_TERMS = ['NTP', 'seastar', 'nullopt', 'stm', 'smp']
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Tokenize a name or description for tautology comparison: strip `_`/`-`,
|
|
20
|
+
* case-fold, split on non-alphanumerics.
|
|
21
|
+
*/
|
|
22
|
+
function tokenize (text) {
|
|
23
|
+
return String(text)
|
|
24
|
+
.toLowerCase()
|
|
25
|
+
.split(/[^a-z0-9]+/)
|
|
26
|
+
.filter(Boolean)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Two tokens "match" when one is a prefix of the other (so `id` matches
|
|
31
|
+
* `identifier`), with a 2-character minimum to avoid single-letter noise.
|
|
32
|
+
*/
|
|
33
|
+
function tokensMatch (a, b) {
|
|
34
|
+
const [short, long] = a.length <= b.length ? [a, b] : [b, a]
|
|
35
|
+
return short.length >= 2 && long.startsWith(short)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* True when the normalized description is just the normalized name:
|
|
40
|
+
* same token count, and every description token pairs with a distinct name
|
|
41
|
+
* token (any order, prefix-tolerant).
|
|
42
|
+
*/
|
|
43
|
+
function isNameEcho (name, description) {
|
|
44
|
+
const nameTokens = tokenize(name)
|
|
45
|
+
const descTokens = tokenize(description.replace(/[.!?]\s*$/, ''))
|
|
46
|
+
if (nameTokens.length === 0 || descTokens.length !== nameTokens.length) return false
|
|
47
|
+
|
|
48
|
+
const used = new Array(nameTokens.length).fill(false)
|
|
49
|
+
for (const descToken of descTokens) {
|
|
50
|
+
const idx = nameTokens.findIndex((nameToken, i) => !used[i] && tokensMatch(descToken, nameToken))
|
|
51
|
+
if (idx === -1) return false
|
|
52
|
+
used[idx] = true
|
|
53
|
+
}
|
|
54
|
+
return true
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const COMMON_RULES = [
|
|
58
|
+
{
|
|
59
|
+
name: 'empty-description',
|
|
60
|
+
description: 'Declaration ships with no doc string at all',
|
|
61
|
+
severity: 'error',
|
|
62
|
+
check: (decl) => {
|
|
63
|
+
if (decl.string == null || decl.string.trim() === '') {
|
|
64
|
+
return [{ message: 'Description is empty or missing. This ships as a blank entry on docs.redpanda.com.' }]
|
|
65
|
+
}
|
|
66
|
+
return []
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
name: 'starts-lowercase',
|
|
71
|
+
description: 'Description starts with a lowercase letter',
|
|
72
|
+
severity: 'warning',
|
|
73
|
+
check: (decl) => {
|
|
74
|
+
const text = (decl.string || '').trim()
|
|
75
|
+
if (!text) return []
|
|
76
|
+
// A description may legitimately open with a backticked literal
|
|
77
|
+
// (for example, "`true` means ..."); only flag bare prose.
|
|
78
|
+
if (text.startsWith('`')) return []
|
|
79
|
+
const firstAlpha = text.match(/[A-Za-z]/)
|
|
80
|
+
if (firstAlpha && firstAlpha.index === 0 && /[a-z]/.test(firstAlpha[0])) {
|
|
81
|
+
// Mixed-case product spellings (gRPC, iOS, jsonPath) are correct as
|
|
82
|
+
// written; only all-lowercase first words are capitalization drift.
|
|
83
|
+
const firstWord = text.split(/\s/, 1)[0]
|
|
84
|
+
if (/[A-Z]/.test(firstWord)) return []
|
|
85
|
+
return [{ message: `Description starts with a lowercase letter: "${text.slice(0, 60)}"` }]
|
|
86
|
+
}
|
|
87
|
+
return []
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
name: 'name-echo',
|
|
92
|
+
description: 'Description merely restates the declared name (tautology)',
|
|
93
|
+
severity: 'warning',
|
|
94
|
+
check: (decl) => {
|
|
95
|
+
const text = (decl.string || '').trim()
|
|
96
|
+
if (!text || !decl.name) return []
|
|
97
|
+
if (isNameEcho(decl.name, text)) {
|
|
98
|
+
return [{ message: `Description just restates the name "${decl.name}": "${text}". Say what it does, why, or what happens when it changes.` }]
|
|
99
|
+
}
|
|
100
|
+
return []
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
name: 'too-short',
|
|
105
|
+
description: 'Description is under 20 characters',
|
|
106
|
+
severity: 'warning',
|
|
107
|
+
check: (decl) => {
|
|
108
|
+
const text = (decl.string || '').trim()
|
|
109
|
+
if (!text) return [] // empty-description owns the empty case
|
|
110
|
+
if (text.length < 20) {
|
|
111
|
+
return [{ message: `Description is only ${text.length} characters: "${text}". Too short to explain behavior, units, or impact.` }]
|
|
112
|
+
}
|
|
113
|
+
return []
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
name: 'unexpanded-jargon',
|
|
118
|
+
description: 'Internal jargon that ships to users unexpanded',
|
|
119
|
+
severity: 'warning',
|
|
120
|
+
check: (decl) => {
|
|
121
|
+
const text = decl.string || ''
|
|
122
|
+
if (!text) return []
|
|
123
|
+
const issues = []
|
|
124
|
+
for (const term of JARGON_TERMS) {
|
|
125
|
+
const pattern = new RegExp(`\\b${term}\\b`, 'i')
|
|
126
|
+
if (pattern.test(text)) {
|
|
127
|
+
issues.push({ message: `Contains internal jargon "${term}". Expand or replace it; users do not know this term.` })
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return issues
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
name: 'unbalanced-backticks',
|
|
135
|
+
description: 'Odd number of backticks ships broken markup',
|
|
136
|
+
severity: 'error',
|
|
137
|
+
check: (decl) => {
|
|
138
|
+
const text = decl.string || ''
|
|
139
|
+
const count = (text.match(/`/g) || []).length
|
|
140
|
+
if (count % 2 !== 0) {
|
|
141
|
+
return [{ message: `Unbalanced backticks (${count} found). The rendered page ships broken monospace markup.` }]
|
|
142
|
+
}
|
|
143
|
+
return []
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
]
|
|
147
|
+
|
|
148
|
+
module.exports = { COMMON_RULES, JARGON_TERMS, isNameEcho, tokenize }
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Rules for verbatim-AsciiDoc surfaces (properties, metrics, crd, connect).
|
|
5
|
+
*
|
|
6
|
+
* These strings ship to docs.redpanda.com with ZERO escaping: a raw `|`
|
|
7
|
+
* breaks the containing table cell, an unknown `{attr}` renders literally or
|
|
8
|
+
* trips attribute-missing handling, and a half-written macro ships as broken
|
|
9
|
+
* markup. rpk is exempt: its formatDescription() transformer rewrites the
|
|
10
|
+
* string before publication.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Product attributes that are legitimately available on rendered pages.
|
|
15
|
+
* Anything else inside `{...}` is not substituted and ships broken.
|
|
16
|
+
* Agent-extensible: add attributes here as surfaces grow.
|
|
17
|
+
*/
|
|
18
|
+
const KNOWN_ATTRIBUTES = new Set([
|
|
19
|
+
'latest-version',
|
|
20
|
+
'latest-redpanda-version',
|
|
21
|
+
'latest-redpanda-tag',
|
|
22
|
+
'latest-console-version',
|
|
23
|
+
'latest-operator-version',
|
|
24
|
+
'latest-connect-version',
|
|
25
|
+
'full-version',
|
|
26
|
+
'page-version',
|
|
27
|
+
'empty'
|
|
28
|
+
])
|
|
29
|
+
|
|
30
|
+
/** AsciiDoc inline macros used in embedded doc strings. */
|
|
31
|
+
const MACRO_NAMES = ['xref', 'glossterm', 'config_ref']
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Scan text for unescaped `|` characters outside backtick spans.
|
|
35
|
+
* Returns the count found.
|
|
36
|
+
*/
|
|
37
|
+
function countRawPipes (text) {
|
|
38
|
+
let inBacktick = false
|
|
39
|
+
let count = 0
|
|
40
|
+
for (let i = 0; i < text.length; i++) {
|
|
41
|
+
const ch = text[i]
|
|
42
|
+
if (ch === '`') {
|
|
43
|
+
inBacktick = !inBacktick
|
|
44
|
+
continue
|
|
45
|
+
}
|
|
46
|
+
if (ch === '|' && !inBacktick && text[i - 1] !== '\\') count++
|
|
47
|
+
}
|
|
48
|
+
return count
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const VERBATIM_ASCIIDOC_RULES = [
|
|
52
|
+
{
|
|
53
|
+
name: 'raw-pipe',
|
|
54
|
+
description: 'Raw unescaped | outside backticks breaks AsciiDoc tables',
|
|
55
|
+
severity: 'error',
|
|
56
|
+
check: (decl) => {
|
|
57
|
+
const text = decl.string || ''
|
|
58
|
+
if (!text.includes('|')) return []
|
|
59
|
+
const count = countRawPipes(text)
|
|
60
|
+
if (count > 0) {
|
|
61
|
+
return [{ message: `Contains ${count} raw unescaped "|" outside backticks. This string is rendered verbatim inside AsciiDoc tables; escape it (\\|) or wrap it in backticks.` }]
|
|
62
|
+
}
|
|
63
|
+
return []
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
name: 'unknown-attribute',
|
|
68
|
+
description: '{attr} reference that is not a known product attribute',
|
|
69
|
+
severity: 'warning',
|
|
70
|
+
check: (decl) => {
|
|
71
|
+
const text = decl.string || ''
|
|
72
|
+
const issues = []
|
|
73
|
+
const pattern = /\{([a-z][a-z0-9_-]*)\}/g
|
|
74
|
+
let match
|
|
75
|
+
while ((match = pattern.exec(text)) !== null) {
|
|
76
|
+
// A backslash-escaped \{...} is not substituted; authors escape
|
|
77
|
+
// deliberately (URL path templates, JSON pointers).
|
|
78
|
+
if (match.index > 0 && text[match.index - 1] === '\\') continue
|
|
79
|
+
if (!KNOWN_ATTRIBUTES.has(match[1])) {
|
|
80
|
+
issues.push({ message: `"{${match[1]}}" looks like an AsciiDoc attribute reference but is not a known product attribute. It will not be substituted on the rendered page.` })
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return issues
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: 'broken-macro',
|
|
88
|
+
description: 'xref:/glossterm:/config_ref: macro with unbalanced brackets',
|
|
89
|
+
severity: 'error',
|
|
90
|
+
check: (decl) => {
|
|
91
|
+
const text = decl.string || ''
|
|
92
|
+
const issues = []
|
|
93
|
+
const pattern = new RegExp(`\\b(${MACRO_NAMES.join('|')}):`, 'g')
|
|
94
|
+
let match
|
|
95
|
+
while ((match = pattern.exec(text)) !== null) {
|
|
96
|
+
const rest = text.slice(match.index + match[0].length)
|
|
97
|
+
// Well-formed: <target>[<label>] with the target free of whitespace
|
|
98
|
+
// and brackets, and the label closed before end of string.
|
|
99
|
+
if (!/^[^\s[\]]*\[[^\]]*\]/.test(rest)) {
|
|
100
|
+
issues.push({ message: `Broken ${match[1]}: macro syntax near "${text.slice(match.index, match.index + 50)}". Expected ${match[1]}:target[label] with balanced brackets.` })
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return issues
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
module.exports = { VERBATIM_ASCIIDOC_RULES, KNOWN_ATTRIBUTES, MACRO_NAMES, countRawPipes }
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const fs = require('fs')
|
|
4
|
+
const path = require('path')
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Shared helper for surface modules: read the exact source lines of a
|
|
8
|
+
* declaration span so findings can carry `declaration_text` (the text a PR
|
|
9
|
+
* suggestion block would replace).
|
|
10
|
+
*/
|
|
11
|
+
class SourceCache {
|
|
12
|
+
constructor (repo) {
|
|
13
|
+
this.repo = repo
|
|
14
|
+
this.files = new Map()
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
lines (file) {
|
|
18
|
+
if (!this.files.has(file)) {
|
|
19
|
+
const absPath = path.isAbsolute(file) ? file : path.join(this.repo, file)
|
|
20
|
+
try {
|
|
21
|
+
this.files.set(file, fs.readFileSync(absPath, 'utf8').split('\n'))
|
|
22
|
+
} catch (err) {
|
|
23
|
+
this.files.set(file, null)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return this.files.get(file)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Exact source text of the inclusive 1-indexed span [lineStart, lineEnd],
|
|
31
|
+
* or null when the file or span is unavailable.
|
|
32
|
+
*/
|
|
33
|
+
span (file, lineStart, lineEnd) {
|
|
34
|
+
if (lineStart == null || lineEnd == null) return null
|
|
35
|
+
const lines = this.lines(file)
|
|
36
|
+
if (!lines || lineStart < 1 || lineEnd > lines.length) return null
|
|
37
|
+
return lines.slice(lineStart - 1, lineEnd).join('\n')
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
module.exports = { SourceCache }
|