@mrclrchtr/supi-code-intelligence 0.1.0 → 1.1.2

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.
@@ -1,10 +1,15 @@
1
1
  // Focused brief generation — directory and file briefs.
2
+ // biome-ignore-all lint/nursery/noExcessiveLinesPerFile: focused brief formatting and recursive directory summarization are kept together to share local helpers
2
3
 
3
4
  import * as fs from "node:fs";
4
5
  import * as path from "node:path";
5
6
  import type { ArchitectureModel } from "./architecture.ts";
6
7
  import { findModuleForPath, getDependencies, getDependents } from "./architecture.ts";
7
8
  import { formatGitContext, gatherGitContext } from "./git-context.ts";
9
+ import {
10
+ appendPrioritySignalsSection,
11
+ summarizePrioritySignalsForFiles,
12
+ } from "./prioritization-signals.ts";
8
13
  import type { BriefDetails, ConfidenceMode } from "./types.ts";
9
14
 
10
15
  /**
@@ -55,7 +60,15 @@ function generateDirectoryBrief(
55
60
  if (mod && mod.root === resolvedPath) {
56
61
  formatModuleBrief({ mod, model, lines, startHere, publicSurfaces, nextQueries, resolvedPath });
57
62
  } else {
58
- formatNonModuleDir({ model, mod, resolvedPath, originalPath, lines });
63
+ formatNonModuleDir({
64
+ model,
65
+ mod,
66
+ resolvedPath,
67
+ originalPath,
68
+ lines,
69
+ publicSurfaces,
70
+ nextQueries,
71
+ });
59
72
  }
60
73
 
61
74
  if (nextQueries.length > 0) {
@@ -66,6 +79,12 @@ function generateDirectoryBrief(
66
79
  }
67
80
  }
68
81
 
82
+ const prioritySignals = summarizePrioritySignalsForFiles(
83
+ model.root,
84
+ summarizeDirectoryRecursively(resolvedPath).allFiles,
85
+ );
86
+ appendPrioritySignalsSection(lines, prioritySignals);
87
+
69
88
  const gitCtx = gatherGitContext(model.root);
70
89
  if (gitCtx) {
71
90
  lines.push(formatGitContext(gitCtx));
@@ -83,6 +102,7 @@ function generateDirectoryBrief(
83
102
  dependencySummary: mod ? { moduleCount: 1, edgeCount: mod.internalDeps.length } : null,
84
103
  omittedCount: 0,
85
104
  nextQueries,
105
+ prioritySignals,
86
106
  },
87
107
  };
88
108
  }
@@ -195,10 +215,13 @@ interface NonModuleDirContext {
195
215
  resolvedPath: string;
196
216
  originalPath: string;
197
217
  lines: string[];
218
+ publicSurfaces: string[];
219
+ nextQueries: string[];
198
220
  }
199
221
 
222
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: nested-directory brief formatting is clearer as one staged formatter than as many tiny helpers
200
223
  function formatNonModuleDir(ctx: NonModuleDirContext): void {
201
- const { model, mod, resolvedPath, originalPath, lines } = ctx;
224
+ const { model, mod, resolvedPath, originalPath, lines, publicSurfaces, nextQueries } = ctx;
202
225
  const relPath = path.relative(model.root, resolvedPath);
203
226
  lines.push(`# Directory: ${relPath || originalPath}`);
204
227
  lines.push("");
@@ -209,17 +232,55 @@ function formatNonModuleDir(ctx: NonModuleDirContext): void {
209
232
  lines.push("");
210
233
  }
211
234
 
212
- const files = listSourceFiles(resolvedPath);
213
- if (files.length > 0) {
214
- const shown = files.slice(0, 10);
235
+ const summary = summarizeDirectoryRecursively(resolvedPath);
236
+ if (summary.directFiles.length > 0) {
215
237
  lines.push("## Source Files");
216
- for (const f of shown) {
238
+ for (const f of summary.directFiles.slice(0, 10)) {
217
239
  lines.push(`- \`${f}\``);
218
240
  }
219
- if (files.length > 10) {
220
- lines.push(`- _+${files.length - 10} more files_`);
241
+ if (summary.directFiles.length > 10) {
242
+ lines.push(`- _+${summary.directFiles.length - 10} more files_`);
221
243
  }
222
- } else {
244
+ lines.push("");
245
+ }
246
+
247
+ if (summary.totalSourceFiles > 0) {
248
+ lines.push("## Descendant Source Files");
249
+ lines.push(`- Total: ${summary.totalSourceFiles}`);
250
+ for (const subdir of summary.subdirs.slice(0, 8)) {
251
+ lines.push(
252
+ `- \`${subdir.name}/\` — ${subdir.fileCount} file${subdir.fileCount !== 1 ? "s" : ""}`,
253
+ );
254
+ }
255
+ if (summary.subdirs.length > 8) {
256
+ lines.push(`- _+${summary.subdirs.length - 8} more subdirectories_`);
257
+ }
258
+ lines.push("");
259
+ }
260
+
261
+ if (summary.publicSurfaces.length > 0) {
262
+ lines.push("## Public Surfaces");
263
+ for (const surface of summary.publicSurfaces.slice(0, 8)) {
264
+ lines.push(`- ${surface}`);
265
+ publicSurfaces.push(surface);
266
+ }
267
+ if (summary.publicSurfaces.length > 8) {
268
+ lines.push(`- _+${summary.publicSurfaces.length - 8} more exports_`);
269
+ }
270
+ lines.push("");
271
+ }
272
+
273
+ if (summary.totalSourceFiles > 0) {
274
+ lines.push("## Import / Export Summary");
275
+ lines.push(`- Imports: ${summary.importCount}`);
276
+ lines.push(`- Exports: ${summary.exportCount}`);
277
+ lines.push("");
278
+ nextQueries.push(
279
+ `\`code_intel pattern\` with \`path: "${relPath || originalPath}"\` to inspect a specific nested symbol`,
280
+ );
281
+ }
282
+
283
+ if (summary.totalSourceFiles === 0) {
223
284
  lines.push("No recognized source files in this directory.");
224
285
  }
225
286
  }
@@ -266,6 +327,12 @@ function generateFileBrief(
266
327
  lines.push("_Could not read file contents._");
267
328
  }
268
329
 
330
+ const prioritySignals = summarizePrioritySignalsForFiles(model.root, [resolvedPath]);
331
+ if (prioritySignals) {
332
+ lines.push("");
333
+ appendPrioritySignalsSection(lines, prioritySignals);
334
+ }
335
+
269
336
  nextQueries.push(
270
337
  `\`code_intel callers\` with \`file: "${relPath}"\` and a line/character for call-site analysis`,
271
338
  );
@@ -300,6 +367,7 @@ function generateFileBrief(
300
367
  dependencySummary: null,
301
368
  omittedCount: 0,
302
369
  nextQueries,
370
+ prioritySignals,
303
371
  },
304
372
  };
305
373
  }
@@ -365,6 +433,16 @@ const SOURCE_EXTENSIONS = new Set([
365
433
  ".sql",
366
434
  ]);
367
435
 
436
+ interface RecursiveDirectorySummary {
437
+ directFiles: string[];
438
+ allFiles: string[];
439
+ totalSourceFiles: number;
440
+ subdirs: Array<{ name: string; fileCount: number }>;
441
+ publicSurfaces: string[];
442
+ importCount: number;
443
+ exportCount: number;
444
+ }
445
+
368
446
  function listSourceFiles(dir: string): string[] {
369
447
  const files: string[] = [];
370
448
  try {
@@ -381,3 +459,105 @@ function listSourceFiles(dir: string): string[] {
381
459
  }
382
460
  return files.sort((a, b) => a.localeCompare(b));
383
461
  }
462
+
463
+ function summarizeDirectoryRecursively(dir: string): RecursiveDirectorySummary {
464
+ const directFiles = listSourceFiles(dir);
465
+ const summary: RecursiveDirectorySummary = {
466
+ directFiles,
467
+ allFiles: directFiles.map((file) => path.join(dir, file)),
468
+ totalSourceFiles: directFiles.length,
469
+ subdirs: [],
470
+ publicSurfaces: [],
471
+ importCount: 0,
472
+ exportCount: 0,
473
+ };
474
+
475
+ accumulateJsTsSignals(summary, dir, directFiles, "");
476
+
477
+ try {
478
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
479
+ for (const entry of entries) {
480
+ if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
481
+ const childDir = path.join(dir, entry.name);
482
+ const childSummary = summarizeDirectoryRecursively(childDir);
483
+ if (childSummary.totalSourceFiles === 0) continue;
484
+ summary.totalSourceFiles += childSummary.totalSourceFiles;
485
+ summary.allFiles.push(...childSummary.allFiles);
486
+ summary.subdirs.push({ name: entry.name, fileCount: childSummary.totalSourceFiles });
487
+ summary.publicSurfaces.push(
488
+ ...childSummary.publicSurfaces.map((surface) => `${entry.name}/${surface}`),
489
+ );
490
+ summary.importCount += childSummary.importCount;
491
+ summary.exportCount += childSummary.exportCount;
492
+ }
493
+ } catch {
494
+ // Directory not readable
495
+ }
496
+
497
+ summary.subdirs.sort((a, b) => a.name.localeCompare(b.name));
498
+ return summary;
499
+ }
500
+
501
+ function accumulateJsTsSignals(
502
+ summary: RecursiveDirectorySummary,
503
+ dir: string,
504
+ files: string[],
505
+ prefix: string,
506
+ ): void {
507
+ for (const file of files) {
508
+ if (!isJsTsFile(file)) continue;
509
+ try {
510
+ const relFile = prefix ? `${prefix}/${file}` : file;
511
+ const content = fs.readFileSync(path.join(dir, file), "utf-8");
512
+ summary.importCount += countMatches(content, /^import\s/gm);
513
+ const exports = extractNamedExports(content);
514
+ summary.exportCount += exports.length;
515
+ for (const exportedName of exports) {
516
+ summary.publicSurfaces.push(`\`${exportedName}\` — \`${relFile}\``);
517
+ }
518
+ } catch {
519
+ // File not readable
520
+ }
521
+ }
522
+ }
523
+
524
+ function isJsTsFile(file: string): boolean {
525
+ return [".ts", ".tsx", ".js", ".jsx", ".mts", ".cts", ".mjs", ".cjs"].includes(
526
+ path.extname(file),
527
+ );
528
+ }
529
+
530
+ /**
531
+ * Lightweight exported-name extraction for recursive directory briefs.
532
+ *
533
+ * Best-effort only: handles exported declarations and simple named export lists,
534
+ * but intentionally does not try to cover default exports, export-all forms,
535
+ * re-exported defaults, or every multiline formatting variant.
536
+ */
537
+ function extractNamedExports(content: string): string[] {
538
+ const names = new Set<string>();
539
+ const declarationRegex =
540
+ /export\s+(?:async\s+)?(?:function|class|interface|type|const|let|var|enum)\s+([A-Za-z_$][\w$]*)/g;
541
+ const namedExportRegex = /export\s*\{\s*([^}]+)\s*\}/g;
542
+
543
+ for (const match of content.matchAll(declarationRegex)) {
544
+ if (match[1]) names.add(match[1]);
545
+ }
546
+
547
+ for (const match of content.matchAll(namedExportRegex)) {
548
+ const parts = match[1]?.split(",") ?? [];
549
+ for (const part of parts) {
550
+ const candidate = part
551
+ .trim()
552
+ .split(/\s+as\s+/i)[0]
553
+ ?.trim();
554
+ if (candidate) names.add(candidate);
555
+ }
556
+ }
557
+
558
+ return [...names].sort((a, b) => a.localeCompare(b));
559
+ }
560
+
561
+ function countMatches(content: string, regex: RegExp): number {
562
+ return [...content.matchAll(regex)].length;
563
+ }
@@ -73,7 +73,10 @@ export default function codeIntelligenceExtension(pi: ExtensionAPI) {
73
73
  Type.String({ description: "Scope or focus path (package, directory, or file)" }),
74
74
  ),
