openreport 0.1.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 (86) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +117 -0
  3. package/bin/openreport.ts +6 -0
  4. package/package.json +61 -0
  5. package/src/agents/api-documentation.ts +66 -0
  6. package/src/agents/architecture-analyst.ts +46 -0
  7. package/src/agents/code-quality-reviewer.ts +59 -0
  8. package/src/agents/dependency-analyzer.ts +51 -0
  9. package/src/agents/onboarding-guide.ts +59 -0
  10. package/src/agents/orchestrator.ts +41 -0
  11. package/src/agents/performance-analyzer.ts +57 -0
  12. package/src/agents/registry.ts +50 -0
  13. package/src/agents/security-auditor.ts +61 -0
  14. package/src/agents/test-coverage-analyst.ts +58 -0
  15. package/src/agents/todo-generator.ts +50 -0
  16. package/src/app/App.tsx +151 -0
  17. package/src/app/theme.ts +54 -0
  18. package/src/cli.ts +145 -0
  19. package/src/commands/init.ts +81 -0
  20. package/src/commands/interactive.tsx +29 -0
  21. package/src/commands/list.ts +53 -0
  22. package/src/commands/run.ts +168 -0
  23. package/src/commands/view.tsx +52 -0
  24. package/src/components/generation/AgentStatusItem.tsx +125 -0
  25. package/src/components/generation/AgentStatusList.tsx +70 -0
  26. package/src/components/generation/ProgressSummary.tsx +107 -0
  27. package/src/components/generation/StreamingOutput.tsx +154 -0
  28. package/src/components/layout/Container.tsx +24 -0
  29. package/src/components/layout/Footer.tsx +52 -0
  30. package/src/components/layout/Header.tsx +50 -0
  31. package/src/components/report/MarkdownRenderer.tsx +50 -0
  32. package/src/components/report/ReportCard.tsx +31 -0
  33. package/src/components/report/ScrollableView.tsx +164 -0
  34. package/src/config/cli-detection.ts +130 -0
  35. package/src/config/cli-model.ts +397 -0
  36. package/src/config/cli-prompt-formatter.ts +129 -0
  37. package/src/config/defaults.ts +79 -0
  38. package/src/config/loader.ts +168 -0
  39. package/src/config/ollama.ts +48 -0
  40. package/src/config/providers.ts +199 -0
  41. package/src/config/resolve-provider.ts +62 -0
  42. package/src/config/saver.ts +50 -0
  43. package/src/config/schema.ts +51 -0
  44. package/src/errors.ts +34 -0
  45. package/src/hooks/useReportGeneration.ts +199 -0
  46. package/src/hooks/useTerminalSize.ts +35 -0
  47. package/src/ingestion/context-selector.ts +247 -0
  48. package/src/ingestion/file-tree.ts +227 -0
  49. package/src/ingestion/token-budget.ts +52 -0
  50. package/src/pipeline/agent-runner.ts +360 -0
  51. package/src/pipeline/combiner.ts +199 -0
  52. package/src/pipeline/context.ts +108 -0
  53. package/src/pipeline/extraction.ts +153 -0
  54. package/src/pipeline/progress.ts +192 -0
  55. package/src/pipeline/runner.ts +526 -0
  56. package/src/report/html-renderer.ts +294 -0
  57. package/src/report/html-script.ts +123 -0
  58. package/src/report/html-styles.ts +1127 -0
  59. package/src/report/md-to-html.ts +153 -0
  60. package/src/report/open-browser.ts +22 -0
  61. package/src/schemas/findings.ts +48 -0
  62. package/src/schemas/report.ts +64 -0
  63. package/src/screens/ConfigScreen.tsx +271 -0
  64. package/src/screens/GenerationScreen.tsx +278 -0
  65. package/src/screens/HistoryScreen.tsx +108 -0
  66. package/src/screens/HomeScreen.tsx +143 -0
  67. package/src/screens/ViewerScreen.tsx +82 -0
  68. package/src/storage/metadata.ts +69 -0
  69. package/src/storage/report-store.ts +128 -0
  70. package/src/tools/get-file-tree.ts +157 -0
  71. package/src/tools/get-git-info.ts +123 -0
  72. package/src/tools/glob.ts +48 -0
  73. package/src/tools/grep.ts +149 -0
  74. package/src/tools/index.ts +30 -0
  75. package/src/tools/list-directory.ts +57 -0
  76. package/src/tools/read-file.ts +52 -0
  77. package/src/tools/read-package-json.ts +48 -0
  78. package/src/tools/run-command.ts +154 -0
  79. package/src/tools/shared-ignore.ts +58 -0
  80. package/src/types/index.ts +127 -0
  81. package/src/types/marked-terminal.d.ts +17 -0
  82. package/src/utils/debug.ts +25 -0
  83. package/src/utils/file-utils.ts +77 -0
  84. package/src/utils/format.ts +56 -0
  85. package/src/utils/grade-colors.ts +43 -0
  86. package/src/utils/project-detector.ts +296 -0
