@redpanda-data/docs-extensions-and-macros 5.48.1 → 5.50.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +324 -28
- package/tools/lint-strings/rules/common.js +9 -2
- 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 +6 -0
- package/tools/lint-strings/surfaces/rpk.js +20 -1
- 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/README.adoc +10 -9
- package/tools/property-extractor/transformers.py +26 -0
- package/tools/rpk-docs/generate-rpk-docs.js +38 -4
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,40 @@ 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 touchedFiles = new Set([...files.keys(), ...removedFiles])
|
|
120
|
+
// A surface whose extraction covers the whole surface anyway (the
|
|
121
|
+
// properties extractor parses every config file on each run) supplies
|
|
122
|
+
// context from all of it, so a property in an untouched file that
|
|
123
|
+
// names, or is named by, a changed one is still on its page. Review
|
|
124
|
+
// scope stays the touched files either way.
|
|
125
|
+
const everything = surface.contextScope === 'surface' ? surface.extract({ repo: repoPath, log }) : null
|
|
126
|
+
const head = everything
|
|
127
|
+
? everything.filter((d) => touchedFiles.has(d.file))
|
|
128
|
+
: surface.extract({ repo: repoPath, files: touchedFiles, log })
|
|
129
|
+
headBySurface[surfaceName] = head
|
|
130
|
+
const declarations = []
|
|
131
|
+
for (const decl of head) {
|
|
132
|
+
if (!touches(decl, files.get(decl.file))) continue
|
|
133
|
+
decl.in_pr_diff = true
|
|
134
|
+
decl.fingerprint = fingerprint(decl)
|
|
135
|
+
if (reviewed.has(decl.fingerprint)) {
|
|
136
|
+
alreadyReviewed++
|
|
137
|
+
continue
|
|
138
|
+
}
|
|
139
|
+
declarations.push(decl)
|
|
140
|
+
pending.push({ ...pendingEntry(decl), context: pageContext(decl, everything || head) })
|
|
141
|
+
}
|
|
109
142
|
results.push(runRules(declarations, rulesFor(surface), { skipRules, onlyRules }))
|
|
110
143
|
}
|
|
144
|
+
|
|
145
|
+
removal = collectRemovals({ repoPath, diffBase, removed: removedBySurface, surfaces, requested, headBySurface, log })
|
|
146
|
+
const kept = removal.declarations.filter((decl) => !reviewed.has(decl.fingerprint))
|
|
147
|
+
alreadyReviewed += removal.declarations.length - kept.length
|
|
148
|
+
removal.declarations = kept
|
|
111
149
|
} else {
|
|
112
150
|
for (const surfaceName of requested) {
|
|
113
151
|
const surface = SURFACES[surfaceName]
|
|
@@ -119,34 +157,266 @@ function lintStrings (options) {
|
|
|
119
157
|
}
|
|
120
158
|
|
|
121
159
|
const merged = mergeResults(results)
|
|
160
|
+
for (const finding of merged.findings) {
|
|
161
|
+
if (finding.in_pr_diff) finding.fingerprint = fingerprint(finding)
|
|
162
|
+
}
|
|
122
163
|
merged.findings.sort((a, b) =>
|
|
123
164
|
a.surface.localeCompare(b.surface) || a.file.localeCompare(b.file) || (a.line_start || 0) - (b.line_start || 0))
|
|
124
165
|
merged.unsupported_surfaces = unsupportedSurfaces
|
|
166
|
+
if (diffBase) {
|
|
167
|
+
merged.declarations = pending
|
|
168
|
+
merged.summary.alreadyReviewed = alreadyReviewed
|
|
169
|
+
}
|
|
170
|
+
merged.summary.removedDeclarations = removal.declarations.map(pendingEntry)
|
|
171
|
+
const removedSurfaceFiles = [...removal.rawFiles, ...removedFilesFor(removal.declarations)]
|
|
172
|
+
.sort((a, b) => a.surface.localeCompare(b.surface) || a.file.localeCompare(b.file))
|
|
125
173
|
merged.summary.removedSurfaceFiles = removedSurfaceFiles
|
|
126
174
|
merged.summary.removedSurfaceLines = removedSurfaceFiles.reduce((sum, entry) => sum + entry.lines, 0)
|
|
127
175
|
return merged
|
|
128
176
|
}
|
|
129
177
|
|
|
130
178
|
/**
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
|
|
179
|
+
* A declaration's anchor lines: its span, plus any lines a surface records
|
|
180
|
+
* separately because the declared name lives outside it (metrics names sit
|
|
181
|
+
* in the enclosing make_*() call).
|
|
182
|
+
*/
|
|
183
|
+
function spansOf (decl) {
|
|
184
|
+
const spans = [[decl.line_start, decl.line_end]]
|
|
185
|
+
const extra = decl.meta && decl.meta.name_lines
|
|
186
|
+
if (extra) spans.push(extra)
|
|
187
|
+
return spans
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function inSpans (decl, line) {
|
|
191
|
+
return spansOf(decl).some(([start, end]) => start != null && line >= start && line <= end)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function touches (decl, lineSet) {
|
|
195
|
+
return spansOf(decl).some(([start, end]) => spanIntersects(start, end, lineSet))
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const CONTEXT_LIMIT = 15
|
|
199
|
+
const CONTEXT_STRING_LIMIT = 600
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The other strings a reader sees alongside this one, so a review can judge
|
|
203
|
+
* a string against its page rather than on its own: a flag's usage next to
|
|
204
|
+
* its command's Long text and examples, a property that names another
|
|
205
|
+
* property, the fields around an API field.
|
|
135
206
|
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
207
|
+
* - rpk: every other string declared in the same file, which is the
|
|
208
|
+
* command's Short, Long and flags (rpk keeps one command per file).
|
|
209
|
+
* - api: the declarations nearest by line, which are the enclosing message
|
|
210
|
+
* or rpc and its neighbors.
|
|
211
|
+
* - properties and metrics: declarations whose string names this one, or
|
|
212
|
+
* that this string names, plus (metrics) its neighbors in the same group.
|
|
138
213
|
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
214
|
+
* Drawn from declarations already extracted for the diff, so it costs no
|
|
215
|
+
* extra extraction. Deterministic, capped, and order-stable.
|
|
216
|
+
*/
|
|
217
|
+
function pageContext (decl, all) {
|
|
218
|
+
const others = all.filter((d) => d !== decl && d.string != null)
|
|
219
|
+
const byDistance = (d) => Math.abs((d.line_start || 0) - (decl.line_start || 0))
|
|
220
|
+
const sameFile = others.filter((d) => d.file === decl.file)
|
|
221
|
+
let picked
|
|
222
|
+
if (decl.surface === 'rpk') {
|
|
223
|
+
// The command's Short and Long always, then the flags nearest this
|
|
224
|
+
// declaration, so a changed flag late in a long command keeps its
|
|
225
|
+
// neighbors when the cap applies. Shown in source order.
|
|
226
|
+
const isText = (d) => d.meta && (d.meta.kind === 'short' || d.meta.kind === 'long')
|
|
227
|
+
const text = sameFile.filter(isText)
|
|
228
|
+
const flags = sameFile.filter((d) => !isText(d)).sort((a, b) => byDistance(a) - byDistance(b))
|
|
229
|
+
picked = [...text, ...flags].slice(0, CONTEXT_LIMIT)
|
|
230
|
+
.sort((a, b) => (a.line_start || 0) - (b.line_start || 0))
|
|
231
|
+
} else if (decl.surface === 'api') {
|
|
232
|
+
picked = sameFile.sort((a, b) => byDistance(a) - byDistance(b)).slice(0, 6)
|
|
233
|
+
} else {
|
|
234
|
+
const mentions = (text, name) => Boolean(text && name) &&
|
|
235
|
+
new RegExp(`(^|[^A-Za-z0-9_])${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}($|[^A-Za-z0-9_])`).test(text)
|
|
236
|
+
const related = others.filter((d) => mentions(d.string, decl.name) || mentions(decl.string, d.name))
|
|
237
|
+
const neighbors = decl.surface === 'metrics'
|
|
238
|
+
? sameFile.filter((d) => !related.includes(d)).sort((a, b) => byDistance(a) - byDistance(b)).slice(0, 2)
|
|
239
|
+
: []
|
|
240
|
+
picked = [...related, ...neighbors]
|
|
241
|
+
}
|
|
242
|
+
return picked.slice(0, CONTEXT_LIMIT).map((d) => ({
|
|
243
|
+
name: d.name,
|
|
244
|
+
kind: (d.meta && d.meta.kind) || d.surface,
|
|
245
|
+
file: d.file,
|
|
246
|
+
line_start: d.line_start,
|
|
247
|
+
string: d.string.length > CONTEXT_STRING_LIMIT ? `${d.string.slice(0, CONTEXT_STRING_LIMIT)}...` : d.string
|
|
248
|
+
}))
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Stable identity for one version of one doc string. A PR review records the
|
|
253
|
+
* fingerprints it has seen, and a later push skips any declaration whose
|
|
254
|
+
* surface, name and text are unchanged, so rebases, moves and unrelated edits
|
|
255
|
+
* never re-review a string. Editing the string changes the fingerprint.
|
|
256
|
+
*/
|
|
257
|
+
function fingerprint (decl) {
|
|
258
|
+
return crypto.createHash('sha256')
|
|
259
|
+
.update(JSON.stringify([decl.surface, decl.name, decl.string == null ? null : decl.string]))
|
|
260
|
+
.digest('hex')
|
|
261
|
+
.slice(0, 16)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function removalFingerprint (surfaceName, identity) {
|
|
265
|
+
return crypto.createHash('sha256')
|
|
266
|
+
.update(JSON.stringify(['removed', surfaceName, identity]))
|
|
267
|
+
.digest('hex')
|
|
268
|
+
.slice(0, 16)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Where a declaration lives, for matching the merge-base side against HEAD.
|
|
273
|
+
* Bare names repeat on most surfaces (a `name` field in many proto messages,
|
|
274
|
+
* `enabled` in many CRD structs, the same flag on many rpk commands), so each
|
|
275
|
+
* surface supplies a contextual identity; without one, the name is used.
|
|
141
276
|
*/
|
|
142
|
-
function
|
|
277
|
+
function identityOf (surface, decl) {
|
|
278
|
+
return surface.identity ? surface.identity(decl) : decl.name
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function pendingEntry (decl) {
|
|
282
|
+
return {
|
|
283
|
+
surface: decl.surface,
|
|
284
|
+
name: decl.name,
|
|
285
|
+
file: decl.file,
|
|
286
|
+
line_start: decl.line_start,
|
|
287
|
+
line_end: decl.line_end,
|
|
288
|
+
fingerprint: decl.fingerprint
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function removedFilesFor (declarations) {
|
|
293
|
+
const byFile = new Map()
|
|
294
|
+
for (const decl of declarations) {
|
|
295
|
+
const key = `${decl.surface}\0${decl.file}`
|
|
296
|
+
const lines = (decl.removed_lines || 0)
|
|
297
|
+
byFile.set(key, { surface: decl.surface, file: decl.file, lines: (byFile.has(key) ? byFile.get(key).lines : 0) + lines })
|
|
298
|
+
}
|
|
299
|
+
return [...byFile.values()].sort((a, b) => a.surface.localeCompare(b.surface) || a.file.localeCompare(b.file))
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Materialize the merge-base version of `files` into a scratch tree, so a
|
|
304
|
+
* surface extractor can read the pre-image. A surface that declares
|
|
305
|
+
* baseScope 'directory' gets the whole directories instead: the properties
|
|
306
|
+
* extractor pairs each .cc with its .h.
|
|
307
|
+
*/
|
|
308
|
+
function materializeBase (repoPath, diffBase, files, scope = 'file') {
|
|
309
|
+
const mb = spawnSync('git', ['merge-base', diffBase, 'HEAD'], { cwd: repoPath, encoding: 'utf8' })
|
|
310
|
+
if (mb.status !== 0) throw new Error(`git merge-base ${diffBase} HEAD failed: ${mb.stderr}`)
|
|
311
|
+
const base = mb.stdout.trim()
|
|
312
|
+
const paths = scope === 'directory' ? [...new Set(files.map((f) => path.dirname(f)))] : files
|
|
313
|
+
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'lint-strings-base-'))
|
|
314
|
+
const archive = spawnSync('git', ['archive', '--format=tar', base, '--', ...paths], { cwd: repoPath, maxBuffer: 1024 * 1024 * 1024 })
|
|
315
|
+
if (archive.status !== 0) {
|
|
316
|
+
fs.rmSync(scratch, { recursive: true, force: true })
|
|
317
|
+
throw new Error(`git archive ${base} failed: ${archive.stderr}`)
|
|
318
|
+
}
|
|
319
|
+
const untar = spawnSync('tar', ['-x', '-C', scratch], { input: archive.stdout })
|
|
320
|
+
if (untar.status !== 0) {
|
|
321
|
+
fs.rmSync(scratch, { recursive: true, force: true })
|
|
322
|
+
throw new Error(`tar failed: ${untar.stderr}`)
|
|
323
|
+
}
|
|
324
|
+
return scratch
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Doc-string declarations the diff removed or renamed.
|
|
329
|
+
*
|
|
330
|
+
* A deleted line only matters when it belonged to a declaration: removing an
|
|
331
|
+
* include, a struct field or a checksum from a surface file is not a removed
|
|
332
|
+
* surface. So the old side of each affected file is extracted at the merge
|
|
333
|
+
* base, and a declaration counts as removed when its span there lost lines
|
|
334
|
+
* and HEAD, across every file the diff touched, holds fewer declarations with
|
|
335
|
+
* its identity (see identityOf) than the merge base did. Still present means
|
|
336
|
+
* it was edited or moved, and HEAD-side review covers it. Counting, rather
|
|
337
|
+
* than asking whether the identity appears at all, keeps a deletion visible
|
|
338
|
+
* when another declaration shares the identity and survives.
|
|
339
|
+
*
|
|
340
|
+
* Declarations are absent from HEAD by construction, so they cannot be linted;
|
|
341
|
+
* they are reported for the published-content check instead.
|
|
342
|
+
*
|
|
343
|
+
* Surfaces routed without a registered extractor, and any base extraction
|
|
344
|
+
* that fails, fall back to counting raw deleted lines: without an extractor
|
|
345
|
+
* there is no way to tell, and the gate should err toward reviewing.
|
|
346
|
+
*
|
|
347
|
+
* @returns {{ rawFiles: Array, declarations: Array }}
|
|
348
|
+
*/
|
|
349
|
+
function collectRemovals ({ repoPath, diffBase, removed, surfaces, requested, headBySurface, log }) {
|
|
143
350
|
const only = surfaces && surfaces.length > 0 ? new Set(surfaces) : null
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
351
|
+
const rawFiles = []
|
|
352
|
+
const declarations = []
|
|
353
|
+
|
|
354
|
+
for (const [surfaceName, files] of Object.entries(removed)) {
|
|
355
|
+
if (only && !only.has(surfaceName)) continue
|
|
356
|
+
const surface = SURFACES[surfaceName]
|
|
357
|
+
const raw = () => {
|
|
358
|
+
for (const [file, lines] of files) rawFiles.push({ surface: surfaceName, file, lines: lines.size })
|
|
359
|
+
}
|
|
360
|
+
if (!surface || !requested.includes(surfaceName)) {
|
|
361
|
+
raw()
|
|
362
|
+
continue
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
let scratch = null
|
|
366
|
+
try {
|
|
367
|
+
scratch = materializeBase(repoPath, diffBase, [...files.keys()], surface.baseScope)
|
|
368
|
+
log(`[${surfaceName}] ${files.size} file(s) lost lines; extracting declarations at the merge base...`)
|
|
369
|
+
const baseDecls = surface.extract({ repo: scratch, files: new Set(files.keys()), log })
|
|
370
|
+
const head = headBySurface[surfaceName] ||
|
|
371
|
+
surface.extract({ repo: repoPath, files: new Set(files.keys()), log })
|
|
372
|
+
const keyOf = (decl) => JSON.stringify(identityOf(surface, decl))
|
|
373
|
+
const headCount = new Map()
|
|
374
|
+
for (const decl of head) headCount.set(keyOf(decl), (headCount.get(keyOf(decl)) || 0) + 1)
|
|
375
|
+
const baseCount = new Map()
|
|
376
|
+
const touched = new Map()
|
|
377
|
+
for (const decl of baseDecls) {
|
|
378
|
+
const key = keyOf(decl)
|
|
379
|
+
baseCount.set(key, (baseCount.get(key) || 0) + 1)
|
|
380
|
+
const lost = files.get(decl.file)
|
|
381
|
+
if (!touches(decl, lost)) continue
|
|
382
|
+
let count = 0
|
|
383
|
+
for (const line of lost) if (inSpans(decl, line)) count++
|
|
384
|
+
if (!touched.has(key)) touched.set(key, [])
|
|
385
|
+
touched.get(key).push({ decl, count })
|
|
386
|
+
}
|
|
387
|
+
// HEAD lost `deficit` declarations with this identity. When more
|
|
388
|
+
// touched ones share it than went missing, report the ones that lost
|
|
389
|
+
// the most lines: a deleted declaration loses its whole span.
|
|
390
|
+
const gone = new Map()
|
|
391
|
+
for (const [key, list] of touched) {
|
|
392
|
+
const deficit = baseCount.get(key) - (headCount.get(key) || 0)
|
|
393
|
+
if (deficit <= 0) continue
|
|
394
|
+
const picked = [...list].sort((a, b) => b.count - a.count || a.decl.line_start - b.decl.line_start).slice(0, deficit)
|
|
395
|
+
for (const entry of picked) gone.set(entry.decl, entry.count)
|
|
396
|
+
}
|
|
397
|
+
for (const decl of baseDecls) {
|
|
398
|
+
if (!gone.has(decl)) continue
|
|
399
|
+
const count = gone.get(decl)
|
|
400
|
+
declarations.push({
|
|
401
|
+
surface: surfaceName,
|
|
402
|
+
name: decl.name,
|
|
403
|
+
file: decl.file,
|
|
404
|
+
line_start: decl.line_start,
|
|
405
|
+
line_end: decl.line_end,
|
|
406
|
+
string: decl.string,
|
|
407
|
+
removed_lines: count,
|
|
408
|
+
fingerprint: removalFingerprint(surfaceName, identityOf(surface, decl))
|
|
409
|
+
})
|
|
410
|
+
}
|
|
411
|
+
} catch (err) {
|
|
412
|
+
log(`[${surfaceName}] could not extract the merge-base side (${err.message}); counting raw deleted lines instead`)
|
|
413
|
+
raw()
|
|
414
|
+
} finally {
|
|
415
|
+
if (scratch) fs.rmSync(scratch, { recursive: true, force: true })
|
|
416
|
+
}
|
|
148
417
|
}
|
|
149
|
-
|
|
418
|
+
|
|
419
|
+
return { rawFiles, declarations }
|
|
150
420
|
}
|
|
151
421
|
|
|
152
422
|
/**
|
|
@@ -175,9 +445,15 @@ function formatHuman (result) {
|
|
|
175
445
|
lines.push('='.repeat(60))
|
|
176
446
|
lines.push(`Declarations checked: ${summary.totalDeclarations}`)
|
|
177
447
|
lines.push(`Declarations flagged: ${summary.flaggedDeclarations}`)
|
|
178
|
-
if (summary.
|
|
448
|
+
if (summary.alreadyReviewed) {
|
|
449
|
+
lines.push(`Already reviewed on an earlier push (skipped): ${summary.alreadyReviewed}`)
|
|
450
|
+
}
|
|
451
|
+
for (const decl of summary.removedDeclarations || []) {
|
|
452
|
+
lines.push(`Removed or renamed: ${decl.name} (${decl.surface}, ${decl.file})`)
|
|
453
|
+
}
|
|
454
|
+
if (summary.removedSurfaceLines && !(summary.removedDeclarations || []).length) {
|
|
179
455
|
lines.push(`Lines deleted from doc-string surfaces: ${summary.removedSurfaceLines} ` +
|
|
180
|
-
`(${summary.removedSurfaceFiles.length} file(s)
|
|
456
|
+
`(${summary.removedSurfaceFiles.length} file(s) with no extractor, so every deleted line counts)`)
|
|
181
457
|
}
|
|
182
458
|
lines.push(`Errors: ${summary.errors} Warnings: ${summary.warnings} Info: ${summary.info}`)
|
|
183
459
|
if (Object.keys(summary.byRule).length > 0) {
|
|
@@ -202,6 +478,22 @@ function formatHuman (result) {
|
|
|
202
478
|
return lines.join('\n')
|
|
203
479
|
}
|
|
204
480
|
|
|
481
|
+
/**
|
|
482
|
+
* Read a --reviewed file: fingerprints separated by whitespace or commas.
|
|
483
|
+
* Anything that is not a 16-hex-digit fingerprint is ignored, and a missing
|
|
484
|
+
* file is an empty set, so a first run and a corrupted cache both mean
|
|
485
|
+
* "review everything" rather than an error.
|
|
486
|
+
*/
|
|
487
|
+
function readFingerprints (file) {
|
|
488
|
+
let text = ''
|
|
489
|
+
try {
|
|
490
|
+
text = fs.readFileSync(file, 'utf8')
|
|
491
|
+
} catch {
|
|
492
|
+
return new Set()
|
|
493
|
+
}
|
|
494
|
+
return new Set(text.split(/[\s,]+/).filter((token) => /^[0-9a-f]{16}$/.test(token)))
|
|
495
|
+
}
|
|
496
|
+
|
|
205
497
|
/**
|
|
206
498
|
* CLI entry point shared by bin/doc-tools.js and direct invocation
|
|
207
499
|
* (node tools/lint-strings --repo <path> ...).
|
|
@@ -217,7 +509,8 @@ function runCli (options) {
|
|
|
217
509
|
surfaces: options.surface ? String(options.surface).split(',').map((s) => s.trim()).filter(Boolean) : null,
|
|
218
510
|
diffBase: options.diff || null,
|
|
219
511
|
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
|
|
512
|
+
onlyRules: options.onlyRules ? String(options.onlyRules).split(',').map((s) => s.trim()).filter(Boolean) : null,
|
|
513
|
+
reviewedFingerprints: options.reviewed ? readFingerprints(options.reviewed) : null
|
|
221
514
|
})
|
|
222
515
|
} catch (err) {
|
|
223
516
|
console.error(`Error: ${err.message}`)
|
|
@@ -230,11 +523,13 @@ function runCli (options) {
|
|
|
230
523
|
console.log(formatHuman(result))
|
|
231
524
|
}
|
|
232
525
|
|
|
233
|
-
|
|
234
|
-
|
|
526
|
+
// exitCode, not process.exit(): exiting immediately drops whatever part of
|
|
527
|
+
// a large report is still buffered when stdout is a pipe, so a caller
|
|
528
|
+
// piping --format json into another process got truncated JSON.
|
|
529
|
+
process.exitCode = options.strict && result.summary.errors > 0 ? 1 : 0
|
|
235
530
|
}
|
|
236
531
|
|
|
237
|
-
module.exports = { lintStrings, formatHuman, runCli, SURFACES, rulesFor }
|
|
532
|
+
module.exports = { lintStrings, formatHuman, runCli, SURFACES, rulesFor, fingerprint, readFingerprints, pageContext }
|
|
238
533
|
|
|
239
534
|
// Direct usage: node tools/lint-strings --repo <path> [--surface a,b]
|
|
240
535
|
// [--diff <base>] [--format json|human] [--strict]
|
|
@@ -249,10 +544,11 @@ if (require.main === module) {
|
|
|
249
544
|
else if (arg === '--format') options.format = args[++i]
|
|
250
545
|
else if (arg === '--skip-rules') options.skipRules = args[++i]
|
|
251
546
|
else if (arg === '--only-rules') options.onlyRules = args[++i]
|
|
547
|
+
else if (arg === '--reviewed') options.reviewed = args[++i]
|
|
252
548
|
else if (arg === '--strict') options.strict = true
|
|
253
549
|
else {
|
|
254
550
|
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]')
|
|
551
|
+
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
552
|
process.exit(2)
|
|
257
553
|
}
|
|
258
554
|
}
|
|
@@ -100,7 +100,9 @@ function maskNonProse (text) {
|
|
|
100
100
|
const CODE_TOKEN_PATTERNS = Object.freeze([
|
|
101
101
|
{ id: 'identifier', re: /\b[a-z][a-z0-9]*(?:_[a-z0-9]+)+\b/g },
|
|
102
102
|
{ id: 'flag', re: /(?<![\w-])--[a-z][a-z0-9]*(?:-[a-z0-9]+)*\b/g },
|
|
103
|
-
|
|
103
|
+
// A path may not end in a period, so "served from /v1/brokers." reports
|
|
104
|
+
// `/v1/brokers`, not the sentence's full stop with it.
|
|
105
|
+
{ id: 'path', re: /(?<![\w/])\/[a-z][a-zA-Z0-9_.-]*(?:\/[a-zA-Z0-9_.{}-]*[a-zA-Z0-9_{}-])+\/?/g }
|
|
104
106
|
])
|
|
105
107
|
|
|
106
108
|
/**
|
|
@@ -276,7 +278,12 @@ const COMMON_RULES = [
|
|
|
276
278
|
description: 'Code value or field name in prose without inline code',
|
|
277
279
|
severity: 'warning',
|
|
278
280
|
check: (decl) => {
|
|
279
|
-
|
|
281
|
+
// A surface whose generator formats some code values itself declares
|
|
282
|
+
// which (convention.auto_inline_code); those are not findings there.
|
|
283
|
+
const auto = (decl.convention && decl.convention.auto_inline_code) || null
|
|
284
|
+
const bare = findBareCodeTokens(decl.string, decl.name).filter((b) => !auto ||
|
|
285
|
+
!((auto.kinds || []).includes(b.kind) ||
|
|
286
|
+
(b.kind === 'path' && (auto.path_prefixes || []).some((prefix) => b.token.startsWith(prefix)))))
|
|
280
287
|
if (bare.length === 0) return []
|
|
281
288
|
const list = bare.map((b) => `\`${b.token}\``).join(', ')
|
|
282
289
|
return [{
|
|
@@ -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
|