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.
- package/LICENSE +21 -0
- package/README.md +117 -0
- package/bin/openreport.ts +6 -0
- package/package.json +61 -0
- package/src/agents/api-documentation.ts +66 -0
- package/src/agents/architecture-analyst.ts +46 -0
- package/src/agents/code-quality-reviewer.ts +59 -0
- package/src/agents/dependency-analyzer.ts +51 -0
- package/src/agents/onboarding-guide.ts +59 -0
- package/src/agents/orchestrator.ts +41 -0
- package/src/agents/performance-analyzer.ts +57 -0
- package/src/agents/registry.ts +50 -0
- package/src/agents/security-auditor.ts +61 -0
- package/src/agents/test-coverage-analyst.ts +58 -0
- package/src/agents/todo-generator.ts +50 -0
- package/src/app/App.tsx +151 -0
- package/src/app/theme.ts +54 -0
- package/src/cli.ts +145 -0
- package/src/commands/init.ts +81 -0
- package/src/commands/interactive.tsx +29 -0
- package/src/commands/list.ts +53 -0
- package/src/commands/run.ts +168 -0
- package/src/commands/view.tsx +52 -0
- package/src/components/generation/AgentStatusItem.tsx +125 -0
- package/src/components/generation/AgentStatusList.tsx +70 -0
- package/src/components/generation/ProgressSummary.tsx +107 -0
- package/src/components/generation/StreamingOutput.tsx +154 -0
- package/src/components/layout/Container.tsx +24 -0
- package/src/components/layout/Footer.tsx +52 -0
- package/src/components/layout/Header.tsx +50 -0
- package/src/components/report/MarkdownRenderer.tsx +50 -0
- package/src/components/report/ReportCard.tsx +31 -0
- package/src/components/report/ScrollableView.tsx +164 -0
- package/src/config/cli-detection.ts +130 -0
- package/src/config/cli-model.ts +397 -0
- package/src/config/cli-prompt-formatter.ts +129 -0
- package/src/config/defaults.ts +79 -0
- package/src/config/loader.ts +168 -0
- package/src/config/ollama.ts +48 -0
- package/src/config/providers.ts +199 -0
- package/src/config/resolve-provider.ts +62 -0
- package/src/config/saver.ts +50 -0
- package/src/config/schema.ts +51 -0
- package/src/errors.ts +34 -0
- package/src/hooks/useReportGeneration.ts +199 -0
- package/src/hooks/useTerminalSize.ts +35 -0
- package/src/ingestion/context-selector.ts +247 -0
- package/src/ingestion/file-tree.ts +227 -0
- package/src/ingestion/token-budget.ts +52 -0
- package/src/pipeline/agent-runner.ts +360 -0
- package/src/pipeline/combiner.ts +199 -0
- package/src/pipeline/context.ts +108 -0
- package/src/pipeline/extraction.ts +153 -0
- package/src/pipeline/progress.ts +192 -0
- package/src/pipeline/runner.ts +526 -0
- package/src/report/html-renderer.ts +294 -0
- package/src/report/html-script.ts +123 -0
- package/src/report/html-styles.ts +1127 -0
- package/src/report/md-to-html.ts +153 -0
- package/src/report/open-browser.ts +22 -0
- package/src/schemas/findings.ts +48 -0
- package/src/schemas/report.ts +64 -0
- package/src/screens/ConfigScreen.tsx +271 -0
- package/src/screens/GenerationScreen.tsx +278 -0
- package/src/screens/HistoryScreen.tsx +108 -0
- package/src/screens/HomeScreen.tsx +143 -0
- package/src/screens/ViewerScreen.tsx +82 -0
- package/src/storage/metadata.ts +69 -0
- package/src/storage/report-store.ts +128 -0
- package/src/tools/get-file-tree.ts +157 -0
- package/src/tools/get-git-info.ts +123 -0
- package/src/tools/glob.ts +48 -0
- package/src/tools/grep.ts +149 -0
- package/src/tools/index.ts +30 -0
- package/src/tools/list-directory.ts +57 -0
- package/src/tools/read-file.ts +52 -0
- package/src/tools/read-package-json.ts +48 -0
- package/src/tools/run-command.ts +154 -0
- package/src/tools/shared-ignore.ts +58 -0
- package/src/types/index.ts +127 -0
- package/src/types/marked-terminal.d.ts +17 -0
- package/src/utils/debug.ts +25 -0
- package/src/utils/file-utils.ts +77 -0
- package/src/utils/format.ts +56 -0
- package/src/utils/grade-colors.ts +43 -0
- 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
|
+
}
|