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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/bin/doc-tools-mcp.js +88 -0
  2. package/bin/doc-tools.js +252 -102
  3. package/bin/mcp-tools/index.js +20 -0
  4. package/bin/mcp-tools/lint-doc-strings.js +75 -0
  5. package/bin/mcp-tools/overrides-audit.js +100 -0
  6. package/bin/mcp-tools/preview-doc-string.js +71 -0
  7. package/cli-utils/diff-utils.js +77 -0
  8. package/cli-utils/helm-commented-values.js +172 -23
  9. package/package.json +2 -1
  10. package/tools/generate-cli-docs.js +28 -67
  11. package/tools/lint-strings/diff.js +137 -0
  12. package/tools/lint-strings/engine.js +163 -0
  13. package/tools/lint-strings/fixtures/connect/lint_connect.go +67 -0
  14. package/tools/lint-strings/fixtures/crd/crd-ref-docs-config.yaml +15 -0
  15. package/tools/lint-strings/fixtures/crd/lint_types.go +57 -0
  16. package/tools/lint-strings/fixtures/helm/lint_values.yaml +47 -0
  17. package/tools/lint-strings/fixtures/metrics/lint_probe.cc +41 -0
  18. package/tools/lint-strings/fixtures/properties/lint_fixture.cc +51 -0
  19. package/tools/lint-strings/fixtures/properties/lint_fixture.h +33 -0
  20. package/tools/lint-strings/fixtures/rpk/lint_cmd.go +69 -0
  21. package/tools/lint-strings/go-source.js +327 -0
  22. package/tools/lint-strings/index.js +229 -0
  23. package/tools/lint-strings/rules/common.js +148 -0
  24. package/tools/lint-strings/rules/verbatim-asciidoc.js +108 -0
  25. package/tools/lint-strings/source-text.js +41 -0
  26. package/tools/lint-strings/surfaces/connect.js +233 -0
  27. package/tools/lint-strings/surfaces/crd.js +239 -0
  28. package/tools/lint-strings/surfaces/helm.js +159 -0
  29. package/tools/lint-strings/surfaces/metrics.js +368 -0
  30. package/tools/lint-strings/surfaces/properties.js +216 -0
  31. package/tools/lint-strings/surfaces/rpk.js +377 -0
  32. package/tools/overrides-audit/README.adoc +152 -0
  33. package/tools/overrides-audit/adapters/connect.js +144 -0
  34. package/tools/overrides-audit/adapters/properties.js +117 -0
  35. package/tools/overrides-audit/adapters/rpk.js +192 -0
  36. package/tools/overrides-audit/classify.js +546 -0
  37. package/tools/overrides-audit/index.js +185 -0
  38. package/tools/preview-string/index.js +245 -0
  39. package/tools/property-extractor/Makefile +9 -4
  40. package/tools/property-extractor/parser.py +26 -0
  41. package/tools/property-extractor/property_extractor.py +41 -0
  42. package/tools/property-extractor/transformers.py +15 -2
  43. package/tools/rpk-docs/detect-platform-commands.js +8 -1
  44. package/tools/rpk-docs/scan-deprecated-commands.js +15 -18
