@redpanda-data/docs-extensions-and-macros 5.14.0 → 5.17.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 (44) hide show
  1. package/bin/doc-tools-mcp.js +88 -0
  2. package/bin/doc-tools.js +252 -102
  3. package/bin/mcp-tools/index.js +20 -0
  4. package/bin/mcp-tools/lint-doc-strings.js +75 -0
  5. package/bin/mcp-tools/overrides-audit.js +100 -0
  6. package/bin/mcp-tools/preview-doc-string.js +71 -0
  7. package/cli-utils/diff-utils.js +77 -0
  8. package/cli-utils/helm-commented-values.js +172 -23
  9. package/package.json +2 -1
  10. package/tools/generate-cli-docs.js +28 -67
  11. package/tools/lint-strings/diff.js +137 -0
  12. package/tools/lint-strings/engine.js +163 -0
  13. package/tools/lint-strings/fixtures/connect/lint_connect.go +67 -0
  14. package/tools/lint-strings/fixtures/crd/crd-ref-docs-config.yaml +15 -0
  15. package/tools/lint-strings/fixtures/crd/lint_types.go +57 -0
  16. package/tools/lint-strings/fixtures/helm/lint_values.yaml +47 -0
  17. package/tools/lint-strings/fixtures/metrics/lint_probe.cc +41 -0
  18. package/tools/lint-strings/fixtures/properties/lint_fixture.cc +51 -0
  19. package/tools/lint-strings/fixtures/properties/lint_fixture.h +33 -0
  20. package/tools/lint-strings/fixtures/rpk/lint_cmd.go +69 -0
  21. package/tools/lint-strings/go-source.js +327 -0
  22. package/tools/lint-strings/index.js +229 -0
  23. package/tools/lint-strings/rules/common.js +148 -0
  24. package/tools/lint-strings/rules/verbatim-asciidoc.js +108 -0
  25. package/tools/lint-strings/source-text.js +41 -0
  26. package/tools/lint-strings/surfaces/connect.js +233 -0
  27. package/tools/lint-strings/surfaces/crd.js +239 -0
  28. package/tools/lint-strings/surfaces/helm.js +159 -0
  29. package/tools/lint-strings/surfaces/metrics.js +368 -0
  30. package/tools/lint-strings/surfaces/properties.js +216 -0
  31. package/tools/lint-strings/surfaces/rpk.js +377 -0
  32. package/tools/overrides-audit/README.adoc +152 -0
  33. package/tools/overrides-audit/adapters/connect.js +144 -0
  34. package/tools/overrides-audit/adapters/properties.js +117 -0
  35. package/tools/overrides-audit/adapters/rpk.js +192 -0
  36. package/tools/overrides-audit/classify.js +546 -0
  37. package/tools/overrides-audit/index.js +185 -0
  38. package/tools/preview-string/index.js +245 -0
  39. package/tools/property-extractor/Makefile +9 -4
  40. package/tools/property-extractor/parser.py +26 -0
  41. package/tools/property-extractor/property_extractor.py +41 -0
  42. package/tools/property-extractor/transformers.py +15 -2
  43. package/tools/rpk-docs/detect-platform-commands.js +8 -1
  44. package/tools/rpk-docs/scan-deprecated-commands.js +15 -18
