@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.
- package/bin/doc-tools-mcp.js +88 -0
- package/bin/doc-tools.js +252 -102
- package/bin/mcp-tools/index.js +20 -0
- package/bin/mcp-tools/lint-doc-strings.js +75 -0
- package/bin/mcp-tools/overrides-audit.js +100 -0
- package/bin/mcp-tools/preview-doc-string.js +71 -0
- package/cli-utils/diff-utils.js +77 -0
- package/cli-utils/helm-commented-values.js +172 -23
- package/package.json +2 -1
- package/tools/generate-cli-docs.js +28 -67
- package/tools/lint-strings/diff.js +137 -0
- package/tools/lint-strings/engine.js +163 -0
- package/tools/lint-strings/fixtures/connect/lint_connect.go +67 -0
- package/tools/lint-strings/fixtures/crd/crd-ref-docs-config.yaml +15 -0
- package/tools/lint-strings/fixtures/crd/lint_types.go +57 -0
- package/tools/lint-strings/fixtures/helm/lint_values.yaml +47 -0
- package/tools/lint-strings/fixtures/metrics/lint_probe.cc +41 -0
- package/tools/lint-strings/fixtures/properties/lint_fixture.cc +51 -0
- package/tools/lint-strings/fixtures/properties/lint_fixture.h +33 -0
- package/tools/lint-strings/fixtures/rpk/lint_cmd.go +69 -0
- package/tools/lint-strings/go-source.js +327 -0
- package/tools/lint-strings/index.js +229 -0
- package/tools/lint-strings/rules/common.js +148 -0
- package/tools/lint-strings/rules/verbatim-asciidoc.js +108 -0
- package/tools/lint-strings/source-text.js +41 -0
- package/tools/lint-strings/surfaces/connect.js +233 -0
- package/tools/lint-strings/surfaces/crd.js +239 -0
- package/tools/lint-strings/surfaces/helm.js +159 -0
- package/tools/lint-strings/surfaces/metrics.js +368 -0
- package/tools/lint-strings/surfaces/properties.js +216 -0
- package/tools/lint-strings/surfaces/rpk.js +377 -0
- package/tools/overrides-audit/README.adoc +152 -0
- package/tools/overrides-audit/adapters/connect.js +144 -0
- package/tools/overrides-audit/adapters/properties.js +117 -0
- package/tools/overrides-audit/adapters/rpk.js +192 -0
- package/tools/overrides-audit/classify.js +546 -0
- package/tools/overrides-audit/index.js +185 -0
- package/tools/preview-string/index.js +245 -0
- package/tools/property-extractor/Makefile +9 -4
- package/tools/property-extractor/parser.py +26 -0
- package/tools/property-extractor/property_extractor.py +41 -0
- package/tools/property-extractor/transformers.py +15 -2
- package/tools/rpk-docs/detect-platform-commands.js +8 -1
- 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
|
+
}
|