@redpanda-data/docs-extensions-and-macros 5.3.0 → 5.3.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redpanda-data/docs-extensions-and-macros",
3
- "version": "5.3.0",
3
+ "version": "5.3.1",
4
4
  "description": "Antora extensions and macros developed for Redpanda documentation.",
5
5
  "keywords": [
6
6
  "antora",
@@ -79,7 +79,7 @@ ENUM_PATTERN = re.compile(r'^[a-zA-Z0-9_:]+::([a-zA-Z0-9_]+)$') # Match full qu
79
79
  CONSTRUCTOR_PATTERN = re.compile(r'([a-zA-Z0-9_:]+)\((.*)\)')
80
80
  BRACED_CONSTRUCTOR_PATTERN = re.compile(r'([a-zA-Z0-9_:]+)\{(.*)\}')
81
81
  DIGIT_SEPARATOR_PATTERN = re.compile(r"(?<=\d)'(?=\d)")
82
- FUNCTION_CALL_PATTERN = re.compile(r'([a-zA-Z0-9_:]+)\(\)')
82
+ FUNCTION_CALL_PATTERN = re.compile(r'([a-zA-Z0-9_:.]+)\(\)$')
83
83
  CHRONO_PATTERN = re.compile(r'std::chrono::([a-zA-Z]+)\s*\{\s*(\d+)\s*\}')
84
84
  CHRONO_PAREN_PATTERN = re.compile(r'(?:std::)?chrono::([a-zA-Z]+)\s*\(\s*([^)]+)\s*\)')
85
85
  TIME_UNIT_PATTERN = re.compile(r'(\d+)\s*(min|s|ms|h)')
@@ -159,6 +159,15 @@ class ConstexprCache:
159
159
  re.compile(r'const\s+model::ns\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*\(\s*"([^"]+)"\s*\)'),
160
160
  ]
161
161
 
162
+ # Member accessors on inline constants used in property defaults,
163
+ # for example model::schema_registry_internal_tp.topic() -> "_schemas".
164
+ # Each entry maps a variable declaration to the member name whose call
165
+ # returns the captured string, cached under "<variable>.<member>".
166
+ member_accessor_patterns = [
167
+ # Pattern: inline const model::topic_partition name{model::topic{"value"}, ...}
168
+ (re.compile(r'inline\s+const\s+(?:model::)?topic_partition\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*\{\s*(?:model::)?topic\{\s*"([^"]+)"\s*\}'), 'topic'),
169
+ ]
170
+
162
171
  # Legacy specific patterns (kept for compatibility, but general patterns should cover these)
163
172
  function_patterns = {}
164
173
 
@@ -206,6 +215,17 @@ class ConstexprCache:
206
215
  qualified_name = f"{namespace}::{func_name}"
207
216
  self.function_cache[qualified_name] = func_value
208
217
 
218
+ # Extract member accessors on inline constants
219
+ for pattern, member in member_accessor_patterns:
220
+ for match in pattern.finditer(content):
221
+ var_name = match.group(1)
222
+ value = match.group(2)
223
+ key = f"{var_name}.{member}"
224
+ namespace = self._extract_namespace_for_function(content, match.start())
225
+ self.function_cache[key] = value
226
+ if namespace:
227
+ self.function_cache[f"{namespace}::{key}"] = value
228
+
209
229
  # Legacy: Extract function definitions from hardcoded patterns (if any)
210
230
  for func_name, patterns in function_patterns.items():
211
231
  for pattern in patterns:
