pi-lens 2.0.37 → 2.0.39

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/CHANGELOG.md +16 -0
  2. package/README.md +1 -1
  3. package/clients/architect-client.js +50 -20
  4. package/clients/architect-client.ts +61 -22
  5. package/clients/ast-grep-client.js +32 -127
  6. package/clients/ast-grep-client.test.js +2 -14
  7. package/clients/ast-grep-client.test.ts +2 -16
  8. package/clients/ast-grep-client.ts +34 -150
  9. package/clients/auto-loop.js +117 -0
  10. package/clients/auto-loop.ts +171 -0
  11. package/clients/biome-client.js +25 -22
  12. package/clients/biome-client.ts +28 -25
  13. package/clients/complexity-client.js +111 -74
  14. package/clients/complexity-client.ts +149 -105
  15. package/clients/dependency-checker.js +16 -30
  16. package/clients/dependency-checker.ts +15 -35
  17. package/clients/fix-scanners.js +195 -0
  18. package/clients/fix-scanners.ts +297 -0
  19. package/clients/interviewer-templates.js +75 -0
  20. package/clients/interviewer-templates.ts +90 -0
  21. package/clients/interviewer.js +73 -101
  22. package/clients/interviewer.ts +195 -140
  23. package/clients/knip-client.js +12 -4
  24. package/clients/knip-client.ts +21 -16
  25. package/clients/metrics-history.js +215 -0
  26. package/clients/metrics-history.ts +300 -0
  27. package/clients/scan-architectural-debt.js +62 -72
  28. package/clients/scan-architectural-debt.ts +79 -64
  29. package/clients/scan-utils.js +98 -0
  30. package/clients/scan-utils.ts +112 -0
  31. package/clients/sg-runner.js +138 -0
  32. package/clients/sg-runner.ts +168 -0
  33. package/clients/subprocess-client.js +0 -37
  34. package/clients/subprocess-client.ts +0 -60
  35. package/clients/ts-service.ts +1 -7
  36. package/clients/type-safety-client.js +3 -8
  37. package/clients/type-safety-client.ts +10 -14
  38. package/clients/typescript-client.js +55 -56
  39. package/clients/typescript-client.ts +94 -52
  40. package/default-architect.yaml +87 -0
  41. package/index.ts +143 -1165
  42. package/package.json +2 -1
  43. package/rules/ast-grep-rules/rules/large-class.yml +6 -2
  44. package/rules/ast-grep-rules/rules/long-method.yml +1 -1
  45. package/rules/ast-grep-rules/rules/no-single-char-var.yml +2 -2
  46. package/rules/ast-grep-rules/rules/switch-without-default.yml +0 -12
@@ -2,11 +2,14 @@
2
2
  * Shared architectural debt scanning — used by booboo-fix and booboo-refactor.
3
3
  * Scans ast-grep skip rules + complexity metrics + architect.yaml rules.
4
4
  */
5
+
6
+ import { spawnSync } from "node:child_process";
5
7
  import * as fs from "node:fs";
6
8
  import * as path from "node:path";
9
+ import type { ArchitectClient } from "./architect-client.js";
7
10
  import type { AstGrepClient } from "./ast-grep-client.js";
8
11
  import type { ComplexityClient } from "./complexity-client.js";
9
- import type { ArchitectClient } from "./architect-client.js";
12
+ import { getSourceFiles, parseAstGrepJson } from "./scan-utils.js";
10
13
 
11
14
  export type SkipIssue = { rule: string; line: number; note: string };
12
15
  export type FileMetrics = { mi: number; cognitive: number; nesting: number };
