@redpanda-data/docs-extensions-and-macros 5.56.1 → 5.58.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.js +95 -1
- package/extension-utils/property-values.js +255 -0
- package/macros/PROPERTY_AND_ENTERPRISE_REFERENCES.adoc +25 -1
- package/macros/README.adoc +3 -1
- package/macros/REFERENCE.adoc +158 -1
- package/macros/USER_GUIDE.adoc +28 -0
- package/macros/prop.js +135 -0
- package/package.json +1 -1
- package/tools/lint-strings/surfaces/connect.js +18 -1
- package/tools/migrate-property-refs.js +15 -12
- package/tools/migrate-property-values.js +1255 -0
- package/tools/property-extractor/README.adoc +4 -2
- package/tools/redpanda-connect/AUTOMATION.md +4 -2
- package/tools/redpanda-connect/multi-version-summary.js +2 -1
- package/tools/redpanda-connect/pr-summary-formatter.js +49 -8
- package/tools/redpanda-connect/report-delta.js +14 -5
- package/tools/redpanda-connect/rpcn-connector-docs-handler.js +17 -8
- package/tools/redpanda-release-notes/generate-release-notes.js +87 -48
- package/tools/redpanda-release-notes/parse-release-body.js +33 -4
- package/tools/redpanda-release-notes/property-names.js +65 -0
|
@@ -0,0 +1,1255 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/* Report hand-written property values in prose, and propose propval: macros.
|
|
4
|
+
*
|
|
5
|
+
* Finds sentences that mention a property (a prop: call or the backticked
|
|
6
|
+
* name) and state one of its values by hand -- "defaults to 128 MiB",
|
|
7
|
+
* "the default is `true`", "minimum 1 minute" -- and checks the written value
|
|
8
|
+
* against the property data for that repo's own version, chosen exactly the
|
|
9
|
+
* way the prop: macro chooses it (loadPropertiesFor in macros/prop.js).
|
|
10
|
+
*
|
|
11
|
+
* Every finding gets one status:
|
|
12
|
+
*
|
|
13
|
+
* exact The written text is exactly what propval: renders (human or
|
|
14
|
+
* raw form). Replacing it changes nothing a reader sees, so
|
|
15
|
+
* these are the only findings --write applies.
|
|
16
|
+
* equivalent Same value, different wording ("7 days" vs "1 week",
|
|
17
|
+
* "128 MB" vs "128 MiB"). Proposed, never applied.
|
|
18
|
+
* mismatch The written value differs from the data. Most likely a real
|
|
19
|
+
* doc error. Never applied.
|
|
20
|
+
* unset The data default is null (not set) but the prose states a
|
|
21
|
+
* value. Redpanda often computes a runtime fallback for an
|
|
22
|
+
* unset property (compaction_ctrl_backlog_size uses 10% of
|
|
23
|
+
* free disk), so a human confirms it against the source.
|
|
24
|
+
* unverifiable The value could not be compared: unparseable, the property
|
|
25
|
+
* has no such field, the data does not state it for this
|
|
26
|
+
* audience (Cloud defaults, ifdef::env-cloud[] blocks), or a
|
|
27
|
+
* topic property is said to have no default of its own.
|
|
28
|
+
* example-output A value shown in command output in a listing, literal or
|
|
29
|
+
* fenced block. Its verdict is stale (a row that asserts the
|
|
30
|
+
* default, such as SOURCE DEFAULT_CONFIG, shows another
|
|
31
|
+
* value), matches, or unverifiable with a reason. Counted and
|
|
32
|
+
* listed apart from prose findings, and never written.
|
|
33
|
+
*
|
|
34
|
+
* Tables are scanned: a statement in a cell belongs to the property that opens
|
|
35
|
+
* the row (or cell), and a Default column states the default of the row's
|
|
36
|
+
* property. Table findings are reported, never written.
|
|
37
|
+
*
|
|
38
|
+
* Skipped entirely: prose inside delimited blocks (listing, literal,
|
|
39
|
+
* passthrough, comment, fenced code; only command output in listing, literal
|
|
40
|
+
* and fenced blocks is read), headings, attribute entries, block titles, comment lines,
|
|
41
|
+
* ifndef::env-cloud[] content on a Cloud page, files carrying the
|
|
42
|
+
* generated-content marker, and the generated paths migrate-property-refs
|
|
43
|
+
* also excludes.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
const fs = require('fs')
|
|
47
|
+
const path = require('path')
|
|
48
|
+
const yaml = require('js-yaml')
|
|
49
|
+
const { loadPropertiesFor, isAvailable, describeDataset } = require('../macros/prop')
|
|
50
|
+
const { fieldValue, unitOf, FIELDS, FORMAT_RAW, FORMAT_HUMAN } = require('../extension-utils/property-values')
|
|
51
|
+
const { DEFAULT_EXCLUDE } = require('./migrate-property-refs')
|
|
52
|
+
|
|
53
|
+
const PROPERTIES_JSON_RX = /^redpanda-properties-(v\d+\.\d+\.\d+(?:-[\w.]+)?)\.json$/
|
|
54
|
+
const GENERATED_MARKER = 'This content is autogenerated. Do not edit manually.'
|
|
55
|
+
const SKIP_LINE_RX = /^(=+\s|:[-\w]+!?:|\.[^.\s]|\/\/)/
|
|
56
|
+
const DELIMITER_RX = /^(-{4,}|\.{4,}|\/{4,}|\+{4,}|`{3,}.*)\s*$/
|
|
57
|
+
const TABLE_RX = /^[|!,:]={3,}\s*$/
|
|
58
|
+
// A nested table (!===) opens and closes inside a cell of the outer table.
|
|
59
|
+
const NESTED_TABLE_RX = /^!={3,}\s*$/
|
|
60
|
+
// A tagged region: an including page can take this part of the file alone,
|
|
61
|
+
// as cloud-docs does with include::streaming:...[tag=...].
|
|
62
|
+
const TAG_RX = /^\s*\/\/\s*tag::[^[\s]+\[\]\s*$/m
|
|
63
|
+
const CONDITIONAL_RX = /^(ifdef|ifndef)::([^[\s]*)\[(.*)\]\s*$/
|
|
64
|
+
const IFEVAL_RX = /^ifeval::\[.*\]\s*$/
|
|
65
|
+
const ENDIF_RX = /^endif::[^[\s]*\[\]\s*$/
|
|
66
|
+
// A table line that starts a cell: |, or a cell spec such as a|, 2+|, 3*|.
|
|
67
|
+
const CELL_START_RX = /^\s*(?:\d+(?:\.\d+)?[+*])?[adehlmsv]?(?=\|)\|/
|
|
68
|
+
const HISTORICAL_RX = /release-notes|incompat-changes|whats-new|upgrade\//
|
|
69
|
+
|
|
70
|
+
// A property mention: prop:name[...] or `name`.
|
|
71
|
+
const MENTION_RX = /prop:([A-Za-z][A-Za-z0-9_.]*)\[[^\]]*\]|(?<![`\\])`([A-Za-z][A-Za-z0-9_.]*)`(?!`)/g
|
|
72
|
+
|
|
73
|
+
// Value phrases. Each captures the text after the phrase, which parseValue
|
|
74
|
+
// then reads one value from.
|
|
75
|
+
const PHRASES = [
|
|
76
|
+
{ field: 'default', rx: /\b(?:defaults?\s+to|default\s+(?:value\s+)?(?:is|of|=|:)|default\s+for\s+(?=prop:|xref:|`)|default\s*:|default\s+[-\u2013]\s+|is\s+set\s+to\s+(?=\S+\s+by\s+default)|by\s+default\s+(?:is|it\s+is)|default\s+setting\s+is)\s*/gi },
|
|
77
|
+
{ field: 'min', rx: /\b(?:minimum|min\.?)(?:\s+(?:value|allowed\s+value))?\s*(?:is|of|=|:)?\s+(?:of\s+)?/gi },
|
|
78
|
+
{ field: 'max', rx: /\b(?:maximum|max\.?)(?:\s+(?:value|allowed\s+value))?\s*(?:is|of|=|:)?\s+(?:of\s+)?/gi },
|
|
79
|
+
]
|
|
80
|
+
// The phrase must start within this many characters after the mention, with
|
|
81
|
+
// no sentence boundary and no other property mention in between.
|
|
82
|
+
const WINDOW = 120
|
|
83
|
+
|
|
84
|
+
const BYTE_UNITS = {
|
|
85
|
+
b: 1, byte: 1, bytes: 1,
|
|
86
|
+
kb: 1000, kib: 1024, mb: 1000 ** 2, mib: 1024 ** 2, gb: 1000 ** 3, gib: 1024 ** 3, tb: 1000 ** 4, tib: 1024 ** 4,
|
|
87
|
+
}
|
|
88
|
+
// Docs commonly write KB/MB/GB for binary sizes, so those also match binary.
|
|
89
|
+
const BYTE_BINARY_ALIAS = { kb: 1024, mb: 1024 ** 2, gb: 1024 ** 3, tb: 1024 ** 4 }
|
|
90
|
+
const TIME_UNITS_MS = {
|
|
91
|
+
ms: 1, millisecond: 1, milliseconds: 1,
|
|
92
|
+
s: 1000, sec: 1000, secs: 1000, second: 1000, seconds: 1000,
|
|
93
|
+
m: 60000, min: 60000, mins: 60000, minute: 60000, minutes: 60000,
|
|
94
|
+
h: 3600000, hr: 3600000, hrs: 3600000, hour: 3600000, hours: 3600000,
|
|
95
|
+
d: 86400000, day: 86400000, days: 86400000,
|
|
96
|
+
w: 604800000, week: 604800000, weeks: 604800000,
|
|
97
|
+
y: 31536000000, year: 31536000000, years: 31536000000,
|
|
98
|
+
}
|
|
99
|
+
const NULLISH = new Set(['null', 'none', 'no default', 'not set', 'unset', 'no limit', 'empty', 'nothing'])
|
|
100
|
+
|
|
101
|
+
const norm = (text) => String(text).replace(/`/g, '').replace(/\s+/g, ' ').trim()
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Read one written value from the start of `text`.
|
|
105
|
+
*
|
|
106
|
+
* @returns {undefined | {written: string, length: number, backticked: boolean,
|
|
107
|
+
* kind: 'number'|'null'|'bool'|'string', number?: number, unit?: string,
|
|
108
|
+
* unitKind?: 'bytes'|'time', bool?: boolean, string?: string}}
|
|
109
|
+
*/
|
|
110
|
+
function parseValue (text) {
|
|
111
|
+
const backtick = text.match(/^`([^`]+)`/)
|
|
112
|
+
if (backtick) {
|
|
113
|
+
const inner = parseValue(backtick[1])
|
|
114
|
+
const base = { written: backtick[0], length: backtick[0].length, backticked: true }
|
|
115
|
+
if (inner && inner.length === backtick[1].length) return { ...inner, ...base }
|
|
116
|
+
return { ...base, kind: 'string', string: backtick[1] }
|
|
117
|
+
}
|
|
118
|
+
const quoted = text.match(/^"([^"]*)"/)
|
|
119
|
+
if (quoted) return { written: quoted[0], length: quoted[0].length, backticked: false, kind: 'string', string: quoted[1] }
|
|
120
|
+
const nullish = text.match(/^(no default|not set|unset|no limit|null|none|empty)\b/i)
|
|
121
|
+
if (nullish) return { written: nullish[0], length: nullish[0].length, backticked: false, kind: 'null' }
|
|
122
|
+
const bool = text.match(/^(true|false|enabled|disabled|on|off)(?![-\w])/i)
|
|
123
|
+
if (bool) {
|
|
124
|
+
const word = bool[1].toLowerCase()
|
|
125
|
+
return { written: bool[0], length: bool[0].length, backticked: false, kind: 'bool', bool: ['true', 'enabled', 'on'].includes(word), string: bool[1] }
|
|
126
|
+
}
|
|
127
|
+
const number = text.match(/^(-?\d(?:[,_']?\d)*(?:\.\d+)?)(?:\s*([A-Za-z]+)\b)?/)
|
|
128
|
+
if (number) {
|
|
129
|
+
const value = Number(number[1].replace(/[,_']/g, ''))
|
|
130
|
+
if (Number.isNaN(value)) return undefined
|
|
131
|
+
if (text.slice(number[1].length).startsWith('%')) {
|
|
132
|
+
return { written: `${number[1]}%`, length: number[1].length + 1, backticked: false, kind: 'number', number: value, unit: '%', unitKind: 'percent' }
|
|
133
|
+
}
|
|
134
|
+
const unit = number[2] && number[2].toLowerCase()
|
|
135
|
+
if (unit && BYTE_UNITS[unit] !== undefined) {
|
|
136
|
+
return { written: number[0], length: number[0].length, backticked: false, kind: 'number', number: value, unit, unitKind: 'bytes' }
|
|
137
|
+
}
|
|
138
|
+
if (unit && TIME_UNITS_MS[unit] !== undefined) {
|
|
139
|
+
return { written: number[0], length: number[0].length, backticked: false, kind: 'number', number: value, unit, unitKind: 'time' }
|
|
140
|
+
}
|
|
141
|
+
const bare = number[1]
|
|
142
|
+
return { written: bare, length: bare.length, backticked: false, kind: 'number', number: value }
|
|
143
|
+
}
|
|
144
|
+
const word = text.match(/^([A-Za-z_][\w.-]*[\w])/)
|
|
145
|
+
if (word) return { written: word[0], length: word[0].length, backticked: false, kind: 'string', string: word[1], bareWord: true }
|
|
146
|
+
return undefined
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Compare a written value with one data value.
|
|
151
|
+
*
|
|
152
|
+
* @returns {{status: 'match'|'mismatch'|'unverifiable', note?: string}}
|
|
153
|
+
*/
|
|
154
|
+
function compareValue (parsed, dataValue, entry) {
|
|
155
|
+
// Topic properties such as retention.ms carry no units and formatUnits
|
|
156
|
+
// reads only an _ms suffix, so a .ms suffix is milliseconds too.
|
|
157
|
+
const unit = unitOf(entry) || (/\.ms$/.test(entry && entry.name) ? 'milliseconds' : '')
|
|
158
|
+
if (dataValue === null || dataValue === undefined || dataValue === '') {
|
|
159
|
+
if (parsed.kind === 'null') return { status: 'match' }
|
|
160
|
+
// "disabled" often means "null, so the feature is off".
|
|
161
|
+
if (parsed.kind === 'bool' && !parsed.bool) return { status: 'unverifiable', note: 'written as disabled; the data default is null' }
|
|
162
|
+
// Not set is not "no value": Redpanda often falls back to a computed
|
|
163
|
+
// value at runtime, which the prose may be describing.
|
|
164
|
+
return { status: 'unset', note: `not set in the data; the prose states ${norm(parsed.written)}, which may be a runtime fallback` }
|
|
165
|
+
}
|
|
166
|
+
if (parsed.unitKind === 'percent' && unit !== 'percent') {
|
|
167
|
+
return { status: 'unverifiable', note: 'written as a percentage' }
|
|
168
|
+
}
|
|
169
|
+
if (typeof dataValue === 'boolean') {
|
|
170
|
+
if (parsed.kind === 'bool') return { status: parsed.bool === dataValue ? 'match' : 'mismatch' }
|
|
171
|
+
if (parsed.kind === 'string' && /^(true|false)$/i.test(parsed.string)) return { status: (parsed.string.toLowerCase() === String(dataValue)) ? 'match' : 'mismatch' }
|
|
172
|
+
return { status: 'unverifiable', note: 'written value is not a boolean' }
|
|
173
|
+
}
|
|
174
|
+
if (typeof dataValue === 'number') {
|
|
175
|
+
if (parsed.kind === 'null') return { status: 'mismatch' }
|
|
176
|
+
if (parsed.kind !== 'number') return { status: 'unverifiable', note: 'written value is not a number' }
|
|
177
|
+
if (parsed.unitKind === 'time') {
|
|
178
|
+
const scale = unit === 'milliseconds' ? 1 : unit === 'seconds' ? 1000 : undefined
|
|
179
|
+
if (!scale) return { status: 'unverifiable', note: 'written as a duration, but the data gives no time unit for this property' }
|
|
180
|
+
const writtenMs = parsed.number * TIME_UNITS_MS[parsed.unit]
|
|
181
|
+
return { status: Math.abs(writtenMs - dataValue * scale) < 1e-6 ? 'match' : 'mismatch' }
|
|
182
|
+
}
|
|
183
|
+
if (parsed.unitKind === 'bytes') {
|
|
184
|
+
if (unit && unit !== 'bytes') return { status: 'unverifiable', note: `written in bytes, but the data's unit is ${unit}` }
|
|
185
|
+
const candidates = [BYTE_UNITS[parsed.unit], BYTE_BINARY_ALIAS[parsed.unit]].filter(Boolean)
|
|
186
|
+
const ok = candidates.some((factor) => Math.abs(parsed.number * factor - dataValue) < 0.5)
|
|
187
|
+
const note = unit ? undefined : 'the data gives no unit for this property; compared as bytes because the text is written in bytes'
|
|
188
|
+
return { status: ok ? 'match' : 'mismatch', ...(note ? { note } : {}) }
|
|
189
|
+
}
|
|
190
|
+
return { status: parsed.number === dataValue ? 'match' : 'mismatch' }
|
|
191
|
+
}
|
|
192
|
+
if (typeof dataValue === 'string') {
|
|
193
|
+
// An enum value can look like a keyword: 'none', 'disabled', 'true'.
|
|
194
|
+
if (norm(parsed.written).toLowerCase() === dataValue.toLowerCase()) return { status: 'match' }
|
|
195
|
+
if (parsed.kind === 'string' || parsed.kind === 'bool') {
|
|
196
|
+
const written = parsed.string
|
|
197
|
+
return { status: written.toLowerCase() === dataValue.toLowerCase() ? 'match' : 'mismatch' }
|
|
198
|
+
}
|
|
199
|
+
if (parsed.kind === 'number') return { status: parsed.written === dataValue ? 'match' : 'mismatch' }
|
|
200
|
+
if (parsed.kind === 'null') return { status: 'mismatch' }
|
|
201
|
+
}
|
|
202
|
+
return { status: 'unverifiable', note: 'the data value is a list or object' }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The data value a field compares against.
|
|
207
|
+
*/
|
|
208
|
+
function dataValueFor (entry, field) {
|
|
209
|
+
if (field === 'default') return { present: Object.prototype.hasOwnProperty.call(entry, 'default'), value: entry.default }
|
|
210
|
+
const key = field === 'min' ? 'minimum' : 'maximum'
|
|
211
|
+
return { present: entry[key] !== undefined && entry[key] !== null, value: entry[key] }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The propval: call that renders `written` exactly, if any: human first,
|
|
216
|
+
* then raw. Undefined when neither rendering equals the written text.
|
|
217
|
+
*/
|
|
218
|
+
function exactCall (name, field, entry, parsed) {
|
|
219
|
+
const target = norm(parsed.written)
|
|
220
|
+
const human = fieldValue(entry, field, { format: FORMAT_HUMAN })
|
|
221
|
+
if (human.ok && norm(human.text) === target) {
|
|
222
|
+
return { call: `propval:${name}[${field}]`, rendered: human.text }
|
|
223
|
+
}
|
|
224
|
+
const raw = fieldValue(entry, field, { format: FORMAT_RAW })
|
|
225
|
+
if (raw.ok && norm(raw.text) === target) {
|
|
226
|
+
return { call: `propval:${name}[${field},format=raw]`, rendered: raw.text }
|
|
227
|
+
}
|
|
228
|
+
return undefined
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// xref:...properties/...adoc#anchor[label]: the anchor names the property
|
|
232
|
+
// (dots become hyphens in topic property anchors).
|
|
233
|
+
const XREF_RX = /xref:[^[\s]*propert[^[\s]*#([\w.-]+)\[[^\]]*\]/g
|
|
234
|
+
// Sentence boundary: terminal punctuation, then whitespace, then something
|
|
235
|
+
// that starts a sentence.
|
|
236
|
+
const SENTENCE_RX = /[.!?](?=\s+(?:[A-Z*`(]|prop:|xref:))/g
|
|
237
|
+
// A sentence that qualifies the statement to another audience, release, or
|
|
238
|
+
// tool says nothing about this page's own default, so it is never compared
|
|
239
|
+
// as if it did.
|
|
240
|
+
const QUALIFIER_RX = /\b(?:earlier|prior|previous|older)\s+(?:versions?|releases?)\b|\b(?:before|until|prior to)\s+(?:Redpanda\s+)?v?\d+\.\d+|\bin\s+(?:Redpanda\s+)?v?\d+\.\d+\b|\bRedpanda Cloud\b|\bBYOC\b|\bDedicated\b|\bServerless\b|\bHelm\b|\bchart\b|\bclients?\b|\blibrar(?:y|ies)\b|\bdev-container\b|\bdev mode\b|\bKubernetes\b|\boperator\b/i
|
|
241
|
+
// Macro attribute lists (prop:x[link=true,helm-path=auto]) are not prose, so
|
|
242
|
+
// they never qualify a sentence.
|
|
243
|
+
const stripMacroAttributes = (text) => text.replace(/\b(prop|propval):([A-Za-z][A-Za-z0-9_.]*)\[[^\]]*\]/g, '$1:$2[]')
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Every property mention on a line: prop: calls, property xrefs, and
|
|
247
|
+
* backticked names (outside any xref already counted).
|
|
248
|
+
*/
|
|
249
|
+
function findMentions (line, registry, requireSeparator) {
|
|
250
|
+
const has = (name) => Object.prototype.hasOwnProperty.call(registry.properties, name)
|
|
251
|
+
const mentions = []
|
|
252
|
+
for (const m of line.matchAll(XREF_RX)) {
|
|
253
|
+
const anchor = m[1]
|
|
254
|
+
const name = has(anchor) ? anchor : has(anchor.replace(/-/g, '.')) ? anchor.replace(/-/g, '.') : undefined
|
|
255
|
+
mentions.push({ name, start: m.index, end: m.index + m[0].length })
|
|
256
|
+
}
|
|
257
|
+
const inside = (index) => mentions.some((x) => index >= x.start && index < x.end)
|
|
258
|
+
for (const m of line.matchAll(MENTION_RX)) {
|
|
259
|
+
if (inside(m.index)) continue
|
|
260
|
+
const name = m[1] || m[2]
|
|
261
|
+
if (!has(name)) continue
|
|
262
|
+
if (!m[1] && requireSeparator && !/[_.]/.test(name)) continue
|
|
263
|
+
mentions.push({ name, start: m.index, end: m.index + m[0].length })
|
|
264
|
+
}
|
|
265
|
+
return mentions.filter((m) => m.name).sort((a, b) => a.start - b.start)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function sentenceBounds (line) {
|
|
269
|
+
const starts = [0]
|
|
270
|
+
for (const m of line.matchAll(SENTENCE_RX)) starts.push(m.index + 1)
|
|
271
|
+
const sentenceAt = (index) => {
|
|
272
|
+
let i = 0
|
|
273
|
+
while (i + 1 < starts.length && starts[i + 1] <= index) i++
|
|
274
|
+
return i
|
|
275
|
+
}
|
|
276
|
+
const text = (first, last) => line.slice(starts[first], last + 1 < starts.length ? starts[last + 1] : line.length)
|
|
277
|
+
return { sentenceAt, text }
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Scan one line for value statements. Exported for unit testing.
|
|
282
|
+
*
|
|
283
|
+
* A statement belongs to the nearest property mention before it, in the same
|
|
284
|
+
* sentence or the one before ("Set prop:x[]. The default is 5."), provided no
|
|
285
|
+
* other property is mentioned in between.
|
|
286
|
+
*
|
|
287
|
+
* @param {string} line
|
|
288
|
+
* @param {object} ctx
|
|
289
|
+
* @param {object} ctx.registry - From loadPropertiesFor.
|
|
290
|
+
* @param {boolean} [ctx.requireSeparator] - Only treat backticked names that
|
|
291
|
+
* contain _ or . as mentions (prop: calls and xrefs always count). Default
|
|
292
|
+
* true, the rule migrate-property-refs uses for word-like names.
|
|
293
|
+
* @returns {object[]} findings, with column offsets of the value.
|
|
294
|
+
*/
|
|
295
|
+
function scanLine (line, { registry, requireSeparator = true, subject: rowSubject, qualifier: blockQualifier }) {
|
|
296
|
+
const findings = []
|
|
297
|
+
const mentions = findMentions(line, registry, requireSeparator)
|
|
298
|
+
if (!mentions.length && !rowSubject) return findings
|
|
299
|
+
const { sentenceAt, text } = sentenceBounds(line)
|
|
300
|
+
const claimed = new Set()
|
|
301
|
+
const between = (a, b) => mentions.filter((m) => m.start >= a && m.start < b)
|
|
302
|
+
|
|
303
|
+
const add = (mention, field, phrase, valueStart, association) => {
|
|
304
|
+
if (claimed.has(valueStart)) return
|
|
305
|
+
const parsed = parseValue(line.slice(valueStart))
|
|
306
|
+
if (!parsed) return
|
|
307
|
+
const entry = registry.properties[mention.name]
|
|
308
|
+
// A bare word only counts for string-valued properties; otherwise
|
|
309
|
+
// "defaults to the value of ..." would read "the" as a value.
|
|
310
|
+
if (parsed.bareWord && typeof entry.default !== 'string') return
|
|
311
|
+
// "defaults to `other_property`" is a reference, not a value.
|
|
312
|
+
if (parsed.kind === 'string' && parsed.string !== entry.default &&
|
|
313
|
+
Object.prototype.hasOwnProperty.call(registry.properties, parsed.string)) return
|
|
314
|
+
claimed.add(valueStart)
|
|
315
|
+
const context = text(sentenceAt(mention.start), sentenceAt(valueStart))
|
|
316
|
+
// A conditional ("If the cluster default is X") states no default.
|
|
317
|
+
const own = text(sentenceAt(valueStart), sentenceAt(valueStart))
|
|
318
|
+
const qualifier = stripMacroAttributes(context).match(QUALIFIER_RX) || own.match(/^\s*(?:If|When|Unless|Suppose)\b/) || (blockQualifier ? [blockQualifier] : null)
|
|
319
|
+
// Other properties on the line (the paragraph), or a list entry about
|
|
320
|
+
// another property, make the binding a guess, so it is never auto-safe.
|
|
321
|
+
const named = new Set([mention.name])
|
|
322
|
+
for (const m of mentions) named.add(m.name)
|
|
323
|
+
if (subject && subject.name) named.add(subject.name)
|
|
324
|
+
findings.push({
|
|
325
|
+
name: mention.name,
|
|
326
|
+
field,
|
|
327
|
+
phrase,
|
|
328
|
+
parsed,
|
|
329
|
+
start: valueStart,
|
|
330
|
+
end: valueStart + parsed.length,
|
|
331
|
+
association,
|
|
332
|
+
soleProperty: named.size === 1,
|
|
333
|
+
...(qualifier ? { qualifier: qualifier[0].trim() } : {}),
|
|
334
|
+
})
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// A list item or description-list entry that opens with a name
|
|
338
|
+
// ("* `x`: ...", "* prop:x[]. Default: 5") is about that name, so a
|
|
339
|
+
// statement in a later sentence of the entry belongs to it, not to whatever
|
|
340
|
+
// property the entry mentions along the way. A subject that is not a
|
|
341
|
+
// property (a Helm value, a client setting) claims the statement too, and
|
|
342
|
+
// then nothing is reported.
|
|
343
|
+
const subjectMatch = line.match(/^\s*(?:[*.-]+\s+|\d+\.\s+)?(?=prop:|xref:|`)/)
|
|
344
|
+
let subject
|
|
345
|
+
if (subjectMatch) {
|
|
346
|
+
const at = subjectMatch[0].length
|
|
347
|
+
subject = mentions.find((m) => m.start === at) || (/^`[^`]+`/.test(line.slice(at)) ? { name: undefined, start: at, end: at + line.slice(at).match(/^`[^`]+`/)[0].length } : undefined)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// The property a statement at `index` belongs to: the nearest mention
|
|
351
|
+
// before it in the same sentence, else (one sentence later) the entry's
|
|
352
|
+
// subject or the nearest mention, with no other property in between.
|
|
353
|
+
const ownerBefore = (index) => {
|
|
354
|
+
const before = mentions.filter((m) => m.end <= index)
|
|
355
|
+
const mention = before[before.length - 1]
|
|
356
|
+
if (mention && sentenceAt(index) === sentenceAt(mention.end) && index - mention.end <= 200 &&
|
|
357
|
+
!between(mention.end, index).some((m) => m.name !== mention.name)) {
|
|
358
|
+
return { mention, association: 'same-sentence' }
|
|
359
|
+
}
|
|
360
|
+
// In a table, anything else in the cell is about the row's property.
|
|
361
|
+
if (rowSubject) return { mention: { name: rowSubject.name, start: 0, end: 0 }, association: rowSubject.association }
|
|
362
|
+
if (subject && subject.end <= index && sentenceAt(index) > sentenceAt(subject.end)) {
|
|
363
|
+
return subject.name ? { mention: subject, association: 'list-subject' } : undefined
|
|
364
|
+
}
|
|
365
|
+
if (!mention || index - mention.end > 200) return undefined
|
|
366
|
+
if (between(mention.end, index).some((m) => m.name !== mention.name)) return undefined
|
|
367
|
+
if (sentenceAt(index) - sentenceAt(mention.end) !== 1) return undefined
|
|
368
|
+
return { mention, association: 'previous-sentence' }
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
for (const phrase of PHRASES) {
|
|
372
|
+
for (const p of line.matchAll(phrase.rx)) {
|
|
373
|
+
const valueStart = p.index + p[0].length
|
|
374
|
+
// "the default value of `x` is 5" / "the default for prop:x[] is 5".
|
|
375
|
+
if (/(?:of|for)\s*$/i.test(p[0]) || /^(?:of|for)\s/i.test(line.slice(valueStart))) {
|
|
376
|
+
const rest = line.slice(valueStart).replace(/^(?:of|for)\s+/i, '')
|
|
377
|
+
const offset = line.length - rest.length
|
|
378
|
+
const next = mentions.find((m) => m.start === offset)
|
|
379
|
+
if (next) {
|
|
380
|
+
const tail = line.slice(next.end).match(/^\s*(?:is|=|:|,)\s*/)
|
|
381
|
+
if (tail) add(next, phrase.field, p[0].trim(), next.end + tail[0].length, 'same-sentence')
|
|
382
|
+
continue
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
const owner = ownerBefore(p.index)
|
|
386
|
+
if (owner) add(owner.mention, phrase.field, p[0].trim(), valueStart, owner.association)
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// "`x` is `true` by default", "is set to 5 by default".
|
|
391
|
+
for (const m of line.matchAll(/\b(?:is|are)\s+(?:set\s+to\s+)?(`[^`]+`|enabled|disabled|true|false|unset|-?\d(?:[,_']?\d)*(?:\.\d+)?(?:\s*[A-Za-z%]+)?)\s+by\s+default\b/gi)) {
|
|
392
|
+
const valueStart = m.index + m[0].lastIndexOf(m[1])
|
|
393
|
+
const owner = ownerBefore(m.index)
|
|
394
|
+
if (owner && owner.association === 'same-sentence') add(owner.mention, 'default', 'by default', valueStart, 'same-sentence')
|
|
395
|
+
}
|
|
396
|
+
// "By default, prop:x[] is set to `true`."
|
|
397
|
+
for (const m of line.matchAll(/\bby default,?\s+/gi)) {
|
|
398
|
+
const after = m.index + m[0].length
|
|
399
|
+
const mention = mentions.find((x) => x.start >= after && x.start - after <= 40)
|
|
400
|
+
if (!mention || sentenceAt(mention.start) !== sentenceAt(m.index)) continue
|
|
401
|
+
const tail = line.slice(mention.end).match(/^(?:\s+(?:cluster|topic|broker|configuration|config|property|properties|tunable))*\s+(?:is|are)\s+(?:set\s+to\s+)?/i)
|
|
402
|
+
if (tail) add(mention, 'default', 'by default', mention.end + tail[0].length, 'same-sentence')
|
|
403
|
+
}
|
|
404
|
+
// "By default, the property is empty." in a row or entry about one property.
|
|
405
|
+
for (const m of line.matchAll(/\bby default,?\s+(?:the|this)\s+property\s+is\s+(?:set\s+to\s+)?/gi)) {
|
|
406
|
+
const owner = ownerBefore(m.index)
|
|
407
|
+
if (owner) add(owner.mention, 'default', 'by default', m.index + m[0].length, owner.association)
|
|
408
|
+
}
|
|
409
|
+
// "No default", "There is no default.": a statement that the value is unset.
|
|
410
|
+
for (const m of line.matchAll(/\b[Nn]o default(?=\s*(?:[.,;)]|$))/g)) {
|
|
411
|
+
const owner = ownerBefore(m.index)
|
|
412
|
+
if (owner) add(owner.mention, 'default', 'no default', m.index, owner.association)
|
|
413
|
+
}
|
|
414
|
+
return findings
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Classify one finding against the data.
|
|
419
|
+
*/
|
|
420
|
+
function classify (finding, registry) {
|
|
421
|
+
const entry = registry.properties[finding.name]
|
|
422
|
+
const out = {
|
|
423
|
+
property: finding.name,
|
|
424
|
+
field: finding.field,
|
|
425
|
+
phrase: finding.phrase,
|
|
426
|
+
written: finding.parsed.written,
|
|
427
|
+
association: finding.association || 'same-sentence',
|
|
428
|
+
autoSafe: false,
|
|
429
|
+
}
|
|
430
|
+
const human = fieldValue(entry, finding.field, { format: FORMAT_HUMAN })
|
|
431
|
+
const raw = fieldValue(entry, finding.field, { format: FORMAT_RAW })
|
|
432
|
+
if (human.ok) out.dataValue = human.text
|
|
433
|
+
if (raw.ok) out.dataRaw = raw.text
|
|
434
|
+
const cloud = registry.surface === 'cloud'
|
|
435
|
+
if (!isAvailable(registry, entry)) {
|
|
436
|
+
out.status = 'unverifiable'
|
|
437
|
+
out.reason = 'cloud-unavailable'
|
|
438
|
+
out.note = 'cloud_supported: false in the data, so propval: renders nothing on this page'
|
|
439
|
+
return out
|
|
440
|
+
}
|
|
441
|
+
const { present, value } = dataValueFor(entry, finding.field)
|
|
442
|
+
if (!present) {
|
|
443
|
+
out.status = 'unverifiable'
|
|
444
|
+
out.reason = 'no-such-field'
|
|
445
|
+
out.note = `the data has no ${finding.field} for this property`
|
|
446
|
+
return out
|
|
447
|
+
}
|
|
448
|
+
const compared = compareValue(finding.parsed, value, entry)
|
|
449
|
+
if (compared.note) out.note = compared.note
|
|
450
|
+
if (finding.qualifier) {
|
|
451
|
+
// The sentence is about another audience or release ("In Redpanda Cloud",
|
|
452
|
+
// "in earlier versions"), so this page's data cannot confirm or refute it.
|
|
453
|
+
out.status = 'unverifiable'
|
|
454
|
+
out.reason = compared.status === 'mismatch' ? 'qualified-differs' : 'qualified'
|
|
455
|
+
out.qualifier = finding.qualifier
|
|
456
|
+
return out
|
|
457
|
+
}
|
|
458
|
+
if (compared.status === 'unset') {
|
|
459
|
+
out.status = 'unset'
|
|
460
|
+
return out
|
|
461
|
+
}
|
|
462
|
+
if (cloud && finding.field === 'default') {
|
|
463
|
+
// The Cloud reference states no defaults, so propval: renders a
|
|
464
|
+
// placeholder here. Still compare with the self-managed default: a
|
|
465
|
+
// difference is worth a look, though it may be a Cloud-specific value.
|
|
466
|
+
out.status = 'unverifiable'
|
|
467
|
+
out.reason = compared.status === 'mismatch' ? 'cloud-default-differs-from-self-managed' : 'cloud-default'
|
|
468
|
+
return out
|
|
469
|
+
}
|
|
470
|
+
if (compared.status === 'mismatch' && finding.parsed.kind === 'null' && entry.config_scope === 'topic' && entry.corresponding_cluster_property) {
|
|
471
|
+
// "No default" for a topic property is true of the topic: it has no value
|
|
472
|
+
// of its own and inherits the cluster property's.
|
|
473
|
+
out.status = 'unverifiable'
|
|
474
|
+
out.reason = 'inherits-cluster-default'
|
|
475
|
+
out.note = `a topic property inherits ${entry.corresponding_cluster_property} when not set`
|
|
476
|
+
return out
|
|
477
|
+
}
|
|
478
|
+
if (compared.status === 'mismatch') {
|
|
479
|
+
out.status = 'mismatch'
|
|
480
|
+
return out
|
|
481
|
+
}
|
|
482
|
+
if (compared.status === 'unverifiable') {
|
|
483
|
+
const exact = exactCall(finding.name, finding.field, entry, finding.parsed)
|
|
484
|
+
if (exact) {
|
|
485
|
+
out.status = 'exact'
|
|
486
|
+
out.proposed = finding.parsed.backticked ? `\`${exact.call}\`` : exact.call
|
|
487
|
+
out.autoSafe = out.association === 'same-sentence'
|
|
488
|
+
delete out.note
|
|
489
|
+
return out
|
|
490
|
+
}
|
|
491
|
+
out.status = 'unverifiable'
|
|
492
|
+
out.reason = 'not-comparable'
|
|
493
|
+
return out
|
|
494
|
+
}
|
|
495
|
+
const exact = exactCall(finding.name, finding.field, entry, finding.parsed)
|
|
496
|
+
if (exact) {
|
|
497
|
+
out.status = 'exact'
|
|
498
|
+
out.proposed = finding.parsed.backticked ? `\`${exact.call}\`` : exact.call
|
|
499
|
+
// Only a statement in the same sentence as its property is applied
|
|
500
|
+
// automatically; one tied to an earlier sentence is a proposal.
|
|
501
|
+
out.autoSafe = out.association === 'same-sentence'
|
|
502
|
+
} else {
|
|
503
|
+
out.status = 'equivalent'
|
|
504
|
+
out.proposed = `propval:${finding.name}[${finding.field}]`
|
|
505
|
+
}
|
|
506
|
+
return out
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Read an Antora component descriptor and its property attachments into the
|
|
511
|
+
* file shapes loadPropertiesFor reads from a content catalog.
|
|
512
|
+
*/
|
|
513
|
+
function readComponent (repoDir) {
|
|
514
|
+
const descriptor = yaml.load(fs.readFileSync(path.join(repoDir, 'antora.yml'), 'utf8')) || {}
|
|
515
|
+
const component = String(descriptor.name || '')
|
|
516
|
+
const version = descriptor.version === null || descriptor.version === undefined ? '' : String(descriptor.version)
|
|
517
|
+
const attributes = (descriptor.asciidoc && descriptor.asciidoc.attributes) || {}
|
|
518
|
+
const dir = path.join(repoDir, 'modules', 'reference', 'attachments')
|
|
519
|
+
const files = []
|
|
520
|
+
if (fs.existsSync(dir)) {
|
|
521
|
+
for (const name of fs.readdirSync(dir)) {
|
|
522
|
+
if (!PROPERTIES_JSON_RX.test(name)) continue
|
|
523
|
+
files.push({
|
|
524
|
+
src: { component, version, module: 'reference', family: 'attachment', relative: name },
|
|
525
|
+
contents: fs.readFileSync(path.join(dir, name)),
|
|
526
|
+
})
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
return { component, version, attributes, files }
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* A minimal content catalog over one or more component checkouts, enough for
|
|
534
|
+
* loadPropertiesFor to select a dataset exactly as it does in an Antora build.
|
|
535
|
+
*/
|
|
536
|
+
function catalogFor (components) {
|
|
537
|
+
const files = components.flatMap((c) => c.files)
|
|
538
|
+
return {
|
|
539
|
+
findBy: (query) => files.filter((file) => Object.entries(query).every(([key, value]) => file.src[key] === value)),
|
|
540
|
+
getComponent: (name) => {
|
|
541
|
+
const own = components.filter((c) => c.component === name)
|
|
542
|
+
if (!own.length) return undefined
|
|
543
|
+
const versions = own.map((c) => ({ version: c.version, asciidoc: { attributes: c.attributes } }))
|
|
544
|
+
return { name, versions, latest: versions[0] }
|
|
545
|
+
},
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function isGenerated (content) {
|
|
550
|
+
return content.split('\n', 15).some((line) => line.includes(GENERATED_MARKER))
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// ---------------------------------------------------------------------------
|
|
554
|
+
// Example output in listing, literal and fenced blocks.
|
|
555
|
+
//
|
|
556
|
+
// Prose inside a block is never read, but a block that shows command output
|
|
557
|
+
// can state a property's value as plainly as a sentence does:
|
|
558
|
+
// `rpk topic describe` config tables, `rpk cluster config get` and `export`,
|
|
559
|
+
// Admin API cluster_config responses, kafka-configs --describe, and
|
|
560
|
+
// Key/Value/Source records (debug bundles, Topic resource status). A row
|
|
561
|
+
// whose source is DEFAULT_CONFIG asserts the default, so it is compared, as
|
|
562
|
+
// does output with no source (config get, export, Admin API) when the text
|
|
563
|
+
// just before the block says it is the default. A row from another source, a property the page sets earlier, a Helm or
|
|
564
|
+
// operator deployment, or a cluster started in dev-container mode or from a
|
|
565
|
+
// bootstrap file is not a default, and is reported as unverifiable with the
|
|
566
|
+
// reason. Example findings are never written.
|
|
567
|
+
// ---------------------------------------------------------------------------
|
|
568
|
+
|
|
569
|
+
// Sources that state the topic or broker has no value of its own.
|
|
570
|
+
const DEFAULT_SOURCES = new Set(['DEFAULT_CONFIG', 'DEFAULT'])
|
|
571
|
+
const COLUMN_HEADER_RX = /^\s*(?:KEY|NAME|PROPERTY)\s+VALUE(?:\s+SOURCE)?\s*$/i
|
|
572
|
+
const STATUS_HEADER_RX = /^\s*NODE\s+CONFIG-VERSION\b/i
|
|
573
|
+
// segment.bytes=1073741824 (DEFAULT_CONFIG)
|
|
574
|
+
const EQUALS_SOURCE_RX = /^\s*([A-Za-z][\w.]*)=(\S*)\s+\(([A-Z_]+)\)\s*$/
|
|
575
|
+
// kafka-configs --describe: segment.bytes=1073741824 sensitive=false synonyms={DEFAULT_CONFIG:log_segment_size=1073741824}
|
|
576
|
+
const KAFKA_CONFIGS_RX = /^\s*([A-Za-z][\w.]*)=(\S*)\s+sensitive=\w+\s+synonyms=\{(?:([A-Z_]+):)?/
|
|
577
|
+
// A field of a Key/Value/Source record, in JSON or YAML.
|
|
578
|
+
const RECORD_FIELD_RX = /^(\s*(?:-\s+)?)"?(Key|key|Name|name|Value|value|Source|source)"?\s*:\s*(.*?)\s*,?\s*$/
|
|
579
|
+
// A flat "name": value or name: value line (Admin API JSON, config export).
|
|
580
|
+
const FLAT_FIELD_RX = /^\s*"?([a-z][a-z0-9_]*)"?\s*:\s*(.*?)\s*,?\s*$/
|
|
581
|
+
const COMMAND_RX = /^\s*(?:\$\s+)?(?:\S*=\S*\s+)*(?:kubectl|helm|docker|rpk|internal-rpk|curl|kafka-configs(?:\.sh)?)\b/
|
|
582
|
+
const HELM_PATH_RX = /(?:^|\/)kubernetes\/|(?:^|\/)k-[^/]*\.adoc$/
|
|
583
|
+
const DEV_CONFIG_RX = /dev-container|--mode\s+dev|rpk container start|\bbootstrap\.ya?ml\b|\.bootstrap\.yaml/
|
|
584
|
+
|
|
585
|
+
const unquote = (text) => {
|
|
586
|
+
const t = String(text).trim().replace(/,$/, '').trim()
|
|
587
|
+
const m = t.match(/^"(.*)"$/) || t.match(/^'(.*)'$/)
|
|
588
|
+
return m ? m[1] : t
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* The value rows a block of command output states. Exported for unit
|
|
593
|
+
* testing.
|
|
594
|
+
*
|
|
595
|
+
* @param {string[]} lines - The block's lines.
|
|
596
|
+
* @param {string} commands - Command text for the block: its own command
|
|
597
|
+
* lines, else the previous block's when that one held only commands.
|
|
598
|
+
* @returns {{rows: object[], shape?: string}} rows of {name, value, source?,
|
|
599
|
+
* offset} where offset is the line within the block.
|
|
600
|
+
*/
|
|
601
|
+
function parseExampleBlock (lines, commands = '') {
|
|
602
|
+
const rows = []
|
|
603
|
+
// rpk cluster config status lists versions and restart flags, no values.
|
|
604
|
+
if (lines.some((l) => STATUS_HEADER_RX.test(l))) return { rows, shape: 'cluster-config-status' }
|
|
605
|
+
|
|
606
|
+
// NAME/KEY VALUE SOURCE columns (rpk topic describe, kubectl go-templates).
|
|
607
|
+
const header = lines.findIndex((l) => COLUMN_HEADER_RX.test(l))
|
|
608
|
+
if (header >= 0) {
|
|
609
|
+
const hasSource = /SOURCE\s*$/i.test(lines[header])
|
|
610
|
+
for (let i = header + 1; i < lines.length; i++) {
|
|
611
|
+
const text = lines[i].trim()
|
|
612
|
+
if (!text) break
|
|
613
|
+
const tokens = text.split(/\s+/)
|
|
614
|
+
if (tokens.length < 2) break
|
|
615
|
+
const name = tokens[0]
|
|
616
|
+
let source
|
|
617
|
+
let rest = text.slice(name.length).trim()
|
|
618
|
+
if (hasSource && tokens.length >= 3 && /^[A-Z_]+$/.test(tokens[tokens.length - 1])) {
|
|
619
|
+
source = tokens[tokens.length - 1]
|
|
620
|
+
rest = rest.slice(0, rest.length - source.length).trim()
|
|
621
|
+
}
|
|
622
|
+
rows.push({ name, value: rest, source, offset: i })
|
|
623
|
+
}
|
|
624
|
+
return { rows, shape: 'config-table' }
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// name=value (SOURCE) and kafka-configs --describe lines.
|
|
628
|
+
lines.forEach((l, offset) => {
|
|
629
|
+
const m = l.match(EQUALS_SOURCE_RX) || l.match(KAFKA_CONFIGS_RX)
|
|
630
|
+
if (m) rows.push({ name: m[1], value: m[2], source: m[3], offset })
|
|
631
|
+
})
|
|
632
|
+
if (rows.length) return { rows, shape: 'name-equals-value' }
|
|
633
|
+
|
|
634
|
+
// Key/Value/Source records: a record is the fields at one indentation
|
|
635
|
+
// within one list item or object.
|
|
636
|
+
const open = new Map()
|
|
637
|
+
const flush = (col) => {
|
|
638
|
+
const r = open.get(col)
|
|
639
|
+
open.delete(col)
|
|
640
|
+
if (r && r.name !== undefined && r.value !== undefined && r.source !== undefined) {
|
|
641
|
+
rows.push({ name: r.name, value: r.value, source: r.source, offset: r.offset })
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
lines.forEach((l, offset) => {
|
|
645
|
+
const m = l.match(RECORD_FIELD_RX)
|
|
646
|
+
if (!m) return
|
|
647
|
+
const col = m[1].length
|
|
648
|
+
const key = m[2].toLowerCase() === 'key' ? 'name' : m[2].toLowerCase()
|
|
649
|
+
const raw = m[3]
|
|
650
|
+
// A nested object or list ("Synonyms": [) is not a scalar field.
|
|
651
|
+
if (/^[[{]/.test(raw)) return
|
|
652
|
+
const r = open.get(col)
|
|
653
|
+
if (r && (r[key] !== undefined || /-\s+$/.test(m[1]))) flush(col)
|
|
654
|
+
if (!open.has(col)) open.set(col, {})
|
|
655
|
+
const rec = open.get(col)
|
|
656
|
+
rec[key] = unquote(raw)
|
|
657
|
+
if (key === 'value') rec.offset = offset
|
|
658
|
+
})
|
|
659
|
+
for (const col of [...open.keys()]) flush(col)
|
|
660
|
+
if (rows.length) return { rows, shape: 'records' }
|
|
661
|
+
|
|
662
|
+
const commandLines = lines.filter((l) => COMMAND_RX.test(l))
|
|
663
|
+
const output = lines.map((text, offset) => ({ text, offset })).filter((l) => l.text.trim() && !COMMAND_RX.test(l.text))
|
|
664
|
+
|
|
665
|
+
// rpk cluster config get <name>, with its output in the same block or the
|
|
666
|
+
// block after the command.
|
|
667
|
+
const gets = [...(commandLines.length ? commandLines.join('\n') : commands).matchAll(/\brpk\s+cluster\s+config\s+get\s+([a-z][a-z0-9_]*)/g)]
|
|
668
|
+
if (gets.length === 1 && output.length === 1) {
|
|
669
|
+
rows.push({ name: gets[0][1], value: output[0].text.trim().replace(/^\$\s+/, ''), offset: output[0].offset })
|
|
670
|
+
return { rows, shape: 'cluster-config-get' }
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// An Admin API cluster_config response or an rpk cluster config export.
|
|
674
|
+
const context = commandLines.length ? commandLines.join('\n') : commands
|
|
675
|
+
const api = /\/v1\/cluster_config(?!\/)/.test(context)
|
|
676
|
+
const exported = /\brpk\s+cluster\s+config\s+export\b/.test(context)
|
|
677
|
+
if (api || exported) {
|
|
678
|
+
for (const { text, offset } of output) {
|
|
679
|
+
const m = text.match(FLAT_FIELD_RX)
|
|
680
|
+
if (m && !/^[[{]?$/.test(m[2])) rows.push({ name: m[1], value: unquote(m[2]), offset })
|
|
681
|
+
}
|
|
682
|
+
if (rows.length) return { rows, shape: api ? 'admin-api-cluster-config' : 'cluster-config-export' }
|
|
683
|
+
}
|
|
684
|
+
return { rows }
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* Compare a value as command output prints it (raw bytes, milliseconds,
|
|
689
|
+
* true/false, enum text) with a data value. Exact after unit normalization.
|
|
690
|
+
*
|
|
691
|
+
* @returns {{status: 'match'|'mismatch'|'unset'|'not-comparable', note?: string}}
|
|
692
|
+
*/
|
|
693
|
+
function compareRaw (written, dataValue, { scale = 1 } = {}) {
|
|
694
|
+
const text = String(written).trim()
|
|
695
|
+
if (dataValue === null || dataValue === undefined || dataValue === '') {
|
|
696
|
+
// Kafka reports "no limit" as -1.
|
|
697
|
+
if (/^(?:-1|null|none|)$/i.test(text)) return { status: 'match', ...(text === '-1' ? { note: '-1 is how Kafka reports an unset limit' } : {}) }
|
|
698
|
+
return { status: 'unset' }
|
|
699
|
+
}
|
|
700
|
+
if (typeof dataValue === 'boolean') {
|
|
701
|
+
if (!/^(true|false)$/i.test(text)) return { status: 'not-comparable' }
|
|
702
|
+
return { status: (text.toLowerCase() === 'true') === dataValue ? 'match' : 'mismatch' }
|
|
703
|
+
}
|
|
704
|
+
if (typeof dataValue === 'number') {
|
|
705
|
+
if (!/^-?\d+(?:\.\d+)?$/.test(text)) return { status: 'not-comparable' }
|
|
706
|
+
return { status: Number(text) === dataValue * scale ? 'match' : 'mismatch' }
|
|
707
|
+
}
|
|
708
|
+
if (typeof dataValue === 'string') {
|
|
709
|
+
if (text === dataValue) return { status: 'match' }
|
|
710
|
+
if (text.toLowerCase() === dataValue.toLowerCase()) return { status: 'match', note: 'differs only in case' }
|
|
711
|
+
return { status: /\s/.test(text) ? 'not-comparable' : 'mismatch' }
|
|
712
|
+
}
|
|
713
|
+
return { status: 'not-comparable' }
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* The property data a row compares against: the row's own entry, or for a
|
|
718
|
+
* topic property the cluster property it inherits (corresponding_cluster_
|
|
719
|
+
* property; alternate_cluster_property for a compacted topic), both from the
|
|
720
|
+
* property data. A Kafka broker alias such as log.segment.bytes is not in the
|
|
721
|
+
* data; its Redpanda-named synonym row is compared instead.
|
|
722
|
+
*/
|
|
723
|
+
function exampleTarget (registry, name, { compacted = false } = {}) {
|
|
724
|
+
const props = registry.properties
|
|
725
|
+
const entry = Object.prototype.hasOwnProperty.call(props, name) ? props[name] : undefined
|
|
726
|
+
if (!entry) return undefined
|
|
727
|
+
if (entry.config_scope === 'topic') {
|
|
728
|
+
const cluster = (compacted && entry.alternate_cluster_property) || entry.corresponding_cluster_property
|
|
729
|
+
if (cluster && Object.prototype.hasOwnProperty.call(props, cluster)) {
|
|
730
|
+
return { entry, data: props[cluster], cluster, via: compacted && cluster === entry.alternate_cluster_property ? 'alternate_cluster_property' : 'corresponding_cluster_property' }
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
return { entry, data: entry }
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* Where, before `beforeLine`, the page sets `names` to a value: a
|
|
738
|
+
* name=value or name: value assignment (rpk ... --set, -c, YAML, JSON, Helm
|
|
739
|
+
* config.cluster.name), or rpk cluster config set name value.
|
|
740
|
+
*/
|
|
741
|
+
function setEarlier (lines, beforeLine, names) {
|
|
742
|
+
const alternatives = names.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')
|
|
743
|
+
const rx = new RegExp(`(?<![\\w.-])(?:${alternatives})"?\\s*[=:]\\s*(?!\\s*$)\\S|\\bconfig\\s+set\\s+(?:${alternatives})\\s+\\S`)
|
|
744
|
+
for (let i = beforeLine - 1; i >= 0; i--) {
|
|
745
|
+
if (rx.test(lines[i])) return i + 1
|
|
746
|
+
}
|
|
747
|
+
return undefined
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* Classify one example row. Exported for unit testing through scanDocument.
|
|
752
|
+
*/
|
|
753
|
+
function classifyExample (row, registry, ctx) {
|
|
754
|
+
const target = exampleTarget(registry, row.name, { compacted: ctx.compacted })
|
|
755
|
+
if (!target) return undefined
|
|
756
|
+
const { entry, data, cluster, via } = target
|
|
757
|
+
const out = {
|
|
758
|
+
property: row.name,
|
|
759
|
+
field: 'default',
|
|
760
|
+
phrase: ctx.shape,
|
|
761
|
+
written: row.value,
|
|
762
|
+
association: 'example-output',
|
|
763
|
+
autoSafe: false,
|
|
764
|
+
kind: 'example-output',
|
|
765
|
+
...(row.source ? { source: row.source } : {}),
|
|
766
|
+
...(cluster ? { clusterProperty: cluster, mappedBy: via } : {}),
|
|
767
|
+
}
|
|
768
|
+
const human = fieldValue(data, 'default', { format: FORMAT_HUMAN })
|
|
769
|
+
const raw = fieldValue(data, 'default', { format: FORMAT_RAW })
|
|
770
|
+
if (human.ok) out.dataValue = human.text
|
|
771
|
+
if (raw.ok) out.dataRaw = raw.text
|
|
772
|
+
// Seconds-based cluster property behind a .ms topic property.
|
|
773
|
+
const scale = cluster && /\.ms$/.test(row.name) && unitOf(data) === 'seconds' ? 1000 : 1
|
|
774
|
+
const compared = compareRaw(row.value, data.default, { scale })
|
|
775
|
+
if (compared.note) out.note = compared.note
|
|
776
|
+
const unverifiable = (reason, note) => {
|
|
777
|
+
out.status = 'example-output'
|
|
778
|
+
out.verdict = 'unverifiable'
|
|
779
|
+
out.reason = reason
|
|
780
|
+
if (note) out.note = note
|
|
781
|
+
out.differs = compared.status === 'mismatch'
|
|
782
|
+
return out
|
|
783
|
+
}
|
|
784
|
+
if (row.source && !DEFAULT_SOURCES.has(row.source)) {
|
|
785
|
+
return unverifiable('not-default-source', `source ${row.source} is not a default`)
|
|
786
|
+
}
|
|
787
|
+
if (!isAvailable(registry, entry)) {
|
|
788
|
+
return unverifiable('cloud-unavailable', 'cloud_supported: false in the data')
|
|
789
|
+
}
|
|
790
|
+
if (ctx.qualifier) return unverifiable('ifdef::env-cloud', 'inside ifdef::env-cloud[]: a Cloud value')
|
|
791
|
+
if (registry.surface === 'cloud') return unverifiable('cloud-default', 'the Cloud reference states no defaults')
|
|
792
|
+
// A DEFAULT_CONFIG row inherits the cluster value, so only a cluster-level
|
|
793
|
+
// setting earlier on the page changes what it shows; a topic-level one
|
|
794
|
+
// would have changed the row's source.
|
|
795
|
+
const topicDefaultRow = row.source && entry.config_scope === 'topic'
|
|
796
|
+
const watched = cluster ? [cluster] : topicDefaultRow ? [] : [row.name]
|
|
797
|
+
const set = watched.length ? setEarlier(ctx.lines, ctx.blockStart, watched) : undefined
|
|
798
|
+
if (set) return unverifiable('set-earlier-on-page', `the page sets ${cluster || row.name} at line ${set}`)
|
|
799
|
+
if (ctx.helm) return unverifiable('helm-operator-context', ctx.helm)
|
|
800
|
+
if (ctx.devConfig) return unverifiable('dev-or-bootstrap-config', ctx.devConfig)
|
|
801
|
+
if (!row.source && !ctx.statesDefault) return unverifiable('current-value', 'output with no source column shows the current value, and the text before it does not say it is the default')
|
|
802
|
+
if (compared.status === 'unset') return unverifiable('unset-in-data', `not set in the data; the output shows ${row.value}, which may be a runtime fallback`)
|
|
803
|
+
if (compared.status === 'not-comparable') return unverifiable('not-comparable')
|
|
804
|
+
out.status = 'example-output'
|
|
805
|
+
out.verdict = compared.status === 'match' ? 'matches' : 'stale'
|
|
806
|
+
return out
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* Scan one document. Exported for unit testing.
|
|
811
|
+
*
|
|
812
|
+
* @returns {{findings: object[], content: string}} content with exact
|
|
813
|
+
* findings replaced (only used by --write).
|
|
814
|
+
*/
|
|
815
|
+
function scanDocument (content, registry, options = {}) {
|
|
816
|
+
const lines = content.split('\n')
|
|
817
|
+
const findings = []
|
|
818
|
+
const requireSeparator = options.requireSeparator !== false
|
|
819
|
+
const cloudSurface = registry.surface === 'cloud'
|
|
820
|
+
let open = null
|
|
821
|
+
let block = null
|
|
822
|
+
let lastBlock = null
|
|
823
|
+
let table = null
|
|
824
|
+
const helmPage = options.path && HELM_PATH_RX.test(options.path)
|
|
825
|
+
// A tagged file, or any partial (cloud-docs includes some partials whole),
|
|
826
|
+
// may be included in a Cloud page.
|
|
827
|
+
const taggedFile = TAG_RX.test(content) || /(?:^|\/)partials\//.test(options.path || '')
|
|
828
|
+
const devConfig = content.match(DEV_CONFIG_RX)
|
|
829
|
+
|
|
830
|
+
// Read the command output in a closed block and report its value rows.
|
|
831
|
+
const scanBlock = (b) => {
|
|
832
|
+
const texts = b.lines.map((l) => l.text)
|
|
833
|
+
const own = texts.filter((t) => COMMAND_RX.test(t))
|
|
834
|
+
// An output block's command is the previous block's, when that block held
|
|
835
|
+
// only commands and ended just before ("Input" / "Output" pairs).
|
|
836
|
+
const previous = lastBlock && b.start - lastBlock.end <= 12 && lastBlock.lines.every((l) => !l.text.trim() || COMMAND_RX.test(l.text) || /\\\s*$/.test(l.text) || /^\s+-/.test(l.text))
|
|
837
|
+
? lastBlock.lines.map((l) => l.text).join('\n')
|
|
838
|
+
: ''
|
|
839
|
+
const commands = own.length ? own.join('\n') : previous
|
|
840
|
+
// Output with no source column shows the current value. It states the
|
|
841
|
+
// default only when the text just before it says so ("on a cluster with
|
|
842
|
+
// default settings", "the default is").
|
|
843
|
+
const leadEnd = previous ? lastBlock.start : b.start
|
|
844
|
+
const leadIn = lines.slice(Math.max(0, leadEnd - 11), Math.max(0, leadEnd - 1)).join('\n')
|
|
845
|
+
const { rows, shape } = parseExampleBlock(texts, commands)
|
|
846
|
+
if (!rows.length) return
|
|
847
|
+
const compacted = rows.some((r) => r.name === 'cleanup.policy' && /compact/.test(r.value))
|
|
848
|
+
let helm
|
|
849
|
+
if (helmPage) helm = 'on a Kubernetes page: the Helm chart or operator may set this value'
|
|
850
|
+
else if (/\b(?:kubectl|helm)\b/.test(commands)) helm = 'run through kubectl or helm: the Helm chart or operator may set this value'
|
|
851
|
+
const ctx = {
|
|
852
|
+
shape,
|
|
853
|
+
lines,
|
|
854
|
+
blockStart: b.start,
|
|
855
|
+
qualifier: b.state.qualifier,
|
|
856
|
+
compacted,
|
|
857
|
+
helm,
|
|
858
|
+
devConfig: devConfig ? `the page configures the cluster (${devConfig[0]}), which can change defaults` : undefined,
|
|
859
|
+
statesDefault: /\bdefaults?\b/i.test(leadIn),
|
|
860
|
+
}
|
|
861
|
+
for (const row of rows) {
|
|
862
|
+
const result = classifyExample(row, registry, ctx)
|
|
863
|
+
if (!result) continue
|
|
864
|
+
const at = b.lines[row.offset]
|
|
865
|
+
findings.push({ line: at.index + 1, text: at.text.trim(), ...result })
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
const conditions = []
|
|
869
|
+
const condition = () => conditionOf(conditions, cloudSurface)
|
|
870
|
+
|
|
871
|
+
// Classify the findings for one line (or the content of a single-line
|
|
872
|
+
// conditional at `offset`) and rewrite the auto-safe ones.
|
|
873
|
+
const emit = (index, line, lineFindings, offset = 0, { inTable = false, cloudExcluded = condition().cloudExcluded } = {}) => {
|
|
874
|
+
let rewritten = line
|
|
875
|
+
// Replace right to left so earlier offsets stay valid.
|
|
876
|
+
const classified = lineFindings.map((f) => ({ raw: f, result: classify(f, registry) }))
|
|
877
|
+
for (const { raw, result } of classified) {
|
|
878
|
+
const entry = registry.properties[raw.name]
|
|
879
|
+
if (result.autoSafe && raw.soleProperty === false) {
|
|
880
|
+
result.autoSafe = false
|
|
881
|
+
result.note = 'another property is named in the same sentence or entry: proposed only'
|
|
882
|
+
}
|
|
883
|
+
// A null default is "unset", which deserves a human look even when the
|
|
884
|
+
// prose says null.
|
|
885
|
+
if (result.autoSafe && raw.field === 'default' && (entry.default === null || entry.default === '')) {
|
|
886
|
+
result.autoSafe = false
|
|
887
|
+
result.note = 'the data leaves this unset: proposed only'
|
|
888
|
+
}
|
|
889
|
+
// cloud-docs can include this line. On Cloud, propval: renders a
|
|
890
|
+
// placeholder for a field the Cloud reference hides and for a property
|
|
891
|
+
// Cloud does not support, so outside ifndef::env-cloud[] a tagged file
|
|
892
|
+
// or a partial (either can be included in a Cloud page) is proposed only.
|
|
893
|
+
const cloudPlaceholder = (FIELDS[raw.field] && FIELDS[raw.field].cloudHidden) ||
|
|
894
|
+
(entry.config_scope === 'cluster' && entry.cloud_supported === false)
|
|
895
|
+
if (result.autoSafe && !cloudSurface && cloudPlaceholder && !cloudExcluded && taggedFile) {
|
|
896
|
+
result.autoSafe = false
|
|
897
|
+
result.note = 'proposed: page may be single-sourced; wrap in ifndef::env-cloud[] first'
|
|
898
|
+
}
|
|
899
|
+
// Release notes and upgrade notes describe a release, not the current
|
|
900
|
+
// value, so a later regeneration must not rewrite what they say.
|
|
901
|
+
if (result.autoSafe && options.historical) {
|
|
902
|
+
result.autoSafe = false
|
|
903
|
+
result.note = 'release or upgrade notes: proposed only'
|
|
904
|
+
}
|
|
905
|
+
if (result.autoSafe && inTable) {
|
|
906
|
+
result.autoSafe = false
|
|
907
|
+
result.note = 'in a table: proposed only'
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
for (const { raw, result } of [...classified].sort((a, b) => b.raw.start - a.raw.start)) {
|
|
911
|
+
if (result.autoSafe && options.write) {
|
|
912
|
+
rewritten = rewritten.slice(0, offset + raw.start) + result.proposed + rewritten.slice(offset + raw.end)
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
for (const { result } of classified) findings.push({ line: index + 1, text: line.trim(), ...result })
|
|
916
|
+
return rewritten
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// One line inside a table. `t` carries the header, the cell count and the
|
|
920
|
+
// property the current row (or cell) is about.
|
|
921
|
+
const scanTableLine = (t, text, at, qualifier) => {
|
|
922
|
+
const trimmed = text.trim()
|
|
923
|
+
const cells = splitCells(text)
|
|
924
|
+
const subjectAt = (cellText) => {
|
|
925
|
+
const lead = cellText.match(/^\s*/)[0].length
|
|
926
|
+
const m = findMentions(cellText, registry, requireSeparator).find((x) => x.start === lead)
|
|
927
|
+
return m && m.name
|
|
928
|
+
}
|
|
929
|
+
// The first row, when it names no property, is the header.
|
|
930
|
+
if (t.header === undefined && trimmed) {
|
|
931
|
+
if (cells && cells.length > 1 && !findMentions(text, registry, requireSeparator).length) {
|
|
932
|
+
t.header = cells.map((c) => c.text.trim())
|
|
933
|
+
t.cols = cells.length
|
|
934
|
+
t.defaultCol = t.header.findIndex((h) => /^default(?:\s+value)?$/i.test(h))
|
|
935
|
+
return text
|
|
936
|
+
}
|
|
937
|
+
t.header = null
|
|
938
|
+
}
|
|
939
|
+
if (!cells) {
|
|
940
|
+
// A continuation of the current cell.
|
|
941
|
+
return emit(at, text, scanLine(text, { registry, requireSeparator, qualifier, subject: t.subject }), 0, { inTable: true })
|
|
942
|
+
}
|
|
943
|
+
const lineFindings = []
|
|
944
|
+
for (const c of cells) {
|
|
945
|
+
t.cell++
|
|
946
|
+
const col = t.cols ? t.cell % t.cols : -1
|
|
947
|
+
if (col === 0) t.subject = undefined
|
|
948
|
+
const name = subjectAt(c.text)
|
|
949
|
+
if (name) {
|
|
950
|
+
t.subject = { name, association: 'table-row' }
|
|
951
|
+
continue
|
|
952
|
+
}
|
|
953
|
+
if (col >= 0 && col === t.defaultCol && t.subject) {
|
|
954
|
+
const lead = c.text.match(/^\s*/)[0].length
|
|
955
|
+
const parsed = parseValue(c.text.slice(lead))
|
|
956
|
+
const entry = registry.properties[t.subject.name]
|
|
957
|
+
if (parsed && !(parsed.bareWord && typeof entry.default !== 'string')) {
|
|
958
|
+
lineFindings.push({ name: t.subject.name, field: 'default', phrase: 'Default column', parsed, start: c.start + lead, end: c.start + lead + parsed.length, association: 'table-default-column', ...(qualifier ? { qualifier } : {}) })
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
if (!lineFindings.length) {
|
|
963
|
+
lineFindings.push(...scanLine(text, { registry, requireSeparator, qualifier, subject: t.subject }))
|
|
964
|
+
}
|
|
965
|
+
return emit(at, text, lineFindings, 0, { inTable: true })
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
const out = lines.map((line, index) => {
|
|
969
|
+
const trimmed = line.trim()
|
|
970
|
+
const delimiter = trimmed.match(DELIMITER_RX)
|
|
971
|
+
if (delimiter) {
|
|
972
|
+
const fence = delimiter[1].startsWith('```') ? '```' : delimiter[1]
|
|
973
|
+
if (open === null) {
|
|
974
|
+
open = fence
|
|
975
|
+
// Listing, literal and fenced blocks can hold command output.
|
|
976
|
+
const state = condition()
|
|
977
|
+
block = /^[-.`]/.test(fence) && !state.hidden ? { start: index + 1, lines: [], state } : null
|
|
978
|
+
} else if (open === fence) {
|
|
979
|
+
open = null
|
|
980
|
+
if (block) {
|
|
981
|
+
block.end = index
|
|
982
|
+
scanBlock(block)
|
|
983
|
+
lastBlock = block
|
|
984
|
+
}
|
|
985
|
+
block = null
|
|
986
|
+
}
|
|
987
|
+
return line
|
|
988
|
+
}
|
|
989
|
+
if (open !== null) {
|
|
990
|
+
if (block) block.lines.push({ index, text: line })
|
|
991
|
+
return line
|
|
992
|
+
}
|
|
993
|
+
if (ENDIF_RX.test(trimmed)) {
|
|
994
|
+
conditions.pop()
|
|
995
|
+
return line
|
|
996
|
+
}
|
|
997
|
+
if (IFEVAL_RX.test(trimmed)) {
|
|
998
|
+
conditions.push({ type: 'ifeval', attrs: '' })
|
|
999
|
+
return line
|
|
1000
|
+
}
|
|
1001
|
+
const directive = trimmed.match(CONDITIONAL_RX)
|
|
1002
|
+
if (directive) {
|
|
1003
|
+
const frame = { type: directive[1], attrs: directive[2] }
|
|
1004
|
+
if (!directive[3]) {
|
|
1005
|
+
conditions.push(frame)
|
|
1006
|
+
return line
|
|
1007
|
+
}
|
|
1008
|
+
// Single-line form: ifdef::env-cloud[The default is ...]
|
|
1009
|
+
conditions.push(frame)
|
|
1010
|
+
const state = condition()
|
|
1011
|
+
conditions.pop()
|
|
1012
|
+
if (state.hidden) return line
|
|
1013
|
+
const offset = line.indexOf('[', line.indexOf('::')) + 1
|
|
1014
|
+
const inner = directive[3]
|
|
1015
|
+
return emit(index, line, scanLine(inner, { registry, requireSeparator, qualifier: state.qualifier }), offset, { cloudExcluded: state.cloudExcluded })
|
|
1016
|
+
}
|
|
1017
|
+
if (condition().hidden) return line
|
|
1018
|
+
if (table && NESTED_TABLE_RX.test(trimmed)) return line
|
|
1019
|
+
if (TABLE_RX.test(trimmed)) {
|
|
1020
|
+
table = table ? null : { header: undefined, cols: 0, defaultCol: -1, cell: -1, subject: undefined }
|
|
1021
|
+
return line
|
|
1022
|
+
}
|
|
1023
|
+
if (SKIP_LINE_RX.test(line)) return line
|
|
1024
|
+
const { qualifier } = condition()
|
|
1025
|
+
if (table) return scanTableLine(table, line, index, qualifier)
|
|
1026
|
+
return emit(index, line, scanLine(line, { registry, requireSeparator, qualifier }))
|
|
1027
|
+
})
|
|
1028
|
+
return { findings, content: out.join('\n') }
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
/**
|
|
1032
|
+
* Split a table line into cells, or undefined when the line continues the
|
|
1033
|
+
* previous cell. A cell starts at a | (with an optional spec such as a| or
|
|
1034
|
+
* 2+|) outside backticks; start is the offset of the cell's text.
|
|
1035
|
+
*/
|
|
1036
|
+
function splitCells (line) {
|
|
1037
|
+
const lead = line.match(CELL_START_RX)
|
|
1038
|
+
if (!lead) return undefined
|
|
1039
|
+
const cells = []
|
|
1040
|
+
let start = lead[0].length
|
|
1041
|
+
let inCode = false
|
|
1042
|
+
for (let i = start; i <= line.length; i++) {
|
|
1043
|
+
const ch = line[i]
|
|
1044
|
+
if (ch === '`') inCode = !inCode
|
|
1045
|
+
if (i === line.length || (ch === '|' && !inCode && line[i - 1] !== '\\')) {
|
|
1046
|
+
cells.push({ text: line.slice(start, i), start })
|
|
1047
|
+
start = i + 1
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
return cells
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
/**
|
|
1054
|
+
* Whether content under the open conditionals is hidden on this surface, the
|
|
1055
|
+
* qualifier it carries, and whether it is excluded from Cloud (cloudExcluded).
|
|
1056
|
+
* Only conditions that name env-cloud are interpreted; anything else is
|
|
1057
|
+
* treated as visible. ifdef::env-cloud,x[] (OR) and ifdef::env-cloud+x[]
|
|
1058
|
+
* (AND) both render on Cloud. ifndef::env-cloud,x[] renders only when none
|
|
1059
|
+
* is set, so it is excluded from Cloud; ifndef::env-cloud+x[] is not.
|
|
1060
|
+
*/
|
|
1061
|
+
function conditionOf (conditions, cloudSurface) {
|
|
1062
|
+
let hidden = false
|
|
1063
|
+
let cloudExcluded = false
|
|
1064
|
+
let qualifier
|
|
1065
|
+
for (const { type, attrs } of conditions) {
|
|
1066
|
+
const names = attrs.split(/[,+]/)
|
|
1067
|
+
if (type === 'ifdef' && names.includes('env-cloud')) {
|
|
1068
|
+
if (!cloudSurface) qualifier = 'ifdef::env-cloud'
|
|
1069
|
+
} else if (type === 'ifndef' && !attrs.includes('+') && names.includes('env-cloud')) {
|
|
1070
|
+
cloudExcluded = true
|
|
1071
|
+
if (cloudSurface) hidden = true
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
return { hidden, qualifier, cloudExcluded }
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
/**
|
|
1078
|
+
* Scan a repo checkout.
|
|
1079
|
+
*
|
|
1080
|
+
* @param {object} opts
|
|
1081
|
+
* @param {string} opts.docsDir - Repo root containing antora.yml and modules/.
|
|
1082
|
+
* @param {string[]} [opts.lenderDirs] - Other component checkouts whose
|
|
1083
|
+
* property data the repo may borrow (a Cloud repo borrows streaming's).
|
|
1084
|
+
* @param {boolean} [opts.write] - Apply exact findings.
|
|
1085
|
+
*/
|
|
1086
|
+
function scanRepo ({ docsDir, lenderDirs = [], write = false, exclude = DEFAULT_EXCLUDE }) {
|
|
1087
|
+
const own = readComponent(docsDir)
|
|
1088
|
+
const lenders = lenderDirs.map(readComponent)
|
|
1089
|
+
const catalog = catalogFor([own, ...lenders])
|
|
1090
|
+
const registry = loadPropertiesFor(catalog, own.component, own.version)
|
|
1091
|
+
if (!registry) {
|
|
1092
|
+
return {
|
|
1093
|
+
component: own.component,
|
|
1094
|
+
version: own.version,
|
|
1095
|
+
error: `no property data for ${own.component}@${own.version || 'unversioned'}: the prop macro would refuse to validate this version, so no value can be checked. ` +
|
|
1096
|
+
(own.files.length ? `Found only ${own.files.map((f) => f.src.relative).join(', ')}.` : 'Pass --lender for a Cloud repo.'),
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
const findings = []
|
|
1100
|
+
let files = 0
|
|
1101
|
+
let changedFiles = 0
|
|
1102
|
+
let skippedGenerated = 0
|
|
1103
|
+
const walk = (dir) => {
|
|
1104
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
1105
|
+
const full = path.join(dir, entry.name)
|
|
1106
|
+
const relative = path.relative(docsDir, full)
|
|
1107
|
+
if (exclude.some((fragment) => `${relative}/`.includes(fragment) || relative.includes(fragment))) continue
|
|
1108
|
+
if (entry.isDirectory()) walk(full)
|
|
1109
|
+
else if (entry.name.endsWith('.adoc')) {
|
|
1110
|
+
const original = fs.readFileSync(full, 'utf8')
|
|
1111
|
+
if (isGenerated(original)) {
|
|
1112
|
+
skippedGenerated++
|
|
1113
|
+
continue
|
|
1114
|
+
}
|
|
1115
|
+
files++
|
|
1116
|
+
const result = scanDocument(original, registry, { write, historical: HISTORICAL_RX.test(relative), path: relative })
|
|
1117
|
+
for (const f of result.findings) findings.push({ file: relative, ...f })
|
|
1118
|
+
if (write && result.content !== original) {
|
|
1119
|
+
fs.writeFileSync(full, result.content)
|
|
1120
|
+
changedFiles++
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
walk(path.join(docsDir, 'modules'))
|
|
1126
|
+
const counts = { exact: 0, equivalent: 0, mismatch: 0, unset: 0, unverifiable: 0, autoSafe: 0, 'example-output': 0, exampleStale: 0 }
|
|
1127
|
+
for (const f of findings) {
|
|
1128
|
+
counts[f.status]++
|
|
1129
|
+
if (f.autoSafe) counts.autoSafe++
|
|
1130
|
+
if (f.verdict === 'stale') counts.exampleStale++
|
|
1131
|
+
}
|
|
1132
|
+
return {
|
|
1133
|
+
component: own.component,
|
|
1134
|
+
version: own.version,
|
|
1135
|
+
dataset: describeDataset(registry),
|
|
1136
|
+
surface: registry.surface,
|
|
1137
|
+
files,
|
|
1138
|
+
skippedGenerated,
|
|
1139
|
+
findings,
|
|
1140
|
+
counts,
|
|
1141
|
+
changedFiles,
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
const dataCell = (f) => (f.dataRaw && f.dataRaw !== f.dataValue ? `${f.dataValue} (${f.dataRaw})` : f.dataValue)
|
|
1146
|
+
const mdEscape = (text) => String(text == null ? '' : text).replace(/\|/g, '\\|').replace(/\n/g, ' ')
|
|
1147
|
+
|
|
1148
|
+
/**
|
|
1149
|
+
* The example-output section of a repo's report.
|
|
1150
|
+
*/
|
|
1151
|
+
function renderExamples (out, examples) {
|
|
1152
|
+
const by = (verdict) => examples.filter((f) => f.verdict === verdict)
|
|
1153
|
+
const stale = by('stale')
|
|
1154
|
+
const unverifiable = by('unverifiable')
|
|
1155
|
+
out.push('', `### Example output (${examples.length}: ${stale.length} stale, ${by('matches').length} matching, ${unverifiable.length} unverifiable)`, '')
|
|
1156
|
+
out.push('Values shown in command output in listing blocks. Reported only: a block is never rewritten. A topic property is compared with the cluster property it inherits in the data.', '')
|
|
1157
|
+
out.push(`#### Stale (${stale.length})`, '')
|
|
1158
|
+
if (stale.length) {
|
|
1159
|
+
out.push('| File:line | Property | Cluster property | Shown | Default | Source | Output |', '|---|---|---|---|---|---|---|')
|
|
1160
|
+
for (const f of stale) out.push(`| ${mdEscape(`${f.file}:${f.line}`)} | \`${mdEscape(f.property)}\` | ${mdEscape(f.clusterProperty || '')} | ${mdEscape(f.written)} | ${mdEscape(dataCell(f))} | ${mdEscape(f.source || '')} | ${f.phrase} |`)
|
|
1161
|
+
} else out.push('None.')
|
|
1162
|
+
const differs = unverifiable.filter((f) => f.differs && f.reason !== 'not-default-source')
|
|
1163
|
+
if (differs.length) {
|
|
1164
|
+
out.push('', `#### Differ from the default in a context that may set them (${differs.length})`, '')
|
|
1165
|
+
out.push('The output may show a configured value, not the default. Listed for a human to check.', '')
|
|
1166
|
+
out.push('| File:line | Property | Cluster property | Shown | Default | Reason | Note |', '|---|---|---|---|---|---|---|')
|
|
1167
|
+
for (const f of differs) out.push(`| ${mdEscape(`${f.file}:${f.line}`)} | \`${mdEscape(f.property)}\` | ${mdEscape(f.clusterProperty || '')} | ${mdEscape(f.written)} | ${mdEscape(dataCell(f))} | ${f.reason} | ${mdEscape(f.note)} |`)
|
|
1168
|
+
}
|
|
1169
|
+
const byReason = {}
|
|
1170
|
+
for (const f of unverifiable) byReason[f.reason] = (byReason[f.reason] || 0) + 1
|
|
1171
|
+
if (Object.keys(byReason).length) out.push('', `#### Unverifiable by reason (${unverifiable.length})`, '', Object.entries(byReason).map(([k, v]) => `* ${k}: ${v}`).join('\n'))
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
/**
|
|
1175
|
+
* Render scan results as Markdown.
|
|
1176
|
+
*/
|
|
1177
|
+
function renderMarkdown (results, { title = 'Hand-written property values report' } = {}) {
|
|
1178
|
+
const out = [`# ${title}`, '']
|
|
1179
|
+
out.push('Statuses: **exact** is what propval: renders. **auto-safe** is an exact match in the same sentence as its property, the only kind `--write` applies. **equivalent** is the same value worded differently, proposed only. **mismatch** differs from the data and is likely a doc error. **unset** states a value for a property the data leaves unset, which may be a runtime fallback. **unverifiable** could not be compared. **example output** is a value shown in command output in a listing block, counted apart and never written: **stale** when a row that asserts the default (such as SOURCE `DEFAULT_CONFIG`) shows another value.', '')
|
|
1180
|
+
out.push('| Repo | Dataset | Files scanned | Findings | Mismatch | Unset | Exact | Auto-safe | Equivalent | Unverifiable | Example output (stale) |', '|---|---|---|---|---|---|---|---|---|---|---|')
|
|
1181
|
+
for (const r of results) {
|
|
1182
|
+
if (r.error) {
|
|
1183
|
+
out.push(`| ${mdEscape(r.label || r.component)} | ${mdEscape(r.error)} | | | | | | | | | |`)
|
|
1184
|
+
continue
|
|
1185
|
+
}
|
|
1186
|
+
out.push(`| ${mdEscape(r.label || r.component)} | ${mdEscape(r.dataset)} (${r.surface}) | ${r.files} | ${r.findings.length} | ${r.counts.mismatch} | ${r.counts.unset} | ${r.counts.exact} | ${r.counts.autoSafe} | ${r.counts.equivalent} | ${r.counts.unverifiable} | ${r.counts['example-output'] || 0} (${r.counts.exampleStale || 0}) |`)
|
|
1187
|
+
}
|
|
1188
|
+
for (const r of results) {
|
|
1189
|
+
if (r.error) continue
|
|
1190
|
+
out.push('', `## ${r.label || r.component}`, '')
|
|
1191
|
+
const mismatches = r.findings.filter((f) => f.status === 'mismatch')
|
|
1192
|
+
out.push(`### Mismatches (${mismatches.length})`, '')
|
|
1193
|
+
if (mismatches.length) {
|
|
1194
|
+
out.push('| File:line | Property | Field | Written | Data value | Tied to | Note |', '|---|---|---|---|---|---|---|')
|
|
1195
|
+
for (const f of mismatches) {
|
|
1196
|
+
out.push(`| ${mdEscape(`${f.file}:${f.line}`)} | \`${mdEscape(f.property)}\` | ${f.field} | ${mdEscape(f.written)} | ${mdEscape(dataCell(f))} | ${f.association} | ${mdEscape(f.note)} |`)
|
|
1197
|
+
}
|
|
1198
|
+
} else out.push('None.')
|
|
1199
|
+
const unset = r.findings.filter((f) => f.status === 'unset')
|
|
1200
|
+
if (unset.length) {
|
|
1201
|
+
out.push('', `### Not set in the data (${unset.length})`, '')
|
|
1202
|
+
out.push('The data default is null, but the prose states a value. Redpanda often computes a fallback at runtime for an unset property; confirm it in the source.', '')
|
|
1203
|
+
out.push('| File:line | Property | Field | Written | Note |', '|---|---|---|---|---|')
|
|
1204
|
+
for (const f of unset) out.push(`| ${mdEscape(`${f.file}:${f.line}`)} | \`${mdEscape(f.property)}\` | ${f.field} | ${mdEscape(f.written)} | ${mdEscape(f.note)} |`)
|
|
1205
|
+
}
|
|
1206
|
+
const qualified = r.findings.filter((f) => f.reason === 'qualified-differs')
|
|
1207
|
+
if (qualified.length) {
|
|
1208
|
+
out.push('', `### Qualified statements that differ from this version's data (${qualified.length})`, '')
|
|
1209
|
+
out.push('The sentence names another audience or release, so the data cannot confirm it. Listed for a human to check.', '')
|
|
1210
|
+
out.push('| File:line | Property | Written | Data value | Qualifier |', '|---|---|---|---|---|')
|
|
1211
|
+
for (const f of qualified) out.push(`| ${mdEscape(`${f.file}:${f.line}`)} | \`${mdEscape(f.property)}\` | ${mdEscape(f.written)} | ${mdEscape(dataCell(f))} | ${mdEscape(f.qualifier)} |`)
|
|
1212
|
+
}
|
|
1213
|
+
const cloudDiffers = r.findings.filter((f) => f.reason === 'cloud-default-differs-from-self-managed')
|
|
1214
|
+
if (cloudDiffers.length) {
|
|
1215
|
+
out.push('', `### Cloud defaults that differ from the self-managed default (${cloudDiffers.length})`, '')
|
|
1216
|
+
out.push('The Cloud property reference states no defaults, so these cannot be verified. They may be Cloud-specific values or errors.', '')
|
|
1217
|
+
out.push('| File:line | Property | Written | Self-managed default |', '|---|---|---|---|')
|
|
1218
|
+
for (const f of cloudDiffers) out.push(`| ${mdEscape(`${f.file}:${f.line}`)} | \`${mdEscape(f.property)}\` | ${mdEscape(f.written)} | ${mdEscape(f.dataValue)} |`)
|
|
1219
|
+
}
|
|
1220
|
+
for (const status of ['exact', 'equivalent']) {
|
|
1221
|
+
const list = r.findings.filter((f) => f.status === status)
|
|
1222
|
+
const heading = status === 'exact'
|
|
1223
|
+
? `Exact (${list.length}, ${list.filter((f) => f.autoSafe).length} auto-safe)`
|
|
1224
|
+
: `Equivalent, proposed and never applied (${list.length})`
|
|
1225
|
+
out.push('', `### ${heading}`, '')
|
|
1226
|
+
if (!list.length) {
|
|
1227
|
+
out.push('None.')
|
|
1228
|
+
continue
|
|
1229
|
+
}
|
|
1230
|
+
out.push('| File:line | Written | Proposed | Renders | Auto-safe |', '|---|---|---|---|---|')
|
|
1231
|
+
for (const f of list) out.push(`| ${mdEscape(`${f.file}:${f.line}`)} | ${mdEscape(f.written)} | \`${mdEscape(f.proposed.replace(/^`|`$/g, ''))}\` | ${mdEscape(f.proposed.includes('format=raw') ? f.dataRaw : f.dataValue)} | ${f.autoSafe ? 'yes' : 'no'} |`)
|
|
1232
|
+
}
|
|
1233
|
+
const unverifiable = r.findings.filter((f) => f.status === 'unverifiable' && !['cloud-default-differs-from-self-managed', 'qualified-differs'].includes(f.reason))
|
|
1234
|
+
out.push('', `### Unverifiable (${unverifiable.length})`, '')
|
|
1235
|
+
const byReason = {}
|
|
1236
|
+
for (const f of unverifiable) byReason[f.reason] = (byReason[f.reason] || 0) + 1
|
|
1237
|
+
out.push(Object.keys(byReason).length ? Object.entries(byReason).map(([k, v]) => `* ${k}: ${v}`).join('\n') : 'None.')
|
|
1238
|
+
renderExamples(out, r.findings.filter((f) => f.status === 'example-output'))
|
|
1239
|
+
}
|
|
1240
|
+
return out.join('\n') + '\n'
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
module.exports = {
|
|
1244
|
+
parseValue,
|
|
1245
|
+
compareValue,
|
|
1246
|
+
scanLine,
|
|
1247
|
+
scanDocument,
|
|
1248
|
+
classify,
|
|
1249
|
+
scanRepo,
|
|
1250
|
+
renderMarkdown,
|
|
1251
|
+
parseExampleBlock,
|
|
1252
|
+
compareRaw,
|
|
1253
|
+
readComponent,
|
|
1254
|
+
catalogFor,
|
|
1255
|
+
}
|