@redpanda-data/docs-extensions-and-macros 5.48.1 → 5.49.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.
package/bin/doc-tools.js CHANGED
@@ -2755,6 +2755,7 @@ programCli
2755
2755
  .option('--format <format>', 'Output format: human or json', 'human')
2756
2756
  .option('--skip-rules <list>', 'Comma-separated rule ids to skip')
2757
2757
  .option('--only-rules <list>', 'Comma-separated rule ids to run exclusively')
2758
+ .option('--reviewed <file>', 'Diff mode: skip declarations whose fingerprint is listed in <file> (already reviewed on an earlier push)')
2758
2759
  .option('--strict', 'Exit 1 when any error-severity finding exists (default: always exit 0 - suggest, never block)')
2759
2760
  .action((options) => {
2760
2761
  const { runCli } = require('../tools/lint-strings')
@@ -2908,6 +2909,92 @@ overridesGroup
2908
2909
  }
2909
2910
  })
2910
2911
 
2912
+ /**
2913
+ * @description Build the plain-text prompt an external LLM triage call
2914
+ * should send for one UPSTREAMABLE or SPLIT KEEP_UNTIL_UPSTREAMED audit row.
2915
+ * Reads a single candidate object (not an array) from --candidate. See
2916
+ * tools/overrides-audit/triage.js for the prompt contract.
2917
+ *
2918
+ * @why Separated from the actual LLM call so the workflow that runs it can
2919
+ * use whatever action/model it wants; this command only builds the prompt.
2920
+ *
2921
+ * @example
2922
+ * npx doc-tools overrides triage-prompt --candidate candidate.json
2923
+ */
2924
+ overridesGroup
2925
+ .command('triage-prompt')
2926
+ .description('Build the triage prompt for one UPSTREAMABLE/SPLIT candidate')
2927
+ .requiredOption('--candidate <path>', 'Path to a single candidate row JSON file (one object, not an array)')
2928
+ .action((options) => {
2929
+ const { buildTriagePrompt } = require('../tools/overrides-audit/triage')
2930
+ try {
2931
+ const candidate = JSON.parse(fs.readFileSync(path.resolve(options.candidate), 'utf8'))
2932
+ console.log(buildTriagePrompt(candidate))
2933
+ } catch (err) {
2934
+ fail(err.message)
2935
+ }
2936
+ })
2937
+
2938
+ /**
2939
+ * @description Parse the raw text an LLM triage call returned for one
2940
+ * candidate and merge it into that candidate's manifest row, adding
2941
+ * agent_verdict / agent_reason / triage_failed. Any malformed, empty, or
2942
+ * unexpected response degrades to a safe AMBIGUOUS verdict rather than
2943
+ * throwing — a triage call failing is not a reason to fail the pipeline,
2944
+ * it's a reason to ask a human. See tools/overrides-audit/triage.js.
2945
+ *
2946
+ * @example
2947
+ * npx doc-tools overrides triage-parse --candidate candidate.json --response response.txt
2948
+ */
2949
+ overridesGroup
2950
+ .command('triage-parse')
2951
+ .description('Merge an LLM triage response into a candidate row')
2952
+ .requiredOption('--candidate <path>', 'Path to the candidate row JSON file this response answers')
2953
+ .requiredOption('--response <path>', 'Path to the raw text file the LLM triage call returned')
2954
+ .action((options) => {
2955
+ const { triageCandidate } = require('../tools/overrides-audit/triage')
2956
+ try {
2957
+ const candidate = JSON.parse(fs.readFileSync(path.resolve(options.candidate), 'utf8'))
2958
+ // A triage call that failed before writing its output leaves no
2959
+ // response file. Treat that as an empty response so it reaches the
2960
+ // AMBIGUOUS fallback instead of failing the command.
2961
+ let rawResponse = ''
2962
+ try {
2963
+ rawResponse = fs.readFileSync(path.resolve(options.response), 'utf8')
2964
+ } catch (err) {
2965
+ if (err.code !== 'ENOENT') throw err
2966
+ }
2967
+ console.log(JSON.stringify(triageCandidate(candidate, rawResponse), null, 2))
2968
+ } catch (err) {
2969
+ fail(err.message)
2970
+ }
2971
+ })
2972
+
2973
+ /**
2974
+ * @description Render one plain-English markdown report section from a
2975
+ * batch of triaged candidate rows (rows already carrying agent_verdict from
2976
+ * triage-parse). See tools/overrides-audit/report.js for what each section
2977
+ * contains and what happens to rows outside the requested verdict.
2978
+ *
2979
+ * @example
2980
+ * npx doc-tools overrides report --candidates triaged.json --section upstream
2981
+ */
2982
+ overridesGroup
2983
+ .command('report')
2984
+ .description('Build a plain-English markdown report section from triaged candidates')
2985
+ .requiredOption('--candidates <path>', 'Path to a JSON array of triaged candidate rows')
2986
+ .addOption(new Option('--section <section>', 'Which report section to build').choices(['upstream', 'retirement', 'ambiguous']).makeOptionMandatory())
2987
+ .action((options) => {
2988
+ const { buildUpstreamSection, buildRetirementSection, buildAmbiguousDigest } = require('../tools/overrides-audit/report')
2989
+ const BUILDERS = { upstream: buildUpstreamSection, retirement: buildRetirementSection, ambiguous: buildAmbiguousDigest }
2990
+ try {
2991
+ const candidates = JSON.parse(fs.readFileSync(path.resolve(options.candidates), 'utf8'))
2992
+ console.log(BUILDERS[options.section](candidates))
2993
+ } catch (err) {
2994
+ fail(err.message)
2995
+ }
2996
+ })
2997
+
2911
2998
  programCli.addCommand(automation)