@@ -0,0 +1,148 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Rules that apply to every doc-string surface.
5
+ *
6
+ * Rule shape follows tools/rpk-docs/validate-output.js VALIDATION_RULES:
7
+ * { name, description, severity, check(decl) -> issues[] }.
8
+ * `decl` is the declaration object documented in ../engine.js.
9
+ */
10
+
11
+ /**
12
+ * Jargon that ships verbatim to docs.redpanda.com but means nothing to users
13
+ * unless expanded. Matched on word boundaries (underscores count as word
14
+ * characters, so `archival_meta_stm` does not trip the `stm` entry).
15
+ */
16
+ const JARGON_TERMS = ['NTP', 'seastar', 'nullopt', 'stm', 'smp']
17
+
18
+ /**
19
+ * Tokenize a name or description for tautology comparison: strip `_`/`-`,
20
+ * case-fold, split on non-alphanumerics.
21
+ */
22
+ function tokenize (text) {
23
+ return String(text)
24
+ .toLowerCase()
25
+ .split(/[^a-z0-9]+/)
26
+ .filter(Boolean)
27
+ }
28
+
29
+ /**
30
+ * Two tokens "match" when one is a prefix of the other (so `id` matches
31
+ * `identifier`), with a 2-character minimum to avoid single-letter noise.
32
+ */
33
+ function tokensMatch (a, b) {
34
+ const [short, long] = a.length <= b.length ? [a, b] : [b, a]
35
+ return short.length >= 2 && long.startsWith(short)
36
+ }
37
+
38
+ /**
39
+ * True when the normalized description is just the normalized name:
40
+ * same token count, and every description token pairs with a distinct name
41
+ * token (any order, prefix-tolerant).
42
+ */
43
+ function isNameEcho (name, description) {
44
+ const nameTokens = tokenize(name)
45
+ const descTokens = tokenize(description.replace(/[.!?]\s*$/, ''))
46
+ if (nameTokens.length === 0 || descTokens.length !== nameTokens.length) return false
47
+
48
+ const used = new Array(nameTokens.length).fill(false)
49
+ for (const descToken of descTokens) {
50
+ const idx = nameTokens.findIndex((nameToken, i) => !used[i] && tokensMatch(descToken, nameToken))
51
+ if (idx === -1) return false
52
+ used[idx] = true
53
+ }
54
+ return true
55
+ }
56
+
57
+ const COMMON_RULES = [
58
+ {
59
+ name: 'empty-description',
60
+ description: 'Declaration ships with no doc string at all',
61
+ severity: 'error',
62
+ check: (decl) => {
63
+ if (decl.string == null || decl.string.trim() === '') {
64
+ return [{ message: 'Description is empty or missing. This ships as a blank entry on docs.redpanda.com.' }]
65
+ }
66
+ return []
67
+ }
68
+ },
69
+ {
70
+ name: 'starts-lowercase',
71
+ description: 'Description starts with a lowercase letter',
72
+ severity: 'warning',
73
+ check: (decl) => {
74
+ const text = (decl.string || '').trim()
75
+ if (!text) return []
76
+ // A description may legitimately open with a backticked literal
77
+ // (for example, "`true` means ..."); only flag bare prose.
78
+ if (text.startsWith('`')) return []
79
+ const firstAlpha = text.match(/[A-Za-z]/)
80
+ if (firstAlpha && firstAlpha.index === 0 && /[a-z]/.test(firstAlpha[0])) {
81
+ // Mixed-case product spellings (gRPC, iOS, jsonPath) are correct as
82
+ // written; only all-lowercase first words are capitalization drift.
83
+ const firstWord = text.split(/\s/, 1)[0]
84
+ if (/[A-Z]/.test(firstWord)) return []
85
+ return [{ message: `Description starts with a lowercase letter: "${text.slice(0, 60)}"` }]
86
+ }
87
+ return []
88
+ }
89
+ },
90
+ {
91
+ name: 'name-echo',
92
+ description: 'Description merely restates the declared name (tautology)',
93
+ severity: 'warning',
94
+ check: (decl) => {
95
+ const text = (decl.string || '').trim()
96
+ if (!text || !decl.name) return []
97
+ if (isNameEcho(decl.name, text)) {
98
+ return [{ message: `Description just restates the name "${decl.name}": "${text}". Say what it does, why, or what happens when it changes.` }]
99
+ }
100
+ return []
101
+ }
102
+ },
103
+ {
104
+ name: 'too-short',
105
+ description: 'Description is under 20 characters',
106
+ severity: 'warning',
107
+ check: (decl) => {
108
+ const text = (decl.string || '').trim()
109
+ if (!text) return [] // empty-description owns the empty case
110
+ if (text.length < 20) {
111
+ return [{ message: `Description is only ${text.length} characters: "${text}". Too short to explain behavior, units, or impact.` }]
112
+ }
113
+ return []
114
+ }
115
+ },
116
+ {
117
+ name: 'unexpanded-jargon',
118
+ description: 'Internal jargon that ships to users unexpanded',
119
+ severity: 'warning',
120
+ check: (decl) => {
121
+ const text = decl.string || ''
122
+ if (!text) return []
123
+ const issues = []
124
+ for (const term of JARGON_TERMS) {
125
+ const pattern = new RegExp(`\\b${term}\\b`, 'i')
126
+ if (pattern.test(text)) {
127
+ issues.push({ message: `Contains internal jargon "${term}". Expand or replace it; users do not know this term.` })
128
+ }
129
+ }
130
+ return issues
131
+ }
132
+ },
133
+ {
134
+ name: 'unbalanced-backticks',
135
+ description: 'Odd number of backticks ships broken markup',
136
+ severity: 'error',
137
+ check: (decl) => {
138
+ const text = decl.string || ''
139
+ const count = (text.match(/`/g) || []).length
140
+ if (count % 2 !== 0) {
141
+ return [{ message: `Unbalanced backticks (${count} found). The rendered page ships broken monospace markup.` }]
142
+ }
143
+ return []
144
+ }
145
+ }
146
+ ]
147
+
148
+ module.exports = { COMMON_RULES, JARGON_TERMS, isNameEcho, tokenize }
@@ -0,0 +1,108 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Rules for verbatim-AsciiDoc surfaces (properties, metrics, crd, connect).
5
+ *
6
+ * These strings ship to docs.redpanda.com with ZERO escaping: a raw `|`
7
+ * breaks the containing table cell, an unknown `{attr}` renders literally or
8
+ * trips attribute-missing handling, and a half-written macro ships as broken
9
+ * markup. rpk is exempt: its formatDescription() transformer rewrites the
10
+ * string before publication.
11
+ */
12
+
13
+ /**
14
+ * Product attributes that are legitimately available on rendered pages.
15
+ * Anything else inside `{...}` is not substituted and ships broken.
16
+ * Agent-extensible: add attributes here as surfaces grow.
17
+ */
18
+ const KNOWN_ATTRIBUTES = new Set([
19
+ 'latest-version',
20
+ 'latest-redpanda-version',
21
+ 'latest-redpanda-tag',
22
+ 'latest-console-version',
23
+ 'latest-operator-version',
24
+ 'latest-connect-version',
25
+ 'full-version',
26
+ 'page-version',
27
+ 'empty'
28
+ ])
29
+
30
+ /** AsciiDoc inline macros used in embedded doc strings. */
31
+ const MACRO_NAMES = ['xref', 'glossterm', 'config_ref']
32
+
33
+ /**
34
+ * Scan text for unescaped `|` characters outside backtick spans.
35
+ * Returns the count found.
36
+ */
37
+ function countRawPipes (text) {
38
+ let inBacktick = false
39
+ let count = 0
40
+ for (let i = 0; i < text.length; i++) {
41
+ const ch = text[i]
42
+ if (ch === '`') {
43
+ inBacktick = !inBacktick
44
+ continue
45
+ }
46
+ if (ch === '|' && !inBacktick && text[i - 1] !== '\\') count++
47
+ }
48
+ return count
49
+ }
50
+
51
+ const VERBATIM_ASCIIDOC_RULES = [
52
+ {
53
+ name: 'raw-pipe',
54
+ description: 'Raw unescaped | outside backticks breaks AsciiDoc tables',
55
+ severity: 'error',
56
+ check: (decl) => {
57
+ const text = decl.string || ''
58
+ if (!text.includes('|')) return []
59
+ const count = countRawPipes(text)
60
+ if (count > 0) {
61
+ return [{ message: `Contains ${count} raw unescaped "|" outside backticks. This string is rendered verbatim inside AsciiDoc tables; escape it (\\|) or wrap it in backticks.` }]
62
+ }
63
+ return []
64
+ }
65
+ },
66
+ {
67
+ name: 'unknown-attribute',
68
+ description: '{attr} reference that is not a known product attribute',
69
+ severity: 'warning',
70
+ check: (decl) => {
71
+ const text = decl.string || ''
72
+ const issues = []
73
+ const pattern = /\{([a-z][a-z0-9_-]*)\}/g
74
+ let match
75
+ while ((match = pattern.exec(text)) !== null) {
76
+ // A backslash-escaped \{...} is not substituted; authors escape
77
+ // deliberately (URL path templates, JSON pointers).
78
+ if (match.index > 0 && text[match.index - 1] === '\\') continue
79
+ if (!KNOWN_ATTRIBUTES.has(match[1])) {
80
+ issues.push({ message: `"{${match[1]}}" looks like an AsciiDoc attribute reference but is not a known product attribute. It will not be substituted on the rendered page.` })
81
+ }
82
+ }
83
+ return issues
84
+ }
85
+ },
86
+ {
87
+ name: 'broken-macro',
88
+ description: 'xref:/glossterm:/config_ref: macro with unbalanced brackets',
89
+ severity: 'error',
90
+ check: (decl) => {
91
+ const text = decl.string || ''
92
+ const issues = []
93
+ const pattern = new RegExp(`\\b(${MACRO_NAMES.join('|')}):`, 'g')
94
+ let match
95
+ while ((match = pattern.exec(text)) !== null) {
96
+ const rest = text.slice(match.index + match[0].length)
97
+ // Well-formed: <target>[<label>] with the target free of whitespace
98
+ // and brackets, and the label closed before end of string.
99
+ if (!/^[^\s[\]]*\[[^\]]*\]/.test(rest)) {
100
+ issues.push({ message: `Broken ${match[1]}: macro syntax near "${text.slice(match.index, match.index + 50)}". Expected ${match[1]}:target[label] with balanced brackets.` })
101
+ }
102
+ }
103
+ return issues
104
+ }
105
+ }
106
+ ]
107
+
108
+ module.exports = { VERBATIM_ASCIIDOC_RULES, KNOWN_ATTRIBUTES, MACRO_NAMES, countRawPipes }
@@ -0,0 +1,41 @@
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+
6
+ /**
7
+ * Shared helper for surface modules: read the exact source lines of a
8
+ * declaration span so findings can carry `declaration_text` (the text a PR
9
+ * suggestion block would replace).
10
+ */
11
+ class SourceCache {
12
+ constructor (repo) {
13
+ this.repo = repo
14
+ this.files = new Map()
15
+ }
16
+
17
+ lines (file) {
18
+ if (!this.files.has(file)) {
19
+ const absPath = path.isAbsolute(file) ? file : path.join(this.repo, file)
20
+ try {
21
+ this.files.set(file, fs.readFileSync(absPath, 'utf8').split('\n'))
22
+ } catch (err) {
23
+ this.files.set(file, null)
24
+ }
25
+ }
26
+ return this.files.get(file)
27
+ }
28
+
29
+ /**
30
+ * Exact source text of the inclusive 1-indexed span [lineStart, lineEnd],
31
+ * or null when the file or span is unavailable.
32
+ */
33
+ span (file, lineStart, lineEnd) {
34
+ if (lineStart == null || lineEnd == null) return null
35
+ const lines = this.lines(file)
36
+ if (!lines || lineStart < 1 || lineEnd > lines.length) return null
37
+ return lines.slice(lineStart - 1, lineEnd).join('\n')
38
+ }
39
+ }
40
+
41
+ module.exports = { SourceCache }
@@ -0,0 +1,233 @@
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+
6
+ 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')
16
+
17
+ /**
18
+ * Connect surface: service.NewConfigSpec() Summary/Description strings and
19
+ * service.NewXField(name).Description(...) chains in internal/impl.
20
+ *
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.
29
+ *
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.
33
+ */
34
+
35
+ const CONVENTION = {
36
+ case: 'sentence',
37
+ terminal_period: true,
38
+ verbatim_asciidoc: true
39
+ }
40
+
41
+ // Field constructors that ship with NO built-in documentation: a chain on
42
+ // one of these without .Description() publishes an empty field description.
43
+ // Composite helpers (NewTLSToggledField, NewAutoRetryNacksToggleField,
44
+ // NewBatchPolicyField, ...) carry their own docs and are exempt.
45
+ const BARE_FIELD_CTORS = new Set([
46
+ 'NewStringField', 'NewStringListField', 'NewStringMapField',
47
+ 'NewStringEnumField', 'NewStringAnnotatedEnumField',
48
+ 'NewIntField', 'NewIntListField', 'NewIntMapField',
49
+ 'NewFloatField', 'NewBoolField', 'NewDurationField',
50
+ 'NewInterpolatedStringField', 'NewInterpolatedStringListField',
51
+ 'NewInterpolatedStringMapField', 'NewInterpolatedStringEnumField',
52
+ 'NewBloblangField', 'NewAnyField', 'NewAnyListField', 'NewAnyMapField',
53
+ 'NewObjectField', 'NewObjectListField', 'NewURLField'
54
+ ])
55
+
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*,/
59
+
60
+ /**
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.
64
+ */
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
80
+ })
81
+ i = closeIndex + 1
82
+ }
83
+ return { calls, end: i }
84
+ }
85
+
86
+ /**
87
+ * Scan one file's content for connect declarations. Exported for tests.
88
+ */
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() }
119
+ })
120
+ }
121
+ }
122
+
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
130
+
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
134
+
135
+ const { calls, end } = readChain(masked, closeIndex + 1)
136
+ const descriptionCall = calls.find((c) => c.method === 'Description')
137
+
138
+ if (descriptionCall) {
139
+ const result = evalStringExpr(descriptionCall.args, consts)
140
+ if (result.verifiable) {
141
+ declarations.push({
142
+ surface: 'connect',
143
+ 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(),
148
+ declaration_text: null,
149
+ convention: CONVENTION,
150
+ meta: { kind: 'field', ctor }
151
+ })
152
+ }
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
+ }
170
+ // Resume INSIDE the constructor args: object fields nest child field
171
+ // constructors (NewObjectField("x", NewStringField("y")...)).
172
+ FIELD_CTOR.lastIndex = openIndex + 1
173
+ }
174
+
175
+ return declarations
176
+ }
177
+
178
+ /**
179
+ * Extract connect declarations.
180
+ *
181
+ * @param {Object} options - { repo, files (Set of repo-relative paths, diff
182
+ * mode; when omitted, scans internal/impl), log }
183
+ */
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
+
193
+ const cache = new SourceCache(repo)
194
+ 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)
202
+ declarations.push(decl)
203
+ }
204
+ }
205
+ return declarations
206
+ }
207
+
208
+ /** Surface-specific convention rules. */
209
+ const RULES = [
210
+ {
211
+ name: 'missing-field-description',
212
+ description: 'Config field constructed with no .Description()',
213
+ severity: 'warning',
214
+ check: (decl) => {
215
+ if (decl.meta.kind !== 'field' || !decl.meta.missing_description) return []
216
+ return [{ message: `Field ${decl.name ? `"${decl.name}" ` : ''}(${decl.meta.ctor}) has no .Description(). It ships as an undocumented field on the connector page.` }]
217
+ }
218
+ }
219
+ ]
220
+
221
+ module.exports = {
222
+ name: 'connect',
223
+ convention: CONVENTION,
224
+ extract,
225
+ scanFile,
226
+ rules: RULES,
227
+ // A connect description is the AsciiDoc page body, not a table cell:
228
+ // bare | is legitimate table syntax there, and missing prose is handled
229
+ // by the connect-specific missing-field-description warning. Brace
230
+ // placeholders ({endpoint}, {my-domain}) are the established URL-template
231
+ // idiom in connect descriptions, so the unknown-attribute check is noise.
232
+ skipRules: ['raw-pipe', 'empty-description', 'unknown-attribute']
233
+ }