@redpanda-data/docs-extensions-and-macros 5.37.2 → 5.37.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redpanda-data/docs-extensions-and-macros",
3
- "version": "5.37.2",
3
+ "version": "5.37.4",
4
4
  "description": "Antora extensions and macros developed for Redpanda documentation.",
5
5
  "keywords": [
6
6
  "antora",
@@ -37,7 +37,7 @@ const fs = require('fs')
37
37
  const os = require('os')
38
38
  const path = require('path')
39
39
  const { spawnSync } = require('child_process')
40
- const { getRpUtilSchema, SCHEMA_FLAGS } = require('./rp-util-fetch')
40
+ const { getRpUtilSchema, schemaExpectation, fetchPublishedSchema, SCHEMA_FLAGS } = require('./rp-util-fetch')
41
41
  const bigIntJson = require('../../cli-utils/big-int-json')
42
42
 
43
43
  function getArg(args, flag) {
@@ -57,13 +57,140 @@ const RELEASE_TAG_RX = /^v?\d+\.\d+\.\d+(-rc\d+)?$/
57
57
  * tag (see the header comment), a warning plus the Tree-sitter fallback for
58
58
  * everything else.
59
59
  */
60
- function handleMergeUnavailable(tag, enhanced, reason) {
60
+ // How long to wait for a schema that is being built right now, and how often
61
+ // to look. streaming-enterprise's dispatch-docs-updates.yml fires the schema
62
+ // publish and this generation from the same job, and the publish is a
63
+ // ~27-minute Bazel build per scope, so on a fresh release tag the schema
64
+ // reliably does not exist yet when we first ask. The docs runner has neither
65
+ // bazel nor docker, so the from-source fallback cannot cover the gap: without
66
+ // waiting, every RC and GA regeneration fails on a race it is guaranteed to
67
+ // lose. Overridable so a one-off backfill need not sit through it.
68
+ /**
69
+ * Read a millisecond knob from the environment, or fall back. Number('') is 0
70
+ * and Number('abc') is NaN, and a 0 or NaN poll interval turns the wait loop
71
+ * into a busy-poll of the GitHub API for the whole budget. `allowZero` is for
72
+ * the wait budget, where 0 is the documented way to disable waiting.
73
+ */
74
+ function parseEnvMs(name, fallback, { allowZero = false } = {}) {
75
+ const raw = process.env[name]
76
+ if (raw === undefined || raw === '') return fallback
77
+ const n = Number(raw)
78
+ if (Number.isInteger(n) && (n > 0 || (allowZero && n === 0))) return n
79
+ // Quoted by hand: this file is held to bigIntJson.stringify by a repo-wide
80
+ // guard test, and a plain string needs no BigInt-aware serializer anyway.
81
+ console.warn(`Warning: ${name}='${raw}' is not a ${allowZero ? 'non-negative' : 'positive'} integer; using ${fallback}.`)
82
+ return fallback
83
+ }
84
+
85
+ const SCHEMA_WAIT_MS = parseEnvMs('RP_UTIL_SCHEMA_WAIT_MS', 45 * 60 * 1000, { allowZero: true })
86
+ const SCHEMA_POLL_MS = parseEnvMs('RP_UTIL_SCHEMA_POLL_MS', 60 * 1000)
87
+ const CLASSIFY_BACKOFF_MS = parseEnvMs('RP_UTIL_CLASSIFY_BACKOFF_MS', 5 * 1000)
88
+
89
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
90
+
91
+ /**
92
+ * Poll for a published schema release until it appears or the budget runs out.
93
+ * @returns {Promise<object|null>} The schemas, or null if none appeared
94
+ */
95
+ function isPermanentLookupError(err) {
96
+ const s = Number(err && err.status)
97
+ return s >= 400 && s < 500 && s !== 404 && s !== 429
98
+ }
99
+
100
+ /**
101
+ * schemaExpectation() with a few retries. It is one GitHub contents call, and
102
+ * a single flaky response used to leave the classification at 'unknown', which
103
+ * skipped the wait entirely and dropped a release straight into the race this
104
+ * module exists to avoid. Only exceptions are retried; a real classification,
105
+ * including 'unknown' from the classifier itself, is returned as-is.
106
+ */
107
+ async function classifyWithRetry(releaseTag, { attempts = 3, backoffMs = CLASSIFY_BACKOFF_MS, sleepFn = sleep } = {}) {
108
+ let lastErr
109
+ for (let i = 1; i <= attempts; i++) {
110
+ try {
111
+ return await schemaExpectation(releaseTag)
112
+ } catch (err) {
113
+ lastErr = err
114
+ console.warn(`Warning: could not classify ${releaseTag} (attempt ${i} of ${attempts}): ${err.message}`)
115
+ if (i < attempts) await sleepFn(backoffMs * i)
116
+ }
117
+ }
118
+ return { expectation: 'unknown', reason: `could not classify ${releaseTag} after ${attempts} attempts: ${lastErr.message}` }
119
+ }
120
+
121
+ async function waitForPublishedSchema(releaseTag, budgetMs, pollMs, now = Date.now) {
122
+ const deadline = now() + budgetMs
123
+ let attempt = 0
124
+ for (;;) {
125
+ attempt++
126
+ let published = null
127
+ try {
128
+ published = await fetchPublishedSchema(releaseTag)
129
+ } catch (err) {
130
+ // A transient failure is not the same as "not published": log and
131
+ // keep waiting. A 4xx other than 404 (which fetchPublishedSchema maps
132
+ // to null) or 429 is permanent: a bad or under-scoped token will not
133
+ // heal in 45 minutes, so surface it now instead of after the budget.
134
+ if (isPermanentLookupError(err)) {
135
+ throw new Error(`Cannot look up the rp_util schema for ${releaseTag}: ${err.message}`)
136
+ }
137
+ console.warn(`Warning: schema lookup for ${releaseTag} failed (attempt ${attempt}): ${err.message}`)
138
+ }
139
+ if (published) {
140
+ console.log(`rp_util schema for ${releaseTag} is published (after ${attempt} check(s)).`)
141
+ return published
142
+ }
143
+ const remaining = deadline - now()
144
+ if (remaining <= 0) return null
145
+ const nap = Math.min(pollMs, remaining)
146
+ console.log(
147
+ `Waiting for the rp_util schema for ${releaseTag} to be published ` +
148
+ `(${Math.round(remaining / 1000)}s of budget left)...`
149
+ )
150
+ await sleep(nap)
151
+ }
152
+ }
153
+
154
+ async function handleMergeUnavailable(tag, enhanced, reason, output = enhanced) {
61
155
  if (RELEASE_TAG_RX.test(tag)) {
62
156
  // Print the canonical v-prefixed tag in the remediation command:
63
157
  // streaming-enterprise tags (and therefore schema release names) are
64
158
  // always v-prefixed, so telling the operator to publish a v-less name
65
159
  // would send them to a workflow run that can never help.
66
160
  const releaseTag = tag.startsWith('v') ? tag : `v${tag}`
161
+
162
+ // Not every release CAN have a schema, and the ones that cannot do not
163
+ // need one. streaming-enterprise#63 added the broker-scope dump flags and
164
+ // the enum_set_property conversions in the same change, so a tag without
165
+ // the flags also has no enum_set properties for the Tree-sitter pass to
166
+ // misparse. Failing those releases forever would block every regeneration
167
+ // of a v26.2.x or older line on a schema that can never be published.
168
+ // See schemaExpectation() for the evidence.
169
+ const expectation = await classifyWithRetry(releaseTag)
170
+
171
+ if (expectation.expectation === 'unavailable') {
172
+ console.warn(`Warning: ${reason}`)
173
+ console.warn(`No rp_util schema can exist for ${releaseTag}: ${expectation.reason}`)
174
+ console.warn('Keeping the Tree-sitter-only extraction, which is accurate for this release.')
175
+ markRpUtilMergeUnavailable(enhanced)
176
+ // generate-docs reads --output right after this returns. When it is
177
+ // not the same file as --enhanced, the degraded result has to land
178
+ // there too, or the run exits 0 having left the output stale.
179
+ if (output && output !== enhanced) fs.copyFileSync(enhanced, output)
180
+ return
181
+ }
182
+
183
+ if (expectation.expectation === 'inconsistent') {
184
+ console.error(`Error: ${expectation.reason}`)
185
+ console.error(
186
+ 'This is the one combination that must never ship: neither the schema ' +
187
+ 'nor the Tree-sitter extraction can describe those properties. Backport ' +
188
+ 'the rp_util schema flags to this branch before regenerating.'
189
+ )
190
+ process.exitCode = 1
191
+ return
192
+ }
193
+
67
194
  console.error(`Error: ${reason}`)
68
195
  console.error(
69
196
  `Refusing to publish Tree-sitter-only property data for release ${tag}: ` +
@@ -138,14 +265,46 @@ async function main() {
138
265
 
139
266
  console.log(`Fetching rp_util schema for ${tag}...`)
140
267
  let schemas
268
+
269
+ // On a release tag whose rp_util can dump every scope, the schema is either
270
+ // already published or being built right now, so wait for it instead of
271
+ // racing the publisher. A tag that can never have one skips the wait and
272
+ // falls through to the classification in handleMergeUnavailable.
273
+ if (!sourcePath && RELEASE_TAG_RX.test(tag) && SCHEMA_WAIT_MS > 0) {
274
+ const releaseTag = tag.startsWith('v') ? tag : `v${tag}`
275
+ const expectation = await classifyWithRetry(releaseTag)
276
+ if (expectation.expectation === 'required') {
277
+ let waited
278
+ try {
279
+ waited = await waitForPublishedSchema(releaseTag, SCHEMA_WAIT_MS, SCHEMA_POLL_MS)
280
+ } catch (err) {
281
+ // 'required' means degrading is not an option, so a permanent
282
+ // lookup failure is the run's failure.
283
+ console.error(`Error: ${err.message}`)
284
+ process.exitCode = 1
285
+ return
286
+ }
287
+ if (waited) {
288
+ schemas = { ...waited, sourcePath: null }
289
+ } else {
290
+ console.warn(
291
+ `Warning: no rp_util schema for ${releaseTag} after waiting ` +
292
+ `${Math.round(SCHEMA_WAIT_MS / 60000)} minutes.`
293
+ )
294
+ }
295
+ }
296
+ }
297
+
141
298
  try {
142
299
  // --source-path builds from an existing local streaming-enterprise
143
300
  // checkout instead of fetching a published release or cloning+building
144
301
  // fresh -- same option rp-util-fetch.js itself exposes, useful for
145
302
  // local iteration against a branch that has no published schema yet.
146
- schemas = await getRpUtilSchema(tag, sourcePath ? { sourcePath } : undefined)
303
+ if (!schemas) {
304
+ schemas = await getRpUtilSchema(tag, sourcePath ? { sourcePath } : undefined)
305
+ }
147
306
  } catch (err) {
148
- handleMergeUnavailable(tag, enhanced, `could not get rp_util schema for ${tag}: ${err.message}`)
307
+ await handleMergeUnavailable(tag, enhanced, `could not get rp_util schema for ${tag}: ${err.message}`, output)
149
308
  return
150
309
  }
151
310
 
@@ -159,7 +318,7 @@ async function main() {
159
318
  }
160
319
  }
161
320
  if (!anySchema) {
162
- handleMergeUnavailable(tag, enhanced, `rp_util schema for ${tag} came back empty.`)
321
+ await handleMergeUnavailable(tag, enhanced, `rp_util schema for ${tag} came back empty.`, output)
163
322
  return
164
323
  }
165
324
 
@@ -188,10 +347,11 @@ async function main() {
188
347
  const result = spawnSync(pythonBin, mergeArgs, { cwd: __dirname, stdio: 'inherit' })
189
348
  if (result.error || result.status !== 0) {
190
349
  fs.rmSync(tempOutput, { force: true })
191
- handleMergeUnavailable(
350
+ await handleMergeUnavailable(
192
351
  tag,
193
352
  enhanced,
194
- `rp_util merge failed (${result.error ? result.error.message : `exit ${result.status}`}).`
353
+ `rp_util merge failed (${result.error ? result.error.message : `exit ${result.status}`}).`,
354
+ output
195
355
  )
196
356
  } else {
197
357
  fs.renameSync(tempOutput, output)
@@ -201,7 +361,16 @@ async function main() {
201
361
  }
202
362
  }
203
363
 
204
- module.exports = { main, markRpUtilMergeUnavailable }
364
+ module.exports = {
365
+ main,
366
+ parseEnvMs,
367
+ classifyWithRetry,
368
+ isPermanentLookupError,
369
+ markRpUtilMergeUnavailable,
370
+ // exported for testing
371
+ waitForPublishedSchema,
372
+ handleMergeUnavailable
373
+ }
205
374
 
206
375
  if (require.main === module) {
207
376
  main()
@@ -1032,6 +1032,85 @@ def report_phantom_stubs():
1032
1032
  logger.warning("=" * 70)
1033
1033
 
1034
1034
 
1035
+ def infer_topic_property_category(name):
1036
+ """Map a topic property's dotted name to a category, for properties with
1037
+ no explicit category override.
1038
+
1039
+ "other" is the fallback for a name that matches no group. It only renders
1040
+ where a page includes tags=category-other: cloud-docs' topic properties
1041
+ page does (cloud-docs#686); docs' topic-properties.adoc and
1042
+ broker-properties.adoc do not yet, so on docs main a property that falls
1043
+ to "other" is present in the partial but appears on no page. The point of
1044
+ the fallback is that such a property is at least reachable by a known tag,
1045
+ rather than untagged and unreachable by any include.
1046
+ """
1047
+ retention = [
1048
+ "cleanup.policy", "compaction.strategy", "delete.retention.ms", "max.compaction.lag.ms",
1049
+ "min.cleanable.dirty.ratio", "min.compaction.lag.ms", "retention.bytes", "retention.ms"
1050
+ ]
1051
+ segment = [
1052
+ "compression.type", "max.message.bytes", "message.timestamp.type", "segment.bytes", "segment.ms"
1053
+ ]
1054
+ performance = [
1055
+ "flush.bytes", "flush.ms", "redpanda.leaders.preference", "replication.factor", "write.caching"
1056
+ ]
1057
+ tiered = [
1058
+ "initial.retention.local.target.bytes", "initial.retention.local.target.ms", "redpanda.remote.delete",
1059
+ "redpanda.remote.read", "redpanda.remote.recovery", "redpanda.remote.write", "retention.local.target.bytes",
1060
+ "retention.local.target.ms"
1061
+ ]
1062
+ remote_replica = ["redpanda.remote.readreplica"]
1063
+ iceberg = [
1064
+ "redpanda.iceberg.delete", "redpanda.iceberg.invalid.record.action", "redpanda.iceberg.mode",
1065
+ "redpanda.iceberg.partition.spec", "redpanda.iceberg.target.lag.ms"
1066
+ ]
1067
+ schema_registry = [
1068
+ "redpanda.key.schema.id.validation", "redpanda.key.subject.name.strategy", "redpanda.value.schema.id.validation",
1069
+ "redpanda.value.subject.name.strategy", "confluent.key.schema.validation", "confluent.key.subject.name.strategy",
1070
+ "confluent.value.schema.validation", "confluent.value.subject.name.strategy"
1071
+ ]
1072
+ if name in retention:
1073
+ return "retention-compaction"
1074
+ if name in segment:
1075
+ return "segment-message"
1076
+ if name in performance:
1077
+ return "performance-cluster"
1078
+ if name in tiered:
1079
+ return "tiered-storage"
1080
+ if name in remote_replica:
1081
+ return "remote-read-replica"
1082
+ if name in iceberg:
1083
+ return "iceberg-integration"
1084
+ if name in schema_registry:
1085
+ return "schema-registry"
1086
+ return "other"
1087
+
1088
+
1089
+ def _ensure_category_fallback(properties):
1090
+ """Guarantee every property has a category, so the property.hbs/
1091
+ topic-property.hbs templates' `{{#if category}}` always wraps it in a
1092
+ tag::category-X[] region. A property with no category at all gets no
1093
+ tag region and is silently excluded by every page that assembles its
1094
+ content from specific category-tag includes -- present in the raw
1095
+ generated partial, invisible everywhere it's actually read from.
1096
+
1097
+ Runs after overrides are applied (including override-created phantom
1098
+ stub properties, which previously never received a category at all)
1099
+ so this covers every property regardless of where it came from.
1100
+ """
1101
+ for prop_name, property_data in properties.items():
1102
+ if not hasattr(property_data, "get") or property_data.get("category"):
1103
+ continue
1104
+ if property_data.get("config_scope") == "topic" or property_data.get("is_topic_property"):
1105
+ property_data["category"] = infer_topic_property_category(property_data.get("name", prop_name))
1106
+ else:
1107
+ # No per-name taxonomy exists for cluster/broker properties today
1108
+ # (unlike topic properties' infer_topic_property_category) --
1109
+ # "other" at least guarantees a real, included tag region
1110
+ # instead of silent exclusion.
1111
+ property_data["category"] = "other"
1112
+
1113
+
1035
1114
  def apply_property_overrides(properties, overrides, overrides_file_path=None):
1036
1115
  """
1037
1116
  Apply overrides from an overrides mapping to the extracted properties, mutating and returning the properties dictionary.
@@ -1114,6 +1193,8 @@ def apply_property_overrides(properties, overrides, overrides_file_path=None):
1114
1193
  for property_data in properties.values():
1115
1194
  if hasattr(property_data, "get") and property_data.get("description"):
1116
1195
  property_data["description"] = _normalize_config_ref_macros(property_data["description"])
1196
+
1197
+ _ensure_category_fallback(properties)
1117
1198
  return properties
1118
1199
 
1119
1200
 
@@ -2844,49 +2925,6 @@ def extract_topic_properties(source_path, cluster_properties=None):
2844
2925
  if prop_data.get("is_noop", False):
2845
2926
  continue
2846
2927
 
2847
- # Assign category based on property name pattern or mapping
2848
- def infer_category(name):
2849
- retention = [
2850
- "cleanup.policy", "compaction.strategy", "delete.retention.ms", "max.compaction.lag.ms",
2851
- "min.cleanable.dirty.ratio", "min.compaction.lag.ms", "retention.bytes", "retention.ms"
2852
- ]
2853
- segment = [
2854
- "compression.type", "max.message.bytes", "message.timestamp.type", "segment.bytes", "segment.ms"
2855
- ]
2856
- performance = [
2857
- "flush.bytes", "flush.ms", "redpanda.leaders.preference", "replication.factor", "write.caching"
2858
- ]
2859
- tiered = [
2860
- "initial.retention.local.target.bytes", "initial.retention.local.target.ms", "redpanda.remote.delete",
2861
- "redpanda.remote.read", "redpanda.remote.recovery", "redpanda.remote.write", "retention.local.target.bytes",
2862
- "retention.local.target.ms"
2863
- ]
2864
- remote_replica = ["redpanda.remote.readreplica"]
2865
- iceberg = [
2866
- "redpanda.iceberg.delete", "redpanda.iceberg.invalid.record.action", "redpanda.iceberg.mode",
2867
- "redpanda.iceberg.partition.spec", "redpanda.iceberg.target.lag.ms"
2868
- ]
2869
- schema_registry = [
2870
- "redpanda.key.schema.id.validation", "redpanda.key.subject.name.strategy", "redpanda.value.schema.id.validation",
2871
- "redpanda.value.subject.name.strategy", "confluent.key.schema.validation", "confluent.key.subject.name.strategy",
2872
- "confluent.value.schema.validation", "confluent.value.subject.name.strategy"
2873
- ]
2874
- if name in retention:
2875
- return "retention-compaction"
2876
- if name in segment:
2877
- return "segment-message"
2878
- if name in performance:
2879
- return "performance-cluster"
2880
- if name in tiered:
2881
- return "tiered-storage"
2882
- if name in remote_replica:
2883
- return "remote-read-replica"
2884
- if name in iceberg:
2885
- return "iceberg-integration"
2886
- if name in schema_registry:
2887
- return "schema-registry"
2888
- return "other"
2889
-
2890
2928
  converted_properties[prop_name] = {
2891
2929
  "name": prop_name,
2892
2930
  "description": prop_data.get("description", ""),
@@ -2900,7 +2938,7 @@ def extract_topic_properties(source_path, cluster_properties=None):
2900
2938
  "acceptable_values": prop_data.get("acceptable_values", ""),
2901
2939
  "is_deprecated": False,
2902
2940
  "is_topic_property": True,
2903
- "category": infer_category(prop_name)
2941
+ "category": infer_topic_property_category(prop_name)
2904
2942
  }
2905
2943
 
2906
2944
  # Add default values if they exist (inherited from cluster properties)
@@ -276,6 +276,94 @@ const RELEASE_ASSET_TO_SCHEMA_KEY = {
276
276
  'schema-registry-config-schema.json': 'schemaRegistrySchema'
277
277
  }
278
278
 
279
+ // Files schemaExpectation() reads to decide whether a ref can have a schema.
280
+ // Both were changed by the same PR (streaming-enterprise#63), which is what
281
+ // makes the pairing in schemaExpectation() a property of the source rather
282
+ // than a guess.
283
+ const SE_SOURCE_REPO = 'redpanda-data/streaming-enterprise'
284
+ const RP_UTIL_MAIN_PATH = 'src/v/rp_util/main.cc'
285
+ const CONFIGURATION_H_PATH = 'src/v/config/configuration.h'
286
+
287
+ /**
288
+ * Read one file from streaming-enterprise at a ref.
289
+ * @returns {Promise<string|null>} Contents, or null if it cannot be read
290
+ */
291
+ async function fetchSourceFile(ref, filePath) {
292
+ const token = getGitHubApiToken()
293
+ const headers = { Accept: 'application/vnd.github.raw' }
294
+ if (token) headers.Authorization = `Bearer ${token}`
295
+ const resp = await fetch(
296
+ `https://api.github.com/repos/${SE_SOURCE_REPO}/contents/${filePath}?ref=${encodeURIComponent(ref)}`,
297
+ { headers }
298
+ )
299
+ if (!resp.ok) return null
300
+ return resp.text()
301
+ }
302
+
303
+ /**
304
+ * Whether a ref can produce an rp_util schema, and whether it needs one.
305
+ *
306
+ * These two are the same question, because they arrived in the same commit.
307
+ * streaming-enterprise#63 added both the four broker-scope dump flags to
308
+ * rp_util and the `enum_set_property` conversions that the Tree-sitter
309
+ * extractor misparses. So a ref either has both or neither:
310
+ *
311
+ * ref enum_set_property broker-scope flags
312
+ * v26.2.1 0 0/4
313
+ * v26.2.2 0 0/4
314
+ * v26.2.3-rc1 0 0/4
315
+ * v26.2.x (branch) 3 4/4
316
+ * dev 3 4/4
317
+ *
318
+ * That is what makes degrading safe for an old tag: a tag with no flags can
319
+ * never have a schema, and equally has no enum_set properties to misparse, so
320
+ * the Tree-sitter extraction is correct for it rather than merely tolerable.
321
+ *
322
+ * The flags are matched QUOTED. `"config_schema_json"` must not match inside
323
+ * `"node_config_schema_json"`, and the leading quote is what prevents it.
324
+ *
325
+ * @param {string} ref - Tag, branch, or SHA
326
+ * @returns {Promise<{expectation: string, reason: string}>} expectation is one
327
+ * of 'required' (schema needed and obtainable), 'unavailable' (this ref can
328
+ * never have one, and does not need one), 'inconsistent' (needs one but
329
+ * cannot have one, which must never ship), or 'unknown' (could not tell)
330
+ */
331
+ async function schemaExpectation(ref) {
332
+ const mainCc = await fetchSourceFile(ref, RP_UTIL_MAIN_PATH)
333
+ if (mainCc === null) {
334
+ return { expectation: 'unknown', reason: `could not read ${RP_UTIL_MAIN_PATH} at ${ref}` }
335
+ }
336
+ const missingFlags = SCHEMA_FLAGS
337
+ .map(({ flag }) => flag.replace(/^--/, ''))
338
+ .filter((name) => !mainCc.includes(`"${name}"`))
339
+
340
+ if (missingFlags.length === 0) {
341
+ return { expectation: 'required', reason: `rp_util at ${ref} dumps every scope` }
342
+ }
343
+
344
+ // No flags. Confirm the other half of the pairing rather than assuming it:
345
+ // if this ref somehow carries enum_set properties without the dump flags,
346
+ // the Tree-sitter fallback would silently drop them and degrading would be
347
+ // the wrong call.
348
+ const configurationH = await fetchSourceFile(ref, CONFIGURATION_H_PATH)
349
+ if (configurationH === null) {
350
+ return {
351
+ expectation: 'unknown',
352
+ reason: `rp_util at ${ref} cannot dump ${missingFlags.join(', ')}, and ${CONFIGURATION_H_PATH} could not be read to confirm it has no enum_set properties`
353
+ }
354
+ }
355
+ if (configurationH.includes('enum_set_property')) {
356
+ return {
357
+ expectation: 'inconsistent',
358
+ reason: `${ref} declares enum_set properties but its rp_util cannot dump ${missingFlags.join(', ')}, so neither the schema nor the Tree-sitter extraction can describe them correctly`
359
+ }
360
+ }
361
+ return {
362
+ expectation: 'unavailable',
363
+ reason: `rp_util at ${ref} predates the broker-scope dumps (${missingFlags.join(', ')}) and ${ref} declares no enum_set properties, so the Tree-sitter extraction is correct for it`
364
+ }
365
+ }
366
+
279
367
  /**
280
368
  * Fetch rp_util's schema from a previously published GitHub release
281
369
  * (publish-rp-util-schema.yaml's rp-util-schema-<tag> release in this repo)
@@ -296,10 +384,10 @@ async function fetchPublishedSchema(tag) {
296
384
  )
297
385
  if (releaseResp.status === 404) return null
298
386
  if (!releaseResp.ok) {
299
- throw new Error(
387
+ throw Object.assign(new Error(
300
388
  `Failed to look up published rp_util schema release for ${tag}: ` +
301
389
  `${releaseResp.status} ${releaseResp.statusText}`
302
- )
390
+ ), { status: releaseResp.status })
303
391
  }
304
392
  const release = await releaseResp.json()
305
393
 
@@ -333,9 +421,9 @@ async function fetchPublishedSchema(tag) {
333
421
  headers: { ...headers, Accept: 'application/octet-stream' }
334
422
  })
335
423
  if (!assetResp.ok) {
336
- throw new Error(
424
+ throw Object.assign(new Error(
337
425
  `Failed to download published rp_util schema asset ${asset.name}: ${assetResp.status}`
338
- )
426
+ ), { status: assetResp.status })
339
427
  }
340
428
  let payload
341
429
  try {
@@ -437,6 +525,7 @@ async function getRpUtilSchema(ref, options = {}) {
437
525
 
438
526
  module.exports = {
439
527
  getRpUtilSchema,
528
+ schemaExpectation,
440
529
  SCHEMA_FLAGS,
441
530
  // exported for testing
442
531
  cloneStreamingEnterprise,