@redpanda-data/docs-extensions-and-macros 5.3.1 → 5.3.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.
package/bin/doc-tools.js CHANGED
@@ -27,7 +27,8 @@ const {
27
27
  generatePropertyComparisonReport,
28
28
  updatePropertyOverridesWithVersion,
29
29
  updatePropertiesJsonWithVersion,
30
- cleanupOldDiffs
30
+ cleanupOldDiffs,
31
+ resolveDiffBaseline
31
32
  } = require('../cli-utils/diff-utils')
32
33
 
33
34
  // Import other utilities
@@ -890,6 +891,7 @@ automation
890
891
  .option('-t, --tag <tag>', 'Git tag for released content (GA/beta)')
891
892
  .option('-b, --branch <branch>', 'Branch name for in-progress content')
892
893
  .option('--diff <oldTag>', 'Diff properties against <oldTag> and restore removed deprecated properties. Recommended for accurate output; falls back to latest-redpanda-tag from antora.yml if not specified')
894
+ .option('--regenerate-old-baseline', 'Re-extract the --diff tag from source instead of using the committed attachments/redpanda-properties-<oldTag>.json baseline')
893
895
  .option('--overrides <path>', 'Optional JSON file with property description overrides', 'docs-data/property-overrides.json')
894
896
  .option('--output-dir <dir>', 'Where to write all generated files', 'modules/reference')
895
897
  .option('--cloud-support', 'Add AsciiDoc tags to generated property docs to indicate which ones are supported in Redpanda Cloud. This data is fetched from the cloudv2 repository so requires a GitHub token with repo permissions. Set the token as an environment variable using GITHUB_TOKEN, GH_TOKEN, or REDPANDA_GITHUB_TOKEN', true)
@@ -982,7 +984,13 @@ automation
982
984
  // When a diff is needed, skip AsciiDoc generation during extraction so we
983
985
  // can merge removed deprecated properties first and generate only once.
984
986
  if (needsDiff) {
985
- make(oldTag, overridesPath, templates, outputDir, { skipPartials: true })
987
+ const { useCommitted, baselinePath } = resolveDiffBaseline(outputDir, oldTag, options.regenerateOldBaseline)
988
+ if (useCommitted) {
989
+ console.log(`Using committed baseline for ${oldTag}: ${baselinePath}`)
990
+ console.log(' Pass --regenerate-old-baseline to rebuild it from source instead.')
991
+ } else {
992
+ make(oldTag, overridesPath, templates, outputDir, { skipPartials: true })
993
+ }
986
994
  make(newTag, overridesPath, templates, outputDir, { skipPartials: true })
987
995
  } else {
988
996
  make(newTag, overridesPath, templates, outputDir)
@@ -992,7 +1000,16 @@ automation
992
1000
  // the new JSON so they appear in the generated documentation.
993
1001
  if (needsDiff) {
994
1002
  const diffOutputDir = overridesPath ? path.dirname(path.resolve(overridesPath)) : outputDir
995
- generatePropertyComparisonReport(oldTag, newTag, diffOutputDir)
1003
+ try {
1004
+ generatePropertyComparisonReport(oldTag, newTag, diffOutputDir)
1005
+ } catch (err) {
1006
+ // A missing baseline only warns inside generatePropertyComparisonReport.
1007
+ // Reaching this catch means both inputs existed and the comparison
1008
+ // itself failed, so fail the run: continuing would silently drop
1009
+ // removed-deprecated restoration and "Introduced in" version stamping.
1010
+ console.error(`Error: Property comparison failed: ${err.message}`)
1011
+ process.exit(1)
1012
+ }
996
1013
 
997
1014
  try {
998
1015
  const diffReportPath = path.join(diffOutputDir, `redpanda-property-changes-${oldTag}-to-${newTag}.json`)
@@ -1012,7 +1029,11 @@ automation
1012
1029
  }
1013
1030
  }
1014
1031
  } catch (err) {
1015
- console.warn(`Warning: Failed to generate PR summary: ${err.message}`)
1032
+ // The diff report exists but could not be read or applied. Fail the
1033
+ // run rather than shipping docs that are missing removed-deprecated
1034
+ // restoration or version stamps.
1035
+ console.error(`Error: Failed to process the property diff report: ${err.message}`)
1036
+ process.exit(1)
1016
1037
  }
1017
1038
 
1018
1039
  cleanupOldDiffs(diffOutputDir)
@@ -1040,15 +1061,32 @@ automation
1040
1061
 
