@redpanda-data/docs-extensions-and-macros 5.48.1 → 5.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -421,11 +421,22 @@ const RULES = [
421
421
  }
422
422
  ]
423
423
 
424
+ /**
425
+ * Where a declaration lives, for removal matching. json names repeat across
426
+ * structs (`enabled`, `name`), so the key is the Go package directory, the
427
+ * struct and the json name. Go type names are unique per package, and a
428
+ * package is one directory.
429
+ */
430
+ function identity (decl) {
431
+ return [path.dirname(decl.file), (decl.meta && decl.meta.struct) || null, decl.name]
432
+ }
433
+
424
434
  module.exports = {
425
435
  name: 'crd',
426
436
  convention: CONVENTION,
427
437
  extract,
428
438
  scanFile,
439
+ identity,
429
440
  loadConfig,
430
441
  collectInlinedTypes,
431
442
  UNDESCRIBED_EXTERNAL_TYPES,
@@ -472,6 +472,15 @@ const RULES = [
472
472
  }
473
473
  ]
474
474
 
475
+ /**
476
+ * Where a declaration lives, for removal matching. The name is the full
477
+ * values path, unique inside one values.yaml but repeated across charts, so
478
+ * the key is the file, the kind and the path.
479
+ */
480
+ function identity (decl) {
481
+ return [decl.file, (decl.meta && decl.meta.kind) || null, decl.name]
482
+ }
483
+
475
484
  module.exports = {
476
485
  name: 'helm',
477
486
  convention: CONVENTION,
@@ -480,6 +489,7 @@ module.exports = {
480
489
  publishableBlankKeys,
481
490
  scanKeyLines,
482
491
  misattachedMarkers,
492
+ identity,
483
493
  HELM_DOCS_MARKER_RE,
484
494
  rules: RULES
485
495
  }
@@ -197,7 +197,7 @@ function unescapeCpp (text) {
197
197
  * preceding make_* call that still encloses it, then read its first argument
198
198
  * if that argument is a string literal (adjacent literals concatenated).
199
199
  */
200
- function resolveMetricName (content, descIndex) {
200
+ function resolveMetricName (content, descIndex, where = null) {
201
201
  const candidates = []
202
202
  MAKE_CALL.lastIndex = 0
203
203
  let match
@@ -213,6 +213,8 @@ function resolveMetricName (content, descIndex) {
213
213
  let i = openIndex + 1
214
214
  let name = ''
215
215
  let sawLiteral = false
216
+ let firstLiteral = -1
217
+ let lastLiteralEnd = -1
216
218
  while (i < content.length) {
217
219
  const ch = content[i]
218
220
  if (/\s/.test(ch)) {
@@ -228,11 +230,17 @@ function resolveMetricName (content, descIndex) {
228
230
  const end = skipLiteral(content, i)
229
231
  name += unescapeCpp(content.slice(i + 1, end - 1))
230
232
  sawLiteral = true
233
+ if (firstLiteral === -1) firstLiteral = i
234
+ lastLiteralEnd = end - 1
231
235
  i = end
232
236
  continue
233
237
  }
234
238
  break
235
239
  }
240
+ if (sawLiteral && where) {
241
+ where.start = firstLiteral
242
+ where.end = lastLiteralEnd
243
+ }
236
244
  return sawLiteral ? name : null
237
245
  }
238
246
  return null
@@ -268,16 +276,24 @@ function scanFile (content, file) {
268
276
  if (closeIndex === -1) continue
269
277
 
270
278
  const { verifiable, value } = parseArgument(masked.slice(openIndex + 1, closeIndex))
279
+ // The name literal sits in the enclosing make_*() call, outside the
280
+ // description span. Its lines are recorded so a rename counts as touching
281
+ // the declaration without widening the span a suggestion replaces.
282
+ const where = {}
283
+ const name = resolveMetricName(masked, match.index, where)
271
284
  declarations.push({
272
285
  surface: 'metrics',
273
- name: resolveMetricName(masked, match.index),
286
+ name,
274
287
  file,
275
288
  line_start: lineOf(masked, match.index),
276
289
  line_end: lineOf(masked, closeIndex),
277
290
  string: value,
278
291
  declaration_text: null,
279
292
  convention: CONVENTION,
280
- meta: { unverifiable: !verifiable }
293
+ meta: {
294
+ unverifiable: !verifiable,
295
+ name_lines: where.start != null ? [lineOf(masked, where.start), lineOf(masked, where.end)] : null
296
+ }
281
297
  })
282
298
  DESCRIPTION_CALL.lastIndex = closeIndex + 1
283
299
  }
@@ -359,10 +375,21 @@ const RULES = [
359
375
  }
360
376
  ]
361
377
 
378
+ /**
379
+ * Where a declaration lives, for removal matching. The extracted name is the
380
+ * make_*() argument without its add_group() prefix, so the same name repeats
381
+ * across groups; the file narrows it. Same-file repeats are handled by
382
+ * counting in the caller.
383
+ */
384
+ function identity (decl) {
385
+ return [decl.file, decl.name]
386
+ }
387
+
362
388
  module.exports = {
363
389
  name: 'metrics',
364
390
  convention: CONVENTION,
365
391
  extract,
366
392
  scanFile,
393
+ identity,
367
394
  rules: RULES
368
395
  }
@@ -242,6 +242,12 @@ const RULES = [
242
242
 
243
243
  module.exports = {
244
244
  name: 'properties',
245
+ // The extractor pairs each .cc with its .h, so a merge-base extraction needs
246
+ // the whole directory, not only the files that lost lines.
247
+ baseScope: 'directory',
248
+ // extract() runs the extractor over the whole repo and filters after, so
249
+ // page context can use every property at no extra cost.
250
+ contextScope: 'surface',
245
251
  convention: CONVENTION,
246
252
  extract,
247
253
  mapExtractorJson,
@@ -37,7 +37,15 @@ const CONVENTION = {
37
37
  case: 'sentence',
38
38
  terminal_period: false,
39
39
  verbatim_asciidoc: false,
40
- transformer: 'formatDescription'
40
+ transformer: 'formatDescription',
41
+ // Code values formatDescription wraps in inline code by itself, so a bare
42
+ // one in the source is not a finding. Long is a Go raw string and cannot
43
+ // hold a backtick at all, which is why the generator does this. Keep in
44
+ // step with the auto-backtick passes in tools/rpk-docs/generate-rpk-docs.js.
45
+ auto_inline_code: {
46
+ kinds: ['flag'],
47
+ path_prefixes: ['/etc/', '/var/', '/usr/', '/home/', '/tmp/', '~/.']
48
+ }
41
49
  }
42
50
 
43
51
  // Composite literal only: gofmt writes `cobra.Command{` with no space, while
@@ -365,11 +373,22 @@ const RULES = [
365
373
  }
366
374
  ]
367
375
 
376
+ /**
377
+ * Where a declaration lives, for removal matching. Flag names (`format`,
378
+ * `brokers`) and command names (`list`, `create`) repeat across the command
379
+ * tree, which the extractor does not resolve, so the key is the file, the
380
+ * kind and the name. Same-file repeats are handled by counting in the caller.
381
+ */
382
+ function identity (decl) {
383
+ return [decl.file, (decl.meta && decl.meta.kind) || null, decl.name]
384
+ }
385
+
368
386
  module.exports = {
369
387
  name: 'rpk',
370
388
  convention: CONVENTION,
371
389
  extract,
372
390
  scanFile,
391
+ identity,
373
392
  rules: RULES,
374
393
  // Shorts and flag usages are one-liners by convention; the generic
375
394
  // too-short prose rule would flag nearly every conforming declaration.
@@ -326,7 +326,7 @@ function deepEqual (a, b) {
326
326
  */
327
327
  function row (fields) {
328
328
  const base = { name: fields.name, field: fields.field, class: fields.class }
329
- for (const key of ['upstream_candidate_text', 'audience_scoped_text', 'upstream_ref', 'content_hash', 'source_file', 'source_line', 'note']) {
329
+ for (const key of ['upstream_candidate_text', 'audience_scoped_text', 'upstream_ref', 'content_hash', 'source_file', 'source_line', 'source_text', 'note']) {
330
330
  if (fields[key] !== undefined) base[key] = fields[key]
331
331
  }
332
332
  return base
@@ -357,7 +357,11 @@ function classifyDescription (name, override, sourceProp, opts = {}) {
357
357
  field: 'description',
358
358
  content_hash: contentHash(name, overrideText),
359
359
  source_file: sourceProp ? sourceProp.defined_in : undefined,
360
- source_line: sourceProp && sourceProp.line_start !== undefined ? sourceProp.line_start : undefined
360
+ source_line: sourceProp && sourceProp.line_start !== undefined ? sourceProp.line_start : undefined,
361
+ // Raw source description text, carried through so the agent triage layer
362
+ // (tools/overrides-audit/triage.js) can compare it against the override
363
+ // candidate text without re-reading the extracted-properties JSON itself.
364
+ source_text: sourceProp ? normalizeText(sourceProp.description) : undefined
361
365
  }
362
366
  if (override.upstream_ref !== undefined) common.upstream_ref = override.upstream_ref
363
367
  // Shown, never upstreamed. A scoped paragraph exists precisely so the other
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Overrides Audit - Triage Report Sections
3
+ *
4
+ * Formats triaged candidates (classify.js rows extended with agent_verdict /
5
+ * agent_reason / triage_failed by triage.js's triageCandidate) into the
6
+ * plain-English markdown sections a downstream workflow glues into PR and
7
+ * issue bodies. This module never calls `gh` itself; it only builds strings.
8
+ *
9
+ * Every builder here returns a non-empty string even for an empty input
10
+ * array, so a caller can always embed the return value directly into a
11
+ * larger PR/issue body without checking for emptiness first.
12
+ */
13
+
14
+ 'use strict'
15
+
16
+ /**
17
+ * Render one candidate's property name as a markdown subsection heading.
18
+ *
19
+ * @param {string} name - Property (or command) name.
20
+ * @returns {string} Markdown heading line.
21
+ */
22
+ function heading (name) {
23
+ return `## ${name}`
24
+ }
25
+
26
+ /**
27
+ * Render a labeled block of text as an indented markdown blockquote, so long
28
+ * override/source text cannot be confused with the surrounding prose.
29
+ *
30
+ * @param {string} label - Label line, e.g. "Override text:".
31
+ * @param {string} text - The text to quote.
32
+ * @returns {string} Markdown block.
33
+ */
34
+ function quotedBlock (label, text) {
35
+ const quoted = String(text)
36
+ .split('\n')
37
+ .map((line) => `> ${line}`)
38
+ .join('\n')
39
+ return `${label}\n\n${quoted}`
40
+ }
41
+
42
+ /**
43
+ * Build the "nothing happened this run" placeholder for an empty section.
44
+ * Never an empty string: this is meant to be embedded directly into a larger
45
+ * PR/issue body, and an empty string there reads as a rendering bug rather
46
+ * than "there was nothing to report."
47
+ *
48
+ * @param {string} noun - What there was none of, e.g. "properties to upstream".
49
+ * @returns {string} A one-line markdown message.
50
+ */
51
+ function nothingThisRun (noun) {
52
+ return `Nothing to report: no ${noun} this run.`
53
+ }
54
+
55
+ /**
56
+ * Whether a row carries an explicitly successful triage state.
57
+ *
58
+ * triageCandidate always emits a boolean triage_failed, so anything other
59
+ * than exactly false (missing, null, a string, a number) is a row this code
60
+ * cannot trust. Such a row stays out of the actionable sections and lands in
61
+ * the ambiguous digest instead.
62
+ *
63
+ * @param {Object} row - A triaged candidate row.
64
+ * @returns {boolean} True only when triage_failed is exactly false.
65
+ */
66
+ function triageSucceeded (row) {
67
+ return row.triage_failed === false
68
+ }
69
+
70
+ /**
71
+ * Build the markdown section listing candidates the agent triage layer
72
+ * decided are genuinely better in the override and should be ported
73
+ * upstream into engineering source (verdict UPSTREAM_OVERRIDE).
74
+ *
75
+ * @param {Object[]} candidates - Triaged rows (from triage.js's triageCandidate).
76
+ * @returns {string} Markdown section, always non-empty.
77
+ */
78
+ function buildUpstreamSection (candidates) {
79
+ const rows = (candidates || []).filter((c) => c.agent_verdict === 'UPSTREAM_OVERRIDE' && triageSucceeded(c))
80
+ if (rows.length === 0) return nothingThisRun('property descriptions to upstream')
81
+
82
+ const parts = [
83
+ `Found ${rows.length} property description${rows.length === 1 ? '' : 's'} worth porting upstream into engineering source.`
84
+ ]
85
+ for (const row of rows) {
86
+ parts.push([
87
+ heading(row.name),
88
+ row.agent_reason,
89
+ // A SPLIT row's override also carries audience-scoped paragraphs or
90
+ // docs-only markup that the candidate text leaves out, so porting the
91
+ // prose alone does not make the override redundant.
92
+ row.class === 'KEEP_UNTIL_UPSTREAMED'
93
+ ? 'This PR ports that into the source description. The docs override stays after it ships, because it also carries audience-scoped paragraphs or docs-only markup that source does not; that content needs handling separately before the override can retire.'
94
+ : 'This PR ports that into the source description; once merged and released, the docs override becomes redundant and retires itself automatically.'
95
+ ].join('\n\n'))
96
+ }
97
+ return parts.join('\n\n')
98
+ }
99
+
100
+ /**
101
+ * Build the markdown section listing candidates the agent triage layer
102
+ * decided source has caught up to or surpassed, so the docs-side override
103
+ * should simply be deleted (verdict RETIRE_OVERRIDE). Engineering source is
104
+ * never touched for these.
105
+ *
106
+ * @param {Object[]} candidates - Triaged rows (from triage.js's triageCandidate).
107
+ * @returns {string} Markdown section, always non-empty.
108
+ */
109
+ function buildRetirementSection (candidates) {
110
+ const rows = (candidates || []).filter((c) => c.agent_verdict === 'RETIRE_OVERRIDE' && triageSucceeded(c))
111
+ if (rows.length === 0) return nothingThisRun('overrides to retire')
112
+
113
+ const parts = [
114
+ `Found ${rows.length} docs override${rows.length === 1 ? '' : 's'} that source has caught up to; retiring them rather than touching engineering source.`
115
+ ]
116
+ for (const row of rows) {
117
+ parts.push([
118
+ heading(row.name),
119
+ row.agent_reason,
120
+ quotedBlock('Override text (current docs override):', row.upstream_candidate_text),
121
+ quotedBlock('Source text (what docs will render instead):', row.source_text),
122
+ '**Retiring this override; docs will now render source\'s description.**'
123
+ ].join('\n\n'))
124
+ }
125
+ return parts.join('\n\n')
126
+ }
127
+
128
+ /**
129
+ * Build one combined markdown digest of every candidate the agent triage
130
+ * layer could not confidently resolve either way: a real AMBIGUOUS verdict,
131
+ * a failed/malformed triage response (triage_failed: true), and a row with
132
+ * no explicit triage state (triage_failed missing or not a boolean) get exactly
133
+ * the same treatment here, because all three are equally unresolved from a
134
+ * human's point of view - a parse failure is not quietly dropped just
135
+ * because it never produced a real verdict.
136
+ *
137
+ * This is one digest, not one artifact per property, matching this
138
+ * codebase's "one rolling artifact, idempotently rebuilt every run" pattern
139
+ * (see tools/rpk-docs/generate-plugin-stubs.js for that idiom elsewhere):
140
+ * it is meant to become the body of a single rolling tracking issue that
141
+ * gets replaced wholesale each run, not appended to.
142
+ *
143
+ * @param {Object[]} candidates - Triaged rows (from triage.js's triageCandidate).
144
+ * @returns {string} Markdown digest, always non-empty.
145
+ */
146
+ function buildAmbiguousDigest (candidates) {
147
+ const rows = (candidates || []).filter((c) => c.agent_verdict === 'AMBIGUOUS' || !triageSucceeded(c))
148
+ if (rows.length === 0) return nothingThisRun('ambiguous property descriptions')
149
+
150
+ const parts = [
151
+ `${rows.length} property description${rows.length === 1 ? '' : 's'} could not be confidently triaged and need a human call.`
152
+ ]
153
+ for (const row of rows) {
154
+ parts.push([
155
+ heading(row.name),
156
+ quotedBlock('Override text:', row.upstream_candidate_text),
157
+ quotedBlock('Source text:', row.source_text),
158
+ row.agent_reason,
159
+ 'Needs a human call.'
160
+ ].join('\n\n'))
161
+ }
162
+ return parts.join('\n\n')
163
+ }
164
+
165
+ module.exports = {
166
+ buildUpstreamSection,
167
+ buildRetirementSection,
168
+ buildAmbiguousDigest
169
+ }
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Overrides Audit - Agent Triage Layer
3
+ *
4
+ * classify.js is deterministic: any UPSTREAMABLE or SPLIT KEEP_UNTIL_UPSTREAMED
5
+ * candidate whose override text differs from source is treated as "our text
6
+ * is better, push it upstream." That is not always true. Source can be
7
+ * independently improved between audit runs to something as good as or
8
+ * better than the override, and classify.js has no way to notice: it only
9
+ * compares normalized text for equality, never for which one reads better.
10
+ *
11
+ * This module is the missing judgment step. It does not call an LLM itself -
12
+ * a GitHub Actions workflow in the docs repo owns that call. This module only
13
+ * (a) builds the prompt that call should send, and (b) parses whatever text
14
+ * comes back into one of three verdicts, defaulting to the safest one
15
+ * (AMBIGUOUS, meaning "leave the override alone, ask a human") on any
16
+ * uncertainty at all. Silently guessing UPSTREAM_OVERRIDE or RETIRE_OVERRIDE
17
+ * on a malformed response would either write bad prose into engineering
18
+ * source or delete an override that source has not actually caught up to -
19
+ * both worse than asking a human.
20
+ */
21
+
22
+ 'use strict'
23
+
24
+ const VERDICTS = Object.freeze({
25
+ UPSTREAM_OVERRIDE: 'UPSTREAM_OVERRIDE',
26
+ RETIRE_OVERRIDE: 'RETIRE_OVERRIDE',
27
+ AMBIGUOUS: 'AMBIGUOUS'
28
+ })
29
+
30
+ const VALID_VERDICTS = new Set(Object.values(VERDICTS))
31
+ const ALLOWED_RESPONSE_KEYS = new Set(['verdict', 'reason'])
32
+
33
+ /**
34
+ * Build the plain-text prompt an external LLM call should send for one
35
+ * triage candidate.
36
+ *
37
+ * The prompt is written for a reader who has never seen this codebase: it
38
+ * explains the two texts being compared, the three possible verdicts in
39
+ * plain terms, and when to refuse to choose. It requires strict JSON back
40
+ * with no markdown fences and no surrounding prose, because parseTriageResponse
41
+ * has to be able to trust the shape of a well-behaved response and only fall
42
+ * back defensively for a badly-behaved one.
43
+ *
44
+ * @param {Object} candidate - A classify.js manifest row for a `description`
45
+ * field classified UPSTREAMABLE or KEEP_UNTIL_UPSTREAMED (SPLIT), extended
46
+ * with `source_text` (see classify.js's `common.source_text`).
47
+ * @param {string} candidate.name - Property (or command) name.
48
+ * @param {string} candidate.upstream_candidate_text - The override's candidate text.
49
+ * @param {string} candidate.source_text - The current source description text.
50
+ * @param {string} [candidate.source_file] - File the source description lives in.
51
+ * @param {number} [candidate.source_line] - Line the source description starts at.
52
+ * @returns {string} The prompt text.
53
+ * @throws {Error} When `candidate` is missing `upstream_candidate_text` or `source_text`.
54
+ * This is a programming-error guard, not the agent-response fallback below:
55
+ * a caller that hands this function a REDUNDANT row, or any row that never
56
+ * got a source_text, has a bug that should fail loudly, not produce a
57
+ * prompt that quietly asks an LLM to compare a real string to `undefined`.
58
+ */
59
+ function buildTriagePrompt (candidate) {
60
+ if (!candidate || typeof candidate.upstream_candidate_text !== 'string') {
61
+ throw new Error(`buildTriagePrompt: candidate for "${candidate && candidate.name}" is missing upstream_candidate_text`)
62
+ }
63
+ if (typeof candidate.source_text !== 'string') {
64
+ throw new Error(`buildTriagePrompt: candidate for "${candidate.name}" is missing source_text`)
65
+ }
66
+
67
+ const location = candidate.source_file
68
+ ? `${candidate.source_file}${candidate.source_line !== undefined ? `:${candidate.source_line}` : ''}`
69
+ : 'unknown location'
70
+
71
+ return `You are triaging one property description for a documentation team.
72
+
73
+ A documentation site publishes reference docs for a configuration property named "${candidate.name}". Its description currently comes from a manually written "override" that the docs team keeps because, at some point in the past, the engineering source code's own description of this property was missing, wrong, or worse than what the docs team wrote. Engineering source now defines its own description for the same property, at ${location}.
74
+
75
+ Your job is to compare the two descriptions below and decide what should happen next.
76
+
77
+ OVERRIDE TEXT (currently shown to docs readers, and a candidate to copy into engineering source):
78
+ """
79
+ ${candidate.upstream_candidate_text}
80
+ """
81
+
82
+ CURRENT SOURCE TEXT (what engineering's own code says today):
83
+ """
84
+ ${candidate.source_text}
85
+ """
86
+
87
+ Choose exactly one of these three verdicts:
88
+
89
+ 1. UPSTREAM_OVERRIDE - The override text is genuinely better or more complete than the source text. The right move is to copy the override's wording into the engineering source code, so that source eventually says what the override already says.
90
+
91
+ 2. RETIRE_OVERRIDE - The source text has caught up to, or become as good as or better than, the override in substance. There is no longer a good reason to keep the override. The right move is to delete the docs-side override entirely and let the docs site render the source text as-is, without ever touching engineering source.
92
+
93
+ 3. AMBIGUOUS - You cannot confidently choose between the two options above. Choose this if you are genuinely uncertain, if the two texts each contain distinct real information and neither one is a strict subset of the other (so picking one would lose real content), or if judging the two texts correctly requires technical knowledge of this system that cannot be confirmed from the text alone. When in doubt, choose AMBIGUOUS rather than guessing. Guessing wrong here either writes bad text into engineering source code or throws away a description a docs reader currently relies on, so it is always safer to flag this for a human than to guess.
94
+
95
+ Respond with strict JSON only. Do not use markdown code fences. Do not include any words, explanation, or punctuation outside the JSON object. The response must be exactly one JSON object shaped like this:
96
+
97
+ {"verdict": "UPSTREAM_OVERRIDE" | "RETIRE_OVERRIDE" | "AMBIGUOUS", "reason": "one or two plain-English sentences a non-engineer documentation reviewer can understand, explaining the comparison you made and why this verdict follows from it"}
98
+ `
99
+ }
100
+
101
+ /**
102
+ * Extract a JSON value from raw agent response text. Accepts only a whole
103
+ * JSON response or one wrapped in a single pair of markdown code fences, the
104
+ * one common way a model fails to follow "strict JSON only" without changing
105
+ * what it said. Prose around the JSON is rejected rather than stripped: that
106
+ * prose can contradict the embedded verdict (for example "I cannot decide"
107
+ * followed by a RETIRE_OVERRIDE object), so it is not safe to trust the object.
108
+ *
109
+ * @param {string} text - Raw response text.
110
+ * @returns {*} The parsed value.
111
+ * @throws {Error} When the (unfenced) response is not valid JSON.
112
+ */
113
+ function extractJson (text) {
114
+ let candidate = text.trim()
115
+
116
+ // Strip a single pair of markdown code fences, ```json ... ``` or ``` ... ```.
117
+ const fenceMatch = candidate.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i)
118
+ if (fenceMatch) candidate = fenceMatch[1].trim()
119
+
120
+ return JSON.parse(candidate)
121
+ }
122
+
123
+ /**
124
+ * The safe, conservative fallback result for any response the parser cannot
125
+ * fully trust.
126
+ *
127
+ * @param {string} reason - Specific, human-readable explanation of what went wrong.
128
+ * @returns {{verdict: string, reason: string, triageFailed: boolean}}
129
+ */
130
+ function ambiguousFallback (reason) {
131
+ return { verdict: VERDICTS.AMBIGUOUS, reason, triageFailed: true }
132
+ }
133
+
134
+ /**
135
+ * Defensively parse whatever text an LLM triage call returned.
136
+ *
137
+ * Clean JSON and JSON wrapped in markdown fences are accepted. JSON
138
+ * surrounded by stray prose, an empty or truncated response, or JSON that
139
+ * parses but carries an invalid or missing verdict or reason, or any field
140
+ * other than verdict and reason, is treated as
141
+ * a triage failure and mapped to the same safe AMBIGUOUS
142
+ * fallback: this function's job is to never let a malformed response be
143
+ * mistaken for a real UPSTREAM_OVERRIDE or RETIRE_OVERRIDE call.
144
+ *
145
+ * @param {string} rawText - Raw text returned by the LLM call.
146
+ * @returns {{verdict: string, reason: string, triageFailed: boolean}}
147
+ */
148
+ function parseTriageResponse (rawText) {
149
+ if (typeof rawText !== 'string' || rawText.trim().length === 0) {
150
+ return ambiguousFallback('The triage call returned no response.')
151
+ }
152
+
153
+ let parsed
154
+ try {
155
+ parsed = extractJson(rawText)
156
+ } catch {
157
+ return ambiguousFallback('The triage response was not valid JSON.')
158
+ }
159
+
160
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
161
+ return ambiguousFallback('The triage response was valid JSON but not a JSON object.')
162
+ }
163
+
164
+ // The prompt asks for exactly { verdict, reason }. An extra field such as
165
+ // needs_human_review or confidence can contradict the verdict, and nothing
166
+ // downstream reads it, so treat any extra key as a failed triage rather
167
+ // than silently dropping it.
168
+ const extraKeys = Object.keys(parsed).filter((key) => !ALLOWED_RESPONSE_KEYS.has(key))
169
+ if (extraKeys.length > 0) {
170
+ return ambiguousFallback(`The triage response included unexpected fields: ${extraKeys.join(', ')}.`)
171
+ }
172
+
173
+ // Case-sensitive on purpose: a case variant (e.g. "upstream_override") is a
174
+ // real deviation from the required response shape, worth surfacing as a
175
+ // failure rather than silently normalizing away.
176
+ if (typeof parsed.verdict !== 'string' || !VALID_VERDICTS.has(parsed.verdict)) {
177
+ return ambiguousFallback('The triage response used an unrecognized verdict value.')
178
+ }
179
+
180
+ if (typeof parsed.reason !== 'string' || parsed.reason.trim().length === 0) {
181
+ return ambiguousFallback('The triage response was missing a reason.')
182
+ }
183
+
184
+ return { verdict: parsed.verdict, reason: parsed.reason, triageFailed: false }
185
+ }
186
+
187
+ /**
188
+ * Combine a classify.js candidate row with the parsed result of a triage
189
+ * call, producing a manifest row extended with the agent's verdict.
190
+ *
191
+ * Field names follow classify.js's snake_case manifest-row convention rather
192
+ * than parseTriageResponse's camelCase return shape.
193
+ *
194
+ * @param {Object} candidate - A classify.js manifest row.
195
+ * @param {string} rawAgentResponse - Raw text returned by the LLM triage call.
196
+ * A RETIRE_OVERRIDE verdict on a SPLIT (KEEP_UNTIL_UPSTREAMED) candidate is
197
+ * downgraded to AMBIGUOUS, because retiring that override would also delete
198
+ * its audience-scoped or docs-only content.
199
+ *
200
+ * @returns {Object} `candidate` merged with `agent_verdict`, `agent_reason`, `triage_failed`.
201
+ */
202
+ function triageCandidate (candidate, rawAgentResponse) {
203
+ let { verdict, reason, triageFailed } = parseTriageResponse(rawAgentResponse)
204
+ // A SPLIT (KEEP_UNTIL_UPSTREAMED) override also carries audience-scoped
205
+ // paragraphs or docs-only markup that the candidate text leaves out.
206
+ // Retiring the whole override would delete that content, so even when
207
+ // source prose has caught up, route the row to a human instead.
208
+ if (verdict === VERDICTS.RETIRE_OVERRIDE && candidate && candidate.class === 'KEEP_UNTIL_UPSTREAMED') {
209
+ verdict = VERDICTS.AMBIGUOUS
210
+ reason = `${reason} This override also carries audience-scoped paragraphs or docs-only markup that source does not, so it cannot be retired whole. Decide what to do with that content.`
211
+ }
212
+ return {
213
+ ...candidate,
214
+ agent_verdict: verdict,
215
+ agent_reason: reason,
216
+ triage_failed: triageFailed
217
+ }
218
+ }
219
+
220
+ module.exports = {
221
+ VERDICTS,
222
+ buildTriagePrompt,
223
+ parseTriageResponse,
224
+ triageCandidate
225
+ }
@@ -1231,7 +1231,12 @@ breaks. Two files feed it, both mirrors of the `docs` repo:
1231
1231
  fields the rendering and link layers read. `cloud_supported` is the point: it is what
1232
1232
  makes the "no Cloud link to a property Cloud does not publish" assertion real rather
1233
1233
  than assumed. Descriptions are deliberately absent, because the corpus test drives prose from
1234
- the overrides file, and a full copy would be 695 KB of text that rots.
1234
+ the overrides file, and a full copy would be 695 KB of text that rots. The exceptions are
1235
+ overrides that act on the source description instead of replacing it: links with no prose of
1236
+ their own, which apply to the source description, and includes with no description of their
1237
+ own, which the generator appends to it. The snapshot keeps the source description for those
1238
+ properties. `.github/scripts/derive-property-snapshot.js` makes that call from the overrides
1239
+ file, and both the recipe below and `property-corpus-drift.sh` use it.
1235
1240
 
1236
1241
  Keep the overrides copy a faithful mirror. It previously doubled as
1237
1242
  `property-docs-overrides.test.js`'s fixture, someone hand-edited an `admin` entry into
@@ -1264,17 +1269,13 @@ TAG="$(git -C "$DOCS" ls-tree origin/main --name-only -- modules/reference/attac
1264
1269
  echo "Refreshing against $TAG"
1265
1270
 
1266
1271
  git -C "$DOCS" show "origin/main:modules/reference/attachments/redpanda-properties-${TAG}.json" \
1272
+ > /tmp/redpanda-properties.json
1273
+ node .github/scripts/derive-property-snapshot.js /tmp/redpanda-properties.json \
1274
+ __tests__/docs-data/property-overrides.json \
1267
1275
  | node -e '
1268
1276
  const fs = require("fs");
1269
- const src = JSON.parse(fs.readFileSync(0, "utf8"));
1270
- const keep = ["name","config_scope","type","cloud_supported","cloud_editable",
1271
- "cloud_readonly","cloud_byoc_only","is_deprecated","nullable"];
1272
- const properties = {};
1273
- for (const [k, v] of Object.entries(src.properties || {})) {
1274
- properties[k] = Object.fromEntries(keep.filter((f) => f in v).map((f) => [f, v[f]]));
1275
- }
1276
1277
  const out = JSON.parse(fs.readFileSync("__tests__/docs-data/property-snapshot.json", "utf8"));
1277
- out.properties = properties;
1278
+ out.properties = JSON.parse(fs.readFileSync(0, "utf8"));
1278
1279
  out.source_tag = process.argv[1];
1279
1280
  fs.writeFileSync("__tests__/docs-data/property-snapshot.json", JSON.stringify(out, null, 1) + "\n");
1280
1281
  ' "$TAG"
@@ -2124,6 +2124,32 @@ class EnterpriseTransformer:
2124
2124
  restricted_vals = self._extract_list_items(params[0]["value"])
2125
2125
  info["params"] = params[1:]
2126
2126
 
2127
+ # --- restricted scalar ahead of the name, with a trailing vector ---
2128
+ # Pattern: (restricted, name, description, meta, default, ..., vector)
2129
+ # enterprise<enum_set_property<T>> takes this shape: http_authentication
2130
+ # is ("OIDC", "http_authentication", ...) ending in its allowed values.
2131
+ # Without this branch the trailing vector wins as sanctioned_only and
2132
+ # the restricted value is left where the name belongs. Anchored on the
2133
+ # member name, so a scalar is only taken as a restriction when the
2134
+ # argument after it is the property's own name.
2135
+ elif (
2136
+ len(params) >= 5
2137
+ and "std::vector" in str(params[-1]["value"])
2138
+ and params[0]["type"] in ("true", "false", "integer_literal", "string_literal", "qualified_identifier")
2139
+ and self._clean_value(params[1]["value"]) == info.get("name_in_file")
2140
+ and self._clean_value(params[0]["value"]) != info.get("name_in_file")
2141
+ ):
2142
+ restricted_vals = [self._clean_value(params[0]["value"])]
2143
+ if "enum" in str(info.get("type") or ""):
2144
+ # The trailing vector is the enum's allowed values; keep it for
2145
+ # TypeTransformer.
2146
+ enterprise_constructor = "restricted_only"
2147
+ info["params"] = params[1:]
2148
+ else:
2149
+ enterprise_constructor = "restricted_with_sanctioned"
2150
+ sanctioned_vals = self._extract_list_items(params[-1]["value"])
2151
+ info["params"] = params[1:-1]
2152
+
2127
2153
  # --- sanctioned_only (vector form) ---
2128
2154
  elif len(params) >= 5 and "std::vector" in str(params[-1]["value"]):
2129
2155
  enterprise_constructor = "sanctioned_only"