@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.
Files changed (44) hide show
  1. package/bin/doc-tools-mcp.js +88 -0
  2. package/bin/doc-tools.js +252 -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/package.json +2 -1
  10. package/tools/generate-cli-docs.js +28 -67
  11. package/tools/lint-strings/diff.js +137 -0
  12. package/tools/lint-strings/engine.js +163 -0
  13. package/tools/lint-strings/fixtures/connect/lint_connect.go +67 -0
  14. package/tools/lint-strings/fixtures/crd/crd-ref-docs-config.yaml +15 -0
  15. package/tools/lint-strings/fixtures/crd/lint_types.go +57 -0
  16. package/tools/lint-strings/fixtures/helm/lint_values.yaml +47 -0
  17. package/tools/lint-strings/fixtures/metrics/lint_probe.cc +41 -0
  18. package/tools/lint-strings/fixtures/properties/lint_fixture.cc +51 -0
  19. package/tools/lint-strings/fixtures/properties/lint_fixture.h +33 -0
  20. package/tools/lint-strings/fixtures/rpk/lint_cmd.go +69 -0
  21. package/tools/lint-strings/go-source.js +327 -0
  22. package/tools/lint-strings/index.js +229 -0
  23. package/tools/lint-strings/rules/common.js +148 -0
  24. package/tools/lint-strings/rules/verbatim-asciidoc.js +108 -0
  25. package/tools/lint-strings/source-text.js +41 -0
  26. package/tools/lint-strings/surfaces/connect.js +233 -0
  27. package/tools/lint-strings/surfaces/crd.js +239 -0
  28. package/tools/lint-strings/surfaces/helm.js +159 -0
  29. package/tools/lint-strings/surfaces/metrics.js +368 -0
  30. package/tools/lint-strings/surfaces/properties.js +216 -0
  31. package/tools/lint-strings/surfaces/rpk.js +377 -0
  32. package/tools/overrides-audit/README.adoc +152 -0
  33. package/tools/overrides-audit/adapters/connect.js +144 -0
  34. package/tools/overrides-audit/adapters/properties.js +117 -0
  35. package/tools/overrides-audit/adapters/rpk.js +192 -0
  36. package/tools/overrides-audit/classify.js +546 -0
  37. package/tools/overrides-audit/index.js +185 -0
  38. package/tools/preview-string/index.js +245 -0
  39. package/tools/property-extractor/Makefile +9 -4
  40. package/tools/property-extractor/parser.py +26 -0
  41. package/tools/property-extractor/property_extractor.py +41 -0
  42. package/tools/property-extractor/transformers.py +15 -2
  43. package/tools/rpk-docs/detect-platform-commands.js +8 -1
  44. package/tools/rpk-docs/scan-deprecated-commands.js +15 -18