@@ -25,29 +28,41 @@ export function scanSkipViolations(
25
28
  const skipByFile = new Map<string, SkipIssue[]>();
26
29
  if (!astGrepClient.isAvailable()) return skipByFile;
27
30
 
28
- const { spawnSync } = require("node:child_process") as typeof import("node:child_process");
29
31
  const sgResult = spawnSync(
30
32
  "npx",
31
33
  [
32
- "sg", "scan", "--config", configPath, "--json",
33
- "--globs", "!**/*.test.ts", "--globs", "!**/*.spec.ts",
34
- "--globs", "!**/test-utils.ts", "--globs", "!**/.pi-lens/**",
34
+ "sg",
35
+ "scan",
36
+ "--config",
37
+ configPath,
38
+ "--json",
39
+ "--globs",
40
+ "!**/*.test.ts",
41
+ "--globs",
42
+ "!**/*.spec.ts",
43
+ "--globs",
44
+ "!**/test-utils.ts",
45
+ "--globs",
46
+ "!**/.pi-lens/**",
35
47
  ...(isTsProject ? ["--globs", "!**/*.js"] : []),
36
48
  targetPath,
37
49
  ],
38
- { encoding: "utf-8", timeout: 30000, shell: true, maxBuffer: 32 * 1024 * 1024 },
50
+ {
51
+ encoding: "utf-8",
52
+ timeout: 30000,
53
+ shell: true,
54
+ maxBuffer: 32 * 1024 * 1024,
55
+ },
39
56
  );
40
57
 
41
- const raw = sgResult.stdout?.trim() ?? "";
42
- // biome-ignore lint/suspicious/noExplicitAny: ast-grep JSON output is untyped
43
- const items: Record<string, any>[] = raw.startsWith("[")
44
- ? (() => { try { return JSON.parse(raw); } catch { return []; } })()
45
- : raw.split("\n").flatMap((l: string) => { try { return [JSON.parse(l)]; } catch { return []; } });
58
+ const items = parseAstGrepJson(sgResult.stdout?.trim() ?? "");
46
59
 
47
60
  for (const item of items) {
48
61
  const rule = item.ruleId || item.rule?.title || item.name || "unknown";
49
62
  if (!skipRules.has(rule)) continue;
50
- const line = (item.labels?.[0]?.range?.start?.line ?? item.range?.start?.line ?? 0) + 1;
63
+ const line =
64
+ (item.labels?.[0]?.range?.start?.line ?? item.range?.start?.line ?? 0) +
65
+ 1;
51
66
  const absFile = path.resolve(item.file ?? "");
52
67
  const list = skipByFile.get(absFile) ?? [];
53
68
  list.push({ rule, line, note: ruleActions[rule]?.note ?? "" });
@@ -65,25 +80,22 @@ export function scanComplexityMetrics(
65
80
  isTsProject: boolean,
66
81
  ): Map<string, FileMetrics> {
67
82
  const metricsByFile = new Map<string, FileMetrics>();
83
+ const files = getSourceFiles(targetPath, isTsProject);
68
84
 
69
- const scanDir = (dir: string) => {
70
- if (!fs.existsSync(dir)) return;
71
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
72
- const full = path.join(dir, entry.name);
73
- if (entry.isDirectory()) {
74
- if (["node_modules", ".git", "dist", "build", ".next", ".pi-lens"].includes(entry.name)) continue;
75
- scanDir(full);
76
- } else if (
77
- complexityClient.isSupportedFile(full) &&
78
- !/\.(test|spec)\.[jt]sx?$/.test(entry.name) &&
79
- !(isTsProject && /\.js$/.test(entry.name))
80
- ) {
81
- const m = complexityClient.analyzeFile(full);
82
- if (m) metricsByFile.set(full, { mi: m.maintainabilityIndex, cognitive: m.cognitiveComplexity, nesting: m.maxNestingDepth });
83
- }
85
+ for (const full of files) {
86
+ if (
87
+ complexityClient.isSupportedFile(full) &&
88
+ !/\.(test|spec)\.[jt]sx?$/.test(path.basename(full))
89
+ ) {
90
+ const m = complexityClient.analyzeFile(full);
91
+ if (m)
92
+ metricsByFile.set(full, {
93
+ mi: m.maintainabilityIndex,
94
+ cognitive: m.cognitiveComplexity,
95
+ nesting: m.maxNestingDepth,
96
+ });
84
97
  }
85
- };
86
- scanDir(targetPath);
98
+ }
87
99
  return metricsByFile;
88
100
  }
89
101
 
@@ -98,37 +110,31 @@ export function scanArchitectViolations(
98
110
  const violationsByFile = new Map<string, string[]>();
99
111
  if (!architectClient.hasConfig()) return violationsByFile;
100
112
 
101
- const scanDir = (dir: string) => {
102
- if (!fs.existsSync(dir)) return;
103
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
104
- const full = path.join(dir, entry.name);
105
- if (entry.isDirectory()) {
106
- if (["node_modules", ".git", "dist", "build", ".next", ".pi-lens"].includes(entry.name)) continue;
107
- scanDir(full);
108
- } else if (/\.(ts|tsx|js|jsx|py|go|rs)$/.test(entry.name)) {
109
- const relPath = path.relative(targetPath, full).replace(/\\/g, "/");
110
- const content = fs.readFileSync(full, "utf-8");
111
- const lineCount = content.split("\n").length;
112
- const msgs: string[] = [];
113
-
114
- // Check pattern violations
115
- for (const v of architectClient.checkFile(relPath, content)) {
116
- msgs.push(v.message);
117
- }
118
-
119
- // Check file size
120
- const sizeV = architectClient.checkFileSize(relPath, lineCount);
121
- if (sizeV) {
122
- msgs.push(sizeV.message);
123
- }
124
-
125
- if (msgs.length > 0) {
126
- violationsByFile.set(full, msgs);
127
- }
128
- }
113
+ const isTsProject = fs.existsSync(path.join(targetPath, "tsconfig.json"));
114
+ const files = getSourceFiles(targetPath, isTsProject);
115
+
116
+ for (const full of files) {
117
+ const relPath = path.relative(targetPath, full).replace(/\\/g, "/");
118
+ const content = fs.readFileSync(full, "utf-8");
119
+ const lineCount = content.split("\n").length;
120
+ const msgs: string[] = [];
121
+
122
+ // Check pattern violations
123
+ for (const v of architectClient.checkFile(relPath, content)) {
124
+ const lineStr = v.line ? `L${v.line}: ` : "";
125
+ msgs.push(`${lineStr}${v.message}`);
126
+ }
127
+
128
+ // Check file size
129
+ const sizeV = architectClient.checkFileSize(relPath, lineCount);
130
+ if (sizeV) {
131
+ msgs.push(sizeV.message);
129
132
  }
130
- };
131
- scanDir(targetPath);
133
+
134
+ if (msgs.length > 0) {
135
+ violationsByFile.set(full, msgs);
136
+ }
137
+ }
132
138
  return violationsByFile;
133
139
  }
134
140
 
@@ -150,9 +156,14 @@ export function scoreFiles(
150
156
  let score = 0;
151
157
  const m = metricsByFile.get(file);
152
158
  if (m) {
153
- if (m.mi < 20) score += 5; else if (m.mi < 40) score += 3; else if (m.mi < 60) score += 1;
154
- if (m.cognitive > 300) score += 4; else if (m.cognitive > 150) score += 2; else if (m.cognitive > 80) score += 1;
155
- if (m.nesting > 8) score += 2; else if (m.nesting > 5) score += 1;
159
+ if (m.mi < 20) score += 5;
160
+ else if (m.mi < 40) score += 3;
161
+ else if (m.mi < 60) score += 1;
162
+ if (m.cognitive > 300) score += 4;
163
+ else if (m.cognitive > 150) score += 2;
164
+ else if (m.cognitive > 80) score += 1;
165
+ if (m.nesting > 8) score += 2;
166
+ else if (m.nesting > 5) score += 1;
156
167
  }
157
168
  for (const issue of skipByFile.get(file) ?? []) {
158
169
  if (issue.rule === "large-class") score += 5;
@@ -182,9 +193,13 @@ export function extractCodeSnippet(
182
193
  ): { snippet: string; start: number; end: number } | null {
183
194
  try {
184
195
  const fileLines = fs.readFileSync(filePath, "utf-8").split("\n");
185
- const start = Math.max(0, (firstLine - 1) - contextLines);
196
+ const start = Math.max(0, firstLine - 1 - contextLines);
186
197
  const end = Math.min(fileLines.length, start + maxLines);
187
- return { snippet: fileLines.slice(start, end).join("\n"), start: start + 1, end };
198
+ return {
199
+ snippet: fileLines.slice(start, end).join("\n"),
200
+ start: start + 1,
201
+ end,
202
+ };
188
203
  } catch {
189
204
  return null;
190
205
  }
@@ -0,0 +1,98 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ /**
4
+ * Common parsing logic for ast-grep JSON output (handles both array and NDJSON).
5
+ */
6
+ // biome-ignore lint/suspicious/noExplicitAny: ast-grep JSON output is untyped
7
+ export function parseAstGrepJson(raw) {
8
+ if (!raw)
9
+ return [];
10
+ const trimmed = raw.trim();
11
+ if (trimmed.startsWith("[")) {
12
+ try {
13
+ return JSON.parse(trimmed);
14
+ }
15
+ catch {
16
+ return [];
17
+ }
18
+ }
19
+ return trimmed.split("\n").flatMap((l) => {
20
+ try {
21
+ return [JSON.parse(l)];
22
+ }
23
+ catch {
24
+ return [];
25
+ }
26
+ });
27
+ }
28
+ /**
29
+ * Check if a file should be ignored based on project type and common patterns.
30
+ */
31
+ export function shouldIgnoreFile(filePath, isTsProject) {
32
+ const relPath = filePath.replace(/\\/g, "/");
33
+ const basename = path.basename(relPath);
34
+ // Ignore compiled JS in TS projects
35
+ const isJs = relPath.endsWith(".js") ||
36
+ relPath.endsWith(".mjs") ||
37
+ relPath.endsWith(".cjs");
38
+ if (isTsProject && isJs)
39
+ return true;
40
+ // Ignore test scripts and common test patterns
41
+ if (basename.startsWith("test-") ||
42
+ basename.includes(".test.") ||
43
+ basename.includes(".spec.")) {
44
+ return true;
45
+ }
46
+ // Ignore hidden directories and common build outputs
47
+ if (relPath.includes("/node_modules/") ||
48
+ relPath.includes("/.git/") ||
49
+ relPath.includes("/dist/") ||
50
+ relPath.includes("/build/") ||
51
+ relPath.includes("/.next/") ||
52
+ relPath.includes("/.pi-lens/")) {
53
+ return true;
54
+ }
55
+ return false;
56
+ }
57
+ /**
58
+ * Recursively find source files in a directory, respecting common excludes.
59
+ */
60
+ export function getSourceFiles(dir, isTsProject) {
61
+ const files = [];
62
+ if (!fs.existsSync(dir))
63
+ return files;
64
+ const scan = (d) => {
65
+ let entries = [];
66
+ try {
67
+ entries = fs.readdirSync(d, { withFileTypes: true });
68
+ }
69
+ catch {
70
+ return;
71
+ }
72
+ for (const entry of entries) {
73
+ const full = path.join(d, entry.name);
74
+ if (entry.isDirectory()) {
75
+ if ([
76
+ "node_modules",
77
+ ".git",
78
+ "dist",
79
+ "build",
80
+ ".next",
81
+ ".pi-lens",
82
+ ].includes(entry.name))
83
+ continue;
84
+ scan(full);
85
+ }
86
+ else if (/\.(ts|tsx|js|jsx|py|go|rs)$/.test(entry.name)) {
87
+ // Skip compiled JS if it's a TS project
88
+ if (isTsProject &&
89
+ entry.name.endsWith(".js") &&
90
+ fs.existsSync(full.replace(/\.js$/, ".ts")))
91
+ continue;
92
+ files.push(full);
93
+ }
94
+ }
95
+ };
96
+ scan(dir);
97
+ return files;
98
+ }
@@ -0,0 +1,112 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+
4
+ /**
5
+ * Common parsing logic for ast-grep JSON output (handles both array and NDJSON).
6
+ */
7
+ // biome-ignore lint/suspicious/noExplicitAny: ast-grep JSON output is untyped
8
+ export function parseAstGrepJson(raw: string): any[] {
9
+ if (!raw) return [];
10
+ const trimmed = raw.trim();
11
+ if (trimmed.startsWith("[")) {
12
+ try {
13
+ return JSON.parse(trimmed);
14
+ } catch {
15
+ return [];
16
+ }
17
+ }
18
+ return trimmed.split("\n").flatMap((l) => {
19
+ try {
20
+ return [JSON.parse(l)];
21
+ } catch {
22
+ return [];
23
+ }
24
+ });
25
+ }
26
+
27
+ /**
28
+ * Check if a file should be ignored based on project type and common patterns.
29
+ */
30
+ export function shouldIgnoreFile(
31
+ filePath: string,
32
+ isTsProject: boolean,
33
+ ): boolean {
34
+ const relPath = filePath.replace(/\\/g, "/");
35
+ const basename = path.basename(relPath);
36
+
37
+ // Ignore compiled JS in TS projects
38
+ const isJs =
39
+ relPath.endsWith(".js") ||
40
+ relPath.endsWith(".mjs") ||
41
+ relPath.endsWith(".cjs");
42
+ if (isTsProject && isJs) return true;
43
+
44
+ // Ignore test scripts and common test patterns
45
+ if (
46
+ basename.startsWith("test-") ||
47
+ basename.includes(".test.") ||
48
+ basename.includes(".spec.")
49
+ ) {
50
+ return true;
51
+ }
52
+
53
+ // Ignore hidden directories and common build outputs
54
+ if (
55
+ relPath.includes("/node_modules/") ||
56
+ relPath.includes("/.git/") ||
57
+ relPath.includes("/dist/") ||
58
+ relPath.includes("/build/") ||
59
+ relPath.includes("/.next/") ||
60
+ relPath.includes("/.pi-lens/")
61
+ ) {
62
+ return true;
63
+ }
64
+
65
+ return false;
66
+ }
67
+
68
+ /**
69
+ * Recursively find source files in a directory, respecting common excludes.
70
+ */
71
+ export function getSourceFiles(dir: string, isTsProject: boolean): string[] {
72
+ const files: string[] = [];
73
+ if (!fs.existsSync(dir)) return files;
74
+
75
+ const scan = (d: string) => {
76
+ let entries: fs.Dirent[] = [];
77
+ try {
78
+ entries = fs.readdirSync(d, { withFileTypes: true });
79
+ } catch {
80
+ return;
81
+ }
82
+
83
+ for (const entry of entries) {
84
+ const full = path.join(d, entry.name);
85
+ if (entry.isDirectory()) {
86
+ if (
87
+ [
88
+ "node_modules",
89
+ ".git",
90
+ "dist",
91
+ "build",
92
+ ".next",
93
+ ".pi-lens",
94
+ ].includes(entry.name)
95
+ )
96
+ continue;
97
+ scan(full);
98
+ } else if (/\.(ts|tsx|js|jsx|py|go|rs)$/.test(entry.name)) {
99
+ // Skip compiled JS if it's a TS project
100
+ if (
101
+ isTsProject &&
102
+ entry.name.endsWith(".js") &&
103
+ fs.existsSync(full.replace(/\.js$/, ".ts"))
104
+ )
105
+ continue;
106
+ files.push(full);
107
+ }
108
+ }
109
+ };
110
+ scan(dir);
111
+ return files;
112
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * SgRunner - encapsulates ast-grep subprocess management
3
+ *
4
+ * Extracted from AstGrepClient to simplify the main client.
5
+ * Handles: spawn, spawnSync, temp dir management, JSON parsing.
6
+ */
7
+ import { spawn, spawnSync } from "node:child_process";
8
+ import * as fs from "node:fs";
9
+ import * as os from "node:os";
10
+ import * as path from "node:path";
11
+ export class SgRunner {
12
+ constructor(verbose = false) {
13
+ this.log = verbose ? (msg) => console.error(`[sg-runner] ${msg}`) : () => { };
14
+ }
15
+ /**
16
+ * Check if ast-grep CLI is available
17
+ */
18
+ isAvailable() {
19
+ const result = spawnSync("npx", ["sg", "--version"], {
20
+ encoding: "utf-8",
21
+ timeout: 10000,
22
+ shell: true,
23
+ });
24
+ return !result.error && result.status === 0;
25
+ }
26
+ /**
27
+ * Run ast-grep asynchronously, return parsed matches
28
+ */
29
+ async exec(args) {
30
+ return new Promise((resolve) => {
31
+ const proc = spawn("npx", ["sg", ...args], {
32
+ stdio: ["ignore", "pipe", "pipe"],
33
+ shell: true,
34
+ });
35
+ let stdout = "";
36
+ let stderr = "";
37
+ proc.stdout.on("data", (data) => (stdout += data.toString()));
38
+ proc.stderr.on("data", (data) => (stderr += data.toString()));
39
+ proc.on("error", (err) => {
40
+ if (err.message.includes("ENOENT")) {
41
+ resolve({ matches: [], error: "ast-grep CLI not found. Install: npm i -D @ast-grep/cli" });
42
+ }
43
+ else {
44
+ resolve({ matches: [], error: err.message });
45
+ }
46
+ });
47
+ proc.on("close", (code) => {
48
+ if (code !== 0 && !stdout.trim()) {
49
+ resolve({
50
+ matches: [],
51
+ error: stderr.includes("No files found") ? undefined : stderr.trim() || `Exit code ${code}`,
52
+ });
53
+ return;
54
+ }
55
+ if (!stdout.trim()) {
56
+ resolve({ matches: [] });
57
+ return;
58
+ }
59
+ try {
60
+ const parsed = JSON.parse(stdout);
61
+ const matches = Array.isArray(parsed) ? parsed : [parsed];
62
+ resolve({ matches });
63
+ }
64
+ catch {
65
+ resolve({ matches: [], error: "Failed to parse output" });
66
+ }
67
+ });
68
+ });
69
+ }
70
+ /**
71
+ * Run ast-grep synchronously (for simple scans)
72
+ */
73
+ execSync(args) {
74
+ const result = spawnSync("npx", ["sg", ...args], {
75
+ encoding: "utf-8",
76
+ timeout: 30000,
77
+ shell: true,
78
+ maxBuffer: 32 * 1024 * 1024,
79
+ });
80
+ if (result.error) {
81
+ return { output: "", error: result.error.message };
82
+ }
83
+ const output = result.stdout || result.stderr || "";
84
+ return { output };
85
+ }
86
+ /**
87
+ * Run a temporary rule scan (creates temp dir with rule file)
88
+ */
89
+ tempScan(dir, ruleId, ruleYaml, timeout = 30000) {
90
+ const tmpDir = os.tmpdir();
91
+ const ts = Date.now();
92
+ const sessionDir = path.join(tmpDir, `pi-lens-temp-${ruleId}-${ts}`);
93
+ const rulesSubdir = path.join(sessionDir, "rules");
94
+ const ruleFile = path.join(rulesSubdir, `${ruleId}.yml`);
95
+ const configFile = path.join(sessionDir, ".sgconfig.yml");
96
+ try {
97
+ fs.mkdirSync(rulesSubdir, { recursive: true });
98
+ fs.writeFileSync(configFile, `ruleDirs:\n - ./rules\n`);
99
+ fs.writeFileSync(ruleFile, ruleYaml);
100
+ const result = spawnSync("npx", ["sg", "scan", "--config", configFile, "--json", dir], { encoding: "utf-8", timeout, shell: true });
101
+ const output = result.stdout || result.stderr || "";
102
+ if (!output.trim())
103
+ return [];
104
+ const items = JSON.parse(output);
105
+ return Array.isArray(items) ? items : [items];
106
+ }
107
+ catch {
108
+ return [];
109
+ }
110
+ finally {
111
+ try {
112
+ fs.rmSync(sessionDir, { recursive: true, force: true });
113
+ }
114
+ catch (err) {
115
+ this.log(`Cleanup failed: ${err.message}`);
116
+ }
117
+ }
118
+ }
119
+ /**
120
+ * Format matches for display
121
+ */
122
+ formatMatches(matches, isDryRun = false, maxItems = 50) {
123
+ if (matches.length === 0)
124
+ return "No matches found";
125
+ const shown = matches.slice(0, maxItems);
126
+ const lines = shown.map((m) => {
127
+ const loc = `${m.file}:${m.range.start.line + 1}:${m.range.start.column + 1}`;
128
+ const text = m.text.length > 100 ? `${m.text.slice(0, 100)}...` : m.text;
129
+ return isDryRun && m.replacement
130
+ ? `${loc}\n - ${text}\n + ${m.replacement}`
131
+ : `${loc}: ${text}`;
132
+ });
133
+ if (matches.length > maxItems) {
134
+ lines.unshift(`Found ${matches.length} matches (showing first ${maxItems}):`);
135
+ }
136
+ return lines.join("\n");
137
+ }
138
+ }