@redpanda-data/docs-extensions-and-macros 5.48.0 → 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 +87 -0
- package/package.json +1 -1
- package/tools/lint-strings/diff.js +8 -1
- package/tools/lint-strings/index.js +262 -28
- package/tools/lint-strings/surfaces/api.js +18 -0
- package/tools/lint-strings/surfaces/connect.js +12 -0
- package/tools/lint-strings/surfaces/crd.js +11 -0
- package/tools/lint-strings/surfaces/helm.js +10 -0
- package/tools/lint-strings/surfaces/metrics.js +30 -3
- package/tools/lint-strings/surfaces/properties.js +3 -0
- package/tools/lint-strings/surfaces/rpk.js +11 -0
- package/tools/overrides-audit/classify.js +6 -2
- package/tools/overrides-audit/report.js +169 -0
- package/tools/overrides-audit/triage.js +225 -0
- package/tools/property-extractor/helpers/applyPropertyLinks.js +11 -6
- package/tools/property-extractor/transformers.py +26 -0
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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
|
|
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
|
-
*
|
|
137
|
-
*
|
|
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
|
-
*
|
|
140
|
-
*
|
|
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
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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.
|
|
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)
|
|
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
|
-
|
|
234
|
-
|
|
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
|
|
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: {
|
|
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
|
+
}
|
|
@@ -424,11 +424,17 @@ function normalizeIncludes(propName, raw, warnings) {
|
|
|
424
424
|
*
|
|
425
425
|
* Two separate reasons, and only the first was obvious. `cloud_supported: false`
|
|
426
426
|
* says the control plane does not expose the property to customers. But
|
|
427
|
-
* cloud-docs also publishes no broker properties page
|
|
428
|
-
*
|
|
429
|
-
*
|
|
430
|
-
*
|
|
431
|
-
*
|
|
427
|
+
* cloud-docs also publishes no broker properties page at all, so a link to any
|
|
428
|
+
* broker-scope property dangles there however the install pack feels about it.
|
|
429
|
+
* That case cannot be read off `cloud_supported`: cloud_config.py annotates
|
|
430
|
+
* cluster scope only and leaves the field absent elsewhere, because "absent"
|
|
431
|
+
* means no opinion rather than false.
|
|
432
|
+
*
|
|
433
|
+
* Topic scope is not in that case. cloud-docs publishes a topic properties page
|
|
434
|
+
* that includes the docs topic-properties partial with the same tags as the docs
|
|
435
|
+
* page (every category, minus deprecated and exclude-from-docs), so a topic
|
|
436
|
+
* property missing from Cloud is missing from self-managed too, and
|
|
437
|
+
* `self-managed-only:` would only strip a working link.
|
|
432
438
|
*
|
|
433
439
|
* @param {Object} target - The property being linked to.
|
|
434
440
|
* @returns {string|null} A short reason, or null when the target is reachable.
|
|
@@ -436,7 +442,6 @@ function normalizeIncludes(propName, raw, warnings) {
|
|
|
436
442
|
function cloudUnreachable(target) {
|
|
437
443
|
if (target.cloud_supported === false) return 'cloud_supported: false';
|
|
438
444
|
if (target.config_scope === 'broker') return 'Cloud publishes no broker properties page';
|
|
439
|
-
if (target.config_scope === 'topic') return 'Cloud publishes no topic properties page';
|
|
440
445
|
return null;
|
|
441
446
|
}
|
|
442
447
|
|
|
@@ -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"
|