@@ -0,0 +1,185 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Overrides Audit CLI
5
+ *
6
+ * Field-level classification of docs-side override entries against
7
+ * extracted source strings, powering the override-retirement loop
8
+ * (retirement: delete REDUNDANT fields on each release regeneration;
9
+ * upstreaming: draft source PRs from UPSTREAMABLE/SPLIT candidates).
10
+ *
11
+ * Usage (standalone):
12
+ * node tools/overrides-audit/index.js \
13
+ * --overrides docs-data/property-overrides.json \
14
+ * --extracted tools/property-extractor/gen/properties-output.json \
15
+ * [--surface properties|rpk|connect] [--format json|human] [--output <file>]
16
+ *
17
+ * Also exposed as `doc-tools overrides audit` and the `audit_overrides`
18
+ * MCP tool. See README.adoc in this directory for the classification rules
19
+ * and the upstream_ref policy.
20
+ */
21
+
22
+ 'use strict'
23
+
24
+ const fs = require('fs')
25
+ const path = require('path')
26
+
27
+ const ADAPTERS = {
28
+ properties: () => require('./adapters/properties'),
29
+ rpk: () => require('./adapters/rpk'),
30
+ connect: () => require('./adapters/connect')
31
+ }
32
+
33
+ /**
34
+ * Run the audit and return the result object.
35
+ *
36
+ * @param {Object} options - { overrides, extracted, surface, format, output }.
37
+ * @returns {Object} Audit result ({ surface, manifest, summary, ... }).
38
+ */
39
+ function runAudit (options) {
40
+ const surface = options.surface || 'properties'
41
+ const adapterFactory = ADAPTERS[surface]
42
+ if (!adapterFactory) {
43
+ throw new Error(`Unknown surface '${surface}'. Valid surfaces: ${Object.keys(ADAPTERS).join(', ')}`)
44
+ }
45
+ if (!options.overrides) {
46
+ throw new Error('Missing required option --overrides <path>')
47
+ }
48
+ let extractedPath = options.extracted ? path.resolve(options.extracted) : undefined
49
+ if (surface === 'properties' && !extractedPath) {
50
+ if (!options.repo) {
51
+ throw new Error('Provide --extracted <path> (raw extractor JSON, no overrides applied) or --repo <path> (a redpanda checkout to extract from)')
52
+ }
53
+ // Extract raw source strings ourselves. Never audit against a published
54
+ // redpanda-properties-<tag>.json: those already have overrides applied,
55
+ // which classifies every description REDUNDANT.
56
+ const fs = require('fs')
57
+ const os = require('os')
58
+ const { runExtractor } = require('../lint-strings/surfaces/properties')
59
+ const json = runExtractor(path.resolve(options.repo), (msg) => console.error(msg))
60
+ extractedPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'overrides-audit-')), 'extracted.json')
61
+ fs.writeFileSync(extractedPath, JSON.stringify(json))
62
+ }
63
+
64
+ const adapter = adapterFactory()
65
+ return adapter.audit({
66
+ overridesPath: path.resolve(options.overrides),
67
+ extractedPath
68
+ })
69
+ }
70
+
71
+ /**
72
+ * Render the audit result as a human-readable report.
73
+ *
74
+ * @param {Object} result - Audit result from runAudit().
75
+ * @returns {string} Multi-line report.
76
+ */
77
+ function formatHumanReport (result) {
78
+ const lines = []
79
+ const { summary } = result
80
+ lines.push('='.repeat(64))
81
+ lines.push(`Overrides audit — surface: ${result.surface}`)
82
+ lines.push(`Overrides: ${result.overrides_file}`)
83
+ lines.push(`Extracted: ${result.extracted_file || '(none)'}`)
84
+ lines.push('='.repeat(64))
85
+ lines.push('')
86
+ lines.push(`Classified ${summary.total} override field(s):`)
87
+ const order = [
88
+ 'REDUNDANT',
89
+ 'UPSTREAMABLE',
90
+ 'KEEP_UNTIL_UPSTREAMED',
91
+ 'UPSTREAMABLE_SLOT',
92
+ 'REDUNDANT_OR_UPSTREAMABLE',
93
+ 'KEEP',
94
+ 'REVIEW'
95
+ ]
96
+ for (const cls of order) {
97
+ if (summary.byClass[cls]) {
98
+ lines.push(` ${cls.padEnd(26)} ${summary.byClass[cls]}`)
99
+ }
100
+ }
101
+ lines.push('')
102
+ lines.push('Per field:')
103
+ for (const [field, byClass] of Object.entries(summary.byField)) {
104
+ const parts = Object.entries(byClass).map(([cls, count]) => `${cls}: ${count}`)
105
+ lines.push(` ${field.padEnd(20)} ${parts.join(', ')}`)
106
+ }
107
+
108
+ const sections = [
109
+ ['REDUNDANT', 'Retire in the next auto-docs PR (source already matches)'],
110
+ ['UPSTREAMABLE', 'Send upstream verbatim'],
111
+ ['UPSTREAMABLE_SLOT', 'Migrate to a source metadata slot'],
112
+ ['REDUNDANT_OR_UPSTREAMABLE', 'Needs a human ruling'],
113
+ ['REVIEW', 'Needs attention (possible source bug, typo, or missing property)']
114
+ ]
115
+ for (const [cls, heading] of sections) {
116
+ const rows = result.manifest.filter((row) => row.class === cls)
117
+ if (rows.length === 0) continue
118
+ lines.push('')
119
+ lines.push(`${cls} — ${heading} (${rows.length}):`)
120
+ for (const row of rows) {
121
+ lines.push(` • ${row.name} [${row.field}]${row.note ? ` — ${row.note}` : ''}`)
122
+ }
123
+ }
124
+
125
+ if (result.cross_check) {
126
+ lines.push('')
127
+ lines.push('Cross-check (compare-properties.js):')
128
+ lines.push(` descriptions still masked by overrides: ${result.cross_check.changedDescriptions}`)
129
+ lines.push(` empty source descriptions: ${result.cross_check.emptyDescriptions}`)
130
+ if (result.cross_check.violations.length > 0) {
131
+ lines.push(` CLASSIFIER BUG — raw-equal but not REDUNDANT: ${result.cross_check.violations.join(', ')}`)
132
+ } else {
133
+ lines.push(' consistency: OK (every raw-equal description classified REDUNDANT)')
134
+ }
135
+ }
136
+
137
+ lines.push('')
138
+ lines.push('='.repeat(64))
139
+ return lines.join('\n')
140
+ }
141
+
142
+ /**
143
+ * CLI entry point.
144
+ *
145
+ * @param {string[]} argv - process.argv.
146
+ */
147
+ function main (argv) {
148
+ const { Command } = require('commander')
149
+ const program = new Command()
150
+
151
+ program
152
+ .name('overrides-audit')
153
+ .description('Classify docs-side override fields against extracted source strings')
154
+ .requiredOption('--overrides <path>', 'Path to the overrides JSON file (for example docs-data/property-overrides.json)')
155
+ .option('--extracted <path>', 'Path to the extracted source JSON (property extractor raw output; required for the properties surface)')
156
+ .option('--surface <surface>', 'Override surface: properties, rpk, or connect', 'properties')
157
+ .option('--format <format>', 'Output format: json or human', 'json')
158
+ .option('--output <path>', 'Also write the JSON result to this file')
159
+ .action((options) => {
160
+ try {
161
+ const result = runAudit(options)
162
+ if (options.output) {
163
+ fs.mkdirSync(path.dirname(path.resolve(options.output)), { recursive: true })
164
+ fs.writeFileSync(path.resolve(options.output), JSON.stringify(result, null, 2) + '\n')
165
+ console.error(`JSON result written to ${options.output}`)
166
+ }
167
+ if (options.format === 'human') {
168
+ console.log(formatHumanReport(result))
169
+ } else {
170
+ console.log(JSON.stringify(result, null, 2))
171
+ }
172
+ } catch (err) {
173
+ console.error(`Error: ${err.message}`)
174
+ process.exit(1)
175
+ }
176
+ })
177
+
178
+ program.parse(argv)
179
+ }
180
+
181
+ if (require.main === module) {
182
+ main(process.argv)
183
+ }
184
+
185
+ module.exports = { runAudit, formatHumanReport }
@@ -0,0 +1,245 @@
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+
6
+ /**
7
+ * doc-tools preview-string: render ONE doc-string declaration from local
8
+ * engineering source to the final published snippet, so an engineer can see
9
+ * what their string becomes on docs.redpanda.com BEFORE it ships.
10
+ *
11
+ * - properties: runs the real Python extractor and the real Handlebars
12
+ * property template. With --overrides, renders a second "as shipped"
13
+ * pane and prints a MASKED-BY-OVERRIDE notice when a docs-repo override
14
+ * would replace the source string.
15
+ * - rpk: runs the string through the real formatDescription() transformer
16
+ * from tools/rpk-docs (the killer demo: an ALLCAPS line becomes a "==="
17
+ * heading before your eyes).
18
+ * - metrics/helm/crd/connect: renders the description in the surface's
19
+ * output shape with a one-line header.
20
+ *
21
+ * Extraction reuses the lint-strings surface modules, so preview and lint
22
+ * always agree on what the source declares.
23
+ */
24
+
25
+ const HR = '-'.repeat(72)
26
+
27
+ function pane (title, body) {
28
+ return `${HR}\n${title}\n${HR}\n${body}\n`
29
+ }
30
+
31
+ /**
32
+ * Render a preview for one declaration.
33
+ *
34
+ * @param {Object} options
35
+ * @param {string} options.repo - Path to the engineering checkout
36
+ * @param {string} options.surface - properties|rpk|metrics|helm|crd|connect
37
+ * @param {string} options.name - Declaration name (property name, rpk
38
+ * command or --flag, metric name, helm key path, crd json field, connect
39
+ * component/field name)
40
+ * @param {string} [options.overrides] - Overrides JSON path (properties only)
41
+ * @param {Function} [options.log]
42
+ * @returns {string} The rendered preview text
43
+ */
44
+ function previewString (options) {
45
+ const { repo, surface, name, overrides = null, log = (msg) => process.stderr.write(`${msg}\n`) } = options
46
+ if (!repo) throw new Error('preview-string requires --repo <path>')
47
+ if (!surface) throw new Error('preview-string requires --surface <name>')
48
+ if (!name) throw new Error('preview-string requires --name <name>')
49
+ const repoPath = path.resolve(repo)
50
+ if (!fs.existsSync(repoPath)) throw new Error(`Repo path does not exist: ${repoPath}`)
51
+
52
+ if (overrides && surface !== 'properties') {
53
+ log(`Note: --overrides applies to the properties surface only; ignoring it for ${surface}.`)
54
+ }
55
+
56
+ switch (surface) {
57
+ case 'properties': return previewProperty(repoPath, name, overrides, log)
58
+ case 'rpk': return previewRpk(repoPath, name, log)
59
+ case 'metrics':
60
+ case 'helm':
61
+ case 'crd':
62
+ case 'connect': return previewLintSurface(surface, repoPath, name, log)
63
+ default: throw new Error(`Unknown surface "${surface}". Supported: properties, rpk, metrics, helm, crd, connect`)
64
+ }
65
+ }
66
+
67
+ /* -------------------------------- properties ---------------------------- */
68
+
69
+ function previewProperty (repo, name, overridesPath, log) {
70
+ const properties = require('../lint-strings/surfaces/properties')
71
+ const handlebars = require('handlebars')
72
+ // Requiring the generator registers its Handlebars helpers (eq, ...) as a
73
+ // side effect - the same environment a real generation run uses.
74
+ require('../property-extractor/generate-handlebars-docs')
75
+
76
+ const json = properties.runExtractor(repo, log)
77
+ const prop = (json.properties || {})[name]
78
+ if (!prop) {
79
+ const near = Object.keys(json.properties || {}).filter((k) => k.includes(name)).slice(0, 8)
80
+ throw new Error(`Property "${name}" not found in ${repo}.${near.length > 0 ? ` Close matches: ${near.join(', ')}` : ''}`)
81
+ }
82
+
83
+ const templatePath = path.join(properties.TOOL_ROOT, 'templates', 'property.hbs')
84
+ const template = handlebars.compile(fs.readFileSync(templatePath, 'utf8'))
85
+ const asSource = template({ ...prop, name }).trim()
86
+
87
+ const spanNote = prop.line_start != null
88
+ ? `${prop.defined_in || ''}:${prop.line_start}-${prop.line_end != null ? prop.line_end : prop.line_start}`
89
+ : (prop.defined_in || '(unknown source location)')
90
+ let out = `Property: ${name} (${spanNote})\n\n`
91
+ out += pane('AS SOURCE (rendered from your checkout)', asSource)
92
+
93
+ if (overridesPath) {
94
+ const raw = JSON.parse(fs.readFileSync(path.resolve(overridesPath), 'utf8'))
95
+ // Accept both { properties: { name: {...} } } (docs-repo
96
+ // property-overrides.json) and a flat { name: {...} } map.
97
+ const entry = (raw.properties && raw.properties[name]) || (!raw.properties && raw[name]) || null
98
+ if (!entry) {
99
+ out += `\nNo override entry for "${name}" in ${overridesPath}: the source string ships as-is.\n`
100
+ } else {
101
+ const shipped = { ...prop, ...entry, name }
102
+ const asShipped = template(shipped).trim()
103
+ out += '\n' + pane('AS SHIPPED (override applied)', asShipped)
104
+ if (asShipped !== asSource) {
105
+ const maskedFields = Object.keys(entry).filter((k) => JSON.stringify(prop[k]) !== JSON.stringify(entry[k]))
106
+ out += `\nMASKED-BY-OVERRIDE: the docs repo overrides ${maskedFields.map((f) => `"${f}"`).join(', ') || 'this entry'} for ${name}.\n` +
107
+ 'Changes to the source string will NOT reach docs.redpanda.com until the override field is retired (fix the source string upstream, then delete the override field).\n'
108
+ } else {
109
+ out += '\nOverride entry present but the rendered output is identical: the override is redundant and can be retired.\n'
110
+ }
111
+ }
112
+ }
113
+ return out
114
+ }
115
+
116
+ /* ----------------------------------- rpk -------------------------------- */
117
+
118
+ /**
119
+ * Mirror the real generate-rpk-docs.js pipeline for one string:
120
+ * - Long: parseDescriptionSections first (ALLCAPS lines become "===" section
121
+ * headings, exactly as on the generated command page), then
122
+ * formatDescription + ensurePeriod on the main description and
123
+ * formatDescription on each section body.
124
+ * - Short: ensurePeriod(capToTwoSentences(formatDescription(...))) - the
125
+ * shortDesc path.
126
+ * - Flag usage: ensurePeriod(formatDescription(...)) - the flag table path.
127
+ */
128
+ function publishRpkString (kind, text, helpers) {
129
+ const { formatDescription, parseDescriptionSections, ensurePeriod, capToTwoSentences } = helpers
130
+ if (kind === 'long') {
131
+ const { mainDescription, sections } = parseDescriptionSections(text)
132
+ let out = ensurePeriod(formatDescription(mainDescription))
133
+ for (const [section, body] of Object.entries(sections)) {
134
+ const title = section.charAt(0).toUpperCase() + section.slice(1).toLowerCase()
135
+ out += `\n\n=== ${title}\n\n${formatDescription(body)}`
136
+ }
137
+ return out.trim()
138
+ }
139
+ if (kind === 'short') {
140
+ return ensurePeriod(capToTwoSentences(formatDescription(text, null, { skipTableConversion: true, skipListConversion: true }))).trim()
141
+ }
142
+ return ensurePeriod(formatDescription(text)).trim()
143
+ }
144
+
145
+ function previewRpk (repo, name, log) {
146
+ const rpk = require('../lint-strings/surfaces/rpk')
147
+ const helpers = require('../rpk-docs/generate-rpk-docs')
148
+
149
+ log(`Scanning rpk sources in ${repo}...`)
150
+ const decls = rpk.extract({ repo, log })
151
+ const isFlag = name.startsWith('-')
152
+ const target = isFlag ? name.replace(/^-+/, '') : name
153
+ const matches = decls.filter((d) => isFlag
154
+ ? (d.meta.kind === 'flag' && d.name === target)
155
+ : (d.meta.kind !== 'flag' && d.name === target))
156
+ if (matches.length === 0) {
157
+ throw new Error(`No rpk ${isFlag ? 'flag' : 'command'} named "${target}" found under src/go/rpk/pkg/cli. Use the leaf command token (for example "health") or --flag-name.`)
158
+ }
159
+
160
+ let out = ''
161
+ for (const d of matches.slice(0, 10)) {
162
+ const label = d.meta.kind === 'flag' ? `--${d.name} (flag usage)` : `${d.name} (${d.meta.kind})`
163
+ out += `rpk ${label} ${d.file}:${d.line_start}-${d.line_end}\n\n`
164
+ if (d.string == null) {
165
+ out += 'Source string is built dynamically (not a plain literal); preview unavailable.\n\n'
166
+ continue
167
+ }
168
+ out += pane('AS SOURCE', d.string)
169
+ out += '\n' + pane('AS PUBLISHED (after formatDescription)', publishRpkString(d.meta.kind, d.string, helpers)) + '\n'
170
+ }
171
+ if (matches.length > 10) out += `(${matches.length - 10} more matches not shown)\n`
172
+ return out
173
+ }
174
+
175
+ /* -------------------- metrics / helm / crd / connect --------------------- */
176
+
177
+ const SHAPES = {
178
+ // How each surface's description lands on the published page.
179
+ metrics: (d) => `=== ${d.name}\n\n${d.string}`,
180
+ helm: (d) => `=== ${d.name}\n\n${d.string}${d.meta.default_annotation ? `\n\n*Default:* ${d.meta.default_annotation}` : ''}`,
181
+ crd: (d) => `| *\`${d.name}\`* +\n(table cell in the CRD reference and \`kubectl explain\` output)\n| ${d.string}`,
182
+ connect: (d) => d.string // connect descriptions ARE the AsciiDoc page body
183
+ }
184
+
185
+ function previewLintSurface (surfaceName, repo, name, log) {
186
+ const { SURFACES } = require('../lint-strings')
187
+ const surface = SURFACES[surfaceName]
188
+ log(`Scanning ${surfaceName} sources in ${repo}...`)
189
+ const decls = surface.extract({ repo, log })
190
+ const matches = decls.filter((d) => d.name === name ||
191
+ (surfaceName === 'crd' && `${d.meta.struct}.${d.name}` === name))
192
+ if (matches.length === 0) {
193
+ throw new Error(`No ${surfaceName} declaration named "${name}" found in ${repo}.`)
194
+ }
195
+
196
+ let out = ''
197
+ for (const d of matches.slice(0, 10)) {
198
+ out += `${surfaceName}: ${d.name}${d.meta.struct ? ` (in ${d.meta.struct})` : ''} ${d.file}:${d.line_start}-${d.line_end}\n\n`
199
+ if (d.string == null) {
200
+ out += 'No description in source; this ships blank.\n\n'
201
+ continue
202
+ }
203
+ out += pane('AS PUBLISHED', SHAPES[surfaceName](d)) + '\n'
204
+ }
205
+ if (matches.length > 10) out += `(${matches.length - 10} more matches not shown)\n`
206
+ return out
207
+ }
208
+
209
+ /* ----------------------------------- CLI --------------------------------- */
210
+
211
+ function runCli (options) {
212
+ try {
213
+ process.stdout.write(previewString({
214
+ repo: options.repo,
215
+ surface: options.surface,
216
+ name: options.name,
217
+ overrides: options.overrides || null
218
+ }))
219
+ } catch (err) {
220
+ console.error(`Error: ${err.message}`)
221
+ process.exit(2)
222
+ }
223
+ process.exit(0)
224
+ }
225
+
226
+ module.exports = { previewString, publishRpkString, runCli }
227
+
228
+ // Direct usage: node tools/preview-string --repo <path> --surface <s> --name <n> [--overrides <path>]
229
+ if (require.main === module) {
230
+ const args = process.argv.slice(2)
231
+ const options = {}
232
+ for (let i = 0; i < args.length; i++) {
233
+ const arg = args[i]
234
+ if (arg === '--repo') options.repo = args[++i]
235
+ else if (arg === '--surface') options.surface = args[++i]
236
+ else if (arg === '--name') options.name = args[++i]
237
+ else if (arg === '--overrides') options.overrides = args[++i]
238
+ else {
239
+ console.error(`Unknown argument: ${arg}`)
240
+ console.error('Usage: node tools/preview-string --repo <path> --surface <s> --name <name> [--overrides <path>]')
241
+ process.exit(2)
242
+ }
243
+ }
244
+ runCli(options)
245
+ }
@@ -20,6 +20,11 @@ TOOL_ROOT := $(MODULE_ROOT)/tools/property-extractor
20
20
  TMP_ROOT := $(TOOL_ROOT)/tmp
