@redpanda-data/docs-extensions-and-macros 5.51.1 → 5.52.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.
Files changed (33) hide show
  1. package/bin/doc-tools-mcp.js +4 -0
  2. package/bin/doc-tools.js +1 -0
  3. package/bin/mcp-tools/rpcn-docs.js +1 -0
  4. package/extensions/REFERENCE.adoc +19 -4
  5. package/extensions/generate-rp-connect-categories.js +76 -25
  6. package/extensions/generate-rp-connect-info.js +150 -40
  7. package/extensions/modify-connect-tag-playbook.js +183 -17
  8. package/extensions/util/connect-catalog.js +153 -0
  9. package/macros/rp-connect-components.js +23 -8
  10. package/package.json +1 -1
  11. package/tools/lint-strings/diff.js +4 -1
  12. package/tools/lint-strings/engine.js +5 -0
  13. package/tools/lint-strings/fixtures/connect/lint_connect.go +1 -1
  14. package/tools/lint-strings/fixtures/connect/repo/go.mod.txt +5 -0
  15. package/tools/lint-strings/fixtures/connect/repo/internal/cli/lint.go +19 -0
  16. package/tools/lint-strings/fixtures/connect/repo/internal/impl/azure/common.go +20 -0
  17. package/tools/lint-strings/fixtures/connect/repo/internal/impl/azure/docs.go +8 -0
  18. package/tools/lint-strings/fixtures/connect/repo/internal/impl/azure/input_thing.go +32 -0
  19. package/tools/lint-strings/fixtures/connect/repo/internal/impl/azure/output_thing.go +12 -0
  20. package/tools/lint-strings/fixtures/connect/repo/internal/impl/widget/bloblang.go +21 -0
  21. package/tools/lint-strings/fixtures/connect/repo/internal/impl/widget/scanner.go +12 -0
  22. package/tools/lint-strings/fixtures/connect/repo/internal/plugins/info.csv +3 -0
  23. package/tools/lint-strings/fixtures/connect/repo/internal/retries/retries.go +13 -0
  24. package/tools/lint-strings/go-index.js +1143 -0
  25. package/tools/lint-strings/index.js +28 -2
  26. package/tools/lint-strings/rules/common.js +10 -2
  27. package/tools/lint-strings/surfaces/connect.js +1484 -137
  28. package/tools/lint-strings/surfaces/rpk.js +1 -0
  29. package/tools/redpanda-connect/generate-rpcn-connector-docs.js +23 -13
  30. package/tools/redpanda-connect/report-delta.js +1 -0
  31. package/tools/redpanda-connect/rpcn-connector-docs-handler.js +19 -13
  32. package/tools/redpanda-connect/templates/connector.hbs +20 -2
  33. package/tools/rpk-docs/helpers/index.js +33 -36
@@ -2,34 +2,55 @@
2
2
 
3
3
  const fs = require('fs')
4
4
  const path = require('path')
5
+ const Papa = require('papaparse')
5
6
 
6
7
  const { SourceCache } = require('../source-text')
7
- const {
8
- maskComments,
9
- findBalancedClose,
10
- splitTopLevelArgs,
11
- collectStringConsts,
12
- evalStringExpr,
13
- lineOf,
14
- collectGoFiles
15
- } = require('../go-source')
8
+ const { splitTopLevelArgs, findBalancedClose, collectGoFiles } = require('../go-source')
9
+ const { GoIndex, statementEnd, straightLineReturn, stripStrings, renderResult, isResolved, unresolvedParts } = require('../go-index')
10
+ const { parseLiteralFields } = require('./rpk')
16
11
 
17
12
  /**
18
- * Connect surface: service.NewConfigSpec() Summary/Description strings and
19
- * service.NewXField(name).Description(...) chains in internal/impl.
13
+ * Connect surface: every published string a Redpanda Connect build defines.
20
14
  *
21
- * These strings are AsciiDoc page bodies (connect docs are generated from
22
- * them directly), so AsciiDoc constructs are LEGITIMATE here: `==` headings,
23
- * links, code fences, and the `raw + "`literal`" + raw` backtick
24
- * concatenation idiom all appear in healthy descriptions. The scanner
25
- * evaluates Go string concatenation (raw strings, interpreted strings, and
26
- * same-file constants) so those idioms resolve to the exact shipped text.
27
- * Descriptions built from calls or cross-file values are skipped silently -
28
- * suggest-only posture means silence beats guessing.
15
+ * - Component specs (service.ConfigSpec): Summary, Description, Footnotes,
16
+ * and Example titles and summaries.
17
+ * - Config fields (service.ConfigField): Description, ShortDescription, and
18
+ * the option descriptions of annotated enum fields.
19
+ * - Bloblang plugins (bloblang.PluginSpec): Description, Example summaries,
20
+ * and parameter descriptions.
21
+ * - `rpk connect` help (urfave/cli): command Usage, UsageText, ArgsUsage and
22
+ * Description, and flag Usage.
29
23
  *
30
- * Because a connect description IS the page body (not a table cell), the
31
- * verbatim raw-pipe rule is skipped: a bare `|` is valid AsciiDoc table
32
- * syntax in this position.
24
+ * Calls are found wherever they are: in helper packages outside
25
+ * internal/impl (httpclient, retries, ...), on specs built by helper
26
+ * constructors (`azureComponentSpec().Summary(...)`), and on variables
27
+ * updated by reassignment (`spec = spec.Description(...)`). The receiver of
28
+ * each call is traced back to its constructor, through local variables,
29
+ * parameters, package variables and helper return types, so a
30
+ * `.Description()` on something that is not a benthos spec is never taken
31
+ * for one.
32
+ *
33
+ * Strings are evaluated across files and packages with GoIndex: constants
34
+ * from another file or package, helper functions that return a string,
35
+ * fmt.Sprintf, strings.Join and local variables all resolve to the exact
36
+ * shipped text. A part that cannot be evaluated is never guessed at: the
37
+ * declaration is still reported, with a numbered `{{unresolved:N}}`
38
+ * placeholder for each gap, meta.unverifiable set (so mechanical rules,
39
+ * which would judge partial text, skip it) and meta.unresolved naming the
40
+ * source of each gap. Calls whose receiver cannot be traced are reported in
41
+ * the extraction's `skipped` list. Nothing is dropped silently.
42
+ *
43
+ * Component declarations carry their registrations (type and name) and the
44
+ * matching internal/plugins/info.csv row (support level, Cloud and Cloud
45
+ * GPU availability), so a review can catch self-managed-only wording in a
46
+ * component that runs in Cloud.
47
+ *
48
+ * Spec strings are AsciiDoc page bodies (cmd/tools/docs_gen writes them into
49
+ * partials verbatim), so AsciiDoc constructs are legitimate: `==` headings,
50
+ * links, tables, and the `raw` + "`literal`" + `raw` backtick idiom. The
51
+ * verbatim raw-pipe rule is skipped because a bare `|` is table syntax in
52
+ * that position. CLI help is not AsciiDoc: rpk-docs formats it like cobra
53
+ * help, so CLI declarations skip the verbatim rules.
33
54
  */
34
55
 
