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,52 @@
|
|
|
1
|
+
// Approximate token limits per model family
|
|
2
|
+
const MODEL_TOKEN_LIMITS: Record<string, number> = {
|
|
3
|
+
// Anthropic
|
|
4
|
+
"claude-sonnet-4-5": 200_000,
|
|
5
|
+
"claude-opus-4": 200_000,
|
|
6
|
+
"claude-haiku-4": 200_000,
|
|
7
|
+
// OpenAI
|
|
8
|
+
"gpt-4o": 128_000,
|
|
9
|
+
"gpt-4-turbo": 128_000,
|
|
10
|
+
"gpt-4": 8_192,
|
|
11
|
+
"gpt-3.5": 16_000,
|
|
12
|
+
"o1": 200_000,
|
|
13
|
+
"o3": 200_000,
|
|
14
|
+
// Google
|
|
15
|
+
"gemini-2": 1_000_000,
|
|
16
|
+
"gemini-1.5": 1_000_000,
|
|
17
|
+
// Mistral
|
|
18
|
+
"mistral-large": 128_000,
|
|
19
|
+
"mistral-medium": 32_000,
|
|
20
|
+
// Ollama local models
|
|
21
|
+
"qwen2.5-coder": 32_000,
|
|
22
|
+
"qwen2.5": 32_000,
|
|
23
|
+
"llama3": 8_192,
|
|
24
|
+
"llama3.1": 128_000,
|
|
25
|
+
"llama3.2": 128_000,
|
|
26
|
+
"codellama": 16_000,
|
|
27
|
+
"deepseek-coder": 16_000,
|
|
28
|
+
"mistral": 32_000,
|
|
29
|
+
"mixtral": 32_000,
|
|
30
|
+
"phi3": 128_000,
|
|
31
|
+
"gemma2": 8_192,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export function getModelTokenLimit(modelId: string, configLimits?: Record<string, number>): number {
|
|
35
|
+
const allLimits = { ...MODEL_TOKEN_LIMITS, ...configLimits };
|
|
36
|
+
|
|
37
|
+
// Try exact match first
|
|
38
|
+
if (allLimits[modelId]) return allLimits[modelId];
|
|
39
|
+
|
|
40
|
+
// Try prefix match
|
|
41
|
+
for (const [prefix, limit] of Object.entries(allLimits)) {
|
|
42
|
+
if (modelId.startsWith(prefix)) return limit;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Default to a conservative limit
|
|
46
|
+
return 128_000;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Rough token estimation (4 chars ~ 1 token for English text/code)
|
|
50
|
+
export function estimateTokens(text: string): number {
|
|
51
|
+
return Math.ceil(text.length / 4);
|
|
52
|
+
}
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import { generateText, streamText } from "ai";
|
|
2
|
+
import type { LanguageModelV1 } from "ai";
|
|
3
|
+
import { ProgressTracker } from "./progress.js";
|
|
4
|
+
import { extractSubReport } from "./extraction.js";
|
|
5
|
+
import { buildFileContentBlock, type SharedContext } from "./context.js";
|
|
6
|
+
import { getAgentById } from "../agents/registry.js";
|
|
7
|
+
import { createBaseTools, createExtendedTools, type BaseTools, type ExtendedTools } from "../tools/index.js";
|
|
8
|
+
import { getRelevantFilePaths } from "../ingestion/context-selector.js";
|
|
9
|
+
import { debugLog } from "../utils/debug.js";
|
|
10
|
+
import { getErrorMessage } from "../utils/format.js";
|
|
11
|
+
import type { AgentId, AgentDefinition, SubReport } from "../types/index.js";
|
|
12
|
+
import type { OpenReportConfig } from "../config/schema.js";
|
|
13
|
+
|
|
14
|
+
// ── Retry with exponential backoff ───────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
export async function withRetry<T>(
|
|
17
|
+
fn: () => Promise<T>,
|
|
18
|
+
maxRetries = 2,
|
|
19
|
+
baseDelay = 2000,
|
|
20
|
+
): Promise<T> {
|
|
21
|
+
for (let attempt = 0; ; attempt++) {
|
|
22
|
+
try {
|
|
23
|
+
return await fn();
|
|
24
|
+
} catch (err) {
|
|
25
|
+
if (attempt >= maxRetries || !isRetryableError(err)) throw err;
|
|
26
|
+
const delay = baseDelay * 2 ** attempt;
|
|
27
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function isRetryableError(err: unknown): boolean {
|
|
33
|
+
if (err instanceof Error) {
|
|
34
|
+
if (err.name === "AbortError") return false;
|
|
35
|
+
|
|
36
|
+
const status = (err as unknown as Record<string, unknown>).status;
|
|
37
|
+
if (typeof status === "number") {
|
|
38
|
+
if (status === 401 || status === 403) return false;
|
|
39
|
+
if (status === 429 || status >= 500) return true;
|
|
40
|
+
}
|
|
41
|
+
const code = (err as unknown as Record<string, unknown>).code;
|
|
42
|
+
if (typeof code === "string") {
|
|
43
|
+
if (code === "ECONNRESET" || code === "ECONNREFUSED" || code === "ETIMEDOUT") return true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const msg = err.message.toLowerCase();
|
|
47
|
+
if (msg.includes("401") || msg.includes("403") || msg.includes("authentication") || msg.includes("unauthorized")) return false;
|
|
48
|
+
if (msg.includes("429") || msg.includes("rate limit") || msg.includes("timeout") || msg.includes("econnreset") || msg.includes("econnrefused") || msg.includes("500") || msg.includes("502") || msg.includes("503") || msg.includes("504") || msg.includes("fetch failed")) return true;
|
|
49
|
+
}
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── Streaming throttle helper ────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
export function createStreamThrottle(onUpdate: (preview: string) => void, intervalMs = 500) {
|
|
56
|
+
let fullText = "";
|
|
57
|
+
let lastUpdate = 0;
|
|
58
|
+
return {
|
|
59
|
+
push(chunk: string) {
|
|
60
|
+
fullText += chunk;
|
|
61
|
+
const now = Date.now();
|
|
62
|
+
if (now - lastUpdate > intervalMs) {
|
|
63
|
+
lastUpdate = now;
|
|
64
|
+
onUpdate(fullText.slice(-120).replace(/\n/g, " "));
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
getFullText() {
|
|
68
|
+
return fullText;
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── Shared agent context preparation ────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
interface PreparedAgentContext {
|
|
76
|
+
agentDef: AgentDefinition;
|
|
77
|
+
relevantFiles: string[];
|
|
78
|
+
contextWithFiles: string;
|
|
79
|
+
tools: BaseTools | ExtendedTools;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function prepareAgentContext(
|
|
83
|
+
agentId: AgentId,
|
|
84
|
+
projectRoot: string,
|
|
85
|
+
sharedContext: SharedContext,
|
|
86
|
+
): PreparedAgentContext | null {
|
|
87
|
+
const agentDef = getAgentById(agentId);
|
|
88
|
+
if (!agentDef) return null;
|
|
89
|
+
|
|
90
|
+
const relevantFiles = sharedContext.agentFilePaths?.get(agentId)
|
|
91
|
+
?? getRelevantFilePaths(agentId, sharedContext.fileTree);
|
|
92
|
+
const fileContentBlock = buildFileContentBlock(sharedContext, relevantFiles);
|
|
93
|
+
const orchestratorSection = sharedContext.orchestratorAnalysis
|
|
94
|
+
? `## Orchestrator Project Overview\n> This analysis was produced by a preliminary scan of the project. Use it as context for your specialized analysis.\n\n${sharedContext.orchestratorAnalysis}`
|
|
95
|
+
: "";
|
|
96
|
+
const contextWithFiles = [
|
|
97
|
+
sharedContext.contextInfo,
|
|
98
|
+
orchestratorSection,
|
|
99
|
+
fileContentBlock,
|
|
100
|
+
].filter(Boolean).join("\n\n");
|
|
101
|
+
|
|
102
|
+
const tools =
|
|
103
|
+
agentDef.toolSet === "extended"
|
|
104
|
+
? (sharedContext.extendedTools ?? createExtendedTools(projectRoot))
|
|
105
|
+
: (sharedContext.baseTools ?? createBaseTools(projectRoot));
|
|
106
|
+
|
|
107
|
+
return { agentDef, relevantFiles, contextWithFiles, tools };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── Prompt building ─────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
export function buildAnalysisPrompt(agentId: string, agentName: string, contextInfo: string): string {
|
|
113
|
+
return `Analyze this project and produce a structured sub-report.
|
|
114
|
+
Start your analysis directly from the pre-loaded source files provided below. Only use tools to read additional files not already provided.
|
|
115
|
+
|
|
116
|
+
${contextInfo}
|
|
117
|
+
|
|
118
|
+
After your analysis, output your findings as a JSON object wrapped in <report> tags.
|
|
119
|
+
|
|
120
|
+
IMPORTANT: Output ONLY the JSON inside <report> tags — no code fences, no extra text around it.
|
|
121
|
+
|
|
122
|
+
<report>
|
|
123
|
+
{
|
|
124
|
+
"title": "Your Section Title",
|
|
125
|
+
"summary": "Brief 1-2 sentence summary of findings",
|
|
126
|
+
"sections": [
|
|
127
|
+
{ "heading": "Section Name", "content": "Detailed markdown content for this section..." }
|
|
128
|
+
],
|
|
129
|
+
"findings": [
|
|
130
|
+
{
|
|
131
|
+
"id": "unique-id",
|
|
132
|
+
"severity": "critical|warning|info|suggestion",
|
|
133
|
+
"category": "security|performance|architecture|code-quality|dependency|testing|documentation|maintainability",
|
|
134
|
+
"title": "Short finding title",
|
|
135
|
+
"description": "Detailed description of the issue",
|
|
136
|
+
"files": ["path/to/file.ts"],
|
|
137
|
+
"recommendation": "How to fix this",
|
|
138
|
+
"effort": "low|medium|high"
|
|
139
|
+
}
|
|
140
|
+
]
|
|
141
|
+
}
|
|
142
|
+
</report>
|
|
143
|
+
|
|
144
|
+
Rules for the JSON:
|
|
145
|
+
- severity MUST be exactly one of: critical, warning, info, suggestion
|
|
146
|
+
- effort MUST be exactly one of: low, medium, high
|
|
147
|
+
- sections[].content should be proper Markdown (headings, lists, code blocks, bold/italic)
|
|
148
|
+
- Each section should cover a distinct aspect of your analysis
|
|
149
|
+
- findings should be specific, actionable items`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── Orchestrator: global project analysis before specialized agents ──────
|
|
153
|
+
|
|
154
|
+
const ORCHESTRATOR_PROMPT = `You are a senior software architect performing a preliminary analysis of a codebase.
|
|
155
|
+
Your goal is to produce a concise project overview that will be shared with specialized analysis agents.
|
|
156
|
+
|
|
157
|
+
Analyze the provided project information and source files, then produce a structured overview covering:
|
|
158
|
+
|
|
159
|
+
1. **Architecture Overview** - What kind of project is this? What's the high-level architecture?
|
|
160
|
+
2. **Key Components** - What are the main modules/components and their responsibilities?
|
|
161
|
+
3. **Technology Stack** - Key technologies, frameworks, libraries in use
|
|
162
|
+
4. **Code Patterns** - What patterns are used (MVC, Repository, etc.)? Any anti-patterns?
|
|
163
|
+
5. **Entry Points** - Where does the application start? What are the main flows?
|
|
164
|
+
6. **Areas of Concern** - What stands out as potentially problematic at first glance?
|
|
165
|
+
|
|
166
|
+
Keep your analysis factual and concise (aim for 800-1500 words). Reference specific files when relevant.
|
|
167
|
+
This overview will be provided to specialized agents (security, performance, code quality, etc.) so they can focus on their domain without re-discovering the project structure.
|
|
168
|
+
|
|
169
|
+
Output your analysis as plain markdown (no JSON, no tags).`;
|
|
170
|
+
|
|
171
|
+
export async function runOrchestratorAgent(
|
|
172
|
+
model: LanguageModelV1,
|
|
173
|
+
sharedContext: SharedContext,
|
|
174
|
+
config: OpenReportConfig,
|
|
175
|
+
progress: ProgressTracker,
|
|
176
|
+
signal?: AbortSignal
|
|
177
|
+
): Promise<string> {
|
|
178
|
+
const allPaths = [...sharedContext.fileContents.keys()];
|
|
179
|
+
const fileContentBlock = buildFileContentBlock(sharedContext, allPaths);
|
|
180
|
+
|
|
181
|
+
const prompt = `${ORCHESTRATOR_PROMPT}
|
|
182
|
+
|
|
183
|
+
${sharedContext.contextInfo}
|
|
184
|
+
|
|
185
|
+
${fileContentBlock}
|
|
186
|
+
|
|
187
|
+
Produce your project overview now.`;
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
const fullText = await withRetry(async () => {
|
|
191
|
+
const result = await streamText({
|
|
192
|
+
model,
|
|
193
|
+
prompt,
|
|
194
|
+
maxSteps: 1,
|
|
195
|
+
maxTokens: config.agents.maxTokens,
|
|
196
|
+
temperature: 0.3,
|
|
197
|
+
abortSignal: signal,
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const throttle = createStreamThrottle((preview) => {
|
|
201
|
+
progress.setPhaseDetail(preview);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
for await (const chunk of result.textStream) {
|
|
205
|
+
throttle.push(chunk);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return throttle.getFullText();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
return fullText;
|
|
212
|
+
} catch (e) {
|
|
213
|
+
const reason = getErrorMessage(e);
|
|
214
|
+
debugLog("pipeline:orchestrator", `Orchestrator analysis failed: ${reason}`);
|
|
215
|
+
progress.emit("warning", `Orchestrator analysis failed: ${reason}`);
|
|
216
|
+
return "Orchestrator analysis was not available.";
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ── Run agent via API provider (with tools + multi-step) ────────────────
|
|
221
|
+
|
|
222
|
+
export async function runAgentWithTools(
|
|
223
|
+
agentId: AgentId,
|
|
224
|
+
model: LanguageModelV1,
|
|
225
|
+
projectRoot: string,
|
|
226
|
+
sharedContext: SharedContext,
|
|
227
|
+
config: OpenReportConfig,
|
|
228
|
+
progress: ProgressTracker,
|
|
229
|
+
signal?: AbortSignal
|
|
230
|
+
): Promise<SubReport | null> {
|
|
231
|
+
const ctx = prepareAgentContext(agentId, projectRoot, sharedContext);
|
|
232
|
+
if (!ctx) {
|
|
233
|
+
progress.setAgentError(agentId, "Agent definition not found");
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const { agentDef, relevantFiles, contextWithFiles, tools } = ctx;
|
|
238
|
+
|
|
239
|
+
progress.updateAgentStatus(agentId, "running", "Starting analysis...");
|
|
240
|
+
|
|
241
|
+
const maxSteps =
|
|
242
|
+
config.agents.maxStepsOverride[agentId] || agentDef.maxSteps;
|
|
243
|
+
|
|
244
|
+
try {
|
|
245
|
+
const result = await withRetry(() => generateText({
|
|
246
|
+
model,
|
|
247
|
+
system: agentDef.systemPrompt,
|
|
248
|
+
prompt: buildAnalysisPrompt(agentId, agentDef.name, contextWithFiles),
|
|
249
|
+
tools,
|
|
250
|
+
maxSteps,
|
|
251
|
+
temperature: config.agents.temperature,
|
|
252
|
+
maxTokens: config.agents.maxTokens,
|
|
253
|
+
abortSignal: signal,
|
|
254
|
+
onStepFinish: (step) => {
|
|
255
|
+
if (step.text) {
|
|
256
|
+
progress.updateAgentStatus(agentId, "running", step.text.slice(0, 100));
|
|
257
|
+
progress.addStreamText(step.text, agentId);
|
|
258
|
+
}
|
|
259
|
+
if (step.usage) {
|
|
260
|
+
progress.updateAgentTokens(agentId, {
|
|
261
|
+
input: step.usage.promptTokens,
|
|
262
|
+
output: step.usage.completionTokens,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
},
|
|
266
|
+
}));
|
|
267
|
+
|
|
268
|
+
const subReport = extractSubReport(
|
|
269
|
+
agentId,
|
|
270
|
+
agentDef.name,
|
|
271
|
+
result.text,
|
|
272
|
+
relevantFiles.length,
|
|
273
|
+
result.usage
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
progress.updateAgentStatus(agentId, "completed", "Done");
|
|
277
|
+
return subReport;
|
|
278
|
+
} catch (err: unknown) {
|
|
279
|
+
progress.setAgentError(agentId, getErrorMessage(err));
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ── Run agent via CLI provider (single call, no SDK tools, streaming) ───
|
|
285
|
+
|
|
286
|
+
export async function runAgentWithCli(
|
|
287
|
+
agentId: AgentId,
|
|
288
|
+
model: LanguageModelV1,
|
|
289
|
+
projectRoot: string,
|
|
290
|
+
sharedContext: SharedContext,
|
|
291
|
+
config: OpenReportConfig,
|
|
292
|
+
progress: ProgressTracker,
|
|
293
|
+
signal?: AbortSignal
|
|
294
|
+
): Promise<SubReport | null> {
|
|
295
|
+
const ctx = prepareAgentContext(agentId, projectRoot, sharedContext);
|
|
296
|
+
if (!ctx) {
|
|
297
|
+
progress.setAgentError(agentId, "Agent definition not found");
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const { agentDef, relevantFiles, contextWithFiles } = ctx;
|
|
302
|
+
|
|
303
|
+
progress.updateAgentStatus(agentId, "running", "Preparing analysis...");
|
|
304
|
+
|
|
305
|
+
const prompt = `${agentDef.systemPrompt}
|
|
306
|
+
|
|
307
|
+
${contextWithFiles}
|
|
308
|
+
|
|
309
|
+
${buildAnalysisPrompt(agentId, agentDef.name, "")}`;
|
|
310
|
+
|
|
311
|
+
progress.updateAgentStatus(agentId, "running", "Analyzing with AI...");
|
|
312
|
+
|
|
313
|
+
try {
|
|
314
|
+
const { text: fullText, usage } = await withRetry(async () => {
|
|
315
|
+
const result = await streamText({
|
|
316
|
+
model,
|
|
317
|
+
prompt,
|
|
318
|
+
maxSteps: 1,
|
|
319
|
+
temperature: config.agents.temperature,
|
|
320
|
+
maxTokens: config.agents.maxTokens,
|
|
321
|
+
abortSignal: signal,
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
const pendingChunks: string[] = [];
|
|
325
|
+
const throttle = createStreamThrottle((preview) => {
|
|
326
|
+
progress.updateAgentStatus(agentId, "running", preview);
|
|
327
|
+
progress.addStreamText(pendingChunks.join(""), agentId);
|
|
328
|
+
pendingChunks.length = 0;
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
for await (const chunk of result.textStream) {
|
|
332
|
+
pendingChunks.push(chunk);
|
|
333
|
+
throttle.push(chunk);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const collected = throttle.getFullText();
|
|
337
|
+
const u = await result.usage;
|
|
338
|
+
return { text: collected, usage: u };
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
progress.updateAgentTokens(agentId, {
|
|
342
|
+
input: usage.promptTokens,
|
|
343
|
+
output: usage.completionTokens,
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
const subReport = extractSubReport(
|
|
347
|
+
agentId,
|
|
348
|
+
agentDef.name,
|
|
349
|
+
fullText,
|
|
350
|
+
relevantFiles.length,
|
|
351
|
+
usage
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
progress.updateAgentStatus(agentId, "completed", "Done");
|
|
355
|
+
return subReport;
|
|
356
|
+
} catch (err: unknown) {
|
|
357
|
+
progress.setAgentError(agentId, getErrorMessage(err));
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
import type { SubReport, FullReport, ReportMetadata, Finding } from "../types/index.js";
|
|
3
|
+
import type { AgentStatus } from "../types/index.js";
|
|
4
|
+
import { SEVERITY_ORDER, SEVERITY_EMOJI } from "../schemas/findings.js";
|
|
5
|
+
|
|
6
|
+
export function countFindings(findings: Finding[]) {
|
|
7
|
+
const summary = { critical: 0, warning: 0, info: 0, suggestion: 0 };
|
|
8
|
+
for (const f of findings) {
|
|
9
|
+
if (f.severity in summary) {
|
|
10
|
+
summary[f.severity as keyof typeof summary]++;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
return summary;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function calculateGrade(
|
|
17
|
+
summary: { critical: number; warning: number; info: number; suggestion: number },
|
|
18
|
+
agentCount: number,
|
|
19
|
+
): string {
|
|
20
|
+
// Critical findings are absolute deal-breakers
|
|
21
|
+
if (summary.critical >= 5) return "F";
|
|
22
|
+
if (summary.critical >= 3) return "D";
|
|
23
|
+
if (summary.critical >= 1) return "C";
|
|
24
|
+
|
|
25
|
+
// Normalize warnings by the number of analysis agents to account for project scope
|
|
26
|
+
const normalizedWarnings = agentCount > 0
|
|
27
|
+
? summary.warning / agentCount
|
|
28
|
+
: summary.warning;
|
|
29
|
+
|
|
30
|
+
if (normalizedWarnings >= 3) return "C";
|
|
31
|
+
if (normalizedWarnings >= 1.5) return "B-";
|
|
32
|
+
if (normalizedWarnings >= 0.8) return "B";
|
|
33
|
+
if (normalizedWarnings >= 0.3) return "B+";
|
|
34
|
+
return "A";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function generateFindingSummaryTable(summary: {
|
|
38
|
+
critical: number;
|
|
39
|
+
warning: number;
|
|
40
|
+
info: number;
|
|
41
|
+
suggestion: number;
|
|
42
|
+
}): string {
|
|
43
|
+
return `| Severity | Count |
|
|
44
|
+
|----------|-------|
|
|
45
|
+
| Critical | ${summary.critical} |
|
|
46
|
+
| Warning | ${summary.warning} |
|
|
47
|
+
| Info | ${summary.info} |
|
|
48
|
+
| Suggestion | ${summary.suggestion} |
|
|
49
|
+
| **Total** | **${summary.critical + summary.warning + summary.info + summary.suggestion}** |`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function generateTableOfContents(subReports: SubReport[]): string {
|
|
53
|
+
let toc = "## Table of Contents\n\n";
|
|
54
|
+
toc += "1. [Executive Summary](#executive-summary)\n";
|
|
55
|
+
toc += "2. [Finding Summary](#finding-summary)\n";
|
|
56
|
+
|
|
57
|
+
subReports.forEach((report, idx) => {
|
|
58
|
+
const anchor = report.title.toLowerCase().replace(/[^a-z0-9]+/g, "-");
|
|
59
|
+
toc += `${idx + 3}. [${report.title}](#${anchor})\n`;
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
toc += `${subReports.length + 3}. [All Findings by Severity](#all-findings-by-severity)\n`;
|
|
63
|
+
return toc;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function generateFindingsAppendix(allFindings: Finding[]): string {
|
|
67
|
+
const sorted = [...allFindings].sort((a, b) => {
|
|
68
|
+
return (SEVERITY_ORDER[a.severity] ?? 4) - (SEVERITY_ORDER[b.severity] ?? 4);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
let md = "## All Findings by Severity\n\n";
|
|
72
|
+
|
|
73
|
+
for (const finding of sorted) {
|
|
74
|
+
const emoji = SEVERITY_EMOJI[finding.severity] || "";
|
|
75
|
+
md += `### ${emoji} [${finding.severity.toUpperCase()}] ${finding.title}\n\n`;
|
|
76
|
+
md += `${finding.description}\n\n`;
|
|
77
|
+
if (finding.files.length > 0) {
|
|
78
|
+
md += `**Files:** ${finding.files.join(", ")}\n\n`;
|
|
79
|
+
}
|
|
80
|
+
if (finding.codeSnippet) {
|
|
81
|
+
md += `\`\`\`\n${finding.codeSnippet}\n\`\`\`\n\n`;
|
|
82
|
+
}
|
|
83
|
+
md += `**Recommendation:** ${finding.recommendation}\n\n`;
|
|
84
|
+
md += `**Effort:** ${finding.effort}\n\n---\n\n`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return md;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface CombineOptions {
|
|
91
|
+
projectName: string;
|
|
92
|
+
projectPath: string;
|
|
93
|
+
reportType: string;
|
|
94
|
+
model: string;
|
|
95
|
+
executiveSummary: string;
|
|
96
|
+
subReports: SubReport[];
|
|
97
|
+
agentStatuses: AgentStatus[];
|
|
98
|
+
startedAt: Date;
|
|
99
|
+
totalTokens: { input: number; output: number };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function combineReport(options: CombineOptions): FullReport {
|
|
103
|
+
const {
|
|
104
|
+
projectName,
|
|
105
|
+
projectPath,
|
|
106
|
+
reportType,
|
|
107
|
+
model,
|
|
108
|
+
executiveSummary,
|
|
109
|
+
subReports,
|
|
110
|
+
agentStatuses,
|
|
111
|
+
startedAt,
|
|
112
|
+
totalTokens,
|
|
113
|
+
} = options;
|
|
114
|
+
|
|
115
|
+
const allFindings = subReports.flatMap((r) => r.findings);
|
|
116
|
+
const findingSummary = countFindings(allFindings);
|
|
117
|
+
const grade = calculateGrade(findingSummary, subReports.length);
|
|
118
|
+
const duration = Date.now() - startedAt.getTime();
|
|
119
|
+
const id = `rpt_${crypto.randomUUID().replace(/-/g, "").slice(0, 8)}`;
|
|
120
|
+
|
|
121
|
+
// Build markdown content using array for efficient string building
|
|
122
|
+
const parts: string[] = [];
|
|
123
|
+
parts.push(`# ${projectName} - ${reportType}\n\n`);
|
|
124
|
+
parts.push(`> Generated on ${new Date().toISOString().split("T")[0]} using ${model}\n\n`);
|
|
125
|
+
parts.push(`**Grade: ${grade}**\n\n`);
|
|
126
|
+
parts.push("---\n\n");
|
|
127
|
+
|
|
128
|
+
// Finding summary
|
|
129
|
+
parts.push("## Finding Summary\n\n");
|
|
130
|
+
parts.push(generateFindingSummaryTable(findingSummary));
|
|
131
|
+
parts.push("\n\n");
|
|
132
|
+
|
|
133
|
+
// Executive summary
|
|
134
|
+
parts.push("## Executive Summary\n\n");
|
|
135
|
+
parts.push(executiveSummary);
|
|
136
|
+
parts.push("\n\n---\n\n");
|
|
137
|
+
|
|
138
|
+
// Table of contents
|
|
139
|
+
parts.push(generateTableOfContents(subReports));
|
|
140
|
+
parts.push("\n---\n\n");
|
|
141
|
+
|
|
142
|
+
// Sub-reports
|
|
143
|
+
for (const report of subReports) {
|
|
144
|
+
parts.push(`## ${report.title}\n\n`);
|
|
145
|
+
parts.push(`*${report.summary}*\n\n`);
|
|
146
|
+
|
|
147
|
+
for (const section of report.sections) {
|
|
148
|
+
parts.push(`### ${section.heading}\n\n`);
|
|
149
|
+
parts.push(section.content);
|
|
150
|
+
parts.push("\n\n");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (report.findings.length > 0) {
|
|
154
|
+
parts.push(`### Findings (${report.findings.length})\n\n`);
|
|
155
|
+
for (const finding of report.findings) {
|
|
156
|
+
parts.push(`- ${SEVERITY_EMOJI[finding.severity] || ""} **[${finding.severity}]** ${finding.title}: ${finding.description}\n`);
|
|
157
|
+
}
|
|
158
|
+
parts.push("\n");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
parts.push("---\n\n");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Findings appendix
|
|
165
|
+
if (allFindings.length > 0) {
|
|
166
|
+
parts.push(generateFindingsAppendix(allFindings));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const md = parts.join("");
|
|
170
|
+
|
|
171
|
+
const metadata: ReportMetadata = {
|
|
172
|
+
id,
|
|
173
|
+
type: reportType,
|
|
174
|
+
projectName,
|
|
175
|
+
projectPath,
|
|
176
|
+
createdAt: new Date().toISOString(),
|
|
177
|
+
duration,
|
|
178
|
+
model,
|
|
179
|
+
tokens: totalTokens,
|
|
180
|
+
agentStatuses: agentStatuses.map((a) => ({
|
|
181
|
+
agentId: a.agentId,
|
|
182
|
+
status: a.status === "completed" ? "completed" : a.status === "skipped" ? "skipped" : "failed",
|
|
183
|
+
duration: a.completedAt && a.startedAt
|
|
184
|
+
? a.completedAt.getTime() - a.startedAt.getTime()
|
|
185
|
+
: undefined,
|
|
186
|
+
})),
|
|
187
|
+
grade,
|
|
188
|
+
sections: subReports.map((r) => r.title),
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
metadata,
|
|
193
|
+
executiveSummary,
|
|
194
|
+
findingSummary,
|
|
195
|
+
subReports,
|
|
196
|
+
allFindings,
|
|
197
|
+
markdownContent: md,
|
|
198
|
+
};
|
|
199
|
+
}
|