21
21
  REDPANDA_SRC := $(TMP_ROOT)/redpanda
22
22
  TREESITTER_DIR:= $(TOOL_ROOT)/tree-sitter/tree-sitter-cpp
23
+ # One pin, read from the module that also bootstraps this grammar at run time
24
+ # (tools/lint-strings/surfaces/properties.js ensureTreesitter). Holding the tag
25
+ # in both places meant a bump here would silently leave the linter checking out
26
+ # a different grammar than the generator.
27
+ TREESITTER_TAG:= $(shell node -p "require('$(MODULE_ROOT)/tools/lint-strings/surfaces/properties.js').TREESITTER_TAG")
23
28
  VENV := $(TOOL_ROOT)/tmp/redpanda-property-extractor-venv
24
29
  PYTHON := $(VENV)/bin/python
25
30
  TREE_SITTER := npx tree-sitter
@@ -131,13 +136,13 @@ treesitter:
131
136
  git clone https://github.com/tree-sitter/tree-sitter-cpp.git "$(TREESITTER_DIR)"; \
132
137
  fi
133
138
  @CURRENT_TAG=$$(git -C "$(TREESITTER_DIR)" describe --tags --exact-match 2>/dev/null || true); \
134
- if [ "$$CURRENT_TAG" = "v0.20.5" ] && [ -f "$(TREESITTER_DIR)/src/parser.c" ]; then \
135
- echo "✅ tree-sitter-cpp parser already generated at v0.20.5; skipping regeneration"; \
139
+ if [ "$$CURRENT_TAG" = "$(TREESITTER_TAG)" ] && [ -f "$(TREESITTER_DIR)/src/parser.c" ]; then \
140
+ echo "✅ tree-sitter-cpp parser already generated at $(TREESITTER_TAG); skipping regeneration"; \
136
141
  else \
