@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.
@@ -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
+ }