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,108 @@
1
+ import { safeReadFileAsync } from "../utils/file-utils.js";
2
+ import * as path from "path";
3
+ import type {
4
+ FileTreeNode,
5
+ ProjectClassification,
6
+ } from "../types/index.js";
7
+ import type { BaseTools, ExtendedTools } from "../tools/index.js";
8
+
9
+ // ── Shared context for all agents ────────────────────────────────────────
10
+
11
+ export interface SharedContext {
12
+ classification: ProjectClassification;
13
+ fileTreeString: string;
14
+ fileTree: FileTreeNode[];
15
+ fileContents: Map<string, string>; // relPath → content (pre-read)
16
+ contextInfo: string; // formatted project info
17
+ orchestratorAnalysis: string; // global project analysis from orchestrator agent
18
+ baseTools?: BaseTools; // pre-created base tools (shared across agents)
19
+ extendedTools?: ExtendedTools; // pre-created extended tools (shared across agents)
20
+ agentFilePaths?: Map<string, string[]>; // agentId → relevant file paths (cached)
21
+ }
22
+
23
+ // ── Pre-read all files into shared context ──────────────────────────────
24
+
25
+ export async function preReadAllFiles(
26
+ projectRoot: string,
27
+ filePaths: string[],
28
+ maxFiles: number = 30,
29
+ maxCharsPerFile: number = 8000,
30
+ fileSizes?: Map<string, number>
31
+ ): Promise<Map<string, string>> {
32
+ const contents = new Map<string, string>();
33
+
34
+ const candidates = filePaths.slice(0, maxFiles * 2);
35
+
36
+ const sorted = candidates
37
+ .map((p) => ({ path: p, size: fileSizes?.get(p) ?? 0 }))
38
+ .filter((r) => r.size > 0)
39
+ .sort((a, b) => a.size - b.size)
40
+ .slice(0, maxFiles);
41
+
42
+ // Read all selected files in parallel
43
+ const readResults = await Promise.all(
44
+ sorted.map(async ({ path: relPath }) => {
45
+ const content = await safeReadFileAsync(path.join(projectRoot, relPath));
46
+ return { path: relPath, content };
47
+ })
48
+ );
49
+ for (const { path: relPath, content } of readResults) {
50
+ if (content) {
51
+ if (content.length > maxCharsPerFile) {
52
+ const newlineIdx = content.lastIndexOf('\n', maxCharsPerFile);
53
+ const cutoff = newlineIdx > 0 ? newlineIdx : maxCharsPerFile;
54
+ contents.set(relPath, content.slice(0, cutoff) + "\n... (truncated)");
55
+ } else {
56
+ contents.set(relPath, content);
57
+ }
58
+ }
59
+ }
60
+ return contents;
61
+ }
62
+
63
+ // ── Build formatted context info string ─────────────────────────────────
64
+
65
+ export function buildContextInfo(
66
+ classification: ProjectClassification,
67
+ fileTreeString: string,
68
+ relevantFiles: string[]
69
+ ): string {
70
+ return `
71
+ ## Project Info
72
+ - Language: ${classification.language}
73
+ - Framework: ${classification.framework || "none"}
74
+ - Type: ${classification.projectType}
75
+ - Build Tool: ${classification.buildTool || "none"}
76
+ - Package Manager: ${classification.packageManager || "unknown"}
77
+ - Has Tests: ${classification.hasTests}
78
+ - Has CI: ${classification.hasCI}
79
+
80
+ ## File Tree
81
+ ${fileTreeString}
82
+
83
+ ## Relevant Files for Analysis
84
+ ${relevantFiles.slice(0, 50).join("\n")}
85
+ `;
86
+ }
87
+
88
+ // ── Build pre-loaded file content block for agent prompts ───────────────
89
+
90
+ export function buildFileContentBlock(
91
+ sharedContext: SharedContext,
92
+ agentRelevantPaths: string[]
93
+ ): string {
94
+ const parts: string[] = [];
95
+ for (const relPath of agentRelevantPaths) {
96
+ const content = sharedContext.fileContents.get(relPath);
97
+ if (content) {
98
+ parts.push(`### ${relPath}\n\`\`\`\n${content}\n\`\`\``);
99
+ }
100
+ }
101
+ if (parts.length === 0) return "";
102
+
103
+ return `## Pre-loaded Source Files (${parts.length} files)
104
+ > IMPORTANT: These files have already been read for you. Do NOT re-read them with tools.
105
+ > Only use readFile/glob/grep for files NOT listed below.
106
+
107
+ ${parts.join("\n\n")}`;
108
+ }
@@ -0,0 +1,153 @@
1
+ import { SubReportSchema } from "../schemas/report.js";
2
+ import {
3
+ normalizeSeverity as normalizeSeverityStr,
4
+ normalizeEffort as normalizeEffortStr,
5
+ } from "../schemas/findings.js";
6
+ import { ExtractionError } from "../errors.js";
7
+ import { debugLog } from "../utils/debug.js";
8
+ import type { AgentId, SubReport } from "../types/index.js";
9
+
10
+ interface RawSection {
11
+ heading?: string;
12
+ content?: string;
13
+ [key: string]: unknown;
14
+ }
15
+
16
+ interface RawFinding {
17
+ id?: string;
18
+ severity?: string;
19
+ category?: string;
20
+ title?: string;
21
+ description?: string;
22
+ files?: string[];
23
+ recommendation?: string;
24
+ effort?: string;
25
+ codeSnippet?: string;
26
+ [key: string]: unknown;
27
+ }
28
+
29
+ // ── Value normalization (delegates to canonical functions in findings.ts) ──
30
+
31
+ export function normalizeSeverity(value: unknown): "critical" | "warning" | "info" | "suggestion" {
32
+ return normalizeSeverityStr(String(value || "info"));
33
+ }
34
+
35
+ export function normalizeEffort(value: unknown): "low" | "medium" | "high" {
36
+ return normalizeEffortStr(String(value || "medium"));
37
+ }
38
+
39
+ // ── JSON extraction from agent output ───────────────────────────────────
40
+
41
+ export function extractJsonFromText(text: string): string | null {
42
+ // Strategy 1: Find the LAST <report>...</report> block (greedy from end)
43
+ const lastOpen = text.lastIndexOf("<report>");
44
+ const lastClose = text.lastIndexOf("</report>");
45
+ if (lastOpen >= 0 && lastClose > lastOpen) {
46
+ let json = text.substring(lastOpen + "<report>".length, lastClose).trim();
47
+ // Strip code block markers if agent wrapped JSON in ```json ... ```
48
+ json = json.replace(/^```(?:json)?\s*\n?/, "").replace(/\n?```\s*$/, "").trim();
49
+ return json;
50
+ }
51
+
52
+ // Strategy 2: Find a JSON object containing "sections" key
53
+ const jsonMatch = text.match(/\{[\s\S]*"sections"\s*:\s*\[[\s\S]*\][\s\S]*\}/);
54
+ if (jsonMatch) {
55
+ return jsonMatch[0];
56
+ }
57
+
58
+ return null;
59
+ }
60
+
61
+ // ── Sub-report extraction with multi-fallback ───────────────────────────
62
+
63
+ export function extractSubReport(
64
+ agentId: AgentId,
65
+ agentName: string,
66
+ text: string,
67
+ filesAnalyzed: number,
68
+ usage?: { promptTokens: number; completionTokens: number }
69
+ ): SubReport {
70
+ const makeMeta = () => ({
71
+ filesAnalyzed,
72
+ tokensUsed: {
73
+ input: usage?.promptTokens || 0,
74
+ output: usage?.completionTokens || 0,
75
+ },
76
+ duration: 0,
77
+ });
78
+
79
+ const jsonStr = extractJsonFromText(text);
80
+
81
+ if (jsonStr) {
82
+ // Try strict Zod parse first
83
+ try {
84
+ const parsed = JSON.parse(jsonStr);
85
+ return SubReportSchema.parse({
86
+ ...parsed,
87
+ agentId,
88
+ agentName,
89
+ metadata: makeMeta(),
90
+ });
91
+ } catch (e) {
92
+ debugLog("pipeline:strictParse", new ExtractionError(
93
+ `Strict parse failed for agent ${agentId}: ${e instanceof Error ? e.message : String(e)}`
94
+ ));
95
+ }
96
+
97
+ // Try lenient manual parse
98
+ try {
99
+ const parsed = JSON.parse(jsonStr);
100
+ const sections = Array.isArray(parsed.sections)
101
+ ? parsed.sections
102
+ .filter((s: RawSection) => s && typeof s.heading === "string" && typeof s.content === "string")
103
+ .map((s: RawSection) => ({ heading: String(s.heading), content: String(s.content) }))
104
+ : [];
105
+ const findings = Array.isArray(parsed.findings)
106
+ ? parsed.findings
107
+ .filter((f: RawFinding) => f && typeof f.title === "string")
108
+ .map((f: RawFinding) => ({
109
+ id: String(f.id || `${agentId}-${Math.random().toString(36).slice(2, 6)}`),
110
+ severity: normalizeSeverity(f.severity),
111
+ category: String(f.category || "general"),
112
+ title: String(f.title),
113
+ description: String(f.description || ""),
114
+ files: Array.isArray(f.files) ? f.files.map(String) : [],
115
+ recommendation: String(f.recommendation || ""),
116
+ effort: normalizeEffort(f.effort),
117
+ ...(f.codeSnippet ? { codeSnippet: String(f.codeSnippet) } : {}),
118
+ }))
119
+ : [];
120
+
121
+ return {
122
+ agentId,
123
+ agentName,
124
+ title: String(parsed.title || agentName),
125
+ summary: String(parsed.summary || "Analysis completed"),
126
+ sections: sections.length > 0 ? sections : [{ heading: "Analysis", content: String(parsed.summary || "Analysis completed") }],
127
+ findings,
128
+ metadata: makeMeta(),
129
+ };
130
+ } catch (e) {
131
+ debugLog("pipeline:lenientParse", new ExtractionError(
132
+ `Lenient parse failed for agent ${agentId}: ${e instanceof Error ? e.message : String(e)}`
133
+ ));
134
+ }
135
+ } else {
136
+ // No <report> block found in output, falling through to fallback
137
+ }
138
+
139
+ // Fallback: strip the <report> JSON from content so it doesn't render as text
140
+ const cleanText = text
141
+ .replace(/<report>[\s\S]*?<\/report>/g, "")
142
+ .trim();
143
+
144
+ return {
145
+ agentId,
146
+ agentName,
147
+ title: agentName,
148
+ summary: "Analysis completed",
149
+ sections: [{ heading: "Analysis", content: cleanText || "No structured output was produced." }],
150
+ findings: [],
151
+ metadata: makeMeta(),
152
+ };
153
+ }
@@ -0,0 +1,192 @@
1
+ import { EventEmitter } from "events";
2
+ import type {
3
+ AgentId,
4
+ AgentStatus,
5
+ AgentRunStatus,
6
+ PipelinePhase,
7
+ PipelineProgress,
8
+ } from "../types/index.js";
9
+
10
+ /** Maximum characters retained per agent stream to prevent unbounded memory growth. */
11
+ const MAX_STREAM_TEXT = 2000;
12
+
13
+ // ── Typed EventEmitter ─────────────────────────────────────────────────
14
+ // Maps each event name to its listener signature. This ensures that
15
+ // .on(), .emit(), and .off() calls are statically checked.
16
+
17
+ export type ProgressEventMap = {
18
+ phaseChange: [phase: PipelinePhase];
19
+ phaseDetailChange: [detail: string];
20
+ agentStatusChange: [status: AgentStatus];
21
+ streamText: [text: string, agentId: AgentId];
22
+ tokenUpdate: [tokens: { input: number; output: number }];
23
+ warning: [message: string];
24
+ complete: [progress: PipelineProgress];
25
+ };
26
+
27
+ /**
28
+ * A thin typed wrapper around Node's EventEmitter.
29
+ * Constrains `on`, `off`, `once`, and `emit` to the keys / signatures
30
+ * declared in the supplied event map `T`, where each value is a tuple
31
+ * of the event's argument types.
32
+ */
33
+ interface ITypedEmitter<T extends Record<string, unknown[]>> {
34
+ on<K extends keyof T & string>(event: K, listener: (...args: T[K]) => void): this;
35
+ off<K extends keyof T & string>(event: K, listener: (...args: T[K]) => void): this;
36
+ once<K extends keyof T & string>(event: K, listener: (...args: T[K]) => void): this;
37
+ emit<K extends keyof T & string>(event: K, ...args: T[K]): boolean;
38
+ }
39
+
40
+ // Re-export EventEmitter cast through the typed interface so that
41
+ // ProgressTracker inherits the real implementation while exposing
42
+ // only the type-safe signatures to consumers.
43
+ const TypedEventEmitter = EventEmitter as {
44
+ new <T extends Record<string, unknown[]>>(): ITypedEmitter<T> & EventEmitter;
45
+ };
46
+
47
+ export class ProgressTracker extends TypedEventEmitter<ProgressEventMap> {
48
+ private agents: Map<AgentId, AgentStatus> = new Map();
49
+ private phase: PipelinePhase = "scanning";
50
+ private phaseDetail = "";
51
+ private totalTokens = { input: 0, output: 0 };
52
+ private previousTokens: Map<AgentId, { input: number; output: number }> = new Map();
53
+ private startedAt: Date = new Date();
54
+ private currentStreamText = "";
55
+ private agentStreamTexts: Map<AgentId, string> = new Map();
56
+
57
+ setPhase(phase: PipelinePhase): void {
58
+ this.phase = phase;
59
+ this.phaseDetail = "";
60
+ this.emit("phaseChange", phase);
61
+ }
62
+
63
+ setPhaseDetail(detail: string): void {
64
+ this.phaseDetail = detail;
65
+ this.emit("phaseDetailChange", detail);
66
+ }
67
+
68
+ registerAgent(agentId: AgentId, agentName: string): void {
69
+ const status: AgentStatus = {
70
+ agentId,
71
+ agentName,
72
+ status: "pending",
73
+ };
74
+ this.agents.set(agentId, status);
75
+ this.emit("agentStatusChange", status);
76
+ }
77
+
78
+ updateAgentStatus(
79
+ agentId: AgentId,
80
+ status: AgentRunStatus,
81
+ message?: string
82
+ ): void {
83
+ const agent = this.agents.get(agentId);
84
+ if (!agent) return;
85
+
86
+ agent.status = status;
87
+ if (message) agent.statusMessage = message;
88
+
89
+ if (status === "running" && !agent.startedAt) {
90
+ agent.startedAt = new Date();
91
+ }
92
+ if (status === "completed" || status === "failed") {
93
+ agent.completedAt = new Date();
94
+ }
95
+
96
+ this.agents.set(agentId, agent);
97
+ this.emit("agentStatusChange", { ...agent });
98
+ }
99
+
100
+ setAgentError(agentId: AgentId, error: string): void {
101
+ const agent = this.agents.get(agentId);
102
+ if (!agent) return;
103
+
104
+ agent.status = "failed";
105
+ agent.error = error;
106
+ agent.completedAt = new Date();
107
+ this.agents.set(agentId, agent);
108
+ this.emit("agentStatusChange", { ...agent });
109
+ }
110
+
111
+ updateAgentTokens(
112
+ agentId: AgentId,
113
+ tokens: { input: number; output: number }
114
+ ): void {
115
+ const agent = this.agents.get(agentId);
116
+ if (!agent) return;
117
+
118
+ agent.tokensUsed = tokens;
119
+ this.agents.set(agentId, agent);
120
+
121
+ // O(1) delta update: subtract previous values, add new ones
122
+ const prev = this.previousTokens.get(agentId) || { input: 0, output: 0 };
123
+ this.totalTokens.input += tokens.input - prev.input;
124
+ this.totalTokens.output += tokens.output - prev.output;
125
+ this.previousTokens.set(agentId, { input: tokens.input, output: tokens.output });
126
+
127
+ this.emit("tokenUpdate", { ...this.totalTokens });
128
+ }
129
+
130
+ addStreamText(delta: string, agentId: AgentId): void {
131
+ const existing = this.agentStreamTexts.get(agentId) || "";
132
+ let updated = existing + delta;
133
+ if (updated.length > MAX_STREAM_TEXT) {
134
+ updated = updated.slice(-MAX_STREAM_TEXT);
135
+ }
136
+ this.agentStreamTexts.set(agentId, updated);
137
+ this.currentStreamText = updated;
138
+ this.emit("streamText", delta, agentId);
139
+ }
140
+
141
+ getProgress(): PipelineProgress {
142
+ const agents = Array.from(this.agents.values());
143
+ const completed = agents.filter(
144
+ (a) => a.status === "completed" || a.status === "failed"
145
+ ).length;
146
+ const total = agents.length;
147
+
148
+ let estimatedTimeRemaining: number | undefined;
149
+ if (completed > 0 && completed < total) {
150
+ const elapsed = Date.now() - this.startedAt.getTime();
151
+ const avgTimePerAgent = elapsed / completed;
152
+ estimatedTimeRemaining = Math.round(
153
+ avgTimePerAgent * (total - completed) / 1000
154
+ );
155
+ }
156
+
157
+ return {
158
+ phase: this.phase,
159
+ phaseDetail: this.phaseDetail || undefined,
160
+ agents,
161
+ totalTokens: { ...this.totalTokens },
162
+ startedAt: this.startedAt,
163
+ estimatedTimeRemaining,
164
+ currentStreamText: this.currentStreamText,
165
+ };
166
+ }
167
+
168
+ getCompletionPercentage(): number {
169
+ const agents = Array.from(this.agents.values());
170
+ if (agents.length === 0) return 0;
171
+
172
+ const completed = agents.filter(
173
+ (a) =>
174
+ a.status === "completed" ||
175
+ a.status === "failed" ||
176
+ a.status === "skipped"
177
+ ).length;
178
+
179
+ return Math.round((completed / agents.length) * 100);
180
+ }
181
+
182
+ reset(): void {
183
+ this.agents.clear();
184
+ this.phase = "scanning";
185
+ this.phaseDetail = "";
186
+ this.totalTokens = { input: 0, output: 0 };
187
+ this.previousTokens.clear();
188
+ this.startedAt = new Date();
189
+ this.currentStreamText = "";
190
+ this.agentStreamTexts.clear();
191
+ }
192
+ }