@redpanda-data/docs-extensions-and-macros 5.54.0 → 5.56.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 (87) hide show
  1. package/bin/doc-tools-mcp.js +4 -5
  2. package/bin/doc-tools.js +354 -9
  3. package/bin/mcp-tools/generated-docs-review.js +26 -9
  4. package/bin/mcp-tools/overrides-audit.js +1 -1
  5. package/bin/mcp-tools/rpcn-docs.js +4 -14
  6. package/cli-utils/docs-links-to-xrefs.js +174 -0
  7. package/cli-utils/format-helm-spec.js +4 -3
  8. package/cli-utils/generated-marker.js +43 -0
  9. package/cli-utils/helm-commented-values.js +125 -21
  10. package/cli-utils/self-managed-docs-branch.js +36 -30
  11. package/extension-utils/html-utils.js +27 -0
  12. package/extension-utils/llms-utils.js +6 -2
  13. package/extension-utils/metadata-candidates.js +304 -0
  14. package/extension-utils/metadata-check.js +174 -0
  15. package/extension-utils/metadata-suggest.js +573 -0
  16. package/extension-utils/taxonomy-categories.js +106 -0
  17. package/extension-utils/taxonomy.js +566 -0
  18. package/extensions/REFERENCE.adoc +110 -8
  19. package/extensions/add-git-dates.js +13 -1
  20. package/extensions/aggregate-terms.js +34 -20
  21. package/extensions/algolia-indexer/generate-index.js +13 -1
  22. package/extensions/algolia-indexer/index.js +4 -2
  23. package/extensions/convert-sitemap-to-markdown.js +47 -12
  24. package/extensions/convert-to-markdown.js +176 -0
  25. package/extensions/generate-fields-only-pages.js +14 -0
  26. package/extensions/modify-connect-tag-playbook.js +181 -3
  27. package/extensions/resolve-xrefs-in-attachments.js +1 -12
  28. package/extensions/url-to-xref.js +92 -22
  29. package/extensions/util/connect-docs-asset.js +6 -5
  30. package/extensions/validate-attributes.js +10 -1
  31. package/extensions/version-fetcher/fetch-latest-docker-tag.js +35 -13
  32. package/extensions/version-fetcher/get-latest-redpanda-helm-version-from-operator.js +64 -40
  33. package/extensions/version-fetcher/list-connect-releases.js +3 -3
  34. package/extensions/version-fetcher/release-lines.js +64 -0
  35. package/extensions/version-fetcher/set-latest-version.js +73 -3
  36. package/macros/README.adoc +1 -0
  37. package/macros/REFERENCE.adoc +99 -0
  38. package/macros/badge.js +5 -8
  39. package/macros/enterprise.js +2 -5
  40. package/macros/glossary.js +4 -14
  41. package/macros/iceberg-explorer.js +242 -0
  42. package/macros/prop.js +3 -13
  43. package/mcp/USER_GUIDE.adoc +25 -135
  44. package/package.json +5 -2
  45. package/tools/check-metadata/index.js +523 -0
  46. package/tools/cloud-regions/cloud-regions-table-adoc.hbs +1 -2
  47. package/tools/cloud-regions/cloud-regions-table-md.hbs +2 -2
  48. package/tools/cloud-regions/render-cloud-regions.js +3 -0
  49. package/tools/dispatch-graph/check-dispatch-graph.js +790 -0
  50. package/tools/dispatch-graph/classify-runs.js +128 -0
  51. package/tools/dispatch-graph/parse-workflows.js +499 -0
  52. package/tools/doc-impact-recording/index.js +190 -0
  53. package/tools/enterprise-features/detect-gating-changes.js +393 -0
  54. package/tools/enterprise-features/verify.js +2 -1
  55. package/tools/gen-rpk-ascii.py +7 -1
  56. package/tools/lint-strings/fixtures/connect/lint_connect.go +8 -0
  57. package/tools/lint-strings/fixtures/crd/lint_types.go +4 -1
  58. package/tools/lint-strings/fixtures/rpk-pr461/delete.base.go +42 -0
  59. package/tools/lint-strings/fixtures/rpk-pr461/delete.head.go +45 -0
  60. package/tools/lint-strings/fixtures/rpk-pr461/reviewer-comments.json +1 -0
  61. package/tools/lint-strings/index.js +133 -3
  62. package/tools/lint-strings/rules/verbatim-asciidoc.js +166 -1
  63. package/tools/lint-strings/surfaces/connect.js +72 -12
  64. package/tools/lint-strings/surfaces/crd.js +10 -6
  65. package/tools/metrics/metrics.py +11 -0
  66. package/tools/overrides-audit/README.adoc +1 -2
  67. package/tools/overrides-audit/adapters/properties.js +1 -1
  68. package/tools/overrides-audit/index.js +3 -4
  69. package/tools/property-extractor/generate-handlebars-docs.js +9 -1
  70. package/tools/property-extractor/helpers/applyPropertyLinks.js +35 -0
  71. package/tools/redpanda-connect/AUTOMATION.md +3 -36
  72. package/tools/redpanda-connect/README.adoc +5 -33
  73. package/tools/redpanda-connect/generate-rpcn-connector-docs.js +32 -11
  74. package/tools/redpanda-connect/pr-summary-formatter.js +6 -4
  75. package/tools/redpanda-connect/report-delta.js +6 -3
  76. package/tools/redpanda-connect/rpcn-connector-docs-handler.js +42 -25
  77. package/tools/redpanda-connect/templates/bloblang-function.hbs +1 -1
  78. package/tools/redpanda-connect/templates/descriptions-partials.hbs +1 -1
  79. package/tools/redpanda-connect/templates/examples-partials.hbs +1 -1
  80. package/tools/redpanda-connect/templates/fields-partials.hbs +2 -2
  81. package/tools/redpanda-connect/templates/metadata-partials.hbs +1 -1
  82. package/tools/redpanda-release-notes/generate-release-notes.js +116 -1
  83. package/tools/rpk-docs/generate-rpk-docs.js +2 -0
  84. package/tools/rpk-docs/generate-x-env-partial.js +7 -4
  85. package/tools/rpk-docs/helpers/index.js +4 -1
  86. package/tools/rpk-docs/templates/command.hbs +2 -2
  87. package/tools/overrides-audit/adapters/connect.js +0 -144
