@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 CHANGED
@@ -2755,6 +2755,7 @@ programCli
2755
2755
  .option('--format <format>', 'Output format: human or json', 'human')
2756
2756
  .option('--skip-rules <list>', 'Comma-separated rule ids to skip')
2757
2757
  .option('--only-rules <list>', 'Comma-separated rule ids to run exclusively')
2758
+ .option('--reviewed <file>', 'Diff mode: skip declarations whose fingerprint is listed in <file> (already reviewed on an earlier push)')
2758
2759
  .option('--strict', 'Exit 1 when any error-severity finding exists (default: always exit 0 - suggest, never block)')
2759
2760
  .action((options) => {
2760
2761
  const { runCli } = require('../tools/lint-strings')
@@ -2908,6 +2909,92 @@ overridesGroup
2908
2909
  }
2909
2910
  })
2910
2911
 
2912
+ /**
2913
+ * @description Build the plain-text prompt an external LLM triage call
2914
+ * should send for one UPSTREAMABLE or SPLIT KEEP_UNTIL_UPSTREAMED audit row.
2915
+ * Reads a single candidate object (not an array) from --candidate. See
2916
+ * tools/overrides-audit/triage.js for the prompt contract.
2917
+ *
2918
+ * @why Separated from the actual LLM call so the workflow that runs it can
2919
+ * use whatever action/model it wants; this command only builds the prompt.
2920
+ *
2921
+ * @example
2922
+ * npx doc-tools overrides triage-prompt --candidate candidate.json
2923
+ */
2924
+ overridesGroup
2925
+ .command('triage-prompt')
2926
+ .description('Build the triage prompt for one UPSTREAMABLE/SPLIT candidate')
2927
+ .requiredOption('--candidate <path>', 'Path to a single candidate row JSON file (one object, not an array)')
2928
+ .action((options) => {
2929
+ const { buildTriagePrompt } = require('../tools/overrides-audit/triage')
2930
+ try {
2931
+ const candidate = JSON.parse(fs.readFileSync(path.resolve(options.candidate), 'utf8'))
2932
+ console.log(buildTriagePrompt(candidate))
2933
+ } catch (err) {
2934
+ fail(err.message)
2935
+ }
2936
+ })
2937
+
2938
+ /**
2939
+ * @description Parse the raw text an LLM triage call returned for one
2940
+ * candidate and merge it into that candidate's manifest row, adding
2941
+ * agent_verdict / agent_reason / triage_failed. Any malformed, empty, or
2942
+ * unexpected response degrades to a safe AMBIGUOUS verdict rather than
2943
+ * throwing — a triage call failing is not a reason to fail the pipeline,
2944
+ * it's a reason to ask a human. See tools/overrides-audit/triage.js.
2945
+ *
2946
+ * @example
2947
+ * npx doc-tools overrides triage-parse --candidate candidate.json --response response.txt
2948
+ */
2949
+ overridesGroup
2950
+ .command('triage-parse')
2951
+ .description('Merge an LLM triage response into a candidate row')
2952
+ .requiredOption('--candidate <path>', 'Path to the candidate row JSON file this response answers')
2953
+ .requiredOption('--response <path>', 'Path to the raw text file the LLM triage call returned')
2954
+ .action((options) => {
2955
+ const { triageCandidate } = require('../tools/overrides-audit/triage')
2956
+ try {
2957
+ const candidate = JSON.parse(fs.readFileSync(path.resolve(options.candidate), 'utf8'))
2958
+ // A triage call that failed before writing its output leaves no
2959
+ // response file. Treat that as an empty response so it reaches the
2960
+ // AMBIGUOUS fallback instead of failing the command.
2961
+ let rawResponse = ''
2962
+ try {
2963
+ rawResponse = fs.readFileSync(path.resolve(options.response), 'utf8')
2964
+ } catch (err) {
2965
+ if (err.code !== 'ENOENT') throw err
2966
+ }
2967
+ console.log(JSON.stringify(triageCandidate(candidate, rawResponse), null, 2))
2968
+ } catch (err) {
2969
+ fail(err.message)
2970
+ }
2971
+ })
2972
+
2973
+ /**
2974
+ * @description Render one plain-English markdown report section from a
2975
+ * batch of triaged candidate rows (rows already carrying agent_verdict from
2976
+ * triage-parse). See tools/overrides-audit/report.js for what each section
2977
+ * contains and what happens to rows outside the requested verdict.
2978
+ *
2979
+ * @example
2980
+ * npx doc-tools overrides report --candidates triaged.json --section upstream
2981
+ */
2982
+ overridesGroup
2983
+ .command('report')
2984
+ .description('Build a plain-English markdown report section from triaged candidates')
2985
+ .requiredOption('--candidates <path>', 'Path to a JSON array of triaged candidate rows')
2986
+ .addOption(new Option('--section <section>', 'Which report section to build').choices(['upstream', 'retirement', 'ambiguous']).makeOptionMandatory())
2987
+ .action((options) => {
2988
+ const { buildUpstreamSection, buildRetirementSection, buildAmbiguousDigest } = require('../tools/overrides-audit/report')
2989
+ const BUILDERS = { upstream: buildUpstreamSection, retirement: buildRetirementSection, ambiguous: buildAmbiguousDigest }
2990
+ try {
2991
+ const candidates = JSON.parse(fs.readFileSync(path.resolve(options.candidates), 'utf8'))
2992
+ console.log(BUILDERS[options.section](candidates))
2993
+ } catch (err) {
2994
+ fail(err.message)
2995
+ }
2996
+ })
2997
+
2911
2998
  programCli.addCommand(automation)
