@redpanda-data/docs-extensions-and-macros 5.1.1 → 5.1.3

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.
@@ -104,7 +104,11 @@ install_buf() {
104
104
  # Download and install buf for Linux
105
105
  BIN="/usr/local/bin/buf"
106
106
  VERSION="1.28.1"
107
- curl -sSL "https://github.com/bufbuild/buf/releases/download/v${VERSION}/buf-$(uname -s)-$(uname -m)" -o "${BIN}" || { log_error "Failed to download buf"; exit 1; }
107
+ # Same download hardening as install_rpk: fail on HTTP errors and
108
+ # retry transient GitHub/CDN failures with exponential backoff.
109
+ curl -fsSL --retry 5 --retry-all-errors \
110
+ --connect-timeout 30 --max-time 300 --retry-max-time 600 \
111
+ "https://github.com/bufbuild/buf/releases/download/v${VERSION}/buf-$(uname -s)-$(uname -m)" -o "${BIN}" || { log_error "Failed to download buf"; rm -f "${BIN}" 2>/dev/null; exit 1; }
108
112
  chmod +x "${BIN}" || { log_error "Failed to make buf executable"; exit 1; }
109
113
  ;;
110
114
  Darwin)
@@ -464,8 +468,15 @@ install_rpk() {
464
468
 
465
469
  log_info "Detected ${os_name} ${arch_name}, downloading ${rpk_filename}..."
466
470
 
467
- # Try to download and install rpk
468
- if curl -LO "$rpk_url"; then
471
+ # Try to download and install rpk.
472
+ # Use -f so HTTP errors (e.g. 403/404/429/503) fail instead of writing an
473
+ # error page into the zip, and retry with curl's default exponential
474
+ # backoff (1s, 2s, 4s, ...) to ride out transient GitHub/CDN and network
475
+ # errors, including rate limits that need time to clear. --retry-max-time
476
+ # caps the whole retry window so CI fails fast on persistent errors.
477
+ if curl -fL --retry 5 --retry-all-errors \
478
+ --connect-timeout 30 --max-time 300 --retry-max-time 600 \
479
+ -o "$rpk_filename" "$rpk_url"; then
469
480
  if unzip "$rpk_filename" 2>/dev/null; then
470
481
  mkdir -p ~/.local/bin
471
482
  if mv rpk ~/.local/bin/ 2>/dev/null; then
@@ -505,6 +516,8 @@ install_rpk() {
505
516
  fi
506
517
  else
507
518
  log_warn "Failed to download $rpk_url"
519
+ # A mid-transfer failure can leave a partial file behind even with -f.
520
+ rm -f "$rpk_filename" 2>/dev/null
508
521
  log_warn "Please install rpk manually:"
509
522
  log_warn "https://docs.redpanda.com/current/get-started/rpk-install/"
510
523
  return 1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redpanda-data/docs-extensions-and-macros",
3
- "version": "5.1.1",
3
+ "version": "5.1.3",
4
4
  "description": "Antora extensions and macros developed for Redpanda documentation.",
5
5
  "keywords": [
6
6
  "antora",
@@ -235,7 +235,7 @@ function generateMultiVersionPRSummary(masterDiff, binaryAnalysis = null, drafte
235
235
  else if (isSelfHostedOnly) platformIndicator = ' 🖥️';
236
236
  if (isCgoOnly) platformIndicator += ' 🔧';
237
237
 
238
- lines.push(`##### \`${comp.name}\` (${comp.type}, ${comp.status})${platformIndicator}`);
238
+ lines.push(`##### \`${comp.name}\` (${comp.status ? `${comp.type}, ${comp.status}` : comp.type})${platformIndicator}`);
239
239
 
240
240
  // Add description (truncated to 2 sentences)
241
241
  if (comp.description) {
@@ -982,7 +982,7 @@ function generatePRSummary(diffData, binaryAnalysis = null, draftedConnectors =
982
982
  lines.push('**☁️ Cloud Supported:**');
983
983
  lines.push('');
984
984
  cloudSupportedNew.forEach(c => {
985
- lines.push(`- **${c.name}** (${c.type}, ${c.status})`);
985
+ lines.push(`- **${c.name}** (${c.status ? `${c.type}, ${c.status}` : c.type})`);
986
986
  const desc = c.summary || c.description;
987
987
  if (desc) {
988
988
  const shortDesc = truncateToSentence(desc, 2);
@@ -996,7 +996,7 @@ function generatePRSummary(diffData, binaryAnalysis = null, draftedConnectors =
996
996
  lines.push('**Self-Hosted Only:**');
997
997
  lines.push('');
998
998
  selfHostedOnlyNew.forEach(c => {
999
- lines.push(`- **${c.name}** (${c.type}, ${c.status})`);
999
+ lines.push(`- **${c.name}** (${c.status ? `${c.type}, ${c.status}` : c.type})`);
1000
1000
  const desc = c.summary || c.description;
1001
1001
  if (desc) {
1002
1002
  const shortDesc = truncateToSentence(desc, 2);
@@ -1008,7 +1008,7 @@ function generatePRSummary(diffData, binaryAnalysis = null, draftedConnectors =
1008
1008
  } else {
1009
1009
  // No cloud support info, just list all
1010
1010
  diffData.details.newComponents.forEach(c => {
1011
- lines.push(`- **${c.name}** (${c.type}, ${c.status})`);
1011
+ lines.push(`- **${c.name}** (${c.status ? `${c.type}, ${c.status}` : c.type})`);
1012
1012
  const desc = c.summary || c.description;
1013
1013
  if (desc) {
1014
1014
  const shortDesc = truncateToSentence(desc, 2);
@@ -1247,7 +1247,7 @@ function generatePRSummary(diffData, binaryAnalysis = null, draftedConnectors =
1247
1247
  Object.entries(gapsByType).forEach(([type, connectors]) => {
1248
1248
  lines.push(`**${type}:**`);
1249
1249
  connectors.forEach(c => {
1250
- lines.push(`- ${c.name} (${c.status})`);
1250
+ lines.push(`- ${c.name}${c.status ? ` (${c.status})` : ''}`);
1251
1251
  });
1252
1252
  lines.push('');
1253
1253
  });
@@ -21,7 +21,7 @@ function generateConnectorDiffJson(oldIndex, newIndex, opts = {}) {
21
21
  return {
22
22
  name,
23
23
  type,
24
- status: raw.status || raw.type || '',
24
+ status: raw.status || '',
25
25
  version: raw.version || raw.introducedInVersion || '',
26
26
  description: raw.description || '',
27
27
  requiresCgo: metadata.requiresCgo || false,
@@ -39,7 +39,7 @@ function generateConnectorDiffJson(oldIndex, newIndex, opts = {}) {
39
39
  return {
40
40
  name,
41
41
  type,
42
- status: raw.status || raw.type || '',
42
+ status: raw.status || '',
43
43
  version: raw.version || raw.introducedInVersion || '',
44
44
  description: raw.description || '',
45
45
  requiresCgo: metadata.requiresCgo || false,
@@ -101,7 +101,7 @@ function generateConnectorDiffJson(oldIndex, newIndex, opts = {}) {
101
101
  deprecatedComponents.push({
102
102
  name,
103
103
  type,
104
- status: raw.status || raw.type || '',
104
+ status: raw.status || '',
105
105
  version: raw.version || raw.introducedInVersion || '',
106
106
  description: raw.description || ''
107
107
  });
@@ -561,7 +561,7 @@ function printDeltaReport(oldIndex, newIndex) {
561
561
  newComponentKeys.forEach(key => {
562
562
  const [type, name] = key.split(':');
563
563
  const raw = newMap[key].raw;
564
- const status = raw.status || raw.type || '';
564
+ const status = raw.status || '';
565
565
  const version = raw.version || raw.introducedInVersion || '';
566
566
  console.log(
567
567
  ` • ${type}/${name}${
@@ -30,6 +30,8 @@ function capToTwoSentences (description) {
30
30
 
31
31
  const abbreviations = [
32
32
  /https?:\/\/[^\s]+?(?=[.!?](?:\s|$)|\s|$)/gi, // Protect URLs from being split by sentence detection (non-greedy, preserve trailing punctuation)
33
+ /\bxref:[^\s[\]]+\[[^\]]*\]/g, // Protect Antora xref macros (e.g. xref:components:processors/try.adoc[`try`]) whose targets contain periods
34
+ /\b[\w/-]+\.(?:adoc|md|ya?ml|json|html)\b/gi, // Protect bare doc/config filenames (try.adoc, config.yaml) from splitting
33
35
  /\bv\d+\.\d+(?:\.\d+)?/gi,
34
36
  /\d+\.\d+/g,
35
37
  /\be\.g\./gi,
@@ -64,6 +66,16 @@ function capToTwoSentences (description) {
64
66
  const sentenceRegex = /[^.!?]+[.!?]+(?:\s|$)/g
65
67
  const sentences = normalized.match(sentenceRegex)
66
68
 
69
+ // If an unprotected mid-token period prevented the leading text from
70
+ // forming a complete match, merge that dropped prefix into the first
71
+ // sentence rather than silently discarding it.
72
+ if (sentences && sentences.length > 0) {
73
+ const firstIndex = normalized.indexOf(sentences[0])
74
+ if (firstIndex > 0) {
75
+ sentences[0] = normalized.slice(0, firstIndex) + sentences[0]
76
+ }
77
+ }
78
+
67
79
  if (!sentences || sentences.length === 0) {
68
80
  let result = normalized
69
81
  placeholders.forEach(({ placeholder, original }) => {
@@ -135,7 +147,7 @@ function augmentConnectorData (connectorData, binaryAnalysis) {
135
147
  let addedCloudOnlyCount = 0
136
148
 
137
149
  const connectorTypes = ['inputs', 'outputs', 'processors', 'caches', 'rate_limits',
138
- 'buffers', 'metrics', 'scanners', 'tracers']
150
+ 'buffers', 'metrics', 'scanners', 'tracers', 'config']
139
151
 
140
152
  for (const type of connectorTypes) {
141
153
  if (!Array.isArray(augmentedData[type])) {
@@ -195,6 +207,47 @@ function augmentConnectorData (connectorData, binaryAnalysis) {
195
207
  return { augmentedData, augmentedCount, addedCgoCount, addedCloudOnlyCount }
196
208
  }
197
209
 
210
+ /**
211
+ * Build a pure-OSS snapshot from a (possibly augmented) connector index, for use
212
+ * as the baseline in binary analysis. Removes augmentation-only entries (the
213
+ * cloud-only and cgo-only connectors that augmentConnectorData() adds and persists
214
+ * back into the data file) and strips platform metadata so the snapshot reflects
215
+ * only what the standard OSS rpk build reports.
216
+ *
217
+ * @param {Object} connectorIndex - Connector index object with arrays per type
218
+ * @returns {Object} Deep-cloned clean copy safe to diff as pure OSS data
219
+ */
220
+ function buildCleanOssData (connectorIndex) {
221
+ const cleanData = JSON.parse(JSON.stringify(connectorIndex))
222
+ const connectorTypes = ['inputs', 'outputs', 'processors', 'caches', 'rate_limits',
223
+ 'buffers', 'metrics', 'scanners', 'tracers']
224
+
225
+ for (const type of connectorTypes) {
226
+ if (Array.isArray(cleanData[type])) {
227
+ // Keep only connectors from OSS rpk (have config/fields)
228
+ // Remove augmentation-only connectors (added by previous binary analysis)
229
+ cleanData[type] = cleanData[type].filter(c => c.config || c.fields)
230
+
231
+ // Remove platform metadata from remaining connectors
232
+ stripPlatformMetadata(cleanData[type])
233
+ }
234
+ }
235
+
236
+ // Config components have no config/fields wrappers, so the filter above
237
+ // can't distinguish OSS entries from augmentation-only ones. augmentConnectorData()
238
+ // adds cloud-only and cgo-only config entries and persists them back to the data
239
+ // file, so on subsequent runs they would otherwise be copied into the clean OSS
240
+ // snapshot and pollute binary analysis. Drop those augmentation-only entries by
241
+ // their platform markers (genuine OSS config entries never carry cloudOnly, and
242
+ // a standard-build component is never cgo-only) before stripping metadata.
243
+ if (Array.isArray(cleanData.config)) {
244
+ cleanData.config = cleanData.config.filter(c => c.cloudOnly !== true && c.requiresCgo !== true)
245
+ stripPlatformMetadata(cleanData.config)
246
+ }
247
+
248
+ return cleanData
249
+ }
250
+
198
251
  /**
199
252
  * Update whats-new.adoc with new release information
200
253
  * @param {Object} params - Parameters
@@ -1097,20 +1150,7 @@ async function handleRpcnConnectorDocs (options) {
1097
1150
  const cleanOssDataPath = path.join(dataDir, `._connect-${newVersion}-clean.json`)
1098
1151
 
1099
1152
  // Create clean version by removing augmented connectors
1100
- const cleanData = JSON.parse(JSON.stringify(newIndex))
1101
- const connectorTypes = ['inputs', 'outputs', 'processors', 'caches', 'rate_limits',
1102
- 'buffers', 'metrics', 'scanners', 'tracers']
1103
-
1104
- for (const type of connectorTypes) {
1105
- if (Array.isArray(cleanData[type])) {
1106
- // Keep only connectors from OSS rpk (have config/fields)
1107
- // Remove augmentation-only connectors (added by previous binary analysis)
1108
- cleanData[type] = cleanData[type].filter(c => c.config || c.fields)
1109
-
1110
- // Remove platform metadata from remaining connectors
1111
- stripPlatformMetadata(cleanData[type])
1112
- }
1113
- }
1153
+ const cleanData = buildCleanOssData(newIndex)
1114
1154
 
1115
1155
  fs.writeFileSync(cleanOssDataPath, JSON.stringify(cleanData, null, 2), 'utf8')
1116
1156
 
@@ -1357,7 +1397,7 @@ async function handleRpcnConnectorDocs (options) {
1357
1397
  // Strip CGO/cloud-only connectors and metadata from old data
1358
1398
  oldIndexForDiff = JSON.parse(JSON.stringify(oldIndex))
1359
1399
  const connectorTypes = ['inputs', 'outputs', 'processors', 'caches', 'rate_limits',
1360
- 'buffers', 'metrics', 'scanners', 'tracers']
1400
+ 'buffers', 'metrics', 'scanners', 'tracers', 'config']
1361
1401
 
1362
1402
  let totalStripped = 0
1363
1403
  for (const type of connectorTypes) {
@@ -1970,5 +2010,7 @@ async function handleRpcnConnectorDocs (options) {
1970
2010
  module.exports = {
1971
2011
  handleRpcnConnectorDocs,
1972
2012
  updateWhatsNew,
1973
- capToTwoSentences
2013
+ capToTwoSentences,
2014
+ augmentConnectorData,
2015
+ buildCleanOssData
1974
2016
  }
@@ -274,6 +274,23 @@ function shouldExcludeCommand(overrides, commandPath) {
274
274
  return commandOverride?.exclude === true
275
275
  }
276
276
 
277
+ /**
278
+ * Check if a command should be written to the partials directory (cloudSecretDir).
279
+ * Walks up the command path so setting asPartial: true on "rpk ai" applies to all children.
280
+ * @param {Object} overrides - Resolved overrides object
281
+ * @param {string} commandPath - Full command path (e.g., "rpk ai agent list")
282
+ * @returns {boolean}
283
+ */
284
+ function shouldUsePartialDir(overrides, commandPath) {
285
+ if (!overrides?.commands) return false
286
+ const parts = commandPath.split(' ')
287
+ for (let i = parts.length; i >= 1; i--) {
288
+ const ancestor = parts.slice(0, i).join(' ')
289
+ if (overrides.commands[ancestor]?.asPartial === true) return true
290
+ }
291
+ return false
292
+ }
293
+
277
294
  /**
278
295
  * Get command metadata from overrides
279
296
  * @param {Object} overrides - Overrides object (resolved)
@@ -390,13 +407,21 @@ function processContentArray(contentArray, context = '') {
390
407
  }
391
408
 
392
409
  switch (type) {
393
- case 'section':
410
+ case 'section': {
411
+ // Escape {word} (no hyphens) in table cell lines — CLI format specifiers like {hex}
412
+ // are not AsciiDoc attribute refs and must be escaped to prevent substitution.
413
+ const escapedContent = typeof content === 'string'
414
+ ? content.replace(/^\|(.*)$/gm, (m, cell) =>
415
+ /\{[a-z_][a-z0-9_]*\}/.test(cell)
416
+ ? '|' + cell.replace(/\{([a-z_][a-z0-9_]*)\}/g, '\\{$1}')
417
+ : m)
418
+ : content
394
419
  // Pass through all section properties (subsections, parent, headingLevel, exclude)
395
420
  result.sections[position].push({
396
421
  type: 'section',
397
422
  id,
398
423
  title,
399
- content,
424
+ content: escapedContent,
400
425
  subsections: item.subsections,
401
426
  parent: item.parent,
402
427
  headingLevel: item.headingLevel,
@@ -408,6 +433,7 @@ function processContentArray(contentArray, context = '') {
408
433
  console.warn(` Use seeAlso key instead`)
409
434
  }
410
435
  break
436
+ }
411
437
 
412
438
  case 'example':
413
439
  // Structured single example
@@ -1184,7 +1210,10 @@ function convertIndentedTablesToAsciiDoc(text, options = {}) {
1184
1210
  // Remove trailing colon from value (e.g., "Organization:" -> "Organization")
1185
1211
  const cleanValue = value.replace(/:$/, '')
1186
1212
  const desc = descParts.join(' ')
1187
- result.push(`|${cleanValue} |${desc}`)
1213
+ // Escape {word} (no hyphens) in table cells — CLI format specifiers like {hex}, {json}
1214
+ // are not AsciiDoc attribute refs and must be escaped to prevent substitution.
1215
+ const escapeAttrs = s => s.replace(/\{([a-z_][a-z0-9_]*)\}/g, '\\{$1}')
1216
+ result.push(`|${escapeAttrs(cleanValue)} |${escapeAttrs(desc)}`)
1188
1217
  }
1189
1218
  result.push('|===')
1190
1219
  result.push('')
@@ -1203,6 +1232,28 @@ function convertIndentedTablesToAsciiDoc(text, options = {}) {
1203
1232
  return result.join('\n')
1204
1233
  }
1205
1234
 
1235
+ /**
1236
+ * Apply only the text replacements from textTransformations to a raw string.
1237
+ * Used for examples content where we want string substitutions (e.g. rpai → rpk ai)
1238
+ * but NOT AsciiDoc structural formatting or inlineCode wrapping.
1239
+ * @param {string} text
1240
+ * @param {Object|null} customTransformations
1241
+ * @returns {string}
1242
+ */
1243
+ function applyTextTransformations(text, customTransformations) {
1244
+ if (!text || !customTransformations?.replacements) return text
1245
+ let result = text
1246
+ for (const rule of customTransformations.replacements) {
1247
+ try {
1248
+ const flags = rule.flags || 'g'
1249
+ result = result.replace(new RegExp(rule.pattern, flags), rule.replacement)
1250
+ } catch (err) {
1251
+ console.warn(`⚠ Invalid replacement pattern: ${rule.pattern}`)
1252
+ }
1253
+ }
1254
+ return result
1255
+ }
1256
+
1206
1257
  /**
1207
1258
  * Format description by adding backticks around flags and code
1208
1259
  * @param {string} desc - Description text
@@ -1289,10 +1340,18 @@ function formatDescription(desc, customTransformations = null, options = {}) {
1289
1340
  })
1290
1341
 
1291
1342
  // Protect inline code (backticked content)
1343
+ // Escape {word} (no hyphens) inside backtick spans — these are CLI format specifiers or
1344
+ // path placeholders, not AsciiDoc attribute refs (which use hyphens like {full-version}).
1345
+ // AsciiDoc single-backtick spans still apply attribute substitution, so {hex}/{namespace}/etc.
1346
+ // would expand to empty string without this escaping.
1292
1347
  const inlineCode = []
1293
1348
  preProcessed = preProcessed.replace(/`[^`]+`/g, (match) => {
1294
1349
  const placeholder = `__INLINE_CODE_${inlineCode.length}__`
1295
- inlineCode.push(match)
1350
+ // Skip `+...+` passthrough spans — they already prevent attribute substitution
1351
+ const escaped = (match.startsWith('`+') && match.endsWith('+`'))
1352
+ ? match
1353
+ : match.replace(/\{([a-z_][a-z0-9_]*)\}/g, '\\{$1}')
1354
+ inlineCode.push(escaped)
1296
1355
  return placeholder
1297
1356
  })
1298
1357
 
@@ -1536,28 +1595,46 @@ function formatDescription(desc, customTransformations = null, options = {}) {
1536
1595
  // Restore early code blocks FIRST (from indented command/YAML detection)
1537
1596
  // This must happen before inline code restoration so that placeholders
1538
1597
  // inside the early code blocks (like __INLINE_CODE_X__) get resolved
1598
+ // Use function replacements to prevent $ special-pattern interpretation (e.g. `$` → before-match)
1539
1599
  earlyCodeBlocks.forEach((block, i) => {
1540
- result = result.replace(`__EARLY_CODE_BLOCK_${i}__`, block)
1600
+ result = result.replace(`__EARLY_CODE_BLOCK_${i}__`, () => block)
1541
1601
  })
1542
1602
 
1543
1603
  // Restore inline code
1544
1604
  inlineCode.forEach((code, i) => {
1545
- result = result.replace(`__INLINE_CODE_${i}__`, code)
1605
+ result = result.replace(`__INLINE_CODE_${i}__`, () => code)
1546
1606
  })
1547
1607
 
1548
1608
  // Restore xrefs
1549
1609
  xrefs.forEach((xref, i) => {
1550
- result = result.replace(`__XREF_${i}__`, xref)
1610
+ result = result.replace(`__XREF_${i}__`, () => xref)
1551
1611
  })
1552
1612
 
1553
1613
  // Restore code blocks
1554
1614
  codeBlocks.forEach((block, i) => {
1555
- result = result.replace(`__CODE_BLOCK_${i}__`, block)
1615
+ result = result.replace(`__CODE_BLOCK_${i}__`, () => block)
1556
1616
  })
1557
1617
 
1558
1618
  // Clean up double backticks (moved AFTER restoration to catch nested backticks)
1559
1619
  result = result.replace(/``+/g, '`')
1560
1620
 
1621
+ // Final pass: escape {word} (no hyphens) inside any backtick spans that were created
1622
+ // dynamically during processing (after the initial inline-code protection step).
1623
+ // Skip `+...+` passthrough spans — they already prevent attribute substitution.
1624
+ // Use negative lookbehind (?<!\\) to avoid double-escaping already-escaped \{word}.
1625
+ result = result.replace(/`[^`]+`/g, match => {
1626
+ if (match.startsWith('`+') && match.endsWith('+`')) return match
1627
+ return match.replace(/(?<!\\)\{([a-z_][a-z0-9_]*)\}/g, '\\{$1}')
1628
+ })
1629
+
1630
+ // Escape {word} (no hyphens) in AsciiDoc table cell lines that come from pre-formatted
1631
+ // AsciiDoc in CLI descriptions (e.g. format specifier tables with {hex}, {json}).
1632
+ // These never pass through backtick-span protection, so they need a separate pass.
1633
+ result = result.replace(/^\|(.*)$/gm, (match, cellContent) => {
1634
+ if (!/(?<!\\)\{[a-z_][a-z0-9_]*\}/.test(cellContent)) return match
1635
+ return '|' + cellContent.replace(/(?<!\\)\{([a-z_][a-z0-9_]*)\}/g, '\\{$1}')
1636
+ })
1637
+
1561
1638
  // Remove trailing period after backticked commands on their own line
1562
1639
  // e.g., "`rpk cluster status`." → "`rpk cluster status`"
1563
1640
  // Only consume trailing spaces, not newlines (to preserve paragraph breaks)
@@ -1581,40 +1658,48 @@ function formatUsage(usage) {
1581
1658
  }
1582
1659
 
1583
1660
  /**
1584
- * Format EXAMPLES section - convert indented commands and inline code to AsciiDoc code blocks
1661
+ * Format EXAMPLES section - convert indented commands and inline code to AsciiDoc code blocks.
1662
+ *
1663
+ * Handles the standard Go CLI example format where each group is separated by a blank line,
1664
+ * comment lines (# ...) describe the example, and command lines follow:
1665
+ *
1666
+ * # Description of the example
1667
+ * rpk some command --flag
1668
+ *
1669
+ * # Another example
1670
+ * rpk other command
1671
+ * rpk other command --variant
1672
+ *
1673
+ * Comments become plain text descriptions; consecutive command lines share one code block.
1674
+ *
1585
1675
  * @param {string} text - Examples text with indented commands or inline code
1586
1676
  * @returns {string} Formatted examples with code blocks
1587
1677
  */
1588
1678
  function formatExamples(text) {
1589
1679
  if (!text) return text
1590
1680
 
1591
- // Convert both indented commands and inline backticked commands to AsciiDoc code blocks
1592
- // Pattern 1: description line, then indented command (starts with 2+ spaces)
1593
- // Pattern 2: description line ending with colon/period, followed by `rpk command`
1594
1681
  const lines = text.split('\n')
1595
1682
  const result = []
1596
1683
  let i = 0
1597
1684
 
1598
1685
  while (i < lines.length) {
1599
1686
  const line = lines[i]
1687
+ const isIndented = /^\s{2,}\S/.test(line)
1600
1688
 
1601
- // Check if next line is an indented command (2+ spaces followed by non-space)
1602
- if (i + 1 < lines.length && /^\s{2,}\S/.test(lines[i + 1])) {
1603
- // Current line is description, next line is command
1604
- result.push(line)
1605
- result.push('')
1606
- result.push('[,bash]')
1607
- result.push('----')
1608
- result.push(lines[i + 1].trim())
1609
- result.push('----')
1610
- i += 2
1611
- } else if (/^\s{2,}\S/.test(line)) {
1612
- // Standalone indented command without preceding description
1689
+ if (isIndented && line.trim().startsWith('#')) {
1690
+ // Comment line — render as a description paragraph outside the code block
1691
+ result.push(line.trim().replace(/^#\s*/, ''))
1692
+ i++
1693
+ } else if (isIndented) {
1694
+ // Command line — collect all consecutive command lines into one code block
1695
+ // (stops at a blank line or a comment line)
1613
1696
  result.push('[,bash]')
1614
1697
  result.push('----')
1615
- result.push(line.trim())
1698
+ while (i < lines.length && /^\s{2,}\S/.test(lines[i]) && !lines[i].trim().startsWith('#')) {
1699
+ result.push(lines[i].trim())
1700
+ i++
1701
+ }
1616
1702
  result.push('----')
1617
- i++
1618
1703
  } else if (/^\s*`rpk .+`\s*$/.test(line)) {
1619
1704
  // Inline backticked command on its own line - convert to code block
1620
1705
  const command = line.trim().replace(/^`|`$/g, '')
@@ -1637,7 +1722,7 @@ function formatExamples(text) {
1637
1722
  result.push('----')
1638
1723
  i += 2
1639
1724
  } else {
1640
- // Regular line (description, blank line, etc.)
1725
+ // Regular line (blank line, non-indented description text, etc.)
1641
1726
  result.push(line)
1642
1727
  i++
1643
1728
  }
@@ -1997,6 +2082,91 @@ function findTopLevelWithSubcommands(tree) {
1997
2082
  return result
1998
2083
  }
1999
2084
 
2085
+ /**
2086
+ * Static entries that always appear at the top of the rpk nav section,
2087
+ * before the auto-generated command entries. These are hand-written pages
2088
+ * that are not part of the CLI command tree. The root `rpk` command is not
2089
+ * listed here: it is represented by the section's hand-written index.adoc
2090
+ * landing page (the section header), so it is skipped during generation.
2091
+ */
2092
+ const STATIC_RPK_NAV_ENTRIES = [
2093
+ '*** xref:reference:rpk/rpk-commands.adoc[]',
2094
+ '*** xref:reference:rpk/rpk-x-options.adoc[rpk -X]',
2095
+ ]
2096
+
2097
+ /**
2098
+ * Update the rpk section of nav.adoc with the current command tree.
2099
+ * Replaces auto-generated entries while preserving the section header and
2100
+ * the static hand-written entries defined in STATIC_RPK_NAV_ENTRIES.
2101
+ * @param {string} navFile - Absolute path to nav.adoc
2102
+ * @param {Array} commands - Flat command list from flattenCommands()
2103
+ * @param {Object} resolvedOverrides - Resolved overrides
2104
+ * @param {Set} topLevelWithSubcommands - Set of top-level command names with subcommands
2105
+ * @returns {Object} { navUpdated, navEntriesGenerated }
2106
+ */
2107
+ function updateNavFile(navFile, commands, resolvedOverrides, topLevelWithSubcommands) {
2108
+ if (!fs.existsSync(navFile)) {
2109
+ console.warn(`Warning: nav file not found, skipping nav update: ${navFile}`)
2110
+ return { navUpdated: false }
2111
+ }
2112
+
2113
+ const content = fs.readFileSync(navFile, 'utf8')
2114
+ const lines = content.split('\n')
2115
+
2116
+ // Locate the rpk section header
2117
+ const sectionStart = lines.findIndex(l => /xref:reference:rpk\/index\.adoc/.test(l))
2118
+ if (sectionStart === -1) {
2119
+ console.warn('Warning: rpk section not found in nav file, skipping nav update')
2120
+ return { navUpdated: false }
2121
+ }
2122
+
2123
+ // Find where the section ends — next line with the same or fewer asterisks as the header
2124
+ const headerStars = (lines[sectionStart].match(/^\*+/) || [''])[0].length
2125
+ let sectionEnd = lines.length
2126
+ for (let i = sectionStart + 1; i < lines.length; i++) {
2127
+ const m = lines[i].match(/^\*+/)
2128
+ if (m && m[0].length <= headerStars && lines[i].trim() !== '') {
2129
+ sectionEnd = i
2130
+ break
2131
+ }
2132
+ }
2133
+
2134
+ // Generate nav entries from the command tree (DFS order from flattenCommands)
2135
+ const newEntries = []
2136
+ for (const { path: commandPath } of commands) {
2137
+ if (commandPath === 'rpk') continue
2138
+ if (shouldExcludeCommand(resolvedOverrides, commandPath)) continue
2139
+ if (shouldUsePartialDir(resolvedOverrides, commandPath)) continue
2140
+ if (commandPath.startsWith('rpk cloud')) continue
2141
+ if (commandPath.startsWith('rpk security secret')) continue
2142
+
2143
+ const parts = commandPath.split(' ')
2144
+ const stars = '*'.repeat(parts.length + 1)
2145
+ const { subdir, fileName } = getOutputPath(commandPath, topLevelWithSubcommands)
2146
+ const xrefPath = subdir
2147
+ ? `reference:rpk/${subdir}/${fileName}`
2148
+ : `reference:rpk/${fileName}`
2149
+
2150
+ newEntries.push(`${stars} xref:${xrefPath}[]`)
2151
+ }
2152
+
2153
+ // Rebuild: header + static entries + generated entries
2154
+ const newSection = [
2155
+ lines[sectionStart],
2156
+ ...STATIC_RPK_NAV_ENTRIES,
2157
+ ...newEntries,
2158
+ ]
2159
+
2160
+ const newLines = [
2161
+ ...lines.slice(0, sectionStart),
2162
+ ...newSection,
2163
+ ...lines.slice(sectionEnd),
2164
+ ]
2165
+
2166
+ fs.writeFileSync(navFile, newLines.join('\n'), 'utf8')
2167
+ return { navUpdated: true, navEntriesGenerated: newEntries.length }
2168
+ }
2169
+
2000
2170
  /**
2001
2171
  * Generate AsciiDoc documentation for all rpk commands
2002
2172
  * @param {Object} options - Generation options
@@ -2011,7 +2181,8 @@ async function generateRpkDocs(options = {}) {
2011
2181
  rpkVersion,
2012
2182
  pluginVersions = {},
2013
2183
  draftMissing = false,
2014
- flatOutput = false // If true, output all files flat (legacy behavior)
2184
+ flatOutput = false, // If true, output all files flat (legacy behavior)
2185
+ navFile // Optional: path to nav.adoc for automatic nav updates
2015
2186
  } = options
2016
2187
 
2017
2188
  // Register partials
@@ -2058,8 +2229,18 @@ async function generateRpkDocs(options = {}) {
2058
2229
  let filesGenerated = 0
2059
2230
  let filesSkipped = 0
2060
2231
  let filesFailed = 0
2232
+ let filesDeleted = 0
2233
+ const writtenFiles = new Set()
2061
2234
 
2062
2235
  for (const { path: commandPath, command } of commands) {
2236
+ // Skip the root `rpk` command: its reference is the section's hand-written
2237
+ // index.adoc landing page, which generation must not overwrite. The nav
2238
+ // builder skips it for the same reason (see updateNavFile).
2239
+ if (commandPath === 'rpk') {
2240
+ filesSkipped++
2241
+ continue
2242
+ }
2243
+
2063
2244
  // Check if command should be excluded
2064
2245
  if (shouldExcludeCommand(resolvedOverrides, commandPath)) {
2065
2246
  filesSkipped++
@@ -2087,7 +2268,8 @@ async function generateRpkDocs(options = {}) {
2087
2268
  if (mergedCommand.excludeExamples && mergedCommand.excludeExamples.length > 0) {
2088
2269
  examplesContent = filterExamples(sectionContent, mergedCommand.excludeExamples)
2089
2270
  }
2090
- // Format examples: convert indented commands to code blocks
2271
+ // Apply text transformations (e.g. rpai → rpk ai) then format
2272
+ examplesContent = applyTextTransformations(examplesContent, textTransformations)
2091
2273
  sections[sectionName] = formatExamples(examplesContent)
2092
2274
  } else {
2093
2275
  sections[sectionName] = formatDescription(sectionContent, textTransformations)
@@ -2097,11 +2279,15 @@ async function generateRpkDocs(options = {}) {
2097
2279
  // Add examples from rpk source if not already in sections
2098
2280
  // rpk --print-tree provides examples in a separate field
2099
2281
  if (command.examples && !sections.EXAMPLES) {
2100
- let examplesContent = command.examples.trim()
2282
+ // Strip surrounding blank lines only — preserve per-line indentation so formatExamples
2283
+ // can detect indented command lines vs plain description text
2284
+ let examplesContent = command.examples.replace(/^\n+/, '').replace(/\n+$/, '')
2101
2285
  // Apply excludeExamples filter BEFORE formatting
2102
2286
  if (mergedCommand.excludeExamples && mergedCommand.excludeExamples.length > 0) {
2103
2287
  examplesContent = filterExamples(examplesContent, mergedCommand.excludeExamples)
2104
2288
  }
2289
+ // Apply text transformations (e.g. rpai → rpk ai) then format
2290
+ examplesContent = applyTextTransformations(examplesContent, textTransformations)
2105
2291
  sections.EXAMPLES = formatExamples(examplesContent)
2106
2292
  }
2107
2293
 
@@ -2172,11 +2358,12 @@ async function generateRpkDocs(options = {}) {
2172
2358
  }
2173
2359
 
2174
2360
  // Build subcommands with correct xref paths
2175
- // Filter out excluded subcommands so they don't appear in the parent's list
2361
+ // Filter out excluded and asPartial subcommands — excluded have no file,
2362
+ // asPartial ones live in the partials directory with no linkable xref.
2176
2363
  const subcommands = (command.commands || [])
2177
2364
  .filter(sub => {
2178
2365
  const subPath = `${commandPath} ${sub.name}`
2179
- return !shouldExcludeCommand(resolvedOverrides, subPath)
2366
+ return !shouldExcludeCommand(resolvedOverrides, subPath) && !shouldUsePartialDir(resolvedOverrides, subPath)
2180
2367
  })
2181
2368
  .map(sub => {
2182
2369
  const subPath = `${commandPath} ${sub.name}`
@@ -2357,7 +2544,9 @@ async function generateRpkDocs(options = {}) {
2357
2544
  // Check if this command should go to cloudSecretDir
2358
2545
  const isCloudCommand = commandPath.startsWith('rpk cloud')
2359
2546
  const isSecuritySecretCommand = commandPath.startsWith('rpk security secret')
2360
- const useCloudSecretDir = cloudSecretDir && (isCloudCommand || isSecuritySecretCommand)
2547
+ const useCloudSecretDir = cloudSecretDir && (
2548
+ isCloudCommand || isSecuritySecretCommand || shouldUsePartialDir(resolvedOverrides, commandPath)
2549
+ )
2361
2550
  const effectiveOutputDir = useCloudSecretDir ? cloudSecretDir : outputDir
2362
2551
 
2363
2552
  let filePath
@@ -2384,6 +2573,7 @@ async function generateRpkDocs(options = {}) {
2384
2573
 
2385
2574
  try {
2386
2575
  fs.writeFileSync(filePath, content, 'utf8')
2576
+ writtenFiles.add(filePath)
2387
2577
  filesGenerated++
2388
2578
 
2389
2579
  if (filesGenerated % 50 === 0) {
@@ -2395,12 +2585,71 @@ async function generateRpkDocs(options = {}) {
2395
2585
  }
2396
2586
  }
2397
2587
 
2588
+ // Delete stale .adoc files from removed/excluded commands, including whole
2589
+ // subtrees that no longer receive any writes this run.
2590
+ //
2591
+ // We only clean the "rpk-*" subdirectories (e.g. rpk-topic/, rpk-cluster/),
2592
+ // which are created and owned exclusively by generation. We deliberately do
2593
+ // NOT touch the root output directory: it holds hand-written pages alongside
2594
+ // generated ones (e.g. index.adoc, rpk-commands.adoc, rpk-x-options.adoc), and
2595
+ // those hand-written pages share the "rpk-*.adoc" naming, so there is no safe
2596
+ // way to distinguish a stale generated root page from a hand-written one. A
2597
+ // rare stale root page is preferable to deleting hand-written content.
2598
+ //
2599
+ // Deletions are further restricted to the generated "rpk.adoc"/"rpk-*.adoc"
2600
+ // pattern so a stray non-rpk file inside a subdir is never removed. This also
2601
+ // lets us clean the rpk-* subdirs of the shared partials directory
2602
+ // (cloudSecretDir) without touching hand-written partials at its root.
2603
+ const generatedFilePattern = /^rpk(-[^/\\]*)?\.adoc$/
2604
+
2605
+ const rpkSubdirs = (baseDir) => {
2606
+ if (!baseDir || !fs.existsSync(baseDir)) return []
2607
+ return fs.readdirSync(baseDir, { withFileTypes: true })
2608
+ .filter(entry => entry.isDirectory() && entry.name.startsWith('rpk-'))
2609
+ .map(entry => path.join(baseDir, entry.name))
2610
+ }
2611
+
2612
+ const scanDirs = new Set()
2613
+ for (const dir of rpkSubdirs(outputDir)) scanDirs.add(dir)
2614
+ for (const dir of rpkSubdirs(cloudSecretDir)) scanDirs.add(dir)
2615
+
2616
+ for (const dir of scanDirs) {
2617
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
2618
+ if (!entry.isFile() || !generatedFilePattern.test(entry.name)) continue
2619
+ const full = path.join(dir, entry.name)
2620
+ if (!writtenFiles.has(full)) {
2621
+ try {
2622
+ fs.unlinkSync(full)
2623
+ filesDeleted++
2624
+ } catch (err) {
2625
+ console.warn(`Warning: Failed to delete stale file "${full}": ${err.message}`)
2626
+ }
2627
+ }
2628
+ }
2629
+ // Remove the rpk-* subdirectory itself if it is now empty (removed subtree).
2630
+ if (fs.readdirSync(dir).length === 0) {
2631
+ try {
2632
+ fs.rmdirSync(dir)
2633
+ } catch (err) {
2634
+ console.warn(`Warning: Failed to remove empty directory "${dir}": ${err.message}`)
2635
+ }
2636
+ }
2637
+ }
2638
+
2639
+ // Update nav.adoc if a path was provided
2640
+ let navResult = { navUpdated: false }
2641
+ if (navFile) {
2642
+ navResult = updateNavFile(navFile, commands, resolvedOverrides, topLevelWithSubcommands)
2643
+ }
2644
+
2398
2645
  return {
2399
2646
  commandCount: commands.length,
2400
2647
  filesGenerated,
2401
2648
  filesSkipped,
2402
2649
  filesFailed,
2403
- subdirectoriesCreated: createdSubdirs.size
2650
+ filesDeleted,
2651
+ subdirectoriesCreated: createdSubdirs.size,
2652
+ ...navResult
2404
2653
  }
2405
2654
  }
2406
2655
 
@@ -2425,6 +2674,8 @@ module.exports = {
2425
2674
  loadTemplate,
2426
2675
  registerPartial,
2427
2676
  shouldExcludeCommand,
2677
+ shouldUsePartialDir,
2678
+ updateNavFile,
2428
2679
  getCommandMetadata,
2429
2680
  processContentArray,
2430
2681
  // Exported for testing
@@ -387,18 +387,27 @@ function fetchRpkTreeFromLinuxSource(sourcePath) {
387
387
 
388
388
  // Start a container with the Go image, mount source, build rpk, install plugins, then print-tree
389
389
  // Use a long-running container so we can run multiple commands
390
+ // Pin the Go image to the exact version required by go.mod to prevent
391
+ // "go.mod requires go >= X.Y.Z (running X.Y.Z-1)" build failures when
392
+ // golang:1 resolves to a patch release behind the requirement.
393
+ const requiredGoVersion = getRequiredGoVersion(absoluteSourcePath)
394
+ const goImage = requiredGoVersion ? `golang:${requiredGoVersion}` : 'golang:1'
395
+
390
396
  console.log('Starting build container...')
391
397
  const createResult = spawnSync('docker', [
392
398
  'run', '-d', '--rm',
393
399
  '-v', `${absoluteSourcePath}:/rpk-source:ro`,
394
400
  '-w', '/rpk-source',
395
- 'golang:1',
401
+ goImage,
396
402
  'sh', '-c', 'sleep 600' // Keep container alive for 10 minutes
397
403
  ], {
398
404
  encoding: 'utf8',
399
405
  timeout: 60000
400
406
  })
401
407
 
408
+ let activeResult = createResult
409
+ let activeImage = goImage
410
+
402
411
  if (createResult.status !== 0) {
403
412
  const stderr = createResult.stderr || ''
404
413
  if (stderr.includes('Cannot connect to the Docker daemon')) {
@@ -407,13 +416,40 @@ function fetchRpkTreeFromLinuxSource(sourcePath) {
407
416
  'Start Docker Desktop or the Docker service and try again.'
408
417
  )
409
418
  }
410
- throw new Error(
411
- `Failed to create build container: ${stderr}\n` +
412
- 'Make sure Docker is running and has sufficient resources.'
419
+
420
+ // If the pinned tag couldn't be pulled (not yet on Docker Hub for a new
421
+ // patch release), retry with golang:1 before giving up.
422
+ const isPullFailure = requiredGoVersion && (
423
+ stderr.includes('manifest unknown') ||
424
+ stderr.includes('pull access denied') ||
425
+ stderr.includes('not found') ||
426
+ stderr.includes('repository does not exist')
413
427
  )
428
+ if (isPullFailure) {
429
+ console.warn(`⚠ Could not pull ${goImage}: ${stderr.trim()}`)
430
+ console.log('Retrying with golang:1...')
431
+ activeImage = 'golang:1'
432
+ activeResult = spawnSync('docker', [
433
+ 'run', '-d', '--rm',
434
+ '-v', `${absoluteSourcePath}:/rpk-source:ro`,
435
+ '-w', '/rpk-source',
436
+ activeImage,
437
+ 'sh', '-c', 'sleep 600'
438
+ ], {
439
+ encoding: 'utf8',
440
+ timeout: 60000
441
+ })
442
+ }
443
+
444
+ if (activeResult.status !== 0) {
445
+ throw new Error(
446
+ `Failed to create build container: ${activeResult.stderr || stderr}\n` +
447
+ 'Make sure Docker is running and has sufficient resources.'
448
+ )
449
+ }
414
450
  }
415
451
 
416
- const containerId = createResult.stdout.trim()
452
+ const containerId = activeResult.stdout.trim()
417
453
  console.log(`Build container started: ${containerId.substring(0, 12)}`)
418
454
 
419
455
  try {
@@ -1041,6 +1077,9 @@ async function handleRpkDocsGeneration(options = {}) {
1041
1077
  }
1042
1078
  const finalCloudSecretDir = cloudSecretDir || defaultCloudSecretDir
1043
1079
 
1080
+ // nav.adoc lives at modules/ROOT/nav.adoc relative to the repo root
1081
+ const navFile = path.join(repoRoot, 'modules', 'ROOT', 'nav.adoc')
1082
+
1044
1083
  // Determine which version to document
1045
1084
  const version = effectiveRef || 'dev'
1046
1085
  console.log(`\nGenerating rpk documentation for version: ${version}`)
@@ -1088,12 +1127,19 @@ async function handleRpkDocsGeneration(options = {}) {
1088
1127
  rpkVersion,
1089
1128
  pluginVersions: {},
1090
1129
  draftMissing,
1091
- preservationsDir: preserveFrom
1130
+ preservationsDir: preserveFrom,
1131
+ navFile
1092
1132
  })
1093
1133
 
1094
1134
  console.log(`\nGeneration complete!`)
1095
1135
  console.log(` - Commands documented: ${result.commandCount}`)
1096
1136
  console.log(` - Files generated: ${result.filesGenerated}`)
1137
+ if (result.filesDeleted > 0) {
1138
+ console.log(` - Stale files deleted: ${result.filesDeleted}`)
1139
+ }
1140
+ if (result.navUpdated) {
1141
+ console.log(` - Nav entries updated: ${result.navEntriesGenerated}`)
1142
+ }
1097
1143
  console.log(` - Output directory: ${finalOutputDir}`)
1098
1144
 
1099
1145
  // Run validation on generated output
@@ -1387,12 +1433,19 @@ async function handleRpkDocsGeneration(options = {}) {
1387
1433
  rpkVersion,
1388
1434
  pluginVersions,
1389
1435
  draftMissing,
1390
- preservationsDir: preserveFrom
1436
+ preservationsDir: preserveFrom,
1437
+ navFile
1391
1438
  })
1392
1439
 
1393
1440
  console.log(`\nGeneration complete!`)
1394
1441
  console.log(` - Commands documented: ${result.commandCount}`)
1395
1442
  console.log(` - Files generated: ${result.filesGenerated}`)
1443
+ if (result.filesDeleted > 0) {
1444
+ console.log(` - Stale files deleted: ${result.filesDeleted}`)
1445
+ }
1446
+ if (result.navUpdated) {
1447
+ console.log(` - Nav entries updated: ${result.navEntriesGenerated}`)
1448
+ }
1396
1449
  console.log(` - Output directory: ${finalOutputDir}`)
1397
1450
 
1398
1451
  // Run validation on generated output
@@ -1626,6 +1679,7 @@ module.exports = {
1626
1679
  handleRpkDocsGeneration,
1627
1680
  fetchRpkTreeFromSource,
1628
1681
  fetchRpkTreeFromLinuxSource,
1682
+ getRequiredGoVersion,
1629
1683
  prepareSourceFromRef,
1630
1684
  detectLinuxOnlyFromSource,
1631
1685
  addPlatformMarkersFromSource,
@@ -18,7 +18,7 @@ ifndef::env-cloud[]
18
18
  :page-deprecated: true
19
19
  {{/if}}
20
20
 
21
- // This content is autogenerated. Do not edit manually. To customize content, see the writer's guide: https://github.com/redpanda-data/docs-extensions-and-macros/blob/main/docs-data/RPK_OVERRIDES_GUIDE.adoc
21
+ // This content is autogenerated. Do not edit manually. To customize content, see the writer's guide: https://github.com/redpanda-data/docs/blob/main/docs-data/RPK_OVERRIDES_GUIDE.adoc
22
22
  {{#each pageAttributes}}
23
23
  :{{@key}}: {{this}}
24
24
  {{/each}}
@@ -107,9 +107,23 @@ function normalizeCommandPath(cmdPath) {
107
107
 
108
108
  /**
109
109
  * Load and compile the JSON Schema for validation
110
+ * @param {string} [overridesPath] - Path to the overrides file; the schema is
111
+ * looked up as a sibling (same directory) before falling back to the
112
+ * package-relative path.
110
113
  * @returns {Function} Compiled schema validator
111
114
  */
112
- function loadSchemaValidator() {
115
+ function loadSchemaValidator(overridesPath) {
116
+ // Primary: look for the schema next to the overrides file (docs repo's docs-data/).
117
+ if (overridesPath) {
118
+ const siblingPath = path.join(path.dirname(path.resolve(overridesPath)), 'rpk-overrides.schema.json')
119
+ if (fs.existsSync(siblingPath)) {
120
+ const schema = JSON.parse(fs.readFileSync(siblingPath, 'utf8'))
121
+ const ajv = new Ajv2020({ allErrors: true, verbose: true })
122
+ return ajv.compile(schema)
123
+ }
124
+ }
125
+
126
+ // Fallback: package-relative path (legacy / monorepo usage).
113
127
  const schemaPath = path.resolve(__dirname, '../../docs-data/rpk-overrides.schema.json')
114
128
 
115
129
  if (!fs.existsSync(schemaPath)) {
@@ -124,13 +138,14 @@ function loadSchemaValidator() {
124
138
  /**
125
139
  * Validate overrides against JSON Schema
126
140
  * @param {Object} overrides - Overrides object to validate
141
+ * @param {string} [overridesPath] - Path to the overrides file (used to locate schema)
127
142
  * @returns {ValidationResult}
128
143
  */
129
- function validateSchema(overrides) {
144
+ function validateSchema(overrides, overridesPath) {
130
145
  const result = new ValidationResult()
131
146
 
132
147
  try {
133
- const validate = loadSchemaValidator()
148
+ const validate = loadSchemaValidator(overridesPath)
134
149
  const valid = validate(overrides)
135
150
 
136
151
  if (!valid) {
@@ -531,7 +546,7 @@ function validateDescriptions(commandOverride, context) {
531
546
  * @param {Object} [commandTree] - Optional command tree for path validation
532
547
  * @returns {ValidationResult}
533
548
  */
534
- function validateOverrides(overrides, commandTree = null) {
549
+ function validateOverrides(overrides, commandTree = null, overridesPath = null) {
535
550
  const result = new ValidationResult()
536
551
 
537
552
  if (!overrides || typeof overrides !== 'object') {
@@ -540,7 +555,7 @@ function validateOverrides(overrides, commandTree = null) {
540
555
  }
541
556
 
542
557
  // 1. Schema validation
543
- result.merge(validateSchema(overrides))
558
+ result.merge(validateSchema(overrides, overridesPath))
544
559
 
545
560
  // 2. Reference validation
546
561
  result.merge(validateReferences(overrides))
@@ -596,7 +611,7 @@ function loadAndValidateOverrides(overridesPath, commandTree = null) {
596
611
  }
597
612
 
598
613
  // Run validations
599
- validation.merge(validateOverrides(overrides, commandTree))
614
+ validation.merge(validateOverrides(overrides, commandTree, overridesPath))
600
615
 
601
616
  return { overrides, validation }
602
617
  }