@@ -0,0 +1,190 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Turn a Claude Code transcript into a doc-impact MCP recording.
5
+ *
6
+ * The doc-strings review asks the docs MCP server (docs.redpanda.com/mcp)
7
+ * whether published pages already cover a changed string. The doc-impact
8
+ * eval replays those answers per item, so an eval run sees the docs as they
9
+ * were when the review ran, not as they are today. A recording made later
10
+ * would already contain the writer's fix for the PR, and the pass would
11
+ * then correctly say "already documented" for a PR that needed docs.
12
+ *
13
+ * This reads the transcript the review step leaves behind and keeps only
14
+ * what a replay needs: the tool name, its arguments, and the result content.
15
+ * Nothing else from the transcript is copied, so the session's tokens,
16
+ * headers, environment and identity never reach the recording.
17
+ *
18
+ * The arguments are the model's own text, though, and the review reads
19
+ * untrusted PR content. A call whose arguments or result contain something
20
+ * shaped like a credential is dropped rather than saved, because the
21
+ * recording is uploaded as an artifact and log masking doesn't cover
22
+ * artifacts.
23
+ *
24
+ * Accepted input:
25
+ * - the execution file anthropics/claude-code-action writes: one JSON
26
+ * array of SDK messages;
27
+ * - `claude -p --output-format stream-json` output: one message per line.
28
+ * Both carry the same message shapes, so one parser serves both.
29
+ */
30
+
31
+ const fs = require('fs')
32
+ const path = require('path')
33
+
34
+ const DEFAULT_SERVER = 'redpanda-docs'
35
+
36
+ // Credential shapes a recording must never carry: GitHub tokens (classic and
37
+ // fine-grained), JWTs (which include GitHub App and OIDC tokens), and AWS
38
+ // access key IDs.
39
+ const SECRET_PATTERNS = [
40
+ /\bgh[pousr]_[A-Za-z0-9]{20,}/,
41
+ /\bgithub_pat_[A-Za-z0-9_]{20,}/,
42
+ /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}/,
43
+ /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/
44
+ ]
45
+
46
+ /** Whether a value, serialized, contains anything shaped like a credential. */
47
+ function looksSecret (value) {
48
+ const text = typeof value === 'string' ? value : JSON.stringify(value)
49
+ return SECRET_PATTERNS.some((re) => re.test(text || ''))
50
+ }
51
+
52
+ /** Messages from a transcript, in order. Lines that are not JSON are skipped. */
53
+ function parseTranscript (text) {
54
+ const raw = String(text || '')
55
+ const trimmed = raw.trim()
56
+ if (trimmed.startsWith('[')) {
57
+ try {
58
+ const all = JSON.parse(trimmed)
59
+ if (Array.isArray(all)) return all.filter((m) => m && typeof m === 'object')
60
+ } catch {
61
+ // Not one JSON document; read it line by line below.
62
+ }
63
+ }
64
+ const out = []
65
+ for (const line of raw.split('\n')) {
66
+ if (!line.trim()) continue
67
+ try {
68
+ const m = JSON.parse(line)
69
+ if (m && typeof m === 'object' && !Array.isArray(m)) out.push(m)
70
+ } catch {
71
+ // Progress lines and other non-JSON output are not messages.
72
+ }
73
+ }
74
+ return out
75
+ }
76
+
77
+ /** Tool-result content as an array of MCP content blocks. */
78
+ function contentBlocks (content) {
79
+ if (content == null) return []
80
+ if (typeof content === 'string') return [{ type: 'text', text: content }]
81
+ if (Array.isArray(content)) return content.map((b) => (typeof b === 'string' ? { type: 'text', text: b } : b))
82
+ return [{ type: 'text', text: JSON.stringify(content) }]
83
+ }
84
+
85
+ function blocksOf (message) {
86
+ const content = message && message.message && message.message.content
87
+ return Array.isArray(content) ? content : []
88
+ }
89
+
90
+ /**
91
+ * Every call to one MCP server's tools, paired with its result, in call
92
+ * order. A call with no result (the run stopped first) is left out: a
93
+ * replay of it would answer with nothing the server ever said.
94
+ */
95
+ function extractCalls (messages, { server = DEFAULT_SERVER } = {}) {
96
+ const prefix = `mcp__${server}__`
97
+ const calls = []
98
+ const byId = new Map()
99
+ for (const m of messages || []) {
100
+ if (m.type === 'assistant') {
101
+ for (const b of blocksOf(m)) {
102
+ if (b && b.type === 'tool_use' && typeof b.name === 'string' && b.name.startsWith(prefix)) {
103
+ const call = { tool: b.name.slice(prefix.length), arguments: b.input || {}, content: null, is_error: false }
104
+ calls.push(call)
105
+ byId.set(b.id, call)
106
+ }
107
+ }
108
+ } else if (m.type === 'user') {
109
+ for (const b of blocksOf(m)) {
110
+ if (b && b.type === 'tool_result' && byId.has(b.tool_use_id)) {
111
+ const call = byId.get(b.tool_use_id)
112
+ call.content = contentBlocks(b.content)
113
+ call.is_error = Boolean(b.is_error)
114
+ }
115
+ }
116
+ }
117
+ }
118
+ return calls.filter((c) => c.content !== null)
119
+ }
120
+
121
+ /** Split calls into the ones safe to save and the count dropped as secret-bearing. */
122
+ function dropSecretCalls (calls) {
123
+ const kept = calls.filter((c) => !looksSecret(c.arguments) && !looksSecret(c.content))
124
+ return { kept, dropped: calls.length - kept.length }
125
+ }
126
+
127
+ /** The model the session ran on, from its init message. */
128
+ function sessionModel (messages) {
129
+ const init = (messages || []).find((m) => m.type === 'system' && m.subtype === 'init')
130
+ return (init && typeof init.model === 'string' && init.model) || null
131
+ }
132
+
133
+ /**
134
+ * A recording in the shape the doc-impact eval replays:
135
+ * { item, recorded_at, server_url, model, source, calls: [{ tool, arguments, content, is_error }] }
136
+ */
137
+ function buildRecording (messages, { item = null, recordedAt, serverUrl = null, model = null, server = DEFAULT_SERVER } = {}) {
138
+ const { kept, dropped } = dropSecretCalls(extractCalls(messages, { server }))
139
+ const recording = {
140
+ item,
141
+ recorded_at: recordedAt || new Date().toISOString(),
142
+ server_url: serverUrl,
143
+ model: model || sessionModel(messages),
144
+ source: 'production',
145
+ calls: kept
146
+ }
147
+ // Not enumerable, so it never reaches the written file.
148
+ Object.defineProperty(recording, 'droppedSecretCalls', { value: dropped })
149
+ return recording
150
+ }
151
+
152
+ /**
153
+ * CLI entry: read --execution-file, write --output. Writes nothing when the
154
+ * session made no docs calls it can save, and removes any file an earlier
155
+ * run left at --output, so the caller can tell "no calls" from "a
156
+ * recording" by the file's presence. Exits 0 either way; a missing or
157
+ * unreadable transcript is exit 1.
158
+ */
159
+ function runCli (options, { log = console.log, error = console.error } = {}) {
160
+ const file = options.executionFile
161
+ let text
162
+ try {
163
+ text = fs.readFileSync(file, 'utf8')
164
+ } catch (e) {
165
+ error(`Cannot read the transcript at ${file}: ${e.message}`)
166
+ return 1
167
+ }
168
+ const messages = parseTranscript(text)
169
+ const recording = buildRecording(messages, {
170
+ item: options.item || null,
171
+ recordedAt: options.recordedAt,
172
+ serverUrl: options.serverUrl || null,
173
+ model: options.model || null,
174
+ server: options.server || DEFAULT_SERVER
175
+ })
176
+ if (recording.droppedSecretCalls) {
177
+ error(`Dropped ${recording.droppedSecretCalls} MCP call(s) whose arguments or result look like a credential.`)
178
+ }
179
+ if (!recording.calls.length) {
180
+ fs.rmSync(options.output, { force: true })
181
+ log(`No ${options.server || DEFAULT_SERVER} MCP calls to save in ${messages.length} transcript message(s); no recording written.`)
182
+ return 0
183
+ }
184
+ fs.mkdirSync(path.dirname(path.resolve(options.output)), { recursive: true })
185
+ fs.writeFileSync(options.output, JSON.stringify(recording, null, 2) + '\n')
186
+ log(`Wrote ${recording.calls.length} ${options.server || DEFAULT_SERVER} MCP call(s) to ${options.output}.`)
187
+ return 0
188
+ }
189
+
190
+ module.exports = { parseTranscript, extractCalls, sessionModel, buildRecording, dropSecretCalls, looksSecret, runCli, DEFAULT_SERVER }
@@ -0,0 +1,393 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Finds enterprise gating changes in a Redpanda core diff that no doc string
5
+ * announces, and shapes them as doc-impact findings.
6
+ *
7
+ * The doc-strings review derives its published-content impact from the doc
8
+ * strings a PR changes. Two kinds of change carry no doc string at all and
9
+ * still change what the licensing docs say:
10
+ *
11
+ * - a value added to (or removed from) `enum class license_required_feature`
12
+ * in src/v/features/enterprise_features.h: the feature starts (or stops)
13
+ * being reported by `rpk cluster license info` and
14
+ * GET /v1/features/enterprise, and the enterprise features registry in
15
+ * redpanda-data/docs needs an entry under an approved external name;
16
+ * - a property wrapped in `config::enterprise<>` in
17
+ * src/v/config/configuration.h: the property becomes (or stops being)
18
+ * license-gated, so the licensing tables and the disable page change;
19
+ * - a changed restricted value of an enterprise<> property in
20
+ * src/v/config/configuration.cc: which values need a license changes,
21
+ * with no change to the header or to any doc string.
22
+ *
23
+ * streaming-enterprise PR 579 (2026-10-07) was the first miss: it added
24
+ * `manual_finalization` to the enum and changed license enforcement, touched
25
+ * no doc string, and so filed nothing. This module reads the diff text only,
26
+ * so a test can hand it a saved patch; the CLI wrapper runs git.
27
+ *
28
+ * Detection is by added and removed lines, paired by name, so a value that
29
+ * merely moves within the enum is not reported. Both signals the header
30
+ * carries for an enum value are used (the member line inside the enum, and
31
+ * the `case license_required_feature::<name>:` line in format_to), so a
32
+ * hunk header that happens not to name the enum still detects.
33
+ *
34
+ * Each finding names ONE affected page, the licensing overview. The docs-site
35
+ * receiver falls back to matching a finding with no name match against open
36
+ * tickets that share at least two page topics, so two gating findings that
37
+ * both listed the overview and the disable page would be treated as one
38
+ * ticket however different the features. The disable page is named in the
39
+ * summary text instead.
40
+ */
41
+
42
+ const ENUM_HEADER = 'src/v/features/enterprise_features.h'
43
+ const CONFIG_HEADER = 'src/v/config/configuration.h'
44
+ const CONFIG_SOURCE = 'src/v/config/configuration.cc'
45
+
46
+ const LICENSING_OVERVIEW = 'https://docs.redpanda.com/streaming/current/get-started/licensing/overview/'
47
+ const DISABLE_PAGE = 'https://docs.redpanda.com/streaming/current/get-started/licensing/disable-enterprise-features/'
48
+ const REGISTRY_URL = 'https://github.com/redpanda-data/docs/blob/main/shared/modules/ROOT/partials/enterprise-features.yml'
49
+
50
+ /**
51
+ * Split unified diff text into { path, lines[] } per file, where lines are
52
+ * the raw hunk lines (prefixed with ' ', '+', '-') and hunk headers.
53
+ */
54
+ function splitFiles (diffText) {
55
+ const files = []
56
+ let current = null
57
+ for (const raw of String(diffText || '').split('\n')) {
58
+ const m = /^diff --git a\/(.+?) b\/(.+)$/.exec(raw)
59
+ if (m) {
60
+ current = { path: m[2], lines: [] }
61
+ files.push(current)
62
+ continue
63
+ }
64
+ if (!current) continue
65
+ if (/^(\+\+\+|---|index |new file|deleted file|similarity|rename|old mode|new mode)/.test(raw)) continue
66
+ current.lines.push(raw)
67
+ }
68
+ return files
69
+ }
70
+
71
+ /**
72
+ * Enum values added and removed in enterprise_features.h.
73
+ * @returns {{added: string[], removed: string[]}}
74
+ */
75
+ function enumChanges (lines) {
76
+ const added = new Set()
77
+ const removed = new Set()
78
+ // The member line only counts inside the enum. Track that from the hunk
79
+ // header and from context lines, both of which git carries in the diff.
80
+ // Only enum members count: `case license_required_feature::x:` labels move
81
+ // whenever the formatter is rewritten (26.2 replaced operator<< with a
82
+ // format_to switch), so a case label is never read as a feature change.
83
+ let inEnum = false
84
+ for (const line of lines) {
85
+ if (line.startsWith('@@')) {
86
+ inEnum = /enum class license_required_feature/.test(line)
87
+ continue
88
+ }
89
+ const body = line.slice(1)
90
+ if (/^\s*enum class license_required_feature\b/.test(body)) { inEnum = true; continue }
91
+ if (inEnum && /^\s*};/.test(body)) { inEnum = false; continue }
92
+ const sign = line[0]
93
+ if (sign !== '+' && sign !== '-') continue
94
+ const target = sign === '+' ? added : removed
95
+ const member = inEnum && /^\s*([a-z][a-z0-9_]*)\s*,?\s*(\/\/.*)?$/.exec(body)
96
+ if (member) target.add(member[1])
97
+ }
98
+ return {
99
+ added: [...added].filter((n) => !removed.has(n)).sort(),
100
+ removed: [...removed].filter((n) => !added.has(n)).sort()
101
+ }
102
+ }
103
+
104
+ // One declaration can span lines (clang-format wraps a long template
105
+ // argument list, so `enterprise<...>` and the member name land on separate
106
+ // lines). Lines are joined into statements before matching: a statement ends
107
+ // at a `;`, and also at a brace, an access specifier or a preprocessor line,
108
+ // so a declaration never absorbs the line before it.
109
+ const ENTERPRISE_DECL = /^\s*(?:config::)?enterprise<[\s\S]*>\s*([a-z][a-z0-9_]*)\s*;\s*$/
110
+
111
+ // Comments and attributes are dropped before joining: a `/* ... */` or
112
+ // `[[attr]]` line above a declaration has no `;` or brace, so it would
113
+ // otherwise be glued to the front of the declaration and hide it.
114
+ function stripComments (text, state) {
115
+ let out = ''
116
+ let rest = text
117
+ // A hunk can start inside a block comment whose opener is above it; a
118
+ // line that starts with `*` or `*/` is read as part of that comment.
119
+ if (!state.inBlock && /^\s*\*(\s|\/|$)/.test(rest)) state.inBlock = true
120
+ while (rest) {
121
+ if (state.inBlock) {
122
+ const end = rest.indexOf('*/')
123
+ if (end === -1) return out
124
+ state.inBlock = false
125
+ rest = rest.slice(end + 2)
126
+ continue
127
+ }
128
+ const line = rest.indexOf('//')
129
+ const block = rest.indexOf('/*')
130
+ if (block !== -1 && (line === -1 || block < line)) {
131
+ out += rest.slice(0, block) + ' '
132
+ state.inBlock = true
133
+ rest = rest.slice(block + 2)
134
+ continue
135
+ }
136
+ out += line === -1 ? rest : rest.slice(0, line)
137
+ break
138
+ }
139
+ return out.replace(/\[\[[^\]]*\]\]/g, ' ')
140
+ }
141
+
142
+ /**
143
+ * Join C++ source lines into statements. Each statement carries whether any
144
+ * of its lines is marked `changed`.
145
+ * @param {Array<{text: string, changed?: boolean}>} lines
146
+ * @returns {Array<{text: string, changed: boolean}>}
147
+ */
148
+ function joinStatements (lines) {
149
+ const out = []
150
+ let buf = []
151
+ let changed = false
152
+ const flush = () => {
153
+ if (buf.length) out.push({ text: buf.join(' '), changed })
154
+ buf = []
155
+ changed = false
156
+ }
157
+ const state = { inBlock: false }
158
+ for (const { text, changed: c } of lines) {
159
+ const body = stripComments(text, state).trim()
160
+ if (!body) continue
161
+ if (/^#/.test(body) || /^(public|private|protected)\s*:$/.test(body)) { flush(); continue }
162
+ buf.push(body)
163
+ changed = changed || Boolean(c)
164
+ if (/[;{}]$/.test(body)) flush()
165
+ }
166
+ flush()
167
+ return out
168
+ }
169
+
170
+ /**
171
+ * Names of every property wrapped in enterprise<> in a configuration.h text.
172
+ * @param {string} headerText
173
+ * @returns {string[]}
174
+ */
175
+ function enterprisePropertyNames (headerText) {
176
+ const names = new Set()
177
+ for (const st of joinStatements(String(headerText || '').split('\n').map((text) => ({ text })))) {
178
+ const m = ENTERPRISE_DECL.exec(st.text)
179
+ if (m) names.add(m[1])
180
+ }
181
+ return [...names].sort()
182
+ }
183
+
184
+ /**
185
+ * Properties whose declaration gained or lost the enterprise<> wrapper in
186
+ * configuration.h. A declaration changed in place shows as a -/+ pair with
187
+ * the same name; the wrapper is on exactly one side. Each side of each hunk
188
+ * (context plus its own changed lines) is joined into statements, so a
189
+ * declaration that wraps across lines is read whole.
190
+ * @returns {{added: string[], removed: string[]}}
191
+ */
192
+ function enterprisePropertyChanges (lines) {
193
+ const added = new Set()
194
+ const removed = new Set()
195
+ const collect = (side, target) => {
196
+ for (const st of joinStatements(side)) {
197
+ if (!st.changed) continue
198
+ const m = ENTERPRISE_DECL.exec(st.text)
199
+ if (m) target.add(m[1])
200
+ }
201
+ }
202
+ let oldSide = []
203
+ let newSide = []
204
+ const flushHunk = () => {
205
+ collect(oldSide, removed)
206
+ collect(newSide, added)
207
+ oldSide = []
208
+ newSide = []
209
+ }
210
+ for (const line of lines) {
211
+ if (line.startsWith('@@')) { flushHunk(); continue }
212
+ const sign = line[0]
213
+ const text = line.slice(1)
214
+ if (sign === '-') oldSide.push({ text, changed: true })
215
+ else if (sign === '+') newSide.push({ text, changed: true })
216
+ else if (sign === ' ') { oldSide.push({ text }); newSide.push({ text }) }
217
+ }
218
+ flushHunk()
219
+ return {
220
+ added: [...added].filter((n) => !removed.has(n)).sort(),
221
+ removed: [...removed].filter((n) => !added.has(n)).sort()
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Enterprise properties whose restricted value changed in configuration.cc.
227
+ * An enterprise<> property's initializer takes its restricted value (or a
228
+ * predicate) between `*this` and the property's name literal:
229
+ *
230
+ * , audit_enabled(
231
+ * *this,
232
+ * true, // restricted value
233
+ * "audit_enabled",
234
+ *
235
+ * so a changed line between a `*this,` line and the `"<name>",` line of a
236
+ * property in `enterpriseNames` changes which values need a license. Only
237
+ * the initializer is read: a restricted value that names a constant or a
238
+ * function defined elsewhere (sasl_mechanisms reads
239
+ * enterprise_sasl_mechanisms) changes where that is defined, and is not
240
+ * seen here.
241
+ * @param {string[]} lines - hunk lines of configuration.cc
242
+ * @param {Set<string>} enterpriseNames
243
+ * @returns {string[]}
244
+ */
245
+ function restrictedValueChanges (lines, enterpriseNames) {
246
+ const found = new Set()
247
+ let init = null
248
+ let changed = false
249
+ for (const line of lines) {
250
+ if (line.startsWith('@@')) { init = null; changed = false; continue }
251
+ const sign = line[0]
252
+ if (sign !== ' ' && sign !== '+' && sign !== '-') continue
253
+ const body = line.slice(1)
254
+ const opens = /^\s*,\s*([a-z][a-z0-9_]*)\s*\(/.exec(body)
255
+ if (opens) { init = opens[1]; changed = false; continue }
256
+ if (/^\s*\*this\s*,/.test(body)) { changed = sign !== ' '; continue }
257
+ // The name literal closes the restricted arguments. A string literal
258
+ // that is neither this initializer's name nor an enterprise property
259
+ // (http_authentication's restricted "OIDC") is a value, not the name.
260
+ const lit = /^\s*"([a-z][a-z0-9_]*)"\s*,/.exec(body)
261
+ if (lit && (lit[1] === init || enterpriseNames.has(lit[1]))) {
262
+ if (changed && enterpriseNames.has(lit[1])) found.add(lit[1])
263
+ changed = false
264
+ continue
265
+ }
266
+ // A hunk that starts after `*this` still counts the changed lines up to
267
+ // the name literal its context carries.
268
+ if (sign !== ' ') changed = true
269
+ }
270
+ return [...found].sort()
271
+ }
272
+
273
+ /**
274
+ * Detect enterprise gating changes in a diff.
275
+ * @param {string} diffText - unified diff (`git diff base...HEAD`)
276
+ * @param {object} [options]
277
+ * @param {string[]} [options.enterpriseNames] - properties wrapped in
278
+ * enterprise<> at HEAD (see enterprisePropertyNames). Without it, a
279
+ * configuration.cc change cannot be tied to an enterprise property and is
280
+ * not reported.
281
+ * @returns {Array<{surface: string, name: string, change_kind: string, affected_pages: string[], summary: string}>}
282
+ */
283
+ function detectGatingChanges (diffText, { enterpriseNames = [] } = {}) {
284
+ const findings = []
285
+ const files = splitFiles(diffText)
286
+ const header = files.find((f) => f.path === CONFIG_HEADER)
287
+ const headerChanged = new Set(header ? Object.values(enterprisePropertyChanges(header.lines)).flat() : [])
288
+ for (const file of files) {
289
+ if (file.path === ENUM_HEADER) {
290
+ const { added, removed } = enumChanges(file.lines)
291
+ for (const name of added) {
292
+ findings.push({
293
+ surface: 'enterprise-feature',
294
+ name,
295
+ change_kind: 'enterprise-feature-added',
296
+ affected_pages: [LICENSING_OVERVIEW],
297
+ summary: `\`${name}\` was added to \`license_required_feature\` in \`${ENUM_HEADER}\`. From the first release that contains this change, \`rpk cluster license info\` and \`GET /v1/features/enterprise\` report it and the license nag names it. The enterprise features registry (${REGISTRY_URL}) needs an entry under the approved external name, with a gating property and, where a property turns it off, a row on ${DISABLE_PAGE}; the weekly registry check fails until it has one. Any page that describes this feature's license behavior needs the new reporting and enforcement.`
298
+ })
299
+ }
300
+ for (const name of removed) {
301
+ findings.push({
302
+ surface: 'enterprise-feature',
303
+ name,
304
+ change_kind: 'enterprise-feature-removed',
305
+ affected_pages: [LICENSING_OVERVIEW],
306
+ summary: `\`${name}\` was removed from \`license_required_feature\` in \`${ENUM_HEADER}\`. The release that contains this change no longer reports it as an enterprise feature. The enterprise features registry entry that points at it (${REGISTRY_URL}) needs a new source pointer or a note, never deletion: a published external name stays in the registry.`
307
+ })
308
+ }
309
+ } else if (file.path === CONFIG_HEADER) {
310
+ const { added, removed } = enterprisePropertyChanges(file.lines)
311
+ for (const name of added) {
312
+ findings.push({
313
+ surface: 'enterprise-property',
314
+ name,
315
+ change_kind: 'enterprise-gate-added',
316
+ affected_pages: [LICENSING_OVERVIEW],
317
+ summary: `\`${name}\` is now wrapped in \`config::enterprise<>\` in \`${CONFIG_HEADER}\`, so a restricted value needs a license from the first release that contains this change. The enterprise features registry (${REGISTRY_URL}) needs an entry that names this property as its source or gating property, and ${DISABLE_PAGE} needs a row for the unrestricted value.`
318
+ })
319
+ }
320
+ for (const name of removed) {
321
+ findings.push({
322
+ surface: 'enterprise-property',
323
+ name,
324
+ change_kind: 'enterprise-gate-removed',
325
+ affected_pages: [LICENSING_OVERVIEW],
326
+ summary: `\`${name}\` is no longer wrapped in \`config::enterprise<>\` in \`${CONFIG_HEADER}\`, so it no longer needs a license. The registry entry that names it (${REGISTRY_URL}) and its row on ${DISABLE_PAGE} need to change.`
327
+ })
328
+ }
329
+ } else if (file.path === CONFIG_SOURCE) {
330
+ // A property whose wrapper changed in the header is already reported
331
+ // above; its initializer changes with it.
332
+ for (const name of restrictedValueChanges(file.lines, new Set(enterpriseNames))) {
333
+ if (headerChanged.has(name)) continue
334
+ findings.push({
335
+ surface: 'enterprise-property',
336
+ name,
337
+ change_kind: 'enterprise-restricted-value-changed',
338
+ affected_pages: [LICENSING_OVERVIEW],
339
+ summary: `The restricted value of \`${name}\`, a \`config::enterprise<>\` property, changed in \`${CONFIG_SOURCE}\`, so which values need a license changes from the first release that contains this change. The enterprise features registry entry that names it (${REGISTRY_URL}) and its row on ${DISABLE_PAGE} need the new restricted and unrestricted values.`
340
+ })
341
+ }
342
+ }
343
+ }
344
+ return findings
345
+ }
346
+
347
+ /**
348
+ * Shape findings as the doc-impact report the review workflow dispatches
349
+ * (the same JSON the model writes to doc-impact.json), or null when there is
350
+ * nothing to report.
351
+ */
352
+ function buildDocImpact (findings) {
353
+ if (!findings || findings.length === 0) return null
354
+ // The dispatch schema allows ten findings. Title, body and findings all
355
+ // come from the same capped list, so the ticket never names a change the
356
+ // findings (which the receiver dedupes on) leave out; the overflow is
357
+ // counted instead.
358
+ const capped = findings.slice(0, 10)
359
+ const overflow = findings.length - capped.length
360
+ const names = capped.map((f) => `\`${f.name}\``).join(', ')
361
+ const bullets = capped.map((f) => `- ${f.name} (${f.change_kind}): ${f.summary}`).join('\n')
362
+ return {
363
+ findings: capped,
364
+ proposed_ticket: {
365
+ title: `Update enterprise feature docs for ${capped.map((f) => f.name).join(', ')}${overflow > 0 ? ` and ${overflow} more` : ''}`,
366
+ body: [
367
+ `This PR changes which features Redpanda gates on an enterprise license, without changing any doc string, so the licensing docs do not pick it up on regeneration: ${names}.`,
368
+ '',
369
+ bullets,
370
+ ...(overflow > 0 ? [`- ${overflow} more gating change(s) are not listed here; see the PR diff.`] : []),
371
+ '',
372
+ `Registry: ${REGISTRY_URL}`,
373
+ `Licensing overview: ${LICENSING_OVERVIEW}`,
374
+ `Disable page: ${DISABLE_PAGE}`,
375
+ '',
376
+ 'The change is on the default branch until a release contains it; the docs change belongs to the version branch for that release. Confirm the affected release with the PR author.'
377
+ ].join('\n')
378
+ }
379
+ }
380
+ }
381
+
382
+ module.exports = {
383
+ ENUM_HEADER,
384
+ CONFIG_HEADER,
385
+ CONFIG_SOURCE,
386
+ detectGatingChanges,
387
+ buildDocImpact,
388
+ enumChanges,
389
+ enterprisePropertyChanges,
390
+ enterprisePropertyNames,
391
+ restrictedValueChanges,
392
+ splitFiles
393
+ }
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  const yaml = require('js-yaml')
15
+ const { generatedMarker } = require('../../cli-utils/generated-marker')
15
16
  const semver = require('semver')
16
17
 
17
18
  // Mirrors macros/enterprise.js: no 'cloud' scope, because Redpanda Cloud has
@@ -197,7 +198,7 @@ function buildMappingPartial (features, enumValues) {
197
198
  }
198
199
  }
199
200
  const lines = [
200
- '// Generated by `doc-tools validate enterprise-features --write-mapping`. Do not edit.',
201
+ generatedMarker('validate enterprise-features --write-mapping', 'the enterprise feature names reported by rpk cluster license info'),
201
202
  '// Maps the internal feature names reported by `rpk cluster license info`',
202
203
  '// (enterprise_features_in_use) to their documented names.',
203
204
  '[cols="1m,2a"]',
@@ -26,6 +26,11 @@ rpk_basic_command = ["rpk"]
26
26
 
27
27
  def assert_period(s):return s if s.endswith('.') else s + '.'
28
28
 
29
+ # Same marker shape as cli-utils/generated-marker.js (no version, so a release
30
+ # does not rewrite every page); convert-to-markdown reads it.
31
+ GENERATED_MARKER = ("// This content is autogenerated. Do not edit manually. "
32
+ "Generated by doc-tools (rpk-docs) from the rpk command help in redpanda-data/streaming-enterprise.")
33
+
29
34
  def escape_chars(s):
30
35
 
31
36
  s = s.replace("./<timestamp>-bundle.zip", "./&lt;timestamp&gt;-bundle.zip")
@@ -311,7 +316,8 @@ def build_ascii(ascii_result, executed_command, explanation, usage, it_flags, fl
311
316
  if not os.path.exists(rpk_gen_dir):
312
317
  os.makedirs(rpk_gen_dir)
313
318
 
314
- # Construct the asciidoc content
319
+ # Construct the asciidoc content, marker first
320
+ ascii_result += GENERATED_MARKER + "\n"
315
321
  ascii_result += "= " + executed_command
316
322
  ascii_result += "\n:description: " + executed_command
317
323
  ascii_result += "\n\n" + explanation
@@ -23,6 +23,14 @@ When a consumer group is specified this input consumes one or more topics.
23
23
 
24
24
  This fixture exercises the ` + "`raw` + interpreted + `raw`" + ` concatenation idiom.
25
25
 
26
+ ifndef::env-cloud[]
27
+ Set the consumer group in the input config so this paragraph publishes only to the self-managed docs.
28
+ endif::[]
29
+
30
+ ifdef::env-cloud[]
31
+ The consumer group is managed for you, so this paragraph publishes only to the Cloud docs.
32
+ endif::[]
33
+
26
34
  == Metadata
27
35
 
28
36
  This heading and the table below are legitimate AsciiDoc:
@@ -15,9 +15,12 @@ type WidgetSpec struct {
15
15
  // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="ClusterSource is immutable"
16
16
  ClusterSource *ClusterSource `json:"cluster"`
17
17
  // Text is the actual unescaped text of a widget. The Go name matches
18
- // the json name (case only), so this conforms.
18
+ // the json name up to case, and it still opens with the Go name.
19
19
  // +required
20
20
  Text string `json:"text,omitempty"`
21
+ // maxBytes is the largest widget payload accepted. It opens with the
22
+ // YAML key instead of what the field does.
23
+ MaxBytes int `json:"maxBytes,omitempty"`
21
24
  // Specifies how many replicas to run. Conforming: describes behavior,
22
25
  // never names the field.
23
26
  // +kubebuilder:default=3