@redpanda-data/docs-extensions-and-macros 5.13.5 → 5.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/bin/doc-tools-mcp.js +88 -0
  2. package/bin/doc-tools.js +349 -102
  3. package/bin/mcp-tools/index.js +20 -0
  4. package/bin/mcp-tools/lint-doc-strings.js +75 -0
  5. package/bin/mcp-tools/overrides-audit.js +100 -0
  6. package/bin/mcp-tools/preview-doc-string.js +71 -0
  7. package/cli-utils/diff-utils.js +77 -0
  8. package/cli-utils/helm-commented-values.js +172 -23
  9. package/cli-utils/sync-schemas.js +144 -0
  10. package/docs-data/rpk-overrides.schema.json +4 -0
  11. package/package.json +2 -1
  12. package/tools/generate-cli-docs.js +28 -67
  13. package/tools/lint-strings/diff.js +137 -0
  14. package/tools/lint-strings/engine.js +163 -0
  15. package/tools/lint-strings/fixtures/connect/lint_connect.go +67 -0
  16. package/tools/lint-strings/fixtures/crd/crd-ref-docs-config.yaml +15 -0
  17. package/tools/lint-strings/fixtures/crd/lint_types.go +57 -0
  18. package/tools/lint-strings/fixtures/helm/lint_values.yaml +47 -0
  19. package/tools/lint-strings/fixtures/metrics/lint_probe.cc +41 -0
  20. package/tools/lint-strings/fixtures/properties/lint_fixture.cc +51 -0
  21. package/tools/lint-strings/fixtures/properties/lint_fixture.h +33 -0
  22. package/tools/lint-strings/fixtures/rpk/lint_cmd.go +69 -0
  23. package/tools/lint-strings/go-source.js +327 -0
  24. package/tools/lint-strings/index.js +229 -0
  25. package/tools/lint-strings/rules/common.js +148 -0
  26. package/tools/lint-strings/rules/verbatim-asciidoc.js +108 -0
  27. package/tools/lint-strings/source-text.js +41 -0
  28. package/tools/lint-strings/surfaces/connect.js +233 -0
  29. package/tools/lint-strings/surfaces/crd.js +239 -0
  30. package/tools/lint-strings/surfaces/helm.js +159 -0
  31. package/tools/lint-strings/surfaces/metrics.js +368 -0
  32. package/tools/lint-strings/surfaces/properties.js +216 -0
  33. package/tools/lint-strings/surfaces/rpk.js +377 -0
  34. package/tools/overrides-audit/README.adoc +152 -0
  35. package/tools/overrides-audit/adapters/connect.js +144 -0
  36. package/tools/overrides-audit/adapters/properties.js +117 -0
  37. package/tools/overrides-audit/adapters/rpk.js +192 -0
  38. package/tools/overrides-audit/classify.js +546 -0
  39. package/tools/overrides-audit/index.js +185 -0
  40. package/tools/preview-string/index.js +245 -0
  41. package/tools/property-extractor/Makefile +9 -4
  42. package/tools/property-extractor/parser.py +26 -0
  43. package/tools/property-extractor/property_extractor.py +41 -0
  44. package/tools/property-extractor/transformers.py +15 -2
  45. package/tools/rpk-docs/detect-platform-commands.js +8 -1
  46. package/tools/rpk-docs/scan-deprecated-commands.js +15 -18