1041
1062
  try {
1042
1063
  const jsonDir = path.resolve(outputDir, 'attachments')
1064
+ // Invariant: always retain the comparison pair this run actually used
1065
+ // (the current tag's JSON and the diff baseline's JSON) in addition to
1066
+ // the 2 newest versioned JSONs. A backport run can compare tags that
1067
+ // are not the 2 newest, and the next run's comparison needs its
1068
+ // baseline JSON to survive. Mirrors the retention in
1069
+ // tools/property-extractor/Makefile (generate-docs cleanup).
1070
+ const parseVersion = f => f.match(/^redpanda-properties-v([\d.]+)\.json$/)[1].split('.').map(Number)
1071
+ const byVersionDesc = (a, b) => {
1072
+ const [va, vb] = [parseVersion(a), parseVersion(b)]
1073
+ for (let i = 0; i < Math.max(va.length, vb.length); i++) {
1074
+ if ((vb[i] || 0) !== (va[i] || 0)) return (vb[i] || 0) - (va[i] || 0)
1075
+ }
1076
+ return 0
1077
+ }
1043
1078
  const propertyFiles = fs.readdirSync(jsonDir)
1044
1079
  .filter(f => /^redpanda-properties-v[\d.]+\.json$/.test(f))
1045
- .sort()
1080
+ .sort(byVersionDesc)
1046
1081
 
1047
- const keepFile = `redpanda-properties-${newTag}.json`
1048
- const filesToDelete = propertyFiles.filter(f => f !== keepFile)
1082
+ const filesToKeep = new Set(propertyFiles.slice(0, 2))
1083
+ for (const tag of [newTag, oldTag]) {
1084
+ if (tag) filesToKeep.add(`redpanda-properties-${tag}.json`)
1085
+ }
1086
+ const filesToDelete = propertyFiles.filter(f => !filesToKeep.has(f))
1049
1087
 
1050
1088
  if (filesToDelete.length > 0) {
1051
- console.log('🧹 Cleaning up old property JSON files...')
1089
+ console.log('🧹 Cleaning up old property JSON files (keeping the 2 newest plus the comparison pair)...')
1052
1090
  filesToDelete.forEach(file => {
1053
1091
  fs.unlinkSync(path.join(jsonDir, file))
1054
1092
  console.log(` Deleted: ${file}`)
@@ -65,9 +65,16 @@ function cleanupOldDiffs (diffDir) {
65
65
  /**
66
66
  * Generate a detailed JSON report describing property changes between two releases.
67
67
  *
68
+ * When either input JSON is missing the comparison is skipped with a warning
69
+ * (a genuinely new oldest tag legitimately has no baseline). When both inputs
70
+ * exist but the comparison itself fails, this throws so callers fail loudly
71
+ * instead of silently dropping removed-deprecated restoration and version
72
+ * stamping.
73
+ *
68
74
  * @param {string} oldTag - Release tag or identifier for the "old" properties set.
69
75
  * @param {string} newTag - Release tag or identifier for the "new" properties set.
70
76
  * @param {string} outputDir - Directory where the comparison report will be written.
77
+ * @throws {Error} When both input JSONs exist but the comparison fails to run or exits non-zero.
71
78
  */
72
79
  function generatePropertyComparisonReport (oldTag, newTag, outputDir) {
73
80
  try {
@@ -81,13 +88,14 @@ function generatePropertyComparisonReport (oldTag, newTag, outputDir) {
81
88
 
82
89
  if (!fs.existsSync(oldJsonPath)) {
83
90
  console.log(`Warning: Old properties JSON not found at: ${oldJsonPath}`)
84
- console.log(' Skipping detailed property comparison.')
91
+ console.log(' Skipping detailed property comparison: no diff report will be generated, so removed deprecated properties are not restored and new properties are not stamped with "Introduced in" versions.')
92
+ console.log(` This is expected only when ${oldTag} has never been extracted (for example, the oldest supported release).`)
85
93
  return
86
94
  }
87
95
 
88
96
  if (!fs.existsSync(newJsonPath)) {
89
97
  console.log(`Warning: New properties JSON not found at: ${newJsonPath}`)
90
- console.log(' Skipping detailed property comparison.')
98
+ console.log(' Skipping detailed property comparison: no diff report will be generated, so removed deprecated properties are not restored and new properties are not stamped with "Introduced in" versions.')
91
99
  return
92
100
  }
93
101
 
@@ -107,15 +115,19 @@ function generatePropertyComparisonReport (oldTag, newTag, outputDir) {
107
115
  cwd: propertyExtractorDir
108
116
  })
109
117
 
118
+ // Both inputs exist, so a failed comparison is a hard error: continuing
119
+ // would silently skip removed-deprecated restoration and version stamping
110
120
  if (result.error) {
111
- console.error(`Error: Property comparison failed: ${result.error.message}`)
112
- } else if (result.status !== 0) {
113
- console.error(`Error: Property comparison exited with code: ${result.status}`)
114
- } else {
115
- console.log(`Done: Property comparison report saved to: ${reportPath}`)
121
+ throw new Error(`Property comparison failed to run: ${result.error.message}`)
122
+ }
123
+ if (result.status !== 0) {
124
+ throw new Error(`Property comparison exited with code ${result.status}`)
116
125
  }
126
+ console.log(`Done: Property comparison report saved to: ${reportPath}`)
117
127
  } catch (error) {
118
128
  console.error(`Error: Error generating property comparison: ${error.message}`)
129
+ // Propagate so callers fail loudly instead of shipping incomplete docs
130
+ throw error
119
131
  }
120
132
  }
121
133
 
@@ -346,7 +358,30 @@ function diffDirs (kind, oldTag, newTag, oldTempDir, newTempDir) {
346
358
  }
347
359
  }
348
360
 
361
+ /**
362
+ * Decide whether a committed baseline attachment can serve as the old side of
363
+ * a property diff. Rebuilding the old tag in place overwrites the committed
364
+ * attachment with a fresh extraction, so any contamination in that extraction
365
+ * silently becomes both the stored baseline and the diff input (the diff then
366
+ * compares the run against its own output and reports no new properties).
367
+ * @param {string} outputDir - Docs output dir that contains attachments/
368
+ * @param {string} oldTag - Old version tag (for example, v26.1.14)
369
+ * @param {boolean} [regenerate=false] - Force re-extraction even if a baseline exists
370
+ * @returns {{useCommitted: boolean, baselinePath: string}}
371
+ */
372
+ function resolveDiffBaseline (outputDir, oldTag, regenerate = false) {
373
+ const attachmentsDir = path.resolve(outputDir, 'attachments')
374
+ const baselinePath = path.resolve(attachmentsDir, `redpanda-properties-${oldTag}.json`)
375
+ const relativePath = path.relative(attachmentsDir, baselinePath)
376
+ if (relativePath.startsWith('..') || path.isAbsolute(relativePath) || relativePath.includes(path.sep)) {
377
+ throw new Error(`Invalid old tag "${oldTag}": baseline must resolve within the attachments directory`)
378
+ }
379
+ return { useCommitted: !regenerate && fs.existsSync(baselinePath), baselinePath }
380
+ }
381
+
349
382
  module.exports = {
383
+ resolveDiffBaseline,
384
+
350
385
  runClusterDocs,
351
386
  cleanupOldDiffs,
352
387
  generatePropertyComparisonReport,
@@ -428,13 +428,19 @@ module.exports.register = function ({ config }) {
428
428
  function buildContextSwitcher (connectorRows, currentRow, variant) {
429
429
  const capitalize = (value) => value.charAt(0).toUpperCase() + value.slice(1)
430
430
  const orderedRows = [currentRow, ...connectorRows.filter((row) => row !== currentRow)]
431
- const data = {}
431
+ // The process-context-switcher extension and the UI's context-switcher partial
432
+ // both expect an array of { name, to } items; `to` is a root-relative pub URL.
433
+ const items = []
432
434
  for (const row of orderedRows) {
433
435
  const link = (variant === 'cloud' && row.redpandaCloudUrl) || row.redpandaConnectUrl
434
436
  if (!link) continue
435
437
  const type = row.type.trim()
436
- data[type.toLowerCase()] = { name: capitalize(type), to: link }
438
+ // First occurrence wins: orderedRows puts the current page's row first,
439
+ // so a connector with duplicate types keeps its own entry. (The old
440
+ // object-keyed build let later rows overwrite earlier ones.)
441
+ if (items.some((item) => item.name === capitalize(type))) continue
442
+ items.push({ name: capitalize(type), to: link })
437
443
  }
438
- return JSON.stringify(data)
444
+ return JSON.stringify(items)
439
445
  }
440
446
  }
@@ -74,6 +74,11 @@ module.exports.register = function ({ config }) {
74
74
  item.to = currentResourceId;
75
75
  hasChanges = true;
76
76
  logger.debug(`Replaced 'current' with '${currentResourceId}' in context-switcher`);
77
+ } else if (typeof item.to === 'string' && item.to.startsWith('/')) {
78
+ // Root-relative URLs (e.g. pub.url values set by generate-rp-connect-info)
79
+ // need no resolution or target injection: the UI's resolve-resource helper
80
+ // passes them through, and the producer decorates every variant directly.
81
+ continue;
77
82
  } else if (item.to !== currentResourceId) {
78
83
  // For non-current items, find and update the target page
79
84
  const targetPage = findPageByResourceId(item.to, contentCatalog, page);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redpanda-data/docs-extensions-and-macros",
3
- "version": "5.3.1",
3
+ "version": "5.3.3",
4
4
  "description": "Antora extensions and macros developed for Redpanda documentation.",
5
5
  "keywords": [
6
6
  "antora",
@@ -72,19 +72,30 @@ clean:
72
72
  @rm -rf $(VENV) $(TMP_ROOT) $(REPO_ROOT)/autogenerated
73
73
 
74
74
  # --- Clone or update Redpanda, checkout TAG or default branch ---
75
+ # Clones are shallow: the extractor only reads source files at a single tag,
76
+ # so full history is wasted time and disk.
77
+ # Auth is passed as a per-invocation HTTP header (git -c before the clone
78
+ # subcommand), never embedded in the remote URL, so no token is persisted in
79
+ # the clone's .git/config. The clone lives inside the installed package dir,
80
+ # so a persisted token would be readable by anything that can list it.
75
81
  redpanda-git:
76
82
  @mkdir -p "$(TMP_ROOT)"
77
83
  @echo "🔄 Cloning/updating Redpanda into $(REDPANDA_SRC)…"
78
84
  @if [ -d "$(REDPANDA_SRC)" ]; then \
79
- git -C "$(REDPANDA_SRC)" fetch --all --tags -q; \
85
+ git -C "$(REDPANDA_SRC)" fetch --depth 1 -q origin "+refs/tags/$(TAG):refs/tags/$(TAG)" 2>/dev/null || \
86
+ git -C "$(REDPANDA_SRC)" fetch --depth 1 -q origin "+refs/heads/$(TAG):refs/remotes/origin/$(TAG)" 2>/dev/null || \
87
+ git -C "$(REDPANDA_SRC)" fetch --all --tags -q; \
80
88
  else \
81
89
  GH_TOKEN=$${REDPANDA_GITHUB_TOKEN:-$${GITHUB_TOKEN:-$${GH_TOKEN}}}; \
90
+ REPO_URL="https://github.com/redpanda-data/redpanda.git"; \
82
91
  if [ -n "$$GH_TOKEN" ]; then \
83
92
  echo "🔑 Using authenticated clone (token provided)"; \
84
- git clone -q https://$$GH_TOKEN@github.com/redpanda-data/redpanda.git "$(REDPANDA_SRC)"; \
93
+ AUTH_HEADER="AUTHORIZATION: basic $$(printf '%s' "x-access-token:$$GH_TOKEN" | base64 | tr -d '\n')"; \
85
94
  else \
86
- git clone -q https://github.com/redpanda-data/redpanda.git "$(REDPANDA_SRC)"; \
95
+ AUTH_HEADER=""; \
87
96
  fi; \
97
+ git -c http.https://github.com/.extraheader="$$AUTH_HEADER" clone -q --depth 1 --branch "$(TAG)" "$$REPO_URL" "$(REDPANDA_SRC)" 2>/dev/null || \
98
+ git -c http.https://github.com/.extraheader="$$AUTH_HEADER" clone -q --depth 1 "$$REPO_URL" "$(REDPANDA_SRC)"; \
88
99
  fi; \
89
100
  if git -C "$(REDPANDA_SRC)" rev-parse --verify -q "origin/$(TAG)" >/dev/null 2>&1; then \
90
101
  echo "🔖 Checking out remote branch 'origin/$(TAG)'"; \
@@ -94,24 +105,44 @@ redpanda-git:
94
105
  echo "🔖 Checking out '$(TAG)'"; \
95
106
  git -C "$(REDPANDA_SRC)" checkout -q "$(TAG)"; \
96
107
  else \
97
- DEFAULT_BRANCH=$$(git -C "$(REDPANDA_SRC)" symbolic-ref --short refs/remotes/origin/HEAD); \
98
- echo "⚠️ Tag '$(TAG)' not found; falling back to '$${DEFAULT_BRANCH}'"; \
99
- git -C "$(REDPANDA_SRC)" checkout -q "$${DEFAULT_BRANCH}"; \
108
+ DEFAULT_BRANCH=$$(git -C "$(REDPANDA_SRC)" symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null || true); \
109
+ if [ -n "$$DEFAULT_BRANCH" ]; then \
110
+ echo "⚠️ Tag '$(TAG)' not found; falling back to '$${DEFAULT_BRANCH}'"; \
111
+ git -C "$(REDPANDA_SRC)" checkout -q "$${DEFAULT_BRANCH}"; \
112
+ else \
113
+ DEFAULT_BRANCH=$$(git -C "$(REDPANDA_SRC)" ls-remote --symref origin HEAD | awk '/^ref:/ {sub(/^refs\/heads\//, "", $$2); print $$2; exit}'); \
114
+ if [ -z "$$DEFAULT_BRANCH" ]; then \
115
+ echo "❌ Tag '$(TAG)' not found and the default branch could not be determined"; \
116
+ exit 1; \
117
+ fi; \
118
+ echo "⚠️ Tag '$(TAG)' not found; falling back to '$${DEFAULT_BRANCH}'"; \
119
+ git -C "$(REDPANDA_SRC)" fetch --depth 1 -q origin "$$DEFAULT_BRANCH"; \
120
+ git -C "$(REDPANDA_SRC)" checkout -q FETCH_HEAD; \
121
+ fi; \
100
122
  fi
101
123
 
102
124
  # --- Clone Tree-sitter grammar & generate parser ---
125
+ # Skips npm install and parser generation when the generated output already
126
+ # exists for the pinned grammar version, so repeat builds stay fast
103
127
  treesitter:
104
128
  @echo "🌲 Ensuring tree-sitter-cpp grammar…"
105
129
  @if [ ! -d "$(TREESITTER_DIR)/.git" ]; then \
106
130
  rm -rf "$(TREESITTER_DIR)"; \
107
131
  git clone https://github.com/tree-sitter/tree-sitter-cpp.git "$(TREESITTER_DIR)"; \
108
132
  fi
109
- @echo "🔄 Ensuring tree-sitter-cpp is at compatible version v0.20.5…"
110
- @cd "$(TREESITTER_DIR)" && \
111
- git fetch --tags -q && \
112
- git checkout -q v0.20.5
113
- @echo "🔧 Generating parser in $(TREESITTER_DIR)…"
114
- @cd "$(TREESITTER_DIR)" && export CFLAGS="-Wno-unused-but-set-variable" && npm install --silent && export CFLAGS="-Wno-unused-but-set-variable" && $(TREE_SITTER) generate
133
+ @CURRENT_TAG=$$(git -C "$(TREESITTER_DIR)" describe --tags --exact-match 2>/dev/null || true); \
134
+ if [ "$$CURRENT_TAG" = "v0.20.5" ] && [ -f "$(TREESITTER_DIR)/src/parser.c" ]; then \
135
+ echo "✅ tree-sitter-cpp parser already generated at v0.20.5; skipping regeneration"; \
136
+ else \
137
+ echo "🔄 Ensuring tree-sitter-cpp is at compatible version v0.20.5…"; \
138
+ cd "$(TREESITTER_DIR)" && \
139
+ git fetch --tags -q && \
140
+ git checkout -q v0.20.5 && \
141
+ echo "🔧 Generating parser in $(TREESITTER_DIR)…" && \
142
+ export CFLAGS="-Wno-unused-but-set-variable" && \
143
+ npm install --silent && \
144
+ $(TREE_SITTER) generate; \
145
+ fi
115
146
 
116
147
  # --- Install Node.js dependencies for Handlebars ---
117
148
  node-deps:
@@ -146,6 +177,9 @@ generate-docs: node-deps
146
177
  else \
147
178
  echo " ⚠️ Source file $(TOOL_ROOT)/gen/$(TAG)-properties.json not found, skipping copy"; \
148
179
  fi
180
+ @# Invariant: keep at least 2 versioned JSONs, the current release plus the
181
+ @# diff baseline that the next run's comparison depends on. Mirrors the
182
+ @# retention in bin/doc-tools.js (post-run property JSON cleanup).
149
183
  @echo "🧹 Cleaning up old versioned JSON files (keeping only 2 most recent)…"
150
184
  @cd "$(OUTPUT_JSON_DIR)" && \
151
185
  ls -t redpanda-properties-*.json 2>/dev/null | tail -n +3 | while read file; do \
@@ -434,6 +434,22 @@ def fetch_cloud_config(github_token: Optional[str] = None) -> CloudConfig:
434
434
  raise CloudConfigError(error_msg) from e
435
435
 
436
436
 
437
+ def _match_name_or_alias(prop_name: str, prop_data: Dict, cloud_set: Set[str]) -> Optional[str]:
438
+ """
439
+ Return the name under which a property appears in a cloud property set.
440
+
441
+ Checks the property's own name first, then each entry in its `aliases`
442
+ array. Returns the matching name, or None when neither the name nor any
443
+ alias is in the set.
444
+ """
445
+ if prop_name in cloud_set:
446
+ return prop_name
447
+ for alias in prop_data.get('aliases') or []:
448
+ if alias in cloud_set:
449
+ return alias
450
+ return None
451
+
452
+
437
453
  def add_cloud_support_metadata(properties: Dict, cloud_config: CloudConfig) -> Dict:
438
454
  """
439
455
  Annotate property definitions with cloud-support metadata derived from a CloudConfig.
@@ -492,29 +508,39 @@ def add_cloud_support_metadata(properties: Dict, cloud_config: CloudConfig) -> D
492
508
  continue
493
509
 
494
510
  processed_count += 1
495
-
511
+
496
512
  # Initialize cloud metadata with defaults
497
513
  prop_data['cloud_editable'] = False
498
514
  prop_data['cloud_readonly'] = False
499
515
  prop_data['cloud_supported'] = False
500
516
  prop_data['cloud_byoc_only'] = False
501
-
502
- # Determine cloud support status
503
- if prop_name in editable_props:
517
+
518
+ # Determine cloud support status. Match on the property's current
519
+ # name or any of its aliases, so a property renamed in Redpanda
520
+ # still picks up cloud metadata when the cloud configuration
521
+ # references the old name.
522
+ editable_match = _match_name_or_alias(prop_name, prop_data, editable_props)
523
+ readonly_match = None if editable_match else _match_name_or_alias(prop_name, prop_data, readonly_props)
524
+
525
+ if editable_match:
526
+ if editable_match != prop_name:
527
+ logger.info(f"Property '{prop_name}' matched cloud editable configuration via alias '{editable_match}'")
504
528
  prop_data['cloud_editable'] = True
505
529
  prop_data['cloud_readonly'] = False
506
530
  prop_data['cloud_supported'] = True
507
531
  cloud_supported_count += 1
508
532
  editable_count += 1
509
-
510
- # Check if BYOC only
511
- if cloud_config.is_byoc_only(prop_name):
533
+
534
+ # Check if BYOC only, using the name the cloud configuration knows
535
+ if cloud_config.is_byoc_only(editable_match):
512
536
  prop_data['cloud_byoc_only'] = True
513
537
  byoc_only_count += 1
514
538
  else:
515
539
  prop_data['cloud_byoc_only'] = False
516
-
517
- elif prop_name in readonly_props:
540
+
541
+ elif readonly_match:
542
+ if readonly_match != prop_name:
543
+ logger.info(f"Property '{prop_name}' matched cloud readonly configuration via alias '{readonly_match}'")
518
544
  prop_data['cloud_editable'] = False
519
545
  prop_data['cloud_readonly'] = True
520
546
  prop_data['cloud_supported'] = True
@@ -565,11 +591,14 @@ def add_cloud_support_metadata(properties: Dict, cloud_config: CloudConfig) -> D
565
591
  logger.warning(" 2. Property names don't match between sources")
566
592
  logger.warning(" 3. All properties are self-managed only")
567
593
 
568
- # Check for potential mismatches
569
- unmatched_cloud_props = (editable_props | readonly_props) - {
570
- name for name, data in properties.items()
571
- if isinstance(data, dict) and data.get('config_scope') in ['cluster', 'broker', 'topic']
572
- }
594
+ # Check for potential mismatches, counting aliases as known names so a
595
+ # renamed property does not show up as missing
596
+ extracted_cloud_names = set()
597
+ for name, data in properties.items():
598
+ if isinstance(data, dict) and data.get('config_scope') in ['cluster', 'broker', 'topic']:
599
+ extracted_cloud_names.add(name)
600
+ extracted_cloud_names.update(data.get('aliases') or [])
601
+ unmatched_cloud_props = (editable_props | readonly_props) - extracted_cloud_names
573
602
 
574
603
  if unmatched_cloud_props:
575
604
  logger.info(f"Cloud configuration contains {len(unmatched_cloud_props)} properties not found in extracted properties:")
@@ -364,8 +364,10 @@ function generateConsoleReport(report, oldVersion, newVersion) {
364
364
  /**
365
365
  * Write a structured JSON comparison report to disk.
366
366
  *
367
- * Produces a JSON file containing a comparison header (old/new versions and timestamp),
367
+ * Produces a JSON file containing a comparison header (old/new versions),
368
368
  * a summary with counts for each change category, and the full details object passed as `report`.
369
+ * The report is deterministic: rerunning the comparison on the same inputs
370
+ * produces byte-identical output, so committed reports do not churn.
369
371
  *
370
372
  * @param {Object} report - Comparison details object produced by compareProperties; expected to contain arrays: `newProperties`, `changedDefaults`, `changedDescriptions`, `changedTypes`, `deprecatedProperties`, `removedProperties`, and `emptyDescriptions`.
371
373
  * @param {string} oldVersion - The previous version identifier included in the comparison header.
@@ -376,8 +378,7 @@ function generateJsonReport(report, oldVersion, newVersion, outputPath) {
376
378
  const jsonReport = {
377
379
  comparison: {
378
380
  oldVersion,
379
- newVersion,
380
- timestamp: new Date().toISOString()
381
+ newVersion
381
382
  },
382
383
  summary: {
383
384
  newProperties: report.newProperties.length,
@@ -20,7 +20,7 @@
20
20
  *
21
21
  * Input format (diff data from compare-properties.js):
22
22
  * {
23
- * comparison: { oldVersion, newVersion, timestamp },
23
+ * comparison: { oldVersion, newVersion },
24
24
  * summary: { newProperties, changedDefaults, ... },
25
25
  * details: { newProperties: [...], changedDefaults: [...], ... }
26
26
  * }
@@ -278,11 +278,14 @@ function generatePRSummary(diffData) {
278
278
  lines.push('</details>');
279
279
  lines.push('');
280
280
 
281
- // Footer
281
+ // Footer. The timestamp only appears in reports generated before the
282
+ // field was removed to keep committed diff reports deterministic.
282
283
  lines.push('---');
283
284
  lines.push('');
284
- lines.push(`*Generated: ${diffData.comparison.timestamp}*`);
285
- lines.push('');
285
+ if (diffData.comparison.timestamp) {
286
+ lines.push(`*Generated: ${diffData.comparison.timestamp}*`);
287
+ lines.push('');
288
+ }
286
289
  lines.push('<!-- PR_SUMMARY_END -->');
287
290
 
288
291
  return lines.join('\n');
@@ -321,16 +321,35 @@ _constexpr_cache = ConstexprCache()
321
321
  _type_definitions_cache = {}
322
322
 
323
323
  # Import topic property extractor
324
+ # Set from --path in main() so every consumer of the Redpanda source uses the
325
+ # tree the caller pointed at, regardless of the current working directory. The
326
+ # hardcoded search below is only a fallback for direct module use.
327
+ _redpanda_source_override = None
328
+
329
+
330
+ def set_redpanda_source(path):
331
+ """Record the Redpanda source directory passed via --path."""
332
+ global _redpanda_source_override
333
+ _redpanda_source_override = path
334
+
335
+
324
336
  def find_redpanda_source():
325
337
  """
326
- Locate the Redpanda source directory by searching standard locations.
327
-
328
- The property extractor looks for the Redpanda source code in multiple
329
- locations to handle different execution contexts (project root, tools directory, etc.).
330
-
338
+ Locate the Redpanda source directory.
339
+
340
+ Prefers the path passed via --path (recorded by set_redpanda_source);
341
+ falls back to searching standard locations relative to the current
342
+ working directory. Before this preference existed, running the extractor
343
+ from any directory that did not match a hardcoded guess silently
344
+ disabled the ConstantResolver: no validator enums, no enterprise enum
345
+ metadata, no constexpr resolution, with only a debug-level log line.
346
+
331
347
  Returns:
332
348
  str or None: Path to the Redpanda source directory if found, None otherwise.
333
349
  """
350
+ if _redpanda_source_override and os.path.exists(_redpanda_source_override):
351
+ return _redpanda_source_override
352
+
334
353
  redpanda_source_paths = [
335
354
  'tmp/redpanda', # Current directory
336
355
  '../tmp/redpanda', # Parent directory
@@ -725,6 +744,13 @@ def transform_files_with_properties(files_with_properties):
725
744
  if src_v_path.exists():
726
745
  constant_resolver = ConstantResolver(src_v_path)
727
746
  logger.debug(f"Initialized ConstantResolver with path: {src_v_path}")
747
+ if constant_resolver is None:
748
+ logger.warning(
749
+ "Redpanda source directory not found: validator enum extraction "
750
+ "and enterprise enum metadata are DISABLED for this run. "
751
+ "Properties like sasl_mechanisms will be missing items.enum and "
752
+ "x-enum-metadata. Pass --path to the Redpanda checkout to fix this."
753
+ )
728
754
 
729
755
  transformers = [
730
756
  EnterpriseTransformer(), ## this must be the first, as it modifies current data
@@ -942,6 +968,32 @@ def merge_properties_and_definitions(properties, definitions):
942
968
  return dict(properties=properties, definitions=definitions)
943
969
 
944
970
 
971
+ # Phantom stub entries created by apply_property_overrides during the current
972
+ # run. An entry is recorded whenever an override key matches no extracted
973
+ # property and a placeholder doc entry is fabricated from the override alone.
974
+ # Tracked at module level so the run summary in main() can report them all in
975
+ # one place at the end of the run.
976
+ phantom_stub_entries = []
977
+
978
+
979
+ def report_phantom_stubs():
980
+ """Log one prominent warning block listing every phantom stub created from overrides."""
981
+ if not phantom_stub_entries:
982
+ return
983
+ logger.warning("=" * 70)
984
+ logger.warning(
985
+ f"⚠️ {len(phantom_stub_entries)} override "
986
+ f"{'entry' if len(phantom_stub_entries) == 1 else 'entries'} matched no extracted property"
987
+ )
988
+ for entry in phantom_stub_entries:
989
+ logger.warning(
990
+ f"Override key '{entry['name']}' matched no extracted property — created a stub entry "
991
+ f"with config_scope '{entry['config_scope']}'. If the property was renamed or removed, "
992
+ f"update docs-data/property-overrides.json."
993
+ )
994
+ logger.warning("=" * 70)
995
+
996
+
945
997
  def apply_property_overrides(properties, overrides, overrides_file_path=None):
946
998
  """
947
999
  Apply overrides from an overrides mapping to the extracted properties, mutating and returning the properties dictionary.
@@ -961,6 +1013,7 @@ def apply_property_overrides(properties, overrides, overrides_file_path=None):
961
1013
  Returns:
962
1014
  dict: The same properties mapping with overrides applied and any new properties created.
963
1015
  """
1016
+ phantom_stub_entries.clear()
964
1017
  if overrides and "properties" in overrides:
965
1018
  for prop, override in overrides["properties"].items():
966
1019
  # First check if property exists by key
@@ -982,7 +1035,13 @@ def apply_property_overrides(properties, overrides, overrides_file_path=None):
982
1035
  else:
983
1036
  # Create new property from override
984
1037
  logger.info(f"Creating new property from override: {prop}")
985
- properties[prop] = _create_property_from_override(prop, override, overrides_file_path)
1038
+ new_property = _create_property_from_override(prop, override, overrides_file_path)
1039
+ properties[prop] = new_property
1040
+ # Record the phantom stub so the run summary can flag it loudly
1041
+ phantom_stub_entries.append({
1042
+ "name": prop,
1043
+ "config_scope": new_property.get("config_scope"),
1044
+ })
986
1045
  return properties
987
1046
 
988
1047
 
@@ -2876,6 +2935,11 @@ def main():
2876
2935
  treesitter_dir, destination_path
2877
2936
  )
2878
2937
 
2938
+ # Every consumer of the Redpanda source (ConstantResolver, constexpr
2939
+ # fallback search) must use the tree the caller pointed at, not a
2940
+ # cwd-relative guess.
2941
+ set_redpanda_source(options.path)
2942
+
2879
2943
  # Pre-build constexpr cache for performance
2880
2944
  # This avoids repeated filesystem walks when resolving C++ identifiers and function calls
2881
2945
  logger.info("🔧 Building constexpr identifier cache...")
@@ -3073,5 +3137,16 @@ def main():
3073
3137
  logging.error(f"Failed to write enhanced output file: {e}")
3074
3138
  sys.exit(1)
3075
3139
 
3140
+ # Surface phantom stubs prominently at the end of the run. These are
3141
+ # override keys that matched no extracted property, so a placeholder doc
3142
+ # entry was fabricated from the override alone.
3143
+ if phantom_stub_entries:
3144
+ report_phantom_stubs()
3145
+ phantom_names = ", ".join(entry["name"] for entry in phantom_stub_entries)
3146
+ print(
3147
+ f"⚠️ Phantom stub entries created from overrides: "
3148
+ f"{len(phantom_stub_entries)} ({phantom_names})"
3149
+ )
3150
+
3076
3151
  if __name__ == "__main__":
3077
3152
  main()
@@ -15,6 +15,19 @@ Object.entries(helpers).forEach(([name, fn]) => {
15
15
  // Template paths
16
16
  const TEMPLATES_DIR = path.resolve(__dirname, './templates')
17
17
 
18
+ // Full command paths from the tree being generated (e.g. "rpk ai run codex").
19
+ // Registered by generateRpkDocs so formatDescription can wrap real multi-word
20
+ // command paths as a unit instead of heuristically wrapping `rpk` alone.
21
+ let knownCommandPaths = new Set()
22
+
23
+ /**
24
+ * Register the set of real command paths for the current generation run.
25
+ * @param {string[]} paths - Full command paths (e.g. "rpk ai run codex")
26
+ */
27
+ function registerKnownCommandPaths(paths) {
28
+ knownCommandPaths = new Set(paths)
29
+ }
30
+
18
31
  /**
19
32
  * Register a Handlebars partial from file
20
33
  * @param {string} name - Partial name
@@ -1607,6 +1620,25 @@ function formatDescription(desc, customTransformations = null, options = {}) {
1607
1620
  // Fix product name: "Redpanda cloud" → "Redpanda Cloud" (product name)
1608
1621
  .replace(/Redpanda\s+cloud\b/g, 'Redpanda Cloud')
1609
1622
 
1623
+ // === RPK COMMAND FORMATTING (known paths first, ground truth) ===
1624
+ // Wrap full multi-word command paths that exist in the generated tree
1625
+ // (e.g. "rpk ai run codex" -> `rpk ai run codex`). The heuristic formatter
1626
+ // below only ever matches "rpk <word>", so without this pass it wraps
1627
+ // `rpk` alone and splits the command in half. Matching against the real
1628
+ // tree means prose that merely resembles a command is never wrapped.
1629
+ if (knownCommandPaths.size > 0) {
1630
+ result = result.replace(/(?<![`\w])rpk((?: [a-z][-a-z0-9]*)+)/g, (match, rest) => {
1631
+ const tokens = rest.trim().split(' ')
1632
+ for (let n = tokens.length; n >= 2; n--) {
1633
+ const path = `rpk ${tokens.slice(0, n).join(' ')}`
1634
+ if (knownCommandPaths.has(path)) {
1635
+ return `\`${path}\`` + match.slice(path.length)
1636
+ }
1637
+ }
1638
+ return match
1639
+ })
1640
+ }
1641
+
1610
1642
  // === RPK COMMAND FORMATTING (context-aware) ===
1611
1643
  // Process "rpk X" patterns based on what follows
1612
1644
  result = result.replace(/(?<![`\w])rpk(\s+)([a-z][-a-z0-9]*)(?:\s+|(?=[.,;:!?)'"}\]]|$))/gi, (match, space, word, offset, str) => {
@@ -1702,6 +1734,11 @@ function formatDescription(desc, customTransformations = null, options = {}) {
1702
1734
  // Add backticks around standalone file names (not inside paths)
1703
1735
  result = result.replace(/(?<![`/])(redpanda\.yaml|rpk\.yaml)(?!`)/gi, '`$1`')
1704
1736
 
1737
+ // Add backticks around internal topic names (_redpanda.transform_logs,
1738
+ // _redpanda.audit_log, ...). The leading underscore is also an AsciiDoc
1739
+ // italics delimiter, so these must never render as bare prose.
1740
+ result = result.replace(/(?<![`\w/])(_redpanda(?:\.[a-z_]+)+)(?!`)/g, '`$1`')
1741
+
1705
1742
  // Add backticks around standalone "rpk" (at end of phrase, before punctuation)
1706
1743
  // Also exclude rpk inside paths (preceded by /)
1707
1744
  result = result.replace(/(?<![`\w/])rpk(?=[\s]*[.,;:!?)'"}\]]|[\s]*$)/g, '`rpk`')
@@ -1730,9 +1767,13 @@ function formatDescription(desc, customTransformations = null, options = {}) {
1730
1767
  result = result.replace(`__EARLY_CODE_BLOCK_${i}__`, () => transformed)
1731
1768
  })
1732
1769
 
1733
- // Restore inline code
1770
+ // Restore inline code. Spans are verbatim, but rules flagged applyToCode
1771
+ // (like the rpai -> rpk ai binary-name rewrite) must reach them the same
1772
+ // way they reach code blocks, or the internal binary name survives in
1773
+ // published spans like `rpai auth token`.
1734
1774
  inlineCode.forEach((code, i) => {
1735
- result = result.replace(`__INLINE_CODE_${i}__`, () => code)
1775
+ const transformed = applyTextTransformations(code, customTransformations, { code: true })
1776
+ result = result.replace(`__INLINE_CODE_${i}__`, () => transformed)
1736
1777
  })
1737
1778
 
1738
1779
  // Restore xrefs
@@ -2173,6 +2214,12 @@ function capToTwoSentences(desc) {
2173
2214
  // The pattern matches: colon, optional whitespace/newlines, then indented content
2174
2215
  cleaned = cleaned.replace(/:\s*\n+[ \t]+.+$/s, ':')
2175
2216
 
2217
+ // A paragraph break is a sentence boundary even when the paragraph has no
2218
+ // terminal punctuation (cobra short descriptions often lack one: "Generate
2219
+ // a trial license\n\nThis command..."). Without this, flattening newlines
2220
+ // glues the paragraphs into one run-on "sentence".
2221
+ cleaned = cleaned.replace(/([^.!?:\s])[ \t]*\n[ \t]*\n/g, '$1.\n\n')
2222
+
2176
2223
  // Normalize newlines to spaces for inline use (like :description: attribute)
2177
2224
  const singleLine = cleaned.replace(/\s*\n+\s*/g, ' ').trim()
2178
2225
 
@@ -2202,6 +2249,14 @@ function capToTwoSentences(desc) {
2202
2249
  // dropped by the sentence matcher
2203
2250
  normalized = normalized.replace(/(\d)\.(\d)/g, '$1__DECIMAL__$2')
2204
2251
 
2252
+ // Protect ALL mid-token periods (no whitespace after): dotted names like
2253
+ // _redpanda.transform_logs or URLs like redpanda.com/contact are not
2254
+ // sentence boundaries. Without this the sentence matcher below cannot
2255
+ // match the sentence containing the token, silently drops everything up
2256
+ // to the mid-token period, and emits the tail fragment as a "sentence"
2257
+ // ("View logs for a transform. transform_logs.").
2258
+ normalized = normalized.replace(/\.(?=\S)/g, '__MIDDOT__')
2259
+
2205
2260
  // Match sentences
2206
2261
  let sentences = normalized.match(/[^.!?]+[.!?]+(?:\s|$)/g)
2207
2262
 
@@ -2217,6 +2272,7 @@ function capToTwoSentences(desc) {
2217
2272
  if (!sentences || sentences.length === 0) {
2218
2273
  // Restore and return
2219
2274
  let result = normalized.replace(/__DECIMAL__/g, '.')
2275
+ result = result.replace(/__MIDDOT__/g, '.')
2220
2276
  placeholders.forEach(({ ph, original }) => {
2221
2277
  result = result.replace(ph, original)
2222
2278
  })
@@ -2230,6 +2286,7 @@ function capToTwoSentences(desc) {
2230
2286
 
2231
2287
  // Restore decimal points
2232
2288
  result = result.replace(/__DECIMAL__/g, '.')
2289
+ result = result.replace(/__MIDDOT__/g, '.')
2233
2290
 
2234
2291
  // Restore abbreviations
2235
2292
  placeholders.forEach(({ ph, original }) => {
@@ -2578,6 +2635,9 @@ async function generateRpkDocs(options = {}) {
2578
2635
  // Flatten command tree
2579
2636
  const commands = flattenCommands(tree)
2580
2637
 
2638
+ // Let formatDescription wrap real multi-word command paths as a unit
2639
+ registerKnownCommandPaths(commands.map(c => c.path))
2640
+
2581
2641
  // Determine which managed plugins are protected this run: explicitly
2582
2642
  // passed by the caller (failed installs) merged with auto-detection of
2583
2643
  // absent or shim-only plugin subtrees. Protection covers the whole
@@ -3000,6 +3060,21 @@ async function generateRpkDocs(options = {}) {
3000
3060
  }
3001
3061
 
3002
3062
  try {
3063
+ // A page that HAD a Flags section and loses it in this render is the
3064
+ // signature of missing flag data (a snapshot without extraction, or a
3065
+ // curated table removed before the extracted one exists). Nothing else
3066
+ // catches this class: the run exits 0 with a clean log while the
3067
+ // published page silently drops its flag documentation (seen on
3068
+ // rpk connect run).
3069
+ if (fs.existsSync(filePath)) {
3070
+ const previous = fs.readFileSync(filePath, 'utf8')
3071
+ if (/^== Flags$/m.test(previous) && !/^== Flags$/m.test(content)) {
3072
+ console.warn(
3073
+ `⚠️ ${commandPath}: the previously rendered page had a Flags section and this render has none. ` +
3074
+ 'The command likely lost its flag data (snapshot without extraction, or a curated Flags override removed too early).'
3075
+ )
3076
+ }
3077
+ }
3003
3078
  fs.writeFileSync(filePath, content, 'utf8')
3004
3079
  writtenFiles.add(filePath)
3005
3080
  filesGenerated++
@@ -3121,5 +3196,6 @@ module.exports = {
3121
3196
  // Exported for testing
3122
3197
  filterExamples,
3123
3198
  formatExamples,
3124
- applyTextTransformationsToExamples
3199
+ applyTextTransformationsToExamples,
3200
+ registerKnownCommandPaths
3125
3201
  }
@@ -894,7 +894,7 @@ function loadVersionedJson(version, dataDir) {
894
894
  * version (the page note renders "introduced in <plugin> version X"), not
895
895
  * the rpk version, because plugins release on their own cadence.
896
896
  */
897
- function updateOverridesWithIntroducedVersions(diffData, overridesPath, version, pluginVersions = {}) {
897
+ function updateOverridesWithIntroducedVersions(diffData, overridesPath, version, pluginVersions = {}, options = {}) {
898
898
  const hasNewCommands = diffData.details.newCommands && diffData.details.newCommands.length > 0
899
899
  const hasNewFlags = diffData.details.newFlags && diffData.details.newFlags.length > 0
900
900
 
@@ -908,6 +908,23 @@ function updateOverridesWithIntroducedVersions(diffData, overridesPath, version,
908
908
  return pluginVersions[topLevel] || version
909
909
  }
910
910
 
911
+ // Plugin-owned entries are only stamped when the caller vouches that the
912
+ // baseline is manifest-adjacent to this run's plugin version (see
913
+ // isPluginStampAttributable). "New relative to the snapshot" is not "new
914
+ // in this release" when the snapshot skipped releases. Callers that omit
915
+ // attributablePlugins keep legacy stamp-everything behavior.
916
+ const attributable = options.attributablePlugins
917
+ ? new Set(options.attributablePlugins)
918
+ : null
919
+ const skippedByPlugin = new Map()
920
+ const stampable = (cmdPath) => {
921
+ const topLevel = cmdPath.split(' ')[1]
922
+ if (!KNOWN_PLUGINS.includes(topLevel) || !attributable) return true
923
+ if (attributable.has(topLevel)) return true
924
+ skippedByPlugin.set(topLevel, (skippedByPlugin.get(topLevel) || 0) + 1)
925
+ return false
926
+ }
927
+
911
928
  let overrides = {}
912
929
  if (fs.existsSync(overridesPath)) {
913
930
  try {
@@ -929,6 +946,7 @@ function updateOverridesWithIntroducedVersions(diffData, overridesPath, version,
929
946
  if (hasNewCommands) {
930
947
  for (const newCmd of diffData.details.newCommands) {
931
948
  const cmdPath = newCmd.path
949
+ if (!stampable(cmdPath)) continue
932
950
  if (!overrides.commands[cmdPath]) {
933
951
  overrides.commands[cmdPath] = {}
934
952
  }
@@ -945,6 +963,7 @@ function updateOverridesWithIntroducedVersions(diffData, overridesPath, version,
945
963
  for (const newFlag of diffData.details.newFlags) {
946
964
  const cmdPath = newFlag.commandPath
947
965
  const flagName = newFlag.flagName
966
+ if (!stampable(cmdPath)) continue
948
967
 
949
968
  if (!overrides.commands[cmdPath]) {
950
969
  overrides.commands[cmdPath] = {}
@@ -964,6 +983,15 @@ function updateOverridesWithIntroducedVersions(diffData, overridesPath, version,
964
983
  }
965
984
  }
966
985
 
986
+ for (const [plugin, count] of skippedByPlugin) {
987
+ console.warn(
988
+ `\u26a0 Skipped stamping introducedInVersion for ${count} new '${plugin}' entr${count === 1 ? 'y' : 'ies'}: ` +
989
+ `the baseline snapshot's ${plugin} version is not the release immediately before ` +
990
+ `${pluginVersions[plugin] || version} in the plugin manifest, so the introduction ` +
991
+ 'version cannot be attributed automatically. Attribute manually against released binaries.'
992
+ )
993
+ }
994
+
967
995
  if (commandsUpdated > 0 || flagsUpdated > 0) {
968
996
  fs.writeFileSync(overridesPath, JSON.stringify(overrides, null, 2), 'utf8')
969
997
  const updates = []
@@ -1444,6 +1472,75 @@ function fetchLatestPluginVersion(plugin) {
1444
1472
  }
1445
1473
  }
1446
1474
 
1475
+ /**
1476
+ * Fetch the ordered list of published versions for a plugin from its
1477
+ * manifest. Cached per plugin for the process lifetime.
1478
+ * @param {string} plugin - rpk command name (ai, connect, k8s, check)
1479
+ * @returns {string[]|null} Versions in manifest (release) order, or null
1480
+ */
1481
+ const pluginManifestVersionsCache = new Map()
1482
+ function fetchPluginManifestVersions(plugin) {
1483
+ if (pluginManifestVersionsCache.has(plugin)) {
1484
+ return pluginManifestVersionsCache.get(plugin)
1485
+ }
1486
+ const slug = PLUGIN_MANIFEST_SLUGS[plugin] || plugin
1487
+ const result = spawnSync('curl', [
1488
+ '-fsSL', '--retry', '3', '--connect-timeout', '15', '--max-time', '30',
1489
+ `${PLUGIN_MANIFEST_HOST}/${slug}/manifest.json`
1490
+ ], { encoding: 'utf8', timeout: 60000 })
1491
+ let versions = null
1492
+ if (result.status === 0) {
1493
+ try {
1494
+ versions = (JSON.parse(result.stdout).archives || []).map(a => a.version)
1495
+ } catch (err) {
1496
+ console.warn(`Could not parse plugin manifest for ${plugin}: ${err.message}`)
1497
+ }
1498
+ } else {
1499
+ console.warn(`Could not fetch plugin manifest for ${plugin} (slug: ${slug})`)
1500
+ }
1501
+ pluginManifestVersionsCache.set(plugin, versions)
1502
+ return versions
1503
+ }
1504
+
1505
+ /**
1506
+ * Decide whether new commands under a plugin can truthfully be attributed
1507
+ * to newVersion: only when the baseline snapshot recorded the plugin
1508
+ * version AND that version is the release immediately before newVersion in
1509
+ * the plugin's manifest. Any gap means a "new" command may have shipped in
1510
+ * an intermediate release, so a stamp would fabricate history (30 rpk ai
1511
+ * commands were once labeled 0.2.32 when they shipped in 0.2.26/0.2.28).
1512
+ * @param {string} plugin - rpk command name
1513
+ * @param {string|undefined} oldVersion - Plugin version recorded in the
1514
+ * baseline snapshot's plugin_versions, if any
1515
+ * @param {string} newVersion - Plugin version in this run
1516
+ * @returns {boolean}
1517
+ */
1518
+ function isPluginStampAttributable(plugin, oldVersion, newVersion) {
1519
+ if (!oldVersion || !newVersion) return false
1520
+ if (oldVersion === newVersion) return true
1521
+ const versions = fetchPluginManifestVersions(plugin)
1522
+ if (!versions) return false
1523
+ const oldIdx = versions.indexOf(oldVersion)
1524
+ const newIdx = versions.indexOf(newVersion)
1525
+ return oldIdx !== -1 && newIdx === oldIdx + 1
1526
+ }
1527
+
1528
+ /**
1529
+ * Compute the set of plugins whose new commands may be stamped this run.
1530
+ * @param {Object} oldPluginVersions - plugin_versions from the baseline snapshot
1531
+ * @param {Object} newPluginVersions - plugin_versions for this run
1532
+ * @returns {Set<string>}
1533
+ */
1534
+ function attributablePluginSet(oldPluginVersions = {}, newPluginVersions = {}) {
1535
+ const attributable = new Set()
1536
+ for (const plugin of Object.keys(newPluginVersions)) {
1537
+ if (isPluginStampAttributable(plugin, oldPluginVersions[plugin], newPluginVersions[plugin])) {
1538
+ attributable.add(plugin)
1539
+ }
1540
+ }
1541
+ return attributable
1542
+ }
1543
+
1447
1544
  /**
1448
1545
  * Check whether a plugin node contains real plugin commands, as opposed to
1449
1546
  * only the install/uninstall/upgrade shim compiled into rpk itself.
@@ -1952,7 +2049,12 @@ async function handleRpkDocsGeneration(options = {}) {
1952
2049
  pluginDiffData,
1953
2050
  overridesPath || path.join(dataDir, 'rpk-overrides.json'),
1954
2051
  resolvedVersion,
1955
- { [plugin]: resolvedVersion }
2052
+ { [plugin]: resolvedVersion },
2053
+ {
2054
+ attributablePlugins: isPluginStampAttributable(
2055
+ plugin, (jsonData.plugin_versions || {})[plugin], resolvedVersion
2056
+ ) ? [plugin] : []
2057
+ }
1956
2058
  )
1957
2059
  }
1958
2060
 
@@ -1996,7 +2098,9 @@ async function handleRpkDocsGeneration(options = {}) {
1996
2098
  newDeprecatedCommands: jsonData.deprecated_commands || {}
1997
2099
  })
1998
2100
  if (stampDiff.details.newCommands.length > 0 || stampDiff.details.newFlags.length > 0) {
1999
- updateOverridesWithIntroducedVersions(stampDiff, effectiveOverridesPath, rpkVersion, pluginVersions)
2101
+ updateOverridesWithIntroducedVersions(stampDiff, effectiveOverridesPath, rpkVersion, pluginVersions, {
2102
+ attributablePlugins: attributablePluginSet(oldDataForStamp.plugin_versions, pluginVersions)
2103
+ })
2000
2104
  }
2001
2105
  }
2002
2106
  }
@@ -2400,7 +2504,9 @@ async function handleRpkDocsGeneration(options = {}) {
2400
2504
  // cadence, so the rpk version would be wrong and the page note would
2401
2505
  // render "introduced in <plugin> version <rpk version>").
2402
2506
  if ((diffData.details.newCommands.length > 0 || diffData.details.newFlags.length > 0) && effectiveOverridesPath) {
2403
- updateOverridesWithIntroducedVersions(diffData, effectiveOverridesPath, rpkVersion, pluginVersions)
2507
+ updateOverridesWithIntroducedVersions(diffData, effectiveOverridesPath, rpkVersion, pluginVersions, {
2508
+ attributablePlugins: attributablePluginSet(oldData.plugin_versions, pluginVersions)
2509
+ })
2404
2510
  }
2405
2511
 
2406
2512
  // Update what's-new file if requested
@@ -2819,6 +2925,11 @@ module.exports = {
2819
2925
  getPlatformDescription,
2820
2926
  getCurrentPlatform,
2821
2927
  updateOverridesWithIntroducedVersions,
2928
+ isPluginStampAttributable,
2929
+ attributablePluginSet,
2930
+ fetchPluginManifestVersions,
2931
+ // Exported for tests to seed manifest data without network access
2932
+ pluginManifestVersionsCache,
2822
2933
  filterDiffForWhatsNew,
2823
2934
  computeDescriptionCoverage,
2824
2935
  updateWhatsNewFile,
@@ -1,11 +1,10 @@
1
- {{!-- Cloud-only wrapper start --}}
2
- {{#if cloudOnly}}
3
- ifdef::env-cloud[]
4
- {{/if}}
5
- {{!-- Self-hosted-only wrapper start --}}
6
- {{#if selfHostedOnly}}
7
- ifndef::env-cloud[]
8
- {{/if}}
1
+ {{!-- cloudOnly/selfHostedOnly deliberately do NOT wrap the page. The
2
+ gating that matters happens inside the single-source tag region
3
+ (subcommand rows, flags, content items): includes only extract that
4
+ region, and the docs component never builds with env-cloud set, so a
5
+ page-level conditional is dead code at best and an empty, untitled
6
+ page at worst. Availability pages are excluded from cloud by not
7
+ generating a stub for them in cloud-docs. --}}
9
8
  = {{commandPath}}
10
9
  :description: {{{wrapDescriptionPassthrough shortDesc}}}
11
10
  {{#if pageAliases}}
@@ -583,11 +582,3 @@ include::{{{this}}}[]
583
582
  {{> content-item this commandPath=../commandPath}}
584
583
  {{/each}}
585
584
  // end::single-source[]
586
- {{!-- Self-hosted-only wrapper end --}}
587
- {{#if selfHostedOnly}}
588
- endif::[]
589
- {{/if}}
590
- {{!-- Cloud-only wrapper end --}}
591
- {{#if cloudOnly}}
592
- endif::[]
593
- {{/if}}
@@ -1,161 +0,0 @@
1
- {{#if category}}
2
- // tag::category-{{category}}[]
3
- {{/if}}
4
- {{#if exclude_from_docs}}
5
- // tag::exclude-from-docs[]
6
- {{/if}}
7
- {{#if is_deprecated}}
8
- // tag::deprecated[]
9
- {{/if}}
10
- {{#if cloud_supported}}
11
- // tag::redpanda-cloud[]
12
- {{/if}}
13
- === {{name}}
14
- {{#if version}}
15
-
16
- ifndef::env-cloud[]
17
- *Introduced in {{version}}*
18
- endif::[]
19
- {{/if}}
20
- {{#if description}}
21
-
22
- {{{description}}}
23
- {{else}}
24
-
25
- No description available.
26
-
27
- {{/if}}
28
- {{#if is_enterprise}}
29
-
30
- ifndef::env-cloud[]
31
- *Enterprise license required*: `{{enterprise_value}}` (for license details, see xref:get-started:licensing/index.adoc[Redpanda Licensing])
32
- endif::[]
33
- {{/if}}
34
- {{#if cloud_byoc_only}}
35
-
36
- ifdef::env-cloud[]
37
- NOTE: This property is available only in Redpanda Cloud BYOC deployments.
38
- endif::[]
39
-
40
- {{/if}}
41
- {{#if units}}
42
-
43
- *Unit:* {{units}}
44
- {{else}}
45
- {{#if (formatUnits name)}}
46
-
47
- *Unit:* {{formatUnits name}}
48
- {{/if}}
49
- {{/if}}
50
- {{#if (ne defined_in "src/v/config/node_config.cc")}}
51
- {{#if (ne needs_restart undefined)}}
52
-
53
- *Requires restart:* {{#if needs_restart}}Yes{{else}}No{{/if}}
54
- {{/if}}
55
- {{/if}}
56
-
57
- ifndef::env-cloud[]
58
- *Restored during xref:manage:whole-cluster-restore.adoc[Whole Cluster Restore]:* {{#if (ne gets_restored false)}}Yes{{else}}No{{/if}}
59
- endif::[]
60
- {{#if visibility}}
61
-
62
- // tag::self-managed-only[]
63
- *Visibility:* `{{visibility}}`
64
- // end::self-managed-only[]
65
- {{/if}}
66
- {{#if type}}
67
-
68
- *Type:* {{type}}
69
- {{/if}}
70
- {{#if (and minimum maximum)}}
71
-
72
- *Accepted values:* [`{{minimum}}`, `{{maximum}}`]
73
- {{else}}
74
- {{#if minimum}}
75
-
76
- *Minimum value:* `{{minimum}}`
77
- {{/if}}
78
- {{#if maximum}}
79
-
80
- *Maximum value:* `{{maximum}}`
81
- {{/if}}
82
- {{/if}}
83
- {{#if (ne default undefined)}}
84
-
85
- ifdef::env-cloud[]
86
- *Default:* Available in the Redpanda Cloud Console
87
- endif::[]
88
- ifndef::env-cloud[]
89
- *Default:* `{{formatPropertyValue default type}}`
90
- endif::[]
91
- {{/if}}
92
-
93
- *Nullable:* {{#if nullable}}Yes{{else}}No{{/if}}
94
- {{#if example}}
95
-
96
- {{{renderPropertyExample this}}}
97
- {{/if}}
98
- {{#if related_topics}}
99
- {{#with (allTopicsConditional related_topics) as |sectionType|}}
100
-
101
- {{#if (eq sectionType "cloud")}}
102
- ifdef::env-cloud[]
103
- *Related topics:*
104
-
105
- {{#each ../related_topics}}
106
- {{#with (parseRelatedTopic this)}}
107
- * {{{value}}}
108
- {{/with}}
109
- {{/each}}
110
- endif::[]
111
- {{else if (eq sectionType "self-managed")}}
112
- ifndef::env-cloud[]
113
- *Related topics:*
114
-
115
- {{#each ../related_topics}}
116
- {{#with (parseRelatedTopic this)}}
117
- * {{{value}}}
118
- {{/with}}
119
- {{/each}}
120
- endif::[]
121
- {{else}}
122
- *Related topics:*
123
-
124
- {{#each ../related_topics}}
125
- {{#with (parseRelatedTopic this)}}
126
- {{#if (eq type "cloud")}}
127
- ifdef::env-cloud[]
128
- * {{{value}}}
129
- endif::[]
130
- {{else if (eq type "self-managed")}}
131
- ifndef::env-cloud[]
132
- * {{{value}}}
133
- endif::[]
134
- {{else}}
135
- * {{{value}}}
136
- {{/if}}
137
- {{/with}}
138
- {{/each}}
139
- {{/if}}
140
- {{/with}}
141
- {{/if}}
142
- {{#if aliases}}
143
-
144
- // tag::self-managed-only[]
145
- *Aliases:* {{join aliases ", "}}
146
- // end::self-managed-only[]
147
- {{/if}}
148
-
149
- ---
150
- {{#if cloud_supported}}
151
- // end::redpanda-cloud[]
152
- {{/if}}
153
- {{#if is_deprecated}}
154
- // end::deprecated[]
155
- {{/if}}
156
- {{#if exclude_from_docs}}
157
- // end::exclude-from-docs[]
158
- {{/if}}
159
- {{#if category}}
160
- // end::category-{{category}}[]
161
- {{/if}}
@@ -1,148 +0,0 @@
1
- {{#if category}}
2
- // tag::category-{{category}}[]
3
- {{/if}}
4
- {{#if exclude_from_docs}}
5
- // tag::exclude-from-docs[]
6
- {{/if}}
7
- {{#if is_deprecated}}
8
- // tag::deprecated[]
9
- {{/if}}
10
- {{#if cloud_supported}}
11
- // tag::redpanda-cloud[]
12
- {{/if}}
13
- === {{name}}
14
- {{#if version}}
15
-
16
- *Introduced in {{version}}*
17
- {{/if}}
18
- {{#if description}}
19
-
20
- {{{description}}}
21
- {{else}}
22
-
23
- No description available.
24
- {{/if}}
25
- {{#if is_enterprise}}
26
-
27
- ifndef::env-cloud[]
28
- *Enterprise license required*: `{{enterprise_value}}` (for license details, see xref:get-started:licensing/index.adoc[Redpanda Licensing])
29
- endif::[]
30
- {{/if}}
31
- {{#if cloud_byoc_only}}
32
- ifdef::env-cloud[]
33
- NOTE: This property is only available in Redpanda Cloud BYOC deployments.
34
- endif::[]
35
- {{/if}}
36
- {{#if type}}
37
-
38
- *Type:* {{type}}
39
- {{/if}}
40
- {{#if acceptable_values}}
41
-
42
- *Accepted values:* {{{acceptable_values}}}
43
- {{/if}}
44
- {{#if corresponding_cluster_property}}
45
-
46
- *Related cluster property:* xref:reference:cluster-properties.adoc#{{corresponding_cluster_property}}[{{corresponding_cluster_property}}]
47
- {{/if}}
48
- {{#if (and minimum maximum)}}
49
-
50
- *Accepted values:* [`{{minimum}}`, `{{maximum}}`]
51
- {{else}}
52
- {{#if minimum}}
53
-
54
- *Minimum value:* `{{minimum}}`
55
- {{/if}}
56
- {{#if maximum}}
57
-
58
- *Maximum value:* `{{maximum}}`
59
- {{/if}}
60
- {{/if}}
61
- {{#if (ne default undefined)}}
62
- {{#if cloud_supported}}
63
-
64
- ifdef::env-cloud[]
65
- *Default:* Available in the Redpanda Cloud Console
66
- endif::[]
67
- ifndef::env-cloud[]
68
- *Default:* `{{formatPropertyValue default type}}`
69
- endif::[]
70
- {{else}}
71
-
72
- *Default:* `{{formatPropertyValue default type}}`
73
- {{/if}}
74
- {{/if}}
75
-
76
- *Nullable:* {{#if nullable}}Yes{{else}}No{{/if}}
77
-
78
- ifndef::env-cloud[]
79
- *Restored during xref:manage:whole-cluster-restore.adoc[Whole Cluster Restore]:* {{#if (ne gets_restored false)}}Yes{{else}}No{{/if}}
80
- endif::[]
81
- {{#if example}}
82
-
83
- {{{renderPropertyExample this}}}
84
- {{/if}}
85
- {{#if related_topics}}
86
- {{#with (allTopicsConditional related_topics) as |sectionType|}}
87
-
88
- {{#if (eq sectionType "cloud")}}
89
- ifdef::env-cloud[]
90
- *Related topics:*
91
-
92
- {{#each ../related_topics}}
93
- {{#with (parseRelatedTopic this)}}
94
- * {{{value}}}
95
- {{/with}}
96
- {{/each}}
97
- endif::[]
98
- {{else if (eq sectionType "self-managed")}}
99
- ifndef::env-cloud[]
100
- *Related topics:*
101
-
102
- {{#each ../related_topics}}
103
- {{#with (parseRelatedTopic this)}}
104
- * {{{value}}}
105
- {{/with}}
106
- {{/each}}
107
- endif::[]
108
- {{else}}
109
- *Related topics:*
110
-
111
- {{#each ../related_topics}}
112
- {{#with (parseRelatedTopic this)}}
113
- {{#if (eq type "cloud")}}
114
- ifdef::env-cloud[]
115
- * {{{value}}}
116
- endif::[]
117
- {{else if (eq type "self-managed")}}
118
- ifndef::env-cloud[]
119
- * {{{value}}}
120
- endif::[]
121
- {{else}}
122
- * {{{value}}}
123
- {{/if}}
124
- {{/with}}
125
- {{/each}}
126
- {{/if}}
127
- {{/with}}
128
- {{/if}}
129
- {{#if aliases}}
130
-
131
- // tag::self-managed-only[]
132
- *Aliases:* {{join aliases ", "}}
133
- // end::self-managed-only[]
134
- {{/if}}
135
-
136
- ---
137
- {{#if cloud_supported}}
138
- // end::redpanda-cloud[]
139
- {{/if}}
140
- {{#if is_deprecated}}
141
- // end::deprecated[]
142
- {{/if}}
143
- {{#if exclude_from_docs}}
144
- // end::exclude-from-docs[]
145
- {{/if}}
146
- {{#if category}}
147
- // end::category-{{category}}[]
148
- {{/if}}