137
- echo "🔄 Ensuring tree-sitter-cpp is at compatible version v0.20.5…"; \
142
+ echo "🔄 Ensuring tree-sitter-cpp is at compatible version $(TREESITTER_TAG)…"; \
138
143
  cd "$(TREESITTER_DIR)" && \
139
144
  git fetch --tags -q && \
140
- git checkout -q v0.20.5 && \
145
+ git checkout -q $(TREESITTER_TAG) && \
141
146
  echo "🔧 Generating parser in $(TREESITTER_DIR)…" && \
142
147
  export CFLAGS="-Wno-unused-but-set-variable" && \
143
148
  npm install --silent && \
@@ -362,8 +362,29 @@ def parse_cpp_source(treesitter_parser, cpp_language, source_code):
362
362
  parameters = PropertyBag()
363
363
  current_field = None
364
364
  seen_first_argument = False
365
+ # Line spans (1-indexed, inclusive) of each full member-initializer entry
366
+ # (`name(arg, arg, ...)`), keyed by field name. Recorded so downstream
367
+ # consumers (for example, doc-string linting and PR suggestion blocks) can
368
+ # anchor a property to its exact source lines. Additive: existing
369
+ # consumers of the output JSON are unaffected.
370
+ declaration_spans = {}
365
371
 