@@ -0,0 +1,229 @@
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+
6
+ const { runRules, mergeResults } = require('./engine')
7
+ const { getChangedLines, classifyDiff, spanIntersects, SURFACE_ROUTES } = require('./diff')
8
+ const { COMMON_RULES } = require('./rules/common')
9
+ const { VERBATIM_ASCIIDOC_RULES } = require('./rules/verbatim-asciidoc')
10
+
11
+ /**
12
+ * doc-tools lint-strings: deterministic linting of user-facing doc strings
13
+ * embedded in engineering source code (the strings doc-tools publishes
14
+ * verbatim to docs.redpanda.com).
15
+ *
16
+ * Registered surfaces. SEAM: new surface modules plug in here - implement
17
+ * ./surfaces/<name>.js with the same contract as the existing modules
18
+ * ({ name, convention, extract({repo, files}), rules, [skipRules] }) and
19
+ * add it to this registry plus ./diff.js SURFACE_ROUTES for diff-mode path
20
+ * routing.
21
+ */
22
+ const SURFACES = {
23
+ properties: require('./surfaces/properties'),
24
+ metrics: require('./surfaces/metrics'),
25
+ rpk: require('./surfaces/rpk'),
26
+ helm: require('./surfaces/helm'),
27
+ crd: require('./surfaces/crd'),
28
+ connect: require('./surfaces/connect')
29
+ }
30
+
31
+ /**
32
+ * Build the rule set for a surface: common rules + verbatim-AsciiDoc rules
33
+ * (for surfaces whose strings ship unescaped) + surface-specific rules.
34
+ * A surface may opt out of specific auto-applied rules via an optional
35
+ * skipRules array (for example, rpk skips too-short: one-line Shorts and
36
+ * flag usages are the convention, not a defect).
37
+ */
38
+ function rulesFor (surface) {
39
+ const rules = [...COMMON_RULES]
40
+ if (surface.convention.verbatim_asciidoc) rules.push(...VERBATIM_ASCIIDOC_RULES)
41
+ rules.push(...surface.rules)
42
+ const skip = new Set(surface.skipRules || [])
43
+ return skip.size > 0 ? rules.filter((rule) => !skip.has(rule.name)) : rules
44
+ }
45
+
46
+ /**
47
+ * Run the linter.
48
+ *
49
+ * @param {Object} options
50
+ * @param {string} options.repo - Path to the engineering checkout (required)
51
+ * @param {string[]} [options.surfaces] - Surface names (default: all registered)
52
+ * @param {string} [options.diffBase] - Declaration-anchored diff mode: lint
53
+ * only declarations whose span intersects lines changed since this ref
54
+ * @param {string[]} [options.skipRules]
55
+ * @param {string[]} [options.onlyRules]
56
+ * @param {Function} [options.log] - Progress logger (stderr by default)
57
+ * @returns {Object} { findings, summary, unsupported_surfaces }
58
+ */
59
+ function lintStrings (options) {
60
+ const {
61
+ repo,
62
+ surfaces = null,
63
+ diffBase = null,
64
+ skipRules = [],
65
+ onlyRules = null,
66
+ log = (msg) => process.stderr.write(`${msg}\n`)
67
+ } = options
68
+
69
+ if (!repo) throw new Error('lint-strings requires --repo <path>')
70
+ const repoPath = path.resolve(repo)
71
+ if (!fs.existsSync(repoPath)) throw new Error(`Repo path does not exist: ${repoPath}`)
72
+
73
+ const requested = surfaces && surfaces.length > 0 ? surfaces : Object.keys(SURFACES)
74
+ for (const name of requested) {
75
+ if (!SURFACES[name]) {
76
+ const known = SURFACE_ROUTES.some((route) => route.surface === name)
77
+ throw new Error(known
78
+ ? `Surface "${name}" is routed but has no extractor registered yet. Registered: ${Object.keys(SURFACES).join(', ')}`
79
+ : `Unknown surface "${name}". Registered: ${Object.keys(SURFACES).join(', ')}`)
80
+ }
81
+ }
82
+
83
+ const results = []
84
+ const unsupportedSurfaces = []
85
+
86
+ if (diffBase) {
87
+ const changed = getChangedLines(repoPath, diffBase)
88
+ const classified = classifyDiff(changed)
89
+
90
+ for (const [surfaceName, files] of Object.entries(classified)) {
91
+ if (!SURFACES[surfaceName]) {
92
+ // Routed by path but no extractor registered yet (rpk/helm/crd/connect
93
+ // until their surface modules land). Report instead of dropping.
94
+ unsupportedSurfaces.push({ surface: surfaceName, files: [...files.keys()] })
95
+ continue
96
+ }
97
+ if (!requested.includes(surfaceName)) continue
98
+
99
+ const surface = SURFACES[surfaceName]
100
+ log(`[${surfaceName}] ${files.size} changed file(s) in diff; extracting declarations at HEAD...`)
101
+ const fileSet = new Set(files.keys())
102
+ const declarations = surface
103
+ .extract({ repo: repoPath, files: fileSet, log })
104
+ .filter((decl) => spanIntersects(decl.line_start, decl.line_end, files.get(decl.file)))
105
+ for (const decl of declarations) decl.in_pr_diff = true
106
+ results.push(runRules(declarations, rulesFor(surface), { skipRules, onlyRules }))
107
+ }
108
+ } else {
109
+ for (const surfaceName of requested) {
110
+ const surface = SURFACES[surfaceName]
111
+ log(`[${surfaceName}] extracting declarations from ${repoPath}...`)
112
+ const declarations = surface.extract({ repo: repoPath, log })
113
+ for (const decl of declarations) decl.in_pr_diff = false
114
+ results.push(runRules(declarations, rulesFor(surface), { skipRules, onlyRules }))
115
+ }
116
+ }
117
+
118
+ const merged = mergeResults(results)
119
+ merged.findings.sort((a, b) =>
120
+ a.surface.localeCompare(b.surface) || a.file.localeCompare(b.file) || (a.line_start || 0) - (b.line_start || 0))
121
+ merged.unsupported_surfaces = unsupportedSurfaces
122
+ return merged
123
+ }
124
+
125
+ /**
126
+ * Human-readable report (the default --format).
127
+ */
128
+ function formatHuman (result) {
129
+ const lines = []
130
+ const { findings, summary } = result
131
+
132
+ for (const finding of findings) {
133
+ const span = finding.line_start != null ? `:${finding.line_start}-${finding.line_end}` : ''
134
+ const diffMark = finding.in_pr_diff ? ' [in PR diff]' : ''
135
+ lines.push(`${finding.file}${span} ${finding.name || '(unresolved name)'} (${finding.surface})${diffMark}`)
136
+ if (finding.string != null) {
137
+ const preview = finding.string.length > 100 ? `${finding.string.slice(0, 100)}...` : finding.string
138
+ lines.push(` string: ${JSON.stringify(preview)}`)
139
+ }
140
+ for (const issue of finding.rules) {
141
+ lines.push(` ${issue.severity.toUpperCase().padEnd(7)} [${issue.id}] ${issue.message}`)
142
+ }
143
+ lines.push('')
144
+ }
145
+
146
+ lines.push('='.repeat(60))
147
+ lines.push('LINT-STRINGS SUMMARY')
148
+ lines.push('='.repeat(60))
149
+ lines.push(`Declarations checked: ${summary.totalDeclarations}`)
150
+ lines.push(`Declarations flagged: ${summary.flaggedDeclarations}`)
151
+ lines.push(`Errors: ${summary.errors} Warnings: ${summary.warnings} Info: ${summary.info}`)
152
+ if (Object.keys(summary.byRule).length > 0) {
153
+ lines.push('\nBy rule:')
154
+ for (const [rule, counts] of Object.entries(summary.byRule).sort()) {
155
+ const parts = []
156
+ if (counts.errors) parts.push(`${counts.errors} errors`)
157
+ if (counts.warnings) parts.push(`${counts.warnings} warnings`)
158
+ if (counts.info) parts.push(`${counts.info} info`)
159
+ lines.push(` ${rule}: ${parts.join(', ')}`)
160
+ }
161
+ }
162
+ if (Object.keys(summary.bySurface).length > 0) {
163
+ lines.push('\nBy surface:')
164
+ for (const [surface, counts] of Object.entries(summary.bySurface).sort()) {
165
+ lines.push(` ${surface}: ${counts.flagged}/${counts.declarations} declarations flagged (${counts.errors} errors, ${counts.warnings} warnings, ${counts.info} info)`)
166
+ }
167
+ }
168
+ for (const entry of result.unsupported_surfaces || []) {
169
+ lines.push(`\nNote: diff touches ${entry.surface} files but that surface has no extractor registered yet: ${entry.files.join(', ')}`)
170
+ }
171
+ return lines.join('\n')
172
+ }
173
+
174
+ /**
175
+ * CLI entry point shared by bin/doc-tools.js and direct invocation
176
+ * (node tools/lint-strings --repo <path> ...).
177
+ *
178
+ * Exit code contract: always 0 (suggest, never block), unless --strict is
179
+ * passed AND there is at least one error-severity finding.
180
+ */
181
+ function runCli (options) {
182
+ let result
183
+ try {
184
+ result = lintStrings({
185
+ repo: options.repo,
186
+ surfaces: options.surface ? String(options.surface).split(',').map((s) => s.trim()).filter(Boolean) : null,
187
+ diffBase: options.diff || null,
188
+ skipRules: options.skipRules ? String(options.skipRules).split(',').map((s) => s.trim()).filter(Boolean) : [],
189
+ onlyRules: options.onlyRules ? String(options.onlyRules).split(',').map((s) => s.trim()).filter(Boolean) : null
190
+ })
191
+ } catch (err) {
192
+ console.error(`Error: ${err.message}`)
193
+ process.exit(2)
194
+ }
195
+
196
+ if (options.format === 'json') {
197
+ console.log(JSON.stringify(result, null, 2))
198
+ } else {
199
+ console.log(formatHuman(result))
200
+ }
201
+
202
+ if (options.strict && result.summary.errors > 0) process.exit(1)
203
+ process.exit(0)
204
+ }
205
+
206
+ module.exports = { lintStrings, formatHuman, runCli, SURFACES, rulesFor }
207
+
208
+ // Direct usage: node tools/lint-strings --repo <path> [--surface a,b]
209
+ // [--diff <base>] [--format json|human] [--strict]
210
+ if (require.main === module) {
211
+ const args = process.argv.slice(2)
212
+ const options = {}
213
+ for (let i = 0; i < args.length; i++) {
214
+ const arg = args[i]
215
+ if (arg === '--repo') options.repo = args[++i]
216
+ else if (arg === '--surface') options.surface = args[++i]
217
+ else if (arg === '--diff') options.diff = args[++i]
218
+ else if (arg === '--format') options.format = args[++i]
219
+ else if (arg === '--skip-rules') options.skipRules = args[++i]
220
+ else if (arg === '--only-rules') options.onlyRules = args[++i]
221
+ else if (arg === '--strict') options.strict = true
222
+ else {
223
+ console.error(`Unknown argument: ${arg}`)
224
+ console.error('Usage: node tools/lint-strings --repo <path> [--surface a,b] [--diff <base>] [--format json|human] [--skip-rules a,b] [--only-rules a,b] [--strict]')
225
+ process.exit(2)
226
+ }
227
+ }
228
+ runCli(options)
229
+ }
@@ -0,0 +1,148 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Rules that apply to every doc-string surface.
5
+ *
6
+ * Rule shape follows tools/rpk-docs/validate-output.js VALIDATION_RULES:
7
+ * { name, description, severity, check(decl) -> issues[] }.
8
+ * `decl` is the declaration object documented in ../engine.js.
9
+ */
10
+
11
+ /**
12
+ * Jargon that ships verbatim to docs.redpanda.com but means nothing to users
13
+ * unless expanded. Matched on word boundaries (underscores count as word
14
+ * characters, so `archival_meta_stm` does not trip the `stm` entry).
15
+ */
16
+ const JARGON_TERMS = ['NTP', 'seastar', 'nullopt', 'stm', 'smp']
17
+
18
+ /**
19
+ * Tokenize a name or description for tautology comparison: strip `_`/`-`,
20
+ * case-fold, split on non-alphanumerics.
21
+ */
22
+ function tokenize (text) {
23
+ return String(text)
24
+ .toLowerCase()
25
+ .split(/[^a-z0-9]+/)
26
+ .filter(Boolean)
27
+ }
28
+
29
+ /**
30
+ * Two tokens "match" when one is a prefix of the other (so `id` matches
31
+ * `identifier`), with a 2-character minimum to avoid single-letter noise.
32
+ */
33
+ function tokensMatch (a, b) {
34
+ const [short, long] = a.length <= b.length ? [a, b] : [b, a]
35
+ return short.length >= 2 && long.startsWith(short)
36
+ }
37
+
38
+ /**
39
+ * True when the normalized description is just the normalized name:
40
+ * same token count, and every description token pairs with a distinct name
41
+ * token (any order, prefix-tolerant).
42
+ */
43
+ function isNameEcho (name, description) {
44
+ const nameTokens = tokenize(name)
45
+ const descTokens = tokenize(description.replace(/[.!?]\s*$/, ''))
46
+ if (nameTokens.length === 0 || descTokens.length !== nameTokens.length) return false
47
+
48
+ const used = new Array(nameTokens.length).fill(false)
49
+ for (const descToken of descTokens) {
50
+ const idx = nameTokens.findIndex((nameToken, i) => !used[i] && tokensMatch(descToken, nameToken))
51
+ if (idx === -1) return false
52
+ used[idx] = true
53
+ }
54
+ return true
55
+ }
56
+
57
+ const COMMON_RULES = [
58
+ {
59
+ name: 'empty-description',
60
+ description: 'Declaration ships with no doc string at all',
61
+ severity: 'error',
62
+ check: (decl) => {
63
+ if (decl.string == null || decl.string.trim() === '') {
64
+ return [{ message: 'Description is empty or missing. This ships as a blank entry on docs.redpanda.com.' }]
65
+ }
66
+ return []
67
+ }
68
+ },
69
+ {
70
+ name: 'starts-lowercase',
71
+ description: 'Description starts with a lowercase letter',
72
+ severity: 'warning',
73
+ check: (decl) => {
74
+ const text = (decl.string || '').trim()
75
+ if (!text) return []
76
+ // A description may legitimately open with a backticked literal
77
+ // (for example, "`true` means ..."); only flag bare prose.
78
+ if (text.startsWith('`')) return []
79
+ const firstAlpha = text.match(/[A-Za-z]/)
80
+ if (firstAlpha && firstAlpha.index === 0 && /[a-z]/.test(firstAlpha[0])) {
81
+ // Mixed-case product spellings (gRPC, iOS, jsonPath) are correct as
82
+ // written; only all-lowercase first words are capitalization drift.
83
+ const firstWord = text.split(/\s/, 1)[0]
84
+ if (/[A-Z]/.test(firstWord)) return []
85
+ return [{ message: `Description starts with a lowercase letter: "${text.slice(0, 60)}"` }]
86
+ }
87
+ return []
88
+ }
89
+ },
90
+ {
91
+ name: 'name-echo',
92
+ description: 'Description merely restates the declared name (tautology)',
93
+ severity: 'warning',
94
+ check: (decl) => {
95
+ const text = (decl.string || '').trim()
96
+ if (!text || !decl.name) return []
97
+ if (isNameEcho(decl.name, text)) {
98
+ return [{ message: `Description just restates the name "${decl.name}": "${text}". Say what it does, why, or what happens when it changes.` }]
99
+ }
100
+ return []
101
+ }
102
+ },
103
+ {
104
+ name: 'too-short',
105
+ description: 'Description is under 20 characters',
106
+ severity: 'warning',
107
+ check: (decl) => {
108
+ const text = (decl.string || '').trim()
109
+ if (!text) return [] // empty-description owns the empty case
110
+ if (text.length < 20) {
111
+ return [{ message: `Description is only ${text.length} characters: "${text}". Too short to explain behavior, units, or impact.` }]
112
+ }
113
+ return []
114
+ }
115
+ },
116
+ {
117
+ name: 'unexpanded-jargon',
118
+ description: 'Internal jargon that ships to users unexpanded',
119
+ severity: 'warning',
120
+ check: (decl) => {
121
+ const text = decl.string || ''
122
+ if (!text) return []
123
+ const issues = []
124
+ for (const term of JARGON_TERMS) {
125
+ const pattern = new RegExp(`\\b${term}\\b`, 'i')
126
+ if (pattern.test(text)) {
127
+ issues.push({ message: `Contains internal jargon "${term}". Expand or replace it; users do not know this term.` })
128
+ }
129
+ }
130
+ return issues
131
+ }
132
+ },
133
+ {
134
+ name: 'unbalanced-backticks',
135
+ description: 'Odd number of backticks ships broken markup',
136
+ severity: 'error',
137
+ check: (decl) => {
138
+ const text = decl.string || ''
139
+ const count = (text.match(/`/g) || []).length
140
+ if (count % 2 !== 0) {
141
+ return [{ message: `Unbalanced backticks (${count} found). The rendered page ships broken monospace markup.` }]
142
+ }
143
+ return []
144
+ }
145
+ }
146
+ ]
147
+
148
+ module.exports = { COMMON_RULES, JARGON_TERMS, isNameEcho, tokenize }
@@ -0,0 +1,108 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Rules for verbatim-AsciiDoc surfaces (properties, metrics, crd, connect).
5
+ *
6
+ * These strings ship to docs.redpanda.com with ZERO escaping: a raw `|`
7
+ * breaks the containing table cell, an unknown `{attr}` renders literally or
8
+ * trips attribute-missing handling, and a half-written macro ships as broken
9
+ * markup. rpk is exempt: its formatDescription() transformer rewrites the
10
+ * string before publication.
11
+ */
12
+
13
+ /**
14
+ * Product attributes that are legitimately available on rendered pages.
15
+ * Anything else inside `{...}` is not substituted and ships broken.
16
+ * Agent-extensible: add attributes here as surfaces grow.
17
+ */
18
+ const KNOWN_ATTRIBUTES = new Set([
19
+ 'latest-version',
20
+ 'latest-redpanda-version',
21
+ 'latest-redpanda-tag',
22
+ 'latest-console-version',
23
+ 'latest-operator-version',
24
+ 'latest-connect-version',
25
+ 'full-version',
26
+ 'page-version',
27
+ 'empty'
28
+ ])
29
+
30
+ /** AsciiDoc inline macros used in embedded doc strings. */
31
+ const MACRO_NAMES = ['xref', 'glossterm', 'config_ref']
32
+
33
+ /**
34
+ * Scan text for unescaped `|` characters outside backtick spans.
35
+ * Returns the count found.
36
+ */
37
+ function countRawPipes (text) {
38
+ let inBacktick = false
39
+ let count = 0
40
+ for (let i = 0; i < text.length; i++) {
41
+ const ch = text[i]
42
+ if (ch === '`') {
43
+ inBacktick = !inBacktick
44
+ continue
45
+ }
46
+ if (ch === '|' && !inBacktick && text[i - 1] !== '\\') count++
47
+ }
48
+ return count
49
+ }
50
+
51
+ const VERBATIM_ASCIIDOC_RULES = [
52
+ {
53
+ name: 'raw-pipe',
54
+ description: 'Raw unescaped | outside backticks breaks AsciiDoc tables',
55
+ severity: 'error',
56
+ check: (decl) => {
57
+ const text = decl.string || ''
58
+ if (!text.includes('|')) return []
59
+ const count = countRawPipes(text)
60
+ if (count > 0) {
61
+ return [{ message: `Contains ${count} raw unescaped "|" outside backticks. This string is rendered verbatim inside AsciiDoc tables; escape it (\\|) or wrap it in backticks.` }]
62
+ }
63
+ return []
64
+ }
65
+ },
66
+ {
67
+ name: 'unknown-attribute',
68
+ description: '{attr} reference that is not a known product attribute',
69
+ severity: 'warning',
70
+ check: (decl) => {
71
+ const text = decl.string || ''
72
+ const issues = []
73
+ const pattern = /\{([a-z][a-z0-9_-]*)\}/g
74
+ let match
75
+ while ((match = pattern.exec(text)) !== null) {
76
+ // A backslash-escaped \{...} is not substituted; authors escape
77
+ // deliberately (URL path templates, JSON pointers).
78
+ if (match.index > 0 && text[match.index - 1] === '\\') continue
79
+ if (!KNOWN_ATTRIBUTES.has(match[1])) {
80
+ issues.push({ message: `"{${match[1]}}" looks like an AsciiDoc attribute reference but is not a known product attribute. It will not be substituted on the rendered page.` })
81
+ }
82
+ }
83
+ return issues
84
+ }
85
+ },
86
+ {
87
+ name: 'broken-macro',
88
+ description: 'xref:/glossterm:/config_ref: macro with unbalanced brackets',
89
+ severity: 'error',
90
+ check: (decl) => {
91
+ const text = decl.string || ''
92
+ const issues = []
93
+ const pattern = new RegExp(`\\b(${MACRO_NAMES.join('|')}):`, 'g')
94
+ let match
95
+ while ((match = pattern.exec(text)) !== null) {
96
+ const rest = text.slice(match.index + match[0].length)
97
+ // Well-formed: <target>[<label>] with the target free of whitespace
98
+ // and brackets, and the label closed before end of string.
99
+ if (!/^[^\s[\]]*\[[^\]]*\]/.test(rest)) {
100
+ issues.push({ message: `Broken ${match[1]}: macro syntax near "${text.slice(match.index, match.index + 50)}". Expected ${match[1]}:target[label] with balanced brackets.` })
101
+ }
102
+ }
103
+ return issues
104
+ }
105
+ }
106
+ ]
107
+
108
+ module.exports = { VERBATIM_ASCIIDOC_RULES, KNOWN_ATTRIBUTES, MACRO_NAMES, countRawPipes }
@@ -0,0 +1,41 @@
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+
6
+ /**
7
+ * Shared helper for surface modules: read the exact source lines of a
8
+ * declaration span so findings can carry `declaration_text` (the text a PR
9
+ * suggestion block would replace).
10
+ */
11
+ class SourceCache {
12
+ constructor (repo) {
13
+ this.repo = repo
14
+ this.files = new Map()
15
+ }
16
+
17
+ lines (file) {
18
+ if (!this.files.has(file)) {
19
+ const absPath = path.isAbsolute(file) ? file : path.join(this.repo, file)
20
+ try {
21
+ this.files.set(file, fs.readFileSync(absPath, 'utf8').split('\n'))
22
+ } catch (err) {
23
+ this.files.set(file, null)
24
+ }
25
+ }
26
+ return this.files.get(file)
27
+ }
28
+
29
+ /**
30
+ * Exact source text of the inclusive 1-indexed span [lineStart, lineEnd],
31
+ * or null when the file or span is unavailable.
32
+ */
33
+ span (file, lineStart, lineEnd) {
34
+ if (lineStart == null || lineEnd == null) return null
35
+ const lines = this.lines(file)
36
+ if (!lines || lineStart < 1 || lineEnd > lines.length) return null
37
+ return lines.slice(lineStart - 1, lineEnd).join('\n')
38
+ }
39
+ }
40
+
41
+ module.exports = { SourceCache }