2912
2999
  /**
2913
3000
  * validate kapa-source-groups
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redpanda-data/docs-extensions-and-macros",
3
- "version": "5.48.1",
3
+ "version": "5.50.0",
4
4
  "description": "Antora extensions and macros developed for Redpanda documentation.",
5
5
  "keywords": [
6
6
  "antora",
@@ -20,7 +20,14 @@ const { spawnSync } = require('child_process')
20
20
  */
21
21
  const SURFACE_ROUTES = [
22
22
  { surface: 'properties', pattern: /^src\/v\/config\// },
23
- { surface: 'metrics', pattern: /(^|\/)[^/]*probe\.cc$|^src\/v\/metrics\// },
23
+ // Metrics are registered wherever a component exposes them, not in files
24
+ // with a predictable name: raft/consensus.cc, cluster/rm_stm.cc and
25
+ // headers such as kafka/server/kafka_probe.h all call sm::description. So
26
+ // route every non-test C++ source under src/v (config/ has already matched
27
+ // properties above) and let the scanner, which skips any file with no
28
+ // description() call, decide what is a declaration. Test directories are
29
+ // excluded the same way the whole-repo scan excludes them.
30
+ { surface: 'metrics', pattern: /^src\/v\/(?!(?:.*\/)?tests?\/).*\.(?:cc|h)$/ },
24
31
  { surface: 'rpk', pattern: /^src\/go\/rpk\/pkg\/cli\// },
25
32
  // Both chart layouts ship: charts/<name>/chart/values.yaml (redpanda,
26
33
  // console) and charts/<name>/values.yaml (connectors).
@@ -1,7 +1,10 @@
1
1
  'use strict'
2
2
 
3
+ const crypto = require('crypto')
3
4
  const fs = require('fs')
5
+ const os = require('os')
4
6
  const path = require('path')
7
+ const { spawnSync } = require('child_process')
5
8
 
6
9
  const { runRules, mergeResults } = require('./engine')
7
10
  const { getDiffLines, classifyDiff, spanIntersects, SURFACE_ROUTES } = require('./diff')
@@ -54,6 +57,9 @@ function rulesFor (surface) {
54
57
  * only declarations whose span intersects lines changed since this ref
55
58
  * @param {string[]} [options.skipRules]
56
59
  * @param {string[]} [options.onlyRules]
60
+ * @param {Set<string>} [options.reviewedFingerprints] - Diff mode: skip
61
+ * declarations (and removals) whose fingerprint is in this set, so a PR
62
+ * review sees each string once across pushes
57
63
  * @param {Function} [options.log] - Progress logger (stderr by default)
58
64
  * @returns {Object} { findings, summary, unsupported_surfaces }
59
65
  */
@@ -64,6 +70,7 @@ function lintStrings (options) {
64
70
  diffBase = null,
65
71
  skipRules = [],
66
72
  onlyRules = null,
73
+ reviewedFingerprints = null,
67
74
  log = (msg) => process.stderr.write(`${msg}\n`)
68
75
  } = options
69
76
 
@@ -83,12 +90,16 @@ function lintStrings (options) {
83
90
 
84
91
  const results = []
85
92
  const unsupportedSurfaces = []
86
- let removedSurfaceFiles = []
93
+ const reviewed = reviewedFingerprints || new Set()
94
+ const pending = []
95
+ let alreadyReviewed = 0
96
+ let removal = { rawFiles: [], declarations: [] }
87
97
 
88
98
  if (diffBase) {
89
99
  const { changed, removed } = getDiffLines(repoPath, diffBase)
90
100
  const classified = classifyDiff(changed)
91
- removedSurfaceFiles = collectRemovals(removed, surfaces)
101
+ const removedBySurface = classifyDiff(removed)
102
+ const headBySurface = {}
92
103
 
93
104
  for (const [surfaceName, files] of Object.entries(classified)) {
94
105
  if (!SURFACES[surfaceName]) {
@@ -101,13 +112,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
- const fileSet = new Set(files.keys())
105
- const declarations = surface
106
- .extract({ repo: repoPath, files: fileSet, log })
107
- .filter((decl) => spanIntersects(decl.line_start, decl.line_end, files.get(decl.file)))
108
- for (const decl of declarations) decl.in_pr_diff = true
115
+ // Files that only lost lines are extracted too: a declaration that
116
+ // still exists at HEAD, in any touched file, was edited or moved, not
117
+ // removed.
118
+ const removedFiles = removedBySurface[surfaceName] ? [...removedBySurface[surfaceName].keys()] : []
119
+ const 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
- * Deletions in files that route to a doc-string surface, so a deletion-only PR
132
- * still registers as touching a surface. Reported separately from
133
- * totalDeclarations because a removed declaration is absent from HEAD and
134
- * therefore cannot be extracted or linted.
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
- * Surfaces routed without a registered extractor are included: the gate cares
137
- * that documented content went away, not whether we can lint what replaced it.
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
- * @param {Map<string, Set<number>>} removed - From getDiffLines().removed
140
- * @param {string[]|null} surfaces - Explicit --surface narrowing, if any
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 collectRemovals (removed, surfaces) {
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 entries = []
145
- for (const [surface, files] of Object.entries(classifyDiff(removed))) {
146
- if (only && !only.has(surface)) continue
147
- for (const [file, lines] of files) entries.push({ surface, file, lines: lines.size })
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
- return entries.sort((a, b) => a.surface.localeCompare(b.surface) || a.file.localeCompare(b.file))
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.removedSurfaceLines) {
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); removed declarations cannot be extracted from HEAD)`)
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
- if (options.strict && result.summary.errors > 0) process.exit(1)
234
- process.exit(0)
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
- { id: 'path', re: /(?<![\w/])\/[a-z][a-zA-Z0-9_.-]*(?:\/[a-zA-Z0-9_.{}-]+)+\/?/g }
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
- const bare = findBareCodeTokens(decl.string, decl.name)
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