366
372
  for node, label in captures:
373
+ # The SOURCE_QUERY also captures the whole field_initializer node as
374
+ # @field. Use it to record the declaration's line span; the entry is
375
+ # keyed by its field_identifier child so this works regardless of the
376
+ # order tree-sitter returns same-start captures in.
377
+ if label == "field" and node.type == "field_initializer":
378
+ name_node = next(
379
+ (c for c in node.children if c.type == "field_identifier"), None
380
+ )
381
+ if name_node is not None:
382
+ declaration_spans[name_node.text.decode("utf-8")] = (
383
+ node.start_point[0] + 1,
384
+ node.end_point[0] + 1,
385
+ )
386
+ continue
387
+
367
388
  # Start of a new field initializer
368
389
  if label == "field" and node.type == "field_identifier":
369
390
  current_field = node.text.decode("utf-8")
@@ -391,6 +412,11 @@ def parse_cpp_source(treesitter_parser, cpp_language, source_code):
391
412
  if normalized_param:
392
413
  parameters[current_field]["params"].append(normalized_param)
393
414
 
415
+ for field_name, (line_start, line_end) in declaration_spans.items():
416
+ if field_name in parameters:
417
+ parameters[field_name]["line_start"] = line_start
418
+ parameters[field_name]["line_end"] = line_end
419
+
394
420
  return parameters