@@ -0,0 +1,566 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Static (source-based) detection of Linux-only rpk commands.
5
+ *
6
+ * Works on any platform (including Linux CI runners, where the dynamic
7
+ * "build on Linux and Darwin and diff the trees" approach is impossible
8
+ * because a cross-compiled darwin rpk cannot be executed).
9
+ *
10
+ * How it works
11
+ * ------------
12
+ * rpk gates platform-specific commands with Go build constraints:
13
+ *
14
+ * 1. Whole-package gating: every file in a command's package carries a
15
+ * Linux constraint (explicit `//go:build linux` tag or a `_linux.go`
16
+ * filename suffix) and no darwin-buildable counterpart exists.
17
+ * Example: pkg/cli/iotune, pkg/cli/redpanda/tune.
18
+ *
19
+ * 2. Dual registration: a package provides two variants of the same
20
+ * command constructor, one per platform, registering different
21
+ * subcommand sets. Example: pkg/cli/redpanda/redpanda.go
22
+ * (`//go:build linux`, registers start/stop/check/mode/config/tune)
23
+ * vs pkg/cli/redpanda/redpanda_darwin.go (`//go:build darwin`,
24
+ * registers only the hidden admin command). Same pattern at the root:
25
+ * pkg/cli/root_linux.go vs pkg/cli/root_darwin.go.
26
+ *
27
+ * This module detects both patterns:
28
+ *
29
+ * - For every scanned package, it computes which files build on linux
30
+ * and which build on darwin (from `//go:build` / `// +build` tags AND
31
+ * filename-implied constraints like `_linux.go`).
32
+ * - Packages whose files all fail to build on darwin (but build on
33
+ * linux) and that define a cobra command constructor are Linux-only.
34
+ * - Packages containing platform-differential files get a registration
35
+ * diff: subcommand constructors referenced from linux-buildable files
36
+ * but not from darwin-buildable files are Linux-only. Deprecated and
37
+ * hidden constructors are ignored because `rpk --print-tree` (the
38
+ * basis of the generated docs) excludes hidden commands.
39
+ *
40
+ * Scope: built-in commands only. Plugin commands (rpk connect, rpk ai, ...)
41
+ * are not part of the rpk source tree, so their platform availability
42
+ * cannot be derived here. Plugins keep whatever platform behavior the
43
+ * caller's other detection paths provide (for example, dynamic detection
44
+ * marks plugin-only commands when plugins are only installed in the Linux
45
+ * build container).
46
+ */
47
+
48
+ const fs = require('fs')
49
+ const path = require('path')
50
+
51
+ /** GOOS values recognized in filename suffixes and build tags */
52
+ const KNOWN_GOOS = new Set([
53
+ 'aix', 'android', 'darwin', 'dragonfly', 'freebsd', 'hurd', 'illumos',
54
+ 'ios', 'js', 'linux', 'nacl', 'netbsd', 'openbsd', 'plan9', 'solaris',
55
+ 'wasip1', 'windows', 'zos'
56
+ ])
57
+
58
+ /** GOOS values that satisfy the `unix` build tag */
59
+ const UNIX_GOOS = new Set([
60
+ 'aix', 'android', 'darwin', 'dragonfly', 'freebsd', 'hurd', 'illumos',
61
+ 'ios', 'linux', 'netbsd', 'openbsd', 'solaris'
62
+ ])
63
+
64
+ /** GOARCH values recognized in filename suffixes */
65
+ const KNOWN_GOARCH = new Set([
66
+ '386', 'amd64', 'amd64p32', 'arm', 'arm64', 'arm64be', 'armbe', 'loong64',
67
+ 'mips', 'mips64', 'mips64le', 'mips64p32', 'mips64p32le', 'mipsle',
68
+ 'ppc', 'ppc64', 'ppc64le', 'riscv', 'riscv64', 's390', 's390x',
69
+ 'sparc', 'sparc64', 'wasm'
70
+ ])
71
+
72
+ /**
73
+ * Directories (relative to the rpk source root) scanned for command
74
+ * implementations. Modern rpk keeps commands in pkg/cli/<command>/;
75
+ * the other roots cover older source layouts.
76
+ */
77
+ const SCAN_ROOTS = ['pkg/cli', 'pkg/cli/cmd', 'cmd/rpk']
78
+
79
+ /** Directory names that never correspond to a command path segment */
80
+ const NON_COMMAND_SEGMENTS = new Set(['internal', 'common', 'testdata'])
81
+
82
+ /**
83
+ * Determine the GOOS constraint implied by a Go filename.
84
+ * Follows Go's rules: *_GOOS.go, *_GOOS_GOARCH.go constrain the OS;
85
+ * *_GOARCH.go alone does not.
86
+ * @param {string} fileName - e.g. 'redpanda_darwin.go', 'bundle_k8s_linux.go'
87
+ * @returns {string|null} GOOS name or null when unconstrained
88
+ */
89
+ function goosFromFileName(fileName) {
90
+ const base = fileName.replace(/\.go$/, '')
91
+ const parts = base.split('_')
92
+ if (parts.length < 2) return null
93
+
94
+ const last = parts[parts.length - 1]
95
+ if (KNOWN_GOOS.has(last)) return last
96
+ if (KNOWN_GOARCH.has(last) && parts.length >= 3) {
97
+ const secondLast = parts[parts.length - 2]
98
+ if (KNOWN_GOOS.has(secondLast)) return secondLast
99
+ }
100
+ return null
101
+ }
102
+
103
+ /**
104
+ * Evaluate a `//go:build` constraint expression for a target GOOS.
105
+ * Supports identifiers, !, &&, || and parentheses.
106
+ * Unknown identifiers (custom build tags) evaluate to false, matching a
107
+ * default `go build` with no -tags flag. GOARCH identifiers evaluate to
108
+ * true because we only care about OS-level availability.
109
+ * @param {string} expr - Expression after `//go:build`
110
+ * @param {string} goos - Target GOOS ('linux' or 'darwin')
111
+ * @returns {boolean}
112
+ */
113
+ function evaluateBuildExpr(expr, goos) {
114
+ const tokens = expr.match(/[A-Za-z0-9_.]+|&&|\|\||!|\(|\)/g) || []
115
+ let pos = 0
116
+
117
+ const peek = () => tokens[pos]
118
+ const next = () => tokens[pos++]
119
+
120
+ function evalIdent(ident) {
121
+ if (KNOWN_GOOS.has(ident)) return ident === goos
122
+ if (ident === 'unix') return UNIX_GOOS.has(goos)
123
+ if (KNOWN_GOARCH.has(ident)) return true
124
+ if (ident === 'cgo') return true
125
+ // Custom build tags (e.g. withasan, integration) are unset by default
126
+ return false
127
+ }
128
+
129
+ function parsePrimary() {
130
+ const tok = next()
131
+ if (tok === '(') {
132
+ const val = parseOr()
133
+ if (peek() === ')') next()
134
+ return val
135
+ }
136
+ if (tok === '!') return !parsePrimary()
137
+ if (tok === undefined) return false
138
+ return evalIdent(tok)
139
+ }
140
+
141
+ function parseAnd() {
142
+ let val = parsePrimary()
143
+ while (peek() === '&&') {
144
+ next()
145
+ val = parsePrimary() && val
146
+ }
147
+ return val
148
+ }
149
+
150
+ function parseOr() {
151
+ let val = parseAnd()
152
+ while (peek() === '||') {
153
+ next()
154
+ val = parseAnd() || val
155
+ }
156
+ return val
157
+ }
158
+
159
+ return parseOr()
160
+ }
161
+
162
+ /**
163
+ * Evaluate legacy `// +build` lines for a target GOOS.
164
+ * Multiple lines AND together; within a line, space-separated options OR
165
+ * and comma-separated terms AND.
166
+ * @param {string[]} lines - Contents after `// +build` (one per line)
167
+ * @param {string} goos - Target GOOS
168
+ * @returns {boolean}
169
+ */
170
+ function evaluatePlusBuildLines(lines, goos) {
171
+ const evalTerm = (term) => {
172
+ let negate = false
173
+ while (term.startsWith('!')) {
174
+ negate = !negate
175
+ term = term.slice(1)
176
+ }
177
+ let val
178
+ if (KNOWN_GOOS.has(term)) val = term === goos
179
+ else if (term === 'unix') val = UNIX_GOOS.has(goos)
180
+ else if (KNOWN_GOARCH.has(term) || term === 'cgo') val = true
181
+ else val = false
182
+ return negate ? !val : val
183
+ }
184
+
185
+ return lines.every(line => {
186
+ const options = line.trim().split(/\s+/).filter(Boolean)
187
+ if (options.length === 0) return true
188
+ return options.some(opt => opt.split(',').every(evalTerm))
189
+ })
190
+ }
191
+
192
+ /**
193
+ * Extract the build constraint (if any) from a Go file's header.
194
+ * Only lines before the `package` declaration are considered.
195
+ * @param {string} content - Go file content
196
+ * @returns {{goBuildExpr: string|null, plusBuildLines: string[]}}
197
+ */
198
+ function extractBuildConstraint(content) {
199
+ const header = content.split(/^package\s/m)[0]
200
+ const goBuildMatch = header.match(/^\/\/go:build\s+(.+)$/m)
201
+ const plusBuildLines = []
202
+ const plusBuildRe = /^\/\/\s*\+build\s+(.+)$/gm
203
+ let m
204
+ while ((m = plusBuildRe.exec(header)) !== null) {
205
+ plusBuildLines.push(m[1])
206
+ }
207
+ return {
208
+ goBuildExpr: goBuildMatch ? goBuildMatch[1].trim() : null,
209
+ plusBuildLines
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Determine whether a Go file builds for a target GOOS, taking both the
215
+ * filename-implied constraint and explicit build tags into account.
216
+ * @param {string} fileName - Base name of the file
217
+ * @param {string} content - File content
218
+ * @param {string} goos - Target GOOS ('linux' or 'darwin')
219
+ * @returns {boolean}
220
+ */
221
+ function fileBuildsOn(fileName, content, goos) {
222
+ const fileGoos = goosFromFileName(fileName)
223
+ if (fileGoos && fileGoos !== goos) return false
224
+
225
+ const { goBuildExpr, plusBuildLines } = extractBuildConstraint(content)
226
+ if (goBuildExpr) return evaluateBuildExpr(goBuildExpr, goos)
227
+ if (plusBuildLines.length > 0) return evaluatePlusBuildLines(plusBuildLines, goos)
228
+ return true
229
+ }
230
+
231
+ /**
232
+ * Derive a command name from a cobra constructor function name.
233
+ * NewStartCommand -> start, newTuneCommand -> tune, NewCommand -> null.
234
+ * @param {string} funcName
235
+ * @returns {string|null}
236
+ */
237
+ function commandNameFromConstructor(funcName) {
238
+ const m = funcName.match(/^(?:New|new)(\w+?)(?:Command|Cmd)$/)
239
+ if (m && m[1]) return m[1].toLowerCase()
240
+ return null
241
+ }
242
+
243
+ /**
244
+ * Parse cobra command constructors defined in a Go file.
245
+ * A constructor is any `func XxxYyy(...) *cobra.Command`. For each one we
246
+ * record the cobra `Use:` name (first token) and whether the command is
247
+ * deprecated or hidden (such commands never appear in `rpk --print-tree`).
248
+ * @param {string} content - Go file content
249
+ * @returns {Object<string, {useName: string|null, excluded: boolean}>}
250
+ */
251
+ function parseConstructors(content) {
252
+ const constructors = {}
253
+ const funcRe = /^func\s+(\w+)\s*\([^)]*\)\s*\*cobra\.Command\s*\{/gm
254
+ const matches = []
255
+ let m
256
+ while ((m = funcRe.exec(content)) !== null) {
257
+ matches.push({ name: m[1], start: m.index })
258
+ }
259
+
260
+ for (let i = 0; i < matches.length; i++) {
261
+ const bodyEnd = i + 1 < matches.length ? matches[i + 1].start : content.length
262
+ const body = content.slice(matches[i].start, bodyEnd)
263
+
264
+ const useMatch = body.match(/\bUse:\s*"([^"]+)"/)
265
+ const useName = useMatch ? useMatch[1].trim().split(/\s+/)[0] : null
266
+
267
+ // Deprecated or hidden commands are excluded from `rpk --print-tree`
268
+ const excluded = /\bHidden:\s*true\b/.test(body) ||
269
+ /\bDeprecated:\s*"/.test(body) ||
270
+ /\bDeprecateCmd\s*\(/.test(body)
271
+
272
+ constructors[matches[i].name] = { useName, excluded }
273
+ }
274
+
275
+ return constructors
276
+ }
277
+
278
+ /**
279
+ * Extract constructor references from all AddCommand(...) calls in a file.
280
+ * Handles both same-package (`NewStartCommand(...)`) and cross-package
281
+ * (`tune.NewCommand(...)`) references. Only functions starting with
282
+ * New/new are considered, which skips wrappers like cobraext.DeprecateCmd.
283
+ * @param {string} content - Go file content
284
+ * @returns {Array<{qualifier: string|null, funcName: string}>}
285
+ */
286
+ function parseAddCommandRefs(content) {
287
+ const refs = []
288
+ const addRe = /\bAddCommand\s*\(/g
289
+ let m
290
+ while ((m = addRe.exec(content)) !== null) {
291
+ // Find the span of this AddCommand(...) call by balancing parentheses
292
+ let depth = 1
293
+ let i = m.index + m[0].length
294
+ while (i < content.length && depth > 0) {
295
+ if (content[i] === '(') depth++
296
+ else if (content[i] === ')') depth--
297
+ i++
298
+ }
299
+ const span = content.slice(m.index + m[0].length, i - 1)
300
+
301
+ const callRe = /(?:(\w+)\.)?((?:New|new)\w*)\s*\(/g
302
+ let c
303
+ while ((c = callRe.exec(span)) !== null) {
304
+ refs.push({ qualifier: c[1] || null, funcName: c[2] })
305
+ }
306
+ }
307
+ return refs
308
+ }
309
+
310
+ /**
311
+ * Recursively collect non-test Go files under a directory.
312
+ * @param {string} dir - Absolute directory path
313
+ * @param {Array} out - Accumulator of {absPath, dir, name}
314
+ */
315
+ function collectGoFiles(dir, out) {
316
+ let entries
317
+ try {
318
+ entries = fs.readdirSync(dir, { withFileTypes: true })
319
+ } catch (err) {
320
+ return
321
+ }
322
+ for (const entry of entries) {
323
+ const fullPath = path.join(dir, entry.name)
324
+ if (entry.isDirectory()) {
325
+ if (entry.name === 'testdata' || entry.name === 'vendor') continue
326
+ collectGoFiles(fullPath, out)
327
+ } else if (entry.name.endsWith('.go') && !entry.name.endsWith('_test.go')) {
328
+ out.push({ absPath: fullPath, dir, name: entry.name })
329
+ }
330
+ }
331
+ }
332
+
333
+ /**
334
+ * Build the command path prefix for a package directory.
335
+ * pkg/cli -> 'rpk'; pkg/cli/redpanda -> 'rpk redpanda';
336
+ * pkg/cli/cmd/redpanda (legacy) -> 'rpk redpanda'.
337
+ * @param {string} scanRoot - Absolute path of the scan root
338
+ * @param {string} dir - Absolute directory path
339
+ * @returns {string|null} Command path prefix, or null for non-command dirs
340
+ */
341
+ function commandPrefixForDir(scanRoot, dir) {
342
+ const rel = path.relative(scanRoot, dir)
343
+ let segments = rel === '' ? [] : rel.split(path.sep)
344
+ // Legacy layouts nested commands under an extra cmd/ level
345
+ if (segments[0] === 'cmd') segments = segments.slice(1)
346
+ if (segments.some(s => NON_COMMAND_SEGMENTS.has(s))) return null
347
+ return ['rpk', ...segments].join(' ')
348
+ }
349
+
350
+ /**
351
+ * Analyze one scan root and add detected Linux-only command paths.
352
+ * @param {string} scanRoot - Absolute path of the scan root
353
+ * @param {Set<string>} linuxOnlyCommands - Accumulator
354
+ */
355
+ function scanRootForLinuxOnly(scanRoot, linuxOnlyCommands) {
356
+ const files = []
357
+ collectGoFiles(scanRoot, files)
358
+ if (files.length === 0) return
359
+
360
+ // Group files (with parsed metadata) by directory
361
+ const dirs = new Map()
362
+ for (const file of files) {
363
+ let content
364
+ try {
365
+ content = fs.readFileSync(file.absPath, 'utf8')
366
+ } catch (err) {
367
+ continue
368
+ }
369
+ const info = {
370
+ name: file.name,
371
+ content,
372
+ buildsOnLinux: fileBuildsOn(file.name, content, 'linux'),
373
+ buildsOnDarwin: fileBuildsOn(file.name, content, 'darwin')
374
+ }
375
+ if (!dirs.has(file.dir)) dirs.set(file.dir, [])
376
+ dirs.get(file.dir).push(info)
377
+ }
378
+
379
+ // Per-directory constructor index: dir -> {funcName: {useName, excluded}}
380
+ const constructorIndex = new Map()
381
+ for (const [dir, dirFiles] of dirs) {
382
+ const index = {}
383
+ for (const f of dirFiles) {
384
+ Object.assign(index, parseConstructors(f.content))
385
+ }
386
+ constructorIndex.set(dir, index)
387
+ }
388
+
389
+ /**
390
+ * Resolve an AddCommand constructor reference to a visible command name.
391
+ * Returns null for deprecated/hidden/unresolvable commands.
392
+ */
393
+ const resolveRef = (dir, ref) => {
394
+ let target = null
395
+ let fallback = commandNameFromConstructor(ref.funcName)
396
+
397
+ if (ref.qualifier) {
398
+ const subDir = path.join(dir, ref.qualifier)
399
+ target = constructorIndex.get(subDir) || null
400
+ if (!fallback) fallback = ref.qualifier
401
+ } else {
402
+ target = constructorIndex.get(dir) || null
403
+ }
404
+
405
+ if (target && target[ref.funcName]) {
406
+ const ctor = target[ref.funcName]
407
+ if (ctor.excluded) return null
408
+ return ctor.useName || fallback
409
+ }
410
+
411
+ // Unresolvable reference: only trust it if the function name follows
412
+ // the cobra constructor convention (New[Xxx]Command). This skips
413
+ // helper arguments captured inside AddCommand spans, such as the
414
+ // rp.NewLauncher() argument in redpanda.NewCommand(fs, p, rp.NewLauncher()).
415
+ if (!/^(?:New|new)(?:\w*(?:Command|Cmd))?$/.test(ref.funcName)) return null
416
+ return fallback
417
+ }
418
+
419
+ for (const [dir, dirFiles] of dirs) {
420
+ const prefix = commandPrefixForDir(scanRoot, dir)
421
+ if (!prefix) continue
422
+
423
+ const hasDifferentialFile = dirFiles.some(f => f.buildsOnLinux !== f.buildsOnDarwin)
424
+
425
+ // Pattern 1: whole package is Linux-gated (and defines a command)
426
+ if (prefix !== 'rpk') {
427
+ const anyLinux = dirFiles.some(f => f.buildsOnLinux)
428
+ const anyDarwin = dirFiles.some(f => f.buildsOnDarwin)
429
+ const definesCommand = Object.keys(constructorIndex.get(dir)).length > 0
430
+ if (anyLinux && !anyDarwin && definesCommand) {
431
+ linuxOnlyCommands.add(prefix)
432
+ continue
433
+ }
434
+ }
435
+
436
+ // Pattern 2: dual registration - diff subcommands registered from
437
+ // linux-buildable files vs darwin-buildable files
438
+ if (!hasDifferentialFile) continue
439
+
440
+ const refsFor = (goos) => {
441
+ const names = new Set()
442
+ for (const f of dirFiles) {
443
+ const buildable = goos === 'linux' ? f.buildsOnLinux : f.buildsOnDarwin
444
+ if (!buildable) continue
445
+ for (const ref of parseAddCommandRefs(f.content)) {
446
+ const name = resolveRef(dir, ref)
447
+ if (name) names.add(name)
448
+ }
449
+ }
450
+ return names
451
+ }
452
+
453
+ const linuxRefs = refsFor('linux')
454
+ const darwinRefs = refsFor('darwin')
455
+
456
+ for (const name of linuxRefs) {
457
+ if (!darwinRefs.has(name)) {
458
+ linuxOnlyCommands.add(`${prefix} ${name}`)
459
+ }
460
+ }
461
+ }
462
+ }
463
+
464
+ /**
465
+ * Detect Linux-only rpk commands by statically analyzing Go source.
466
+ * Works on any platform, including Linux CI runners.
467
+ * @param {string} sourcePath - Path to the rpk Go source root (src/go/rpk)
468
+ * @returns {Set<string>} Linux-only command paths (e.g. 'rpk iotune').
469
+ * Descendants of a returned path are implicitly Linux-only too.
470
+ */
471
+ function detectLinuxOnlyFromSource(sourcePath) {
472
+ const linuxOnlyCommands = new Set()
473
+
474
+ const scannedRoots = new Set()
475
+ for (const root of SCAN_ROOTS) {
476
+ const absRoot = path.join(sourcePath, root)
477
+ if (!fs.existsSync(absRoot)) continue
478
+ // pkg/cli/cmd is nested inside pkg/cli; avoid scanning it twice
479
+ if ([...scannedRoots].some(r => absRoot.startsWith(r + path.sep))) continue
480
+ scannedRoots.add(absRoot)
481
+ scanRootForLinuxOnly(absRoot, linuxOnlyCommands)
482
+ }
483
+
484
+ // Drop any detected path that is a descendant of another detected path;
485
+ // platform markers already propagate to descendants.
486
+ for (const cmd of [...linuxOnlyCommands]) {
487
+ if ([...linuxOnlyCommands].some(other => other !== cmd && cmd.startsWith(other + ' '))) {
488
+ linuxOnlyCommands.delete(cmd)
489
+ }
490
+ }
491
+
492
+ return linuxOnlyCommands
493
+ }
494
+
495
+ /**
496
+ * List Go files under the scanned roots that carry a Linux-only build
497
+ * constraint (explicit tag or filename suffix). Used as a tripwire: if
498
+ * detection returns an empty set while this list is non-empty, the scan
499
+ * is almost certainly broken (wrong directory layout, changed tag style).
500
+ * @param {string} sourcePath - Path to the rpk Go source root
501
+ * @returns {string[]} Relative paths of Linux-constrained files
502
+ */
503
+ function findLinuxConstrainedFiles(sourcePath) {
504
+ const results = []
505
+ for (const root of SCAN_ROOTS) {
506
+ const absRoot = path.join(sourcePath, root)
507
+ if (!fs.existsSync(absRoot)) continue
508
+ const files = []
509
+ collectGoFiles(absRoot, files)
510
+ for (const file of files) {
511
+ let content
512
+ try {
513
+ content = fs.readFileSync(file.absPath, 'utf8')
514
+ } catch (err) {
515
+ continue
516
+ }
517
+ if (fileBuildsOn(file.name, content, 'linux') && !fileBuildsOn(file.name, content, 'darwin')) {
518
+ results.push(path.relative(sourcePath, file.absPath))
519
+ }
520
+ }
521
+ }
522
+ return [...new Set(results)]
523
+ }
524
+
525
+ /**
526
+ * Emit a loud warning when platform detection came back empty even though
527
+ * the source demonstrably contains Linux-gated files. Never let a scan
528
+ * failure silently mark every command as cross-platform.
529
+ * @param {string} sourcePath - Path to the rpk Go source root
530
+ * @param {Set<string>} linuxOnlyCommands - Combined detection result
531
+ * @returns {boolean} True when the tripwire fired
532
+ */
533
+ function warnIfDetectionLooksBroken(sourcePath, linuxOnlyCommands) {
534
+ if (linuxOnlyCommands.size > 0) return false
535
+
536
+ const constrained = findLinuxConstrainedFiles(sourcePath)
537
+ if (constrained.length === 0) return false
538
+
539
+ console.warn('\n' + '!'.repeat(70))
540
+ console.warn('⚠ PLATFORM DETECTION TRIPWIRE: no Linux-only commands were detected,')
541
+ console.warn(`⚠ but the rpk source contains ${constrained.length} Linux-constrained file(s), e.g.:`)
542
+ for (const file of constrained.slice(0, 5)) {
543
+ console.warn(`⚠ - ${file}`)
544
+ }
545
+ console.warn('⚠ This usually means the static source scan failed (unexpected')
546
+ console.warn('⚠ directory layout or build-tag style) and every command is about')
547
+ console.warn('⚠ to be marked as available on both Linux and macOS, which is wrong.')
548
+ console.warn('⚠ Fix tools/rpk-docs/detect-platform-commands.js before publishing')
549
+ console.warn('⚠ these docs. See redpanda-data/docs#1831 for the impact of this.')
550
+ console.warn('!'.repeat(70) + '\n')
551
+ return true
552
+ }
553
+
554
+ module.exports = {
555
+ detectLinuxOnlyFromSource,
556
+ findLinuxConstrainedFiles,
557
+ warnIfDetectionLooksBroken,
558
+ // Exported for unit tests
559
+ fileBuildsOn,
560
+ goosFromFileName,
561
+ evaluateBuildExpr,
562
+ evaluatePlusBuildLines,
563
+ parseConstructors,
564
+ parseAddCommandRefs,
565
+ commandNameFromConstructor
566
+ }
@@ -291,6 +291,24 @@ function shouldUsePartialDir(overrides, commandPath) {
291
291
  return false
292
292
  }
293
293
 
294
+ /**
295
+ * Check if a command belongs to the rpk cloud or rpk security secret families.
296
+ * Their pages are routed to the cloud partials directory (cloudSecretDir) and
297
+ * published by cloud-docs, so links to them from regular pages must cross to
298
+ * the cloud component.
299
+ * @param {string} commandPath - Full command path (e.g., "rpk cloud login")
300
+ * @returns {boolean}
301
+ */
302
+ function isCloudSecretCommand(commandPath) {
303
+ return commandPath === 'rpk cloud' || commandPath.startsWith('rpk cloud ') ||
304
+ commandPath === 'rpk security secret' || commandPath.startsWith('rpk security secret ')
305
+ }
306
+
307
+ /**
308
+ * Antora component that publishes the rpk cloud and rpk security secret pages.
309
+ */
310
+ const CLOUD_DOCS_COMPONENT = 'cloud-data-platform'
311
+
294
312
  /**
295
313
  * Get command metadata from overrides
296
314
  * @param {Object} overrides - Overrides object (resolved)
@@ -2420,8 +2438,7 @@ function updateNavFile(navFile, commands, resolvedOverrides, topLevelWithSubcomm
2420
2438
  if (isProtectedCommandPath(commandPath, protectedPlugins)) continue
2421
2439
  if (shouldExcludeCommand(resolvedOverrides, commandPath)) continue
2422
2440
  if (shouldUsePartialDir(resolvedOverrides, commandPath)) continue
2423
- if (commandPath.startsWith('rpk cloud')) continue
2424
- if (commandPath.startsWith('rpk security secret')) continue
2441
+ if (isCloudSecretCommand(commandPath)) continue
2425
2442
 
2426
2443
  const parts = commandPath.split(' ')
2427
2444
  const stars = '*'.repeat(parts.length + 1)
@@ -2725,16 +2742,13 @@ async function generateRpkDocs(options = {}) {
2725
2742
  // Build subcommands with correct xref paths
2726
2743
  // Filter out excluded and asPartial subcommands — excluded have no file,
2727
2744
  // asPartial ones live in the partials directory with no linkable xref.
2728
- // rpk cloud and rpk security secret are hardcoded-routed to partials
2729
- // (single-sourced into cloud docs), so they have no linkable pages
2730
- // either: rpk-security.adoc linking rpk-security-secret.adoc was a
2731
- // broken xref in the published site.
2745
+ // rpk cloud and rpk security secret rows stay in the table: their pages
2746
+ // are routed to the cloud partials directory and published by
2747
+ // cloud-docs, so the xref below links across to the cloud component
2748
+ // instead of dropping the row (which hid the subcommand entirely).
2732
2749
  const subcommands = (command.commands || [])
2733
2750
  .filter(sub => {
2734
2751
  const subPath = `${commandPath} ${sub.name}`
2735
- if (subPath.startsWith('rpk cloud') || subPath.startsWith('rpk security secret')) {
2736
- return false
2737
- }
2738
2752
  return !shouldExcludeCommand(resolvedOverrides, subPath) && !shouldUsePartialDir(resolvedOverrides, subPath)
2739
2753
  })
2740
2754
  .map(sub => {
@@ -2756,6 +2770,13 @@ async function generateRpkDocs(options = {}) {
2756
2770
  }
2757
2771
  }
2758
2772
 
2773
+ // rpk cloud and rpk security secret pages are routed to the cloud
2774
+ // partials directory and published by cloud-docs, so rows in regular
2775
+ // pages must link across to the cloud component.
2776
+ if (cloudSecretDir && isCloudSecretCommand(subPath) && !isCloudSecretCommand(commandPath)) {
2777
+ xrefPath = `${CLOUD_DOCS_COMPONENT}:${xrefPath}`
2778
+ }
2779
+
2759
2780
  // Use overridden description if available
2760
2781
  const subOverride = resolvedOverrides?.commands?.[subPath]
2761
2782
  const subDescription = subOverride?.description || sub.description || ''
@@ -2765,6 +2786,8 @@ async function generateRpkDocs(options = {}) {
2765
2786
  fullPath: subPath,
2766
2787
  dashifiedPath: dashifiedSubPath,
2767
2788
  xrefPath,
2789
+ cloudOnly: subOverride?.cloudOnly === true,
2790
+ selfHostedOnly: subOverride?.selfHostedOnly === true,
2768
2791
  shortDesc: ensurePeriod(capToTwoSentences(formatDescription(subDescription, textTransformations, { skipTableConversion: true, skipListConversion: true })))
2769
2792
  }
2770
2793
  })
@@ -2949,10 +2972,8 @@ async function generateRpkDocs(options = {}) {
2949
2972
 
2950
2973
  // Determine output path
2951
2974
  // Check if this command should go to cloudSecretDir
2952
- const isCloudCommand = commandPath.startsWith('rpk cloud')
2953
- const isSecuritySecretCommand = commandPath.startsWith('rpk security secret')
2954
2975
  const useCloudSecretDir = cloudSecretDir && (
2955
- isCloudCommand || isSecuritySecretCommand || shouldUsePartialDir(resolvedOverrides, commandPath)
2976
+ isCloudSecretCommand(commandPath) || shouldUsePartialDir(resolvedOverrides, commandPath)
2956
2977
  )
2957
2978
  const effectiveOutputDir = useCloudSecretDir ? cloudSecretDir : outputDir
2958
2979
 
@@ -8,6 +8,7 @@ const os = require('os')
8
8
  const semver = require('semver')
9
9
  const { findRepoRoot } = require('../../cli-utils/doc-tools-utils')
10
10
  const { generateRpkDocs, applyOverridesToTree, resolveReferences, shouldExcludeCommand, shouldUsePartialDir } = require('./generate-rpk-docs')
11
+ const { detectLinuxOnlyFromSource, warnIfDetectionLooksBroken } = require('./detect-platform-commands')
11
12
  const { generateRpkDiff, printDiffReport, generateWhatsNewSection, flattenToMap } = require('./report-delta')
12
13
  const { loadAndValidateOverrides, ValidationResult } = require('./validate-overrides')
13
14
  const { validateDirectory, formatResults } = require('./validate-output')
@@ -656,105 +657,6 @@ function fetchRpkTreeFromLinuxSource(sourcePath, pluginPins = {}) {
656
657
  }
657
658
  }
658
659
 
659
- /**
660
- * Scan Go source files for Linux-only build tags
661
- * Looks for //go:build linux or // +build linux
662
- * @param {string} sourcePath - Path to rpk Go source directory
663
- * @returns {Set<string>} Set of Linux-only command paths (e.g., 'rpk redpanda tune')
664
- */
665
- function detectLinuxOnlyFromSource(sourcePath) {
666
- const linuxOnlyCommands = new Set()
667
-
668
- // Map of source directory patterns to command paths
669
- // rpk commands are typically in pkg/cli/cmd/<command>/<subcommand>/
670
- const scanDirectory = (dir, prefix = '') => {
671
- if (!fs.existsSync(dir)) return
672
-
673
- const entries = fs.readdirSync(dir, { withFileTypes: true })
674
-
675
- for (const entry of entries) {
676
- const fullPath = path.join(dir, entry.name)
677
-
678
- if (entry.isDirectory()) {
679
- // Recurse into subdirectories
680
- const newPrefix = prefix ? `${prefix} ${entry.name}` : entry.name
681
- scanDirectory(fullPath, newPrefix)
682
- } else if (entry.name.endsWith('.go') && !entry.name.endsWith('_test.go')) {
683
- // Check Go files for build tags
684
- try {
685
- const content = fs.readFileSync(fullPath, 'utf8')
686
- const firstLines = content.split('\n').slice(0, 20).join('\n')
687
-
688
- // Check for Linux build constraints
689
- const hasLinuxTag = /\/\/go:build\s+.*linux/.test(firstLines) ||
690
- /\/\/\s*\+build\s+.*linux/.test(firstLines)
691
-
692
- if (hasLinuxTag) {
693
- // Try to determine command path from file location
694
- const commandPath = inferCommandPath(sourcePath, fullPath, entry.name)
695
- if (commandPath) {
696
- linuxOnlyCommands.add(commandPath)
697
- }
698
- }
699
- } catch (err) {
700
- // Skip files we can't read
701
- }
702
- }
703
- }
704
- }
705
-
706
- // Scan the cmd/rpk directory for command implementations
707
- const cmdDir = path.join(sourcePath, 'pkg', 'cli', 'cmd')
708
- if (fs.existsSync(cmdDir)) {
709
- scanDirectory(cmdDir, 'rpk')
710
- }
711
-
712
- // Also check the older structure
713
- const oldCmdDir = path.join(sourcePath, 'cmd', 'rpk')
714
- if (fs.existsSync(oldCmdDir)) {
715
- scanDirectory(oldCmdDir, 'rpk')
716
- }
717
-
718
- return linuxOnlyCommands
719
- }
720
-
721
- /**
722
- * Infer command path from Go source file location
723
- * @param {string} sourcePath - Root source path
724
- * @param {string} filePath - Full path to Go file
725
- * @param {string} fileName - Name of the Go file
726
- * @returns {string|null} Command path or null
727
- */
728
- function inferCommandPath(sourcePath, filePath, fileName) {
729
- // Get relative path from source root
730
- const relativePath = filePath.replace(sourcePath, '').replace(/^\//, '')
731
-
732
- // Common patterns:
733
- // pkg/cli/cmd/redpanda/tune.go -> rpk redpanda tune
734
- // pkg/cli/cmd/redpanda/start.go -> rpk redpanda start
735
- // cmd/rpk/iotune/iotune.go -> rpk iotune
736
-
737
- // Remove common prefixes and file extension
738
- let parts = relativePath
739
- .replace(/^pkg\/cli\/cmd\//, '')
740
- .replace(/^cmd\/rpk\//, '')
741
- .replace(/\.go$/, '')
742
- .split('/')
743
-
744
- // If file name matches directory name, use directory only
745
- // e.g., iotune/iotune.go -> iotune
746
- if (parts.length > 1 && parts[parts.length - 1] === parts[parts.length - 2]) {
747
- parts = parts.slice(0, -1)
748
- }
749
-
750
- // Filter out common non-command directories
751
- parts = parts.filter(p => !['internal', 'common', 'config', 'cmd'].includes(p))
752
-
753
- if (parts.length === 0) return null
754
-
755
- return 'rpk ' + parts.join(' ')
756
- }
757
-
758
660
  /**
759
661
  * Add platform markers to command tree based on source analysis
760
662
  * @param {Object} tree - Command tree from rpk --print-tree
@@ -769,11 +671,18 @@ function addPlatformMarkersFromSource(tree, linuxOnlyCommands) {
769
671
  [...linuxOnlyCommands].some(loc => cmdPath.startsWith(loc + ' '))
770
672
  }
771
673
 
674
+ // Collect every tree path actually marked Linux-only so the persisted
675
+ // linux_only_commands list is fully expanded (each descendant listed),
676
+ // matching what dynamic Linux-vs-Darwin tree comparison produces.
677
+ const markedPaths = new Set()
678
+
772
679
  const markCommands = (commands, parentPath = 'rpk') => {
773
680
  if (!commands) return commands
774
681
  return commands.map(cmd => {
775
682
  const fullPath = `${parentPath} ${cmd.name}`
776
- const platforms = isLinuxOnly(fullPath)
683
+ const linuxOnly = isLinuxOnly(fullPath)
684
+ if (linuxOnly) markedPaths.add(fullPath)
685
+ const platforms = linuxOnly
777
686
  ? [PLATFORMS.LINUX]
778
687
  : [PLATFORMS.LINUX, PLATFORMS.DARWIN]
779
688
  return {
@@ -792,11 +701,15 @@ function addPlatformMarkersFromSource(tree, linuxOnlyCommands) {
792
701
  }
793
702
  }
794
703
 
704
+ const markedCommands = markCommands(tree.commands)
705
+
795
706
  return {
796
707
  ...tree,
797
708
  platforms: [PLATFORMS.LINUX, PLATFORMS.DARWIN],
798
- linux_only_commands: [...linuxOnlyCommands],
799
- commands: markCommands(tree.commands)
709
+ // Union of detected paths and marked tree paths: keeps detected roots
710
+ // even when the tree was built on a platform where they don't exist
711
+ linux_only_commands: [...new Set([...linuxOnlyCommands, ...markedPaths])].sort(),
712
+ commands: markedCommands
800
713
  }
801
714
  }
802
715
 
@@ -1166,6 +1079,37 @@ function makeLinkablePredicate(overridesData) {
1166
1079
  }
1167
1080
  }
1168
1081
 
1082
+ // Command subtrees whose changes never belong in the Self-Managed What's
1083
+ // new. rpk ai's documentation home is adp-docs, and the ADP release notes
1084
+ // already cover its CLI changes per release. The plugin-release receiver
1085
+ // workflow excludes ai from --update-whats-new for exactly this reason; the
1086
+ // full-regeneration path must agree, or a full run floods the Self-Managed
1087
+ // release notes with rpk ai entries (a rename release alone produces 21 new
1088
+ // plus 21 removed bullets).
1089
+ const WHATS_NEW_EXCLUDED_SUBTREES = ['rpk ai']
1090
+
1091
+ /**
1092
+ * Return a copy of diffData without entries under the excluded subtrees.
1093
+ * Only the published What's-new block filters; diff reports and PR
1094
+ * summaries keep the full picture.
1095
+ * @param {Object} diffData - Diff from generateRpkDiff
1096
+ * @param {string[]} [excluded] - Command-path prefixes to drop
1097
+ * @returns {Object} Filtered copy
1098
+ */
1099
+ function filterDiffForWhatsNew(diffData, excluded = WHATS_NEW_EXCLUDED_SUBTREES) {
1100
+ const outside = (cmdPath) => !excluded.some(prefix =>
1101
+ cmdPath === prefix || (typeof cmdPath === 'string' && cmdPath.startsWith(prefix + ' ')))
1102
+ const details = diffData.details || {}
1103
+ const filteredDetails = { ...details }
1104
+ for (const key of ['newCommands', 'newlyDeprecatedCommands', 'removedCommands', 'descriptionChanges']) {
1105
+ if (Array.isArray(details[key])) filteredDetails[key] = details[key].filter(e => outside(e.path))
1106
+ }
1107
+ for (const key of ['newFlags', 'removedFlags', 'changedDefaults', 'changedFlagTypes', 'changedFlagRequirements', 'changedFlagDescriptions']) {
1108
+ if (Array.isArray(details[key])) filteredDetails[key] = details[key].filter(e => outside(e.commandPath))
1109
+ }
1110
+ return { ...diffData, details: filteredDetails }
1111
+ }
1112
+
1169
1113
  function updateWhatsNewFile(diffData, whatsNewPath, version, options = {}) {
1170
1114
  // Each block opens with a "=== <version>" heading so accumulated blocks
1171
1115
  // (successive RCs, multiple plugin releases) never collide on section ids
@@ -1437,8 +1381,28 @@ function acquireRpkBinary(rpkVersion, options = {}) {
1437
1381
  console.warn(`Native build failed (${nativeErr.message.split('\n')[0]}); building in a container...`)
1438
1382
  const goVersion = getRequiredGoVersion(sourcePath)
1439
1383
  const goImage = goVersion ? `golang:${goVersion}` : 'golang:1'
1384
+ // Cross-compile for the HOST platform: the container reports
1385
+ // GOOS=linux, and a linux binary dies silently when executed on the
1386
+ // macOS host that needs it for plugin installs (review finding on the
1387
+ // 5.3.0 train). rpk builds with CGO disabled, so cross-compilation
1388
+ // from the linux container is safe.
1389
+ // Only macOS and Linux hosts are supported: mapping anything else to
1390
+ // linux would hand a Windows host an unrunnable binary, the exact bug
1391
+ // class the cross-compile fixes (review finding on #238).
1392
+ if (process.platform !== 'darwin' && process.platform !== 'linux') {
1393
+ throw new Error(
1394
+ `${nativeErr.message}\n` +
1395
+ `Container fallback does not support host platform "${process.platform}". ` +
1396
+ 'Update Go, or pass --rpk-bin <path> to use an existing rpk binary.'
1397
+ )
1398
+ }
1399
+ const hostGoos = process.platform === 'darwin' ? 'darwin' : 'linux'
1400
+ const hostGoarch = process.arch === 'arm64' ? 'arm64' : 'amd64'
1440
1401
  const buildResult = spawnSync('docker', [
1441
1402
  'run', '--rm',
1403
+ '-e', `GOOS=${hostGoos}`,
1404
+ '-e', `GOARCH=${hostGoarch}`,
1405
+ '-e', 'CGO_ENABLED=0',
1442
1406
  '-v', `${path.resolve(sourcePath)}:/rpk-source:ro`,
1443
1407
  '-v', `${workDir}:/out`,
1444
1408
  '-w', '/rpk-source',
@@ -2001,10 +1965,14 @@ async function handleRpkDocsGeneration(options = {}) {
2001
1965
  // render without xrefs because plugin subtrees may render as
2002
1966
  // partials with no linkable pages.
2003
1967
  if (whatsNewPath) {
2004
- const label = resolvedVersion
2005
- ? `${plugin} plugin ${resolvedVersion}`
2006
- : `${plugin} plugin`
2007
- updateWhatsNewFile(pluginDiffData, whatsNewPath, label, { xrefs: false, sectionHeading: '== rpk plugins' })
1968
+ if (WHATS_NEW_EXCLUDED_SUBTREES.includes(`rpk ${plugin}`)) {
1969
+ console.log(`Skipping What's new for rpk ${plugin}: its documentation home covers CLI changes in its own release notes`)
1970
+ } else {
1971
+ const label = resolvedVersion
1972
+ ? `${plugin} plugin ${resolvedVersion}`
1973
+ : `${plugin} plugin`
1974
+ updateWhatsNewFile(pluginDiffData, whatsNewPath, label, { xrefs: false, sectionHeading: '== rpk plugins' })
1975
+ }
2008
1976
  }
2009
1977
  }
2010
1978
 
@@ -2117,7 +2085,7 @@ async function handleRpkDocsGeneration(options = {}) {
2117
2085
 
2118
2086
  // Update what's-new file if requested
2119
2087
  if (whatsNewPath) {
2120
- updateWhatsNewFile(diffData, whatsNewPath, rpkVersion, { linkable: makeLinkablePredicate(overridesData), hasSubcommands: makeSubcommandPredicate(tree) })
2088
+ updateWhatsNewFile(filterDiffForWhatsNew(diffData), whatsNewPath, rpkVersion, { linkable: makeLinkablePredicate(overridesData), hasSubcommands: makeSubcommandPredicate(tree) })
2121
2089
  }
2122
2090
  } else {
2123
2091
  console.warn(`Warning: Could not load previous version ${diffVersion} for diff`)
@@ -2217,10 +2185,17 @@ async function handleRpkDocsGeneration(options = {}) {
2217
2185
  rpkVersion = effectiveRef || 'local'
2218
2186
 
2219
2187
  // Step 2: Detect Linux-only commands
2220
- // Try dynamic detection first (compare Linux vs Darwin builds)
2221
- // Fall back to static lists for plugins and known commands
2188
+ // Static detection (build-constraint analysis) works on any platform,
2189
+ // including Linux CI runners. Dynamic detection (comparing Linux vs
2190
+ // Darwin builds) supplements it below when the host can run both.
2222
2191
  console.log('\nAnalyzing source for Linux-only commands...')
2223
2192
  let linuxOnlyCommands = detectLinuxOnlyFromSource(sourcePath)
2193
+ if (linuxOnlyCommands.size > 0) {
2194
+ console.log(`Static detection found ${linuxOnlyCommands.size} Linux-only command path(s):`)
2195
+ for (const cmd of [...linuxOnlyCommands].sort()) {
2196
+ console.log(` - ${cmd}`)
2197
+ }
2198
+ }
2224
2199
 
2225
2200
  // Step 3: Build rpk and get command tree
2226
2201
  // Use Docker if available (for running rpk plugins in Linux)
@@ -2230,8 +2205,19 @@ async function handleRpkDocsGeneration(options = {}) {
2230
2205
  const canBuildNative = goCheck.status === 0
2231
2206
  const canBuildLinux = dockerCheck.status === 0
2232
2207
 
2233
- // Dynamic detection: if we can build on both platforms, compare the trees
2234
- if (canBuildLinux && canBuildNative && os.platform() !== 'linux') {
2208
+ // Dynamic detection: if we can build on both platforms, compare the trees.
2209
+ // Only possible on non-Linux hosts: a Linux runner can cross-compile a
2210
+ // darwin rpk but cannot execute it to get its command tree, so Linux CI
2211
+ // relies on the static detection above.
2212
+ // RPK_DOCS_SKIP_DYNAMIC_DETECTION=1 forces the static-only path (useful
2213
+ // for testing what a Linux CI runner would produce).
2214
+ const skipDynamicDetection = ['1', 'true'].includes(
2215
+ String(process.env.RPK_DOCS_SKIP_DYNAMIC_DETECTION || '').toLowerCase()
2216
+ )
2217
+ if (skipDynamicDetection) {
2218
+ console.log('\nDynamic platform detection disabled via RPK_DOCS_SKIP_DYNAMIC_DETECTION')
2219
+ }
2220
+ if (canBuildLinux && canBuildNative && os.platform() !== 'linux' && !skipDynamicDetection) {
2235
2221
  console.log('\nBuilding rpk on both Linux and Darwin for dynamic platform detection...')
2236
2222
 
2237
2223
  try {
@@ -2297,6 +2283,12 @@ async function handleRpkDocsGeneration(options = {}) {
2297
2283
  )
2298
2284
  }
2299
2285
 
2286
+ // Tripwire: an empty combined detection result while the source tree
2287
+ // demonstrably contains Linux-gated files means the scan is broken and
2288
+ // Linux-only pages are about to be published as cross-platform
2289
+ // (see redpanda-data/docs#1831).
2290
+ warnIfDetectionLooksBroken(sourcePath, linuxOnlyCommands)
2291
+
2300
2292
  // Step 4: Add platform markers based on detection
2301
2293
  // This includes both source scanning results and static fallback lists
2302
2294
  tree = addPlatformMarkersFromSource(tree, linuxOnlyCommands)
@@ -2413,7 +2405,7 @@ async function handleRpkDocsGeneration(options = {}) {
2413
2405
 
2414
2406
  // Update what's-new file if requested
2415
2407
  if (whatsNewPath) {
2416
- updateWhatsNewFile(diffData, whatsNewPath, rpkVersion, { linkable: makeLinkablePredicate(overridesData), hasSubcommands: makeSubcommandPredicate(tree) })
2408
+ updateWhatsNewFile(filterDiffForWhatsNew(diffData), whatsNewPath, rpkVersion, { linkable: makeLinkablePredicate(overridesData), hasSubcommands: makeSubcommandPredicate(tree) })
2417
2409
  }
2418
2410
  } else {
2419
2411
  console.warn(`Warning: Could not load previous version ${diffVersion} for diff`)
@@ -2827,6 +2819,7 @@ module.exports = {
2827
2819
  getPlatformDescription,
2828
2820
  getCurrentPlatform,
2829
2821
  updateOverridesWithIntroducedVersions,
2822
+ filterDiffForWhatsNew,
2830
2823
  computeDescriptionCoverage,
2831
2824
  updateWhatsNewFile,
2832
2825
  KNOWN_PLUGINS,
@@ -389,8 +389,20 @@ This section provides examples of how to use `{{commandPath}}`.
389
389
  |Command |Description
390
390
 
391
391
  {{#each subcommands}}
392
+ {{#if cloudOnly}}
393
+ ifdef::env-cloud[]
394
+ {{/if}}
395
+ {{#if selfHostedOnly}}
396
+ ifndef::env-cloud[]
397
+ {{/if}}
392
398
  |xref:{{xrefPath}}[`{{fullPath}}`]
393
399
  |{{{shortDesc}}}
400
+ {{#if cloudOnly}}
401
+ endif::[]
402
+ {{/if}}
403
+ {{#if selfHostedOnly}}
404
+ endif::[]
405
+ {{/if}}
394
406
 
395
407
  {{/each}}
396
408
  |===
@@ -465,8 +465,24 @@ function validatePlatforms(commandOverride, context) {
465
465
  function validateFlags(commandOverride, context) {
466
466
  const result = new ValidationResult()
467
467
 
468
+ // cloudOnly and selfHostedOnly are mutually exclusive: setting both wraps
469
+ // the row in an ifdef::env-cloud + ifndef::env-cloud pair that no build
470
+ // can satisfy, so the row silently vanishes from BOTH sites.
471
+ if (commandOverride.cloudOnly && commandOverride.selfHostedOnly) {
472
+ result.addError(
473
+ 'cloudOnly and selfHostedOnly are both set: the command row is unsatisfiable and disappears from every build. Set at most one.',
474
+ context
475
+ )
476
+ }
477
+
468
478
  if (commandOverride.flags) {
469
479
  for (const [flagName, flagOverride] of Object.entries(commandOverride.flags)) {
480
+ if (flagOverride.cloudOnly && flagOverride.selfHostedOnly) {
481
+ result.addError(
482
+ `cloudOnly and selfHostedOnly are both set on flag "${flagName}": the flag row is unsatisfiable and disappears from every build. Set at most one.`,
483
+ `${context}.flags.${flagName}`
484
+ )
485
+ }
470
486
  // Validate flag name format
471
487
  if (!flagName.match(/^[a-zA-Z][-a-zA-Z0-9]*$/)) {
472
488
  result.addWarning(