75
75
  file: Type.Optional(
76
- Type.String({ description: "Anchored target file (use with line/character)" }),
76
+ Type.String({
77
+ description:
78
+ "Anchored target file (use with line/character) or a file-level semantic target for brief/callers/affected",
79
+ }),
77
80
  ),
78
81
  line: Type.Optional(Type.Number({ description: "1-based line number for anchored target" })),
79
82
  character: Type.Optional(
@@ -92,7 +95,12 @@ export default function codeIntelligenceExtension(pi: ExtensionAPI) {
92
95
  description: "Use regex semantics for pattern action (default: false, literal search)",
93
96
  }),
94
97
  ),
95
- kind: Type.Optional(Type.String({ description: "Symbol kind filter for discovery" })),
98
+ kind: Type.Optional(
99
+ Type.String({
100
+ description:
101
+ "Symbol kind filter for discovery, or pattern kind (`definition` | `export` | `import`) for structured searches",
102
+ }),
103
+ ),
96
104
  exportedOnly: Type.Optional(
97
105
  Type.Boolean({ description: "Limit discovery to exported symbols" }),
98
106
  ),
@@ -1,9 +1,20 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
 
3
+ function scrubGitEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
4
+ const next = { ...env };
5
+ for (const key of Object.keys(next)) {
6
+ if (key.startsWith("GIT_")) {
7
+ delete next[key];
8
+ }
9
+ }
10
+ return next;
11
+ }
12
+
3
13
  function execGit(cwd: string, args: string[]): string {
4
14
  return execFileSync("git", args, {
5
15
  cwd,
6
16
  encoding: "utf-8",
17
+ env: scrubGitEnv(process.env),
7
18
  stdio: ["ignore", "pipe", "ignore"],
8
19
  timeout: 5000,
9
20
  });
package/src/guidance.ts CHANGED
@@ -4,11 +4,11 @@ export const toolDescription = `Code intelligence tool — architecture briefs,
4
4
 
5
5
  Actions:
6
6
  - brief: Architecture overview or focused brief for a project, package, directory, file, or anchored symbol
7
- - callers: Find call sites for a symbol (LSP-first, heuristic text-search fallback)
7
+ - callers: Find call sites for a symbol, or analyze a file-level export surface when only \`file\` is provided
8
8
  - callees: Best-effort outgoing calls from a symbol (structural tree-sitter analysis across supported grammars)
9
9
  - implementations: Find concrete implementations of an interface or abstract type
10
- - affected: Blast-radius analysis — direct references, downstream dependents, risk level, likely tests
11
- - pattern: Bounded text search with grouped matches, context lines, scope enforcement, and regex opt-in via \`regex: true\`
10
+ - affected: Blast-radius analysis for a symbol or exported file surface — direct references, downstream dependents, risk level, likely tests
11
+ - pattern: Bounded text search with grouped matches, context lines, structured \`kind\` filters (\`definition\` | \`export\` | \`import\`), partial-result warnings on oversized structured scans, and regex opt-in via \`regex: true\`
12
12
  - index: Factual project map — file counts by language, top-level directory tree, landmark config files
13
13
 
14
14
  Coordinates are 1-based (line, character) with UTF-16 character columns, matching lsp and tree_sitter conventions.
@@ -18,21 +18,24 @@ Examples:
18
18
  { "action": "brief" }
19
19
  { "action": "brief", "path": "packages/supi-lsp/" }
20
20
  { "action": "brief", "file": "packages/supi-lsp/lsp.ts", "line": 42, "character": 7 }
21
+ { "action": "callers", "file": "packages/supi-core/index.ts" }
21
22
  { "action": "callers", "symbol": "registerSettings", "path": "packages/supi-core/" }
22
23
  { "action": "callees", "file": "src/handler.ts", "line": 88, "character": 12 }
23
24
  { "action": "implementations", "symbol": "SessionLspService", "path": "packages/" }
24
- { "action": "affected", "file": "packages/supi-core/index.ts", "line": 12, "character": 8 }
25
+ { "action": "affected", "file": "packages/supi-core/index.ts" }
25
26
  { "action": "pattern", "pattern": "registerSettings", "path": "packages/", "maxResults": 10 }
26
- { "action": "pattern", "pattern": "register(Settings|Config)", "path": "packages/", "regex": true, "maxResults": 10 }`;
27
+ { "action": "pattern", "pattern": "register(Settings|Config)", "path": "packages/", "regex": true, "maxResults": 10 }
28
+ { "action": "pattern", "pattern": "payment", "kind": "definition", "path": "src/" }`;
27
29
 
28
30
  export const promptSnippet =
29
31
  "Use the code_intel tool for architecture orientation, semantic relationships, impact analysis, and structured search before broad file reads.";
30
32
 
31
33
  export const promptGuidelines = [
32
34
  "Use `code_intel brief` before editing an unfamiliar package, directory, or file to get architecture context and reduce blind reads.",
33
- "Use `code_intel affected` before changing exported APIs, shared helpers, config surfaces, or cross-package contracts to check blast radius and risk.",
34
- "Use `code_intel callers` before modifying a function to verify all call sites; use `callees` and `implementations` for dependency and interface analysis.",
35
- "Use `code_intel pattern` for bounded, scope-aware text search when the question is textual rather than semantic; it treats patterns as literal strings by default and supports `regex: true` when needed.",
35
+ "Use `code_intel affected` before changing exported APIs, shared helpers, config surfaces, or cross-package contracts to check blast radius and risk; file-only requests now expand across exported targets when possible.",
36
+ "Use `code_intel callers` before modifying a function to verify all call sites; use `callees` and `implementations` for dependency and interface analysis, and use file-only `callers` when you need the export surface of a module.",
37
+ 'Use `code_intel pattern` for bounded, scope-aware text search when the question is textual rather than semantic; it treats patterns as literal strings by default, supports `regex: true`, supports `kind: "definition" | "export" | "import"` for structured searches, and may return a partial-result warning when a structured scan is too broad.',
38
+ "Use `code_intel brief` and `code_intel affected` priority signals to notice diagnostics, low coverage, or unused-code hints before editing risky files.",
36
39
  "Use `code_intel index` for a factual project map (file counts, directory structure, landmark files) when you need to orient yourself in a new codebase.",
37
40
  "After `code_intel` narrows the target, use raw `lsp` and `tree_sitter` tools for precise drill-down on exact symbols, types, or AST nodes.",
38
41
  "Do not prefer `code_intel` over direct file reads or lower-level tools for trivial, already-localized edits or exact symbol/AST drill-down tasks.",
@@ -0,0 +1,196 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { createTreeSitterSession } from "@mrclrchtr/supi-tree-sitter";
4
+ import type { ActionParams } from "./tool-actions.ts";
5
+
6
+ export const STRUCTURED_PATTERN_FILE_CAP = 200;
7
+ const STRUCTURED_PATTERN_TIMEOUT_MS = 10_000;
8
+
9
+ export type StructuredPatternKind = "definition" | "export" | "import";
10
+
11
+ export interface StructuredMatch {
12
+ file: string;
13
+ name: string;
14
+ kind: string;
15
+ line: number;
16
+ }
17
+
18
+ export interface StructuredPatternResult {
19
+ matches: StructuredMatch[];
20
+ omittedCount: number;
21
+ partialReason: "file-cap" | "timeout" | null;
22
+ }
23
+
24
+ export function isStructuredPatternKind(kind: string | undefined): kind is StructuredPatternKind {
25
+ return kind === "definition" || kind === "export" || kind === "import";
26
+ }
27
+
28
+ export async function getStructuredPatternMatches(
29
+ params: ActionParams & { pattern: string; kind: StructuredPatternKind },
30
+ scopePath: string,
31
+ cwd: string,
32
+ relScope: string,
33
+ ): Promise<StructuredPatternResult | string | null> {
34
+ const deadline = Date.now() + STRUCTURED_PATTERN_TIMEOUT_MS;
35
+ const collected = collectStructuredFiles(scopePath, deadline);
36
+ if (collected.files.length === 0) {
37
+ return null;
38
+ }
39
+
40
+ const matcher = createStructuredMatcher(params.pattern, params.regex ?? false);
41
+ if (typeof matcher === "string") {
42
+ return matcher;
43
+ }
44
+
45
+ let tsSession: ReturnType<typeof createTreeSitterSession> | null = null;
46
+ try {
47
+ tsSession = createTreeSitterSession(cwd);
48
+ const matches: StructuredMatch[] = [];
49
+ let timedOut = collected.timedOut;
50
+
51
+ for (const [index, file] of collected.files.entries()) {
52
+ if (Date.now() > deadline) {
53
+ collected.omittedCount += collected.files.length - index;
54
+ timedOut = true;
55
+ break;
56
+ }
57
+ const relFile = path.relative(cwd, file);
58
+ await collectMatchesForFile(matches, tsSession, relFile, params.kind, matcher);
59
+ }
60
+
61
+ return {
62
+ matches,
63
+ omittedCount: timedOut ? Math.max(1, collected.omittedCount) : collected.omittedCount,
64
+ partialReason: timedOut ? "timeout" : collected.omittedCount > 0 ? "file-cap" : null,
65
+ };
66
+ } catch {
67
+ return `No structured ${params.kind} search data available in \`${relScope}\`. Try omitting \`kind\` for plain text search.`;
68
+ } finally {
69
+ tsSession?.dispose();
70
+ }
71
+ }
72
+
73
+ // biome-ignore lint/complexity/useMaxParams: helper takes explicit collection inputs to avoid intermediate objects in the hot path
74
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: kind-specific tree-sitter matching is clearest as one helper
75
+ async function collectMatchesForFile(
76
+ matches: StructuredMatch[],
77
+ tsSession: ReturnType<typeof createTreeSitterSession>,
78
+ relFile: string,
79
+ kind: StructuredPatternKind,
80
+ matcher: (value: string) => boolean,
81
+ ): Promise<void> {
82
+ if (kind === "definition") {
83
+ const outline = await tsSession.outline(relFile);
84
+ if (outline.kind !== "success") return;
85
+ for (const item of outline.data) {
86
+ if (!matcher(item.name)) continue;
87
+ matches.push({ file: relFile, name: item.name, kind: item.kind, line: item.range.startLine });
88
+ }
89
+ return;
90
+ }
91
+
92
+ if (kind === "export") {
93
+ const exportsResult = await tsSession.exports(relFile);
94
+ if (exportsResult.kind !== "success") return;
95
+ for (const item of exportsResult.data) {
96
+ if (!matcher(item.name)) continue;
97
+ matches.push({ file: relFile, name: item.name, kind: item.kind, line: item.range.startLine });
98
+ }
99
+ return;
100
+ }
101
+
102
+ const importsResult = await tsSession.imports(relFile);
103
+ if (importsResult.kind !== "success") return;
104
+ for (const item of importsResult.data) {
105
+ if (!matcher(item.moduleSpecifier)) continue;
106
+ matches.push({
107
+ file: relFile,
108
+ name: item.moduleSpecifier,
109
+ kind: "import",
110
+ line: item.range.startLine,
111
+ });
112
+ }
113
+ }
114
+
115
+ function collectStructuredFiles(
116
+ scopePath: string,
117
+ deadline: number,
118
+ ): { files: string[]; omittedCount: number; timedOut: boolean } {
119
+ const files: string[] = [];
120
+ let omittedCount = 0;
121
+ let timedOut = false;
122
+ const skipDirs = new Set(["node_modules", ".git", "dist", "build", "coverage"]);
123
+
124
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: timeout, file, and directory branches stay adjacent for predictable short-circuiting
125
+ function walk(currentPath: string) {
126
+ if (timedOut) return;
127
+ if (Date.now() > deadline) {
128
+ timedOut = true;
129
+ return;
130
+ }
131
+
132
+ let stat: fs.Stats;
133
+ try {
134
+ stat = fs.statSync(currentPath);
135
+ } catch {
136
+ return;
137
+ }
138
+
139
+ if (stat.isFile()) {
140
+ if (isStructuredFile(currentPath)) {
141
+ if (files.length < STRUCTURED_PATTERN_FILE_CAP) {
142
+ files.push(currentPath);
143
+ } else {
144
+ omittedCount++;
145
+ }
146
+ }
147
+ return;
148
+ }
149
+
150
+ let entries: fs.Dirent[];
151
+ try {
152
+ entries = fs.readdirSync(currentPath, { withFileTypes: true });
153
+ } catch {
154
+ return;
155
+ }
156
+
157
+ for (const entry of entries) {
158
+ if (entry.name.startsWith(".")) continue;
159
+ if (entry.isDirectory() && skipDirs.has(entry.name)) continue;
160
+ walk(path.join(currentPath, entry.name));
161
+ if (timedOut) return;
162
+ }
163
+ }
164
+
165
+ walk(scopePath);
166
+ return { files: files.sort((a, b) => a.localeCompare(b)), omittedCount, timedOut };
167
+ }
168
+
169
+ function isStructuredFile(file: string): boolean {
170
+ return [".ts", ".tsx", ".js", ".jsx", ".mts", ".cts", ".mjs", ".cjs"].includes(
171
+ path.extname(file),
172
+ );
173
+ }
174
+
175
+ function createStructuredMatcher(
176
+ pattern: string,
177
+ regex: boolean,
178
+ ): ((value: string) => boolean) | string {
179
+ const ignoreCase = !/[A-Z]/.test(pattern);
180
+
181
+ if (regex) {
182
+ try {
183
+ const compiled = new RegExp(pattern, ignoreCase ? "i" : undefined);
184
+ return (value: string) => compiled.test(value);
185
+ } catch (error) {
186
+ const message = error instanceof Error ? error.message : "Invalid regex";
187
+ return `**Error:** Invalid regex pattern \`${pattern}\`: ${message}`;
188
+ }
189
+ }
190
+
191
+ const needle = ignoreCase ? pattern.toLowerCase() : pattern;
192
+ return (value: string) => {
193
+ const haystack = ignoreCase ? value.toLowerCase() : value;
194
+ return haystack.includes(needle);
195
+ };
196
+ }