395
421
 
396
422
 
@@ -963,6 +963,44 @@ def clean_private_fields_from_definitions(definitions):
963
963
 
964
964
 
965
965
  # The definitions.json file contains type definitions that the extractor uses to standardize and centralize type information. After extracting and transforming the properties from the source code, the function merge_properties_and_definitions looks up each property's type in the definitions. If a property's type (or the type of its items, in the case of arrays) matches one of the definitions, the transformer replaces that type with a JSON pointer ( such as #/definitions/<type>) to the corresponding entry in definitions.json. The final JSON output then includes both a properties section (with types now referencing the definitions) and a definitions section, so that consumers of the output can easily resolve the full type information.
966
+ # Fields recorded for tooling only. lint-strings and preview-string read them
967
+ # from the raw --output to anchor a finding to its declaration; nothing on the
968
+ # docs site does. The enhanced JSON is copied verbatim into
969
+ # modules/reference/attachments and downloaded by every browser that hovers a
970
+ # prop: macro, so ~40 bytes per property (~56KB across the real ~1400-property
971
+ # file) would be paid by readers for data no reader uses.
972
+ TOOLING_ONLY_PROPERTY_FIELDS = ("line_start", "line_end")
973
+
974
+
975
+ def strip_tooling_only_fields(properties):
976
+ """
977
+ Remove tooling-only span fields from properties bound for the PUBLISHED
978
+ JSON attachment.
979
+
980
+ Only the enhanced output is published; the raw --output keeps the spans,
981
+ because that is the file lint-strings and preview-string consume.
982
+
983
+ Args:
984
+ properties: Dictionary of properties
985
+
986
+ Returns:
987
+ The same dictionary, with the tooling-only fields removed
988
+ """
989
+ removed = 0
990
+ for prop in properties.values():
991
+ if not isinstance(prop, dict):
992
+ continue
993
+ for field in TOOLING_ONLY_PROPERTY_FIELDS:
994
+ if field in prop:
995
+ del prop[field]
996
+ removed += 1
997
+ if removed:
998
+ logging.info(
999
+ f"Stripped {removed} tooling-only span field(s) from the published properties JSON"
1000
+ )
1001
+ return properties
1002
+
1003
+
966
1004
  def merge_properties_and_definitions(properties, definitions):