2912
2999
  /**
2913
3000
  * validate kapa-source-groups
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redpanda-data/docs-extensions-and-macros",
3
- "version": "5.48.1",
3
+ "version": "5.49.0",
4
4
  "description": "Antora extensions and macros developed for Redpanda documentation.",
5
5
  "keywords": [
6
6
  "antora",
@@ -20,7 +20,14 @@ const { spawnSync } = require('child_process')
20
20
  */
21
21
  const SURFACE_ROUTES = [
22
22
  { surface: 'properties', pattern: /^src\/v\/config\// },
23
- { surface: 'metrics', pattern: /(^|\/)[^/]*probe\.cc$|^src\/v\/metrics\// },
23
+ // Metrics are registered wherever a component exposes them, not in files
24
+ // with a predictable name: raft/consensus.cc, cluster/rm_stm.cc and
25
+ // headers such as kafka/server/kafka_probe.h all call sm::description. So
26
+ // route every non-test C++ source under src/v (config/ has already matched
27
+ // properties above) and let the scanner, which skips any file with no
28
+ // description() call, decide what is a declaration. Test directories are
29
+ // excluded the same way the whole-repo scan excludes them.
30
+ { surface: 'metrics', pattern: /^src\/v\/(?!(?:.*\/)?tests?\/).*\.(?:cc|h)$/ },
24
31
  { surface: 'rpk', pattern: /^src\/go\/rpk\/pkg\/cli\// },
25
32
  // Both chart layouts ship: charts/<name>/chart/values.yaml (redpanda,
26
33
  // console) and charts/<name>/values.yaml (connectors).
@@ -1,7 +1,10 @@
1
1
  'use strict'
2
2
 
3
+ const crypto = require('crypto')
3
4
  const fs = require('fs')
5
+ const os = require('os')
4
6
  const path = require('path')
7
+ const { spawnSync } = require('child_process')
5
8
 
6
9
  const { runRules, mergeResults } = require('./engine')
7
10
  const { getDiffLines, classifyDiff, spanIntersects, SURFACE_ROUTES } = require('./diff')
@@ -54,6 +57,9 @@ function rulesFor (surface) {
54
57
  * only declarations whose span intersects lines changed since this ref
55
58
  * @param {string[]} [options.skipRules]
56
59
  * @param {string[]} [options.onlyRules]
60
+ * @param {Set<string>} [options.reviewedFingerprints] - Diff mode: skip
61
+ * declarations (and removals) whose fingerprint is in this set, so a PR
62
+ * review sees each string once across pushes
57
63
  * @param {Function} [options.log] - Progress logger (stderr by default)
58
64
  * @returns {Object} { findings, summary, unsupported_surfaces }
59
65
  */
@@ -64,6 +70,7 @@ function lintStrings (options) {
64
70
  diffBase = null,
65
71
  skipRules = [],
66
72
  onlyRules = null,
73
+ reviewedFingerprints = null,
67
74
  log = (msg) => process.stderr.write(`${msg}\n`)
68
75
  } = options
69
76
 
@@ -83,12 +90,16 @@ function lintStrings (options) {
83
90
 
84
91
  const results = []
85
92
  const unsupportedSurfaces = []
86
- let removedSurfaceFiles = []
93
+ const reviewed = reviewedFingerprints || new Set()
94
+ const pending = []
95
+ let alreadyReviewed = 0
96
+ let removal = { rawFiles: [], declarations: [] }
87
97
 
88
98
  if (diffBase) {
89
99
  const { changed, removed } = getDiffLines(repoPath, diffBase)
90
100
  const classified = classifyDiff(changed)
91
- removedSurfaceFiles = collectRemovals(removed, surfaces)
101
+ const removedBySurface = classifyDiff(removed)
102
+ const headBySurface = {}
92
103
 
93
104
  for (const [surfaceName, files] of Object.entries(classified)) {
94
105
  if (!SURFACES[surfaceName]) {
@@ -101,13 +112,31 @@ function lintStrings (options) {
101
112
 
102
113
  const surface = SURFACES[surfaceName]
103
114
  log(`[${surfaceName}] ${files.size} changed file(s) in diff; extracting declarations at HEAD...`)
104
- const fileSet = new Set(files.keys())
105
- const declarations = surface
106
- .extract({ repo: repoPath, files: fileSet, log })
107
- .filter((decl) => spanIntersects(decl.line_start, decl.line_end, files.get(decl.file)))
108
- for (const decl of declarations) decl.in_pr_diff = true
115
+ // Files that only lost lines are extracted too: a declaration that
116
+ // still exists at HEAD, in any touched file, was edited or moved, not
117
+ // removed.
118
+ const removedFiles = removedBySurface[surfaceName] ? [...removedBySurface[surfaceName].keys()] : []
119
+ const head = surface.extract({ repo: repoPath, files: new Set([...files.keys(), ...removedFiles]), log })
120
+ headBySurface[surfaceName] = head
121
+ const declarations = []
122
+ for (const decl of head) {
123
+ if (!touches(decl, files.get(decl.file))) continue
124
+ decl.in_pr_diff = true
125
+ decl.fingerprint = fingerprint(decl)
126
+ if (reviewed.has(decl.fingerprint)) {
127
+ alreadyReviewed++
128
+ continue
129
+ }
130
+ declarations.push(decl)
131
+ pending.push(pendingEntry(decl))
132
+ }
109
133
  results.push(runRules(declarations, rulesFor(surface), { skipRules, onlyRules }))
110
134
  }
135
+
136
+ removal = collectRemovals({ repoPath, diffBase, removed: removedBySurface, surfaces, requested, headBySurface, log })
137
+ const kept = removal.declarations.filter((decl) => !reviewed.has(decl.fingerprint))
138
+ alreadyReviewed += removal.declarations.length - kept.length
139
+ removal.declarations = kept
111
140
  } else {
112
141
  for (const surfaceName of requested) {
113
142
  const surface = SURFACES[surfaceName]
@@ -119,34 +148,213 @@ function lintStrings (options) {
119
148
  }
120
149
 
121
150
  const merged = mergeResults(results)
151
+ for (const finding of merged.findings) {
152
+ if (finding.in_pr_diff) finding.fingerprint = fingerprint(finding)
153
+ }
122
154
  merged.findings.sort((a, b) =>
123
155
  a.surface.localeCompare(b.surface) || a.file.localeCompare(b.file) || (a.line_start || 0) - (b.line_start || 0))
124
156
  merged.unsupported_surfaces = unsupportedSurfaces
157
+ if (diffBase) {
158
+ merged.declarations = pending
159
+ merged.summary.alreadyReviewed = alreadyReviewed
160
+ }
161
+ merged.summary.removedDeclarations = removal.declarations.map(pendingEntry)
162
+ const removedSurfaceFiles = [...removal.rawFiles, ...removedFilesFor(removal.declarations)]
163
+ .sort((a, b) => a.surface.localeCompare(b.surface) || a.file.localeCompare(b.file))
125
164
  merged.summary.removedSurfaceFiles = removedSurfaceFiles
126
165
  merged.summary.removedSurfaceLines = removedSurfaceFiles.reduce((sum, entry) => sum + entry.lines, 0)
127
166
  return merged
128
167
  }
129
168
 
130
169
  /**
131
- * Deletions in files that route to a doc-string surface, so a deletion-only PR
132
- * still registers as touching a surface. Reported separately from
133
- * totalDeclarations because a removed declaration is absent from HEAD and
134
- * therefore cannot be extracted or linted.
170
+ * A declaration's anchor lines: its span, plus any lines a surface records
171
+ * separately because the declared name lives outside it (metrics names sit
172
+ * in the enclosing make_*() call).
173
+ */
174
+ function spansOf (decl) {
175
+ const spans = [[decl.line_start, decl.line_end]]
176
+ const extra = decl.meta && decl.meta.name_lines
177
+ if (extra) spans.push(extra)
178
+ return spans
179
+ }
180
+
181
+ function inSpans (decl, line) {
182
+ return spansOf(decl).some(([start, end]) => start != null && line >= start && line <= end)
183
+ }
184
+
185
+ function touches (decl, lineSet) {
186
+ return spansOf(decl).some(([start, end]) => spanIntersects(start, end, lineSet))
187
+ }
188
+
189
+ /**
190
+ * Stable identity for one version of one doc string. A PR review records the
191
+ * fingerprints it has seen, and a later push skips any declaration whose
192
+ * surface, name and text are unchanged, so rebases, moves and unrelated edits
193
+ * never re-review a string. Editing the string changes the fingerprint.
194
+ */
195
+ function fingerprint (decl) {
196
+ return crypto.createHash('sha256')
197
+ .update(JSON.stringify([decl.surface, decl.name, decl.string == null ? null : decl.string]))
198
+ .digest('hex')
199
+ .slice(0, 16)
200
+ }
201
+
202
+ function removalFingerprint (surfaceName, identity) {
203
+ return crypto.createHash('sha256')
204
+ .update(JSON.stringify(['removed', surfaceName, identity]))
205
+ .digest('hex')
206
+ .slice(0, 16)
207
+ }
208
+
209
+ /**
210
+ * Where a declaration lives, for matching the merge-base side against HEAD.
211
+ * Bare names repeat on most surfaces (a `name` field in many proto messages,
212
+ * `enabled` in many CRD structs, the same flag on many rpk commands), so each
213
+ * surface supplies a contextual identity; without one, the name is used.
214
+ */
215
+ function identityOf (surface, decl) {
216
+ return surface.identity ? surface.identity(decl) : decl.name
217
+ }
218
+
219
+ function pendingEntry (decl) {
220
+ return {
221
+ surface: decl.surface,
222
+ name: decl.name,
223
+ file: decl.file,
224
+ line_start: decl.line_start,
225
+ line_end: decl.line_end,
226
+ fingerprint: decl.fingerprint
227
+ }
228
+ }
229
+
230
+ function removedFilesFor (declarations) {
231
+ const byFile = new Map()
232
+ for (const decl of declarations) {
233
+ const key = `${decl.surface}\0${decl.file}`
234
+ const lines = (decl.removed_lines || 0)
235
+ byFile.set(key, { surface: decl.surface, file: decl.file, lines: (byFile.has(key) ? byFile.get(key).lines : 0) + lines })
236
+ }
237
+ return [...byFile.values()].sort((a, b) => a.surface.localeCompare(b.surface) || a.file.localeCompare(b.file))
238
+ }
239
+
240
+ /**
241
+ * Materialize the merge-base version of `files` into a scratch tree, so a
242
+ * surface extractor can read the pre-image. A surface that declares
243
+ * baseScope 'directory' gets the whole directories instead: the properties
244
+ * extractor pairs each .cc with its .h.
245
+ */
246
+ function materializeBase (repoPath, diffBase, files, scope = 'file') {
247
+ const mb = spawnSync('git', ['merge-base', diffBase, 'HEAD'], { cwd: repoPath, encoding: 'utf8' })
248
+ if (mb.status !== 0) throw new Error(`git merge-base ${diffBase} HEAD failed: ${mb.stderr}`)
249
+ const base = mb.stdout.trim()
250
+ const paths = scope === 'directory' ? [...new Set(files.map((f) => path.dirname(f)))] : files
251
+ const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'lint-strings-base-'))
252
+ const archive = spawnSync('git', ['archive', '--format=tar', base, '--', ...paths], { cwd: repoPath, maxBuffer: 1024 * 1024 * 1024 })
253
+ if (archive.status !== 0) {
254
+ fs.rmSync(scratch, { recursive: true, force: true })
255
+ throw new Error(`git archive ${base} failed: ${archive.stderr}`)
256
+ }
257
+ const untar = spawnSync('tar', ['-x', '-C', scratch], { input: archive.stdout })
258
+ if (untar.status !== 0) {
259
+ fs.rmSync(scratch, { recursive: true, force: true })
260
+ throw new Error(`tar failed: ${untar.stderr}`)
261
+ }
262
+ return scratch
263
+ }
264
+
265
+ /**
266
+ * Doc-string declarations the diff removed or renamed.
267
+ *
268
+ * A deleted line only matters when it belonged to a declaration: removing an
269
+ * include, a struct field or a checksum from a surface file is not a removed
270
+ * surface. So the old side of each affected file is extracted at the merge
271
+ * base, and a declaration counts as removed when its span there lost lines
272
+ * and HEAD, across every file the diff touched, holds fewer declarations with
273
+ * its identity (see identityOf) than the merge base did. Still present means
274
+ * it was edited or moved, and HEAD-side review covers it. Counting, rather
275
+ * than asking whether the identity appears at all, keeps a deletion visible
276
+ * when another declaration shares the identity and survives.
135
277
  *
136
- * Surfaces routed without a registered extractor are included: the gate cares
137
- * that documented content went away, not whether we can lint what replaced it.
278
+ * Declarations are absent from HEAD by construction, so they cannot be linted;
279
+ * they are reported for the published-content check instead.
138
280
  *
139
- * @param {Map<string, Set<number>>} removed - From getDiffLines().removed
140
- * @param {string[]|null} surfaces - Explicit --surface narrowing, if any
281
+ * Surfaces routed without a registered extractor, and any base extraction
282
+ * that fails, fall back to counting raw deleted lines: without an extractor
283
+ * there is no way to tell, and the gate should err toward reviewing.
284
+ *
285
+ * @returns {{ rawFiles: Array, declarations: Array }}
141
286
  */
142
- function collectRemovals (removed, surfaces) {
287
+ function collectRemovals ({ repoPath, diffBase, removed, surfaces, requested, headBySurface, log }) {
143
288
  const only = surfaces && surfaces.length > 0 ? new Set(surfaces) : null
144
- const entries = []
145
- for (const [surface, files] of Object.entries(classifyDiff(removed))) {
146
- if (only && !only.has(surface)) continue
147
- for (const [file, lines] of files) entries.push({ surface, file, lines: lines.size })
289
+ const rawFiles = []
290
+ const declarations = []
291
+
292
+ for (const [surfaceName, files] of Object.entries(removed)) {
293
+ if (only && !only.has(surfaceName)) continue
294
+ const surface = SURFACES[surfaceName]
295
+ const raw = () => {
296
+ for (const [file, lines] of files) rawFiles.push({ surface: surfaceName, file, lines: lines.size })
297
+ }
298
+ if (!surface || !requested.includes(surfaceName)) {
299
+ raw()
300
+ continue
301
+ }
302
+
303
+ let scratch = null
304
+ try {
305
+ scratch = materializeBase(repoPath, diffBase, [...files.keys()], surface.baseScope)
306
+ log(`[${surfaceName}] ${files.size} file(s) lost lines; extracting declarations at the merge base...`)
307
+ const baseDecls = surface.extract({ repo: scratch, files: new Set(files.keys()), log })
308
+ const head = headBySurface[surfaceName] ||
309
+ surface.extract({ repo: repoPath, files: new Set(files.keys()), log })
310
+ const keyOf = (decl) => JSON.stringify(identityOf(surface, decl))
311
+ const headCount = new Map()
312
+ for (const decl of head) headCount.set(keyOf(decl), (headCount.get(keyOf(decl)) || 0) + 1)
313
+ const baseCount = new Map()
314
+ const touched = new Map()
315
+ for (const decl of baseDecls) {
316
+ const key = keyOf(decl)
317
+ baseCount.set(key, (baseCount.get(key) || 0) + 1)
318
+ const lost = files.get(decl.file)
319
+ if (!touches(decl, lost)) continue
320
+ let count = 0
321
+ for (const line of lost) if (inSpans(decl, line)) count++
322
+ if (!touched.has(key)) touched.set(key, [])
323
+ touched.get(key).push({ decl, count })
324
+ }
325
+ // HEAD lost `deficit` declarations with this identity. When more
326
+ // touched ones share it than went missing, report the ones that lost
327
+ // the most lines: a deleted declaration loses its whole span.
328
+ const gone = new Map()
329
+ for (const [key, list] of touched) {
330
+ const deficit = baseCount.get(key) - (headCount.get(key) || 0)
331
+ if (deficit <= 0) continue
332
+ const picked = [...list].sort((a, b) => b.count - a.count || a.decl.line_start - b.decl.line_start).slice(0, deficit)
333
+ for (const entry of picked) gone.set(entry.decl, entry.count)
334
+ }
335
+ for (const decl of baseDecls) {
336
+ if (!gone.has(decl)) continue
337
+ const count = gone.get(decl)
338
+ declarations.push({
339
+ surface: surfaceName,
340
+ name: decl.name,
341
+ file: decl.file,
342
+ line_start: decl.line_start,
343
+ line_end: decl.line_end,
344
+ string: decl.string,
345
+ removed_lines: count,
346
+ fingerprint: removalFingerprint(surfaceName, identityOf(surface, decl))
347
+ })
348
+ }
349
+ } catch (err) {
350
+ log(`[${surfaceName}] could not extract the merge-base side (${err.message}); counting raw deleted lines instead`)
351
+ raw()
352
+ } finally {
353
+ if (scratch) fs.rmSync(scratch, { recursive: true, force: true })
354
+ }
148
355
  }
149
- return entries.sort((a, b) => a.surface.localeCompare(b.surface) || a.file.localeCompare(b.file))
356
+
357
+ return { rawFiles, declarations }
150
358
  }
151
359
 
152
360
  /**
@@ -175,9 +383,15 @@ function formatHuman (result) {
175
383
  lines.push('='.repeat(60))
176
384
  lines.push(`Declarations checked: ${summary.totalDeclarations}`)
177
385
  lines.push(`Declarations flagged: ${summary.flaggedDeclarations}`)
178
- if (summary.removedSurfaceLines) {
386
+ if (summary.alreadyReviewed) {
387
+ lines.push(`Already reviewed on an earlier push (skipped): ${summary.alreadyReviewed}`)
388
+ }
389
+ for (const decl of summary.removedDeclarations || []) {
390
+ lines.push(`Removed or renamed: ${decl.name} (${decl.surface}, ${decl.file})`)
391
+ }
392
+ if (summary.removedSurfaceLines && !(summary.removedDeclarations || []).length) {
179
393
  lines.push(`Lines deleted from doc-string surfaces: ${summary.removedSurfaceLines} ` +
180
- `(${summary.removedSurfaceFiles.length} file(s); removed declarations cannot be extracted from HEAD)`)
394
+ `(${summary.removedSurfaceFiles.length} file(s) with no extractor, so every deleted line counts)`)
181
395
  }
182
396
  lines.push(`Errors: ${summary.errors} Warnings: ${summary.warnings} Info: ${summary.info}`)
183
397
  if (Object.keys(summary.byRule).length > 0) {
@@ -202,6 +416,22 @@ function formatHuman (result) {
202
416
  return lines.join('\n')
203
417
  }
204
418
 
419
+ /**
420
+ * Read a --reviewed file: fingerprints separated by whitespace or commas.
421
+ * Anything that is not a 16-hex-digit fingerprint is ignored, and a missing
422
+ * file is an empty set, so a first run and a corrupted cache both mean
423
+ * "review everything" rather than an error.
424
+ */
425
+ function readFingerprints (file) {
426
+ let text = ''
427
+ try {
428
+ text = fs.readFileSync(file, 'utf8')
429
+ } catch {
430
+ return new Set()
431
+ }
432
+ return new Set(text.split(/[\s,]+/).filter((token) => /^[0-9a-f]{16}$/.test(token)))
433
+ }
434
+
205
435
  /**
206
436
  * CLI entry point shared by bin/doc-tools.js and direct invocation
207
437
  * (node tools/lint-strings --repo <path> ...).
@@ -217,7 +447,8 @@ function runCli (options) {
217
447
  surfaces: options.surface ? String(options.surface).split(',').map((s) => s.trim()).filter(Boolean) : null,
218
448
  diffBase: options.diff || null,
219
449
  skipRules: options.skipRules ? String(options.skipRules).split(',').map((s) => s.trim()).filter(Boolean) : [],
220
- onlyRules: options.onlyRules ? String(options.onlyRules).split(',').map((s) => s.trim()).filter(Boolean) : null
450
+ onlyRules: options.onlyRules ? String(options.onlyRules).split(',').map((s) => s.trim()).filter(Boolean) : null,
451
+ reviewedFingerprints: options.reviewed ? readFingerprints(options.reviewed) : null
221
452
  })
222
453
  } catch (err) {
223
454
  console.error(`Error: ${err.message}`)
@@ -230,11 +461,13 @@ function runCli (options) {
230
461
  console.log(formatHuman(result))
231
462
  }
232
463
 
233
- if (options.strict && result.summary.errors > 0) process.exit(1)
234
- process.exit(0)
464
+ // exitCode, not process.exit(): exiting immediately drops whatever part of
465
+ // a large report is still buffered when stdout is a pipe, so a caller
466
+ // piping --format json into another process got truncated JSON.
467
+ process.exitCode = options.strict && result.summary.errors > 0 ? 1 : 0
235
468
  }
236
469
 
237
- module.exports = { lintStrings, formatHuman, runCli, SURFACES, rulesFor }
470
+ module.exports = { lintStrings, formatHuman, runCli, SURFACES, rulesFor, fingerprint, readFingerprints }
238
471
 
239
472
  // Direct usage: node tools/lint-strings --repo <path> [--surface a,b]
240
473
  // [--diff <base>] [--format json|human] [--strict]
@@ -249,10 +482,11 @@ if (require.main === module) {
249
482
  else if (arg === '--format') options.format = args[++i]
250
483
  else if (arg === '--skip-rules') options.skipRules = args[++i]
251
484
  else if (arg === '--only-rules') options.onlyRules = args[++i]
485
+ else if (arg === '--reviewed') options.reviewed = args[++i]
252
486
  else if (arg === '--strict') options.strict = true
253
487
  else {
254
488
  console.error(`Unknown argument: ${arg}`)
255
- console.error('Usage: node tools/lint-strings --repo <path> [--surface a,b] [--diff <base>] [--format json|human] [--skip-rules a,b] [--only-rules a,b] [--strict]')
489
+ console.error('Usage: node tools/lint-strings --repo <path> [--surface a,b] [--diff <base>] [--format json|human] [--skip-rules a,b] [--only-rules a,b] [--reviewed <file>] [--strict]')
256
490
  process.exit(2)
257
491
  }
258
492
  }
@@ -761,11 +761,29 @@ const RULES = [
761
761
  }
762
762
  ]
763
763
 
764
+ /**
765
+ * Where a declaration lives, for removal matching. Field and message names
766
+ * repeat across messages (`name`, `enabled`), and rpc names across services,
767
+ * so the key is the proto package directory, the kind, and the dotted path
768
+ * inside the file. Proto names are unique per package, and buf keeps a
769
+ * package in one directory; a move to another package counts as a removal,
770
+ * which errs toward reviewing.
771
+ */
772
+ function identity (decl) {
773
+ const meta = decl.meta || {}
774
+ let where = decl.name
775
+ if (meta.kind === 'field') where = meta.path || decl.name
776
+ else if (meta.kind === 'message' || meta.kind === 'enum') where = meta.block ? `${meta.block}.${decl.name}` : decl.name
777
+ else if (meta.kind === 'rpc-summary' || meta.kind === 'rpc-description') where = meta.service ? `${meta.service}.${decl.name}` : decl.name
778
+ return [path.dirname(decl.file), meta.kind || null, where]
779
+ }
780
+
764
781
  module.exports = {
765
782
  name: 'api',
766
783
  convention: CONVENTION,
767
784
  extract,
768
785
  scanFile,
786
+ identity,
769
787
  rules: RULES,
770
788
  API_ROOTS,
771
789
  SUMMARY_KINDS,
@@ -218,11 +218,23 @@ const RULES = [
218
218
  }
219
219
  ]
220
220
 
221
+ /**
222
+ * Where a declaration lives, for removal matching. Field names (`url`,
223
+ * `topic`, `enabled`) repeat across components, and a component name repeats
224
+ * across its input and output, so the key is the file, the kind and the name.
225
+ * Same-file repeats (nested object fields) are handled by counting in the
226
+ * caller.
227
+ */
228
+ function identity (decl) {
229
+ return [decl.file, (decl.meta && decl.meta.kind) || null, decl.name]
230
+ }
231
+
221
232
  module.exports = {
222
233
  name: 'connect',
223
234
  convention: CONVENTION,
224
235
  extract,
225
236
  scanFile,
237
+ identity,
226
238
  rules: RULES,
227
239
  // A connect description is the AsciiDoc page body, not a table cell:
228
240
  // bare | is legitimate table syntax there, and missing prose is handled
@@ -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,9 @@ 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',
245
248
  convention: CONVENTION,
246
249
  extract,
247
250
  mapExtractorJson,
@@ -365,11 +365,22 @@ const RULES = [
365
365
  }
366
366
  ]
367
367
 
368
+ /**
369
+ * Where a declaration lives, for removal matching. Flag names (`format`,
370
+ * `brokers`) and command names (`list`, `create`) repeat across the command
371
+ * tree, which the extractor does not resolve, so the key is the file, the
372
+ * kind and the name. Same-file repeats are handled by counting in the caller.
373
+ */
374
+ function identity (decl) {
375
+ return [decl.file, (decl.meta && decl.meta.kind) || null, decl.name]
376
+ }
377
+
368
378
  module.exports = {
369
379
  name: 'rpk',
370
380
  convention: CONVENTION,
371
381
  extract,
372
382
  scanFile,
383
+ identity,
373
384
  rules: RULES,
374
385
  // Shorts and flag usages are one-liners by convention; the generic
375
386
  // 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
+ }
@@ -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"