@@ -0,0 +1,128 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import type { FullReport, ReportMetadata } from "../types/index.js";
4
+ import { saveMetadata, listMetadata, deleteMetadata } from "./metadata.js";
5
+ import { renderReportToHtml } from "../report/html-renderer.js";
6
+ import { debugLog } from "../utils/debug.js";
7
+
8
+ const SAFE_ID_REGEX = /^[a-zA-Z0-9_-]+$/;
9
+
10
+ function validateReportId(id: string): void {
11
+ if (!SAFE_ID_REGEX.test(id)) {
12
+ throw new Error(`Invalid report ID: ${id}`);
13
+ }
14
+ }
15
+
16
+ function getReportsDir(projectRoot: string): string {
17
+ return path.join(projectRoot, ".openreport", "reports");
18
+ }
19
+
20
+ export async function saveReport(projectRoot: string, report: FullReport): Promise<string> {
21
+ const reportsDir = getReportsDir(projectRoot);
22
+ validateReportId(report.metadata.id);
23
+ await fs.promises.mkdir(reportsDir, { recursive: true });
24
+
25
+ const reportPath = path.join(reportsDir, `${report.metadata.id}.md`);
26
+ const htmlPath = path.join(reportsDir, `${report.metadata.id}.html`);
27
+ const htmlContent = renderReportToHtml(report);
28
+
29
+ const writes: Promise<void>[] = [
30
+ fs.promises.writeFile(reportPath, report.markdownContent, "utf-8"),
31
+ fs.promises.writeFile(htmlPath, htmlContent, "utf-8"),
32
+ saveMetadata(reportsDir, report.metadata),
33
+ ];
34
+
35
+ // Save standalone todo file if a todo-generator sub-report exists
36
+ const todoSubReport = report.subReports.find((sr) => sr.agentId === "todo-generator");
37
+ if (todoSubReport) {
38
+ const todoPath = path.join(reportsDir, `${report.metadata.id}.todo.md`);
39
+ const todoContent = buildStandaloneTodo(report.metadata.projectName, todoSubReport);
40
+ writes.push(fs.promises.writeFile(todoPath, todoContent, "utf-8"));
41
+ }
42
+
43
+ await Promise.all(writes);
44
+
45
+ return reportPath;
46
+ }
47
+
48
+ function buildStandaloneTodo(projectName: string, sub: { title: string; summary: string; sections: Array<{ heading: string; content: string }> }): string {
49
+ const parts: string[] = [];
50
+ parts.push(`# ${sub.title}\n\n`);
51
+ parts.push(`> ${projectName} — Generated by OpenReport on ${new Date().toISOString().split("T")[0]}\n\n`);
52
+ parts.push(`${sub.summary}\n\n---\n\n`);
53
+ for (const section of sub.sections) {
54
+ parts.push(`## ${section.heading}\n\n`);
55
+ parts.push(section.content);
56
+ parts.push("\n\n");
57
+ }
58
+ return parts.join("");
59
+ }
60
+
61
+ export async function loadReport(projectRoot: string, id: string): Promise<string | null> {
62
+ validateReportId(id);
63
+ const reportsDir = getReportsDir(projectRoot);
64
+ const reportPath = path.join(reportsDir, `${id}.md`);
65
+
66
+ try {
67
+ return await fs.promises.readFile(reportPath, "utf-8");
68
+ } catch (e) {
69
+ debugLog("store:loadReport", e);
70
+ return null;
71
+ }
72
+ }
73
+
74
+ export async function listReports(projectRoot: string, limit?: number): Promise<ReportMetadata[]> {
75
+ const reportsDir = getReportsDir(projectRoot);
76
+ return listMetadata(reportsDir, limit);
77
+ }
78
+
79
+ export async function deleteReport(projectRoot: string, id: string): Promise<boolean> {
80
+ validateReportId(id);
81
+ const reportsDir = getReportsDir(projectRoot);
82
+
83
+ let deleted = false;
84
+
85
+ // Delete markdown file
86
+ const reportPath = path.join(reportsDir, `${id}.md`);
87
+ try {
88
+ await fs.promises.unlink(reportPath);
89
+ deleted = true;
90
+ } catch (e) {
91
+ debugLog("store:deleteFile", e);
92
+ }
93
+
94
+ // Delete HTML file
95
+ const htmlPath = path.join(reportsDir, `${id}.html`);
96
+ try {
97
+ await fs.promises.unlink(htmlPath);
98
+ } catch (e) {
99
+ debugLog("store:deleteFile", e);
100
+ }
101
+
102
+ // Delete todo file (if exists)
103
+ const todoPath = path.join(reportsDir, `${id}.todo.md`);
104
+ try {
105
+ await fs.promises.unlink(todoPath);
106
+ } catch (e) {
107
+ debugLog("store:deleteFile", e);
108
+ }
109
+
110
+ // Delete metadata
111
+ if (await deleteMetadata(reportsDir, id)) {
112
+ deleted = true;
113
+ }
114
+
115
+ return deleted;
116
+ }
117
+
118
+ export async function getReportFilePath(projectRoot: string, id: string, ext: string): Promise<string | null> {
119
+ validateReportId(id);
120
+ const reportsDir = getReportsDir(projectRoot);
121
+ const filePath = path.join(reportsDir, `${id}${ext}`);
122
+ try {
123
+ await fs.promises.access(filePath);
124
+ return filePath;
125
+ } catch {
126
+ return null;
127
+ }
128
+ }
@@ -0,0 +1,157 @@
1
+ import { tool } from "ai";
2
+ import { z } from "zod";
3
+ import * as fs from "fs";
4
+ import * as path from "path";
5
+ import { debugLog } from "../utils/debug.js";
6
+ import { resolveAndValidatePath } from "../utils/file-utils.js";
7
+
8
+ const IGNORE_DIRS = new Set([
9
+ "node_modules",
10
+ ".git",
11
+ "dist",
12
+ "build",
13
+ ".next",
14
+ ".nuxt",
15
+ "coverage",
16
+ ".cache",
17
+ "__pycache__",
18
+ ".turbo",
19
+ ".vercel",
20
+ ".output",
21
+ ]);
22
+
23
+ const BINARY_EXTENSIONS = new Set([
24
+ ".png", ".jpg", ".jpeg", ".gif", ".ico", ".svg",
25
+ ".woff", ".woff2", ".ttf", ".eot",
26
+ ".zip", ".tar", ".gz",
27
+ ".exe", ".dll", ".so", ".dylib",
28
+ ".mp3", ".mp4", ".wav", ".avi",
29
+ ".pdf", ".doc", ".docx",
30
+ ]);
31
+
32
+ interface TreeNode {
33
+ name: string;
34
+ path: string;
35
+ type: "file" | "directory";
36
+ children?: TreeNode[];
37
+ size?: number;
38
+ }
39
+
40
+ async function buildTree(
41
+ dir: string,
42
+ projectRoot: string,
43
+ maxDepth: number,
44
+ currentDepth: number = 0
45
+ ): Promise<TreeNode[]> {
46
+ if (currentDepth >= maxDepth) return [];
47
+
48
+ try {
49
+ const entries = await fs.promises.readdir(dir, { withFileTypes: true });
50
+ const nodes: TreeNode[] = [];
51
+
52
+ for (const entry of entries) {
53
+ if (entry.name.startsWith(".") && entry.name !== ".env.example") continue;
54
+ if (IGNORE_DIRS.has(entry.name)) continue;
55
+
56
+ const fullPath = path.join(dir, entry.name);
57
+ const relativePath = path.relative(projectRoot, fullPath);
58
+
59
+ if (entry.isDirectory()) {
60
+ const children = await buildTree(
61
+ fullPath,
62
+ projectRoot,
63
+ maxDepth,
64
+ currentDepth + 1
65
+ );
66
+ nodes.push({
67
+ name: entry.name,
68
+ path: relativePath.replace(/\\/g, "/"),
69
+ type: "directory",
70
+ children,
71
+ });
72
+ } else {
73
+ const ext = path.extname(entry.name).toLowerCase();
74
+ if (BINARY_EXTENSIONS.has(ext)) continue;
75
+
76
+ let size: number | undefined;
77
+ try {
78
+ size = (await fs.promises.stat(fullPath)).size;
79
+ } catch (e) {
80
+ debugLog("getFileTree:stat", e);
81
+ }
82
+
83
+ nodes.push({
84
+ name: entry.name,
85
+ path: relativePath.replace(/\\/g, "/"),
86
+ type: "file",
87
+ size,
88
+ });
89
+ }
90
+ }
91
+
92
+ return nodes.sort((a, b) => {
93
+ if (a.type !== b.type) return a.type === "directory" ? -1 : 1;
94
+ return a.name.localeCompare(b.name);
95
+ });
96
+ } catch {
97
+ return [];
98
+ }
99
+ }
100
+
101
+ function treeToString(nodes: TreeNode[], prefix = ""): string {
102
+ let result = "";
103
+ for (let i = 0; i < nodes.length; i++) {
104
+ const node = nodes[i];
105
+ const isLast = i === nodes.length - 1;
106
+ const connector = isLast ? "└── " : "├── ";
107
+ const sizeStr =
108
+ node.size && node.size > 50000
109
+ ? ` (${(node.size / 1024).toFixed(1)}KB)`
110
+ : "";
111
+
112
+ result += `${prefix}${connector}${node.name}${sizeStr}\n`;
113
+
114
+ if (node.children && node.children.length > 0) {
115
+ const childPrefix = prefix + (isLast ? " " : "│ ");
116
+ result += treeToString(node.children, childPrefix);
117
+ }
118
+ }
119
+ return result;
120
+ }
121
+
122
+ export function createGetFileTreeTool(projectRoot: string) {
123
+ return tool({
124
+ description:
125
+ "Get the complete file tree of the project. Excludes node_modules, .git, dist, and binary files. Shows file sizes for large files.",
126
+ parameters: z.object({
127
+ maxDepth: z
128
+ .number()
129
+ .optional()
130
+ .describe("Maximum directory depth (default: 6)"),
131
+ path: z
132
+ .string()
133
+ .optional()
134
+ .describe("Subdirectory to start from (default: project root)"),
135
+ }),
136
+ execute: async ({ maxDepth = 6, path: subPath }) => {
137
+ let startDir: string;
138
+ if (subPath) {
139
+ const validated = resolveAndValidatePath(projectRoot, subPath);
140
+ if (!validated) {
141
+ return { error: "Path is outside project root" };
142
+ }
143
+ startDir = validated;
144
+ } else {
145
+ startDir = projectRoot;
146
+ }
147
+
148
+ const tree = await buildTree(startDir, projectRoot, maxDepth);
149
+ const treeString = treeToString(tree);
150
+
151
+ return {
152
+ tree: treeString,
153
+ rootPath: subPath || ".",
154
+ };
155
+ },
156
+ });
157
+ }
@@ -0,0 +1,123 @@
1
+ import { tool } from "ai";
2
+ import { z } from "zod";
3
+ import { execFileSync } from "child_process";
4
+
5
+ function runGit(args: string[], cwd: string): string {
6
+ try {
7
+ return execFileSync("git", args, {
8
+ cwd,
9
+ encoding: "utf-8",
10
+ timeout: 10_000,
11
+ stdio: ["pipe", "pipe", "pipe"],
12
+ }).trim();
13
+ } catch {
14
+ return "";
15
+ }
16
+ }
17
+
18
+ export function createGetGitInfoTool(projectRoot: string) {
19
+ return tool({
20
+ description:
21
+ "Get git information about the project: recent commits, branches, most-changed files, and contributors.",
22
+ parameters: z.object({
23
+ type: z
24
+ .enum(["summary", "recent-commits", "file-frequency", "contributors"])
25
+ .describe("Type of git information to retrieve"),
26
+ limit: z
27
+ .number()
28
+ .optional()
29
+ .describe("Number of results to return (default: 20)"),
30
+ }),
31
+ execute: async ({ type, limit = 20 }) => {
32
+ // Check if it's a git repo
33
+ const isGit = runGit(["rev-parse", "--is-inside-work-tree"], projectRoot);
34
+ if (isGit !== "true") {
35
+ return { error: "Not a git repository" };
36
+ }
37
+
38
+ switch (type) {
39
+ case "summary": {
40
+ const branch = runGit(["branch", "--show-current"], projectRoot);
41
+ const commitCount = runGit(
42
+ ["rev-list", "--count", "HEAD"],
43
+ projectRoot
44
+ );
45
+ const lastCommit = runGit(
46
+ ["log", "-1", "--format=%H %s (%ar)"],
47
+ projectRoot
48
+ );
49
+ const remoteUrl = runGit(
50
+ ["config", "--get", "remote.origin.url"],
51
+ projectRoot
52
+ );
53
+ const branches = runGit(["branch", "-a", "--list"], projectRoot)
54
+ .split("\n")
55
+ .filter(Boolean).length;
56
+
57
+ return {
58
+ currentBranch: branch,
59
+ totalCommits: parseInt(commitCount) || 0,
60
+ lastCommit,
61
+ remoteUrl,
62
+ branchCount: branches,
63
+ };
64
+ }
65
+
66
+ case "recent-commits": {
67
+ const log = runGit(
68
+ ["log", `-${limit}`, "--format=%h|%s|%an|%ar"],
69
+ projectRoot
70
+ );
71
+ const commits = log
72
+ .split("\n")
73
+ .filter(Boolean)
74
+ .map((line) => {
75
+ const [hash, subject, author, date] = line.split("|");
76
+ return { hash, subject, author, date };
77
+ });
78
+ return { commits };
79
+ }
80
+
81
+ case "file-frequency": {
82
+ const raw = runGit(
83
+ ["log", "--pretty=format:", "--name-only", `-${Math.min(limit * 5, 500)}`],
84
+ projectRoot
85
+ );
86
+ // Count occurrences of each file path in JS instead of shell pipes
87
+ const counts = new Map<string, number>();
88
+ for (const line of raw.split("\n")) {
89
+ const name = line.trim();
90
+ if (name) {
91
+ counts.set(name, (counts.get(name) || 0) + 1);
92
+ }
93
+ }
94
+ const files = [...counts.entries()]
95
+ .sort((a, b) => b[1] - a[1])
96
+ .slice(0, limit)
97
+ .map(([file, changes]) => ({ file, changes }));
98
+ return { mostChangedFiles: files };
99
+ }
100
+
101
+ case "contributors": {
102
+ const result = runGit(
103
+ ["shortlog", "-sn", "--no-merges", "HEAD"],
104
+ projectRoot
105
+ );
106
+ const contributors = result
107
+ .split("\n")
108
+ .filter(Boolean)
109
+ .slice(0, limit)
110
+ .map((line) => {
111
+ const trimmed = line.trim();
112
+ const tabIdx = trimmed.indexOf("\t");
113
+ return {
114
+ name: trimmed.slice(tabIdx + 1),
115
+ commits: parseInt(trimmed.slice(0, tabIdx)) || 0,
116
+ };
117
+ });
118
+ return { contributors };
119
+ }
120
+ }
121
+ },
122
+ });
123
+ }
@@ -0,0 +1,48 @@
1
+ import { tool } from "ai";
2
+ import { z } from "zod";
3
+ import { filterIgnoredFiles } from "./shared-ignore.js";
4
+
5
+ export function createGlobTool(projectRoot: string) {
6
+ return tool({
7
+ description:
8
+ "Find files matching a glob pattern within the project. Returns up to 200 matching file paths.",
9
+ parameters: z.object({
10
+ pattern: z
11
+ .string()
12
+ .describe(
13
+ 'Glob pattern (e.g., "**/*.ts", "src/**/*.tsx", "*.json")'
14
+ ),
15
+ ignore: z
16
+ .array(z.string())
17
+ .optional()
18
+ .describe("Additional patterns to ignore"),
19
+ }),
20
+ execute: async ({ pattern, ignore: extraIgnore = [] }) => {
21
+ try {
22
+ // Block path traversal attempts
23
+ if (pattern.includes("..")) {
24
+ return { error: "Glob pattern must not contain '..' (path traversal)." };
25
+ }
26
+
27
+ const glob = new Bun.Glob(pattern);
28
+ const allFiles: string[] = [];
29
+ for await (const f of glob.scan({ cwd: projectRoot, dot: false })) {
30
+ allFiles.push(f);
31
+ }
32
+
33
+ const files = await filterIgnoredFiles(allFiles, projectRoot, extraIgnore);
34
+
35
+ const limited = files.slice(0, 200);
36
+
37
+ return {
38
+ files: limited,
39
+ totalMatches: files.length,
40
+ truncated: files.length > 200,
41
+ };
42
+ } catch (err: unknown) {
43
+ const msg = err instanceof Error ? err.message : String(err);
44
+ return { error: `Glob failed: ${msg}` };
45
+ }
46
+ },
47
+ });
48
+ }
@@ -0,0 +1,149 @@
1
+ import { tool } from "ai";
2
+ import { z } from "zod";
3
+ import * as fs from "fs";
4
+ import * as path from "path";
5
+ import { debugLog } from "../utils/debug.js";
6
+ import { filterIgnoredFiles } from "./shared-ignore.js";
7
+
8
+ /** Maximum allowed regex pattern length to reject overly complex patterns. */
9
+ const MAX_PATTERN_LENGTH = 200;
10
+
11
+ /** Maximum total matches to process across all files. */
12
+ const MAX_TOTAL_MATCHES = 1000;
13
+
14
+ /**
15
+ * Check if a regex pattern is safe from ReDoS attacks.
16
+ * Rejects patterns with:
17
+ * - Nested quantifiers like (a+)+, (a*)*, (?:a+)+, etc.
18
+ * - Alternation overlap like (a|a)+
19
+ * - Excessive length (> MAX_PATTERN_LENGTH chars)
20
+ */
21
+ function isReDoSSafe(pattern: string): boolean {
22
+ // Reject overly complex patterns by length
23
+ if (pattern.length > MAX_PATTERN_LENGTH) {
24
+ return false;
25
+ }
26
+
27
+ // Detect nested quantifiers in capturing and non-capturing groups:
28
+ // Matches patterns like (x+)+, (?:x+)+, (x*)+, (?:x+)*, (x{2,})*, etc.
29
+ const nestedQuantifier = /(?:\(\??:?)[^()]*[+*][^()]*\)[+*{]/;
30
+ if (nestedQuantifier.test(pattern)) {
31
+ return false;
32
+ }
33
+
34
+ // Detect alternation overlap: (a|a)+, (?:a|a)+ where alternatives are identical
35
+ const altOverlap = /(?:\(\??:?)([^()|]+)\|\1\)[+*{]/;
36
+ if (altOverlap.test(pattern)) {
37
+ return false;
38
+ }
39
+
40
+ return true;
41
+ }
42
+
43
+ export function createGrepTool(projectRoot: string) {
44
+ return tool({
45
+ description:
46
+ "Search for a regex pattern in project files. Returns matching lines with file paths and line numbers.",
47
+ parameters: z.object({
48
+ pattern: z.string().describe("Regex pattern to search for"),
49
+ filePattern: z
50
+ .string()
51
+ .optional()
52
+ .describe('Glob pattern to filter files (e.g., "**/*.ts")'),
53
+ maxResults: z
54
+ .number()
55
+ .optional()
56
+ .describe("Maximum number of results (default: 50)"),
57
+ }),
58
+ execute: async ({ pattern, filePattern = "**/*", maxResults = 50 }) => {
59
+ try {
60
+ const glob = new Bun.Glob(filePattern);
61
+ const allFiles: string[] = [];
62
+ for await (const f of glob.scan({ cwd: projectRoot, dot: false })) {
63
+ allFiles.push(f);
64
+ }
65
+
66
+ const files = await filterIgnoredFiles(allFiles, projectRoot);
67
+
68
+ if (!isReDoSSafe(pattern)) {
69
+ return { error: "Regex pattern rejected: nested quantifiers detected (potential ReDoS). Simplify the pattern." };
70
+ }
71
+
72
+ let regex: RegExp;
73
+ try {
74
+ regex = new RegExp(pattern, "gi");
75
+ } catch (regexErr: unknown) {
76
+ const msg = regexErr instanceof Error ? regexErr.message : String(regexErr);
77
+ return { error: `Invalid regex pattern: ${msg}` };
78
+ }
79
+ const results: Array<{
80
+ file: string;
81
+ line: number;
82
+ content: string;
83
+ }> = [];
84
+
85
+ // Cap maxResults to the global safety limit
86
+ const effectiveMax = Math.min(maxResults, MAX_TOTAL_MATCHES);
87
+
88
+ // Process files in parallel batches
89
+ const BATCH_SIZE = 20;
90
+ for (let i = 0; i < files.length; i += BATCH_SIZE) {
91
+ if (results.length >= effectiveMax) break;
92
+
93
+ const batch = files.slice(i, i + BATCH_SIZE);
94
+ const batchResults = await Promise.all(
95
+ batch.map(async (file) => {
96
+ const matches: Array<{ file: string; line: number; content: string }> = [];
97
+ try {
98
+ const fullPath = path.join(projectRoot, file);
99
+ const stat = await fs.promises.stat(fullPath);
100
+ if (stat.size > 100_000) return matches;
101
+
102
+ const content = await fs.promises.readFile(fullPath, "utf-8");
103
+ const lines = content.split("\n");
104
+ // Create new regex per file to avoid shared lastIndex issues
105
+ const fileRegex = new RegExp(pattern, "gi");
106
+
107
+ for (let j = 0; j < lines.length; j++) {
108
+ try {
109
+ if (fileRegex.test(lines[j])) {
110
+ matches.push({
111
+ file,
112
+ line: j + 1,
113
+ content: lines[j].trim().slice(0, 200),
114
+ });
115
+ }
116
+ } catch {
117
+ // Regex execution error — skip this line
118
+ break;
119
+ }
120
+ fileRegex.lastIndex = 0;
121
+ }
122
+ } catch (e) {
123
+ debugLog("grep:readFile", e);
124
+ }
125
+ return matches;
126
+ })
127
+ );
128
+
129
+ for (const fileMatches of batchResults) {
130
+ for (const match of fileMatches) {
131
+ if (results.length >= effectiveMax) break;
132
+ results.push(match);
133
+ }
134
+ if (results.length >= effectiveMax) break;
135
+ }
136
+ }
137
+
138
+ return {
139
+ matches: results,
140
+ totalMatches: results.length,
141
+ truncated: results.length >= effectiveMax,
142
+ };
143
+ } catch (err: unknown) {
144
+ const msg = err instanceof Error ? err.message : String(err);
145
+ return { error: `Grep failed: ${msg}` };
146
+ }
147
+ },
148
+ });
149
+ }
@@ -0,0 +1,30 @@
1
+ import { createReadFileTool } from "./read-file.js";
2
+ import { createGlobTool } from "./glob.js";
3
+ import { createGrepTool } from "./grep.js";
4
+ import { createListDirectoryTool } from "./list-directory.js";
5
+ import { createReadPackageJsonTool } from "./read-package-json.js";
6
+ import { createGetFileTreeTool } from "./get-file-tree.js";
7
+ import { createGetGitInfoTool } from "./get-git-info.js";
8
+ import { createRunCommandTool } from "./run-command.js";
9
+
10
+ export function createBaseTools(projectRoot: string) {
11
+ return {
12
+ readFile: createReadFileTool(projectRoot),
13
+ glob: createGlobTool(projectRoot),
14
+ grep: createGrepTool(projectRoot),
15
+ listDirectory: createListDirectoryTool(projectRoot),
16
+ getFileTree: createGetFileTreeTool(projectRoot),
17
+ readPackageJson: createReadPackageJsonTool(projectRoot),
18
+ };
19
+ }
20
+
21
+ export function createExtendedTools(projectRoot: string) {
22
+ return {
23
+ ...createBaseTools(projectRoot),
24
+ getGitInfo: createGetGitInfoTool(projectRoot),
25
+ runCommand: createRunCommandTool(projectRoot),
26
+ };
27
+ }
28
+
29
+ export type BaseTools = ReturnType<typeof createBaseTools>;
30
+ export type ExtendedTools = ReturnType<typeof createExtendedTools>;
@@ -0,0 +1,57 @@
1
+ import { tool } from "ai";
2
+ import { z } from "zod";
3
+ import * as fs from "fs";
4
+ import * as path from "path";
5
+ import { resolveAndValidatePath } from "../utils/file-utils.js";
6
+ import { debugLog } from "../utils/debug.js";
7
+
8
+ export function createListDirectoryTool(projectRoot: string) {
9
+ return tool({
10
+ description:
11
+ "List the contents of a directory relative to the project root. Shows files and subdirectories with basic metadata.",
12
+ parameters: z.object({
13
+ path: z
14
+ .string()
15
+ .default(".")
16
+ .describe("Relative directory path (default: project root)"),
17
+ }),
18
+ execute: async ({ path: dirPath }) => {
19
+ const resolved = resolveAndValidatePath(projectRoot, dirPath);
20
+ if (!resolved) {
21
+ return { error: "Path is outside project root" };
22
+ }
23
+
24
+ try {
25
+ const entries = await fs.promises.readdir(resolved, { withFileTypes: true });
26
+ const items: Array<{ name: string; type: "directory" | "file"; size?: number }> = [];
27
+ for (const entry of entries) {
28
+ if (entry.name.startsWith(".") && entry.name !== ".env.example") continue;
29
+ const fullPath = path.join(resolved, entry.name);
30
+ const isDir = entry.isDirectory();
31
+ let size: number | undefined;
32
+ if (!isDir) {
33
+ try {
34
+ size = (await fs.promises.stat(fullPath)).size;
35
+ } catch (e) {
36
+ debugLog("listDir:stat", e);
37
+ }
38
+ }
39
+ items.push({
40
+ name: entry.name,
41
+ type: isDir ? "directory" : "file",
42
+ size,
43
+ });
44
+ }
45
+ items.sort((a, b) => {
46
+ if (a.type !== b.type) return a.type === "directory" ? -1 : 1;
47
+ return a.name.localeCompare(b.name);
48
+ });
49
+
50
+ return { path: dirPath, entries: items };
51
+ } catch (err: unknown) {
52
+ const msg = err instanceof Error ? err.message : String(err);
53
+ return { error: `Failed to list directory: ${msg}` };
54
+ }
55
+ },
56
+ });
57
+ }