967
1005
  # Do not overwrite the resolved type/default with a reference. Just return the resolved properties and definitions.
968
1006
  return dict(properties=properties, definitions=definitions)
@@ -3143,6 +3181,9 @@ def main():
3143
3181
  # 7. Clean private fields from definitions (keep JSON output clean)
3144
3182
  filtered_enhanced_definitions = clean_private_fields_from_definitions(filtered_enhanced_definitions)
3145
3183
 
3184
+ # 8. Drop tooling-only spans: this JSON is the published attachment.
3185
+ enhanced_properties = strip_tooling_only_fields(enhanced_properties)
3186
+
3146
3187
  # Generate enhanced properties JSON (with overrides)
3147
3188
  enhanced_properties_and_definitions = merge_properties_and_definitions(
3148
3189
  enhanced_properties, filtered_enhanced_definitions
@@ -655,8 +655,10 @@ class BasicInfoTransformer:
655
655
  EXPECTED FINAL RESULT:
656
656
  {
657
657
  "name": "property_name", # Clean identifier for JSON keys
658
- "description": "Human readable...", # Description for documentation
659
- "defined_in": "src/v/config/..." # Normalized file path
658
+ "description": "Human readable...", # Description for documentation
659
+ "defined_in": "src/v/config/...", # Normalized file path
660
+ "line_start": 1852, # First line of the declaration (1-indexed)
661
+ "line_end": 1858 # Last line of the declaration (inclusive)
660
662
  }
661
663
 
662
664
  DOWNSTREAM USAGE:
@@ -724,6 +726,17 @@ class BasicInfoTransformer:
724
726
  property["defined_in"] = re.sub(
725
727
  r"^.*src/", "src/", str(file_pair.implementation)
726
728
  )
729
+
730
+ # --- Step 4: line span (additive) ---
731
+ # 1-indexed, inclusive lines of the full member-initializer entry in
732
+ # the implementation file named by defined_in. Recorded by the parser
733
+ # (parse_cpp_source) from the tree-sitter field_initializer node.
734
+ # Spans the whole declaration, not just the description literal, so
735
+ # diff-anchored tooling can match clang-format-wrapped strings.
736
+ if info.get("line_start") is not None:
737
+ property["line_start"] = info["line_start"]
738
+ property["line_end"] = info.get("line_end", info["line_start"])
739
+
727
740
  return property
728
741
 
729
742
  @debug_transformer
@@ -47,6 +47,7 @@
47
47
 
48
48
  const fs = require('fs')
49
49
  const path = require('path')
50
+ const { maskComments } = require('../lint-strings/go-source')
50
51
 
51
52
  /** GOOS values recognized in filename suffixes and build tags */
52
53
  const KNOWN_GOOS = new Set([
@@ -248,7 +249,13 @@ function commandNameFromConstructor(funcName) {
248
249
  * @param {string} content - Go file content
249
250
  * @returns {Object<string, {useName: string|null, excluded: boolean}>}
250
251
  */
251
- function parseConstructors(content) {
252
+ function parseConstructors(rawContent) {
253
+ // Mask comments first. Scanning raw text read a `Use: "..."` out of a
254
+ // commented-out or example command and reported it as the real one; the
255
+ // masker blanks comment bodies while preserving offsets, line breaks and
256
+ // every string literal, so the regexes below see only live code. Shared with
257
+ // the lint-strings Go surfaces rather than reimplemented here.
258
+ const content = maskComments(rawContent)
252
259
  const constructors = {}
253
260
  const funcRe = /^func\s+(\w+)\s*\([^)]*\)\s*\*cobra\.Command\s*\{/gm
254
261
  const matches = []
@@ -7,6 +7,7 @@
7
7
 
8
8
  const fs = require('fs');
9
9
  const path = require('path');
10
+ const { maskComments, findBalancedClose } = require('../lint-strings/go-source');
10
11
 
11
12
  /**
12
13
  * Scan a Go file for cobra command definitions with deprecation/hidden status
@@ -17,31 +18,27 @@ function scanGoFile(filePath) {
17
18
  const content = fs.readFileSync(filePath, 'utf8');
18
19
 
19
20
  // Look for NewCommand function that returns *cobra.Command
20
- const newCommandMatch = content.match(/func NewCommand\([^)]*\)\s*\*cobra\.Command\s*\{/);
21
+ // Mask comments before any scanning. The hand-rolled brace counter this
22
+ // replaced was comment- and string-blind, so a `{` inside a comment
23
+ // unbalanced it and truncated the cobra.Command block early, and the field
24
+ // regexes then read a `Use:` string out of a commented-out example. Both
25
+ // helpers are string-aware and already shared by the lint-strings Go
26
+ // surfaces; the masker preserves offsets and every string literal.
27
+ const masked = maskComments(content);
28
+
29
+ const newCommandMatch = masked.match(/func NewCommand\([^)]*\)\s*\*cobra\.Command\s*\{/);
21
30
  if (!newCommandMatch) {
22
31
  return null;
23
32
  }
24
33
 
25
34
  // Extract the cobra.Command struct definition
26
- // Handle nested braces and multiline strings
27
- let cmdBlock = '';
28
- let braceCount = 0;
29
- let startIndex = content.indexOf('&cobra.Command{');
30
-
31
- if (startIndex === -1) {
35
+ const structIndex = masked.indexOf('&cobra.Command{');
36
+ if (structIndex === -1) {
32
37
  return null;
33
38
  }
34
-
35
- startIndex += '&cobra.Command{'.length;
36
- for (let i = startIndex; i < content.length; i++) {
37
- const char = content[i];
38
- if (char === '{') braceCount++;
39
- if (char === '}') {
40
- if (braceCount === 0) break;
41
- braceCount--;
42
- }
43
- cmdBlock += char;
44
- }
39
+ const openIndex = structIndex + '&cobra.Command'.length;
40
+ const closeIndex = findBalancedClose(masked, openIndex, '{', '}');
41
+ const cmdBlock = masked.slice(openIndex + 1, closeIndex === -1 ? masked.length : closeIndex);
45
42
 
46
43
  // Extract fields - handle multiline strings with backticks or quotes
47
44
  const useMatch = cmdBlock.match(/Use:\s*"([^"]+)"/);