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,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
+ }