35
56
  const CONVENTION = {
@@ -38,173 +59,1416 @@ const CONVENTION = {
38
59
  verbatim_asciidoc: true
39
60
  }
40
61
 
62
+ const CLI_CONVENTION = {
63
+ case: 'sentence',
64
+ terminal_period: false,
65
+ verbatim_asciidoc: false,
66
+ transformer: 'formatDescription',
67
+ auto_inline_code: {
68
+ kinds: ['flag'],
69
+ path_prefixes: ['/etc/', '/var/', '/usr/', '/home/', '/tmp/', '~/.']
70
+ }
71
+ }
72
+
41
73
  // Field constructors that ship with NO built-in documentation: a chain on
42
74
  // one of these without .Description() publishes an empty field description.
43
75
  // Composite helpers (NewTLSToggledField, NewAutoRetryNacksToggleField,
44
76
  // NewBatchPolicyField, ...) carry their own docs and are exempt.
45
77
  const BARE_FIELD_CTORS = new Set([
46
- 'NewStringField', 'NewStringListField', 'NewStringMapField',
78
+ 'NewStringField', 'NewStringListField', 'NewStringMapField', 'NewStringListOfListsField',
47
79
  'NewStringEnumField', 'NewStringAnnotatedEnumField',
48
80
  'NewIntField', 'NewIntListField', 'NewIntMapField',
49
- 'NewFloatField', 'NewBoolField', 'NewDurationField',
81
+ 'NewFloatField', 'NewFloatListField', 'NewFloatMapField', 'NewBoolField', 'NewDurationField',
50
82
  'NewInterpolatedStringField', 'NewInterpolatedStringListField',
51
83
  'NewInterpolatedStringMapField', 'NewInterpolatedStringEnumField',
52
84
  'NewBloblangField', 'NewAnyField', 'NewAnyListField', 'NewAnyMapField',
53
- 'NewObjectField', 'NewObjectListField', 'NewURLField'
85
+ 'NewObjectField', 'NewObjectListField', 'NewObjectMapField', 'NewURLField', 'NewURLListField'
54
86
  ])
55
87
 
56
- const FIELD_CTOR = /\bNew([A-Z][A-Za-z0-9]*)Field\s*\(/g
57
- const CONFIG_SPEC = /\bNewConfigSpec\s*\(\s*\)/g
58
- const REGISTER_NAME = /\b(?:Must)?Register\w*\(\s*(?:"([^"]+)"|`([^`]+)`)\s*,/
88
+ const SERVICE_PKG = 'github.com/redpanda-data/benthos/v4/public/service'
89
+ const BLOBLANG_PKG = 'github.com/redpanda-data/benthos/v4/public/bloblang'
90
+ const CLI_PKG = /^github\.com\/urfave\/cli(?:\/v\d+)?$/
91
+
92
+ // Repos whose urfave/cli commands are `rpk connect` help.
93
+ const CLI_MODULES = /^github\.com\/redpanda-data\/(?:connect|benthos)(?:\/v\d+)?$/
94
+
95
+ // Directories scanned in a whole-repo run. Helper packages outside
96
+ // internal/impl (httpclient, retries, ...) and the CLI live under these.
97
+ const SCAN_ROOTS = ['internal', 'public', 'cmd']
98
+
99
+ const DOC_METHODS = /\.\s*(Summary|Description|ShortDescription|Footnotes|Example|ExampleNotTested)\s*\(/g
100
+
101
+ const REGISTRATION = /\b(Must)?Register(Batch)?(Input|Output|Processor|Cache|RateLimit|Buffer|MetricsExporter|OtelTracerProvider|Scanner)(?:Creator)?\s*\(/g
102
+ const BLOBLANG_REGISTRATION = /\b(Must)?Register(Method|Function)V2\s*\(/g
103
+
104
+ const REGISTRATION_TYPES = {
105
+ Input: 'input',
106
+ Output: 'output',
107
+ Processor: 'processor',
108
+ Cache: 'cache',
109
+ RateLimit: 'rate_limit',
110
+ Buffer: 'buffer',
111
+ MetricsExporter: 'metric',
112
+ OtelTracerProvider: 'tracer',
113
+ Scanner: 'scanner',
114
+ Method: 'bloblang-method',
115
+ Function: 'bloblang-function'
116
+ }
117
+
118
+ const MAX_COMPONENTS = 10
119
+ const MAX_TRACE_DEPTH = 40
120
+
121
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
59
122
 
60
123
  /**
61
- * Follow a builder chain starting right after `startIndex` (the index just
62
- * past a closing paren): repeated `.Method(args)`. Returns
63
- * [{ method, args, openIndex, closeIndex }] and the chain end offset.
124
+ * Read internal/plugins/info.csv: type/name -> support and availability.
125
+ * Columns are matched by header name, so a reordered or extended CSV still
126
+ * reads correctly.
64
127
  */
65
- function readChain (masked, startIndex) {
66
- const calls = []
67
- let i = startIndex
68
- for (;;) {
69
- const rest = masked.slice(i)
70
- const m = rest.match(/^\s*\.\s*([A-Za-z0-9_]+)\s*\(/)
71
- if (!m) break
72
- const openIndex = i + m[0].length - 1
73
- const closeIndex = findBalancedClose(masked, openIndex)
74
- if (closeIndex === -1) break
75
- calls.push({
76
- method: m[1],
77
- args: masked.slice(openIndex + 1, closeIndex),
78
- openIndex,
79
- closeIndex
128
+ function readInfoCsv (repo) {
129
+ const rows = new Map()
130
+ let text
131
+ try {
132
+ text = fs.readFileSync(path.join(repo, 'internal', 'plugins', 'info.csv'), 'utf8')
133
+ } catch {
134
+ return rows
135
+ }
136
+ // Parse as CSV, so a quoted comma in a commercial name can't shift the
137
+ // support and Cloud columns.
138
+ const { data } = Papa.parse(text, { skipEmptyLines: 'greedy' })
139
+ if (data.length === 0) return rows
140
+ const header = data[0].map((h) => h.trim())
141
+ for (const record of data.slice(1)) {
142
+ const cells = record.map((c) => c.trim())
143
+ const get = (name) => (header.indexOf(name) === -1 ? '' : cells[header.indexOf(name)] || '')
144
+ const name = get('name')
145
+ const type = get('type')
146
+ if (!name || !type) continue
147
+ rows.set(`${type}/${name}`, {
148
+ support: get('support') || null,
149
+ cloud: get('cloud') === 'y',
150
+ cloud_ai: get('cloud_with_gpu') === 'y',
151
+ deprecated: get('deprecated') === 'y',
152
+ commercial_name: get('commercial_name') || null,
153
+ cloud_unsupported_reason: get('cloud_unsupported_reason') || null
80
154
  })
81
- i = closeIndex + 1
82
155
  }
83
- return { calls, end: i }
156
+ return rows
157
+ }
158
+
159
+ /** The package name an unaliased import is known by. */
160
+ function importName (importPath) {
161
+ return importPath.split('/').filter((s) => !/^v\d+$/.test(s)).pop()
162
+ }
163
+
164
+ /** Import aliases in `file` that name a package whose path passes `test`. */
165
+ function aliasesFor (file, test) {
166
+ const out = new Set()
167
+ for (const [key, value] of file.imports) {
168
+ if (key === null) continue
169
+ const matches = typeof test === 'string' ? value === test : test.test(value)
170
+ if (!matches) continue
171
+ if (key.startsWith('\0')) out.add(importName(value))
172
+ else if (key !== '_' && key !== '.') out.add(key)
173
+ }
174
+ return out
175
+ }
176
+
177
+ /** The top-level function whose body contains `offset`, or null. */
178
+ function enclosingFunc (file, offset) {
179
+ for (const fn of file.funcs) {
180
+ if (offset > fn.bodyStart && offset < fn.bodyEnd) return { ...fn, file }
181
+ }
182
+ return null
183
+ }
184
+
185
+ /** The top-level declaration (func or var) whose text contains `offset`. */
186
+ function enclosingUnit (file, offset) {
187
+ for (const fn of file.funcs) {
188
+ if (offset >= fn.start && offset <= fn.bodyEnd) return { kind: 'func', name: fn.name }
189
+ }
190
+ for (const value of file.values) {
191
+ if (offset >= value.start && offset <= value.end) return { kind: value.kind, name: value.name }
192
+ }
193
+ return null
194
+ }
195
+
196
+ function enclosingFuncKey (file, offset) {
197
+ const fn = enclosingFunc(file, offset)
198
+ return fn ? `${file.abs}:${fn.start}` : `${file.abs}:top`
84
199
  }
85
200
 
201
+ const isSpace = (ch) => ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r'
202
+ const isWord = (ch) => ch !== undefined && /\w/.test(ch)
203
+
86
204
  /**
87
- * Scan one file's content for connect declarations. Exported for tests.
205
+ * Walk a builder chain backwards from just after its last character (the
206
+ * index of the `.` before a method name, or the end of an expression) to the
207
+ * expression that starts it. Returns the head:
208
+ * { type: 'call', qual, name, open, close, start } - `qual.name(...)` or `name(...)`
209
+ * { type: 'ident', name, start } - a variable
210
+ * { type: 'unknown', start } - anything else
211
+ * plus `chain`, the methods between the head and that position.
88
212
  */
89
- function scanFile (content, file) {
90
- const declarations = []
91
- const masked = maskComments(content)
92
- const consts = collectStringConsts(masked)
93
-
94
- // Component name: the registration literal, when the file has exactly one.
95
- const registerMatches = [...masked.matchAll(new RegExp(REGISTER_NAME.source, 'g'))]
96
- const componentName = registerMatches.length === 1
97
- ? (registerMatches[0][1] || registerMatches[0][2])
98
- : null
99
-
100
- // ConfigSpec chains -> Summary / Description declarations.
101
- CONFIG_SPEC.lastIndex = 0
102
- let match
103
- while ((match = CONFIG_SPEC.exec(masked)) !== null) {
104
- const { calls } = readChain(masked, match.index + match[0].length)
105
- for (const call of calls) {
106
- if (call.method !== 'Summary' && call.method !== 'Description') continue
107
- const result = evalStringExpr(call.args, consts)
108
- if (!result.verifiable) continue // built dynamically; skip silently
109
- declarations.push({
110
- surface: 'connect',
111
- name: componentName,
112
- file,
113
- line_start: lineOf(masked, call.openIndex),
114
- line_end: lineOf(masked, call.closeIndex),
115
- string: result.value.trim() === '' ? null : result.value.trim(),
116
- declaration_text: null,
117
- convention: CONVENTION,
118
- meta: { kind: call.method.toLowerCase() }
213
+ function chainHead (masked, pairs, dotIndex) {
214
+ let i = dotIndex - 1
215
+ const chain = []
216
+ for (;;) {
217
+ while (i >= 0 && isSpace(masked[i])) i--
218
+ if (masked[i] === ')') {
219
+ const open = pairs.get(i)
220
+ if (open === undefined) return { type: 'unknown', chain, start: i }
221
+ let j = open - 1
222
+ while (j >= 0 && isSpace(masked[j])) j--
223
+ let k = j
224
+ while (k >= 0 && isWord(masked[k])) k--
225
+ const name = masked.slice(k + 1, j + 1)
226
+ if (!name || /^\d/.test(name)) return { type: 'unknown', chain, start: open }
227
+ let d = k
228
+ while (d >= 0 && isSpace(masked[d])) d--
229
+ if (masked[d] !== '.') return { type: 'call', qual: null, name, open, close: i, chain, start: k + 1 }
230
+ let e = d - 1
231
+ while (e >= 0 && isSpace(masked[e])) e--
232
+ if (masked[e] === ')' || masked[e] === ']') {
233
+ chain.unshift({ method: name, open, close: i })
234
+ i = d - 1
235
+ continue
236
+ }
237
+ let f = e
238
+ while (f >= 0 && isWord(masked[f])) f--
239
+ const qual = masked.slice(f + 1, e + 1)
240
+ let g = f
241
+ while (g >= 0 && isSpace(masked[g])) g--
242
+ // a.b.Method(...): a field-selector receiver, not traceable.
243
+ if (!qual || masked[g] === '.') return { type: 'unknown', chain, start: f + 1 }
244
+ return { type: 'call', qual, name, open, close: i, chain, start: f + 1 }
245
+ }
246
+ if (masked[i] === ']') {
247
+ // fields[0].Description(...): an element of a field list.
248
+ const open = pairs.get(i)
249
+ if (open === undefined) return { type: 'unknown', chain, start: i }
250
+ let k = open - 1
251
+ while (k >= 0 && isWord(masked[k])) k--
252
+ const name = masked.slice(k + 1, open)
253
+ let d = k
254
+ while (d >= 0 && isSpace(masked[d])) d--
255
+ if (!name || masked[d] === '.') return { type: 'unknown', chain, start: k + 1 }
256
+ return { type: 'index', name, index: masked.slice(open + 1, i).trim(), chain, start: k + 1 }
257
+ }
258
+ if (isWord(masked[i])) {
259
+ let k = i
260
+ while (k >= 0 && isWord(masked[k])) k--
261
+ const name = masked.slice(k + 1, i + 1)
262
+ let d = k
263
+ while (d >= 0 && isSpace(masked[d])) d--
264
+ if (masked[d] === '.') return { type: 'unknown', chain, start: k + 1 }
265
+ return { type: 'ident', name, chain, start: k + 1 }
266
+ }
267
+ return { type: 'unknown', chain, start: i }
268
+ }
269
+ }
270
+
271
+ /** Classify a Go type or function result list as a spec kind, or null. */
272
+ function kindOfType (typeText) {
273
+ const t = String(typeText || '').replace(/^\(\s*/, '').trim()
274
+ if (/^\[\]\*?(?:\w+\.)?ConfigField\b/.test(t)) return 'field-list'
275
+ if (/^\[\]/.test(t) || /^map\[/.test(t)) return null
276
+ if (/^\*?(?:\w+\.)?ConfigSpec\b/.test(t)) return 'spec'
277
+ if (/^\*?(?:\w+\.)?ConfigField\b/.test(t)) return 'field'
278
+ if (/^\*?(?:\w+\.)?PluginSpec\b/.test(t)) return 'blobl-spec'
279
+ if (/^(?:\w+\.)?ParamDefinition\b/.test(t)) return 'blobl-param'
280
+ // The javascript processor's own builder for the functions it exposes to
281
+ // scripts, published in the processor's footnotes.
282
+ if (/^\*?jsFunctionDefinition\b/.test(t)) return 'js-function'
283
+ return null
284
+ }
285
+
286
+ /** Index of the first top-level `:` in a `key: value` entry, or -1. */
287
+ function topLevelColon (text) {
288
+ let depth = 0
289
+ for (let i = 0; i < text.length; i++) {
290
+ const ch = text[i]
291
+ if (ch === '"' || ch === '`' || ch === "'") {
292
+ const q = ch
293
+ i++
294
+ while (i < text.length && text[i] !== q) i += (q !== '`' && text[i] === '\\') ? 2 : 1
295
+ continue
296
+ }
297
+ if (ch === '(' || ch === '{' || ch === '[') depth++
298
+ else if (ch === ')' || ch === '}' || ch === ']') depth--
299
+ else if (ch === ':' && depth === 0) return i
300
+ }
301
+ return -1
302
+ }
303
+
304
+ /** Forward builder chain from `start`: [{ method, open, close }]. */
305
+ function readChain (masked, pairs, start) {
306
+ const calls = []
307
+ let i = start
308
+ for (;;) {
309
+ const m = masked.slice(i, i + 200).match(/^\s*\.\s*([A-Za-z0-9_]+)\s*\(/)
310
+ if (!m) break
311
+ const open = i + m[0].length - 1
312
+ const close = pairs.get(open)
313
+ if (close === undefined) break
314
+ calls.push({ method: m[1], open, close })
315
+ i = close + 1
316
+ }
317
+ return calls
318
+ }
319
+
320
+ class ConnectScanner {
321
+ constructor (index, { repo }) {
322
+ this.index = index
323
+ this.info = readInfoCsv(repo)
324
+ this.registrations = new Map() // package dir -> registrations
325
+ this.callers = new Map() // package dir -> Map(name -> Set(caller names))
326
+ this.cli = CLI_MODULES.test(index.gomod.module || '')
327
+ }
328
+
329
+ aliases (file) {
330
+ if (!file._aliases) {
331
+ file._aliases = {
332
+ service: aliasesFor(file, SERVICE_PKG),
333
+ bloblang: aliasesFor(file, BLOBLANG_PKG),
334
+ cli: aliasesFor(file, CLI_PKG)
335
+ }
336
+ }
337
+ return file._aliases
338
+ }
339
+
340
+ isImportAlias (file, alias) {
341
+ for (const [key, value] of file.imports) {
342
+ if (key === alias) return true
343
+ if (key && key.startsWith('\0')) {
344
+ const last = importName(value)
345
+ if (last === alias || last.replace(/-/g, '_') === alias) return true
346
+ }
347
+ }
348
+ return false
349
+ }
350
+
351
+ /** Evaluate a string expression found at `offset` in `file`. */
352
+ evalAt (file, exprText, offset) {
353
+ const fn = enclosingFunc(file, offset)
354
+ return this.index.evalString(exprText, {
355
+ file,
356
+ depth: 0,
357
+ resolveLocal: fn ? this.index.localResolver(file, fn, offset) : undefined
358
+ })
359
+ }
360
+
361
+ /**
362
+ * What a chain head constructs: { kind, ctor, name } where kind is
363
+ * spec | field | blobl-spec | blobl-param | null, and name (fields and
364
+ * params) is an evaluated Result.
365
+ */
366
+ classifyHead (file, head, offset, depth = 0) {
367
+ const none = { kind: null }
368
+ if (depth > MAX_TRACE_DEPTH) return none
369
+ const { service, bloblang } = this.aliases(file)
370
+ if (head.type === 'call') {
371
+ const args = () => splitTopLevelArgs(file.masked.slice(head.open + 1, head.close))
372
+ if (head.qual && service.has(head.qual)) {
373
+ if (/^New(?:Struct)?ConfigSpec$/.test(head.name)) return { kind: 'spec', ctor: head.name }
374
+ if (/^New\w*Field$/.test(head.name)) return { kind: 'field', ctor: head.name, ...this.fieldNameOf(file, head, args()) }
375
+ return this.classifyFunc(file, `${head.qual}.${head.name}`, args(), depth)
376
+ }
377
+ if (head.qual && bloblang.has(head.qual)) {
378
+ if (head.name === 'NewPluginSpec') return { kind: 'blobl-spec', ctor: head.name }
379
+ if (/^New\w*Param$/.test(head.name)) {
380
+ const a = args()
381
+ return { kind: 'blobl-param', ctor: head.name, name: a.length ? this.evalAt(file, a[0], head.open) : null }
382
+ }
383
+ return none
384
+ }
385
+ if (head.qual && !this.isImportAlias(file, head.qual)) {
386
+ // `spec.Field(...).Description(...)`: a method on a variable.
387
+ return this.classifyIdent(file, head.qual, head.start, depth + 1)
388
+ }
389
+ return this.classifyFunc(file, head.qual ? `${head.qual}.${head.name}` : head.name, args(), depth)
390
+ }
391
+ if (head.type === 'ident') return this.classifyIdent(file, head.name, head.start, depth)
392
+ if (head.type === 'index') {
393
+ const base = this.classifyIdent(file, head.name, head.start, depth + 1)
394
+ if (base.kind !== 'field-list') return none
395
+ return { kind: 'field', ctor: null, via: `${head.name}[${head.index}]`, name: this.listElementName(file, head, depth) }
396
+ }
397
+ return none
398
+ }
399
+
400
+ /**
401
+ * The name of element N of a field list built by a helper that returns a
402
+ * `[]*service.ConfigField{...}` literal: `fields := FranzConnectionFields()`
403
+ * then `fields[0].Description(...)`.
404
+ */
405
+ listElementName (file, head, depth) {
406
+ if (!/^\d+$/.test(head.index)) return null
407
+ const fn = enclosingFunc(file, head.start)
408
+ const assignment = fn ? this.lastAssignment(file, fn, head.name, head.start) : null
409
+ const call = assignment && assignment.expr && assignment.expr.trim().match(/^([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)?)\s*\(\s*\)$/)
410
+ const helper = call ? this.index.lookupFunc(file, call[1]) : null
411
+ const ret = helper ? straightLineReturn(helper) : null
412
+ const lit = ret && ret.expr.match(/^\s*\[\]\*?(?:\w+\.)?ConfigField\s*\{/)
413
+ if (!lit) return null
414
+ const braceOpen = ret.offset + lit[0].length - 1
415
+ const braceClose = helper.file.pairs.get(braceOpen)
416
+ if (braceClose === undefined) return null
417
+ const element = splitTopLevelArgs(helper.file.masked.slice(braceOpen + 1, braceClose))[Number(head.index)]
418
+ const m = element && element.match(/New\w*Field\s*\(/)
419
+ if (!m) return null
420
+ const open = element.indexOf('(', m.index)
421
+ const close = findBalancedClose(element, open)
422
+ if (close === -1) return null
423
+ const nameArg = splitTopLevelArgs(element.slice(open + 1, close))[0]
424
+ return nameArg ? this.index.evalString(nameArg, { file: helper.file, depth: depth + 1 }) : null
425
+ }
426
+
427
+ /** A call to a helper: classified by its declared result type. */
428
+ classifyFunc (file, callee, args, depth) {
429
+ const fn = this.index.lookupFunc(file, callee)
430
+ if (!fn) return { kind: null }
431
+ const kind = kindOfType(fn.results)
432
+ if (!kind) return { kind: null }
433
+ const out = { kind, ctor: callee, helper: true }
434
+ if (kind === 'field' || kind === 'blobl-param') Object.assign(out, this.helperFieldName(fn, args, file, depth))
435
+ return out
436
+ }
437
+
438
+ /**
439
+ * The field a helper builds: the first benthos field or param
440
+ * constructor in its body, its name argument evaluated with the helper's
441
+ * parameters bound to this call's arguments. Inside benthos itself the
442
+ * constructors are unqualified.
443
+ */
444
+ helperFieldName (fn, args, callerFile, depth) {
445
+ const helperFile = fn.file
446
+ const body = helperFile.masked.slice(fn.bodyStart, fn.bodyEnd)
447
+ const { service, bloblang } = this.aliases(helperFile)
448
+ const quals = [...service, ...bloblang].map(escapeRe)
449
+ const qualified = quals.length > 0 ? `(?:(?:${quals.join('|')})\\s*\\.\\s*)?` : ''
450
+ const m = body.match(new RegExp(`(?:^|[^\\w.])${qualified}(New\\w*(?:Field|Param))\\s*\\(`))
451
+ if (!m) return { name: null }
452
+ const open = fn.bodyStart + m.index + m[0].length - 1
453
+ const close = helperFile.pairs.get(open)
454
+ if (close === undefined) return { name: null }
455
+ const ctorArgs = splitTopLevelArgs(helperFile.masked.slice(open + 1, close))
456
+ if (ctorArgs.length === 0 || ctorArgs[0].trim() === '') return { name: null }
457
+ const locals = new Map()
458
+ fn.params.forEach((param, i) => {
459
+ if (!param.name) return
460
+ const r = i < args.length ? this.index.evalString(args[i], { file: callerFile, depth: depth + 1 }) : null
461
+ locals.set(param.name, r && r.sawString ? r : { parts: [{ unresolved: param.name }], sawString: false })
462
+ })
463
+ const name = this.index.evalString(ctorArgs[0], {
464
+ file: helperFile,
465
+ locals,
466
+ depth: depth + 1,
467
+ resolveLocal: this.index.localResolver(helperFile, fn, open)
468
+ })
469
+ return { name }
470
+ }
471
+
472
+ /** The field name of a benthos field constructor call. */
473
+ fieldNameOf (file, head, args) {
474
+ if (args.length === 0 || args[0].trim() === '') {
475
+ // Nameless composite constructors (NewOutputMaxInFlightField) name
476
+ // their field inside benthos.
477
+ const fn = this.index.lookupFunc(file, `${head.qual}.${head.name}`)
478
+ return fn ? this.helperFieldName(fn, [], file, 1) : { name: null }
479
+ }
480
+ return { name: this.evalAt(file, args[0], head.open), nameExpr: { text: args[0], offset: head.open } }
481
+ }
482
+
483
+ /** A variable, traced to its assignment, parameter type, or package var. */
484
+ classifyIdent (file, ident, offset, depth) {
485
+ if (depth > MAX_TRACE_DEPTH) return { kind: null }
486
+ const fn = enclosingFunc(file, offset)
487
+ if (fn) {
488
+ const assignment = this.lastAssignment(file, fn, ident, offset)
489
+ if (assignment) {
490
+ const kind = assignment.type ? kindOfType(assignment.type) : null
491
+ if (kind) return { kind, via: ident }
492
+ if (assignment.expr) {
493
+ const cls = this.classifyExpr(file, assignment.expr, assignment.exprStart, depth + 1)
494
+ return { ...cls, via: ident }
495
+ }
496
+ }
497
+ const param = fn.params.find((p) => p.name === ident)
498
+ if (param) return { kind: kindOfType(param.type), via: ident }
499
+ if (fn.recv) {
500
+ const m = fn.recv.match(/^(\w+)\s+(.+)$/)
501
+ if (m && m[1] === ident) return { kind: kindOfType(m[2]), via: ident }
502
+ }
503
+ }
504
+ const value = this.index.lookupValue(file, ident)
505
+ if (value) {
506
+ const kind = value.type ? kindOfType(value.type) : null
507
+ if (kind) return { kind, via: ident }
508
+ if (value.expr) return { ...this.classifyExpr(value.file, value.expr, value.exprStart, depth + 1), via: ident }
509
+ }
510
+ return { kind: null }
511
+ }
512
+
513
+ /** Classify an expression by the constructor at the head of its chain. */
514
+ classifyExpr (file, exprText, exprStart, depth) {
515
+ let end = exprStart + exprText.length
516
+ while (end > exprStart && isSpace(file.masked[end - 1])) end--
517
+ const trimmed = exprText.trim().replace(/^&/, '')
518
+ if (/^[A-Za-z_]\w*$/.test(trimmed)) {
519
+ return this.classifyIdent(file, trimmed, exprStart + exprText.indexOf(trimmed), depth + 1)
520
+ }
521
+ if (file.masked[end - 1] !== ')') return { kind: null }
522
+ // The builder methods return their receiver type, so the head of the
523
+ // chain decides what the whole expression is.
524
+ return this.classifyHead(file, chainHead(file.masked, file.pairs, end), exprStart, depth + 1)
525
+ }
526
+
527
+ /**
528
+ * The last assignment to `ident` before offset in fn: `ident := expr`,
529
+ * `ident = expr`, `var ident [T] = expr` or `var ident T`.
530
+ * Returns { type, expr, exprStart } or null.
531
+ */
532
+ lastAssignment (file, fn, ident, offset) {
533
+ return this.assignments(file, fn, ident, offset)[0] || null
534
+ }
535
+
536
+ /**
537
+ * Every assignment to `ident` before offset in fn, latest first. See
538
+ * lastAssignment for the forms. A spec built up by reassignment
539
+ * (`conf = conf.Field(f)` in a loop) has several, and each one is part of
540
+ * the spec.
541
+ */
542
+ assignments (file, fn, ident, offset) {
543
+ const masked = file.masked
544
+ const text = masked.slice(fn.bodyStart, offset)
545
+ const esc = escapeRe(ident)
546
+ const re = new RegExp(`(^|[^\\w.])(?:var\\s+${esc}\\s+([^=\\n;]+?)\\s*(?:(=)(?!=)|[\\n;])|${esc}\\s*(:=|=)(?!=))`, 'g')
547
+ const matches = []
548
+ let m
549
+ while ((m = re.exec(text)) !== null) matches.push(m)
550
+ const out = []
551
+ // Latest first. An assignment whose right-hand side contains offset is
552
+ // the statement being traced (`spec = spec.Example(...)`): the variable
553
+ // there still holds its previous value.
554
+ for (let i = matches.length - 1; i >= 0; i--) {
555
+ const last = matches[i]
556
+ const at = fn.bodyStart + last.index + last[0].length
557
+ if (last[2] && !last[3]) {
558
+ out.push({ type: last[2].trim() })
559
+ break
560
+ }
561
+ const exprEnd = statementEnd(masked, at, fn.bodyEnd)
562
+ if (offset >= at && offset <= exprEnd) continue
563
+ out.push({ type: last[2] ? last[2].trim() : null, expr: masked.slice(at, exprEnd), exprStart: at, declares: Boolean(last[2] || last[4] === ':=') })
564
+ // A declaration starts the variable: nothing earlier is the same one.
565
+ if (last[2] || last[4] === ':=') break
566
+ }
567
+ return out
568
+ }
569
+
570
+ /** Registrations in one package: [{ type, name, refs, spans }]. */
571
+ packageRegistrations (dir) {
572
+ if (this.registrations.has(dir)) return this.registrations.get(dir)
573
+ const regs = []
574
+ for (const file of this.index.package(dir).files) {
575
+ for (const pattern of [REGISTRATION, BLOBLANG_REGISTRATION]) {
576
+ pattern.lastIndex = 0
577
+ let m
578
+ while ((m = pattern.exec(file.masked)) !== null) {
579
+ const open = m.index + m[0].length - 1
580
+ const close = file.pairs.get(open)
581
+ if (close === undefined) continue
582
+ const args = splitTopLevelArgs(file.masked.slice(open + 1, close))
583
+ if (args.length < 2) continue
584
+ const nameResult = this.evalAt(file, args[0], open)
585
+ const reg = {
586
+ type: REGISTRATION_TYPES[pattern === REGISTRATION ? m[3] : m[2]],
587
+ name: isResolved(nameResult) ? renderResult(nameResult) : null,
588
+ refs: new Set(),
589
+ spans: []
590
+ }
591
+ this.collectSpecRefs(file, args[1], open + 1 + args[0].length + 1, reg, 0)
592
+ regs.push(reg)
593
+ }
594
+ }
595
+ }
596
+ this.registrations.set(dir, regs)
597
+ return regs
598
+ }
599
+
600
+ /** What a registration's spec argument refers to. */
601
+ collectSpecRefs (file, exprText, exprStart, reg, depth) {
602
+ if (depth > 3) return
603
+ const trimmed = exprText.trim()
604
+ const lead = exprText.length - exprText.trimStart().length
605
+ if (/^[A-Za-z_]\w*$/.test(trimmed)) {
606
+ const fn = enclosingFunc(file, exprStart)
607
+ const assignments = fn ? this.assignments(file, fn, trimmed, exprStart).filter((a) => a.expr) : []
608
+ if (fn) this.collectMutations(file, fn, trimmed, exprStart, reg)
609
+ if (assignments.length > 0) {
610
+ for (const a of assignments) this.collectSpecRefs(file, a.expr, a.exprStart, reg, depth + 1)
611
+ return
612
+ }
613
+ reg.refs.add(trimmed)
614
+ return
615
+ }
616
+ const call = trimmed.match(/^([A-Za-z_]\w*)\s*\(/)
617
+ if (call) reg.refs.add(call[1])
618
+ reg.spans.push([file.abs, exprStart + lead, exprStart + lead + trimmed.length])
619
+ }
620
+
621
+ /**
622
+ * Builder statements that change a spec in place without reassigning it:
623
+ * `spec.Field(...)` on a line of its own. ConfigSpec methods mutate their
624
+ * receiver, so these are part of the registered spec too.
625
+ */
626
+ collectMutations (file, fn, ident, before, reg) {
627
+ const masked = file.masked
628
+ const re = new RegExp(`(^|[\\n;{]\\s*)${escapeRe(ident)}\\s*\\.\\s*[A-Z]\\w*\\s*\\(`, 'g')
629
+ // String contents blanked, so example YAML in a raw string never
630
+ // reads as a statement.
631
+ const text = stripStrings(masked.slice(fn.bodyStart + 1, before))
632
+ let m
633
+ while ((m = re.exec(text)) !== null) {
634
+ const start = fn.bodyStart + 1 + m.index + m[1].length
635
+ const end = statementEnd(masked, start, fn.bodyEnd)
636
+ reg.spans.push([file.abs, start, end])
637
+ }
638
+ }
639
+
640
+ /**
641
+ * Package-level names a registration's inline spec text mentions, such as
642
+ * the field helper in `conf = conf.Field(accountField("x"))`, so a
643
+ * declaration in that helper reaches the registration.
644
+ */
645
+ addSpanRefs (dir, regs) {
646
+ if (regs._spanRefs) return
647
+ regs._spanRefs = true
648
+ const pkg = this.index.package(dir)
649
+ const names = new Set([...pkg.funcs.keys(), ...pkg.values.keys()])
650
+ for (const reg of regs) {
651
+ for (const [abs, start, end] of reg.spans) {
652
+ const file = this.index.file(abs)
653
+ if (!file) continue
654
+ const text = stripStrings(file.masked.slice(start, end))
655
+ for (const m of text.matchAll(/(?:^|[^\w.])([A-Za-z_]\w*)/g)) if (names.has(m[1])) reg.refs.add(m[1])
656
+ }
657
+ }
658
+ }
659
+
660
+ /** name -> Set of package-level names whose text mentions it. */
661
+ packageCallers (dir) {
662
+ if (this.callers.has(dir)) return this.callers.get(dir)
663
+ const units = []
664
+ for (const file of this.index.package(dir).files) {
665
+ for (const fn of file.funcs) units.push({ name: fn.name, text: file.masked.slice(fn.start, fn.bodyEnd + 1) })
666
+ for (const value of file.values) units.push({ name: value.name, text: file.masked.slice(value.start, value.end) })
667
+ }
668
+ const names = new Set(units.map((u) => u.name))
669
+ const callers = new Map()
670
+ for (const unit of units) {
671
+ for (const m of unit.text.matchAll(/(?:^|[^\w.])([A-Za-z_]\w*)/g)) {
672
+ const name = m[1]
673
+ if (name === unit.name || !names.has(name)) continue
674
+ if (!callers.has(name)) callers.set(name, new Set())
675
+ callers.get(name).add(unit.name)
676
+ }
677
+ }
678
+ this.callers.set(dir, callers)
679
+ return callers
680
+ }
681
+
682
+ /**
683
+ * Directories of the in-module packages that import `importPath`, built
684
+ * once per run from the import blocks of every scanned root.
685
+ */
686
+ importersOf (importPath) {
687
+ if (!this.importers) {
688
+ this.importers = new Map()
689
+ const mod = this.index.gomod.module
690
+ for (const root of SCAN_ROOTS) {
691
+ for (const rel of collectGoFiles(path.join(this.index.repo, root))) {
692
+ const abs = path.join(this.index.repo, root, rel)
693
+ let head
694
+ try {
695
+ head = fs.readFileSync(abs, 'utf8')
696
+ } catch {
697
+ continue
698
+ }
699
+ const end = head.search(/\n(?:func|type|var|const)\b/)
700
+ for (const m of (end === -1 ? head : head.slice(0, end)).matchAll(/"([^"\s]+)"/g)) {
701
+ if (!mod || !m[1].startsWith(`${mod}/`)) continue
702
+ if (!this.importers.has(m[1])) this.importers.set(m[1], new Set())
703
+ this.importers.get(m[1]).add(path.dirname(abs))
704
+ }
705
+ }
706
+ }
707
+ }
708
+ return [...(this.importers.get(importPath) || [])]
709
+ }
710
+
711
+ /**
712
+ * Registrations in other packages that reach an exported helper: a spec
713
+ * built in one package (azure/cosmosdb, httpclient) and registered in
714
+ * another.
715
+ */
716
+ componentsViaImporters (file, unitName, hops) {
717
+ const mod = this.index.gomod.module
718
+ if (!mod || hops > 2 || !/^[A-Z]/.test(unitName)) return []
719
+ const importPath = `${mod}/${this.index.rel(file.dir).split(path.sep).join('/')}`
720
+ const found = []
721
+ for (const dir of this.importersOf(importPath)) {
722
+ for (const other of this.index.package(dir).files) {
723
+ for (const [key, value] of other.imports) {
724
+ if (value !== importPath || key === null) continue
725
+ const alias = key.startsWith('\0') ? this.index.package(file.dir).name || importName(value) : key
726
+ const re = new RegExp(`(?:^|[^\\w.])${escapeRe(alias)}\\s*\\.\\s*${escapeRe(unitName)}\\b`, 'g')
727
+ for (const m of other.masked.matchAll(re)) found.push(...this.componentsAt(other, m.index + 1, hops + 1))
728
+ }
729
+ }
730
+ }
731
+ return found
732
+ }
733
+
734
+ /** Registrations whose spec reaches the code at `offset` in `file`. */
735
+ componentsAt (file, offset, hops = 0) {
736
+ const regs = this.packageRegistrations(file.dir)
737
+ const inline = regs.filter((r) => r.spans.some(([abs, s, e]) => abs === file.abs && offset >= s && offset <= e))
738
+ if (inline.length > 0) return inline
739
+ const unit = enclosingUnit(file, offset)
740
+ if (!unit) return []
741
+ this.addSpanRefs(file.dir, regs)
742
+ const callers = this.packageCallers(file.dir)
743
+ const seen = new Set([unit.name])
744
+ let frontier = [unit.name]
745
+ const found = new Set()
746
+ for (let depth = 0; depth < MAX_TRACE_DEPTH && frontier.length > 0; depth++) {
747
+ for (const reg of regs) if (frontier.some((n) => reg.refs.has(n))) found.add(reg)
748
+ const next = []
749
+ for (const name of frontier) {
750
+ for (const caller of callers.get(name) || []) {
751
+ if (seen.has(caller)) continue
752
+ seen.add(caller)
753
+ next.push(caller)
754
+ }
755
+ }
756
+ frontier = next
757
+ }
758
+ if (found.size === 0) {
759
+ // Nothing in this package registers it: follow the exported names it
760
+ // was reached through into the packages that import them.
761
+ for (const name of seen) {
762
+ for (const reg of this.componentsViaImporters(file, name, hops)) found.add(reg)
763
+ }
764
+ }
765
+ return [...found]
766
+ }
767
+
768
+ /**
769
+ * { components: [{ type, name, ...info.csv row }], components_total }.
770
+ * An offset of -1 asks for the package's single component.
771
+ */
772
+ componentMeta (file, offset) {
773
+ const seen = new Set()
774
+ const components = []
775
+ let regs = offset < 0 ? [] : this.componentsAt(file, offset)
776
+ let via = null
777
+ if (offset < 0) {
778
+ // Text that no spec holds (the javascript functions, which the
779
+ // processor renders at runtime): a package that registers exactly
780
+ // one component documents only that one. Spec text never falls back:
781
+ // a spec nothing registers (timeplus's output) belongs to no page.
782
+ const own = this.packageRegistrations(file.dir)
783
+ if (own.length === 1) {
784
+ regs = own
785
+ via = 'package'
786
+ }
787
+ }
788
+ for (const reg of regs) {
789
+ const key = `${reg.type}/${reg.name}`
790
+ if (seen.has(key)) continue
791
+ seen.add(key)
792
+ const info = reg.name ? this.info.get(key) : null
793
+ components.push({ type: reg.type, name: reg.name, ...(info || {}) })
794
+ }
795
+ components.sort((a, b) => String(a.type).localeCompare(String(b.type)) || String(a.name).localeCompare(String(b.name)))
796
+ const out = { components: components.slice(0, MAX_COMPONENTS), components_total: components.length }
797
+ if (via) out.components_via = via
798
+ return out
799
+ }
800
+
801
+ /** Scan one file. Returns { declarations, skipped }. */
802
+ scan (file, rel) {
803
+ const declarations = []
804
+ const skipped = []
805
+ const { service, bloblang, cli } = this.aliases(file)
806
+ if (service.size > 0 || bloblang.size > 0) this.scanSpecs(file, rel, declarations, skipped)
807
+ if (cli.size > 0 && this.cli) this.scanCli(file, rel, declarations)
808
+ return { declarations, skipped }
809
+ }
810
+
811
+ makeDecl (file, rel, { kind, name, result, start, end, extra = {}, convention = CONVENTION }) {
812
+ const unresolved = result ? unresolvedParts(result) : []
813
+ const text = result ? renderResult(result).trim() : null
814
+ const meta = { kind, ...extra }
815
+ if (unresolved.length > 0) {
816
+ meta.unverifiable = true
817
+ meta.unresolved = unresolved
818
+ }
819
+ return {
820
+ surface: 'connect',
821
+ name: name || null,
822
+ file: rel,
823
+ line_start: file.lineOf(start),
824
+ line_end: file.lineOf(end),
825
+ string: text === '' ? null : text,
826
+ declaration_text: null,
827
+ convention,
828
+ meta
829
+ }
830
+ }
831
+
832
+ /**
833
+ * Push a declaration, expanded per call site when it lives in a helper
834
+ * whose parameters fill its gaps: `regionField(product string)` builds
835
+ * "The AWS region to target for " + product + "." and is called once per
836
+ * component, so each call publishes a different string. Each expansion
837
+ * keeps the helper's lines (that is where a fix goes), and records the
838
+ * call site, the template with its gaps, and the calling component.
839
+ *
840
+ * @param {Object} d - makeDecl options plus valueExpr / nameExpr
841
+ * ({ text, offset }) to re-evaluate with the bound parameters
842
+ */
843
+ pushDecl (declarations, file, rel, d) {
844
+ const fallback = () => declarations.push(this.makeDecl(file, rel, d))
845
+ if (!d.valueExpr) return fallback()
846
+ const bindings = this.callSiteBindings(file, d.valueExpr.offset, [d.result, d.nameResult])
847
+ if (!bindings) return fallback()
848
+ const seen = new Set()
849
+ const template = d.result ? renderResult(d.result).trim() : null
850
+ let pushed = 0
851
+ for (const { locals, site } of bindings) {
852
+ const result = this.evalWith(file, d.valueExpr, locals)
853
+ const nameResult = d.nameExpr ? this.evalWith(file, d.nameExpr, locals) : null
854
+ const name = nameResult && isResolved(nameResult) ? renderResult(nameResult) : d.name
855
+ const key = `${name}\0${renderResult(result)}`
856
+ if (seen.has(key)) continue
857
+ seen.add(key)
858
+ const meta = d.components === false ? {} : this.componentMeta(site.file, site.offset)
859
+ declarations.push(this.makeDecl(file, rel, {
860
+ ...d,
861
+ name,
862
+ result,
863
+ extra: { ...d.extra, ...meta, call_site: `${this.index.rel(site.file.abs)}:${site.file.lineOf(site.offset)}`, template }
864
+ }))
865
+ pushed++
866
+ }
867
+ if (pushed === 0) fallback()
868
+ }
869
+
870
+ /** Evaluate { text, offset } in file with parameters bound to `locals`. */
871
+ evalWith (file, expr, locals) {
872
+ const fn = enclosingFunc(file, expr.offset)
873
+ return this.index.evalString(expr.text, {
874
+ file,
875
+ depth: 0,
876
+ locals,
877
+ resolveLocal: fn ? this.index.localResolver(file, fn, expr.offset) : undefined
878
+ })
879
+ }
880
+
881
+ /**
882
+ * When every gap in `results` is a parameter of the function enclosing
883
+ * `offset`, the parameter bindings at each call of that function.
884
+ * Returns [{ locals, site: { file, offset } }] or null.
885
+ */
886
+ callSiteBindings (file, offset, results) {
887
+ const fn = enclosingFunc(file, offset)
888
+ if (!fn || fn.recv) return null
889
+ const params = new Set(fn.params.map((p) => p.name).filter(Boolean))
890
+ const gaps = results.filter(Boolean).flatMap((r) => unresolvedParts(r))
891
+ if (gaps.length === 0 || !gaps.every((g) => params.has(g.trim().replace(/\.\.\.$/, '')))) return null
892
+ const sites = this.callSites(file, fn)
893
+ if (sites.length === 0) return null
894
+ return sites.map((site) => {
895
+ const locals = new Map()
896
+ fn.params.forEach((param, i) => {
897
+ if (!param.name) return
898
+ if (/^\.\.\./.test(param.type)) {
899
+ // A variadic parameter is the list of the remaining arguments,
900
+ // or the list a trailing `xs...` spreads.
901
+ const rest = site.args.slice(i).filter((a) => a !== '')
902
+ const spread = rest.length === 1 && rest[0].endsWith('...')
903
+ ? this.index.evalStringList(rest[0].slice(0, -3), { file: site.file, depth: 1 })
904
+ : rest.map((a) => this.evalAt(site.file, a, site.offset))
905
+ locals.set(param.name, { parts: [{ unresolved: param.name }], sawString: false, list: spread || [{ parts: [{ unresolved: rest.join(', ') }], sawString: false }] })
906
+ return
907
+ }
908
+ const arg = i < site.args.length ? site.args[i] : null
909
+ const r = arg === null ? null : this.evalAt(site.file, arg, site.offset)
910
+ locals.set(param.name, r && r.sawString ? r : { parts: [{ unresolved: param.name }], sawString: false })
119
911
  })
912
+ return { locals, site }
913
+ })
914
+ }
915
+
916
+ /** Calls of top-level function fn: in its package, and through importers. */
917
+ callSites (file, fn) {
918
+ const key = `${file.abs}:${fn.start}`
919
+ this.siteCache = this.siteCache || new Map()
920
+ if (this.siteCache.has(key)) return this.siteCache.get(key)
921
+ const sites = []
922
+ const scan = (other, prefix) => {
923
+ const re = new RegExp(`(^|[^\\w.])${prefix}${escapeRe(fn.name)}\\s*\\(`, 'g')
924
+ const text = stripStrings(other.masked)
925
+ let m
926
+ while ((m = re.exec(text)) !== null) {
927
+ if (/\bfunc\s*$/.test(text.slice(Math.max(0, m.index - 10), m.index + m[1].length))) continue
928
+ const open = m.index + m[0].length - 1
929
+ const close = other.pairs.get(open)
930
+ if (close === undefined) continue
931
+ sites.push({ file: other, offset: open, args: splitTopLevelArgs(other.masked.slice(open + 1, close)).map((a) => a.trim()) })
932
+ }
933
+ }
934
+ for (const other of this.index.package(file.dir).files) scan(other, '')
935
+ const mod = this.index.gomod.module
936
+ if (mod && /^[A-Z]/.test(fn.name)) {
937
+ const importPath = `${mod}/${this.index.rel(file.dir).split(path.sep).join('/')}`
938
+ for (const dir of this.importersOf(importPath)) {
939
+ for (const other of this.index.package(dir).files) {
940
+ for (const [k, value] of other.imports) {
941
+ if (value !== importPath || k === null || k === '_' || k === '.') continue
942
+ const alias = k.startsWith('\0') ? this.index.package(file.dir).name || importName(value) : k
943
+ scan(other, `${escapeRe(alias)}\\s*\\.\\s*`)
944
+ }
945
+ }
946
+ }
120
947
  }
948
+ this.siteCache.set(key, sites)
949
+ return sites
121
950
  }
122
951
 
123
- // Field constructor chains -> field description declarations.
124
- FIELD_CTOR.lastIndex = 0
125
- while ((match = FIELD_CTOR.exec(masked)) !== null) {
126
- const ctor = `New${match[1]}Field`
127
- const openIndex = match.index + match[0].length - 1
128
- const closeIndex = findBalancedClose(masked, openIndex)
129
- if (closeIndex === -1) continue
952
+ scanSpecs (file, rel, declarations, skipped) {
953
+ const masked = file.masked
954
+ const describedVars = new Set()
955
+ const nameOf = (r) => (r && isResolved(r) ? renderResult(r) : null)
956
+ DOC_METHODS.lastIndex = 0
957
+ let m
958
+ while ((m = DOC_METHODS.exec(masked)) !== null) {
959
+ const method = m[1]
960
+ const dot = m.index
961
+ const open = m.index + m[0].length - 1
962
+ const close = file.pairs.get(open)
963
+ if (close === undefined) continue
964
+ // A method declaration such as `func (c *X) Description(` is not a call.
965
+ if (/func\s*\([^()]*\)\s*$/.test(masked.slice(Math.max(0, dot - 200), dot))) continue
966
+ const head = chainHead(masked, file.pairs, dot)
967
+ const cls = this.classifyHead(file, head, dot)
968
+ // javascript function docs are read by scanJsFunctions; their
969
+ // Example() is script code, not prose.
970
+ if (cls.kind === 'js-function') continue
971
+ if (!cls.kind || cls.kind === 'field-list') {
972
+ const receiver = head.type === 'ident'
973
+ ? head.name
974
+ : head.type === 'call' ? `${head.qual ? `${head.qual}.` : ''}${head.name}()` : null
975
+ skipped.push({
976
+ surface: 'connect',
977
+ file: rel,
978
+ line: file.lineOf(dot),
979
+ method,
980
+ reason: receiver
981
+ ? `receiver ${receiver} does not trace to a benthos spec, field or Bloblang plugin`
982
+ : 'receiver is not a traceable expression'
983
+ })
984
+ continue
985
+ }
986
+ if (head.type === 'ident' && method === 'Description') describedVars.add(`${enclosingFuncKey(file, dot)}\0${head.name}`)
987
+ const args = splitTopLevelArgs(masked.slice(open + 1, close))
988
+ const argStart = (i) => open + 1 + args.slice(0, i).reduce((n, a) => n + a.length + 1, 0)
989
+ let evalArg = (i) => this.evalAt(file, args[i], argStart(i))
990
+ let tupled = false
991
+ // `spec.Example(exampleConfig())`: one call returning every argument.
992
+ if (args.length === 1 && /^(?:Example|ExampleNotTested)$/.test(method) && (cls.kind === 'spec' || cls.kind === 'blobl-spec')) {
993
+ const fn = enclosingFunc(file, open)
994
+ const tuple = this.index.evalTuple(args[0], {
995
+ file,
996
+ depth: 0,
997
+ resolveLocal: fn ? this.index.localResolver(file, fn, open) : undefined
998
+ })
999
+ if (tuple && tuple.length > 1) {
1000
+ args.length = 0
1001
+ args.push(...tuple.map(() => ''))
1002
+ evalArg = (i) => tuple[i]
1003
+ tupled = true
1004
+ } else {
1005
+ skipped.push({ surface: 'connect', file: rel, line: file.lineOf(dot), method, reason: `example arguments come from ${args[0].trim().slice(0, 60)}, which does not evaluate to a title, a summary and a config` })
1006
+ continue
1007
+ }
1008
+ }
1009
+
1010
+ if (cls.kind === 'spec' || cls.kind === 'blobl-spec') {
1011
+ const meta = this.componentMeta(file, dot)
1012
+ const names = [...new Set(meta.components.map((c) => c.name).filter(Boolean))]
1013
+ const name = names.length === 1 ? names[0] : null
1014
+ const bloblang = cls.kind === 'blobl-spec'
1015
+ const exprOf = (i) => (tupled ? null : { text: args[i], offset: argStart(i) })
1016
+ const push = (kind, result, extra = {}, i = 0) => this.pushDecl(declarations, file, rel, {
1017
+ kind, name, result, start: open, end: close, extra: { ...meta, ...extra }, valueExpr: exprOf(i)
1018
+ })
1019
+ if (method === 'Summary' || method === 'Description' || method === 'Footnotes') {
1020
+ if (args.length < 1) continue
1021
+ if (bloblang) push('bloblang', evalArg(0), { part: method.toLowerCase() })
1022
+ else push(method.toLowerCase(), evalArg(0))
1023
+ } else if (!bloblang && method === 'Example') {
1024
+ if (args.length < 3) continue
1025
+ push('example-title', evalArg(0), {}, 0)
1026
+ const summary = evalArg(1)
1027
+ if (renderResult(summary).trim() !== '') push('example-summary', summary, {}, 1)
1028
+ } else if (bloblang && (method === 'Example' || method === 'ExampleNotTested')) {
1029
+ if (args.length < 2) continue
1030
+ const summary = evalArg(0)
1031
+ if (renderResult(summary).trim() !== '') push('bloblang-example', summary)
1032
+ }
1033
+ continue
1034
+ }
1035
+
1036
+ // Fields and Bloblang params: Example() takes a value, not prose.
1037
+ if (method !== 'Description' && method !== 'ShortDescription') continue
1038
+ if (args.length < 1) continue
1039
+ const kind = cls.kind === 'blobl-param'
1040
+ ? 'bloblang-param'
1041
+ : method === 'ShortDescription' ? 'field-short' : 'field'
1042
+ this.pushDecl(declarations, file, rel, {
1043
+ kind,
1044
+ name: nameOf(cls.name),
1045
+ nameResult: cls.name || null,
1046
+ nameExpr: cls.nameExpr || null,
1047
+ result: evalArg(0),
1048
+ valueExpr: { text: args[0], offset: argStart(0) },
1049
+ start: head.type === 'call' ? head.start : open,
1050
+ end: close,
1051
+ extra: { ctor: cls.ctor || null, ...(cls.via ? { via: cls.via } : {}), ...this.componentMeta(file, dot) }
1052
+ })
1053
+ }
1054
+ this.scanFieldCtors(file, rel, declarations, describedVars)
1055
+ this.scanJsFunctions(file, rel, declarations)
1056
+ }
1057
+
1058
+ /**
1059
+ * javascript processor functions: `registerVMRunnerFunction(name,
1060
+ * description).Param(name, type, what)`. The processor renders each one
1061
+ * into its footnotes, so the description and every parameter's text are
1062
+ * published prose.
1063
+ */
1064
+ scanJsFunctions (file, rel, declarations) {
1065
+ const pkg = this.index.package(file.dir)
1066
+ const builders = [...pkg.funcs.values()].filter((fn) => kindOfType(fn.results) === 'js-function')
1067
+ if (builders.length === 0) return
1068
+ const masked = file.masked
1069
+ const meta = this.componentMeta(file, -1)
1070
+ for (const fn of builders) {
1071
+ const descIndex = fn.params.findIndex((p) => p.name === 'description')
1072
+ const nameIndex = fn.params.findIndex((p) => p.name === 'name')
1073
+ if (descIndex === -1) continue
1074
+ const re = new RegExp(`(^|[^\\w.])${escapeRe(fn.name)}\\s*\\(`, 'g')
1075
+ const text = stripStrings(masked)
1076
+ let m
1077
+ while ((m = re.exec(text)) !== null) {
1078
+ if (/\bfunc\s*$/.test(text.slice(Math.max(0, m.index - 10), m.index + m[1].length))) continue
1079
+ const open = m.index + m[0].length - 1
1080
+ const close = file.pairs.get(open)
1081
+ if (close === undefined) continue
1082
+ const args = splitTopLevelArgs(masked.slice(open + 1, close))
1083
+ const argStart = (i) => open + 1 + args.slice(0, i).reduce((n, a) => n + a.length + 1, 0)
1084
+ const nameResult = nameIndex !== -1 && args[nameIndex] ? this.evalAt(file, args[nameIndex], argStart(nameIndex)) : null
1085
+ const fnName = nameResult && isResolved(nameResult) ? renderResult(nameResult) : null
1086
+ if (args[descIndex] === undefined) continue
1087
+ declarations.push(this.makeDecl(file, rel, {
1088
+ kind: 'js-function',
1089
+ name: fnName,
1090
+ result: this.evalAt(file, args[descIndex], argStart(descIndex)),
1091
+ start: m.index + m[1].length,
1092
+ end: close,
1093
+ extra: { ...meta }
1094
+ }))
1095
+ for (const call of readChain(masked, file.pairs, close + 1)) {
1096
+ if (call.method !== 'Param') continue
1097
+ const pargs = splitTopLevelArgs(masked.slice(call.open + 1, call.close))
1098
+ if (pargs.length < 3) continue
1099
+ const pStart = (i) => call.open + 1 + pargs.slice(0, i).reduce((n, a) => n + a.length + 1, 0)
1100
+ const pname = this.evalAt(file, pargs[0], pStart(0))
1101
+ declarations.push(this.makeDecl(file, rel, {
1102
+ kind: 'js-function-param',
1103
+ name: fnName && isResolved(pname) ? `${fnName}.${renderResult(pname)}` : null,
1104
+ result: this.evalAt(file, pargs[2], pStart(2)),
1105
+ start: call.open,
1106
+ end: call.close,
1107
+ extra: { ...meta }
1108
+ }))
1109
+ }
1110
+ }
1111
+ }
1112
+ }
1113
+
1114
+ /**
1115
+ * Field constructors: annotated enum options, and bare constructors that
1116
+ * never get a Description.
1117
+ */
1118
+ scanFieldCtors (file, rel, declarations, describedVars) {
1119
+ const masked = file.masked
1120
+ const { service } = this.aliases(file)
1121
+ if (service.size === 0) return
1122
+ const pattern = new RegExp(`\\b(?:${[...service].map(escapeRe).join('|')})\\s*\\.\\s*(New\\w*Field)\\s*\\(`, 'g')
1123
+ let m
1124
+ while ((m = pattern.exec(masked)) !== null) {
1125
+ const ctor = m[1]
1126
+ const open = m.index + m[0].length - 1
1127
+ const close = file.pairs.get(open)
1128
+ if (close === undefined) continue
1129
+ const args = splitTopLevelArgs(masked.slice(open + 1, close))
1130
+ const nameResult = args.length > 0 && args[0].trim() !== '' ? this.evalAt(file, args[0], open) : null
1131
+ const fieldName = nameResult && isResolved(nameResult) ? renderResult(nameResult) : null
1132
+ const calls = readChain(masked, file.pairs, close + 1)
1133
+ const end = calls.length > 0 ? calls[calls.length - 1].close : close
1134
+
1135
+ if (ctor === 'NewStringAnnotatedEnumField' && args.length >= 2) {
1136
+ this.scanEnumOptions(file, rel, args[1], open + 1 + args[0].length + 1, fieldName, declarations)
1137
+ }
1138
+
1139
+ if (!BARE_FIELD_CTORS.has(ctor)) continue
1140
+ // Deprecated fields are left out of the published reference.
1141
+ if (calls.some((c) => c.method === 'Description' || c.method === 'Deprecated')) continue
1142
+ // `f := service.NewStringField(...)` followed by
1143
+ // `f = f.Description(...)` documents the field by reassignment.
1144
+ const assigned = masked.slice(Math.max(0, m.index - 80), m.index).match(/(?:^|[^\w.])([A-Za-z_]\w*)\s*:?=\s*$/)
1145
+ if (assigned && describedVars.has(`${enclosingFuncKey(file, m.index)}\0${assigned[1]}`)) continue
1146
+ declarations.push(this.makeDecl(file, rel, {
1147
+ kind: 'field',
1148
+ name: fieldName,
1149
+ result: null,
1150
+ start: m.index,
1151
+ end,
1152
+ extra: { ctor, missing_description: true, ...this.componentMeta(file, m.index) }
1153
+ }))
1154
+ }
1155
+ }
130
1156
 
131
- const ctorArgs = splitTopLevelArgs(masked.slice(openIndex + 1, closeIndex))
132
- const nameResult = ctorArgs.length > 0 ? evalStringExpr(ctorArgs[0], consts) : { verifiable: false, value: null }
133
- const fieldName = nameResult.verifiable ? nameResult.value : null
1157
+ /** `map[string]string{"opt": "desc", ...}`, inline or in a variable. */
1158
+ scanEnumOptions (file, rel, argText, argStart, fieldName, declarations) {
1159
+ let text = argText
1160
+ let start = argStart
1161
+ let srcFile = file
1162
+ const trimmed = argText.trim()
1163
+ if (/^[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)?$/.test(trimmed)) {
1164
+ const fn = enclosingFunc(file, argStart)
1165
+ const local = fn && !trimmed.includes('.') ? this.lastAssignment(file, fn, trimmed, argStart) : null
1166
+ if (local && local.expr) {
1167
+ text = local.expr
1168
+ start = local.exprStart
1169
+ } else {
1170
+ const value = this.index.lookupValue(file, trimmed)
1171
+ if (!value || !value.expr) return
1172
+ text = value.expr
1173
+ start = value.exprStart
1174
+ srcFile = value.file
1175
+ }
1176
+ }
1177
+ const lit = text.match(/^\s*map\s*\[\s*string\s*\]\s*string\s*\{/)
1178
+ if (!lit) return
1179
+ const local = /^[A-Za-z_]\w*$/.test(trimmed) && srcFile === file ? enclosingFunc(file, argStart) : null
1180
+ if (local) this.scanEnumIndexAssignments(file, rel, local, trimmed, argStart, fieldName, declarations)
1181
+ const braceOpen = start + lit[0].length - 1
1182
+ const braceClose = srcFile.pairs.get(braceOpen)
1183
+ if (braceClose === undefined) return
1184
+ const srcRel = srcFile === file ? rel : this.index.rel(srcFile.abs)
1185
+ let offset = braceOpen + 1
1186
+ for (const entry of splitTopLevelArgs(srcFile.masked.slice(braceOpen + 1, braceClose))) {
1187
+ const entryStart = offset
1188
+ offset += entry.length + 1
1189
+ if (entry.trim() === '') continue
1190
+ const colon = topLevelColon(entry)
1191
+ if (colon === -1) continue
1192
+ const key = this.evalAt(srcFile, entry.slice(0, colon), entryStart)
1193
+ const value = this.evalAt(srcFile, entry.slice(colon + 1), entryStart + colon + 1)
1194
+ const opt = isResolved(key) ? renderResult(key) : null
1195
+ const lead = entry.length - entry.trimStart().length
1196
+ declarations.push(this.makeDecl(srcFile, srcRel, {
1197
+ kind: 'enum-option',
1198
+ name: fieldName && opt ? `${fieldName}=${opt}` : opt,
1199
+ result: value,
1200
+ start: entryStart + lead,
1201
+ end: entryStart + entry.trimEnd().length - 1,
1202
+ extra: { field: fieldName, option: opt, ...this.componentMeta(file, argStart) }
1203
+ }))
1204
+ }
1205
+ }
134
1206
 
135
- const { calls, end } = readChain(masked, closeIndex + 1)
136
- const descriptionCall = calls.find((c) => c.method === 'Description')
1207
+ /** `opts["key"] = "desc"` assignments that fill an enum option map. */
1208
+ scanEnumIndexAssignments (file, rel, fn, ident, before, fieldName, declarations) {
1209
+ const masked = file.masked
1210
+ const re = new RegExp(`(^|[^\\w.])${escapeRe(ident)}\\s*\\[`, 'g')
1211
+ const text = masked.slice(fn.bodyStart, before)
1212
+ let m
1213
+ while ((m = re.exec(text)) !== null) {
1214
+ const bracket = fn.bodyStart + m.index + m[0].length - 1
1215
+ const close = file.pairs.get(bracket)
1216
+ if (close === undefined) continue
1217
+ const assign = masked.slice(close + 1, close + 40).match(/^\s*=(?!=)\s*/)
1218
+ if (!assign) continue
1219
+ const exprStart = close + 1 + assign[0].length
1220
+ const exprEnd = statementEnd(masked, exprStart, fn.bodyEnd)
1221
+ const key = this.evalAt(file, masked.slice(bracket + 1, close), bracket)
1222
+ const opt = isResolved(key) ? renderResult(key) : null
1223
+ declarations.push(this.makeDecl(file, rel, {
1224
+ kind: 'enum-option',
1225
+ name: fieldName && opt ? `${fieldName}=${opt}` : opt,
1226
+ result: this.evalAt(file, masked.slice(exprStart, exprEnd), exprStart),
1227
+ start: bracket,
1228
+ end: exprEnd,
1229
+ extra: { field: fieldName, option: opt, ...this.componentMeta(file, before) }
1230
+ }))
1231
+ }
1232
+ }
137
1233
 
138
- if (descriptionCall) {
139
- const result = evalStringExpr(descriptionCall.args, consts)
140
- if (result.verifiable) {
1234
+ /** urfave/cli Command and Flag composite literals. */
1235
+ scanCli (file, rel, declarations) {
1236
+ const masked = file.masked
1237
+ const { cli } = this.aliases(file)
1238
+ const pattern = new RegExp(`\\b(?:${[...cli].map(escapeRe).join('|')})\\s*\\.\\s*(Command|\\w+Flag)\\s*\\{`, 'g')
1239
+ let m
1240
+ while ((m = pattern.exec(masked)) !== null) {
1241
+ const literal = m[1]
1242
+ const braceOpen = m.index + m[0].length - 1
1243
+ const braceClose = file.pairs.get(braceOpen)
1244
+ if (braceClose === undefined) continue
1245
+ const body = masked.slice(braceOpen + 1, braceClose)
1246
+ const fields = parseLiteralFields(body)
1247
+ const valueOf = (key) => {
1248
+ const f = fields.get(key)
1249
+ if (!f) return null
1250
+ const at = braceOpen + 1 + f.valueStart
1251
+ return {
1252
+ result: this.evalAt(file, body.slice(f.valueStart, f.end), at),
1253
+ start: braceOpen + 1 + f.start,
1254
+ end: braceOpen + f.end
1255
+ }
1256
+ }
1257
+ const nameField = valueOf('Name')
1258
+ const name = nameField && isResolved(nameField.result) ? renderResult(nameField.result) : null
1259
+ const keys = literal === 'Command'
1260
+ ? [['Usage', 'cli-usage'], ['UsageText', 'cli-usage-text'], ['ArgsUsage', 'cli-args-usage'], ['Description', 'cli-description']]
1261
+ : [['Usage', 'cli-flag']]
1262
+ for (const [key, kind] of keys) {
1263
+ const v = valueOf(key)
1264
+ if (!v) continue
1265
+ declarations.push(this.makeDecl(file, rel, {
1266
+ kind,
1267
+ name,
1268
+ result: v.result,
1269
+ start: v.start,
1270
+ end: v.end,
1271
+ convention: CLI_CONVENTION,
1272
+ extra: {
1273
+ literal: literal === 'Command' ? 'command' : literal,
1274
+ // rpk-docs formats CLI help like cobra help; it is not AsciiDoc.
1275
+ skip_rules: ['raw-pipe', 'unknown-attribute', 'broken-macro', 'too-short']
1276
+ }
1277
+ }))
1278
+ }
1279
+ }
1280
+ }
1281
+ }
1282
+
1283
+ /** Repo-relative .go and template files to scan in a whole-repo run. */
1284
+ function repoFiles (repo) {
1285
+ const out = []
1286
+ for (const root of SCAN_ROOTS) {
1287
+ for (const f of collectGoFiles(path.join(repo, root))) out.push(path.join(root, f))
1288
+ for (const f of collectTemplates(path.join(repo, root))) out.push(path.join(root, f))
1289
+ }
1290
+ return out
1291
+ }
1292
+
1293
+ function collectTemplates (root, base = root, out = []) {
1294
+ let entries = []
1295
+ try {
1296
+ entries = fs.readdirSync(root, { withFileTypes: true })
1297
+ } catch {
1298
+ return out
1299
+ }
1300
+ for (const entry of entries) {
1301
+ if (entry.name.startsWith('.')) continue
1302
+ const full = path.join(root, entry.name)
1303
+ if (entry.isDirectory()) {
1304
+ if (entry.name === 'testdata' || entry.name === 'node_modules') continue
1305
+ collectTemplates(full, base, out)
1306
+ } else if (TEMPLATE_FILE.test(entry.name)) {
1307
+ out.push(path.relative(base, full))
1308
+ }
1309
+ }
1310
+ return out
1311
+ }
1312
+
1313
+ const TEMPLATE_FILE = /\.tmpl\.ya?ml$/
1314
+
1315
+ /**
1316
+ * A config template (`*.tmpl.yaml`, registered with RegisterTemplateYAML):
1317
+ * its summary, description and field descriptions are published like a
1318
+ * component spec's.
1319
+ */
1320
+ function scanTemplate (content, rel, scanner) {
1321
+ const YAML = require('yaml')
1322
+ const declarations = []
1323
+ let doc
1324
+ try {
1325
+ doc = YAML.parseDocument(content)
1326
+ } catch {
1327
+ return declarations
1328
+ }
1329
+ if (!doc || !YAML.isMap(doc.contents)) return declarations
1330
+ const lineIndex = (offset) => content.slice(0, offset).split('\n').length
1331
+ const name = doc.get('name')
1332
+ const type = doc.get('type')
1333
+ if (typeof name !== 'string' || typeof type !== 'string') return declarations
1334
+ const info = scanner.info.get(`${type}/${name}`)
1335
+ const meta = { components: [{ type, name, ...(info || {}) }], components_total: 1, config_template: true }
1336
+ const push = (kind, declName, node, extra = {}) => {
1337
+ if (!node || !YAML.isScalar(node) || typeof node.value !== 'string') return
1338
+ const [start, , end] = node.range
1339
+ const text = node.value.trim()
1340
+ declarations.push({
1341
+ surface: 'connect',
1342
+ name: declName,
1343
+ file: rel,
1344
+ line_start: lineIndex(start),
1345
+ line_end: lineIndex(Math.max(start, end - 1)),
1346
+ string: text === '' ? null : text,
1347
+ declaration_text: null,
1348
+ convention: CONVENTION,
1349
+ meta: { kind, ...meta, ...extra }
1350
+ })
1351
+ }
1352
+ push('summary', name, doc.contents.get('summary', true))
1353
+ push('description', name, doc.contents.get('description', true))
1354
+ const fields = doc.contents.get('fields', true)
1355
+ if (YAML.isSeq(fields)) {
1356
+ for (const field of fields.items) {
1357
+ if (!YAML.isMap(field)) continue
1358
+ const fieldName = field.get('name')
1359
+ const description = field.get('description', true)
1360
+ if (description) push('field', typeof fieldName === 'string' ? fieldName : null, description, { ctor: 'template' })
1361
+ else if (typeof fieldName === 'string') {
1362
+ const node = field.get('name', true)
141
1363
  declarations.push({
142
1364
  surface: 'connect',
143
1365
  name: fieldName,
144
- file,
145
- line_start: lineOf(masked, match.index),
146
- line_end: lineOf(masked, descriptionCall.closeIndex),
147
- string: result.value.trim() === '' ? null : result.value.trim(),
1366
+ file: rel,
1367
+ line_start: lineIndex(node.range[0]),
1368
+ line_end: lineIndex(node.range[0]),
1369
+ string: null,
148
1370
  declaration_text: null,
149
1371
  convention: CONVENTION,
150
- meta: { kind: 'field', ctor }
1372
+ meta: { kind: 'field', ctor: 'template', missing_description: true, ...meta }
151
1373
  })
152
1374
  }
153
- // Unverifiable description: skip silently (cross-file constant or call).
154
- } else if (BARE_FIELD_CTORS.has(ctor) && !calls.some((c) => c.method === 'Deprecated')) {
155
- // Deprecated fields are not part of the user-facing docs contract.
156
- // A bare constructor with no Description() anywhere in its chain
157
- // ships an empty field description.
158
- declarations.push({
159
- surface: 'connect',
160
- name: fieldName,
161
- file,
162
- line_start: lineOf(masked, match.index),
163
- line_end: lineOf(masked, Math.max(closeIndex, end - 1)),
164
- string: null,
165
- declaration_text: null,
166
- convention: CONVENTION,
167
- meta: { kind: 'field', ctor, missing_description: true }
168
- })
169
1375
  }
170
- // Resume INSIDE the constructor args: object fields nest child field
171
- // constructors (NewObjectField("x", NewStringField("y")...)).
172
- FIELD_CTOR.lastIndex = openIndex + 1
173
1376
  }
174
-
175
1377
  return declarations
176
1378
  }
177
1379
 
1380
+ function spanText (content, start, end) {
1381
+ return content.split('\n').slice(start - 1, end).join('\n')
1382
+ }
1383
+
178
1384
  /**
179
1385
  * Extract connect declarations.
180
1386
  *
181
- * @param {Object} options - { repo, files (Set of repo-relative paths, diff
182
- * mode; when omitted, scans internal/impl), log }
1387
+ * @param {Object} options
1388
+ * @param {string} options.repo - The connect (or benthos) checkout
1389
+ * @param {Set<string>} [options.files] - Repo-relative paths (diff mode);
1390
+ * when omitted, scans internal/, public/ and cmd/
1391
+ * @param {boolean} [options.external=true] - Resolve benthos and other
1392
+ * dependencies through the Go module cache when it holds them
1393
+ * @param {Map<string,string>} [options.overlay] - In-memory file contents
1394
+ * @returns {Array} declarations. The array also carries a non-enumerable
1395
+ * `skipped` list: every doc-method call whose receiver could not be traced
1396
+ * to a benthos type, so a caller can report it instead of losing it.
183
1397
  */
184
- function extract ({ repo, files = null }) {
185
- const IMPL_ROOT = path.join('internal', 'impl')
186
- let fileList
187
- if (files) {
188
- fileList = [...files].filter((f) => f.endsWith('.go') && !f.endsWith('_test.go'))
189
- } else {
190
- fileList = collectGoFiles(path.join(repo, IMPL_ROOT)).map((f) => path.join(IMPL_ROOT, f))
191
- }
192
-
1398
+ function extract ({ repo, files = null, external = true, overlay = null }) {
1399
+ const index = new GoIndex(repo, { external, overlay })
1400
+ const scanner = new ConnectScanner(index, { repo })
1401
+ const fileList = files
1402
+ ? [...files].filter((f) => (f.endsWith('.go') && !f.endsWith('_test.go')) || TEMPLATE_FILE.test(f))
1403
+ : repoFiles(repo)
193
1404
  const cache = new SourceCache(repo)
194
1405
  const declarations = []
195
- for (const file of fileList) {
196
- const absPath = path.isAbsolute(file) ? file : path.join(repo, file)
197
- if (!fs.existsSync(absPath)) continue
198
- const content = fs.readFileSync(absPath, 'utf8')
199
- if (!content.includes('Field(') && !content.includes('NewConfigSpec')) continue
200
- for (const decl of scanFile(content, file)) {
201
- decl.declaration_text = cache.span(file, decl.line_start, decl.line_end)
1406
+ const skipped = []
1407
+ for (const rel of fileList) {
1408
+ if (TEMPLATE_FILE.test(rel)) {
1409
+ let content = overlay && overlay.has(rel) ? overlay.get(rel) : null
1410
+ if (content === null) {
1411
+ try {
1412
+ content = fs.readFileSync(path.join(index.repo, rel), 'utf8')
1413
+ } catch {
1414
+ continue
1415
+ }
1416
+ }
1417
+ for (const decl of scanTemplate(content, rel, scanner)) {
1418
+ decl.declaration_text = spanText(content, decl.line_start, decl.line_end)
1419
+ declarations.push(decl)
1420
+ }
1421
+ continue
1422
+ }
1423
+ const file = index.file(path.isAbsolute(rel) ? rel : path.join(index.repo, rel))
1424
+ if (!file) continue
1425
+ const result = scanner.scan(file, rel)
1426
+ for (const decl of result.declarations) {
1427
+ decl.declaration_text = overlay && overlay.has(decl.file)
1428
+ ? spanText(overlay.get(decl.file), decl.line_start, decl.line_end)
1429
+ : cache.span(decl.file, decl.line_start, decl.line_end)
202
1430
  declarations.push(decl)
203
1431
  }
1432
+ skipped.push(...result.skipped)
204
1433
  }
1434
+ Object.defineProperty(declarations, 'skipped', { value: skipped, enumerable: false })
205
1435
  return declarations
206
1436
  }
207
1437
 
1438
+ /**
1439
+ * Scan one file's content with no checkout around it. Exported for tests:
1440
+ * only same-file constants and helpers resolve.
1441
+ */
1442
+ function scanFile (content, file) {
1443
+ return extract({
1444
+ repo: path.join(path.sep, '__lint_strings_virtual__'),
1445
+ files: new Set([file]),
1446
+ external: false,
1447
+ overlay: new Map([[file, content]])
1448
+ })
1449
+ }
1450
+
1451
+ // AsciiDoc and Markdown markup that a plain-text UI shows literally.
1452
+ const MARKUP_PATTERNS = [
1453
+ [/`/, 'backticks'],
1454
+ [/\b(?:xref|link|image|include|glossterm):[^\s[]*\[/, 'an AsciiDoc macro'],
1455
+ [/https?:\/\/\S+\[[^\]]*\]/, 'an AsciiDoc link'],
1456
+ [/<<[^>]+>>/, 'a cross-reference'],
1457
+ [/\*\*[^*]+\*\*/, 'bold markup'],
1458
+ [/^\s*(?:=+|\*+|-|\.)\s/m, 'a heading or list marker'],
1459
+ [/\{[a-z][a-z0-9_-]*\}/, 'an attribute reference'],
1460
+ [/\n/, 'a line break']
1461
+ ]
1462
+
1463
+ const ENV_CONDITIONAL = /\b(?:ifdef|ifndef|ifeval|endif)::/
1464
+
1465
+ // Modules of the Redpanda Connect docs component. A generated partial is
1466
+ // rendered on the self-managed page and included into Cloud pages, so a
1467
+ // link needs an explicit module to resolve in both places.
1468
+ const XREF_MODULES = new Set(['components', 'configuration', 'cookbooks', 'get-started', 'guides', 'install', 'reference'])
1469
+
1470
+ const AVAILABILITY_PROSE = /\b(?:not (?:available|supported) (?:in|on) (?:Redpanda )?Cloud|only available (?:in|on|for) (?:self-managed|Redpanda Cloud|Cloud)|self-managed only|requires? (?:a )?cgo|CGO_ENABLED|x_benthos_extra)\b/i
1471
+
208
1472
  /** Surface-specific convention rules. */
209
1473
  const RULES = [
210
1474
  {
@@ -215,6 +1479,69 @@ const RULES = [
215
1479
  if (decl.meta.kind !== 'field' || !decl.meta.missing_description) return []
216
1480
  return [{ message: `Field ${decl.name ? `"${decl.name}" ` : ''}(${decl.meta.ctor}) has no .Description(). It ships as an undocumented field on the connector page.` }]
217
1481
  }
1482
+ },
1483
+ {
1484
+ name: 'connect-short-description-markup',
1485
+ description: 'ShortDescription is plain text in form-based editors',
1486
+ severity: 'warning',
1487
+ check: (decl) => {
1488
+ if (decl.meta.kind !== 'field-short' || !decl.string) return []
1489
+ const found = MARKUP_PATTERNS.filter(([re]) => re.test(decl.string)).map(([, what]) => what)
1490
+ if (found.length === 0) return []
1491
+ return [{ message: `ShortDescription contains ${found.join(', ')}. Form-based config editors, such as the one in Redpanda Cloud, show it as plain text, so markup renders literally. Write one plain sentence and keep markup in Description().` }]
1492
+ }
1493
+ },
1494
+ {
1495
+ name: 'connect-env-conditional',
1496
+ description: 'AsciiDoc ifdef/ifndef conditional in a Go spec string',
1497
+ severity: 'warning',
1498
+ check: (decl) => {
1499
+ if (!decl.string || !ENV_CONDITIONAL.test(decl.string)) return []
1500
+ return [{ message: 'Contains an AsciiDoc conditional (ifdef::, ifndef:: or endif::). The docs generator owns every Cloud conditional in the partials, and a hand-written one nests inside them, so the Cloud copy shows or hides the wrong text. Fix the availability data in internal/plugins/info.csv instead, or ask the docs team to carry the sentence on the page.' }]
1501
+ }
1502
+ },
1503
+ {
1504
+ name: 'connect-xref-module',
1505
+ description: 'xref without a module, or to a module the Connect docs do not have',
1506
+ severity: 'warning',
1507
+ check: (decl) => {
1508
+ if (!decl.string || (decl.convention && decl.convention.verbatim_asciidoc === false)) return []
1509
+ const issues = []
1510
+ for (const m of decl.string.matchAll(/\bxref:([^\s[\]]+)\[/g)) {
1511
+ const parts = m[1].split(':')
1512
+ // component:module:page names its docs component, so it resolves
1513
+ // wherever the partial is included.
1514
+ if (parts.length >= 3) continue
1515
+ if (parts.length === 2 && XREF_MODULES.has(parts[0])) continue
1516
+ issues.push({
1517
+ message: parts.length === 1
1518
+ ? `"xref:${m[1]}[" has no module. It resolves on the self-managed page, but Cloud includes the same partial into its own module, where the link breaks. Name the module, for example xref:components:${m[1]}[...].`
1519
+ : `"xref:${m[1]}[" names the ${parts[0]} module, which the Redpanda Connect docs do not have, so the link breaks on the rendered page. Use one of ${[...XREF_MODULES].join(', ')}, a component:module:page xref, or a full https:// link.`
1520
+ })
1521
+ }
1522
+ return issues
1523
+ }
1524
+ },
1525
+ {
1526
+ name: 'connect-availability-prose',
1527
+ description: 'Cloud or cgo availability written as prose instead of data',
1528
+ severity: 'warning',
1529
+ check: (decl) => {
1530
+ if (!decl.string) return []
1531
+ const m = decl.string.match(AVAILABILITY_PROSE)
1532
+ if (!m) return []
1533
+ return [{ message: `States availability in prose ("${m[0]}"). The generator renders Cloud and cgo availability notes from internal/plugins/info.csv and the build lists, and prose here shows on both the self-managed and the Cloud page. Remove it and fix the data instead.` }]
1534
+ }
1535
+ },
1536
+ {
1537
+ name: 'connect-cli-usage-multiline',
1538
+ description: 'urfave/cli Usage is a one-line label',
1539
+ severity: 'warning',
1540
+ check: (decl) => {
1541
+ if (decl.meta.kind !== 'cli-usage' && decl.meta.kind !== 'cli-flag') return []
1542
+ if (!decl.string || !decl.string.includes('\n')) return []
1543
+ return [{ message: 'Usage spans multiple lines. `rpk connect` help prints Usage as a one-line label, so move detail into the command Description.' }]
1544
+ }
218
1545
  }
219
1546
  ]
220
1547
 
@@ -229,12 +1556,32 @@ function identity (decl) {
229
1556
  return [decl.file, (decl.meta && decl.meta.kind) || null, decl.name]
230
1557
  }
231
1558
 
1559
+ const DETAIL_KEYS = ['kind', 'part', 'ctor', 'field', 'option', 'components', 'components_total', 'components_via', 'call_site', 'template', 'unresolved', 'config_template']
1560
+
1561
+ /**
1562
+ * What a review needs beyond the string: the kind of declaration, the
1563
+ * components that publish it with their info.csv row (support level, Cloud
1564
+ * and Cloud GPU availability), and, for helper text, the call site and the
1565
+ * template with its gaps.
1566
+ */
1567
+ function reviewContext (decl) {
1568
+ const meta = decl.meta || {}
1569
+ const out = {}
1570
+ for (const key of DETAIL_KEYS) if (meta[key] !== undefined && meta[key] !== null) out[key] = meta[key]
1571
+ return out
1572
+ }
1573
+
232
1574
  module.exports = {
233
1575
  name: 'connect',
234
1576
  convention: CONVENTION,
235
1577
  extract,
236
1578
  scanFile,
237
1579
  identity,
1580
+ reviewContext,
1581
+ readInfoCsv,
1582
+ chainHead,
1583
+ kindOfType,
1584
+ _ConnectScanner: ConnectScanner,
238
1585
  rules: RULES,
239
1586
  // A connect description is the AsciiDoc page body, not a table cell:
240
1587
  // bare | is legitimate table syntax there, and missing prose is handled