@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.
- package/bin/doc-tools-mcp.js +1 -1
- package/bin/doc-tools.js +3 -1
- package/bin/mcp-tools/lint-doc-strings.js +2 -1
- package/package.json +1 -1
- package/tools/lint-strings/diff.js +15 -1
- package/tools/lint-strings/fixtures/api/lint_admin_v2.proto +114 -0
- package/tools/lint-strings/fixtures/api/lint_api.proto +123 -0
- package/tools/lint-strings/fixtures/connect/lint_connect.go +1 -1
- package/tools/lint-strings/fixtures/crd/lint_types.go +70 -0
- package/tools/lint-strings/fixtures/helm/lint_values.yaml +16 -3
- package/tools/lint-strings/index.js +2 -1
- package/tools/lint-strings/rules/common.js +87 -1
- package/tools/lint-strings/surfaces/api.js +786 -0
- package/tools/lint-strings/surfaces/crd.js +204 -7
- package/tools/lint-strings/surfaces/helm.js +338 -12
|
@@ -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
|
+
}
|