@@ -0,0 +1,33 @@
1
+ // Fixture header for lint-strings tests. The declarations mirror the shapes
2
+ // in redpanda's src/v/config/configuration.h so the real property extractor
3
+ // can parse the matching .cc file.
4
+ #pragma once
5
+
6
+ #include "config/property.h"
7
+
8
+ namespace config {
9
+
10
+ struct lint_fixture_config {
11
+ // Known-bad: empty description (mirrors configuration.cc
12
+ // compaction_ctrl_update_interval_ms)
13
+ property<std::chrono::milliseconds> compaction_ctrl_update_interval_ms;
14
+
15
+ // Known-bad: unbalanced backticks (mirrors configuration.cc
16
+ // cloud_storage_credentials_source)
17
+ enum_property<ss::sstring> cloud_storage_credentials_source;
18
+
19
+ // Known-bad: lowercase-start fragment, no terminal period
20
+ property<double> cloud_storage_upload_ctrl_p_coeff;
21
+
22
+ // Known-bad: tautology (description just restates the name)
23
+ property<std::optional<ss::sstring>> cluster_id;
24
+
25
+ // Known-bad: "recomended" typo string. Typo detection is NOT a lint rule;
26
+ // this doubles as the lowercase-start + under-20-chars example.
27
+ property<int32_t> write_caching_default_bytes;
28
+
29
+ // Conforming counterpart: must produce zero findings.
30
+ property<int32_t> kafka_batch_max_bytes;
31
+ };
32
+
33
+ } // namespace config
@@ -0,0 +1,69 @@
1
+ // Package lintfixture is a lint-strings test fixture: known-bad rpk
2
+ // declarations with conforming counterparts. It only needs to parse as Go
3
+ // text for the scanner; it is never compiled.
4
+ package lintfixture
5
+
6
+ import (
7
+ "github.com/spf13/afero"
8
+ "github.com/spf13/cobra"
9
+ )
10
+
11
+ // cobra.Command mentioned in a comment must never match: cobra.Command{Use: "fake"}.
12
+
13
+ func newLowercaseShortCommand(fs afero.Fs) *cobra.Command {
14
+ cmd := &cobra.Command{
15
+ Use: "badshort",
16
+ Short: "queries cluster for health overview.",
17
+ Long: `Query the cluster.
18
+
19
+ USAGE DETAILS
20
+
21
+ This section heading above is intended.
22
+
23
+ Some detail text here that is not a heading.
24
+ ====
25
+
26
+ The ==== line above breaks the generated page.
27
+ `,
28
+ Run: func(cmd *cobra.Command, _ []string) {
29
+ // A paren ) and brace } in a string: "don't break (the) scanner".
30
+ },
31
+ }
32
+ cmd.Flags().BoolVarP(&watch, "watch", "w", false, "Blocks and writes out all changes")
33
+ cmd.Flags().StringVar(&dest, "trailing-period", "", "Writes the output to the given path.")
34
+ cmd.Flags().String("verbose-output", "", "verbose output")
35
+ return cmd
36
+ }
37
+
38
+ const conformingLong = `Manage widgets.
39
+
40
+ This is a conforming Long description with no heading-shaped lines,
41
+ resolved through a package-level constant.
42
+ `
43
+
44
+ func newConformingCommand() *cobra.Command {
45
+ cmd := &cobra.Command{
46
+ Use: "widget [NAME]",
47
+ Aliases: []string{"widgets"},
48
+ Short: "Manage widgets in the cluster",
49
+ Long: conformingLong,
50
+ }
51
+ f := cmd.Flags()
52
+ f.IntVar(&count, "retries", 3, "Number of retries before giving up")
53
+ f.DurationVar(&timeout, "timeout", 0, "How long to wait for the cluster to respond, for example 30s")
54
+ cmd.Flags().StringP("format", "f", "text", "Output format, one of text or json")
55
+ // Dynamic usage strings stay unverifiable, never errors:
56
+ cmd.Flags().BoolP("help", "h", false, "Help for "+cmd.Name())
57
+ return cmd
58
+ }
59
+
60
+ // A multiline Short: the rpk convention is a one-line Short, so this is the
61
+ // known-bad counterpart for the rpk-short-multiline rule. Everything else
62
+ // about it conforms, so it isolates that one rule.
63
+ func newMultilineShortCommand() *cobra.Command {
64
+ return &cobra.Command{
65
+ Use: "multiline",
66
+ Short: `Manage the widget cache
67
+ and everything attached to it`,
68
+ }
69
+ }
@@ -0,0 +1,327 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Shared Go source scanning helpers for the rpk, crd, and connect surfaces.
5
+ *
6
+ * Same philosophy as the metrics scanner (surfaces/metrics.js): a
7
+ * comment-masked regex/paren scanner over real source text, never a compiler.
8
+ * Go adds two string shapes the C++ scanner does not have: raw strings
9
+ * (`...` backticks, no escapes, literal newlines) and rune literals ('x').
10
+ * All helpers preserve offsets and newlines so line numbers stay exact.
11
+ */
12
+
13
+ /**
14
+ * Replace // and /* *\/ comments with spaces, preserving every offset and
15
+ * newline. String and rune literal contents are left untouched, so a "//"
16
+ * inside a string never masks anything.
17
+ */
18
+ function maskComments (content) {
19
+ const out = content.split('')
20
+ let i = 0
21
+ while (i < content.length) {
22
+ const ch = content[i]
23
+ if (ch === '"' || ch === '`' || ch === "'") {
24
+ i = skipString(content, i)
25
+ continue
26
+ }
27
+ if (ch === '/' && content[i + 1] === '/') {
28
+ while (i < content.length && content[i] !== '\n') out[i++] = ' '
29
+ continue
30
+ }
31
+ if (ch === '/' && content[i + 1] === '*') {
32
+ const end = content.indexOf('*/', i + 2)
33
+ const stop = end === -1 ? content.length : end + 2
34
+ while (i < stop) {
35
+ if (content[i] !== '\n') out[i] = ' '
36
+ i++
37
+ }
38
+ continue
39
+ }
40
+ i++
41
+ }
42
+ return out.join('')
43
+ }
44
+
45
+ /**
46
+ * Skip a Go string/rune literal starting at index; returns the index after
47
+ * it. Interpreted strings ("...") and runes ('x') honor backslash escapes;
48
+ * raw strings (`...`) have no escapes and may span lines.
49
+ */
50
+ function skipString (content, start) {
51
+ const quote = content[start]
52
+ let i = start + 1
53
+ if (quote === '`') {
54
+ while (i < content.length && content[i] !== '`') i++
55
+ return Math.min(i + 1, content.length)
56
+ }
57
+ while (i < content.length) {
58
+ if (content[i] === '\\') {
59
+ i += 2
60
+ continue
61
+ }
62
+ if (content[i] === quote) return i + 1
63
+ i++
64
+ }
65
+ return content.length
66
+ }
67
+
68
+ /**
69
+ * Walk forward from an opening delimiter and return the index of its
70
+ * balanced closing delimiter, skipping string/rune literals. Assumes
71
+ * comment-masked content. Returns -1 when unbalanced.
72
+ */
73
+ function findBalancedClose (content, openIndex, open = '(', close = ')') {
74
+ let depth = 0
75
+ let i = openIndex
76
+ while (i < content.length) {
77
+ const ch = content[i]
78
+ if (ch === '"' || ch === '`' || ch === "'") {
79
+ i = skipString(content, i)
80
+ continue
81
+ }
82
+ if (ch === open) depth++
83
+ if (ch === close) {
84
+ depth--
85
+ if (depth === 0) return i
86
+ }
87
+ i++
88
+ }
89
+ return -1
90
+ }
91
+
92
+ /** Decode a Go interpreted-string body (the text between the quotes). */
93
+ function unescapeGo (text) {
94
+ return text.replace(/\\(.)/g, (whole, ch) => {
95
+ if (ch === 'n') return '\n'
96
+ if (ch === 't') return '\t'
97
+ if (ch === 'r') return '\r'
98
+ return ch
99
+ })
100
+ }
101
+
102
+ /**
103
+ * Split argument text on top-level commas (string, paren, brace, and
104
+ * bracket aware). Assumes comment-masked content.
105
+ */
106
+ function splitTopLevelArgs (argText) {
107
+ const args = []
108
+ let start = 0
109
+ let depth = 0
110
+ let i = 0
111
+ while (i < argText.length) {
112
+ const ch = argText[i]
113
+ if (ch === '"' || ch === '`' || ch === "'") {
114
+ i = skipString(argText, i)
115
+ continue
116
+ }
117
+ if (ch === '(' || ch === '{' || ch === '[') depth++
118
+ if (ch === ')' || ch === '}' || ch === ']') depth--
119
+ if (ch === ',' && depth === 0) {
120
+ args.push(argText.slice(start, i))
121
+ start = i + 1
122
+ }
123
+ i++
124
+ }
125
+ const last = argText.slice(start)
126
+ if (last.trim() !== '' || args.length > 0) args.push(last)
127
+ return args
128
+ }
129
+
130
+ /**
131
+ * Collect same-file string constants: `const x = "..."` and const blocks.
132
+ * Only constants whose value is purely (concatenated) string literals are
133
+ * recorded - anything else stays unresolvable so it can never fake a
134
+ * verifiable description.
135
+ *
136
+ * @param {string} masked - Comment-masked file content
137
+ * @returns {Map<string, string>} identifier -> string value
138
+ */
139
+ function collectStringConsts (masked) {
140
+ const consts = new Map()
141
+ const pattern = /\bconst\b/g
142
+ let match
143
+ while ((match = pattern.exec(masked)) !== null) {
144
+ let i = match.index + match[0].length
145
+ while (i < masked.length && /[ \t]/.test(masked[i])) i++
146
+ if (masked[i] === '(') {
147
+ const close = findBalancedClose(masked, i)
148
+ if (close === -1) continue
149
+ collectConstEntries(masked.slice(i + 1, close), consts)
150
+ pattern.lastIndex = close + 1
151
+ } else {
152
+ // Single-const form: the statement ends at the first newline OUTSIDE
153
+ // a string (a raw-string value may span lines), unless that newline
154
+ // follows a `+` continuation - Go allows a string concatenation to
155
+ // wrap across lines with no surrounding parens.
156
+ let j = i
157
+ while (j < masked.length) {
158
+ if (masked[j] === '"' || masked[j] === '`' || masked[j] === "'") {
159
+ j = skipString(masked, j)
160
+ continue
161
+ }
162
+ if (masked[j] === '\n') {
163
+ let k = j - 1
164
+ while (k >= i && /[ \t\r]/.test(masked[k])) k--
165
+ if (k >= i && masked[k] === '+') {
166
+ j++
167
+ continue
168
+ }
169
+ break
170
+ }
171
+ j++
172
+ }
173
+ collectConstEntries(masked.slice(i, j), consts)
174
+ pattern.lastIndex = j
175
+ }
176
+ }
177
+ return consts
178
+ }
179
+
180
+ /** Parse `name [type] = <string expr>` lines inside a const region. */
181
+ function collectConstEntries (regionText, consts) {
182
+ // Split on newlines OUTSIDE strings (raw strings may span lines), unless
183
+ // the newline follows a `+` continuation - a string concatenation may
184
+ // wrap across lines with no surrounding parens, and must stay one entry.
185
+ const lines = []
186
+ let start = 0
187
+ let i = 0
188
+ while (i < regionText.length) {
189
+ const ch = regionText[i]
190
+ if (ch === '"' || ch === '`' || ch === "'") {
191
+ i = skipString(regionText, i)
192
+ continue
193
+ }
194
+ if (ch === '\n') {
195
+ let k = i - 1
196
+ while (k >= start && /[ \t\r]/.test(regionText[k])) k--
197
+ if (k >= start && regionText[k] === '+') {
198
+ i++
199
+ continue
200
+ }
201
+ lines.push(regionText.slice(start, i))
202
+ start = i + 1
203
+ }
204
+ i++
205
+ }
206
+ lines.push(regionText.slice(start))
207
+
208
+ for (const line of lines) {
209
+ const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*(?:[A-Za-z_][A-Za-z0-9_.]*\s*)?=\s*(.+)$/s)
210
+ if (!m) continue
211
+ const { verifiable, value } = evalStringExpr(m[2], consts)
212
+ if (verifiable) consts.set(m[1], value)
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Evaluate a Go expression expected to produce a string: a sequence of
218
+ * string literals and known identifiers joined by `+`. Anything else
219
+ * (function calls, fmt.Sprintf, selectors, unknown identifiers) makes the
220
+ * result unverifiable - the caller reports it, never guesses.
221
+ *
222
+ * @param {string} exprText - Comment-masked expression text
223
+ * @param {Map<string, string>} [consts] - Same-file string constants
224
+ * @returns {{ verifiable: boolean, value: string|null, sawString: boolean }}
225
+ */
226
+ function evalStringExpr (exprText, consts = new Map()) {
227
+ const parts = []
228
+ let verifiable = true
229
+ let sawString = false
230
+ let i = 0
231
+ while (i < exprText.length) {
232
+ const ch = exprText[i]
233
+ if (/\s/.test(ch) || ch === '+') {
234
+ i++
235
+ continue
236
+ }
237
+ if (ch === '"') {
238
+ const end = skipString(exprText, i)
239
+ parts.push(unescapeGo(exprText.slice(i + 1, end - 1)))
240
+ sawString = true
241
+ i = end
242
+ continue
243
+ }
244
+ if (ch === '`') {
245
+ const end = skipString(exprText, i)
246
+ parts.push(exprText.slice(i + 1, end - 1))
247
+ sawString = true
248
+ i = end
249
+ continue
250
+ }
251
+ if (/[A-Za-z_]/.test(ch)) {
252
+ let j = i
253
+ while (j < exprText.length && /[A-Za-z0-9_.]/.test(exprText[j])) j++
254
+ const ident = exprText.slice(i, j)
255
+ // A following "(" means a call - unverifiable.
256
+ let k = j
257
+ while (k < exprText.length && /\s/.test(exprText[k])) k++
258
+ if (exprText[k] === '(') {
259
+ verifiable = false
260
+ const close = findBalancedClose(exprText, k)
261
+ i = close === -1 ? exprText.length : close + 1
262
+ continue
263
+ }
264
+ if (consts.has(ident)) {
265
+ parts.push(consts.get(ident))
266
+ sawString = true
267
+ } else {
268
+ verifiable = false
269
+ }
270
+ i = j
271
+ continue
272
+ }
273
+ // Any other token (&, numbers, composite literals, ...) is not a string.
274
+ verifiable = false
275
+ if (ch === '(' || ch === '{' || ch === '[') {
276
+ const close = findBalancedClose(exprText, i, ch, ch === '(' ? ')' : ch === '{' ? '}' : ']')
277
+ i = close === -1 ? exprText.length : close + 1
278
+ continue
279
+ }
280
+ i++
281
+ }
282
+ if (!sawString) return { verifiable: false, value: null, sawString }
283
+ return { verifiable, value: verifiable ? parts.join('') : null, sawString }
284
+ }
285
+
286
+ /** 1-indexed line number of a character offset. */
287
+ function lineOf (content, index) {
288
+ let line = 1
289
+ for (let i = 0; i < index; i++) {
290
+ if (content[i] === '\n') line++
291
+ }
292
+ return line
293
+ }
294
+
295
+ const fs = require('fs')
296
+ const path = require('path')
297
+
298
+ /**
299
+ * Recursively collect .go files under root (repo-relative paths), skipping
300
+ * _test.go files, testdata/fuzzing dirs, and hidden dirs.
301
+ */
302
+ function collectGoFiles (root, base = root, out = []) {
303
+ if (!fs.existsSync(root)) return out
304
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
305
+ if (entry.name.startsWith('.')) continue
306
+ const fullPath = path.join(root, entry.name)
307
+ if (entry.isDirectory()) {
308
+ if (entry.name === 'testdata' || entry.name === 'fuzzing' || entry.name === 'node_modules') continue
309
+ collectGoFiles(fullPath, base, out)
310
+ } else if (entry.name.endsWith('.go') && !entry.name.endsWith('_test.go')) {
311
+ out.push(path.relative(base, fullPath))
312
+ }
313
+ }
314
+ return out
315
+ }
316
+
317
+ module.exports = {
318
+ maskComments,
319
+ skipString,
320
+ findBalancedClose,
321
+ unescapeGo,
322
+ splitTopLevelArgs,
323
+ collectStringConsts,
324
+ evalStringExpr,
325
+ lineOf,
326
+ collectGoFiles
327
+ }
@@ -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
+ }