@redpanda-data/docs-extensions-and-macros 5.31.0 → 5.35.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.
@@ -0,0 +1,786 @@
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+
6
+ const { SourceCache } = require('../source-text')
7
+ const { isNameEcho } = require('../rules/common')
8
+
9
+ /**
10
+ * API surface: doc strings in the API protos, which reach readers as OpenAPI
11
+ * descriptions in the bundled specs in api-docs -> the published API
12
+ * reference.
13
+ *
14
+ * TWO GENERATORS, which is why this surface is the only one that has to know
15
+ * which tree a file came from. They derive the operation's summary and
16
+ * description from completely different places, so one contract cannot cover
17
+ * both and applying the wrong one produces nothing but false positives:
18
+ *
19
+ * * `openapiv2` - protoc-gen-openapiv2 (buf.build/grpc-ecosystem/openapiv2).
20
+ * Used by console (`proto/redpanda/api/**`) and cloudv2
21
+ * (`proto/public/<area>/redpanda/api/**`). The operation strings are written
22
+ * out explicitly in an option block.
23
+ *
24
+ * * `connect-openapi` - protoc-gen-connect-openapi (buf.build/community/
25
+ * sudorandom-connect-openapi). Used by Admin API v2 in the
26
+ * redpanda/streaming-enterprise repo. The operation strings come from the
27
+ * rpc's own leading comment.
28
+ *
29
+ * THREE STRING FORMS.
30
+ *
31
+ * 1. Leading `//` comments on messages, enums and fields. Both generators
32
+ * copy these into the schema's `description`. Prose, sentence case,
33
+ * terminal period - the same shape as the crd surface.
34
+ *
35
+ * 2. `summary:` and `description:` inside an openapiv2 option block
36
+ * (`openapiv2` only):
37
+ *
38
+ * option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
39
+ * summary: "Create topic"
40
+ * description: "Create a [topic](https://docs.redpanda.com/...)."
41
+ * };
42
+ *
43
+ * `summary` is the operation's one-line label and behaves like a cobra
44
+ * Short: one line, capitalized, no terminal period. `description` is
45
+ * prose and takes one.
46
+ *
47
+ * An `openapiv2_field` block carries the same `description` key inside a
48
+ * field's `[...]` option list, and it OVERRIDES the field's leading
49
+ * comment. Verified by generating: `MCPServer.resources` has both, and
50
+ * the option's text is what ships.
51
+ *
52
+ * 3. An rpc's leading comment (`connect-openapi` only). The FIRST line
53
+ * becomes the operation summary; everything after the first blank
54
+ * comment line becomes the description:
55
+ *
56
+ * // GetShadowLink
57
+ * //
58
+ * // Gets information about a specific shadow link.
59
+ * rpc GetShadowLink(GetShadowLinkRequest) returns (GetShadowLinkResponse) {
60
+ *
61
+ * The blank line is load-bearing. Without it the generator uses the whole
62
+ * comment as BOTH summary and description, which ships a paragraph as the
63
+ * reference's nav item - see `api-rpc-summary-not-separated`.
64
+ *
65
+ * By convention the summary is the rpc's own name (28 of the 29 rpcs in
66
+ * admin/v2), so `name-echo` is replaced by a kind-scoped `api-name-echo`
67
+ * that leaves rpc summaries alone. Punishing the documented convention on
68
+ * every operation would make the surface unusable on those PRs.
69
+ *
70
+ * The output format is Markdown, not AsciiDoc, so `verbatim_asciidoc` is
71
+ * false and none of the AsciiDoc rules (raw pipe, `{attr}`, broken macros)
72
+ * apply. Markdown links to docs.redpanda.com are the existing convention in
73
+ * these files and are deliberately NOT flagged: a link that resolves in the
74
+ * output format is not the same thing as an `xref:` stranded in a C++ string.
75
+ *
76
+ * Out of scope, deliberately:
77
+ *
78
+ * * `description` inside a `responses:` block ("OK", "Topic created").
79
+ * That is HTTP status prose, not user-facing feature prose, and holding
80
+ * two-word status labels to the quality bar would bury the real findings.
81
+ * * Every other openapiv2 key (`title`, `example`, `tags`, ...).
82
+ * * `google.api.http` bindings and `buf.validate` options, which are
83
+ * behavior rather than prose.
84
+ * * Fields inside an `extend` block: protobuf extension fields are wire
85
+ * metadata, never published schema.
86
+ * * A leading comment on an `openapiv2` rpc. 159 of console's 271 rpcs
87
+ * carry one, but they are Go-style code comments ("GetRole retrieves the
88
+ * specific role.") and 23 of them are the only string the operation has.
89
+ * grpc-gateway splits those on the first PARAGRAPH rather than the first
90
+ * line, so they need their own contract; extracting them under form 3
91
+ * would report all 159 against a rule none of them were written for.
92
+ *
93
+ * Known gap: comments on individual enum VALUES (`API_KAFKA = 1;`) are not
94
+ * extracted, only the comment on the enum itself. This is generator-specific
95
+ * and only worth adding for `openapiv2`: protoc-gen-openapiv2 surfaces value
96
+ * descriptions, but protoc-gen-connect-openapi verifiably discards them
97
+ * (`ScramMechanism`'s two per-value comments do not appear in its spec), which
98
+ * is why the Admin v2 process doc tells authors to describe the values on the
99
+ * enum itself instead.
100
+ */
101
+
102
+ const CONVENTION = {
103
+ case: 'sentence',
104
+ terminal_period: true,
105
+ verbatim_asciidoc: false
106
+ }
107
+
108
+ /**
109
+ * In-scope roots and the generator each one is built with. Kept here as well
110
+ * as in diff.js's routing table because full-repo mode has no diff to route:
111
+ * `extract` without a file set walks these.
112
+ *
113
+ * console holds the data plane and Console APIs; cloudv2's proto/public tree
114
+ * holds the control plane; streaming-enterprise's proto/redpanda/core tree
115
+ * holds Admin API v2. A repo that has none of them yields no declarations.
116
+ *
117
+ * The Admin v2 roots are exactly the two directories the api-docs bundler
118
+ * feeds into the published spec (see tools/bundle-openapi.js): `admin/v2` for
119
+ * the services, and `common` for the shared messages they reference.
120
+ * `admin/internal` and `core/testing` are excluded by the repo's own buf.yaml
121
+ * and generate nothing; `core/rest` generates a fragment but is never
122
+ * bundled, so none of them reach readers.
123
+ */
124
+ const API_ROOTS = [
125
+ { root: path.join('proto', 'redpanda', 'api'), generator: 'openapiv2' },
126
+ { root: path.join('proto', 'public'), generator: 'openapiv2' },
127
+ { root: path.join('proto', 'redpanda', 'core', 'admin', 'v2'), generator: 'connect-openapi' },
128
+ { root: path.join('proto', 'redpanda', 'core', 'common'), generator: 'connect-openapi' }
129
+ ]
130
+
131
+ /**
132
+ * Which generator builds a given proto file's OpenAPI. Diff mode has no root
133
+ * to hand down - it gets a bare path list - so the generator is recovered
134
+ * from the path. Anything unrecognized falls back to `openapiv2`, whose form
135
+ * is the explicit option block: a file that has none simply yields no
136
+ * operation strings, whereas guessing `connect-openapi` would read every
137
+ * rpc's code comment as a published summary.
138
+ */
139
+ function generatorFor (file) {
140
+ const normalized = String(file).split(path.sep).join('/')
141
+ for (const { root, generator } of API_ROOTS) {
142
+ const prefix = root.split(path.sep).join('/')
143
+ if (normalized === prefix || normalized.startsWith(`${prefix}/`)) return generator
144
+ }
145
+ return 'openapiv2'
146
+ }
147
+
148
+ /** Recursively collect .proto files under a directory. */
149
+ function collectProtoFiles (dir) {
150
+ const out = []
151
+ if (!fs.existsSync(dir)) return out
152
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
153
+ const full = path.join(dir, entry.name)
154
+ if (entry.isDirectory()) out.push(...collectProtoFiles(full))
155
+ else if (entry.name.endsWith('.proto')) out.push(full)
156
+ }
157
+ return out
158
+ }
159
+
160
+ /**
161
+ * Field declarations look like:
162
+ *
163
+ * string name = 1;
164
+ * optional string value = 3;
165
+ * repeated ConfigSynonym config_synonyms = 7;
166
+ * map<string, string> labels = 4;
167
+ * string name = 1 [(buf.validate.field).string.min_len = 1];
168
+ *
169
+ * The type may be qualified (`redpanda.api.common.v1.ErrorStatus`) or a map.
170
+ * Captures the field name and whether an option list follows.
171
+ */
172
+ const FIELD_RE = /^\s*(?:(?:optional|repeated|required)\s+)?(?:map\s*<[^>]*>|[A-Za-z_][A-Za-z0-9_.]*)\s+([a-z_][a-zA-Z0-9_]*)\s*=\s*\d+\s*(\[|;)/
173
+
174
+ /** `message Foo {`, `enum Foo {`. Services carry no schema description. */
175
+ const BLOCK_RE = /^\s*(message|enum)\s+([A-Za-z_][A-Za-z0-9_]*)\s*\{/
176
+
177
+ /**
178
+ * `rpc CreateTopic(Req) returns (Res) {`. The rpc name is what an openapiv2
179
+ * operation string is about, so findings are named after it. Without this the
180
+ * name fell back to the file's basename and every summary in a file reported
181
+ * under the same name, which is useless in a review comment.
182
+ */
183
+ const RPC_RE = /^\s*rpc\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(/
184
+
185
+ /**
186
+ * `extend google.protobuf.MethodOptions {` declares protobuf extension fields
187
+ * (Console's per-RPC `auth` option, for example). Those are wire metadata, not
188
+ * published schema, so the generator never documents them and neither do we.
189
+ */
190
+ const EXTEND_RE = /^\s*extend\s+[A-Za-z_][A-Za-z0-9_.]*\s*\{/
191
+
192
+ /** An openapiv2 option block opening. Captures which one. */
193
+ const OPENAPI_OPTION_RE = /^\s*option\s*\(\s*grpc\.gateway\.protoc_gen_openapiv2\.options\.(openapiv2_[a-z_]+)\s*\)\s*=\s*\{/
194
+
195
+ /**
196
+ * An `openapiv2_field` block opening inside a field's `[...]` option list.
197
+ * Distinct from OPENAPI_OPTION_RE because a field-level option carries no
198
+ * `option` keyword - it is just `(grpc.gateway...openapiv2_field) = {`, which
199
+ * is exactly why these descriptions were invisible to the surface.
200
+ */
201
+ const OPENAPI_FIELD_OPTION_RE = /\(\s*grpc\.gateway\.protoc_gen_openapiv2\.options\.openapiv2_field\s*\)\s*=\s*\{/
202
+
203
+ /** `summary: "..."` / `description: "..."` inside an option block. */
204
+ const OPENAPI_STRING_RE = /^\s*(summary|description)\s*:\s*"((?:[^"\\]|\\.)*)"\s*$/
205
+
206
+ /** A line that is only a quoted string: proto concatenates these like C++. */
207
+ const CONTINUATION_RE = /^\s*"((?:[^"\\]|\\.)*)"\s*$/
208
+
209
+ /** Unescape a proto string literal's body. */
210
+ function unescape (raw) {
211
+ return raw
212
+ .replace(/\\n/g, '\n')
213
+ .replace(/\\t/g, '\t')
214
+ .replace(/\\"/g, '"')
215
+ .replace(/\\\\/g, '\\')
216
+ }
217
+
218
+ /**
219
+ * Scan one line's braces, ignoring anything inside a string literal. Counting
220
+ * them raw made a `{` inside a description ("use `{prefix}`") close the option
221
+ * block early, which dropped every declaration after it in the file.
222
+ *
223
+ * @returns {{delta: number, hasOpen: boolean}} Net depth change, and whether
224
+ * the line opens a block at all. The two differ on `{}`, so they are
225
+ * computed by one scanner rather than two.
226
+ */
227
+ function scanBraces (line) {
228
+ let delta = 0
229
+ let hasOpen = false
230
+ let inString = false
231
+ for (let i = 0; i < line.length; i++) {
232
+ const ch = line[i]
233
+ if (inString) {
234
+ if (ch === '\\') i++
235
+ else if (ch === '"') inString = false
236
+ continue
237
+ }
238
+ if (ch === '"') inString = true
239
+ else if (ch === '{') {
240
+ delta++
241
+ hasOpen = true
242
+ } else if (ch === '}') delta--
243
+ }
244
+ return { delta, hasOpen }
245
+ }
246
+
247
+ /** Net brace depth change for a line, outside string literals. */
248
+ function braceDelta (line) {
249
+ return scanBraces(line).delta
250
+ }
251
+
252
+ /** True when a line has a `{` outside a string literal. */
253
+ function hasOpenBrace (line) {
254
+ return scanBraces(line).hasOpen
255
+ }
256
+
257
+ /**
258
+ * Find the line index where a field statement ends (its terminating `;`),
259
+ * starting from its first line. An option list can wrap over several lines,
260
+ * and the declaration span has to cover all of them so a suggestion block
261
+ * can replace the whole thing.
262
+ */
263
+ function findStatementEnd (lines, start) {
264
+ for (let i = start; i < lines.length; i++) {
265
+ if (/;\s*(?:\/\/.*)?$/.test(lines[i])) return i
266
+ }
267
+ return start
268
+ }
269
+
270
+ /**
271
+ * The `description` an `openapiv2_field` option sets for a field, or null.
272
+ *
273
+ * Scanned over the field's own statement span only. It overrides the field's
274
+ * leading `//` comment, matching the generator: console has 81 fields with
275
+ * both, and `MCPServer.resources` proves the option wins. 87 more are
276
+ * documented ONLY this way, and every one of them used to be reported as an
277
+ * undocumented field.
278
+ *
279
+ * @returns {{text: string, line_start: number, line_end: number}|null}
280
+ * Line numbers are 0-indexed into `lines`.
281
+ */
282
+ function fieldOptionDescription (lines, start, end) {
283
+ let depth = null
284
+ for (let i = start; i <= end && i < lines.length; i++) {
285
+ const line = lines[i]
286
+ if (depth === null) {
287
+ if (OPENAPI_FIELD_OPTION_RE.test(line)) depth = braceDelta(line)
288
+ continue
289
+ }
290
+ const match = OPENAPI_STRING_RE.exec(line)
291
+ if (match && match[1] === 'description') {
292
+ let text = unescape(match[2])
293
+ let last = i
294
+ while (last + 1 <= end && CONTINUATION_RE.test(lines[last + 1])) {
295
+ last++
296
+ text += unescape(CONTINUATION_RE.exec(lines[last])[1])
297
+ }
298
+ return { text, line_start: i, line_end: last }
299
+ }
300
+ depth += braceDelta(line)
301
+ if (depth <= 0) depth = null
302
+ }
303
+ return null
304
+ }
305
+
306
+ /**
307
+ * The line index where an rpc's signature finishes opening: the line carrying
308
+ * the `{` that opens its body, or the `;` that ends a body-less rpc.
309
+ *
310
+ * Needed because an rpc signature wraps. Admin v2 clang-formats to
311
+ *
312
+ * rpc CreateShadowLink(CreateShadowLinkRequest)
313
+ * returns (CreateShadowLinkResponse) {
314
+ *
315
+ * so the `rpc` line's own brace delta is 0. Testing only that line left the
316
+ * enclosing rpc unrecorded, which is how operation strings ended up named
317
+ * after the file.
318
+ */
319
+ function findRpcHeaderEnd (lines, start) {
320
+ for (let i = start; i < lines.length && i < start + 8; i++) {
321
+ // Tested on the presence of a `{`, not on a positive net delta: an inline
322
+ // empty body (`rpc GetIdentity(A) returns (B) {}`, which console uses)
323
+ // nets to zero, and treating that as "not the end" ran the scan on into
324
+ // the following declarations and dropped them.
325
+ if (hasOpenBrace(lines[i])) return i
326
+ if (/;\s*(?:\/\/.*)?$/.test(lines[i])) return i
327
+ }
328
+ return start
329
+ }
330
+
331
+ /**
332
+ * Split an rpc's leading comment into the summary and description that
333
+ * protoc-gen-connect-openapi derives from it.
334
+ *
335
+ * @param {Array<{line: number, text: string}>} comment - Pending comment lines
336
+ * @returns {{summary: object, description: object|null}|null}
337
+ * Each part is { text, line_start, line_end } with 0-indexed line numbers.
338
+ * `collapsed` on the summary means no blank line separated the two, so the
339
+ * generator uses the whole comment for both.
340
+ */
341
+ function splitRpcComment (comment) {
342
+ // Leading and trailing blank comment lines carry nothing.
343
+ let first = 0
344
+ let last = comment.length - 1
345
+ while (first <= last && comment[first].text.trim() === '') first++
346
+ while (last >= first && comment[last].text.trim() === '') last--
347
+ if (first > last) return null
348
+
349
+ const body = comment.slice(first, last + 1)
350
+ const blank = body.findIndex((c) => c.text.trim() === '')
351
+ const part = (slice) => ({
352
+ text: slice.map((c) => c.text).join('\n').trim(),
353
+ line_start: slice[0].line,
354
+ line_end: slice[slice.length - 1].line
355
+ })
356
+
357
+ if (blank === -1) {
358
+ return { summary: { ...part(body), collapsed: true }, description: null }
359
+ }
360
+ const description = body.slice(blank + 1)
361
+ return {
362
+ summary: part(body.slice(0, blank)),
363
+ description: description.length > 0 ? part(description) : null
364
+ }
365
+ }
366
+
367
+ /**
368
+ * Parse one proto file. Exported for tests.
369
+ *
370
+ * @param {string} content - File content
371
+ * @param {string} file - Repo-relative path
372
+ * @param {string} [generator] - 'openapiv2' | 'connect-openapi'. Decides
373
+ * whether an rpc's leading comment is a published operation string or a
374
+ * code comment; defaults to whatever the path implies.
375
+ * @returns {Array} declarations (without declaration_text)
376
+ */
377
+ function scanFile (content, file, generator = generatorFor(file)) {
378
+ const declarations = []
379
+ const lines = content.split('\n')
380
+
381
+ let comment = [] // pending comment lines: { line (0-indexed), text }
382
+ // Enclosing message/enum names as { name, depth }: depth is the brace depth
383
+ // BEFORE the block opened, so the block pops when depth returns to it. A
384
+ // bare `}` test cannot do this - `oneof {`, an inline `option { ... }` and a
385
+ // wrapped `[...]` option list all move the depth without opening a message,
386
+ // and matching their closing brace popped the enclosing message, which
387
+ // reported sibling messages as nested (Foo.Bar.Baz for three siblings).
388
+ let blocks = []
389
+ let depth = 0
390
+ let extendDepth = null // brace depth an `extend` block opened at, else null
391
+ let rpc = null // { name, depth } for the rpc whose body we are inside
392
+ let option = null // { kind, depth, responsesDepth } while inside an option block
393
+
394
+ const blockNames = () => blocks.map((b) => b.name)
395
+
396
+ for (let i = 0; i < lines.length; i++) {
397
+ const line = lines[i]
398
+ const trimmed = line.trim()
399
+
400
+ // ---- inside an openapiv2 option block ----
401
+ if (option) {
402
+ const before = option.depth
403
+ const delta = braceDelta(line)
404
+ option.depth += delta
405
+ depth += delta
406
+
407
+ // `responses:` opens a sub-block whose descriptions are HTTP status
408
+ // prose. Remember the depth it opened at so the skip ends with it.
409
+ if (/^\s*responses\s*:\s*\{/.test(line)) {
410
+ option.responsesDepth = before
411
+ } else if (option.responsesDepth !== undefined && option.depth <= option.responsesDepth) {
412
+ option.responsesDepth = undefined
413
+ }
414
+
415
+ const strMatch = OPENAPI_STRING_RE.exec(line)
416
+ if (strMatch && option.responsesDepth === undefined) {
417
+ const key = strMatch[1]
418
+ let text = unescape(strMatch[2])
419
+ let end = i
420
+ // Adjacent string literals on following lines concatenate.
421
+ while (end + 1 < lines.length && CONTINUATION_RE.test(lines[end + 1])) {
422
+ end++
423
+ text += unescape(CONTINUATION_RE.exec(lines[end])[1])
424
+ }
425
+ declarations.push({
426
+ surface: 'api',
427
+ name: option.operationId || blockNames()[blocks.length - 1] || path.basename(file, '.proto'),
428
+ file,
429
+ line_start: i + 1,
430
+ line_end: end + 1,
431
+ string: text || null,
432
+ declaration_text: null,
433
+ convention: CONVENTION,
434
+ meta: {
435
+ kind: key === 'summary' ? 'operation-summary' : `${option.kind.replace('openapiv2_', '')}-description`,
436
+ option: option.kind,
437
+ key
438
+ }
439
+ })
440
+ i = end
441
+ }
442
+
443
+ if (option.depth <= 0) option = null
444
+ comment = []
445
+ continue
446
+ }
447
+
448
+ const optMatch = OPENAPI_OPTION_RE.exec(line)
449
+ if (optMatch) {
450
+ option = {
451
+ kind: optMatch[1],
452
+ depth: braceDelta(line),
453
+ operationId: rpc ? rpc.name : blockNames()[blocks.length - 1] || null
454
+ }
455
+ comment = []
456
+ continue
457
+ }
458
+
459
+ // ---- comments accumulate until something consumes them ----
460
+ if (/^\s*\/\//.test(line)) {
461
+ comment.push({ line: i, text: trimmed.replace(/^\/\/\s?/, '') })
462
+ continue
463
+ }
464
+
465
+ // ---- rpc ----
466
+ const rpcMatch = RPC_RE.exec(line)
467
+ if (rpcMatch) {
468
+ const name = rpcMatch[1]
469
+ // Under connect-openapi the rpc's leading comment IS the published
470
+ // operation summary and description. Under openapiv2 it is a code
471
+ // comment that the option block overrides, so it stays unextracted.
472
+ if (generator === 'connect-openapi' && comment.length > 0) {
473
+ const split = splitRpcComment(comment)
474
+ if (split) {
475
+ declarations.push({
476
+ surface: 'api',
477
+ name,
478
+ file,
479
+ line_start: split.summary.line_start + 1,
480
+ line_end: split.summary.line_end + 1,
481
+ string: split.summary.text,
482
+ declaration_text: null,
483
+ convention: CONVENTION,
484
+ meta: {
485
+ kind: 'rpc-summary',
486
+ generator,
487
+ service: blockNames()[blocks.length - 1] || null,
488
+ // No blank line split the comment, so this same text ships as
489
+ // the description too. api-rpc-summary-not-separated owns it.
490
+ collapsed: Boolean(split.summary.collapsed)
491
+ }
492
+ })
493
+ if (split.description) {
494
+ declarations.push({
495
+ surface: 'api',
496
+ name,
497
+ file,
498
+ line_start: split.description.line_start + 1,
499
+ line_end: split.description.line_end + 1,
500
+ string: split.description.text,
501
+ declaration_text: null,
502
+ convention: CONVENTION,
503
+ meta: {
504
+ kind: 'rpc-description',
505
+ generator,
506
+ service: blockNames()[blocks.length - 1] || null
507
+ }
508
+ })
509
+ }
510
+ }
511
+ }
512
+ // A body-less `rpc Foo(A) returns (B);` opens nothing, so only remember
513
+ // the name when the signature actually opens a block. The signature can
514
+ // wrap, so consume it to its `{` or `;` rather than testing one line.
515
+ const headerEnd = findRpcHeaderEnd(lines, i)
516
+ let headerDelta = 0
517
+ for (let j = i; j <= headerEnd; j++) headerDelta += braceDelta(lines[j])
518
+ if (headerDelta > 0) rpc = { name, depth }
519
+ depth += headerDelta
520
+ comment = []
521
+ i = headerEnd
522
+ continue
523
+ }
524
+
525
+ // ---- extend block: wire metadata, not published schema ----
526
+ if (EXTEND_RE.test(line)) {
527
+ extendDepth = depth
528
+ depth += braceDelta(line)
529
+ comment = []
530
+ continue
531
+ }
532
+
533
+ // ---- message / enum ----
534
+ const blockMatch = BLOCK_RE.exec(line)
535
+ if (blockMatch) {
536
+ const [, keyword, name] = blockMatch
537
+ const prose = comment.map((c) => c.text).join('\n').trim()
538
+ if (prose) {
539
+ declarations.push({
540
+ surface: 'api',
541
+ name,
542
+ file,
543
+ line_start: comment[0].line + 1,
544
+ line_end: i + 1,
545
+ string: prose,
546
+ declaration_text: null,
547
+ convention: CONVENTION,
548
+ meta: { kind: keyword === 'enum' ? 'enum' : 'message', block: blockNames().join('.') || null }
549
+ })
550
+ }
551
+ blocks.push({ name, depth })
552
+ depth += braceDelta(line)
553
+ comment = []
554
+ continue
555
+ }
556
+
557
+ // ---- field ----
558
+ const fieldMatch = FIELD_RE.exec(line)
559
+ if (fieldMatch && extendDepth === null) {
560
+ const name = fieldMatch[1]
561
+ const prose = comment.map((c) => c.text).join('\n').trim()
562
+ const end = fieldMatch[2] === '[' ? findStatementEnd(lines, i) : i
563
+ // An openapiv2_field description wins over the comment, so lint the
564
+ // string that actually ships.
565
+ const option = fieldMatch[2] === '[' ? fieldOptionDescription(lines, i, end) : null
566
+ declarations.push({
567
+ surface: 'api',
568
+ name,
569
+ file,
570
+ line_start: (comment.length > 0 ? comment[0].line : i) + 1,
571
+ line_end: end + 1,
572
+ string: (option ? option.text : prose) || null,
573
+ declaration_text: null,
574
+ convention: CONVENTION,
575
+ meta: {
576
+ kind: 'field',
577
+ message: blockNames()[blocks.length - 1] || null,
578
+ path: blocks.length > 0 ? `${blockNames().join('.')}.${name}` : name,
579
+ // Which form supplied the string, so a suggestion edits the right
580
+ // one. 'openapiv2_field' also means a leading comment, if any, is
581
+ // dead text that never reaches readers.
582
+ source: option ? 'openapiv2_field' : (prose ? 'comment' : null)
583
+ }
584
+ })
585
+ comment = []
586
+ for (let j = i; j <= end; j++) depth += braceDelta(lines[j])
587
+ while (blocks.length > 0 && depth <= blocks[blocks.length - 1].depth) blocks.pop()
588
+ i = end
589
+ continue
590
+ }
591
+
592
+ // ---- every other line: track depth, close any block it ends ----
593
+ depth += braceDelta(line)
594
+ while (blocks.length > 0 && depth <= blocks[blocks.length - 1].depth) blocks.pop()
595
+ if (extendDepth !== null && depth <= extendDepth) extendDepth = null
596
+ if (rpc !== null && depth <= rpc.depth) rpc = null
597
+
598
+ if (trimmed !== '') comment = []
599
+ }
600
+
601
+ return declarations
602
+ }
603
+
604
+ /**
605
+ * Extract API doc-string declarations.
606
+ *
607
+ * @param {Object} options - { repo, files (Set of repo-relative paths in diff
608
+ * mode; when omitted, walks API_ROOTS) }
609
+ */
610
+ function extract ({ repo, files = null }) {
611
+ let fileList
612
+ if (files) {
613
+ fileList = [...files].filter((f) => f.endsWith('.proto'))
614
+ } else {
615
+ fileList = []
616
+ for (const { root } of API_ROOTS) {
617
+ fileList.push(
618
+ ...collectProtoFiles(path.join(repo, root)).map((f) => path.relative(repo, f))
619
+ )
620
+ }
621
+ }
622
+
623
+ const cache = new SourceCache(repo)
624
+ const declarations = []
625
+ for (const file of fileList) {
626
+ const absPath = path.isAbsolute(file) ? file : path.join(repo, file)
627
+ if (!fs.existsSync(absPath)) continue
628
+ const content = fs.readFileSync(absPath, 'utf8')
629
+ for (const decl of scanFile(content, file, generatorFor(file))) {
630
+ decl.declaration_text = cache.span(file, decl.line_start, decl.line_end)
631
+ declarations.push(decl)
632
+ }
633
+ }
634
+ return declarations
635
+ }
636
+
637
+ /**
638
+ * Kinds whose string is a one-line LABEL rather than prose: the openapiv2
639
+ * `summary` and, under connect-openapi, the first line of an rpc's comment.
640
+ *
641
+ * Every other kind is prose. Inverted deliberately: the previous allow-list of
642
+ * prose kinds silently exempted any kind not on it from the prose rules, so a
643
+ * newly extracted `*-description` would ship unchecked.
644
+ */
645
+ const SUMMARY_KINDS = Object.freeze(['operation-summary', 'rpc-summary'])
646
+
647
+ /** True when a declaration's string is prose, not a label. */
648
+ function isProse (decl) {
649
+ return !SUMMARY_KINDS.includes(decl.meta?.kind)
650
+ }
651
+
652
+ /** True when a declaration's string is a one-line operation label. */
653
+ function isSummary (decl) {
654
+ return SUMMARY_KINDS.includes(decl.meta?.kind)
655
+ }
656
+
657
+ /** Surface-specific convention rules. */
658
+ const RULES = [
659
+ {
660
+ name: 'api-rpc-summary-not-separated',
661
+ description: 'rpc comment has no blank line, so the whole comment becomes the summary',
662
+ severity: 'error',
663
+ check: (decl) => {
664
+ if (decl.meta?.kind !== 'rpc-summary' || !decl.meta.collapsed) return []
665
+ const text = (decl.string || '').replace(/\s+/g, ' ').trim()
666
+ return [{
667
+ message: `protoc-gen-connect-openapi takes the FIRST line of an rpc comment as the operation summary and everything after the first blank line as the description. This comment has no blank line, so all ${text.length} characters ship as both - the API reference's nav item for \`${decl.name}\` becomes a paragraph. Put a short label on the first line (the convention is the rpc's own name, \`${decl.name}\`), then \`//\` on its own, then the prose.`
668
+ }]
669
+ }
670
+ },
671
+ {
672
+ name: 'api-summary-multiline',
673
+ description: 'An operation summary must be a single line',
674
+ severity: 'error',
675
+ check: (decl) => {
676
+ // api-rpc-summary-not-separated owns the collapsed case and gives the
677
+ // actionable fix; reporting both just doubles up on one defect.
678
+ if (!isSummary(decl) || decl.meta?.collapsed) return []
679
+ if (/\n/.test(decl.string || '')) {
680
+ return [{ message: 'The operation `summary` is the label in the API reference index. Keep it on one line and move detail to the description.' }]
681
+ }
682
+ return []
683
+ }
684
+ },
685
+ {
686
+ name: 'api-summary-terminal-period',
687
+ description: 'An operation summary takes no terminal period',
688
+ severity: 'error',
689
+ check: (decl) => {
690
+ if (!isSummary(decl) || decl.meta?.collapsed) return []
691
+ if (/\.\s*$/.test(decl.string || '')) {
692
+ return [{ message: 'The operation `summary` is a label, not a sentence. Drop the terminal period (same contract as an rpk `Short`).' }]
693
+ }
694
+ return []
695
+ }
696
+ },
697
+ {
698
+ name: 'api-name-echo',
699
+ description: 'Description merely restates the declared name (tautology)',
700
+ severity: 'warning',
701
+ check: (decl) => {
702
+ // Replaces the generic name-echo, which cannot be scoped per kind. An
703
+ // rpc summary is SUPPOSED to be the rpc's name - that is the documented
704
+ // convention and 28 of admin/v2's 29 rpcs follow it - so flagging it
705
+ // would put a warning on every operation in the surface.
706
+ if (decl.meta?.kind === 'rpc-summary') return []
707
+ const text = (decl.string || '').trim()
708
+ if (!text || !decl.name) return []
709
+ if (isNameEcho(decl.name, text)) {
710
+ return [{ message: `Description just restates the name "${decl.name}": "${text}". Say what it does, why, or what happens when it changes.` }]
711
+ }
712
+ return []
713
+ }
714
+ },
715
+ {
716
+ name: 'api-missing-terminal-period',
717
+ description: 'Prose doc string with no terminal period',
718
+ severity: 'warning',
719
+ check: (decl) => {
720
+ if (!isProse(decl)) return []
721
+ const text = (decl.string || '').trim()
722
+ if (!text) return []
723
+ if (/[.!?)]$/.test(text) || /`$/.test(text)) return []
724
+ // A string whose last line is a list item, or ends in a bare URL, is
725
+ // not a sentence missing punctuation. Enum descriptions in particular
726
+ // are written as bulleted value lists, which is what the Admin v2
727
+ // process doc tells authors to do because the generator discards
728
+ // per-value comments.
729
+ const lastLine = text.split('\n').pop().trim()
730
+ if (/^(?:[-*+]|\d+[.)])\s/.test(lastLine)) return []
731
+ if (/\b[a-z][a-z0-9+.-]*:\/\/\S+$/i.test(lastLine)) return []
732
+ return [{ message: `Prose doc strings end in a full stop; this one does not: "${text.slice(-60)}"` }]
733
+ }
734
+ },
735
+ {
736
+ name: 'api-undocumented-field',
737
+ description: 'Published field or message with no doc string',
738
+ severity: 'warning',
739
+ check: (decl) => {
740
+ if (decl.meta?.kind !== 'field') return []
741
+ if (decl.string && decl.string.trim() !== '') return []
742
+ return [{ message: `\`${decl.name}\` has no comment and no \`openapiv2_field\` description, so it ships with a blank description in the API reference.` }]
743
+ }
744
+ },
745
+ {
746
+ name: 'api-description-too-short',
747
+ description: 'Prose doc string too short to explain anything',
748
+ severity: 'warning',
749
+ check: (decl) => {
750
+ // Scoped to prose kinds because an operation `summary` is a label and
751
+ // is short by design: 151 of console's 216 summaries are under the
752
+ // generic too-short threshold, and every one of them is correct.
753
+ if (!isProse(decl)) return []
754
+ const text = (decl.string || '').trim()
755
+ if (!text) return [] // api-undocumented-field owns the empty case
756
+ if (text.length < 20) {
757
+ return [{ message: `Description is only ${text.length} characters: "${text}". Too short to explain behavior, units, or impact.` }]
758
+ }
759
+ return []
760
+ }
761
+ }
762
+ ]
763
+
764
+ module.exports = {
765
+ name: 'api',
766
+ convention: CONVENTION,
767
+ extract,
768
+ scanFile,
769
+ rules: RULES,
770
+ API_ROOTS,
771
+ SUMMARY_KINDS,
772
+ generatorFor,
773
+ splitRpcComment,
774
+ // All three generic rules are replaced by kind-scoped equivalents above,
775
+ // because this surface mixes prose with labels and a wholesale rule cannot
776
+ // tell them apart:
777
+ // * empty-description is an ERROR, and 823 of console's 1736 fields carry
778
+ // no comment, so it would fail on nearly every proto PR. api-undocumented-field
779
+ // reports the same thing as a warning, which matches the suggest-only posture.
780
+ // * too-short fires on 70% of openapiv2 summaries, all correctly short.
781
+ // api-description-too-short applies it to prose kinds only.
782
+ // * name-echo fires on every connect-openapi rpc summary, where echoing
783
+ // the rpc name IS the convention. api-name-echo exempts that one kind
784
+ // and is otherwise identical.
785
+ skipRules: ['empty-description', 'too-short', 'name-echo']
786
+ }