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,199 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
|
2
|
+
import { orchestrate } from "../agents/orchestrator.js";
|
|
3
|
+
import { saveReport } from "../storage/report-store.js";
|
|
4
|
+
import { getErrorMessage } from "../utils/format.js";
|
|
5
|
+
import type { LanguageModelV1 } from "ai";
|
|
6
|
+
import type {
|
|
7
|
+
AgentStatus,
|
|
8
|
+
PipelinePhase,
|
|
9
|
+
FullReport,
|
|
10
|
+
} from "../types/index.js";
|
|
11
|
+
import type { OpenReportConfig } from "../config/schema.js";
|
|
12
|
+
import type { ProgressTracker } from "../pipeline/progress.js";
|
|
13
|
+
|
|
14
|
+
// ── Public interface ─────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
export interface UseReportGenerationOptions {
|
|
17
|
+
projectRoot: string;
|
|
18
|
+
reportType: string;
|
|
19
|
+
model: LanguageModelV1;
|
|
20
|
+
modelId: string;
|
|
21
|
+
config: OpenReportConfig;
|
|
22
|
+
generateTodoList?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ReportGenerationState {
|
|
26
|
+
phase: PipelinePhase;
|
|
27
|
+
phaseDetail: string;
|
|
28
|
+
agents: AgentStatus[];
|
|
29
|
+
agentStreams: Record<string, string>;
|
|
30
|
+
error: string | null;
|
|
31
|
+
report: FullReport | null;
|
|
32
|
+
tokens: { input: number; output: number };
|
|
33
|
+
elapsed: number;
|
|
34
|
+
startedAt: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface UseReportGenerationResult extends ReportGenerationState {
|
|
38
|
+
abort: () => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ── Constants ────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
/** Maximum characters retained per agent stream to prevent unbounded memory growth. */
|
|
44
|
+
const MAX_STREAM_TEXT = 2000;
|
|
45
|
+
|
|
46
|
+
/** Interval (ms) to flush batched stream updates to React state. */
|
|
47
|
+
const STREAM_FLUSH_INTERVAL = 200;
|
|
48
|
+
|
|
49
|
+
// ── Hook implementation ──────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
export function useReportGeneration(
|
|
52
|
+
options: UseReportGenerationOptions
|
|
53
|
+
): UseReportGenerationResult {
|
|
54
|
+
const { projectRoot, reportType, model, modelId, config, generateTodoList } = options;
|
|
55
|
+
|
|
56
|
+
const [phase, setPhase] = useState<PipelinePhase>("scanning");
|
|
57
|
+
const [phaseDetail, setPhaseDetail] = useState<string>("");
|
|
58
|
+
const [agents, setAgents] = useState<AgentStatus[]>([]);
|
|
59
|
+
const [agentStreams, setAgentStreams] = useState<Record<string, string>>({});
|
|
60
|
+
const [tokens, setTokens] = useState({ input: 0, output: 0 });
|
|
61
|
+
const [startedAt] = useState(Date.now());
|
|
62
|
+
const [report, setReport] = useState<FullReport | null>(null);
|
|
63
|
+
const [error, setError] = useState<string | null>(null);
|
|
64
|
+
const [elapsed, setElapsed] = useState(0);
|
|
65
|
+
const abortControllerRef = useRef(new AbortController());
|
|
66
|
+
const progressRef = useRef<ProgressTracker | null>(null);
|
|
67
|
+
|
|
68
|
+
// Batched stream updates: accumulate deltas in a ref and flush periodically
|
|
69
|
+
const pendingStreamDeltas = useRef<Record<string, string>>({});
|
|
70
|
+
const streamFlushTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
71
|
+
|
|
72
|
+
const abort = useCallback(() => {
|
|
73
|
+
abortControllerRef.current.abort();
|
|
74
|
+
}, []);
|
|
75
|
+
|
|
76
|
+
// Orchestration logic
|
|
77
|
+
const startGeneration = useCallback(async () => {
|
|
78
|
+
try {
|
|
79
|
+
const result = await orchestrate({
|
|
80
|
+
projectRoot,
|
|
81
|
+
reportType,
|
|
82
|
+
model,
|
|
83
|
+
modelId,
|
|
84
|
+
config,
|
|
85
|
+
generateTodoList: generateTodoList ?? config.features?.todoList ?? false,
|
|
86
|
+
signal: abortControllerRef.current.signal,
|
|
87
|
+
onProgress: (progress) => {
|
|
88
|
+
progressRef.current = progress;
|
|
89
|
+
progress.on("phaseChange", (p: PipelinePhase) => {
|
|
90
|
+
setPhase(p);
|
|
91
|
+
setPhaseDetail("");
|
|
92
|
+
});
|
|
93
|
+
progress.on("phaseDetailChange", (detail: string) => setPhaseDetail(detail));
|
|
94
|
+
progress.on("agentStatusChange", (status: AgentStatus) => {
|
|
95
|
+
setAgents((prev) => {
|
|
96
|
+
const existing = prev.findIndex(
|
|
97
|
+
(a) => a.agentId === status.agentId
|
|
98
|
+
);
|
|
99
|
+
if (existing >= 0) {
|
|
100
|
+
const updated = [...prev];
|
|
101
|
+
updated[existing] = status;
|
|
102
|
+
return updated;
|
|
103
|
+
}
|
|
104
|
+
return [...prev, status];
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// Clear stream text when an agent completes
|
|
108
|
+
if (
|
|
109
|
+
status.status === "completed" ||
|
|
110
|
+
status.status === "failed" ||
|
|
111
|
+
status.status === "skipped"
|
|
112
|
+
) {
|
|
113
|
+
setAgentStreams((prev) => {
|
|
114
|
+
const next = { ...prev };
|
|
115
|
+
delete next[status.agentId];
|
|
116
|
+
return next;
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// Per-agent stream tracking — receives deltas, batches updates every 200ms
|
|
122
|
+
progress.on("streamText", (delta: string, agentId: string) => {
|
|
123
|
+
if (!agentId) return;
|
|
124
|
+
pendingStreamDeltas.current[agentId] =
|
|
125
|
+
(pendingStreamDeltas.current[agentId] || "") + delta;
|
|
126
|
+
|
|
127
|
+
if (!streamFlushTimer.current) {
|
|
128
|
+
streamFlushTimer.current = setTimeout(() => {
|
|
129
|
+
const deltas = { ...pendingStreamDeltas.current };
|
|
130
|
+
pendingStreamDeltas.current = {};
|
|
131
|
+
streamFlushTimer.current = null;
|
|
132
|
+
|
|
133
|
+
setAgentStreams((prev) => {
|
|
134
|
+
const next = { ...prev };
|
|
135
|
+
for (const [id, d] of Object.entries(deltas)) {
|
|
136
|
+
let updated = (next[id] || "") + d;
|
|
137
|
+
if (updated.length > MAX_STREAM_TEXT) {
|
|
138
|
+
updated = updated.slice(-MAX_STREAM_TEXT);
|
|
139
|
+
}
|
|
140
|
+
next[id] = updated;
|
|
141
|
+
}
|
|
142
|
+
return next;
|
|
143
|
+
});
|
|
144
|
+
}, STREAM_FLUSH_INTERVAL);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
progress.on(
|
|
149
|
+
"tokenUpdate",
|
|
150
|
+
(t: { input: number; output: number }) => {
|
|
151
|
+
setTokens(t);
|
|
152
|
+
}
|
|
153
|
+
);
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
setReport(result.report);
|
|
158
|
+
await saveReport(projectRoot, result.report);
|
|
159
|
+
} catch (err: unknown) {
|
|
160
|
+
if (abortControllerRef.current.signal.aborted) return;
|
|
161
|
+
setError(getErrorMessage(err));
|
|
162
|
+
}
|
|
163
|
+
}, [projectRoot, reportType, model, modelId, config, generateTodoList]);
|
|
164
|
+
|
|
165
|
+
// Start generation on mount, abort on unmount
|
|
166
|
+
useEffect(() => {
|
|
167
|
+
startGeneration();
|
|
168
|
+
return () => {
|
|
169
|
+
abortControllerRef.current.abort();
|
|
170
|
+
progressRef.current?.removeAllListeners();
|
|
171
|
+
if (streamFlushTimer.current) {
|
|
172
|
+
clearTimeout(streamFlushTimer.current);
|
|
173
|
+
streamFlushTimer.current = null;
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
}, []);
|
|
177
|
+
|
|
178
|
+
// Timer for elapsed time
|
|
179
|
+
useEffect(() => {
|
|
180
|
+
if (report || error) return;
|
|
181
|
+
const timer = setInterval(() => {
|
|
182
|
+
setElapsed(Date.now() - startedAt);
|
|
183
|
+
}, 1000);
|
|
184
|
+
return () => clearInterval(timer);
|
|
185
|
+
}, [report, error, startedAt]);
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
phase,
|
|
189
|
+
phaseDetail,
|
|
190
|
+
agents,
|
|
191
|
+
agentStreams,
|
|
192
|
+
error,
|
|
193
|
+
report,
|
|
194
|
+
tokens,
|
|
195
|
+
elapsed,
|
|
196
|
+
startedAt,
|
|
197
|
+
abort,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { useState, useEffect } from "react";
|
|
2
|
+
import { useStdout } from "ink";
|
|
3
|
+
|
|
4
|
+
interface TerminalSize {
|
|
5
|
+
columns: number;
|
|
6
|
+
rows: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const DEFAULT_COLUMNS = 80;
|
|
10
|
+
const DEFAULT_ROWS = 24;
|
|
11
|
+
|
|
12
|
+
export function useTerminalSize(): TerminalSize {
|
|
13
|
+
const { stdout } = useStdout();
|
|
14
|
+
|
|
15
|
+
const [size, setSize] = useState<TerminalSize>({
|
|
16
|
+
columns: stdout.columns || DEFAULT_COLUMNS,
|
|
17
|
+
rows: stdout.rows || DEFAULT_ROWS,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
const onResize = () => {
|
|
22
|
+
setSize({
|
|
23
|
+
columns: stdout.columns || DEFAULT_COLUMNS,
|
|
24
|
+
rows: stdout.rows || DEFAULT_ROWS,
|
|
25
|
+
});
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
stdout.on("resize", onResize);
|
|
29
|
+
return () => {
|
|
30
|
+
stdout.off("resize", onResize);
|
|
31
|
+
};
|
|
32
|
+
}, [stdout]);
|
|
33
|
+
|
|
34
|
+
return size;
|
|
35
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import type { AgentId, FileTreeNode } from "../types/index.js";
|
|
2
|
+
import { flattenFileTree } from "./file-tree.js";
|
|
3
|
+
|
|
4
|
+
type ContextStrategy = (files: FileTreeNode[]) => FileTreeNode[];
|
|
5
|
+
|
|
6
|
+
// Cache for flattenFileTree: avoids re-flattening the same tree reference
|
|
7
|
+
const _flatCache = new WeakMap<FileTreeNode[], FileTreeNode[]>();
|
|
8
|
+
|
|
9
|
+
function getCachedFlatFileTree(tree: FileTreeNode[]): FileTreeNode[] {
|
|
10
|
+
let result = _flatCache.get(tree);
|
|
11
|
+
if (!result) {
|
|
12
|
+
result = flattenFileTree(tree);
|
|
13
|
+
_flatCache.set(tree, result);
|
|
14
|
+
}
|
|
15
|
+
return result;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const CONFIG_PATTERNS = [
|
|
19
|
+
/^package\.json$/,
|
|
20
|
+
/^tsconfig.*\.json$/,
|
|
21
|
+
/^\.eslintrc/,
|
|
22
|
+
/^\.prettierrc/,
|
|
23
|
+
/^next\.config\./,
|
|
24
|
+
/^vite\.config\./,
|
|
25
|
+
/^webpack\.config\./,
|
|
26
|
+
/^nuxt\.config\./,
|
|
27
|
+
/^tailwind\.config\./,
|
|
28
|
+
/^postcss\.config\./,
|
|
29
|
+
/^jest\.config\./,
|
|
30
|
+
/^vitest\.config\./,
|
|
31
|
+
/^babel\.config\./,
|
|
32
|
+
/^docker-compose\./,
|
|
33
|
+
/^Dockerfile$/,
|
|
34
|
+
/^\.env\.example$/,
|
|
35
|
+
/^\.github\//,
|
|
36
|
+
/^\.gitlab-ci/,
|
|
37
|
+
/^Makefile$/,
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
const ENTRY_POINT_PATTERNS = [
|
|
41
|
+
/^src\/index\./,
|
|
42
|
+
/^src\/main\./,
|
|
43
|
+
/^src\/app\./,
|
|
44
|
+
/^src\/App\./,
|
|
45
|
+
/^index\./,
|
|
46
|
+
/^main\./,
|
|
47
|
+
/^app\//,
|
|
48
|
+
/^pages\//,
|
|
49
|
+
/^src\/pages\//,
|
|
50
|
+
/^src\/routes\//,
|
|
51
|
+
/^server\./,
|
|
52
|
+
/^src\/server\./,
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
const SECURITY_PATTERNS = [
|
|
56
|
+
/auth/i,
|
|
57
|
+
/login/i,
|
|
58
|
+
/session/i,
|
|
59
|
+
/password/i,
|
|
60
|
+
/token/i,
|
|
61
|
+
/crypto/i,
|
|
62
|
+
/encrypt/i,
|
|
63
|
+
/middleware/i,
|
|
64
|
+
/cors/i,
|
|
65
|
+
/csrf/i,
|
|
66
|
+
/sanitize/i,
|
|
67
|
+
/validate/i,
|
|
68
|
+
/\.env/,
|
|
69
|
+
/secret/i,
|
|
70
|
+
/permission/i,
|
|
71
|
+
/role/i,
|
|
72
|
+
/guard/i,
|
|
73
|
+
/policy/i,
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
const TEST_PATTERNS = [
|
|
77
|
+
/\.test\./,
|
|
78
|
+
/\.spec\./,
|
|
79
|
+
/\/__tests__\//,
|
|
80
|
+
/\/test\//,
|
|
81
|
+
/\/tests\//,
|
|
82
|
+
/\/spec\//,
|
|
83
|
+
/jest\.config/,
|
|
84
|
+
/vitest\.config/,
|
|
85
|
+
/cypress/,
|
|
86
|
+
/playwright/,
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
const API_PATTERNS = [
|
|
90
|
+
/\/api\//,
|
|
91
|
+
/\/routes\//,
|
|
92
|
+
/\/controllers\//,
|
|
93
|
+
/\/handlers\//,
|
|
94
|
+
/\/endpoints\//,
|
|
95
|
+
/\/resolvers\//,
|
|
96
|
+
/\.controller\./,
|
|
97
|
+
/\.route\./,
|
|
98
|
+
/\.handler\./,
|
|
99
|
+
/swagger/i,
|
|
100
|
+
/openapi/i,
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
const PERFORMANCE_PATTERNS = [
|
|
104
|
+
/cache/i,
|
|
105
|
+
/worker/i,
|
|
106
|
+
/queue/i,
|
|
107
|
+
/pool/i,
|
|
108
|
+
/stream/i,
|
|
109
|
+
/buffer/i,
|
|
110
|
+
/lazy/i,
|
|
111
|
+
/async/i,
|
|
112
|
+
/batch/i,
|
|
113
|
+
/index/i,
|
|
114
|
+
/database/i,
|
|
115
|
+
/query/i,
|
|
116
|
+
/migration/i,
|
|
117
|
+
/orm/i,
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
function matchesAny(filePath: string, patterns: RegExp[]): boolean {
|
|
121
|
+
return patterns.some((p) => p.test(filePath));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const STRATEGIES: Record<AgentId, ContextStrategy> = {
|
|
125
|
+
"architecture-analyst": (files) => {
|
|
126
|
+
return files.filter(
|
|
127
|
+
(f) =>
|
|
128
|
+
matchesAny(f.path, CONFIG_PATTERNS) ||
|
|
129
|
+
matchesAny(f.path, ENTRY_POINT_PATTERNS) ||
|
|
130
|
+
f.path.includes("src/") ||
|
|
131
|
+
f.path.includes("lib/") ||
|
|
132
|
+
f.path.includes("app/")
|
|
133
|
+
);
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
"security-auditor": (files) => {
|
|
137
|
+
return files.filter(
|
|
138
|
+
(f) =>
|
|
139
|
+
matchesAny(f.path, SECURITY_PATTERNS) ||
|
|
140
|
+
matchesAny(f.path, CONFIG_PATTERNS) ||
|
|
141
|
+
matchesAny(f.path, API_PATTERNS) ||
|
|
142
|
+
f.path.includes("middleware")
|
|
143
|
+
);
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
"code-quality-reviewer": (files) => {
|
|
147
|
+
// All source code, excluding tests and config
|
|
148
|
+
return files.filter(
|
|
149
|
+
(f) =>
|
|
150
|
+
f.language &&
|
|
151
|
+
!matchesAny(f.path, TEST_PATTERNS) &&
|
|
152
|
+
!f.path.includes("node_modules")
|
|
153
|
+
);
|
|
154
|
+
},
|
|
155
|
+
|
|
156
|
+
"dependency-analyzer": (files) => {
|
|
157
|
+
return files.filter(
|
|
158
|
+
(f) =>
|
|
159
|
+
f.name === "package.json" ||
|
|
160
|
+
f.name === "package-lock.json" ||
|
|
161
|
+
f.name === "yarn.lock" ||
|
|
162
|
+
f.name === "bun.lockb" ||
|
|
163
|
+
f.name === "pnpm-lock.yaml" ||
|
|
164
|
+
f.name === "requirements.txt" ||
|
|
165
|
+
f.name === "Pipfile" ||
|
|
166
|
+
f.name === "Gemfile" ||
|
|
167
|
+
f.name === "go.mod" ||
|
|
168
|
+
f.name === "Cargo.toml" ||
|
|
169
|
+
f.name === "pom.xml" ||
|
|
170
|
+
f.name === "build.gradle" ||
|
|
171
|
+
matchesAny(f.path, CONFIG_PATTERNS)
|
|
172
|
+
);
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
"performance-analyzer": (files) => {
|
|
176
|
+
return files.filter(
|
|
177
|
+
(f) =>
|
|
178
|
+
matchesAny(f.path, PERFORMANCE_PATTERNS) ||
|
|
179
|
+
matchesAny(f.path, API_PATTERNS) ||
|
|
180
|
+
matchesAny(f.path, ENTRY_POINT_PATTERNS) ||
|
|
181
|
+
(f.language &&
|
|
182
|
+
!matchesAny(f.path, TEST_PATTERNS) &&
|
|
183
|
+
(f.path.includes("src/") || f.path.includes("lib/")))
|
|
184
|
+
);
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
"test-coverage-analyst": (files) => {
|
|
188
|
+
return files.filter(
|
|
189
|
+
(f) =>
|
|
190
|
+
matchesAny(f.path, TEST_PATTERNS) ||
|
|
191
|
+
matchesAny(f.path, CONFIG_PATTERNS) ||
|
|
192
|
+
f.path.includes(".github/")
|
|
193
|
+
);
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
"api-documentation": (files) => {
|
|
197
|
+
return files.filter(
|
|
198
|
+
(f) =>
|
|
199
|
+
matchesAny(f.path, API_PATTERNS) ||
|
|
200
|
+
matchesAny(f.path, CONFIG_PATTERNS) ||
|
|
201
|
+
f.name.toLowerCase().includes("schema") ||
|
|
202
|
+
f.name.toLowerCase().includes("type") ||
|
|
203
|
+
f.path.includes("middleware")
|
|
204
|
+
);
|
|
205
|
+
},
|
|
206
|
+
|
|
207
|
+
"onboarding-guide": (files) => {
|
|
208
|
+
return files.filter(
|
|
209
|
+
(f) =>
|
|
210
|
+
matchesAny(f.path, CONFIG_PATTERNS) ||
|
|
211
|
+
matchesAny(f.path, ENTRY_POINT_PATTERNS) ||
|
|
212
|
+
f.name.toLowerCase() === "readme.md" ||
|
|
213
|
+
f.name.toLowerCase() === "contributing.md" ||
|
|
214
|
+
f.name.toLowerCase() === "changelog.md" ||
|
|
215
|
+
f.path.includes("docs/") ||
|
|
216
|
+
f.path.includes(".github/")
|
|
217
|
+
);
|
|
218
|
+
},
|
|
219
|
+
|
|
220
|
+
"todo-generator": () => [],
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
export function selectContextForAgent(
|
|
224
|
+
agentId: AgentId,
|
|
225
|
+
fileTree: FileTreeNode[]
|
|
226
|
+
): FileTreeNode[] {
|
|
227
|
+
const allFiles = getCachedFlatFileTree(fileTree);
|
|
228
|
+
const strategy = STRATEGIES[agentId];
|
|
229
|
+
|
|
230
|
+
if (!strategy) return allFiles;
|
|
231
|
+
|
|
232
|
+
const selected = strategy(allFiles);
|
|
233
|
+
|
|
234
|
+
// If too few files selected, fall back to all source files
|
|
235
|
+
if (selected.length < 3) {
|
|
236
|
+
return allFiles.filter((f) => f.language);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return selected;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function getRelevantFilePaths(
|
|
243
|
+
agentId: AgentId,
|
|
244
|
+
fileTree: FileTreeNode[]
|
|
245
|
+
): string[] {
|
|
246
|
+
return selectContextForAgent(agentId, fileTree).map((f) => f.path);
|
|
247
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import type { FileTreeNode } from "../types/index.js";
|
|
4
|
+
import { debugLog } from "../utils/debug.js";
|
|
5
|
+
import { loadGitignore } from "../tools/shared-ignore.js";
|
|
6
|
+
|
|
7
|
+
const LANGUAGE_MAP: Record<string, string> = {
|
|
8
|
+
".ts": "typescript",
|
|
9
|
+
".tsx": "typescript",
|
|
10
|
+
".js": "javascript",
|
|
11
|
+
".jsx": "javascript",
|
|
12
|
+
".mjs": "javascript",
|
|
13
|
+
".cjs": "javascript",
|
|
14
|
+
".py": "python",
|
|
15
|
+
".rb": "ruby",
|
|
16
|
+
".go": "go",
|
|
17
|
+
".rs": "rust",
|
|
18
|
+
".java": "java",
|
|
19
|
+
".kt": "kotlin",
|
|
20
|
+
".swift": "swift",
|
|
21
|
+
".cs": "csharp",
|
|
22
|
+
".cpp": "cpp",
|
|
23
|
+
".c": "c",
|
|
24
|
+
".h": "c",
|
|
25
|
+
".php": "php",
|
|
26
|
+
".vue": "vue",
|
|
27
|
+
".svelte": "svelte",
|
|
28
|
+
".html": "html",
|
|
29
|
+
".css": "css",
|
|
30
|
+
".scss": "scss",
|
|
31
|
+
".less": "less",
|
|
32
|
+
".json": "json",
|
|
33
|
+
".yaml": "yaml",
|
|
34
|
+
".yml": "yaml",
|
|
35
|
+
".toml": "toml",
|
|
36
|
+
".xml": "xml",
|
|
37
|
+
".md": "markdown",
|
|
38
|
+
".sql": "sql",
|
|
39
|
+
".sh": "shell",
|
|
40
|
+
".bash": "shell",
|
|
41
|
+
".dockerfile": "docker",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const ALWAYS_IGNORE = new Set([
|
|
45
|
+
"node_modules",
|
|
46
|
+
".git",
|
|
47
|
+
"dist",
|
|
48
|
+
"build",
|
|
49
|
+
".next",
|
|
50
|
+
".nuxt",
|
|
51
|
+
"coverage",
|
|
52
|
+
".cache",
|
|
53
|
+
"__pycache__",
|
|
54
|
+
".turbo",
|
|
55
|
+
".vercel",
|
|
56
|
+
".output",
|
|
57
|
+
".svelte-kit",
|
|
58
|
+
".parcel-cache",
|
|
59
|
+
"vendor",
|
|
60
|
+
"target",
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
const BINARY_EXTENSIONS = new Set([
|
|
64
|
+
".png", ".jpg", ".jpeg", ".gif", ".ico", ".svg", ".webp",
|
|
65
|
+
".woff", ".woff2", ".ttf", ".eot", ".otf",
|
|
66
|
+
".zip", ".tar", ".gz", ".bz2", ".7z", ".rar",
|
|
67
|
+
".exe", ".dll", ".so", ".dylib", ".bin",
|
|
68
|
+
".mp3", ".mp4", ".wav", ".avi", ".mov", ".mkv",
|
|
69
|
+
".pdf", ".doc", ".docx", ".xls", ".xlsx",
|
|
70
|
+
".lock",
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
const LARGE_FILE_THRESHOLD = 50_000; // 50KB
|
|
74
|
+
|
|
75
|
+
export function detectLanguage(filePath: string): string | undefined {
|
|
76
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
77
|
+
if (LANGUAGE_MAP[ext]) return LANGUAGE_MAP[ext];
|
|
78
|
+
|
|
79
|
+
const basename = path.basename(filePath).toLowerCase();
|
|
80
|
+
if (basename === "dockerfile") return "docker";
|
|
81
|
+
if (basename === "makefile") return "make";
|
|
82
|
+
if (basename === "rakefile") return "ruby";
|
|
83
|
+
if (basename === "gemfile") return "ruby";
|
|
84
|
+
if (basename === "cmakelists.txt") return "cmake";
|
|
85
|
+
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface BuildFileTreeOptions {
|
|
90
|
+
maxDepth?: number;
|
|
91
|
+
extraIgnore?: string[];
|
|
92
|
+
maxFileSize?: number;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function buildFileTree(
|
|
96
|
+
projectRoot: string,
|
|
97
|
+
options: BuildFileTreeOptions = {}
|
|
98
|
+
): Promise<FileTreeNode[]> {
|
|
99
|
+
const { maxDepth = 10, extraIgnore = [], maxFileSize = LARGE_FILE_THRESHOLD } = options;
|
|
100
|
+
const ig = await loadGitignore(projectRoot);
|
|
101
|
+
if (extraIgnore.length > 0) {
|
|
102
|
+
ig.add(extraIgnore);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function walk(dir: string, depth: number): Promise<FileTreeNode[]> {
|
|
106
|
+
if (depth >= maxDepth) return [];
|
|
107
|
+
|
|
108
|
+
let entries: fs.Dirent[];
|
|
109
|
+
try {
|
|
110
|
+
entries = await fs.promises.readdir(dir, { withFileTypes: true });
|
|
111
|
+
} catch (e) {
|
|
112
|
+
debugLog("fileTree:readdir", e);
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Separate entries into directories and files after applying skip filters
|
|
117
|
+
const dirEntries: Array<{ entry: typeof entries[0]; fullPath: string; relativePath: string }> = [];
|
|
118
|
+
const fileEntries: Array<{ entry: typeof entries[0]; fullPath: string; relativePath: string }> = [];
|
|
119
|
+
|
|
120
|
+
for (const entry of entries) {
|
|
121
|
+
const fullPath = path.join(dir, entry.name);
|
|
122
|
+
const relativePath = path.relative(projectRoot, fullPath).replace(/\\/g, "/");
|
|
123
|
+
|
|
124
|
+
// Skip always-ignored dirs
|
|
125
|
+
if (entry.isDirectory() && ALWAYS_IGNORE.has(entry.name)) continue;
|
|
126
|
+
|
|
127
|
+
// Skip hidden files (except important dotfiles)
|
|
128
|
+
if (
|
|
129
|
+
entry.name.startsWith(".") &&
|
|
130
|
+
!entry.name.startsWith(".env") &&
|
|
131
|
+
entry.name !== ".eslintrc.js" &&
|
|
132
|
+
entry.name !== ".prettierrc"
|
|
133
|
+
) {
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Check gitignore
|
|
138
|
+
if (ig.ignores(relativePath)) continue;
|
|
139
|
+
|
|
140
|
+
if (entry.isDirectory()) {
|
|
141
|
+
dirEntries.push({ entry, fullPath, relativePath });
|
|
142
|
+
} else {
|
|
143
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
144
|
+
if (BINARY_EXTENSIONS.has(ext)) continue;
|
|
145
|
+
fileEntries.push({ entry, fullPath, relativePath });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Stat all files in parallel
|
|
150
|
+
const fileNodes = await Promise.all(
|
|
151
|
+
fileEntries.map(async ({ entry, fullPath, relativePath }) => {
|
|
152
|
+
let size: number | undefined;
|
|
153
|
+
try {
|
|
154
|
+
const stat = await fs.promises.stat(fullPath);
|
|
155
|
+
size = stat.size;
|
|
156
|
+
} catch (e) {
|
|
157
|
+
debugLog("fileTree:stat", e);
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
name: entry.name,
|
|
161
|
+
path: relativePath,
|
|
162
|
+
type: "file" as const,
|
|
163
|
+
language: detectLanguage(entry.name),
|
|
164
|
+
size,
|
|
165
|
+
isLarge: size !== undefined && size > maxFileSize,
|
|
166
|
+
};
|
|
167
|
+
})
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
// Recurse into directories in parallel
|
|
171
|
+
const dirNodes: FileTreeNode[] = await Promise.all(
|
|
172
|
+
dirEntries.map(async ({ entry, fullPath, relativePath }) => ({
|
|
173
|
+
name: entry.name,
|
|
174
|
+
path: relativePath,
|
|
175
|
+
type: "directory" as const,
|
|
176
|
+
children: await walk(fullPath, depth + 1),
|
|
177
|
+
}))
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
const nodes: FileTreeNode[] = [...dirNodes, ...fileNodes];
|
|
181
|
+
|
|
182
|
+
return nodes.sort((a, b) => {
|
|
183
|
+
if (a.type !== b.type) return a.type === "directory" ? -1 : 1;
|
|
184
|
+
return a.name.localeCompare(b.name);
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return walk(projectRoot, 0);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function flattenFileTree(nodes: FileTreeNode[]): FileTreeNode[] {
|
|
192
|
+
const flat: FileTreeNode[] = [];
|
|
193
|
+
function recurse(list: FileTreeNode[]) {
|
|
194
|
+
for (const node of list) {
|
|
195
|
+
if (node.type === "file") {
|
|
196
|
+
flat.push(node);
|
|
197
|
+
}
|
|
198
|
+
if (node.children) {
|
|
199
|
+
recurse(node.children);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
recurse(nodes);
|
|
204
|
+
return flat;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function fileTreeToString(nodes: FileTreeNode[], prefix = ""): string {
|
|
208
|
+
const lines: string[] = [];
|
|
209
|
+
buildTreeLines(nodes, prefix, lines);
|
|
210
|
+
return lines.join("");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function buildTreeLines(nodes: FileTreeNode[], prefix: string, lines: string[]): void {
|
|
214
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
215
|
+
const node = nodes[i];
|
|
216
|
+
const isLast = i === nodes.length - 1;
|
|
217
|
+
const connector = isLast ? "└── " : "├── ";
|
|
218
|
+
const sizeInfo = node.isLarge ? ` (${((node.size || 0) / 1024).toFixed(1)}KB)` : "";
|
|
219
|
+
|
|
220
|
+
lines.push(`${prefix}${connector}${node.name}${sizeInfo}\n`);
|
|
221
|
+
|
|
222
|
+
if (node.children && node.children.length > 0) {
|
|
223
|
+
const childPrefix = prefix + (isLast ? " " : "│ ");
|
|
224
|
+
buildTreeLines(node.children, childPrefix, lines);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|